做主动脉(aorta)分割的同学大概都有过这种时刻:模型训练完,日志最后一行打出 Dice 0.92,正准备收工,组会上被问了一句"你的模型在主动脉弓那个急转弯的地方,边界到底偏了几个毫米"。那一瞬间你会发现,图像分割评价指标这套东西,Dice、IoU 只是入场券,真正决定一篇分割工作能不能站得住脚的,是 HD95 这类直接刻画边界几何误差的距离型指标。我最早也是只盯着 Dice 看,后来在主动脉这种细长、带分支、走行弯曲的管状结构上反复踩坑,才慢慢把指标这套体系摸清楚。
这篇内容是我把过去几年在血管类分割任务里反复用过的评估指标重新梳理了一遍,从数学定义、公式推导、实现细节,到实际跑出来的数值怎么解读、和别人论文对不上时怎么排查,全部摊开写,代码是完整可运行的。适合三类人:刚入门做分割实验、正准备写第一篇论文的同学;已经在跑临床评估、被 HD95 数值反复折磨的老手;以及做工程落地、需要给下游测量环节一个可信误差范围的开发者。看完至少能做到两件事:自己从零实现一套指标,不依赖任何第三方封装;拿到一份别人论文里的 HD95 数字,能判断它和你的数字是不是在同一套约定下算出来的。
1. 指标体系的全盘设计:为什么只盯 Dice 会出事
1.1 Dice 高但模型很差的三种真实情况
Dice 系数的定义是2|A∩B| / (|A|+|B|),本质是一个体积重叠的比例。它有一个致命弱点:完全丢失空间位置信息。我用主动脉数据举三个具体的例子,都是真实会遇到的情况。
第一种,整体平移。假设预测的主动脉管腔整体往左偏了一个管径的距离,和 GT 完全不重叠,Dice 直接跌到 0。这种时候 Dice 极其敏感,看起来很美好。但反过来,如果只是管壁在径向方向上系统性偏移了半个体素,Dice 可能从 0.93 掉到 0.92,肉眼看几乎没变化,可临床上这半个体素在层内是 0.35 mm,在层厚 2.5 mm 的方向上就是 1.25 mm,已经足以影响直径测量和支架尺寸的选择。
第二种,形状错位。两个环状结构互相错开,交叠面积可以做到 0.85 以上,Dice 看起来很漂亮,但实际上模型学到的是一个"胖了一圈、位置偏了"的形状。这种在主动脉分支根部特别常见:模型倾向于把分支起始段预测得比 GT 更粗,因为那里对比度低、部分容积效应严重。
第三种,小结构被大结构淹没。主动脉主体占几千个体素,某个分支的起始部只有一两百个体素。主体的 Dice 是 0.95,分支的 Dice 是 0.4,加权平均下来还是 0.94。你要是只看这一个数,根本发现不了分支已经被漏掉了一半。
提示:Dice 衡量的是"重叠得够不够多",不是"边界贴得够不够准"。这两件事在管状、分支结构上经常不是一回事。
1.2 重叠类指标与距离类指标的分工
我把常用的分割指标分成两族,理解这个分族关系比记住公式重要得多。
重叠类指标包括 Dice、IoU(Jaccard)、精确率、召回率、特异度、准确率。它们的共同点是:先做逐体素的混淆矩阵统计,再做比值。这类指标对"体积层面的对错"敏感,对"边界在哪"不敏感,计算极快,几乎零成本。
距离类指标包括豪斯多夫距离 HD、95 百分位豪斯多夫距离 HD95、平均表面距离 ASD、中位表面距离 MSD、95 百分位表面距离。它们的共同点是:先把掩膜转成表面点集,再算点集之间的距离分布。这类指标对边界的空间偏移敏感,对内部填充是否正确反而不敏感(一个内部有空洞的预测,表面距离可能依然很小)。
两族的互补关系可以这样理解:重叠类回答"我覆盖了多少",距离类回答"我偏了多远"。一个模型可以覆盖得很好但偏得很远(大结构、宽松边界),也可以覆盖得一般但贴得很准(小结构、严格边界)。
| 指标族 | 代表指标 | 敏感的问题 | 计算成本 | 临床解释性 |
|---|---|---|---|---|
| 重叠类 | Dice、IoU | 漏分割、过分割、整体体积偏差 | 极低,O(N) | 弱,说不清偏在哪 |
| 重叠类 | 精确率、召回率 | 假阳性与假阴性的比例 | 极低 | 中等,能区分漏与多 |
| 距离类 | HD95、ASD | 边界偏移、局部失败、毛刺 | 中等,两次距离变换 | 强,单位是毫米 |
| 距离类 | HD、最大表面距离 | 最坏情况、离群假阳性 | 中等 | 强但过于严苛 |
1.3 论文里最少该报哪几个
我的建议是一个四件套加一个补充:Dice、IoU、HD95、ASD,如果类别极不平衡再加召回率。理由很直接。
Dice 是社区约定俗成的对标基准,别人报你不报,审稿人没法横向比较。IoU 是 Dice 的单调变换,看起来冗余,但很多检测、跟踪方向的工作习惯用 IoU,报一个方便跨领域读者。HD95 是医学分割里衡量边界质量的事实标准,尤其是血管、器官这类需要精确边界的任务。ASD 用来补 HD95 看不到的"整体偏移"信息,两个数字组合起来的解读价值远大于单个。
还有一个容易被忽略的点:报均值的同时要报标准差,最好再给一个箱线图或分位数。主动脉分割这类任务,病例间的方差往往比方法间的差异还大。我见过两组方法均值差 0.01、标准差 0.06 的情况,这种差异基本没有统计意义,但很多人会写成"我们的方法优于基线"。
注意:报告结果时必须写清楚三件事——spacing 有没有用、HD95 的百分位是怎么合并的、评估前有没有做连通域后处理。缺任何一条,数字的可复现性都会打折扣。
2. 重叠类指标:公式背后的取舍与陷阱
2.1 Dice 和 IoU 的换算关系与选用场合
这两个指标不是独立的,存在严格的单调映射:Dice = 2·IoU / (1 + IoU),反解是IoU = Dice / (2 - Dice)。知道这个换算,看别人论文时就不会被数字差异误导。
| Dice | 对应 IoU | 直观含义 |
|---|---|---|
| 0.70 | 0.538 | 大致轮廓对上了,边界很差 |
| 0.80 | 0.667 | 可用,但边界需要人工修正 |
| 0.85 | 0.739 | 中等质量 |
| 0.90 | 0.818 | 较好的自动分割 |
| 0.95 | 0.905 | 接近专家间一致性 |
可以看到 Dice 天然比 IoU 好看,这也是论文里偏爱 Dice 的一个现实原因。但对同一批数据,两者的排序是完全一致的,所以不存在"用 Dice 涨点、用 IoU 掉点"这种矛盾情况。如果真出现了,那一定是代码有问题,比如两个指标的掩膜二值化阈值不一致。
我个人在主动脉任务上的经验是:主体结构 Dice 到 0.90 以上后,再往上提升非常困难,因为标注本身的边界就有 1 到 2 mm 的不确定度;而分支结构(比如头臂干、左锁骨下动脉起始段)Dice 常年卡在 0.75 到 0.85 之间,这里才是真正能做出差异的地方。
2.2 精确率、召回率、特异度在体素级的实际含义
在分割任务里,这四个量是逐体素统计的:
- TP:预测为前景且 GT 为前景
- FP:预测为前景但 GT 为背景,也就是过分割
- FN:预测为背景但 GT 为前景,也就是漏分割
- TN:都判为背景
精确率 = TP/(TP+FP),召回率 = TP/(TP+FN),特异度 = TN/(TN+FP)。
这里有个很实际的观察:在分割任务里特异度几乎永远是 0.99 以上,因为背景体素占了全图的 95% 甚至 99%,这个数基本没有信息量。我见过有论文把特异度 0.998 当成一个亮点写,其实随便一个全零预测都能做到 0.99。所以特异度我一般只在论文里顺带一提,不作为主要指标。
精确率和召回率的取舍才是有意义的。对主动脉分割来说,漏掉一段管腔(召回率低)的后果,通常比多预测一点周围组织(精确率低)更严重,因为下游要做中心线提取和直径测量,管腔断开会导致中心线断裂。所以实际调模型的时候,我会把召回率调到 0.93 以上,再去看精确率能不能守住 0.90。
2.3 eps、空掩膜与 0/0 这些数值陷阱
写指标函数时最容易出错的地方就是分母为零。标准做法是在分子分母同时加一个极小的 eps,但 eps 加在哪里、空掩膜怎么处理,不同实现差别很大。
第一种情况,GT 为空、预测也为空。从"完全一致"的角度看应该给 1.0,但很多库会返回 nan 或者 0。我的处理是:如果 GT 本身为空,说明这个病例的标注有问题或者切片选错了,直接把这条样本从评估里剔除,并打印警告。
第二种情况,GT 非空、预测全空。这是真正的模型失败,Dice 应该记 0,HD95 无定义。我的做法是 Dice 记 0,HD95 记 nan,然后单独统计一个"完全漏检率"。这个数字在论文里其实很有说服力,比如"我们的方法在 200 例测试集上没有出现完全漏检,基线方法出现了 7 例"。
第三种情况,预测只有一个孤立体素。Dice 会是一个很小的正数,看起来"有输出",但 HD95 会直接爆到几十毫米。这种情况在低阈值推理时经常出现,一定要结合 HD95 一起看。
def safe_dice(pred, gt, eps=1e-8): pred = pred.astype(bool) gt = gt.astype(bool) if not gt.any(): return None # 由调用方剔除 inter = np.count_nonzero(pred & gt) return (2 * inter + eps) / (pred.sum() + gt.sum() + eps)eps 放在分子分母同时加,好处是空对空时返回 1.0,符合直觉;坏处是极小掩膜上会略微高估。对于几万个前景体素的分割任务,这个偏差可以忽略。
注意:不要把 eps 只加在分母上,那样空对空会返回 0,会让你的统计表里凭空多出一批"完全失败"的样本。
3. 距离类指标:HD、HD95、ASD 的几何直觉
3.1 豪斯多夫距离到底在量什么
豪斯多夫距离的严格定义是:HD(A,B) = max( sup_{a∈A} d(a,B), sup_{b∈B} d(b,A) ),其中d(a,B)是点 a 到集合 B 中最近点的距离。
用大白话讲:对 A 表面上的每一个点,找它到 B 表面的最近距离,取这些距离里的最大值;再反过来对 B 做一遍;最后两个最大值取大的那个。所以 HD 度量的是"最糟糕的那一个点偏了多远"。
这个定义的几何直觉非常强,但工程上极其脆弱。只要预测结果里冒出一个孤立的假阳性体素,这个体素到 GT 表面的距离可能是 30 mm,HD 直接就是 30 mm,哪怕另外 99.9% 的表面贴得完美无缺。标注噪声、模型在某一层的偶发崩溃、后处理残留的小连通域,都会让 HD 变成一个没有统计意义的数字。
我在早期实验里就吃过这个亏:模型整体质量明显比基线好,但 HD 反而更大。查了半天发现是某几个病例在图像边缘冒了一两个体素的假阳性,距离变换后直接贡献了 40 mm 的最大值。这就是为什么纯粹的 HD 在医学分割里几乎没人用。
3.2 HD95 成为默认选项的三条理由
HD95 的做法是把所有表面距离(双向合并后)排序,取第 95 百分位,丢掉最极端的 5%。这个改动看起来很小,但在实践中解决了一堆问题。
理由一,匹配标注者间变异。两个放射科医生独立勾画同一个主动脉,边界差异本来就有 1 到 2 mm,在钙化斑块、与周围组织粘连的层面能到 3 到 5 mm。如果要求模型的 HD 接近 0,那是在要求模型比人类标注者之间的一致性还高,这不现实。HD95 相当于把这部分不可约的噪声排除在外。
理由二,对离群点鲁棒。5% 的容忍度足以吸收掉零星的假阳性斑点和个别层面的崩溃,同时又能捕捉到系统性的边界偏移。这个平衡点选得相当好。
理由三,社区默认,便于对比。绝大多数公开挑战赛和主流论文都用 HD95,你换别的定义反而会增加读者的理解成本。
关于百分位取 95 而不是 90 或 99,我的理解是:90 太松,一个占表面 10% 的局部失败会被掩盖;99 又太接近 max,一只脚已经踩回 HD 的坑里。95 是个折中,也是实践检验下来最稳的选择。
3.3 ASD 与 HD95 的组合解读矩阵
ASD(平均表面距离)是所有表面距离的算术平均,反映的是"整体偏移量"。它和 HD95 放在一起看,能定位出问题的类型。
| ASD | HD95 | 典型含义 | 排查方向 |
|---|---|---|---|
| 小 | 小 | 整体贴合,模型健康 | 无 |
| 小 | 大 | 绝大部分边界很准,某一处局部失败 | 看距离热图找最大误差位置,通常是分支根部或粘连区 |
| 大 | 大 | 整体存在系统性偏移 | 检查重采样、方向、仿射矩阵是否错位 |
| 大 | 小 | 这种组合很罕见,通常是样本少导致的统计假象 | 检查是否有 nan 被当成 0 参与统计 |
第二种组合是我在实际项目里最常遇到的。主动脉弓上方三个分支的起始部,由于部分容积效应和组织对比度低,模型经常在那里多预测或少预测一小块,导致 HD95 冲到 8 到 12 mm,但 ASD 只有 0.8 mm。这种时候改进方向很明确:针对分支根部做局部难例挖掘,而不是全局调学习率。
第三种组合就更严重了,通常是数据预处理阶段的问题。我遇到过一次,Dice 还有 0.85,但 ASD 高达 6 mm,最后查出来是预测结果和 GT 在重采样时用了不同的插值方式,导致整体偏移了一个体素。这类问题不看距离类指标根本发现不了,因为 Dice 对整体平移一个体素的容忍度很高。
3.4 spacing 和连通性:两个最常被忽略的实现参数
这两个参数直接决定你的 HD95 数值,而且论文里经常不写。
先说 spacing。CT 数据的体素不是正方体,层内常见 0.6 到 0.8 mm,层厚常见 1.0 到 2.5 mm。假设某批数据是[0.7, 0.7, 2.5]mm,那么沿层方向错一个体素就是 2.5 mm,层内错一个体素只有 0.7 mm,两者相差 3.5 倍。如果你在计算距离变换时不传 spacing,相当于把所有体素当成等大的立方体,层方向的误差会被严重低估。
举个具体的数:真实情况下沿层方向偏了 3 层,物理距离是 7.5 mm;忽略 spacing 算出来是 3 个单位,如果你在论文里写成 "3 mm",实际误差被低估了 2.5 倍。这个错误在跨数据集对比时会造成灾难性的结论偏差。
再说连通性。表面提取用的结构元素可以是 6 邻域(面相邻)或 26 邻域(含顶点相邻)。26 邻域提取出的表面更"薄",6 邻域在斜面上会厚一些,表面体素数能差出 30% 以上。表面点集不一样,距离分布就不一样,HD95 自然不同。
主流实现(比如 DeepMind 那套 surface-distance)默认用 1 连通,也就是 6 邻域。你要和别人对比,最好跟着用同一个约定,并且在论文里写清楚。
from scipy.ndimage import generate_binary_structure # 3D 下 connectivity=1 对应 6 邻域,connectivity=3 对应 26 邻域 st6 = generate_binary_structure(3, 1) st26 = generate_binary_structure(3, 3)提示:如果数据是各向同性的(比如各方向都是 1 mm),spacing 的影响会小很多,但仍然建议显式传入,养成习惯。
4. 从零实现一套可复现的评估流程
4.1 环境准备与依赖选择
我的原则是核心指标自己写,不依赖第三方封装。原因很实际:第三方库的默认参数(连通性、百分位合并方式、是否用 spacing)经常和你的论文约定不一致,出了问题很难排查。
必装的是 numpy、scipy、nibabel,用来做数组运算、距离变换和读写 NIfTI。
pip install numpy scipy nibabel pandas matplotlib如果要做交叉验证,可以额外装 surface-distance 和 medpy 做对比,但我不建议把它们当主实现。
pip install surface-distance pip install medpymedpy 在某些环境里编译会报错,尤其是新版 Python 和 numpy 的组合。遇到了不用纠结,自己写的那套完全够用。
4.2 表面提取:腐蚀法的工作原理与边界处理
表面提取的思路很简单:把掩膜腐蚀一圈,再用原掩膜减去腐蚀后的掩膜,剩下的就是最外层那一圈体素。
import numpy as np from scipy.ndimage import binary_erosion, generate_binary_structure def extract_surface(mask, connectivity=1): """提取二值掩膜的表面体素。connectivity=1 对应 6 邻域。""" mask = mask.astype(bool) if not mask.any(): return np.zeros_like(mask, dtype=bool) st = generate_binary_structure(mask.ndim, connectivity) eroded = binary_erosion(mask, structure=st, border_value=0) return mask & ~eroded这里的关键参数是border_value=0。它的含义是:把图像边界之外视为背景。这样做的效果是,当掩膜触到图像边界时,边界上的体素会被判定为表面点。这是正确的,因为那个体素确实有一个方向朝向"外部"。
如果你为了加速把图像 crop 到包围盒再算,一定要在 crop 的时候往外 pad 至少一个体素,否则原本不在边界上的体素会被误判成表面,表面点集变大,距离分布被拉低,HD95 会偏小。这个坑我踩过,cov id 期间在家办公那阵子排查了整整一个下午。
4.3 距离变换:一次 EDT 解决单向距离
对每个表面点,我们需要它到另一个表面的最近距离。暴力做法是对每个点做一次搜索,复杂度是 O(N·M),512×512×300 的 CT 根本跑不动。正确做法是用欧氏距离变换(EDT),一次 O(N) 的扫描就能得到全图每个体素到最近零点的距离。
from scipy.ndimage import distance_transform_edt def surface_distances(pred, gt, spacing=None, connectivity=1): """返回双向表面距离数组 (pred->gt, gt->pred),单位为 spacing 对应的物理单位。""" pred = pred.astype(bool) gt = gt.astype(bool) if not pred.any(): raise ValueError("预测掩膜为空,表面距离无定义") if not gt.any(): raise ValueError("GT 掩膜为空,表面距离无定义") if spacing is None: spacing = (1.0,) * pred.ndim spacing = tuple(float(s) for s in spacing) ps = extract_surface(pred, connectivity) gs = extract_surface(gt, connectivity) # 输入取反:让目标表面成为"零点",EDT 输出的就是到目标表面的距离 dt_to_gt = distance_transform_edt(~gs, sampling=spacing) dt_to_pred = distance_transform_edt(~ps, sampling=spacing) return dt_to_gt[ps], dt_to_pred[gs]这里有一个容易绕晕的地方,值得多说两句。distance_transform_edt的语义是:对输入数组中每个非零元素,计算它到最近的零元素的欧氏距离。我们把~gs传进去,也就是 GT 表面位置为 False(即 0),其余位置为 True(即 1)。这样 EDT 的输出在 GT 表面上是 0,在其他任何位置上是"到最近 GT 表面点的距离"。最后用[ps]索引出预测表面上的那些点,就得到了预测表面每个点到 GT 表面的距离。
sampling参数就是 spacing,传进去之后 EDT 输出的单位直接是毫米,不需要额外乘系数。
4.4 把八个指标封装成一个可复用模块
下面是完整的指标模块,我把它放在seg_metrics.py里,项目里直接 import。所有函数都做了形状校验和空掩膜保护。
# seg_metrics.py import numpy as np from scipy.ndimage import binary_erosion, generate_binary_structure, distance_transform_edt def _as_bool_pair(pred, gt): pred = np.asarray(pred) gt = np.asarray(gt) pred = pred.astype(bool) if pred.dtype == bool else pred > 0.5 gt = gt.astype(bool) if gt.dtype == bool else gt > 0.5 if pred.shape != gt.shape: raise ValueError(f"形状不一致: {pred.shape} vs {gt.shape}") return pred.astype(bool), gt.astype(bool) def dice(pred, gt, eps=1e-8): p, g = _as_bool_pair(pred, gt) inter = np.count_nonzero(p & g) return float((2 * inter + eps) / (p.sum() + g.sum() + eps)) def jaccard(pred, gt, eps=1e-8): p, g = _as_bool_pair(pred, gt) inter = np.count_nonzero(p & g) union = np.count_nonzero(p | g) return float((inter + eps) / (union + eps)) def confusion_counts(pred, gt): p, g = _as_bool_pair(pred, gt) tp = int(np.count_nonzero(p & g)) fp = int(np.count_nonzero(p & ~g)) fn = int(np.count_nonzero(~p & g)) tn = int(np.count_nonzero(~p & ~g)) return tp, fp, fn, tn def precision(pred, gt, eps=1e-8): tp, fp, _, _ = confusion_counts(pred, gt) return float((tp + eps) / (tp + fp + eps)) def recall(pred, gt, eps=1e-8): tp, _, fn, _ = confusion_counts(pred, gt) return float((tp + eps) / (tp + fn + eps)) def extract_surface(mask, connectivity=1): mask = mask.astype(bool) if not mask.any(): return np.zeros_like(mask, dtype=bool) st = generate_binary_structure(mask.ndim, connectivity) eroded = binary_erosion(mask, structure=st, border_value=0) return mask & ~eroded def surface_distances(pred, gt, spacing=None, connectivity=1): p, g = _as_bool_pair(pred, gt) if not p.any(): raise ValueError("预测掩膜为空") if not g.any(): raise ValueError("GT 掩膜为空") if spacing is None: spacing = (1.0,) * p.ndim spacing = tuple(float(s) for s in spacing) ps = extract_surface(p, connectivity) gs = extract_surface(g, connectivity) dt_to_gt = distance_transform_edt(~gs, sampling=spacing) dt_to_pred = distance_transform_edt(~ps, sampling=spacing) return dt_to_gt[ps], dt_to_pred[gs] def hd(pred, gt, spacing=None, connectivity=1): d1, d2 = surface_distances(pred, gt, spacing, connectivity) return float(max(d1.max(), d2.max())) def hd95(pred, gt, spacing=None, connectivity=1, reduction="concat"): """ reduction: concat - 双向距离拼接后取 95 百分位(medpy / surface-distance 的约定) max - 两个方向各取 95 百分位再取较大者(更保守,数值通常更大) mean - 两个方向各取 95 百分位再平均 """ d1, d2 = surface_distances(pred, gt, spacing, connectivity) if reduction == "concat": return float(np.percentile(np.concatenate([d1, d2]), 95)) if reduction == "max": return float(max(np.percentile(d1, 95), np.percentile(d2, 95))) if reduction == "mean": return float(0.5 * (np.percentile(d1, 95) + np.percentile(d2, 95))) raise ValueError(f"未知的 reduction 方式: {reduction}") def asd(pred, gt, spacing=None, connectivity=1): d1, d2 = surface_distances(pred, gt, spacing, connectivity) return float(0.5 * (d1.mean() + d2.mean())) def msd(pred, gt, spacing=None, connectivity=1): d1, d2 = surface_distances(pred, gt, spacing, connectivity) return float(np.median(np.concatenate([d1, d2])))这里要重点说一下reduction这个参数。三种约定算出来的 HD95 数值是不同的,而且差距不小。concat是主流库里最常见的做法,双向距离合并成一个数组再取百分位;max更保守,因为两个方向各取 95 百分位,等于把两个方向的"局部最差"都保住了。
我实测过一批主动脉数据,同一个模型,concat算出来是 3.2 mm,max算出来是 4.1 mm。差 0.9 mm。如果你和别人论文对比时没注意这个约定,很容易得出错误结论。所以我的建议是:默认用concat,因为社区主流是这个;同时在论文里写清楚你用的是哪一种。
4.5 在主动脉分割数据上跑一遍完整评估
下面是一个完整的批处理脚本,读 NIfTI、取 spacing、算全套指标、输出统计表。
# evaluate_all.py import glob import os import nibabel as nib import numpy as np import pandas as pd from seg_metrics import dice, jaccard, precision, recall, hd95, asd, msd def load_mask(path): img = nib.load(path) arr = np.asanyarray(img.dataobj) # nibabel 的 get_zooms 顺序与数组前三维轴顺序一致 spacing = tuple(float(z) for z in img.header.get_zooms()[:3]) return arr, spacing def evaluate_pair(pred_path, gt_path): pred_arr, _ = load_mask(pred_path) gt_arr, spacing = load_mask(gt_path) pred = pred_arr > 0.5 gt = gt_arr > 0 res = { "dice": dice(pred, gt), "iou": jaccard(pred, gt), "precision": precision(pred, gt), "recall": recall(pred, gt), "spacing_x": spacing[0], "spacing_y": spacing[1], "spacing_z": spacing[2], } if pred.any() and gt.any(): res["hd95"] = hd95(pred, gt, spacing=spacing, reduction="concat") res["asd"] = asd(pred, gt, spacing=spacing) res["msd"] = msd(pred, gt, spacing=spacing) else: res["hd95"] = np.nan res["asd"] = np.nan res["msd"] = np.nan res["empty_pred"] = int(not pred.any()) return res def main(gt_dir, pred_dir, out_csv="metrics.csv"): rows = [] for gt_path in sorted(glob.glob(os.path.join(gt_dir, "*.nii.gz"))): case_id = os.path.basename(gt_path).replace(".nii.gz", "") pred_path = os.path.join(pred_dir, case_id + ".nii.gz") if not os.path.exists(pred_path): print(f"[WARN] 缺少预测文件: {case_id}") continue row = evaluate_pair(pred_path, gt_path) row["case"] = case_id rows.append(row) print(f"{case_id}: Dice={row['dice']:.4f} HD95={row['hd95']:.3f}mm") df = pd.DataFrame(rows) df.to_csv(out_csv, index=False) cols = ["dice", "iou", "precision", "recall", "hd95", "asd", "msd"] summary = pd.DataFrame({ "mean": df[cols].mean(), "std": df[cols].std(), "median": df[cols].median(), "p10": df[cols].quantile(0.10), "p90": df[cols].quantile(0.90), }) print(summary.round(4)) print(f"完全漏检病例数: {int(df['empty_pred'].sum())} / {len(df)}") return df if __name__ == "__main__": main("data/gt", "data/pred")跑完之后,你会得到类似下面这样的表格。这些是我在某批测试数据上见过的典型数值区间,具体每批数据肯定不一样,写出来只是给你一个量级参考。
| 病例 | Dice | IoU | HD95 (mm) | ASD (mm) | 备注 |
|---|---|---|---|---|---|
| 病例 A01 | 0.941 | 0.889 | 1.86 | 0.52 | 走行规则,对比度好 |
| 病例 A02 | 0.918 | 0.848 | 2.74 | 0.81 | 弓部有轻度钙化 |
| 病例 A03 | 0.887 | 0.798 | 5.92 | 1.43 | 分支根部糊,HD95 被拉高 |
| 病例 A04 | 0.933 | 0.875 | 2.31 | 0.67 | 正常 |
| 病例 A05 | 0.792 | 0.655 | 11.40 | 3.28 | 层厚 2.5 mm,层方向误差被放大 |
| 均值 | 0.894 | 0.813 | 4.85 | 1.34 |
从这张表能读出很多单个 Dice 读不出来的信息。A03 的 Dice 只比均值低 0.007,但 HD95 高出一倍,说明问题不在整体覆盖,而在某一处局部。A05 的 Dice 明显偏低,同时 ASD 也高,说明是整体性偏移,大概率