资讯动态

图像分割评价指标详解:IoU与Dice的计算、代码实现及训练应用

发布时间:2026/10/15 21:37:31 来源:尧图企业网站定制
简介在计算机视觉与医学影像分析中自动分割结果的准确性常用Dice系数和Hausdorff距离来评价。一份题为《图像分割中常用的两个评价指标》的PDF论文围绕这两种轮廓相似度度量方法展开系统研究适合放疗计划制定和分割算法评估等领域的研究人员与工程技术人员参考。资源包为单个PDF文件大小1.52MB内容是一篇完整的学术论文包含摘要、关键词、公式推导、实验设计与结论。论文源自《中国医学物理学杂志》研究过程严谨对比结果直观。文中详细阐释了Dice系数和Hausdorff距离的计算原理与适用场景并设计18个9对从临床靶区抽象出的轮廓进行比较计算归纳出图像符合度好、整体符合但局部不匹配、轮廓符合度差三种典型情况进而提出应将DSC与HD结合使用的评估策略避免仅以Dice系数为标准忽视局部偏差。目前已有2187人学习下载该资料对于希望深入理解分割评价指标互补关系、提升图像分割验证可靠性的读者具有较高的参考价值。1. 图像分割绕不开的两个指标从「跑分好看」到「模型真能用」做图像分割的同行应该都有过这种经历训练日志里 loss 曲线一路往下走验证集上的指标也刷到了 0.9 以上心里正美结果把预测结果往原图上一叠边缘崩得没法看细小结构全丢。原因往往不是模型没训好而是你选的评价指标根本没卡住关键错误。图像分割里最常用的两个评价指标——IoUIntersection over Union交并比和 Dice 系数——一个衡量区域重合的硬标准一个对类别不平衡更敏感几乎覆盖了语义分割、医学影像分割、遥感地物提取这些最常见场景的评估需求。这篇就把它们的计算逻辑、代码实现、训练期的调参细节和最容易踩的坑一次讲透让你不只会算还能用它们真正判断模型好坏。2. 为什么是 IoU 和 Dice先搞懂它们在衡量什么2.1 IoU 到底算了什么不是所有「对」都算对IoU 的定义一句话就能说清预测区域和真实区域的交集面积除以它们的并集面积。数学上写作 IoU |A ∩ B| / |A ∪ B|A 是预测为正类的像素集合B 是标注为正类的像素集合。取值范围从 0 到 10 是完全没重合1 是完美重合。但真正决定这个指标好不好用的是一个细节它惩罚「过度预测」和「欠预测」的方式是等价的。你多预测了一块不相干的区域分母变大分数下降你漏预测了一块该有的区域分子变小的同时分母也在变同样受罚。这意味着 IoU 不是一个只看你「找对了多少」的指标它同时要求你别「找多了」。这个特性让它在边缘要求高的场景里特别适用——模型如果只学会了把图里所有东西都涂成前景像素准确率可能很高但 IoU 会非常诚实。拿一个简单例子算一下。假设真实标签里正类有 100 个像素模型预测了 120 个像素其中 80 个和真实正类重合。那么交集是 80并集是 100 120 - 80 140IoU 80 / 140 ≈ 0.571。如果模型只预测了 60 个像素全都在真实正类范围内交集 60并集 100 60 - 60 100IoU 0.6。两种情况分数都很低但原因完全不同前者是过分割后者是欠分割IoU 都不放过。2.2 Dice 系数和 IoU 的关系一个公式推导看清本质Dice 系数很多人叫它 F1 分数在像素层面的变体公式是 Dice 2|A ∩ B| / (|A| |B|)。表面上和 IoU 长得不一样但数学上它们有确定的关系。把 |A| |B| 记作 S交集记作 I并集 U S - I。那么 IoU I / UDice 2I / S。由 U S - I 可以推出 S U I于是 Dice 2I / (U I)。从这个式子能看出两件事。第一给定 IoU 值Dice 系数永远大于等于 IoU。具体算一下IoU 0.5 时Dice 2×0.5 / (1 0.5) ≈ 0.667。IoU 0.8 时Dice ≈ 0.889。所以同一次评测Dice 分数总是比 IoU 好看看到 Dice 0.9 不要太兴奋换算成 IoU 可能才 0.82 左右。第二在数值接近 1 的高分区间两个指标的差异不大在低分区间Dice 对错误相对「宽容」。实际选型时如果你的业务对漏检零容忍用 Dice 做训练损失会更合适如果对误检也敏感IoU 是更严格的裁判。2.3 像素准确率和混淆矩阵为什么不能只看一个数很多刚入门的同学会把 Pixel Accuracy像素准确率PA当成第一个评测指标因为最容易算预测对的像素总数除以全部像素数。但在类别不平衡的数据集上PA 几乎没有参考价值。举个例子一张 512×512 的医学图像里病灶区域可能只占 5% 甚至更少模型把所有像素都预测为背景PA 直接 95%。这给不了你任何关于分割质量的信号。所以要养成一个习惯做分割评估至少同时看 IoU 和 Dice并且要按类别分别计算再报一个平均。单个数字的全局指标会掩盖某些类完全没被模型学会的事实。实践里我会先看每个类的 IoU再算一个均值mIoU如果一个类别的 IoU 明显低于其他类哪怕整体均值看着正常这个模型也没法直接上线。这属于图像分割评估里最基础但最容易被忽略的纪律。指标公式对过分割的敏感度对欠分割的敏感度高分区间差异IoU|A∩B| / |A∪B|高高更严格Dice2|A∩B| / (|A||B|)中中比 IoU 高 0.1 左右PA预测正确像素 / 总像素极低极低无参考价值3. 从公式到代码用 Python 实现逐类 IoU 和 Dice 的完整写法3.1 基于混淆矩阵的标准实现逐类计算不踩坑在实际项目里我不会直接用 sklearn 的jaccard_score或f1_score处理分割结果因为语义分割的输出是二维的像素类别图而且多类任务里每个类的像素数量差异可能极大直接用现成库函数虽然简单但遇到「某个类在整张图里一个像素都没有」时会翻车。我一般会自己写一个基于混淆矩阵的版本逻辑透明还能直接复用做 mIoU 和 mDice 的统计。import numpy as np def compute_per_class_iou_dice(pred, label, num_classes): 计算逐类的 IoU 和 Dice 系数。 pred: 模型预测的类别索引图HxW每个像素取值 0~num_classes-1 label: 真实标注的类别索引图HxW num_classes: 类别总数包含背景类 返回: ious, dices, counts 三个数组长度为 num_classes ious np.zeros(num_classes) dices np.zeros(num_classes) counts np.zeros(num_classes) for cls in range(num_classes): pred_mask (pred cls) label_mask (label cls) intersection np.logical_and(pred_mask, label_mask).sum() pred_sum pred_mask.sum() label_sum label_mask.sum() union pred_sum label_sum - intersection if union 0: # 真实和预测都没有这个类跳过不算入均值 ious[cls] float(nan) dices[cls] float(nan) else: ious[cls] intersection / union dices[cls] 2 * intersection / (pred_sum label_sum) counts[cls] label_sum return ious, dices, counts这段代码的关键逻辑在 union 的处理pred_sum label_sum - intersection对应公式里 |A ∪ B| |A| |B| - |A ∩ B|。两个 mask 分别用逻辑运算生成np.logical_and得到交集.sum()统计 True 的个数。注意 union 为 0 的情况这表示这一类在预测和标签里都不存在直接置 nan后面求均值时用np.nanmean跳过而不是用np.mean把 nan 算进去。3.2 从 Tensor 到指标训练流程里最常被忽略的 shape 问题实际训练时模型输出的往往是 logitsshape 是 [B, C, H, W]C 是类别数。做评测前先做argmax得到类别索引图再和标签比较。但这里面有个高频坑标签的 shape 是 [B, H, W] 还是 [B, 1, H, W]取决于你的 Dataset 怎么写。如果不统一pred label的广播会把事情搞得很奇怪指标忽高忽低还找不出原因。import torch def evaluate_model(model, val_loader, num_classes, device): 在验证集上计算 mIoU 和 mDice返回张量形式的结果 model.eval() ious_all [] dices_all [] with torch.no_grad(): for images, labels in val_loader: # labels 可能带 channel 维度也可能是纯索引图 if labels.dim() 4: labels labels.squeeze(1) # [B,1,H,W] - [B,H,W] images images.to(device) labels labels.to(device) logits model(images) preds torch.argmax(logits, dim1) # [B,H,W] # 逐张图计算避免batch内类别分布差异互相掩盖 for i in range(images.size(0)): ious, dices, _ compute_per_class_iou_dice( preds[i].cpu().numpy(), labels[i].cpu().numpy(), num_classes ) ious_all.append(ious) dices_all.append(dices) ious_all np.stack(ious_all) dices_all np.stack(dices_all) # 忽略所有类都不存在的样本按类别求平均 m_iou np.nanmean(ious_all, axis0) m_dice np.nanmean(dices_all, axis0) return m_iou, m_dice这个实现对 batch 内的每一张图分别计算指标再合并而不是把整个 batch 的像素堆在一起算一个指标。原因是如果某张图里label_sum为 0 的类很多堆在一起会把那些类的 contribution 稀释掉。逐图计算再平均每个样本在指标里的权重相同这通常更符合「评估模型在每张图上的表现」这一目标。np.nanmean是处理空类别的最简单解法等价于在求均值时忽略所有没有该类别的样本。3.3 参数说明与边界情况哪些数字必须你亲自确认写这个函数时有几个参数需要你根据实际任务确认不是抄下来就能直接用的。第一num_classes是否包含背景。如果你的模型输出 5 个通道其中 0 类是背景那么 num_classes 传 5循环从 0 到 4一切正常。但很多人写 dataset 时把背景像素标成 255 或者忽略值 -1这时候就要在进入指标计算前把标签重新映射到 0~4否则label cls永远匹配不上背景类所有背景像素都变成错误类mIoU 会被严重拉低。第二多类分割里你是否关心每个类独立的分数。前面讲过只看 mIoU 会掩盖个别类失效的问题。我在实际项目里会把m_iou打印成一行数组比如[0.92 0.85 0.43 0.88]看到第三类只有 0.43立刻就知道这个模型在该类上几乎不可用不需要等 demo 图出来才后悔。第三Dice 计算里pred_sum label_sum会不会为 0。代码里没有单独判断这个分母因为它是两个非负数的和只有两个都为 0 才会是 0而这种情况在 union 判断里已经被拦住了。但如果你改代码把2 * intersection和pred_sum label_sum拆开放一定要注意这个细节漏了就会在空类上出现除零告警。4. 两个指标在训练流程里怎么用不只是「最后跑分」还能当损失函数4.1 Dice Loss类别不平衡时的首选损失函数图像分割里用交叉熵损失训出来的模型遇到前景占比极小的任务病灶、细小裂缝、遥感里的道路经常翻车。交叉熵在像素级别的梯度是平等的背景像素数量远大于前景时模型学到的几乎全是把一切预测为背景的捷径。Dice Loss 的出发点就是让损失值直接和区域重合度挂钩Dice Loss 1 - Dice。它的梯度在预测和真实重合度低时更大能让模型更早关注到难以分类的前景像素。import torch import torch.nn as nn class DiceLoss(nn.Module): 支持多分类的 Dice Loss按类别计算后取平均。 def __init__(self, num_classes, smooth1e-5, ignore_indexNone): super().__init__() self.num_classes num_classes self.smooth smooth self.ignore_index ignore_index def forward(self, logits, targets): # logits: [B,C,H,W]; targets: [B,H,W]值范围 0~C-1 probs torch.softmax(logits, dim1) # 转换为概率分布 targets_one_hot torch.nn.functional.one_hot( targets, num_classesself.num_classes ).permute(0, 3, 1, 2).float() # 忽略指定类别例如边缘像素标注为 255 的样本 if self.ignore_index is not None: ignore_mask (targets self.ignore_index).unsqueeze(1) probs probs * (~ignore_mask).float() targets_one_hot targets_one_hot * (~ignore_mask).float() intersection (probs * targets_one_hot).sum(dim(0, 2, 3)) denominator probs.sum(dim(0, 2, 3)) targets_one_hot.sum(dim(0, 2, 3)) dice_per_class (2 * intersection self.smooth) / (denominator self.smooth) # 排除在整批数据中完全不存在的类避免拉低损失 valid_mask targets_one_hot.sum(dim(0, 2, 3)) 0 dice_per_class dice_per_class[valid_mask] return 1 - dice_per_class.mean()这个实现里smooth参数值得一提。它的作用是防止某个类在预测和真实中都没有出现时分母为 0但也会让损失值不会严格趋向 0。smooth 太大会让低重合度的区域在梯度上被「稀释」训练到后期 loss 降不下去太小又会在空类上抖动。我一般在医疗影像任务里用1e-5遥感任务里用1e-4前者类别更极端后者分割目标相对大一点不需要那么极端的平滑。4.2 训练期用 Dice Loss 还是交叉熵两派做法和真实体验业内做法分两派一派只用交叉熵另一派用Dice Loss CrossEntropy的组合。我个人的习惯是组合用权重按dice_loss 0.5 * ce_loss来配。理由Dice Loss 在类不平衡时收敛快但它的梯度在预测接近全部正确时几乎消失模型后期精细边缘的调整能力不足交叉熵能提供像素级别的持续梯度两个互补能明显减少训练后期指标卡住不动的情况。但注意一个关键点训练时用的 Dice 和验证时算的 mIoU 并不是同一个数。训练时按 batch 算平均 Dice验证时按整张图逐类算 IoU。所以训练日志里 Dice loss 降到 0.2验证集 mIoU 可能只有 0.75这是正常的不代表模型没训好。看趋势就行别用两个不同定义的数字互相验证。4.3 验证频率和保存策略把指标用在 checkpoint 选择上很多项目里 checkpoint 保存只看「验证 loss 最低」但验证 loss 和实际分割质量之间经常存在错位。我会在验证时同时计算 mIoU 和 mDice并优先以 mIoU 为保存标准。因为前面提过mIoU 对过分割和欠分割的惩罚更严格选出来的模型往往边缘更干净。保存策略用以下逻辑每个 epoch 结束后跑一次验证如果 mIoU 比历史最好值高就保存一份 checkpoint同时把对应的 mDice 记下来。最后选模型时不要只看最后一个 epoch而是对比历史最佳 mIoU 的 checkpoint 和最后一个 epoch 的模型大概率你会选前者。用途建议指标原因训练损失Dice Loss 或 Dice CE对类不平衡敏感梯度信号强验证集选模型mIoU惩罚过分割和欠分割更严上线前最终评估逐类 IoU 逐类 Dice暴露个别类失效的问题论文/报告汇报mIoU 和 mDice 同时报两个口径都给出方便对比5. 避坑指南五条最常见的评价指标翻车记录5.1 指标高但图不能看类别分布不均造成的假象现象验证集 mIoU 0.85看起来不错可视化预测图却发现模型把目标区域吞掉了大半。原因数据里大部分样本的某个类别占比极高比如大面积的天空或背景其他类别占比极小。逐张图算完指标后求平均 mIoU占大头的类别把均值拉得很高小类别低分被淹没了。解决打印逐类的 IoU 数组找出明显低于均值的那个类。再不行就看每个类的counts列确认小类别样本量是否足够。如果某类只有十几个样本指标低不代表模型差可能只是训练数据不够。判断标准是看中位类别 IoU而不是均值。5.2 这个类从来没被预测出来空类问题在指标上的表现形式现象训练日志里 mDice 一直在涨但某几个类别的 IoU 始终是 0。原因这些类在训练集中样本太少模型整套训练下来根本没见过几次学到的是「永远不预测这个类」这样反而让整体损失更低。指标计算时这些类的 IoU 为 0但由于类别数多拉低均值的幅度被稀释了。解决这个现象在指标层面无法被单一均值暴露必须养成逐类检查的习惯。我一般会要求自己每次验证时顺手打印一行Per-class IoU: [0.91 0.87 0.00 0.79]看到 0.00 立刻停下来处理数据问题而不是继续调模型结构。5.3 空目标图标签里没有某个类时指标算出来的值会让你误判现象验证集里有一批只含背景的图像某类在所有图里完全没有出现。算全局指标时这块结果很奇怪甚至会出现某个类 IoU 为负数原因不会为负但会出现「该类在大多数样本里是 nan在少数样本里是 1.0」的情况取平均后可能得 0.9 以上的假高分。这源于代码里union 0时跳过该样本只对有效样本求平均。解决不仅要跳过空类的样本还要统计「该类有多少个有效样本」。如果有效样本数少于某个阈值比如 5就把该类的 IoU 标记为-1报告时写「样本不足」而不是强行给出一个没有统计意义的数字。实际落地时有业务价值但样本少的类宁可先小样本过拟合也不能让它拖垮指标可信度。5.4 混淆矩阵是对的但指标代码里少了个括号现象两个同事用同一批模型输出和标签A 算出 mIoU 0.83B 算出 0.78谁也不服谁。原因最常见的是smooth参数加的位置不对。有人把平滑项加在分子分母的整个算式中有人只加在分母数值差异在上千个像素累加后被放大了。还有人是把Dice 2 * intersection / (pred_sum label_sum)写成了Dice intersection / (pred_sum label_sum)少了 2 倍Dice 直接少一半。解决用标准的混淆矩阵实现不要在指标计算里加 smooth。smooth 只在损失函数里用验证阶段用严格的数学定义。另外写一个最小的单类用例测试一下预测全对时 IoU 应为 1.0全错时为 0.0这个测试跑不过代码一定有问题。5.5 验证结果和测试结果不一致数据泄漏和数据划分的锅现象验证集 mIoU 0.88换到测试集变成 0.72差距大到没法接受。原因最典型的不是指标代码错了而是数据划分泄漏。分割任务里同一张原始大图的多个切片被同时分到了训练集和验证集模型在训练时见过了验证图像的局部内容。Dice 和 IoU 对这类重复高度敏感因为像素级的评估会把「见过」和「没见过的内容」区别放得很大。解决按「原始样本」划分数据而不是按「切片」划分。比如遥感大图先切块然后确保同一张大图的所有切块只在同一个数据集里用某个唯一的图像 ID 做 group split。这个步骤在图像分割里属于数据准备阶段但它直接影响评价指标的可信度必须写进验收流程。6. 把指标变成决策工具用逐类 IoU 做模型能力画像先讲一个我养成的检查习惯每次模型训练完不做别的先打印一张「逐类 IoU 矩阵」。横轴是类别纵轴是不同 checkpoints比如第 20、40、60、80 个 epoch 保存的模型。这张小表能直接告诉你模型是在「整体变好」还是「个别类拉高均值」。比如某次模拟项目X里我训练一个五类分割模型背景、道路、建筑、植被、水体。逐类 IoU 打出来大概是checkpoint背景道路建筑植被水体mIoUepoch 200.910.620.730.850.480.72epoch 400.930.710.760.870.570.77epoch 600.940.730.780.880.520.77epoch 800.950.750.790.890.400.76从这张表能看出什么mIoU 在第 40 和 60 个 epoch 之间几乎不动但水体类的 IoU 在 epoch 60 后从 0.57 掉到 0.52 再掉到 0.40。整体指标看起来只是「没提高」实际上模型在水体类上已经退化。如果没有逐类指标这种退化可能到上线后被用户在极端场景里发现才暴露。我的处理方式是用这个表格决定训练策略。如果某个类的 IoU 低于所有其他类 0.2 以上优先检查训练样本里该类的分布如果所有类的 IoU 都在 0.6~0.8 之间但谁也不突出调整损失权重或者模型容量比换网络结构更值得投入如果 mIoU 还在涨但某个小类别已经开始下降说明模型开始过拟合多数类此时应该早点停。说到这里最后补一个我自己反复用到的小技巧把 IoU 和 Dice 的差Dice - IoU打印出来。因为两个指标在数值上有确定关系同一个预测结果下这个差值应该是稳定在一个范围——Dice 比 IoU 高约 0.1 到 0.15。如果你发现某个类别的差值突然异常大比如 IoU 0.3 而 Dice 0.6说明这个类在「漏检少但误检多」的方向上越走越偏反之差值很小说明过分割严重。具体的数值关系是固定的差值越大代表并集里交集的占比低即预测和真实的重合度差。这个信号能直接告诉你该调损失权重还是该做后处理。做图像分割这几年如果只能说一条经验那就是别信任训练日志里的好看数字去看逐类的指标去算 IoU 和 Dice 的差值去手动检查预测图和标签的重叠。指标不是用来安慰自己的是用来帮你做决策的。希望这篇能帮你在下次训练时少走几趟弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑