语义分割mIoU评测:基于混淆矩阵的逐类别IoU实现与避坑
简介:面向语义分割模型评估场景的轻量级代码包,适合需要按类别统计分割精度、复核模型表现的学生与算法开发者使用,重点解决预测图与标注掩码之间缺乏统一评估口径的问题。压缩包共2个文件,均为Python脚本,体积约4KB,分别承担预测结果生成与mIoU指标计算两类职责,结构紧凑,便于直接阅读和二次修改。已有7163人学习,说明该类评估工具在分割任务中具有较稳定的需求。包内围绕8位预测图与mask的比对展开,可得到各类别mIoU值以及整个测试集的整体mIoU,帮助读者快速定位类别间精度差异、发现长尾类别或难分样本的问题。代码依赖简单、逻辑集中,适合作为语义分割项目的评估补充,也便于在此基础上接入自己的数据与模型输出,形成可复用的精度分析流程。
1. 语义分割的mIoU:别让一个平均值把问题盖过去
跑语义分割的人大概都撞过这个场景:训练日志里 mIoU 一路从 0.72 爬到 0.80,看着挺稳,可把验证集的预测 mask 拉出来一看,小目标类别基本糊成一团,有的类干脆整片消失。原因不玄学——mIoU 是各类别 IoU 的算术平均,一个占比七成的大类就能把整体分数撑住,小类哪怕 IoU 掉到 0.1,平均下来也只少几分之一。这份围绕 mIoU pytorch 实现的资源,讲的就是把评价指标从「一个数」拆成「一张表」:用混淆矩阵跨 batch 累计每个类的 TP/FP/FN,逐类别算 IoU 再平均,顺带把 ignore_index、类别名对齐、多卡累计这些容易被忽略的细节一起兜住。它适合已经能跑通分割训练、但想真正看懂模型在哪个类别上翻车的开发者。
2. 整体设计:从logits到混淆矩阵的模块划分
一套靠谱的指标模块,第一原则是「先攒后算」,而不是边跑边加。这一章先把目录摆出来,再讲清楚每个文件负责哪一段链路,最后把配置抽出来,避免类别名散落在各个脚本里。
2.1 为什么先攒混淆矩阵,而不是边算边加
很多人的第一版 mIoU 是这样写的:每个 batch 算出 pred 和 label,当场算一次 IoU,存进列表,跑完取平均。这个写法直观,但结果会跟着 batch size 和样本顺序飘。举个具体的例子,某个小类在 batch A 里只有 3 个像素,算出来 IoU 可能 0.33;到了 batch B 里一个像素都没有,union 为 0,是记 0 还是跳过?两种处理方式最后的平均值能差好几个点。更要命的是,这个差异每次重跑都可能不一样,调参的时候你根本分不清是模型变好了还是指标算歪了。
合格做法是把所有 batch 的预测和 GT 压成一张混淆矩阵(confusion matrix)累加,整个验证集跑完只算一次 IoU。混淆矩阵是 C×C 的方阵,行对应 GT 类别,列对应预测类别,对角线是真阳 TP,某一行非对角是漏检 FN,某一列非对角是误检 FP。矩阵攒对之后,每个类的 IoU 都能从这张表里推出来,跟 batch 怎么切、样本以什么顺序喂都没关系。这也是大多数分割框架内部的做法,只是藏在 evaluator 里没露出来。
2.2 模块划分与数据流
资源里的目录按职责拆开,指标计算和训练脚本完全解耦,换数据集只动配置不动代码:
metric_kit/ ├── metrics/ │ ├── __init__.py │ ├── confmat.py # ConfusionMatrix 类,负责跨 batch 累计 │ ├── iou.py # 从矩阵算逐类 IoU 和 mIoU │ └── evaluator.py # 训练/验证循环的胶水层 ├── configs/ │ └── dataset.yaml # 类别名、num_classes、ignore_index └── tools/ └── eval_demo.py # 离线评估脚本,可单独跑数据流一句话能说清:模型输出 logits(N, C, H, W),argmax 得到(N, H, W)的预测索引,和同形状的 label 一起展平成一维,过滤掉 ignore 像素,用一次bincount把索引对编码进长度C*C的数组,reshape 成C×C累加。验证结束后,iou.py从矩阵里取对角线、行和、列和,一次算出所有类的 IoU,最后nanmean得到 mIoU。
| 张量 | 形状 | 含义 |
|---|---|---|
| logits | (N, C, H, W) | 模型原始输出,未过 softmax |
| pred | (N, H, W) | argmax(dim=1)后的类别索引,int64 |
| label | (N, H, W) | 像素级 GT,int64,可能含 ignore_index |
| hist | (C, C) | 混淆矩阵,行 GT、列预测,int64 累计 |
提示:
eval_demo.py单独保留是有意义的。训练中途怀疑指标不对时,拿一份预测结果丢进去跑,能快速把「模型问题」和「指标代码问题」分开,不用重训。
2.3 配置先行:类别表与ignore_index写在一处
类别名和类别索引错位,是分割项目里最隐蔽的坑之一。模型里 category_id 是 0 到 18,报表里按字母序排一遍,road 就对到了 sky 上,IoU 表看着正常其实全错位。资源把这份对应关系收进 YAML:
num_classes: 19 ignore_index: 255 classes: - road - sidewalk - building - wall - fence - pole - traffic_light - traffic_sign - vegetation - terrain - sky - person - rider - car - truck - bus - train - motorcycle - bicyclenum_classes决定混淆矩阵的边长,也决定bincount的minlength;ignore_index是数据集里表示「不参与评估」的像素值,在 Cityscapes 风格的数据里一般是 255,ADE20K 一般是 0,这个值必须和 dataset 的 transform 保持一致,否则 ignore 像素会被当成一个真实类别参与计算,mIoU 直接被拉低。classes的顺序必须和模型输出的通道顺序严格对齐,建议直接从训练时的class_names导出一份,别手敲。代码里读配置一般是这样:
import yaml def load_metric_config(path): with open(path, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) # 三者一致性校验,早失败比晚失败便宜 assert cfg["num_classes"] == len(cfg["classes"]), "类别数与类别表长度不一致" assert 0 <= cfg["ignore_index"] < 256, "ignore_index 超出常见取值范围" return cfg这段没什么技术含量,但校验不能省。num_classes和classes长度对不上,混淆矩阵会在 reshape 那一步报一个跟根因毫无关系的错,排查起来很费时间。ignore_index的取值校验则是为了拦住「拿 255 当有效类别」这类低级错误。
3. 核心模块拆解:一次argmax加bincount的完整链路
这一章把链路拆成四段:先把 logits 和 label 对齐到同一分辨率,再 argmax 拿到预测索引,然后用bincount填矩阵,最后封装成可以反复 update 的类。每一段都有明确的失败点,跟着代码走一遍,基本能覆盖 90% 的现场问题。
3.1 形状对齐:logits和label必须先对齐到同一分辨率
模型在做深层下采样之后,输出分辨率经常是输入的一半甚至四分之一。训练时用interpolate上采样回原图算 loss,验证时如果忘了这一步,argmax出来的 pred 和 label 形状就不一致,展平之后长度不同,bincount那一步会直接炸,或者更糟——在广播规则下悄悄算出一个错的矩阵。
import torch import torch.nn.functional as F def logits_to_pred(logits, label): # logits: (N, C, H, W),模型原始输出,不要提前 softmax # label: (N, H, W),int64 if logits.dim() != 4: raise ValueError(f"logits 期望 4 维 (N,C,H,W),收到 {tuple(logits.shape)}") if label.dim() != 3: raise ValueError(f"label 期望 3 维 (N,H,W),收到 {tuple(label.shape)}") if logits.shape[-2:] != label.shape[-2:]: # 训练/验证里很常见:输出是 1/2 或 1/4 分辨率 logits = F.interpolate( logits, size=label.shape[-2:], mode="bilinear", align_corners=False, ) return logits.argmax(dim=1) # (N, H, W),dtype=torch.int64逻辑上没什么花活,关键是两个检查。dim检查拦住通道维度被 squeeze 掉的情况,shape检查拦住忘上采样。上采样模式用bilinear还是nearest,实测对 mIoU 的影响在小数点后两位,跟很多框架默认的bilinear保持一致就行;align_corners建议False,这是 PyTorch 新版本的推荐值,和老实现对齐时记得确认一下。argmax 之前不要 softmax——argmax 对单调变换不敏感,softmax 纯属浪费算力,而且数值上还可能引入溢出。
3.2 类型与设备:搬到CPU再转numpy,别在GPU上做bincount
混淆矩阵累计这一步,个人更倾向放在 CPU 上用 numpy 做:np.bincount在 CPU 上是纯 C 实现,一次处理几百万像素也就几毫秒,比在 GPU 上折腾scatter_add省心得多。验证阶段本来就不追求极致吞吐,把 pred 和 label 从 GPU 拷下来的开销可以接受。
def to_numpy_1d(x): # 接受 tensor 或 ndarray,统一成展平的一维 int64 numpy 数组 if torch.is_tensor(x): x = x.detach().cpu().numpy() x = x.astype("int64", copy=False) return x.reshape(-1)两个细节值得说。detach()不能漏,验证时如果忘了torch.no_grad(),pred 还挂在计算图上,直接.numpy()会报错;astype("int64")是必须的,因为bincount不接受 float 输入,而有些模型输出经过argmax之后 dtype 是int32,在索引乘法那里可能溢出。再一个,展平要在 numpy 侧做,别在 tensor 侧.view(-1)完再拷,两种写法等价但后者更容易在非连续张量上报错。
3.3 fast_hist:一次bincount把C×C矩阵填出来
整个模块里最巧的一步就是这个索引编码:把「GT 类别 i、预测类别 j」这个二元组,映射成i * C + j这个整数,然后bincount数每个整数出现了多少次,reshape 回来就是混淆矩阵。
import numpy as np def fast_hist(pred, label, num_classes, ignore_index=255): # pred/label: 已展平的一维 int64 数组 # 返回 (num_classes, num_classes),行=GT,列=预测 mask = (label >= 0) & (label < num_classes) & (label != ignore_index) idx = num_classes * label[mask] + pred[mask] hist = np.bincount(idx, minlength=num_classes ** 2) return hist.reshape(num_classes, num_classes)mask三重条件分别是:类别索引非负、不越界、不等于 ignore。label != ignore_index这一条是血泪经验——一些框架的ignore_index靠近类别数边界,比如 255 配 19 类,如果不专门过滤,label >= num_classes已经能拦住它,但如果ignore_index恰好是 0(ADE20K 的情况),label >= 0就拦不住了,必须单独判。minlength保证即使某几个索引没在 batch 里出现,输出数组长度也是C*C,reshape 不会错位。
举个手算例子核对一下。假设C=3,有一个像素 label=1、pred=2,那么idx = 3*1 + 2 = 5,在长度为 9 的数组里位置 5 加一,reshape 成 3×3 后正好落在第 2 行第 3 列,也就是「GT 是类 1、预测成类 2」,方向正确。方向搞反是这块最常见的错,行换成预测、列换成 GT,IoU 公式里的 TP/FP/FN 全部跟着错,但数值量级看起来还挺正常,非常难发现。
3.4 ConfusionMatrix类:update、reset和持久化
单次计算用fast_hist就够,跨 epoch 复用则需要往上包一层,把矩阵、类别数、ignore_index 收进状态里。
class ConfusionMatrix: def __init__(self, num_classes, ignore_index=255, device="cpu"): self.num_classes = num_classes self.ignore_index = ignore_index self.device = device self.mat = np.zeros((num_classes, num_classes), dtype=np.int64) def update(self, pred, label): pred = to_numpy_1d(pred) label = to_numpy_1d(label) if pred.shape[0] != label.shape[0]: raise RuntimeError( f"pred/label 像素数不一致: {pred.shape[0]} vs {label.shape[0]}" ) self.mat += fast_hist(pred, label, self.num_classes, self.ignore_index) def reset(self): self.mat.fill(0) def state_dict(self): # 训练中断续跑、多卡汇总都要靠这个 return {"mat": self.mat.copy(), "num_classes": self.num_classes} def load_state_dict(self, state): assert state["num_classes"] == self.num_classes self.mat = state["mat"].copy()update里的形状校验是给分布式和 DataParallel 兜底——多卡拼接后的张量如果被某次操作打平过,像素数会莫名其妙对不上,早抛错比默默算出个错矩阵好。state_dict/load_state_dict是为了「前几天验证集的指标还想接着看」和「DDP 结束时把各 rank 的矩阵求和」这两个场景,直接加两个方法比到时候临时改代码强。device参数目前没实际用上,留着是为了以后真要在 GPU 上做scatter_add时有地方挂。
4. 逐类别IoU计算:向量化、除零处理与结果导出
矩阵攒好之后,剩下的事就是从这个 C×C 方阵里把每个类的 IoU 提出来。这一章先说公式怎么落到矩阵的行列求和上,再处理最容易被忽略的除零情况,最后把结果导出成一张能贴进实验记录的报表。
4.1 IoU公式落地:TP、FP、FN从矩阵里取
一个类的 IoU 定义是交集除以并集:IoU_i = TP_i / (TP_i + FP_i + FN_i)。落到混淆矩阵上,这三个量分别对应三类求和:
TP_i = hist[i, i],对角线元素。FP_i = hist[:, i].sum() - hist[i, i],第 i 列除了对角线之外的所有元素。FN_i = hist[i, :].sum() - hist[i, i],第 i 行除了对角线之外的所有元素。
写成向量化形式,一行算完所有类:
import numpy as np def compute_iou(hist, eps=1e-10): # hist: (C, C),行=GT,列=预测 tp = np.diag(hist).astype(np.float64) fp = hist.sum(axis=0).astype(np.float64) - tp fn = hist.sum(axis=1).astype(np.float64) - tp union = tp + fp + fn # union=0 表示该类在 GT 和预测里都没出现,这是无效类而非 IoU=0 valid = union > 0 iou = np.full(hist.shape[0], np.nan, dtype=np.float64) iou[valid] = tp[valid] / np.maximum(union[valid], eps) return iou这里用np.diag取对角线,用axis=0和axis=1分别取列和行,全程没有 for 循环,C=150 的 ADE20K 也几乎不花时间。np.maximum(union, eps)是为了防极端情况下浮点比较把非零判断漏掉,虽然valid已经挡了一层,双保险不亏。
4.2 分母为零:nan、0还是跳过
valid那个布尔数组是整个函数的关键。一个类别在验证集的 GT 里没出现、模型也没预测过它,union就是 0,这时有三种处理方式:记 0、记 1、记为 nan 并排除。三种方式对 mIoU 的影响很不一样。
记 0 会严重惩罚那些样本本来就稀少的类——明明模型没机会预测,却要按最差成绩算,mIoU 被无故拉低;记 1 又太宽松,会让一个「没出现所以没错」的类冒充完美,把 mIoU 虚高。比较公认的做法是排除:用np.nan标记,汇总时用np.nanmean而非np.mean。
def summarize(iou, class_names=None): miou = float(np.nanmean(iou)) per_class = {} for idx, val in enumerate(iou): name = class_names[idx] if class_names else str(idx) per_class[name] = None if np.isnan(val) else float(val) return miou, per_class注意:
np.mean碰到 nan 直接返回 nan,一不小心整个 mIoU 就成了 nan。汇总一律用nanmean,并且在 CI 里加一条断言拦住。
顺带一提,除了标准 mIoU,有些论文还会报 FWIoU(按类别频率加权),或者只对「出现过的类」取平均的 mIoU。前者只要在summarize里再加一个按hist.sum(axis=1)加权的分支就行,后者用np.nanmean天然就是这个语义。关键是报表里写清楚用的是哪一种,别自己改了口径忘了。
4.3 结果导出:类别名表与逐类报告
指标算完,最后一步是把数字变成人看得懂的表。原始 IoU 数组直接打印出来就是 19 个小数,根本对不上是哪个类,必须和configs/dataset.yaml里的classes拼在一起。
def format_report(iou, class_names, sort_desc=True): rows = [] for name, val in zip(class_names, iou): rows.append({ "class": name, "iou": None if np.isnan(val) else round(float(val), 4), }) if sort_desc: rows.sort(key=lambda r: (-1 if r["iou"] is None else r["iou"])) miou = float(np.nanmean(iou)) return rows, round(miou, 4)排序是个小技巧:按 IoU 从低到高排列,最差的类一眼就能看到,调参时优先盯它。导出的 markdown 表格可以直接贴进实验记录,也可以落到 JSON 里给前端看板用。到这一步,一个完整的「输入预测 → 累计矩阵 → 逐类 IoU → mIoU」链路就闭环了,只剩把它接进训练流程。
5. 避坑与排查:mIoU统计里最容易踩的五个坑
指标代码本身不长,但每一步都有独立的失败模式,而且失败的时候通常不报错,只是数字悄悄偏了。下面这五条是按实际踩坑频率排的,每条都按「现象 → 原因 → 解决」写,方便现场对照。
5.1 每个batch算完再取平均,分数随batch size飘
现象:同一个模型、同一份验证集,batch size 从 8 改成 4,mIoU 能差 1 到 3 个点,重跑几次数值还不完全复现。
原因:每个 batch 单独算 IoU 再取平均,等于把平均值又平均了一次,小类在某些 batch 里根本没出现,union=0 时记 0 还是跳过取决于当时的写法,权重也被 batch 切分方式打乱。
解决:统一改成ConfusionMatrix跨 batch 累计,整个验证集跑完只算一次 IoU。这条是最容易改也最容易被忽略的,改完之后 mIoU 的数字会变得可复现,调参才有意义。
5.2 ignore_index漏配,mIoU莫名接近0
现象:模型训练正常,验证 loss 也正常,但 mIoU 只有零点零几,某个不存在的类 IoU 极低。
原因:dataset 里用了 255 表示 ignore,指标模块的ignore_index默认却是 0 或 19,255 被label >= num_classes拦住后又没走 ignore 分支,索引越界或者矩阵形状对不上,最后整块误差被算进某一行。
解决:让ignore_index从 YAML 读取,并且在fast_hist的 mask 里同时判>= 0、< num_classes、!= ignore_index三条。跑之前用一小份数据手算标准答案,对数一下。
5.3 类别顺序错位,road对到了sky
现象:mIoU 看起来正常,但 car、truck、bus 这几个外观相近的类 IoU 集体偏低,road 反而偏高,目视预测 mask 明显是对的。
原因:configs/dataset.yaml里的classes顺序和模型输出的通道顺序不一致,或者中途改过数据集映射但没同步配置。混淆矩阵的行列标签全错位,但整体对角线仍然集中在语义相近的类上,所以数字看起来「合理」。
解决:类别名直接从 dataset 的class_names导出,禁止手写;再加一个单测——喂一张全 0 的预测、一张全 0 的 label,断言hist[0,0]等于像素数,能立刻发现顺序错位。
5.4 多卡DDP下各进程各算各的
现象:4 卡验证,每张卡打印的 mIoU 各不相同,主进程汇总出来的数值偏低或者偏高,和单卡重跑对不上。
原因:每个 rank 只拿到1/N的数据,各自算出的混淆矩阵只覆盖了一部分验证集;如果不做归约,主进程用的就是自己那份局部结果。
解决:每个 rank 维护自己的ConfusionMatrix,epoch 结束时用torch.distributed.all_reduce把矩阵求和,再在 rank 0 上算 IoU。前面state_dict/load_state_dict就是为这一步留的接口。注意要归约矩阵本身,不能归约各 rank 算出的 mIoU 再平均,因为各 rank 的类别分布不同,平均之后得到的不是全局指标。
5.5 验证集用了随机crop,指标来回抖
现象:每个 epoch 的 mIoU 曲线锯齿明显,同一模型连续两个 epoch 能差 2 个点。
原因:验证阶段沿用了训练时的 RandomCrop 或 RandomResize,每个 epoch 看到的其实是验证集的不同子集,指标自然抖。
解决:验证 transform 里只保留 Resize、CenterCrop、Normalize 这类确定性操作。如果显存装不下整图,用滑窗推理并保证窗口重叠区域只计一次,别在验证集上用随机策略。
6. 二次开发:把指标模块接进训练循环和可视化看板
指标模块封好之后,接进训练循环就是几行代码的事,但接的位置和时机决定了它值不值得信任。推荐的做法是验证 epoch 开始时reset,每个 batchupdate,结束后统一算 IoU 并推 TensorBoard。
from metric_kit.metrics import ConfusionMatrix, compute_iou, summarize from torch.utils.tensorboard import SummaryWriter val_metric = ConfusionMatrix(num_classes=cfg["num_classes"], ignore_index=cfg["ignore_index"]) writer = SummaryWriter(log_dir=f"runs/{exp_name}") for epoch in range(start_epoch, num_epochs): model.eval() val_metric.reset() with torch.no_grad(): for images, labels in val_loader: images = images.cuda(non_blocking=True) labels = labels.cuda(non_blocking=True) logits = model(images) pred = logits_to_pred(logits, labels) val_metric.update(pred, labels) # 如果是DDP,这里先 all_reduce 求和再算 iou = compute_iou(val_metric.mat) miou, per_class = summarize(iou, class_names=cfg["classes"]) writer.add_scalar("val/mIoU", miou, epoch) for name, val in per_class.items(): if val is not None: writer.add_scalar(f"val/iou_{name}", val, epoch) print(f"[epoch {epoch}] mIoU={miou:.4f}")想把逐类指标看得更细,可以在summarize之后加一段「Top-K 最差类别」的逻辑,训练完自动把最差的 3 类打出来;配合add_scalar的iou_xxx曲线,哪个类在哪一轮开始掉,一眼就能定位。这套改造我在几个项目里都保留着,比只看 mIoU 曲线有效得多——有一次 mIoU 整体持平,但 person 类的 IoU 从 0.7 掉到 0.5,追下去发现是数据增强里的随机裁剪把行人裁成了半截,这种问题只盯平均值永远发现不了。从那以后我每次验证都强制把逐类 IoU 推上去,不推不让跑。希望帮到你。
本文还有配套的精品资源,点击获取