主动脉分割评价指标全解析:Dice、HD95与边界误差踩坑

发布时间:2026/10/26 15:25:30
主动脉分割评价指标全解析: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.700.538形状大致对,边界明显偏
0.800.667常规可用水平
0.900.818边界较贴合
0.950.905接近标注者间一致性
0.980.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) ↓
基线方法 A89.3 ± 3.16.82 ± 4.151.94 ± 0.88
改进方法 B92.7 ± 2.43.41 ± 2.031.12 ± 0.51
改进方法 C93.1 ± 2.22.97 ± 1.660.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,重采样结果只作为鲁棒性验证放在补充材料。

最后分享一个我在实际项目里养成的小习惯:每次跑完评估,不要只看汇总表,随机抽三到五个病例,把预测和真实的三维表面用不同颜色叠出来转一圈看。数字会说谎,眼睛不会。我遇到过不少次指标看起来正常、但三维叠图一看就发现模型在主动脉弓的内弯侧系统性外扩,这种系统性偏差在均值表里完全看不出来,但在叠加视图里一眼就能识别。指标是给审稿人看的,三维叠图是给自己看的,两个都不能省。