主动脉分割评价指标全解析:Dice、HD95与边界误差踩坑
去年冬天帮一个做主动脉分割的团队复查评测脚本,模型在他们的内部测试集上 Dice 跑到 0.94,团队上下都挺高兴,结果把预测结果和标注叠在血管三维重建上滚了两圈,发现升主动脉和主动脉弓的管壁外侧被系统性削薄了一圈,弓上几个分支的根部基本被吃掉了。Dice 这个数字对此毫无反应,因为主动脉在整个腹部 CT 体积里占的体素比例不到百分之一,你削掉一圈管壁,损失的交集体积在分母里不过是零头的零头。后来换成 HD95 做补充评估,同一个模型在不同病例上的 HD95 从 1.2mm 一路跳到 21mm,问题立刻暴露:整体重叠率很高,但局部边界在某些病例上错得离谱。
这件事说明一个很朴素的问题:图像分割评价指标不是"哪个数字高就用哪个",而是每一种指标都在回答一个特定的问题,同时在其他问题上彻底失明。这篇内容就把论文里最常出现的那组指标——Dice、IoU、敏感度特异度、HD、HD95、ASSD、边界 F1——从定义到代码完整拆一遍,重点放在主动脉这类细长管状结构上,为什么 HD95 必须报、怎么算才算对、不同库算出来的数为什么对不上。适合正在写分割相关论文、需要自己搭评测脚本、或者被审稿人追问过"你的边界误差怎么量化"的读者。代码给的是可以直接跑通的版本,环境依赖、参数口径、踩坑点都会标清楚。
1. 从"指标全绿"的复现失败说起:主动脉分割为什么对重叠率特别宽容
1.1 管状结构的 Dice 宽容度,可以用一个圆柱模型算清楚
主动脉在腹部增强 CT 里是一条细长、弯曲、截面近似圆形的管道,从升主动脉一路下行到髂动脉分叉,长度方向上能有两三百毫米,但横截面直径在正常段只有 20 到 30 毫米,病变段(比如动脉瘤)可能扩张到 50 毫米以上。这种"长而细"的几何形态,决定了重叠类指标天然对它很宽容。
用最简单的圆柱近似算一下。假设真实血管半径 r,预测结果在整段长度上均匀内缩了 Δ,那么交集体积正比于 (r-Δ)²,并集体积正比于 r² + (r-Δ)²,Dice 就是 2(r-Δ)² / [r² + (r-Δ)²]。取 r=15mm、Δ=1mm,算出来 Dice 约等于 0.931;Δ=2mm 时是 0.858;Δ=3mm 时降到 0.787。也就是说,管径被均匀削掉整整 2 毫米,Dice 还稳在 0.85 以上,肉眼看三维重建已经能明显看出"瘦了一圈",但数字上并不刺眼。
再看长度方向。假设一条总长 300mm 的血管,模型在末端少预测了 10mm,只丢了一小段的交集体积,Dice 大约 0.967。可临床上看,末端少 10mm 可能意味着髂动脉分叉处的一个分支完全没分出来,后续做中心线提取、直径测量、支架锚定区规划全都会出问题。而在边界类指标里,这种误差是藏不住的——端面那个位置,预测表面到真实表面的距离直接就是 10mm 量级。
一个经验判断:如果一篇主动脉分割的论文只报 Dice 和 IoU,而且数字漂亮到 0.95 以上,你几乎可以确定它没有量化边界误差。这不是说数字造假,而是指标选得不够。
1.2 三类指标的职责边界:重叠、边界、体积,各有各的盲区
把常用指标按"它到底在衡量什么"归类,思路会清楚很多。重叠类(Dice、IoU、敏感度、特异度、精确率)衡量的是体素级的分类一致性,本质上是在统计"猜对了多少个点",对形状、对边界位置的敏感度很低,尤其是当目标在图像里占比很小的时候。边界类(HD、HD95、ASSD、边界 F1、表面 Dice)衡量的是两个分割表面的几何贴合程度,直接对"边界偏了多少毫米"给出回答,代价是对孤立噪点极其敏感,且计算量比重叠类高一个量级。体积类(体积相似度 VS、相对体积误差 RAVD)只看总体积,用来兜底检查是否存在系统性膨胀或收缩。
这三类指标在主动脉场景下的表现差异非常大。举个实测中的典型例子:某个病例模型把主动脉瘤内的附壁血栓误判成了管腔,管腔被"撑大",体积误差可能到 30%,RAVD 立刻报警;但 Dice 可能只从 0.93 掉到 0.88,因为血栓区域的体素量相对整个主动脉体积还是小头。反过来,模型在弓上分支根部多长出一小块,Dice 几乎不动,HD95 却会因为那一小块离真实表面有距离而明显上升。
所以论文里的指标组合,本质上是在用不同视角交叉验证同一件事。主动脉这类结构,我的建议是至少 Dice + HD95 两个必须报,再把 ASSD 或边界 F1 选一个作为补充,体积类指标按需。只报一个 Dice,等于只从一个角度看问题。
2. 重叠类指标的口径陷阱:Dice、IoU 和敏感度特异度的失真条件
2.1 Dice 与 IoU 的换算关系,以及为什么两个都要报
Dice 和 IoU(Jaccard)在数学上是可以互相换算的,Dice = 2·IoU / (1 + IoU),反解 IoU = Dice / (2 - Dice)。这个换算意味着两者提供的信息并不独立,但它们的"严厉程度"不同:同一个预测,IoU 的数值永远低于 Dice。Dice 0.8 对应 IoU 0.667,Dice 0.9 对应 IoU 0.818,Dice 0.95 对应 IoU 0.905。
| Dice | 对应 IoU | 直观含义 |
|---|---|---|
| 0.70 | 0.538 | 形状大致对,边界明显偏 |
| 0.80 | 0.667 | 常规可用水平 |
| 0.90 | 0.818 | 边界较贴合 |
| 0.95 | 0.905 | 接近标注者间一致性 |
| 0.98 | 0.961 | 基本重合,需警惕标注泄漏 |
之所以建议两个都报,是因为审稿人群体里两派都有,有人习惯看 Dice,有人习惯看 IoU,你只给一个,对方可能还得自己换算。更实际的理由是,IoU 的数值分布更"压缩"在高分段,差异不容易被看出,Dice 的区分度更好一些。真正需要小心的是跨论文比较时口径不一致——有的论文写的是"mean Dice over cases",有的是"global Dice"(把所有病例的体素拼起来算一个总 Dice),后者通常会比前者高,因为大目标的权重被放大了,小目标病例被稀释掉了。
2.2 敏感度与特异度在极度类不平衡下会给出漂亮但无意义的数字
主动脉分割的体素级混淆矩阵里,背景体素通常是前景的上百倍甚至上千倍。在这种比例下,特异度(TN / (TN+FP))几乎恒定在 0.999 附近,哪怕模型在背景上误判了几万个点。我见过有论文把特异度 99.98% 写进表格当作亮点,实际上这个数字对判断模型好坏毫无帮助。
敏感度(召回率,TP / (TP+FN))在主动脉场景下更有意义一些,它直接对应"真实血管有多少比例被漏掉",欠分割时这个数字会掉。精确率(TP / (TP+FP))对应"预测出来的有多少是真血管",过分割、把周围脂肪或静脉误纳入时会掉。这两个指标一起看,能区分错误的类型:敏感度低是漏,精确率低是误纳。
但即便这两个指标,也存在口径问题。体素级的敏感度会被大截面段主导,弓上分支那种几十个体素的小结构,全漏掉了对整体敏感度的影响不到千分之一。所以如果研究关注分支保留,就得单独计算分支区域的指标,或者干脆按分支出子指标。这是很多论文里缺失的一环。
2.3 逐切片平均和三维整体计算,同一批数据能差出好几个点
这是最容易被忽略、也最容易在复现时出事的一个口径问题。假设测试集是 3D 体积,有两种算法:一是把每个 2D 切片单独算 Dice 再求平均,二是把整个 3D 体积当一个整体算一个 Dice。这两者在数学上不等价,而且差异可以很大。
差异的来源主要是空切片。腹部 CT 从下往上扫,最下面几层可能根本没有主动脉,最上面几层也可能只切到一点点。如果这些空切片被算进"逐切片平均",而且按"预测和真实都为空则 Dice 记为 1(或 0,看实现)"处理,结果会被严重扭曲。常见的三种实现:
- 空切片记为 1:会人为抬高整体分数,尤其当空切片占比大时;
- 空切片记为 0:会人为压低分数;
- 空切片从平均中剔除,只在含前景的切片上统计。
第三种最合理,但必须明确写进论文的方法部分。而且即便都采用第三种,2D 逐切片平均和 3D 整体的结果依然不同:2D 平均给每个含前景的切片相同权重,而 3D 整体按体素数加权,大截面切片(比如动脉瘤段)的话语权更重。主动脉不同节段直径差两三倍很正常,这两种口径的差异不容小觑。
我在实际项目中遇到过一批数据,2D 逐片平均 Dice 是 0.921,3D 整体 Dice 是 0.897,差了 2.4 个点,原因就是动脉瘤扩张段的体素量大但边界模糊、分割质量差,3D 整体口径下它的权重被放大了。后来在论文里两个都报了,并说明了主指标是 3D 整体,审稿人反而觉得严谨。
3. HD95 到底在量什么:从有向表面距离到第 95 百分位
3.1 豪斯多夫距离的最大值定义,天然对单点噪声过敏
先看定义。给定预测分割的表面点集 A 和真实标注的表面点集 B,有向距离 h(A,B) = max over a∈A of min over b∈B of ||a-b||,也就是"A 里每个点到 B 的最近距离"里的最大值。双向的豪斯多夫距离取两个方向的最大值,HD = max(h(A,B), h(B,A))。
这个定义的痛点在"max"。只要有一个点错得离谱,整个 HD 就被这一个点决定了。主动脉分割里这种孤立点非常容易出现:后处理残留的一小块连通域、血管周围钙化斑块被误纳入、标注时漏掉的一小段分支尖端。只要有一个几十毫米外的孤立误检点,HD 立刻变成几十毫米,而这个数字完全不能代表整体分割质量,它只反映了"最糟的那一个点"。
这就是 HD95 存在的理由:不取最大值,取距离分布的 95 百分位,把最极端的 5% 掐掉。这样单个离群点的影响被压下去,指标能反映"绝大部分边界偏了多少"。这个取舍有代价——如果模型在某一个区域系统性错得很厉害(比如整段分支全丢),那部分误差占表面点的比例只要超过 5%,HD95 依然会捕捉到;但如果只错了一小块(面积小于表面的 5%),HD95 会把它当噪声忽略。这个特性必须清楚,否则会误读指标。
3.2 第 95 百分位的三种实现方式,结果可能差出一个量级
这是我在复现别人代码时踩得最狠的一个坑。HD95 的计算在细节上有好几种流派,写法不同,数值能差很多。
第一种,把两个方向的距离数组拼起来,对拼接后的数组取 95 分位。第二种,两个方向各自取 95 分位,然后取两者中的最大值——这是 medpy 和 surface-distance 这类主流库的做法,也是绝大多数论文默认的口径。第三种,两个方向各自取 95 分位再取平均。
对于近似对称的分割误差,这三种结果差不多;但主动脉这种细长结构,误差经常是不对称的。欠分割时,真实表面上的某些点到预测表面的距离会很大(因为那里根本没预测出来),而预测表面上的点到真实表面的距离都很小,两个方向的分布形状完全不同。这时候取 max 和取 mean 的差别就很直观了。
还有一个更隐蔽的点:percentile 的插值方式。numpy 的np.percentile默认用线性插值,当距离数组的元素个数不是 20 的倍数时,第 95 百分位会落在两个实际距离值之间,给出一个"不存在的"数值。有些实现则是先排序再按索引int(0.95 * n)直接取值,取的是实际存在的距离。前者更平滑,后者更保守。跨库比较时,这种差异在小数组上可能造成十几甚至几十个百分点的偏差。
写论文时至少要说清楚一句话:HD95 采用双向距离各自取 95 百分位后取最大值。这一句话能让复现的人少走很多弯路。
3.3 体素间距被忽略,是 HD95 数值不可比的头号原因
如果只记一个坑,那一定是这个:HD95 的单位。距离计算必须使用物理坐标(毫米),也就是要把体素索引差乘以对应的体素间距。腹部、胸部 CT 的面内分辨率通常 0.6 到 0.8 毫米,层厚 0.8 到 1.5 毫米,各向异性明显。如果你忘了传 spacing,算出来的 HD95 单位是"体素",直接拿去和别人的毫米制数字比,结论完全是错的。
举个例子。层厚 1.0mm、面内 0.7mm,那么 z 方向 3 个体素对应 3mm,x 方向 4 个体素对应 2.8mm,实际距离是 sqrt(3² + 2.8²) ≈ 4.10mm。如果误用各向同性 0.7mm 计算,得到 sqrt(3²+4²)×0.7 = 3.5mm,误差接近 15%。当层厚和面内差异更大时(比如层厚 5mm 的厚层数据),误差会成倍放大。
另一个相关问题是重采样。有些流程为了统一尺寸,会把所有体积重采样到 1mm 各向同性,然后再算指标。这样做的好处是跨数据集可比,坏处是重采样本身会改变表面形态——用最近邻插值重采样二值掩膜,会在层间产生阶梯状伪影,表面点数增加,HD95 可能反而变小甚至变好看。所以我的做法是:主表用原始 spacing 计算,把重采样后的结果放补充材料,并在方法里写清楚用的是哪一种。
还有一个更细的点:SimpleITK 的GetSpacing()返回的是 (x, y, z) 顺序,而 numpy 数组的轴顺序是 (z, y, x)。这两者不转置直接用,就是把 x 方向的间距乘到了 z 轴上,算出来的距离完全是乱的。这个错误极其常见,而且不会报错,只会给出一个"看起来还挺正常"的数字。下面代码里我会显式做一次反转。
4. 把评估脚本写扎实:表面提取、距离查询与批量跑分
4.1 四个常用库的选型对比,别一上来就装错
| 方案 | 优势 | 短板 | 适用场景 |
|---|---|---|---|
| SimpleITK | 原生处理 spacing、方向矩阵,管道式 API,稳定 | 只给 HD 的最大值和平均值,没有 HD95 | 读取 DICOM/NIfTI、坐标一致性校验 |
| medpy | 提供 hd95、asd、assd 等现成函数 | 部分版本对 spacing 支持不完整,表面提取依赖形态学 | 快速出数、和小规模结果对拍 |
| surface-distance | 支持表面 Dice、加权表面距离等新指标 | 依赖多、大体积内存占用高 | 需要报表面 Dice 的论文 |
| 自己写 numpy + scipy | 完全可控,口径透明,方便调试 | 需要自己处理空洞、单层、空掩膜 | 长期维护的评测流水线 |
我个人的组合是:读数据用 SimpleITK 保证 spacing 和方向不出错,指标计算自己写 numpy 版本,中间用 medpy 对拍一到两个病例,确认口径一致后再批量跑。这样既保证了可控性,又有一个独立实现的交叉验证。
4.2 表面提取的两条路线,选哪条会影响半个体素
表面点的定义本身就有两种主流做法。第一种是形态学差分:用 6 邻域(或 26 邻域)结构元素对掩膜做一次腐蚀,原掩膜减去腐蚀结果就是表面层,取这一层所有体素的中心坐标作为表面点。这是 medpy、surface-distance 的做法,实现简单、速度快。
第二种是等值面法:把掩膜看作一个 0/1 的标量场,用 marching cubes 提取 0.5 等值面,得到的表面点落在体素之间的插值位置上。这种方法给出的表面更接近几何意义上的"真实边界",精度能到亚体素级,尤其在层厚较大的数据上,能避免形态学法在 z 方向上的阶梯偏差。
两者的差别有多大?在一个球体上实测,形态学法和 marching cubes 法算出的 HD95 差异通常在 0.3 到 0.8 毫米之间,取决于体素间距和球的曲率。这在绝对值上不大,但当你的模型 HD95 已经在 1mm 附近时,这零点几毫米就很关键了。论文里不需要纠结用哪种,但必须说清楚用的是哪种,并且所有对比方法统一。
另外,形态学法还有一个隐含假设:表面点在体素中心。而实际边界在体素边缘,所以形态学法算出的距离整体偏大约半个体素对角线。当体素间距是 1mm 时这个偏差约 0.5mm,不可忽略。这也是不同库之间对不上的主要原因之一。
4.3 完整实现:Dice、HD、HD95、ASSD、边界 F1 一套带走
下面这套代码我用了两年多,覆盖空掩膜、单层、spacing 转置这些边界情况,直接抄。
import numpy as np import SimpleITK as sitk from scipy import ndimage from scipy.spatial import cKDTree def load_mask(path, is_label=True): """读 NIfTI,返回 (numpy 数组[z,y,x], spacing[x,y,z], spacing 反转后的 [z,y,x])""" img = sitk.ReadImage(path) arr = sitk.GetArrayFromImage(img) # 轴顺序 (z, y, x) if is_label: arr = (arr > 0).astype(np.uint8) spacing_xyz = np.array(img.GetSpacing(), dtype=np.float64) spacing_zyx = spacing_xyz[::-1] # 关键:转成 (z,y,x) 与数组对齐 return arr, spacing_xyz, spacing_zyx def surface_points(mask, spacing_zyx, connectivity=1): """形态学差分法提取表面点,返回 (N,3) 的物理坐标(毫米)""" mask = np.asarray(mask).astype(bool) if not mask.any(): return np.empty((0, 3), dtype=np.float64) st = ndimage.generate_binary_structure(mask.ndim, connectivity) eroded = ndimage.binary_erosion(mask, structure=st, border_value=0) surf = mask & (~eroded) idx = np.argwhere(surf).astype(np.float64) # 体素索引,未乘 spacing return idx * np.asarray(spacing_zyx, dtype=np.float64) def _directed(a_pts, b_pts): if len(a_pts) == 0 or len(b_pts) == 0: return None tree = cKDTree(b_pts) d, _ = tree.query(a_pts, k=1, workers=-1) return d def dice(pred, gt): pred = np.asarray(pred).astype(bool) gt = np.asarray(gt).astype(bool) s = pred.sum() + gt.sum() if s == 0: return float('nan') # 双方都空,无定义 return 2.0 * np.logical_and(pred, gt).sum() / s def iou(pred, gt): pred = np.asarray(pred).astype(bool) gt = np.asarray(gt).astype(bool) u = np.logical_or(pred, gt).sum() if u == 0: return float('nan') return np.logical_and(pred, gt).sum() / u def hd95(pred, gt, spacing_zyx): sp = surface_points(pred, spacing_zyx) sg = surface_points(gt, spacing_zyx) d_pg = _directed(sp, sg) d_gp = _directed(sg, sp) if d_pg is None: return float('nan') return float(max(np.percentile(d_pg, 95), np.percentile(d_gp, 95))) def hd_max(pred, gt, spacing_zyx): sp = surface_points(pred, spacing_zyx) sg = surface_points(gt, spacing_zyx) d_pg = _directed(sp, sg) d_gp = _directed(sg, sp) if d_pg is None: return float('nan') return float(max(d_pg.max(), d_gp.max())) def assd(pred, gt, spacing_zyx): sp = surface_points(pred, spacing_zyx) sg = surface_points(gt, spacing_zyx) d_pg = _directed(sp, sg) d_gp = _directed(sg, sp) if d_pg is None: return float('nan') return float((d_pg.mean() + d_gp.mean()) / 2.0) def boundary_f1(pred, gt, spacing_zyx, tau=1.0): """tau 为容差(毫米),通常取 1~2mm""" sp = surface_points(pred, spacing_zyx) sg = surface_points(gt, spacing_zyx) d_pg = _directed(sp, sg) d_gp = _directed(sg, sp) if d_pg is None: return float('nan') precision = (d_pg <= tau).sum() / len(sp) recall = (d_gp <= tau).sum() / len(sg) if precision + recall == 0: return 0.0 return float(2 * precision * recall / (precision + recall))用法上注意两点。第一,pred和gt必须已经对齐到同一坐标系,方向矩阵一致。如果一边是从原始 DICOM 直接重建的、另一边是重采样过的,尺寸对了也不代表空间对应,一定要用 SimpleITK 的Resample显式统一。第二,tau的选择会影响边界 F1,1mm 是偏严格的,主动脉这种大血管可以用 1mm,小分支建议加报 2mm 容差的结果。
4.4 用合成数据自检:两个用例能验证掉大部分口径错误
写完脚本别急着跑真实数据,先做两个合成用例自检。第一个是球体平移:构造一个半径 15mm 的球,把预测整体平移 1 个体素,如果你的脚本算出的 HD95 约等于一个体素在对应方向上的物理长度(1mm 间距时约 1mm),说明距离计算至少单位没搞错;如果算出来是 1 或者更小,说明 spacing 被漏掉了,单位还是体素。
第二个是细管偏移,更贴近主动脉。构造半径 12mm、长度 200mm 的圆柱,让预测整体沿径向偏移 2mm。此时 Dice 应该在 0.83 到 0.87 之间,HD95 应该在 2mm 上下。如果 HD95 算出来是几十毫米,多半是表面提取出了问题(比如把内部空洞也当成了表面),或者 KDTree 查询的方向搞反了。
def make_cylinder(shape, spacing_zyx, center_xy, radius, length_z): z, y, x = np.ogrid[:shape[0], :shape[1], :shape[2]] zz = z * spacing_zyx[0] yy = y * spacing_zyx[1] xx = x * spacing_zyx[2] z0 = (shape[0] * spacing_zyx[0] - length_z) / 2.0 inner_z = (zz >= z0) & (zz <= z0 + length_z) radial = (yy - center_xy[0])**2 + (xx - center_xy[1])**2 return (inner_z & (radial <= radius**2)).astype(np.uint8) # 自检 sp = np.array([1.0, 0.7, 0.7]) gt = make_cylinder((160, 128, 128), sp, (64, 64), 12.0, 150.0) pred = make_cylinder((160, 128, 128), sp, (64 + 2.0/0.7, 64), 12.0, 150.0) print(dice(gt, pred), hd95(gt, pred, sp)) # 预期 Dice≈0.85, HD95≈2mm这组自检跑通,说明从读取、spacing 对齐、表面提取到距离查询整条链路是对的。真实数据上再出问题,基本就只可能是数据本身的问题(空掩膜、方向矩阵不一致、标签编号混淆)。
5. 主动脉数据集上的实测踩坑清单
5.1 空掩膜、单层掩膜与标签不连续:最先崩溃的地方
批量跑分最常崩在这三种情况。第一种是空掩膜——某个病例的预测里完全没有该类结构(比如某段分支完全没预测出来),这时surface_points返回空数组,cKDTree直接报错。上面的代码里我用了返回 NaN 的处理,这在统计时会污染均值,所以批量跑完后必须单独统计 NaN 的个数并单独报告,不能简单np.nanmean带过。临床意义上,空预测是一个严重的错误,用 NaN 静默掉是危险的。
第二种是单层掩膜。某个小分支在某几个病例上只有一层切片被预测出来,形态学腐蚀后什么都没剩,表面点集变成空。这种情况需要用 0 邻域(即不做腐蚀)退化处理,或者直接把单层掩膜的上下两个面都作为表面点。
第三种是标签编号。主动脉标注里常见"管腔 + 附壁血栓 + 钙化"多标签,或者"主动脉 + 分支"多标签。有些数据集的标签是 1、2、3,有些是 1、1、2,稍不注意把所有非零值>0一合并,就把血栓和管腔混在一起评估了,Dice 会虚高得离谱。一定要先看一眼标签值的唯一集合再动手。
5.2 spacing 在前处理环节被悄悄丢掉,是数值对不上的头号嫌疑
有一次和另一组对同一批测试集的 HD95,他们报的平均 3.1mm,我自己算是 8.4mm,差了将近三倍。查了半天,最后定位到他们的预处理脚本:为了统一输入尺寸,把所有体积 resize 到固定大小(比如 512×512×128),但保存预测结果时又把预测 resize 回了原始尺寸,而 resize 过程中 spacing 信息没有被恢复,最终被当成 1mm 各向同性来算距离。原始数据层厚是 2.5mm,实际距离比他们算的大了 2.5 倍左右,正好对上三倍的差异。
这个坑的排查链路大概是这样的:先确认读进来的两个体积的GetSpacing()是否一致,再确认保存中间结果时有没有丢失元信息(比如用 numpy.npy存中间结果就会丢,用 NIfTI 存并显式设置 spacing 才安全),最后确认距离计算函数有没有用上 spacing。三步里任何一步出问题,结果都会偏,而且不会报错。
中间结果一律存成带 spacing 的 NIfTI,不要图省事存 npz。哪怕只是临时文件,也保持这个习惯。
5.3 两个库算出来的 HD95 对不上:一条可复现的排查路径
同一条数据,自己写的 numpy 版算出 HD95 = 4.2mm,medpy 算出 5.7mm。这种对不上很常见,按下面顺序查基本能定位:
第一步,比对表面点数。分别打印两边的表面点数量。如果数量差很多,说明表面提取的定义不同(6 邻域 vs 26 邻域,或者一边用了 marching cubes)。6 邻域腐蚀得到的表面包含 6 个面方向,26 邻域包含所有方向,后者点数更多,一般更接近几何表面。
第二步,比对单方向的最大距离。把双向距离拆开,看是哪个方向差异大。主动脉欠分割时,从真实表面指向预测表面的距离分布尾部更重,两个实现如果百分位取法不同,差异会在这里暴露。
第三步,检查百分位实现。medpy 早期的实现用的是np.percentile的线性插值,surface-distance 用的是排序后索引取整。如果你自己也用np.percentile但版本不同,插值行为可能有细微差别。
第四步,检查是否有孤立连通域。如果有,两边对"表面"的定义在孤立点上可能不同,一个把孤立块也算进表面,一个做了连通域筛选。
定位到差异来源之后,不需要强行统一到某个库,重点是在论文里明确写清楚你用的口径,并且在对比实验中所有方法都用同一套脚本,这样至少内部是自洽的。
5.4 细分支和小目标:HD95 的"分母"决定了它能不能报出问题
主动脉弓上的三大分支(头臂干、左颈总、左锁骨下)直径通常 5 到 10 毫米,在整体表面点里占比很小。一个小分支完全丢失,那个分支的表面点大概几百个,而整个主动脉的表面点可能几万个。丢失的分支占表面点总数的比例很容易低于 5%,HD95 会把它当离群值忽略掉,指标看起来依然很好。
这不是 HD95 的缺陷,而是它的设计特性。应对方法是分层评估:把整体、主动脉主体、各分支分别算一套指标。分支级的表面点数少,HD95 会变得敏感,同时也更容易被单个噪点影响,所以分支级建议同时报 HD95 和最大 HD,或者用边界 F1 配一个 1mm 容差。另一个办法是报表面 Dice(NSD),它对小结构的丢失更敏感一些,因为它直接统计"在容差内匹配上的表面点比例"。
实际写论文时,我倾向的结构是:主表报整体 Dice 和整体 HD95,附表按节段(升主动脉、弓部、降主动脉、腹主动脉、分支)分开报。这样审稿人能看到模型在哪一段弱,而不是被一个平均数字糊弄过去。
6. 把指标写进论文:表格结构、统计口径与答辩追问
6.1 一张能过审的指标表该长什么样
指标表最容易犯的错是把所有指标堆在一张表里,行列混乱,读者找不到重点。我习惯做两张表:主表放 Dice、HD95、ASSD 三个核心指标,按方法对比,每格写"均值 ± 标准差";副表放敏感度、精确率、边界 F1、体积误差,作为补充证据。
| 方法 | Dice (%) ↑ | HD95 (mm) ↓ | ASSD (mm) ↓ |
|---|---|---|---|
| 基线方法 A | 89.3 ± 3.1 | 6.82 ± 4.15 | 1.94 ± 0.88 |
| 改进方法 B | 92.7 ± 2.4 | 3.41 ± 2.03 | 1.12 ± 0.51 |
| 改进方法 C | 93.1 ± 2.2 | 2.97 ± 1.66 | 0.96 ± 0.44 |
表格下面必须跟三句话:一是统计口径(3D 整体还是逐病例平均),二是是否做了配对检验(Wilcoxon 符号秩检验最常用,因为样本量通常不大且分布不正态),三是 p 值标在哪里。缺了这三句,审稿人大概率会问。
还有一个细节:HD95 的标准差往往比均值还大。上面表里基线方法 6.82 ± 4.15,说明病例间差异极大,这时候只看均值是有误导性的。更好的做法是补一张逐病例的分布图(箱线图或者散点连线图),让读者看到改进是均匀的还是在少数病例上大幅改善。
6.2 逐病例分布和配对检验,比均值更有说服力
医学图像的数据集通常几十到几百例,均值容易掩盖问题。我一般会额外报三个数:中位数、四分位距、以及"HD95 小于 2mm 的病例占比"。最后这个数字很实用——它直接回答了"有多少比例的病人能拿到临床可接受的边界精度"。
配对检验的选择上,Dice 和 HD95 的分布通常都不满足正态性(HD95 更是明显右偏,有长尾),所以用 Wilcoxon 符号秩检验比 t 检验稳妥。报告时写清楚是双尾、显著性水平 0.05,多重比较时记得做校正,比如 Bonferroni 或者 Holm。这几句话能挡掉不少方法学层面的质疑。
6.3 答辩和审稿里最常被追问的几个点
第一类问题是"为什么用 HD95 不用 HD"。标准回答是 HD 对单点离群值过于敏感,在主动脉这类存在钙化、分支尖端的结构上,单个噪点就能让 HD 从 3mm 跳到 40mm,不具有统计稳定性;HD95 保留了对局部大误差的敏感性,同时抑制了孤立点的影响,是社区通行做法。可以补一句:同时报了最大 HD 作为参考,让读者知道最坏情况。
第二类问题是"HD95 的 95 是怎么定的,有什么依据"。老实说这个数字是经验性的,源于早期挑战赛的惯例,没有严格的理论推导。回答时不必强行找理论,说明它是社区约定、并强调所有对比方法使用一致口径即可。
第三类问题是"你的评估有没有考虑标注者间差异"。这个最狠,也最有价值。如果能拿到同一批数据的两份独立标注,算一下标注者之间的 Dice 和 HD95,就能给出一个"人类水平"的上界。主动脉的标注者间 Dice 通常在 0.90 到 0.95 之间,HD95 在 1.5 到 3mm 之间。如果模型的指标已经落在标注者间差异的范围内,说明继续优化模型的意义不大了,瓶颈在标注本身。这句话写进讨论部分,能显著提升论文的可信度。
第四类问题是"为什么重采样到各向同性之后指标变好了"。这时候要能解释清楚:最近邻重采样会让二值掩膜的边界产生阶梯效应,表面点分布改变,HD95 可能被人为压低。正确做法是主表用原始 spacing,重采样结果只作为鲁棒性验证放在补充材料。
最后分享一个我在实际项目里养成的小习惯:每次跑完评估,不要只看汇总表,随机抽三到五个病例,把预测和真实的三维表面用不同颜色叠出来转一圈看。数字会说谎,眼睛不会。我遇到过不少次指标看起来正常、但三维叠图一看就发现模型在主动脉弓的内弯侧系统性外扩,这种系统性偏差在均值表里完全看不出来,但在叠加视图里一眼就能识别。指标是给审稿人看的,三维叠图是给自己看的,两个都不能省。