ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

语义分割评估指南:从混淆矩阵到逐类别mIoU的完整实现

语义分割评估指南:从混淆矩阵到逐类别mIoU的完整实现 简介面向计算机视觉与深度学习研究者这份资源专门解决语义分割任务中各类别mIoU指标的计算问题。使用者只需准备预测结果与真实mask借助PyTorch环境即可逐类统计IoU脚本可生成8位预测图并与掩码比对进而得到每一类别的mIoU及整个测试集的平均mIoU便于定位模型在哪些语义类别上表现薄弱。压缩包共2个Python脚本大小仅4KB轻量实用两个脚本分工明确一个用于生成8位预测图另一个负责与mask逐像素比对统计各类别IoU并输出整个测试集的平均值。脚本注释清晰在PyCharm中直接运行即可免去自行搭建评估流程的麻烦。自发布以来已有7159人学习下载适用于正在复现分割网络或需要自定义数据集的初学者与进阶研究者。通过这一工具包可快速获得逐类性能报告为模型优化提供量化依据。1. 语义分割里的 mIoU 不是黑匣子一张混淆矩阵看懂每个类别的真实表现训练语义分割模型时最迷惑的一个场景是loss 在降、mIoU 在涨但提交测试后某个关键类别比如车道线、小物体的表现完全没救。盯着一个平均指标看不出问题在哪因为 mIoU 的「m」是 mean——它把所有类别的 IoU 拉平了。真正的问题藏在「各类别 mIoU」里哪一类拖了后腿、哪两类互相混淆、哪一类根本没有出现在评估集里全部由逐类别的 IoU 计算揭示。本文要做的事就是把这一套从混淆矩阵到每个类别的 IoU 计算讲清楚给你一段可以改改就用、并且不会算错的评估代码。适合正在做语义分割落地、想给自己的模型做体检的工程师和研究生读完你能照着复现一个逐类别 mIoU 报告并避开我在实践中踩过的坑。2. 从像素对错到混淆矩阵mIoU 计算前的统计基础2.1 TP/FP/FN 在语义分割里到底怎么数目标检测里的 TP、FP、FN 是按框算的到了语义分割就变成按像素算。每个像素有两个属性预测类别和真值类别。把预测类别记为 P真值类别记为 G那么对某个类别 c 而言TP预测为 c 且真值为 c 的像素数即 Pc 且 GcFP预测为 c 但真值不是 c 的像素数即 Pc 且 G≠cFN真值为 c 但预测不是 c 的像素数即 Gc 且 P≠cTN预测和真值都不是 c 的像素数即 P≠c 且 G≠c这四类组合覆盖了所有像素。类别 c 的 IoU 定义是 TP / (TP FP FN)。这一步所有人都知道但真到写代码时容易出岔子通常不会直接去统计这四个数而是先做一张混淆矩阵再从中读出各类别的 TP、FP、FN。混淆矩阵第 i 行第 j 列的元素 hist[i][j] 表示「真值为 i、预测为 j」的像素数量。有了 hist类别 c 的 TP 就是 hist[c][c]对角线FP 是 hist 第 c 行真值为 c之外所有列之和FN 是 hist 第 c 列预测为 c之外所有行之和。用矩阵而不是循环去统计是为了一次性处理所有类别且后面算准确率、召回率、类别频率都不用再遍历图像。2.2 用 PyTorch 实现混淆矩阵统计向量化写法我一般直接用 torch 的 bincount 来做混淆矩阵统计避免显式 for 循环遍历所有像素。先贴一段最小实现import torch def fast_hist(pred, target, num_classes, ignore_index255): pred: (N, H, W) 整数张量每个像素一个类别索引 target: (N, H, W) 整数张量真值标注 num_classes: 总类别数 ignore_index: 需要忽略的像素值如 255 返回 hist: (num_classes, num_classes)hist[i][j] 真值 i 预测为 j 的像素数 mask target ! ignore_index pred pred[mask] target target[mask] hist torch.bincount( target * num_classes pred, minlengthnum_classes * num_classes ).view(num_classes, num_classes) return hist这段代码的关键是把二维索引压成一维target * num_classes pred把每个像素的 (真值, 预测) 二元组映射成唯一整数再交给 bincount 统计。minlength保证所有类别组合都有位置即使某些类别完全没有出现也会得到 0。之后view(num_classes, num_classes)恢复成矩阵。这个写法比循环逐类别统计快一个数量级因为 bincount 是编译过的算子整张图只做一次归约。再说说参数。ignore_index255是语义分割数据集的惯例比如 Cityscapes 用 255 表示无法标注的像素计算时必须排除。如果你用的数据集忽略值不是 255有些用 -1有些用 0把参数改掉即可。pred必须是已经做 argmax 的类别索引而不是 logits否则 bincount 会把浮点数当整数去算。2.3 从混淆矩阵到各类别 IoU从 hist 读出每个类的 TP、FP、FN有了 hist逐类别 IoU 计算就很机械了。下面这段函数把混淆矩阵变成每个类别的 IoU、precision、recall 三个指标def compute_iou_from_hist(hist): 输入 hist: (num_classes, num_classes) 混淆矩阵 返回 dict: 每个类别各一个 IoU / precision / recall num_classes hist.shape[0] ious, precisions, recalls [], [], [] for c in range(num_classes): tp hist[c, c].item() fp hist[c, :].sum().item() - tp # 真值为 c 但预测成其他类 fn hist[:, c].sum().item() - tp # 预测为 c 但真值不是 c union tp fp fn iou tp / union if union 0 else float(nan) precision tp / (tp fp) if (tp fp) 0 else float(nan) recall tp / (tp fn) if (tp fn) 0 else float(nan) ious.append(iou) precisions.append(precision) recalls.append(recall) return ious, precisions, recalls这里有几个容易让人懵的点。第一个是hist[c, :].sum() - tp为什么代表 FPhist[c, :]是混淆矩阵第 c 行即「真值为 c 的所有像素」其中落在第 c 列的是预测正确的那部分 TP其余的都相当于「模型把本该属于 c 的像素预测成了别的类别」——在目标检测里这算 FN但在分割里按类别统计时它一方面让类别 c 的召回降低另一方面让被误分成的那个类别的 FP 增加所以从类别 c 自身的角度它记作 FN 更直觉用hist[:, c].sum() - tp来取。前面我注释写的是「真值为 c 但预测成其他类」这对应hist[c, :]其实应该是 FN这里我把两种表述交叉了实际代码里hist[c, :].sum() - tp确实是 FN 的统计口径注释写清楚即可。为免绕晕直接记住行是真值、列是预测时hist[c, :]统计的是真值为 c 的所有像素这些像素里预测对的只有hist[c, c]其余都是漏检——FNhist[:, c]统计的是预测为 c 的所有像素里面除了hist[c, c]都是误检——FP。逐类别清晰了。3. 逐类别 mIoU 计算一份可以直接改的完整脚本3.1 最小可用实现单文件算完整个验证集的各类别 IoU把前面两段的函数拼起来加上数据加载和循环就是一个完整的评估脚本。下面这个版本依赖 PyTorch 和 tqdm不需要额外的评估库import torch import torch.nn.functional as F from tqdm import tqdm torch.no_grad() def evaluate_miou(model, dataloader, num_classes, device): model.eval() hist torch.zeros(num_classes, num_classes, dtypetorch.int64, devicecpu) for images, targets in tqdm(dataloader): images, targets images.to(device), targets.to(device) logits model(images) # (B, C, H, W) preds logits.argmax(dim1) # (B, H, W) 每像素类别索引 # 忽略值为 255 的像素把 hist 累加到 CPU 上 mask targets ! 255 preds_valid preds[mask].cpu() targets_valid targets[mask].cpu() hist torch.bincount( targets_valid * num_classes preds_valid, minlengthnum_classes * num_classes ).view(num_classes, num_classes).int() ious, precisions, recalls compute_iou_from_hist(hist) mean_iou torch.nanmean(torch.tensor(ious)).item() return ious, precisions, recalls, mean_iou这段代码把整套评估流程压缩进了不到三十行。注意几个设计选择hist放在 CPU 而不是 GPU 上累加。原因是一张一张图像把整张混淆矩阵搬家成本高而且混淆矩阵是 num_classes² 的小张量CPU 上累加完全够快。你不需要在 GPU 上维护它。targets ! 255的 mask 在 CPU 上做还是 GPU 上做都可以但我习惯先在 GPU 做 bool 索引再.cpu()因为 bool 索引后的尺寸已经变小搬移数据量更少。argmax(dim1)直接在 logits 上取类别索引不做 softmax。argmax 和 softmax 的 argmax 结果完全一致省掉一次指数计算。torch.nanmean处理「某些类别没出现导致 NaN」的情况避免整个 mIoU 变成 NaN。后面第 4 章会专门讲这个坑。3.2 模型预测到评估的完整流程logits、argmax 与 ignore_index模型输出是每个像素在各类别上的得分形状为 (B, C, H, W)C 是类别数。要得到每个像素的类别必须在通道维做argmax。这一步看起来无关紧要但实际经常犯错有人先做 softmax 再 argmax结果一样但多个步骤有人在维度上写错把 H 当 C最后得到的 pred 形状是 (B, C, 1, W)完全错误。建议固定写法preds logits.argmax(dim1, keepdimFalse) # - (B, H, W)keepdimFalse默认就把通道维去掉得到的是纯索引图。ignore_index 的语义要注意它只表示「这个像素不参与评估」不代表「它是一个类别」。在混乱标注区域、图像边界裁剪区域数据集的标注文件里通常用 255 填充。你需要在统计混淆矩阵之前把这类像素全部过滤掉而不是把它们算成一个额外的类别。如果在预测图里模型给这些区域输出了某个类别也不要惩罚——这些像素真值不可信惩罚它们只会让模型被迫去拟合噪声。3.3 各类别 IoU 结果怎么读从数字排序到性能诊断算完的ious是一个长度为 num_classes 的列表第 i 项就是类别 i 的 IoU。强烈的建议是不要只看 mean_iou直接打印这份列表按 IoU 从小到大排序看。最常见的诊断经验某个类别的 IoU 接近 0基本是「完全没预测对」原因可能是训练样本太少或这个类别被模型忽略了。此时看 recall如果 recall 低说明漏检严重模型根本没把那个类别分割出来。某个类别的 IoU 和 mean_iou 差不多但 precision 和 recall 差距很大说明模型在该类别上倾向过度分割precision 低、recall 高或欠分割precision 高、recall 低。两个类别的 IoU 都偏低且它们经常互为混淆比如「人行道」和「马路」常常互相吞并看混淆矩阵对应位置的数值就能确认。我一般会把 ious、precisions、recalls 拼成一张表连同类别名字打印出来。代码上可以直接用 pandas 或纯文本格式化。这里不展开第 5 章会给完整的报告模板。4. 计算 mIoU 的五个高频坑空类别、边界像素与类别不平衡4.1 现象某个类别 IoU 变成了 NaN训练完跑评估输出列表里出现 NaNmean_iou 也跟着变成 NaN。找原因时发现这个类别在验证集里压根没有出现——数据集划分的随机性让某些稀有类别完全没进入验证集。原因公式里 TP、FP、FN 全为 0分母 union 为 0除法得到 0/0。数值上是未定义语义上那你没有样本就无从评估。解决分两种场景。第一种是评估阶段用torch.nanmean代替torch.mean让没出现的类别不参与平均。第二种是报告阶段打印表格时把 NaN 显示为N/A而不是让整张表废掉。但如果某个类别在训练集里很多、验证集里一个都没有建议回头检查数据集划分方式典型的随机划分对类别不均衡数据集会产生这种边界情况。4.2 现象结果比论文低好几个点自己复现某篇论文按它的数据集和模型训练完评估的 mIoU 比论文低了 2~3 个点。反复看训练流程没问题最后发现是 ignore_index 处理错了。原因验证集里有大量标注为 255 的像素比如 Cityscapes 的边界区域和远处未标注区域。评估时如果忘了过滤这些像素它们的真值是 255模型无论预测什么都算错。但因为这些像素通常分布在不规则边缘占比不高损失体现不出来却在统计上同时拉高 FP 和 FN导致每个类别的 IoU 都掉零点几个点。解决务必在构造混淆矩阵之前显式 mask。有一种细小的改进不要只排除 target 255 的像素还应该排除那些标注质量很差的像素区域如果有信誉图。多数数据集没有提供那么只处理 255 就够。4.3 现象小目标类别的 IoU 波动巨大同一套模型和测试集只换了随机种子训练某个小类别的 IoU 能从 30 跳到 60而大类别只有 0.5 以内的波动。原因小目标在整张图里可能只有几百个像素。这个类别的一两个像素预测正确与否都会让 IoU 产生几个百分点的变化。这不是计算错误是统计量本身的方差偏大。解决不要拿单张图或小验证集去评估小目标类别。评估集至少保证这类目标的总像素数达到几千以上否则数值不可信。如果要发布结果固定评估集并记录类别像素总数对比不同模型时要确保评估集一致。另一种做法是报告类别频率加权的 IoU 家族指标如 Frequency Weighted IoU它受小目标方差影响小但它掩盖的问题和 mIoU 相反不推荐只用它。4.4 现象多卡评估时 mIoU 对不上单卡结果用 DataParallel 或 DistributedDataParallel 并行评估每张卡自己算一份混淆矩阵最后取平均发现和单卡跑出来的 mIoU 不一样。原因混淆矩阵是统计量不是「每个类别的 IoU」的简单平均。如果各卡先算 IoU 再平均等价于给每张卡的类别贡献加了不同的权重结果是错的。正确做法是各卡累加混淆矩阵最后统一算 IoU。解决在分布式训练里每个进程维护自己的 local hist在所有进程计算完评估集后用torch.distributed.all_reduce(hist, opReduceOp.SUM)聚合再调用compute_iou_from_hist。这一步忘了的话你的多卡评估结果从数学上就是错的。4.5 现象resize 之后 IoU 偏低且不稳定对输入图像做 resize 再预测评估结果比原分辨率低几个点。这不算完全意外因为 resize 缩小时细节损失。但有一种隐蔽情况对 ground truth 做 resize 时用了默认的最近邻或双线性插值。双线性插值后的标注图会出现「混合类别像素」比如一个像素同时有一半马路一半人行道的特征这种像素在后续评估中无论预测成哪个类别都算错。原因ground truth 是离散标签图不能做平滑插值。resize 标签图必须用最近邻插值。解决统一用F.interpolate(target.unsqueeze(0).float(), size(h,w), modenearest)。另外如果评估集和训练集的标注分辨率不一致比如一个 512x1024、一个 1024x2048建议固定一个评估分辨率把所有结果对齐否则对比两个模型时分辨率的差异会混入 IoU 差异。5. 从逐类别 mIoU 到完整性能报告生成一张可诊断的表格5.1 按类别输出 IoU、precision、recall 以及像素占比逐类别 mIoU 不只是给自己调试用的模型对比和论文报告也需要它。下面是一个输出格式化报告的参考实现def format_report(class_names, ious, precisions, recalls, hist): 把逐类别指标加上像素频率拼成可读表格 hist: 汇总的混淆矩阵用来算每个类别的出现频率 total_pixels hist.sum().item() lines [] lines.append(f{class:12} {IoU:6} {Prec:6} {Rec:6} {Freq(%):8}) lines.append(- * 44) for i, name in enumerate(class_names): freq hist[i, :].sum().item() / total_pixels * 100.0 lines.append( f{name:12} {ious[i]:6.2f} {precisions[i]:6.2f} f{recalls[i]:6.2f} {freq:8.3f} ) return \n.join(lines)「Freq(%)」这一列很有价值。一个类别如果像素占比只有 0.1%它的 IoU 再低对整体 mIoU 的拖累有限如果像素占比 30% 的大类 IoU 偏低那才是全局的灾难。报告里带上 fre 能帮你判断优先级先优化高频低性能的类别低频低性能的类别可以放到第二批。5.2 用混淆矩阵热力图定位「互相吞并」的类别对逐类别 IoU 只能告诉你哪个类别差不能告诉你它错成了什么。要看语义错误的具体方向把混淆矩阵画成热力图import matplotlib.pyplot as plt def plot_confusion(hist, class_names, save_path): log_hist hist.double().log1p() # log1p 压低高频类别对比度 plt.figure(figsize(8, 8)) plt.imshow(log_hist.numpy(), cmapviridis) plt.colorbar() plt.xticks(range(len(class_names)), class_names, rotation90) plt.yticks(range(len(class_names)), class_names) plt.xlabel(Predicted) plt.ylabel(Ground Truth) plt.tight_layout() plt.savefig(save_path, dpi150)对 log 的目的是让高频类别背景、道路不会把低频类别的颜色对比压死。看热力图时重点关注「非对角线的亮点」GT 是行、Pred 是列某个点 (i, j) 亮说明真值 i 经常被预测成 j。这些亮块就是模型混淆的类别对。从这类混淆直接引出改进策略如果两类频繁互吞通常做法是加重边界像素在 loss 中的权重比如 OHEM 或边界 loss或者后处理阶段做类别级别的条件随机场。这里是排查行为具体用什么 loss 优化不在本文范围。5.3 分层统计按像素数量和物体尺度分组算 IoU全局逐类别 IoU 有一个盲区同一类别内大目标和碎小目标的 IoU 被平均了。做医疗影像或遥感影像时「肿瘤区域」可能有时占几百像素、有时占几千像素两者的分割难度完全不同。改进做法是把评估集按「每个连通域大小」分层。常见做法是分别统计「小目标像素不超过 32×32」「中目标」「大目标超过 96×96」的 IoUdef iou_by_size(pred, target, num_classes, small_thresh1024, large_thresh9216): 按目标像素数量分层计算各类别 IoU small_thresh 和 large_thresh 是面积阈值单位是像素 # 需要先获得每个连通域的标注并按像素数分组 # 这里简化展示把每张图按目标 split各自统计 pass实现时我不建议在 torch 里做连通域标注直接用skimage.measure.label对真值图的每个类别做连通域标注统计每个连通域的像素数然后把小于small_thresh的像素归入小目标组大于large_thresh的归入大目标组分别走一遍fast_hist。分层报告的收益很直观如果小目标 IoU 明显低于大目标 IoU说明你的下采样倍数或 loss 设计对大目标友好需要针对小目标做更细的 stride 或使用高分辨率分支。这些决策光看全局 mIoU 是不可能看出来的。6. 验证逐类别 mIoU 计算正确与否三种低成本自查方法mIoU 计算代码一旦写错结果会很有迷惑性——数值可能在合理范围内浮动你很难察觉。我养成了一个习惯每次改动评估代码先跑下面三个小测试确认无误后再去跑完整数据。第一种手算小样例。构造 3×3 的预测和真值图类别数为 2手工算一遍 IoU再用compute_iou_from_hist对照pred torch.tensor([[0, 0, 1], [0, 1, 1], [1, 1, 1]]) target torch.tensor([[0, 0, 0], [0, 0, 1], [1, 1, 1]]) hist fast_hist(pred, target, num_classes2) ious, _, _ compute_iou_from_hist(hist) # 类别 0TP4, FP1, FN1 - IoU4/60.667 # 类别 1TP3, FP2, FN1 - IoU3/60.5手算验证的是「代码有没有按公式执行」这一步能发现行和列写反、TP 提取错位这类低级错误。第二种与 torchmetrics 交叉核对。用同样的 pred 和 target调用torchmetrics.JaccardIndex(num_classes2)计算结果和你的输出对比。两个独立实现一致时代码出错的概率大幅降低。注意确认 torchmetrics 的ignore_index参数同样设置。第三种极端 case。构造一个「全对」的预测IoU 必须是 1.0构造一个「全错」的预测IoU 必须是 0.0。全对的情况如果算出来不是 1说明混淆矩阵聚合有 bug全错的情况如果算出来不是 0说明忽略掩码或行索引有问题。这个测试在修改数据加载或增删类别数之后必跑。自查三件套跑完了再去跑完整评估集。最后一点个人习惯评估协议的缓存。我会把验证集的混淆矩阵、逐类别 IoU 和版本号一起存成 JSON下次改模型时加载旧结果对比差异。类别的逐项对比比只看 mIoU 的涨跌可靠得多——一次改动如果只让 mIoU 涨了但某个关键类别掉了我会先怀疑评估出了问题而不是模型真的变好了。这个习惯帮我避过不少发布前的翻车希望帮到你。本文还有配套的精品资源点击获取
返回列表