简介:面向使用PyTorch开展语义分割实验的研究者与开发者,这套脚本能够高效计算各类别IoU与整个测试集的平均IoU,解决分割模型评估中指标计算繁琐、难以直观查看每个类别表现的问题。压缩包内共包含2个Python文件,整体仅4KB,其中一个脚本负责将分割结果输出为8位预测图,另一个脚本将其与真值mask对比,逐类别统计并汇总mIoU,两个脚本均可在PyCharm中直接运行,接入现有项目十分便捷。整个资源已有7157人学习下载,其简洁性和实用性在同类资源中受到较多关注。借助这组工具,读者无需从零编写评估代码,即可快速获得各类别的IoU数值,直观判断模型对每个类别的分割效果,并据此定位易混淆类别、辅助针对性调优,例如调整类别权重或扩充训练样本;同时可结合作者配套博客理解计算原理与细节,便于在论文实验或工程中灵活复用。 做语义分割的人,没几个没被mIoU虐过。我最早在遥感影像语义分割复现时,把各类别IoU算出来,发现结果和自己预想的完全不一样——背景类IoU高得离谱,目标类却惨不忍睹。后来才明白,不是模型不行,是mIoU的计算方式里藏着太多细节。这篇就从各类别mIoU的计算原理开始,手把手把混淆矩阵和代码给你捋清楚,再把我自己在GEE场景和本地训练中踩过的坑一并倒出来,希望对正在做语义分割复现或者写评估脚本的人有点用。
mIoU(Mean Intersection over Union)是语义分割最通用的评估指标,但很多人对它的理解只停留在“IoU取平均”这一层。真正落地时,你会发现逐类IoU怎么算、平均时包不包括背景、混淆矩阵怎么累加,都会直接影响最终数值。要把这个指标算准,得先从混淆矩阵说起。
1. mIoU计算的底层逻辑:从混淆矩阵到逐类平均
1.1 混淆矩阵:所有指标的地基
语义分割本质上是对每个像素做分类。假设一共有K个类别(包含背景),对于一个测试集,我们把模型预测的每个像素标签和真实标签做对比,就能统计出一个K×K的矩阵,这就是混淆矩阵(Confusion Matrix)。
矩阵的第i行第j列,表示真实类别为i、但被预测成类别j的像素数量。对角线上的元素就是分类正确的像素数。很多初学者直接拿sklearn的confusion_matrix函数一算就完事,但那个函数默认的标签顺序、以及是否包含预测中未出现的类别,都可能给你埋雷。我自己更习惯自己写累加逻辑,权当是给整个评估流程上了一道保险。
有了混淆矩阵,各类别IoU的定义就非常直观。对于第i个类别,它的IoU等于:
[ IoU_i = \frac{TP_i}{TP_i + FP_i + FN_i} ]
其中(TP_i)是真实为i且预测为i的像素数,即混淆矩阵对角线(M_{ii});(FP_i)是真实不是i但预测为i的像素数,也就是第i列除对角线外的元素之和;(FN_i)是真实为i但预测为其他类别的像素数,也就是第i行除对角线外的元素之和。
用一个简单例子说明:二分类(背景0、目标1)中,如果真实目标有100个像素,预测正确了80个,还有20个目标被预测成了背景,同时有10个背景像素被预测成了目标,那目标的IoU就是80 / (80 + 10 + 20) = 0.727。这个数可以理解为“预测区域和真实区域的交集除以并集”,交并比越高,说明模型对该类的空间重合度越好。
1.2 各类别IoU的计算公式与平均方式
计算各类别IoU有两种常见写法,但结果完全等价。第一种是按分类指标定义拆TP/FP/FN;第二种更简洁,直接利用混淆矩阵的行列求和:
[ IoU_i = \frac{M_{ii}}{\sum_{j} M_{ij} + \sum_{j} M_{ji} - M_{ii}} ]
这里分母之所以要减去一个(M_{ii}),是因为行和列求和时都把对角线算了一遍,而实际并集应为“预测为正的像素数 + 真实为正的像素数 - 预测且真实为正的像素数”。我更喜欢这第二种写法,因为它直接基于矩阵,代码上不容易漏掉细节。
mIoU则是所有类别IoU的算术平均:
[ mIoU = \frac{1}{K} \sum_{i=1}^{K} IoU_i ]
注意这里有一个非常关键的差异:有的实现把所有像素的TP、FP、FN先各自加起来,再算一个总的IoU,得到的是全局IoU(global IoU),而不是逐类IoU的平均。全局IoU会被像素数量多的类别主导,比如背景占95%的遥感影像,背景算对了全局IoU就会很高,但小目标类别的表现会被掩盖。mIoU之所以叫“mean”,是因为它先逐类计算再平均,对每个类别一视同仁,这才是论文里通用的mIoU。
2. 手写mIoU计算代码:从零实现到工程化封装
2.1 基础实现:用numpy手撕逐类IoU
很多语义分割框架自带评估函数,但自己写一遍有助于理解内部逻辑,而且遇到特殊需求时更容易改。下面是一个基于numpy的纯Python实现,假设预测和标签都是形状为(H, W)的整数数组,类别编号从0到num_classes-1。
import numpy as np def compute_mIoU(pred, label, num_classes, ignore_index=None): """ 计算逐类IoU和平均mIoU pred: 预测标签数组 (H, W),每个像素为类别编号 label: 真实标签数组 (H, W) num_classes: 类别总数(包含背景) ignore_index: 需要忽略的像素值列表,比如[255]或边界标注 """ # 先筛选出需要评估的像素 mask = np.ones(label.shape, dtype=bool) if ignore_index is not None: for idx in ignore_index: mask &= (label != idx) & (pred != idx) pred = pred[mask] label = label[mask] # 初始化混淆矩阵 conf_matrix = np.zeros((num_classes, num_classes), dtype=np.int64) # 用np.add.at做累加,比双重循环快很多 np.add.at(conf_matrix, (label, pred), 1) # 计算每个类别的IoU ious = [] for i in range(num_classes): tp = conf_matrix[i, i] fn = conf_matrix[i, :].sum() - tp fp = conf_matrix[:, i].sum() - tp union = tp + fp + fn if union == 0: # 该类别在样本中完全不存在,一般有两种处理方式: # 1. 忽略该类别,不参与平均(常用) # 2. 将IoU设为1,表示完美预测(有时见到,但不推荐) ious.append(float('nan')) else: ious.append(tp / union) # 忽略nan后计算平均 valid_ious = [iou for iou in ious if not np.isnan(iou)] miou = np.mean(valid_ious) if valid_ious else float('nan') return ious, miou, conf_matrix这个函数返回三个内容:各类别IoU列表、mIoU、混淆矩阵。用的时候可以这样:
pred = np.array([[0, 0, 1], [0, 1, 1], [2, 2, 2]]) label = np.array([[0, 0, 1], [0, 1, 2], [2, 2, 2]]) ious, miou, cm = compute_mIoU(pred, label, num_classes=3) print("各类别IoU:", ious) print("mIoU:", miou) print("混淆矩阵:\n", cm)这里有一个小细节:np.add.at比conf_matrix[label, pred] += 1更安全。因为后者在遇到重复索引时只会累加最后一次,这类bug非常隐蔽,可能会让你得到完全不合理的mIoU却找不到原因。
2.2 处理忽略标签与边界状况
实际项目中,训练数据的标签经常不是只包含0到C-1。比如遥感影像标注中,常常用255表示“无法确定”的区域,或者用0既表示背景又表示忽略区域。这些像素如果不移除,会干扰混淆矩阵统计。
因此在计算前,必须显式定义哪些像素值不参与评估。我的做法是维护一个ignore_index集合,将真实标签或预测标签等于这些值的像素全部去掉。需要注意,如果真实标签是忽略值但预测不是,或者反过来,这类像素也应当被忽略,否则统计会错乱。上面的代码用(label != idx) & (pred != idx)同时过滤两边,就是这个原因。
另一个容易踩的边界状况是“某类别完全缺失”。比如测试集中没有“水域”这个类别,那么水域的TP、FP、FN都为0,分母为0。很多框架会直接将该类IoU记为0,但这会拉低mIoU,导致评估结果不稳定;更合理的做法是忽略该类别,不参与平均。但论文复现时,最好看一下原作者用的是什么策略,有些数据集固定包含所有类别,有些则不是。
我习惯在返回mIoU的同时,把每个类别的样本像素数也打印出来,这样能快速定位“某个类别像素太少导致mIoU波动”的情况。
3. 遥感语义分割中mIoU计算的真实难点
3.1 类别不均衡:少数类IoU被严重稀释
遥感影像语义分割是mIoU应用的“重灾区”。原因很简单:地物类别极度不均衡。比如一张大范围遥感图上,建筑、道路可能只占几个百分点,而植被、水体可能占了大半。逐类平均看似公平,但少数类由于像素很少,模型稍有偏差,IoU就会剧烈波动。
举个例子,某个类在验证集中只有1000个像素,模型只预测对了500个,又额外猜错了300个,IoU就是500/(500+500+300)=0.385;如果模型多预测对了100个,IoU能跳到0.526。而一个占100万像素的背景类,即使模型出了几千个像素的偏差,IoU可能只是从0.98变成0.97。所以只看mIoU,你根本不知道模型在少数类上到底行不行。
在做遥感影像语义分割复现时,我会额外计算每个类别的“像素占比”和“类别IoU”,并画一个柱状图来看。如果某个小类的IoU比其他类低20个点以上,那通常不是调参的问题,而是样本不平衡导致的,需要考虑加权损失、重采样或者换评估视角(比如F1-score)。
3.2 大规模影像评估的性能优化:GEE场景下的挑战
谷歌地球引擎(GEE)里做语义分割验证,经常会遇到一个现实问题:影像太大,像素数量动辄上亿,不能直接把全部标签和预测加载到本地算mIoU。这时候可以利用GEE的reduceRegion或者编写ee.Reducer.confusionMatrix来统计混淆矩阵。
GEE里通常先把预测结果和真实标签叠加,然后对类别进行分层采样或全量聚合。比如:
var confusionMatrix = errorMatrix.train({ classifier: classifier, features: testFeatureCollection, label: 'landcover', // 这里需要确保预测属性存在 }); print('Confusion Matrix:', confusionMatrix); print('Accuracy:', confusionMatrix.accuracy());但要注意,GEE自带的confusionMatrix.accuracy()是整体精度,它不直接给你逐类IoU。你需要从混淆矩阵里自己提取行和列,再按前面公式计算。另外,GEE中每个Tile的计算有大小限制,如果直接对所有像素做聚合,可能会超时。我一般会随机采样或者分块reduce,然后累加局部混淆矩阵,最后在本地计算mIoU。
还有一点,GEE里影像的投影和分辨率经常不一致。预测结果和真实标签如果分辨率不同,直接比较像素就会出现对齐偏差,导致IoU偏低。处理时必须用resample('bilinear')或reduceResolution统一到同一格网,最好用最近邻方式重采样标签,避免引入混合像素。
4. 论文复现中mIoU对比的常见翻车点
4.1 “全局平均”与“逐类平均”的混用
复现论文时,最让人头疼的莫过于“明明模型效果不差,但mIoU就是比论文低好几个点”。我排查过无数次,很大概率是评估代码的统计口径不一致。
有的论文报告的是“逐类IoU的均值”(standard mIoU),有的则为了突出效果,会报告“所有像素全局IoU”甚至“前景类别平均IoU”(忽略背景)。如果你用全局IoU去对比论文的逐类mIoU,数值自然有差异。更隐蔽的是,有些公开代码里使用的mIoU函数其实是从某个旧项目拷贝来的,根本没有逐类平均,只是把混淆矩阵对角线求和除以总和。这种实现如果类间像素数悬殊,结果会明显偏高。
我给你的建议是:在复现前,先看论文的评估协议里是否写了“per-class IoU”还是“global IoU”,再看官方评估脚本是否有指定。如果都没有,就默认用逐类IoU平均,这也是绝大多数语义分割benchmark的标准做法。
4.2 混淆矩阵计算不一致导致的差异
即使都叫“逐类平均”,实现细节还会造成差异。比如:
- 是否忽略某些未知类别(如255);
- 每个类别在softmax中是否使用argmax取索引;
- 多尺度预测时是否先上采样再argmax;
- 混淆矩阵是按整张图统计,还是按滑窗块统计再求和;
- 使用了不同的边界处理方式。
其中,上采样和argmax的顺序特别容易出错。很多模型输出的是1/4或1/8分辨率的特征图,需要先插值回原图尺寸,再和标签比较。如果你先对低分辨率特征图argmax,再用最近邻放大,那么类别边界会产生锯齿,和真实标签的空间对齐会变差,mIoU可能下降1-2个点。正确做法一般是:先对概率图做双线性上采样到标签分辨率,然后再取argmax。
还有一个容易被忽视的点:混淆矩阵的dtype。如果像素数特别多,比如遥感大图超过21亿像素,int32都可能溢出,需要用int64。我的代码里统一用np.int64,避免这种莫名其妙的负值出现。
5. 常见问题与排查技巧实录
5.1 问题速查表
下面这张表是我在给项目做评估时遇到最多的问题,直接对照排查会很省时间。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| mIoU比论文低很多 | 评估口径不一致,或没忽略ignore区域 | 确认per-class IoU,过滤ignore_index |
| mIoU忽高忽低,不同batch差异大 | 小类别像素太少 | 打印各类别像素数,评估时尽量用完整验证集 |
| 某个类别IoU恒为0 | 该类在预测中从未出现,或标签映射错误 | 检查类别ID是否从0开始连续;检查训练时类别权重 |
| 混淆矩阵出现负数 | 数据类型溢出 | 使用int64累加 |
| 结果和sklearn计算结果不一致 | 混淆矩阵行列顺序或ignore处理不同 | 按自己的代码为准,但需明确说明统计方式 |
| 遥感大图评估超时 | 所有像素一次性计算 | 分块累加混淆矩阵或随机采样 |
5.2 几个我用了很久的独家技巧
第一个技巧是“逐类IoU的平滑”。在验证集像素极少的情况下,直接计算IoU会得到0或者波动很大的值。我常用的做法是采用“拉普拉斯平滑”的变体:给TP、FP、FN各加一个小常数(比如1e-6)。但这个只用于调试,不用于最终报告,否则和论文数值不可比。
第二个技巧是可视化混淆矩阵。每次训练完,我用seaborn的heatmap把归一化后的混淆矩阵打出来,对角线越亮越好。但更重要的是看“某类被错分到了哪类”。比如遥感里建筑经常被分到阴影,道路被分到裸土,这些模式通过混淆矩阵一眼就能看出来,比单纯看mIoU数字有用得多。
第三个技巧是评估流程的“单向确定性”。在复现中,我会固定随机种子、固定数据加载顺序、固定模型推理的batch size,确保每次跑出来的混淆矩阵完全一致。否则你调了一个小参数,mIoU涨了0.3个点,你都不知道是改进还是随机波动。
最后再分享一点:如果你在研究新的分割网络,可以额外关注一下各类别IoU的分布,而不是只盯着mIoU。很多时候mIoU提升1个点,可能是背景类从0.99变到0.991撑起来的,而你真正关心的目标类一点没涨。把逐类IoU打印出来,分析一下瓶颈在哪,然后针对性地设计损失函数或者数据增强,这条路才是做语义分割最扎实的提点方式。
本文还有配套的精品资源,点击获取