语义分割评价指标梳理与代码实现记录

最近在处理烟雾分割任务时,发现现有的语义分割评估指标在应对特定分布(如目标极其微小、类别极度不平衡)时存在一些统计上的盲区。为了更客观地衡量模型性能,我对常用的评价指标进行了梳理,并记录了在 MMSegmentation 框架下实现自定义烟雾分割评价指标的过程。

一、通用语义分割评价指标复习

在语义分割中,预测结果通常被视为逐像素的分类问题。因此,评价指标主要围绕混淆矩阵展开:

  • TP (True Positive):正确预测为正样本的像素数。
  • TN (True Negative):正确预测为负样本的像素数。
  • FP (False Positive):错误预测为正样本的像素数。
  • FN (False Negative):错误预测为负样本的像素数。

1. 像素准确率 (Pixel Accuracy, PA)

这是最直观的指标,表示预测正确的像素在总像素中的占比。

PA=i=0kpiii=0kj=0kpij=TP+TNTP+TN+FP+FNPA = \frac{\sum_{i=0}^{k} p_{ii}}{\sum_{i=0}^{k} \sum_{j=0}^{k} p_{ij}} = \frac{TP + TN}{TP + TN + FP + FN}

2. 平均像素准确率 (mean Pixel Accuracy, mPA)

分别计算每个类别的像素准确率,然后再求取所有类别的平均值。

mPA=1k+1i=0kpiij=0kpijmPA = \frac{1}{k+1} \sum_{i=0}^{k} \frac{p_{ii}}{\sum_{j=0}^{k} p_{ij}}

3. 交并比 (Intersection over Union, IoU)

语义分割中最常用的核心指标,用于衡量特定类别的预测结果与真实标签(Ground Truth)交集和并集的比值。

IoU=TPTP+FP+FNIoU = \frac{TP}{TP + FP + FN}

4. 平均交并比 (mean Intersection over Union, mIoU)

计算所有类别的 IoU,并取综合平均值,以兼顾各类别目标区域的精准度与完整度。

mIoU=1k+1i=0kTPiTPi+FPi+FNimIoU = \frac{1}{k+1} \sum_{i=0}^{k} \frac{TP_i}{TP_i + FP_i + FN_i}


二、面向特定任务(烟雾分割)的评价指标

在烟雾分割等特定场景下,评价指标的计算逻辑通常会有两点区别:
第一,评价指标主要只针对“烟雾”这一个类别进行考察;
第二,为了应对数据分布的不平衡,往往会采用 Image-level(图像级别)的平均机制,而不是全局像素累加。

参考《Deep Smoke Segmentation》和《FoSp: Focus and Separation Network for Early Smoke Segmentation》等论文,我们通常使用以下五个核心指标:mIoU, mFbeta, mPrecision, mRecall, mMse

💡 概念澄清:前缀 “m” 的特殊含义
在此场景下,m 通常代表 mean(所有图像分数的均值),这与通用指标中代表的“所有类别的均值”有所区别。
其计算流程为:先在测试集的每一张图像上独立计算得出指标数值,最后将所有图像的得分加和并除以图像总数 nn

1. 图像级别核心指标的计算

对于单张图像,我们对比预测结果与 GT,得出单图级别的 TPTPFPFPFNFN

  • Precision (精确率): Precision=TPTP+FPPrecision = \frac{TP}{TP + FP}
  • Recall (召回率): Recall=TPTP+FNRecall = \frac{TP}{TP + FN}
  • IoU (交并比): IoU=TPTP+FP+FNIoU = \frac{TP}{TP + FP + FN}
  • Fbeta (FβF_\beta): 结合精确率和召回率,通常定义为 Fβ=(1+β2)×Precision×Recallβ2×Precision+RecallF_\beta = \frac{(1 + \beta^2) \times Precision \times Recall}{\beta^2 \times Precision + Recall}

计算完单图分数后,对数据集中的 nn 张图像求算术平均:

mIoU=1ni=1nIoUimIoU = \frac{1}{n} \sum_{i=1}^{n} IoU_i

2. 均方误差 (mMse) 指标

在对比网络输出的概率图时,mMse (mean Mean Squared Error) 可以直观反映概率分布的偏离程度。具体定义在不同研究中略有差异:

  • 常规设定:将网络输出经过 Sigmoid 激活并通过阈值处理后的二值化图,与二值化标签进行均方误差计算。
  • 变体使用:也有直接利用 [0,1][0,1] 连续概率图进行均方差计算,或者采用均方根误差 (RMSE)。

无论细节如何,其整体逻辑均是先计算单图均方误差,再求全集的均值

mMse=1ni=1nMseimMse = \frac{1}{n} \sum_{i=1}^{n} Mse_i


三、Global IoU 与 Image-level IoU 的差异分析

在学习过程中,我重点对比了全局平均 (Global Average) 与图像平均 (Image-level) 这两种统计方式的差异。在烟雾检测(特别是存在大量极小面积烟雾图像的数据集)中,如果使用默认的 Global IoU 计算方式,可能会掩盖模型在小目标上的真实表现。

统计方式的区别

  1. 全局计算 (Global):将整个数据集中所有的交集像素和并集像素进行统一累加,最后进行一次比值计算。

    IoUglobal=i=1nii=1niIoU_{global} = \frac{\sum_{i=1}^{n} 交集_i}{\sum_{i=1}^{n} 并集_i}

  2. 图像级别计算 (Image-level):如前文所述,先计算每张图像的 IoU 分数,再求取这些分数的平均值。

    IoUimagelevel=1ni=1n(ii)IoU_{image-level} = \frac{1}{n} \sum_{i=1}^{n} \left( \frac{交集_i}{并集_i} \right)

一个极端的测试用例

假设一个微型测试集只包含 2 张照片:

  • 图片 A (大面积目标):真实像素 10000。预测交集 9000,并集 11000。单图 IoUA81.8%IoU_A \approx 81.8\%
  • 图片 B (极小面积目标):真实像素 100。模型漏检,交集 0,并集 100。单图 IoUB=0%IoU_B = 0\%

对比结算结果:

  • Global 计算法9000+011000+10081.08%\frac{9000 + 0}{11000 + 100} \approx 81.08\%
  • Image-level 计算法81.8%+0%2=40.9%\frac{81.8\% + 0\%}{2} = 40.9\%

可以看到,Global 方法下图片 B 的漏检误差被图片 A 庞大的像素基数所掩盖。而 Image-level 方法能更直观地体现出模型在图片 B 上的检测失效,这正是为什么在类别极度不平衡或目标大小差异巨大的任务中,Image-level 评估能更客观地反映模型的平均处理能力。


四、MMSegmentation 自定义评估代码编写

由于 MMSegmentation 1.x 默认采用像素级全局累计的评价方式,为了引入 Image-level 的 Macro-Average 以及 RMSE/MSE 误差记录,需要自定义一套评价体系。eep Smoke Segmentation):将网络输出经过 Sigmoid 函数激活并通过阈值化处理后的二值化预测图**,与二值化的 GT 标签之间进行均方误差计算。

  • 变体 1(非阈值化):部分论文直接将仅经过 Sigmoid 激活后、值域在 [0,1][0,1] 之间的连续预测概率图,直接与二值化 GT 之间计算平方差。
  • 变体 2(均方根误差):也有一些论文将此指标定义为 RMSE,即在均方误差的基础上多开了一个根号。

但无论哪种变体,计算逻辑一律是先在每一张图片上独立求出单图误差(Mse),然后再除以 nn 取所有图像的均值

mMse=1ni=1nMseimMse = \frac{1}{n} \sum_{i=1}^{n} Mse_i


三、避坑:全局平均 (Global IoU) vs 图像平均 (Image-level IoU) 的致命差别

在处理烟雾(尤其是早期微小烟雾)这种占比呈现极度长尾分布的场景时,为什么必须强调使用前面提到的 Image-level 平均机制 呢?

因为如果沿用很多代码库(如 MMSegmentation 原生逻辑)默认的“全局平均精度(Global Average)”计算方式,会造成性能指标“造假虚高”。

数学逻辑的本质差异

  1. 全局平均 (Global 计算):先将整个测试集所有图片上的交集所有图片上的并集各自揉在一起累加,最后做唯一一次除法。

    IoUglobal=i=1nii=1niIoU_{global} = \frac{\sum_{i=1}^{n} 交集_i}{\sum_{i=1}^{n} 并集_i}

  2. 单图平均 (Image-level 计算):如前文所述,先算出单图分数,再把 nn 张图的分数相加除以 nn

    IoUimagelevel=1ni=1n(ii)IoU_{image-level} = \frac{1}{n} \sum_{i=1}^{n} \left( \frac{交集_i}{并集_i} \right)

实例说明

假设测试集只有极端的 2 张照片:

  • 图片 A (大片浓烟):真实像素 10,000。模型预测交集 9,000 像素,并集 11,000 像素。单图 IoUA=90001100081.8%IoU_A = \frac{9000}{11000} \approx \textbf{81.8\%}
  • 图片 B (极小烟雾):真实像素 100 像素。网络完全漏检预测全黑,交集 0,并集 100 像素。单图 IoUB=0100=0%IoU_B = \frac{0}{100} = \textbf{0\%}

如果用这两种方式结算,结果截然不同:

  • 全局模式:把面积数值直接合并 9000+011000+10081.08%\frac{9000 + 0}{11000 + 100} \approx \textbf{81.08\%}
  • 单图模式:百分比分数算术平均 81.8%+0%2=40.9%\frac{81.8\% + 0\%}{2} = \textbf{40.9\%}

直观结论
全局模式下,模型在图片 B 上的致命漏检,被图片 A 庞大的像素绝对数量给“稀释和掩盖”了,制造出高达 81% 的幻象。而 Image-level 模式断崖式坠落到 40.9%,反而更为客观、真实地反映了模型在全数据集上的平均检测性能。这也就说明了烟雾分割领域为何必须严格遵从对单张图进行独立评价再求平均。


四、MMSegmentation 1.x 烟雾分割评价指标代码实现

在 MMSegmentation 1.x 版本中,为了实现上述的 Image-level 评价机制 以及加入 mMse 等指标,我们需要自定义一个评价指标类。它继承自官方的 IoUMetric,在保留全局统计功能的同时,通过覆写 processcompute_metrics,额外计算单图平均(Macro-Average)以及基于概率图的 RMSE/MSE 误差。

操作步骤

  1. 在工程目录下创建 mmseg/evaluation/metrics/smoke_seg_metric.py 文件。
  2. 将以下代码填入文件中,不要忘记添加模块注册功能。
  3. 在你的网络主配置文件的 val_evaluatortest_evaluator 字段中指向 SmokeSegMetric 以启用它。

具体实现代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# Copyright (c) OpenMMLab. All rights reserved.
import os.path as osp
from collections import OrderedDict
from typing import Dict, List, Optional, Sequence

import numpy as np
import torch
from mmengine.dist import is_main_process
from mmengine.logging import MMLogger, print_log
from mmengine.utils import mkdir_or_exist
from PIL import Image
from prettytable import PrettyTable

from mmseg.registry import METRICS
from .iou_metric import IoUMetric


@METRICS.register_module()
class SmokeSegMetric(IoUMetric):
"""烟雾分割评价指标。

在官方 IoUMetric(pixel-level 全局累积)的基础上,新增 image-level
Macro-Average 指标:逐图计算 IoU、Fβ、Precision、Recall,然后对
所有图片取均值。两种指标同时输出。

Image-level 指标会**分别计算每个类**(如 _background_ 和 smoke),
最终输出每个类的 Img_IoU、Img_Fbeta、Img_Precision、Img_Recall。

Args:
ignore_index (int): 评估时忽略的标签索引。默认: 255。
iou_metrics (list[str] | str): 官方 pixel-level 指标类型,
支持 'mIoU'、'mDice'、'mFscore'。默认: ['mIoU']。
nan_to_num (int, optional): 如果指定,NaN 值将被替换为该数值。
默认: None。
beta (int): Fβ 的 β 值,决定 Recall 在综合得分中的权重。
β=1 时为 F1-Score,β=2 时更侧重 Recall。默认: 1。
collect_device (str): 分布式训练时收集结果的设备。默认: 'cpu'。
output_dir (str): 输出预测结果的目录。默认: None。
format_only (bool): 仅格式化结果不进行评估。默认: False。
prefix (str, optional): 指标名称前缀。默认: None。
"""

def __init__(self,
ignore_index: int = 255,
iou_metrics: List[str] = ['mIoU'],
nan_to_num: Optional[int] = None,
beta: int = 1,
collect_device: str = 'cpu',
output_dir: Optional[str] = None,
format_only: bool = False,
prefix: Optional[str] = None,
**kwargs) -> None:
super().__init__(
ignore_index=ignore_index,
iou_metrics=iou_metrics,
nan_to_num=nan_to_num,
beta=beta,
collect_device=collect_device,
output_dir=output_dir,
format_only=format_only,
prefix=prefix,
**kwargs)

def process(self, data_batch: dict,
data_samples: Sequence[dict]) -> None:
"""处理一个 batch 的数据。

在父类 IoUMetric.process() 的基础上,额外从 seg_logits(sigmoid
后的概率图)计算每张图的 RMSE,一并存入 self.results。

每张图的结果为 8 元组:
(area_intersect, area_union, area_pred_label, area_label, rmse_prob, mse_prob, rmse_binary, mse_binary)

Args:
data_batch (dict): 一个 batch 的输入数据。
data_samples (Sequence[dict]): 一个 batch 的模型输出。
"""
num_classes = len(self.dataset_meta['classes'])
for data_sample in data_samples:
pred_label = data_sample['pred_sem_seg']['data'].squeeze()
# format_only 模式不计算指标
if not self.format_only:
label = data_sample['gt_sem_seg']['data'].squeeze().to(
pred_label)
# 计算 intersect_and_union(与父类一致)
iau = self.intersect_and_union(
pred_label, label, num_classes, self.ignore_index)

# ========== 1. 从概率图计算 RMSE_Prob 和 MSE_Prob ==========
seg_logits = data_sample['seg_logits']['data'].squeeze()
gt_float = label.float()
# seg_logits 在 out_channels==1 时已经是 sigmoid 后的概率图
mse_prob = torch.mean((seg_logits - gt_float) ** 2).item()
rmse_prob = torch.sqrt(torch.tensor(mse_prob)).item()

# ========== 2. 从二值化预测图计算 RMSE_Binary 和 MSE_Binary ==========
# 按照 deep-smoke-segmentation 等计算二值化后的误差
# 注意:直接使用已经根据 threshold(如0.4) 生成好的二值图 pred_label
pred_binary = pred_label.float()
mse_binary = torch.mean((pred_binary - gt_float) ** 2).item()
rmse_binary = torch.sqrt(torch.tensor(mse_binary)).item()

# 存储 8 元组
self.results.append((*iau, rmse_prob, mse_prob, rmse_binary, mse_binary))

# 保存预测结果到文件(与父类一致)
if self.output_dir is not None:
basename = osp.splitext(osp.basename(
data_sample['img_path']))[0]
png_filename = osp.abspath(
osp.join(self.output_dir, f'{basename}.png'))
output_mask = pred_label.cpu().numpy()
if data_sample.get('reduce_zero_label', False):
output_mask = output_mask + 1
output = Image.fromarray(output_mask.astype(np.uint8))
output.save(png_filename)

def compute_metrics(self, results: list) -> Dict[str, float]:
"""计算评价指标。

同时输出:
1. 官方 pixel-level 指标(通过父类逻辑)
2. image-level Macro-Average 指标(逐图计算后取均值)
3. RMSE 指标(逐图计算后取均值)

Args:
results (list): 每张图片的处理结果,每项为
(area_intersect, area_union, area_pred_label, area_label, rmse_prob, mse_prob, rmse_binary, mse_binary)
的 8 元组。

Returns:
Dict[str, float]: 所有评价指标的字典。
"""
logger: MMLogger = MMLogger.get_current_instance()
if self.format_only:
logger.info('results are saved to '
f'{self.output_dir}')
return OrderedDict()

# ========== 第一部分:官方 pixel-level 指标 ==========
# 转换数据格式:list of 8-tuples → tuple of lists
results_tuple = tuple(zip(*results))
assert len(results_tuple) == 8

total_area_intersect = sum(results_tuple[0])
total_area_union = sum(results_tuple[1])
total_area_pred_label = sum(results_tuple[2])
total_area_label = sum(results_tuple[3])
rmse_prob_list = list(results_tuple[4])
mse_prob_list = list(results_tuple[5])
rmse_binary_list = list(results_tuple[6])
mse_binary_list = list(results_tuple[7])

# 使用父类的静态方法计算 pixel-level 指标
ret_metrics = self.total_area_to_metrics(
total_area_intersect, total_area_union, total_area_pred_label,
total_area_label, self.metrics, self.nan_to_num, self.beta)

class_names = self.dataset_meta['classes']
num_classes = len(class_names)

# 汇总表(pixel-level)
ret_metrics_summary = OrderedDict({
ret_metric:
np.round(np.nanmean(ret_metric_value) * 100, 2)
for ret_metric, ret_metric_value in ret_metrics.items()
})
metrics = dict()
for key, val in ret_metrics_summary.items():
if key == 'aAcc':
metrics[key] = val
else:
metrics['m' + key] = val

# 每类表(pixel-level)
ret_metrics.pop('aAcc', None)
ret_metrics_class = OrderedDict({
ret_metric:
np.round(ret_metric_value * 100, 2)
for ret_metric, ret_metric_value in ret_metrics.items()
})
ret_metrics_class.update({'Class': class_names})
ret_metrics_class.move_to_end('Class', last=False)
class_table_data = PrettyTable()
for key, val in ret_metrics_class.items():
class_table_data.add_column(key, val)

print_log('per class results (pixel-level):', logger)
print_log('\n' + class_table_data.get_string(), logger=logger)

# ========== 第二部分:image-level Macro-Average 指标 ==========
# 提取前 4 项(intersect_and_union 结果)用于 image-level 指标
iau_results = [(r[0], r[1], r[2], r[3]) for r in results]
img_metrics = self._compute_image_level_metrics(
iau_results, num_classes)

# RMSE 和 MSE(逐图均值)
mean_rmse_prob = np.round(np.mean(rmse_prob_list), 4)
mean_mse_prob = np.round(np.mean(mse_prob_list), 4)
mean_rmse_binary = np.round(np.mean(rmse_binary_list), 4)
mean_mse_binary = np.round(np.mean(mse_binary_list), 4)

# 打印 image-level 表格
img_table_data = PrettyTable()
img_table_data.add_column('Class', list(class_names))
for key in ['Img_IoU', 'Img_Fbeta', 'Img_Prec', 'Img_Rec']:
img_table_data.add_column(
key,
[np.round(img_metrics[key][c] * 100, 2)
for c in range(num_classes)])

print_log('per class results (image-level macro-average):', logger)
print_log('\n' + img_table_data.get_string(), logger=logger)
print_log(f'RMSE_Prob: {mean_rmse_prob}', logger)
print_log(f'MSE_Prob: {mean_mse_prob}', logger)
print_log(f'RMSE_Binary: {mean_rmse_binary}', logger)
print_log(f'MSE_Binary: {mean_mse_binary}', logger)

# 汇总 image-level 指标到返回字典(按类分别输出)
for key in ['Img_IoU', 'Img_Fbeta', 'Img_Prec', 'Img_Rec']:
for c in range(num_classes):
class_key = f'{class_names[c]}.{key}'
metrics[class_key] = np.round(
img_metrics[key][c] * 100, 2)
metrics['RMSE_Prob'] = mean_rmse_prob
metrics['MSE_Prob'] = mean_mse_prob
metrics['RMSE_Binary'] = mean_rmse_binary
metrics['MSE_Binary'] = mean_mse_binary

return metrics

def _compute_image_level_metrics(
self,
results: list,
num_classes: int) -> Dict[str, np.ndarray]:
"""逐图计算 IoU、Fβ、Precision、Recall,然后取均值。

模仿 FoSp 的处理方式:
- 所有图片都参与均值计算,不跳过任何图
- 分母为 0 时用 max(1, x) 保护,使该项结果为 0
- 最终除以总图片数取算术平均

Args:
results (list): 每张图的 4 元组列表。
num_classes (int): 类别数量。

Returns:
Dict[str, np.ndarray]: 每个类的 image-level 均值指标,
shape = (num_classes,)。
"""
n_images = len(results)
beta = self.beta

# 累积每个类的指标和,shape = (num_classes,)
iou_sum = np.zeros(num_classes)
precision_sum = np.zeros(num_classes)
recall_sum = np.zeros(num_classes)
fbeta_sum = np.zeros(num_classes)

for area_intersect, area_union, \
area_pred_label, area_label in results:
for c in range(num_classes):
intersect = area_intersect[c].item()
union = area_union[c].item()
pred = area_pred_label[c].item()
label = area_label[c].item()

# IoU:交集 / 并集(union 不可能为 0,因为每张图中
# 每个类在 GT 或预测中至少有一方存在像素)
iou_sum[c] += intersect / union

# Precision:交集 / max(1, 预测面积)
p = intersect / max(1, pred)
precision_sum[c] += p

# Recall:交集 / max(1, GT面积)
r = intersect / max(1, label)
recall_sum[c] += r

# Fβ = (1+β²) * P * R / max(β²*P + R, 1e-3)
denom = (beta ** 2) * p + r
fbeta_sum[c] += (1 + beta ** 2) * p * r / max(denom, 1e-3)

# 除以总图片数取均值
img_metrics = {
'Img_IoU': iou_sum / n_images,
'Img_Fbeta': fbeta_sum / n_images,
'Img_Prec': precision_sum / n_images,
'Img_Rec': recall_sum / n_images,
}

return img_metrics