引言
模型训完了,第一个问题是「它到底有多好」。检测与分割的指标看似标准,实际处处是口径陷阱:同一个模型在 VOC 口径下 mAP 0.85,在 COCO 口径下可能只有 0.55;同一份代码换个插值方式,AP 差 2 个点;mIoU 报 0.9 但业务上不可用,因为关键类别 IoU 只有 0.3。
这些不是理论问题,而是每天都在发生的工程事故。团队之间对比模型时,如果口径不一致,结论完全不可比;把指标当验收标准时,如果指标与业务目标错位,模型上线后会立刻打回。
本文把检测与分割的指标拆到可手算的粒度:先讲匹配规则与 IoU,再讲 AP 的插值方式与 COCO/VOC 差异,然后讲分割指标与类别不平衡,最后讨论阈值、NMS 的影响以及离线与线上的差距。检测的整体流程见 目标检测与 YOLO 系列工程落地 ,分割任务见 图像分割与实例分割 。
目录
- 为什么指标口径容易出错
- IoU 的计算与常见变体
- 匹配规则:排序与一对一
- PR 曲线与 AP 的插值方式
- COCO 与 VOC 的口径差异
- AP50、AP75 与 mAP@[.5:.95]
- 代码:手写 VOC 风格 AP
- 代码:COCO 风格 AP 与 IoU 扫描
- 分割指标:mIoU、Dice 与像素准确率
- 类别不平衡下的指标选择
- 置信度阈值与 NMS 的影响
- 离线指标与线上效果的差距
1. 为什么指标口径容易出错
指标出错通常源于四个不显眼的差异:
| 差异点 | 常见分歧 | 影响 |
|---|---|---|
| IoU 阈值 | 0.5 与 0.5:0.95 | 数值差可达 20 个点 |
| 插值方式 | 11 点与全点积分 | 差 1 到 3 个点 |
| 是否含困难样本 | 过滤 ignore 区域 | 差数个点 |
| 类别平均方式 | 宏平均与微平均 | 长尾类别影响巨大 |
最典型的场景是拿 YOLO 官方报的 mAP@0.5 :0.95 与论文里的 VOC mAP@0.5 直接比较,得出「新模型退步」的错误结论。规范做法是在报告里写明「口径三要素」:IoU 阈值集合、插值方式、数据集与划分。
2. IoU 的计算与常见变体
IoU(Intersection over Union)是预测区域与真值区域的交集除以并集。检测用框的 IoU,分割用掩码的 IoU。
import numpy as np
def box_iou(a, b):
# a, b: [x1, y1, x2, y2]
xx1, yy1 = max(a[0], b[0]), max(a[1], b[1])
xx2, yy2 = min(a[2], b[2]), min(a[3], b[3])
inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
return inter / (area_a + area_b - inter + 1e-9)
def mask_iou(pred, target):
inter = np.logical_and(pred, target).sum()
union = np.logical_or(pred, target).sum()
return float(inter) / (union + 1e-9)
print(round(box_iou([0, 0, 10, 10], [5, 5, 15, 15]), 4)) # 0.1429
变体与注意事项:
- GIoU / DIoU / CIoU:训练用的回归损失,也可作评估,但公开指标默认还是普通 IoU。
- GIoU 可为负:无重叠时 GIoU 为负,别直接当 IoU 用。
- 坐标约定:xyxy 与 xywh 混用会导致 IoU 全错,这是最常见的实现 bug。
- 小目标 IoU 敏感:10×10 的框偏移 2 像素,IoU 就从 1.0 掉到 0.67,小目标指标天然偏低。
- 掩码 IoU 的空集:预测与真值都为空时,IoU 应跳过而不是记 1 或 0。
3. 匹配规则:排序与一对一
AP 的计算需要先把预测与真值配对。标准流程是:
- 把所有预测按置信度从高到低排序。
- 依次取出预测,与该类尚未被匹配的真值框算 IoU,取最大者。
- 若最大 IoU ≥ 阈值,判为 TP 并标记该真值已匹配;否则判为 FP。
- 未被匹配的真值算 FN。
关键规则是一对一:一个真值只能被匹配一次,所以同一目标的重复预测除了最高分那个,其余全算 FP。这就是 NMS 存在的原因——不做 NMS,FP 会暴涨,精确率崩掉。
还要处理 ignore 区域:数据集常把「难以判断」的区域标记为 ignore(如人群、模糊区)。落在 ignore 区域的预测既不算 TP 也不算 FP,必须从统计中剔除,否则指标失真。
4. PR 曲线与 AP 的插值方式
有了 TP/FP 序列,就能算累积精确率与召回率,画 PR 曲线。AP 是曲线下面积,但「面积怎么算」有多种口径。
- 11 点插值(VOC2007):在召回率 0、0.1、…、1.0 共 11 个点上取「右侧最大精确率」的平均。这是最老的口径。
- 全点插值(VOC2010 起):对每个召回率变化点取右侧最大精确率,做数值积分。比 11 点更精确。
- COCO 口径:101 点插值,且要求精确率在召回率上单调不增(取右侧包络)。
「右侧最大精确率」这个操作叫包络化:把 PR 曲线变成单调不增的阶梯,去掉锯齿。不包络直接积分会低估 AP。同一个模型,包络与不包络可能差 1 到 2 个点。
def average_precision(tp, fp, n_gt, method="all"):
# tp/fp 是按置信度排序后的 0/1 序列
tp_cum, fp_cum = np.cumsum(tp), np.cumsum(fp)
recall = tp_cum / max(n_gt, 1)
precision = tp_cum / np.maximum(tp_cum + fp_cum, 1e-9)
# 从右向左取最大精确率,做包络化
for i in range(len(precision) - 2, -1, -1):
precision[i] = max(precision[i], precision[i + 1])
if method == "11point":
points = np.linspace(0, 1, 11)
return float(np.mean([precision[recall >= p].max() if (recall >= p).any() else 0
for p in points]))
# 全点插值:对召回率的变化点做积分
mrec = np.concatenate(([0.0], recall, [1.0]))
mpre = np.concatenate(([0.0], precision, [0.0]))
idx = np.where(mrec[1:] != mrec[:-1])[0]
return float(np.sum((mrec[idx + 1] - mrec[idx]) * mpre[idx + 1]))
注意 n_gt 是该类真值总数,用错(比如用了整个数据集的数量)会让召回率整体偏移。
5. COCO 与 VOC 的口径差异
| 维度 | VOC | COCO |
|---|---|---|
| IoU 阈值 | 单一 0.5 | 0.5 到 0.95 步长 0.05 |
| 插值点 | 11 点(2007)或全点 | 101 点 |
| 面积划分 | 无 | small/medium/large 分组 |
| 最大检测数 | 无限制 | 每图每类 100 |
| 困难样本 | 有 difficult 标记 | 用 ignore 区域 |
| 主指标 | mAP@0.5 | mAP@[.5:.95] |
| 类别数 | 20 | 80 |
最大检测数是个容易被忽略的差异:COCO 每图每类只取前 100 个预测。如果你的模型在某张图上输出 500 个框,多出的 400 个不参与评估,但这不代表它们在线上的误报不存在。VOC 不设上限,因此密集场景下两个口径的差距会拉大。
面积分组是 COCO 的实用特性:报 AP_small、AP_medium、AP_large 能直接暴露小目标短板。只看总 AP 会掩盖「大目标很好、小目标很烂」的事实。
6. AP50、AP75 与 mAP@[.5:.95]
三个数字的含义与用途不同:
- AP50(mAP@0.5):IoU 阈值 0.5,宽松,框大致对就算命中。适合定位精度要求不高的场景,也是 VOC 主指标。
- AP75:IoU 阈值 0.75,严格,要求框贴合紧密。适合需要精确定位的场景(如尺寸测量、密集小目标)。
- mAP@[.5:.95]:10 个阈值的平均,综合衡量定位质量,是 COCO 主指标。
一个模型的 AP50 与 AP75 差距越大,说明「框找得到但贴不准」。若 AP50 高而 AP75 低,优先改回归损失(CIoU 之类)与标注质量,而不是换更大的骨干。
参考值:YOLOv8m 在 COCO 上 AP50 约 67、AP75 约 54、mAP@[.5:.95] 约 50.2。Faster R-CNN R50-FPN 的 mAP@[.5:.95] 约 37.4,但 AP50 可达 58 左右。
7. 代码:手写 VOC 风格 AP
把上面的规则串成一个完整的评估器,用于验证框架结果是否可信。
import numpy as np
def voc_ap(preds, gts, iou_thr=0.5):
"""preds: [(box, score)], gts: [box],均为一类一张图,返回 AP 与 TP/FP 序列"""
preds = sorted(preds, key=lambda x: -x[1])
matched = [False] * len(gts)
tp, fp = [], []
for box, _ in preds:
best_iou, best_j = 0.0, -1
for j, g in enumerate(gts):
if matched[j]:
continue
iou = box_iou(box, g)
if iou > best_iou:
best_iou, best_j = iou, j
if best_iou >= iou_thr and best_j >= 0:
matched[best_j] = True
tp.append(1); fp.append(0)
else:
tp.append(0); fp.append(1)
return average_precision(np.array(tp), np.array(fp), len(gts))
# 一个真值框,两个预测:一个高分的偏框(IoU 0.6)、一个低分的准框
gt = [[0, 0, 10, 10]]
preds = [([1, 1, 11, 11], 0.9), ([0, 0, 10, 10], 0.3)]
print("AP@0.5:", round(voc_ap(preds, gt, 0.5), 3)) # 1.0(两个都算命中)
print("AP@0.75:", round(voc_ap(preds, gt, 0.75), 3)) # 0.5(只有准框命中)
这个例子直观说明 IoU 阈值的作用:同一个模型,阈值 0.5 时两个框都是 TP,阈值 0.75 时只有精确的那个是 TP,AP 直接腰斩。
8. 代码:COCO 风格 AP 与 IoU 扫描
COCO 的 mAP 是对 10 个 IoU 阈值取平均,下面给出扫描框架。
import numpy as np
def coco_map(preds_by_class, gts_by_class, iou_thrs=None):
"""preds_by_class/gts_by_class: {class_id: [(box, score)] / [box]}"""
if iou_thrs is None:
iou_thrs = np.arange(0.5, 1.0, 0.05)
aps = []
for thr in iou_thrs:
per_class = []
for c in gts_by_class:
if not gts_by_class[c]:
continue
per_class.append(voc_ap(preds_by_class.get(c, []), gts_by_class[c], thr))
aps.append(float(np.mean(per_class)) if per_class else 0.0)
return float(np.mean(aps)), aps
# 单类单图的最小示例
preds = {0: [([0, 0, 10, 10], 0.9)]}
gts = {0: [[0, 0, 10, 10]]}
m, per = coco_map(preds, gts)
print("mAP@[.5:.95]:", round(m, 3))
print("per-threshold:", [round(v, 2) for v in per])
真实实现还要处理面积分组、最大检测数与 ignore 区域。推荐用 pycocotools 做最终验收,自己手写一份用于交叉验证——两者结果差超过 0.5 个点,说明有一方口径错了。
9. 分割指标:mIoU、Dice 与像素准确率
| 指标 | 公式 | 特点 |
|---|---|---|
| 像素准确率(PA) | 正确像素 / 总像素 | 类别不平衡时虚高 |
| mIoU | 各类 IoU 的算术平均 | 语义分割主指标 |
| FWIoU | 按类频率加权的 IoU | 缓解长尾,偏重大类 |
| Dice / F1 | 2·交集 / (预测 + 真值) | 医学常用,小目标更敏感 |
| AP_mask | 掩码 IoU 阈值下的 AP | 实例分割 |
| PQ | SQ × RQ | 全景分割 |
Dice 与 IoU 是单调对应的:Dice = 2·IoU / (1 + IoU)。同样重叠程度下 Dice 的数值总是高于 IoU(IoU 0.5 对应 Dice 0.667),所以跨论文比较时必须看清用的是哪个。
像素准确率是最容易被误用的指标:背景占 95% 时,全预测背景就能得 0.95 的 PA,但模型毫无用处。因此分割任务绝不能用 PA 作为唯一指标。
def dice_from_iou(iou):
return 2 * iou / (1 + iou)
def miou(pred, target, num_classes, ignore=None):
ious = []
for c in range(num_classes):
if ignore is not None:
mask = (target != ignore)
p, t = (pred == c) & mask, (target == c) & mask
else:
p, t = pred == c, target == c
if t.sum() == 0 and p.sum() == 0:
continue # 两者都空,跳过而非记 0
inter = np.logical_and(p, t).sum()
union = np.logical_or(p, t).sum()
ious.append(inter / max(union, 1))
return float(np.mean(ious)), ious
print(round(dice_from_iou(0.5), 4)) # 0.6667
10. 类别不平衡下的指标选择
长尾是 CV 的常态,指标选择直接决定优化方向。
- 宏平均(macro):每类等权,少数类被放大。mAP 与 mIoU 默认是宏平均。
- 微平均(micro):按样本数加权,大类主导。整体 AP 更接近微平均。
- 加权平均(weighted):按类频率加权,介于两者之间。
选择原则:业务关心每一类都不能差 → 用宏平均并逐类看;业务只关心总体正确率 → 用微平均。
工程上必须逐类报告,不能只报平均值。一张「每类 AP 表」能立刻暴露问题类别:某类 AP 只有 0.05 时,平均 mAP 0.5 毫无意义。对少数类,还要看绝对数量——如果测试集里该类只有 10 个实例,AP 的方差极大,不足以支撑结论。
11. 置信度阈值与 NMS 的影响
指标是在某个置信度阈值下计算的,而 COCO/VOC 的 AP 是对所有阈值积分的,因此 AP 本身与阈值无关。但线上系统必须选一个阈值,此时指标会发生剧烈变化。
- 阈值高:精确率上升、召回率下降,误报少但漏报多。
- 阈值低:召回率上升、精确率下降,漏报少但误报多。
- NMS 阈值高:保留更多重叠框,密集场景减少漏检但增加重复。
- NMS 阈值低:抑制更激进,密集场景容易漏掉相邻目标。
因此「离线 AP 高」不等于「线上可用」:如果业务要求精确率 0.95,需要在 PR 曲线上找到对应的工作点,看该点的召回率是多少。这个工作点才是上线时应该报告的指标。
def operating_point(tp, fp, n_gt, target_precision=0.95):
tp_cum, fp_cum = np.cumsum(tp), np.cumsum(fp)
precision = tp_cum / np.maximum(tp_cum + fp_cum, 1e-9)
recall = tp_cum / max(n_gt, 1)
ok = np.where(precision >= target_precision)[0]
if len(ok) == 0:
return None
i = ok[-1] # 取满足精确率要求下召回率最高的点
return dict(precision=round(float(precision[i]), 4),
recall=round(float(recall[i]), 4))
12. 离线指标与线上效果的差距
离线指标好而线上效果差,原因通常是五类:
- 数据分布不同:测试集来自公开数据或历史数据,线上是新的设备、光照、季节。
- 标注质量差异:测试集标注精细,线上真值缺失,无法复现同样的口径。
- 阈值与后处理不同:离线用 0.25 阈值算 AP,线上用 0.5,工作点完全不同。
- 级联误差累积:离线评估单个模型,线上是多级流水线,前级误差会放大。
- 评测集泄漏:测试集被反复用于调参,指标逐渐「训练」到测试集上。
缩小差距的做法:用线上回流数据构造一个固定回归集,样本 200 到 500 条,人工精标,每次模型更新都跑一遍;同时监控线上的置信度分布与类别分布,与离线对比。数据标注与质量控制的手段见 数据标注与数据集工程 ,模型监控见 模型评估与指标设计 。
权衡取舍
| 维度 | 倾向 | 代价 |
|---|---|---|
| AP50 与 mAP@[.5:.95] | 定位要求高用严格口径 | 数值低,需重新设定目标 |
| 宏平均与微平均 | 长尾用宏平均 | 少数类方差大,需更多样本 |
| 像素准确率与 mIoU | 分割用 mIoU | 计算复杂,需处理空类 |
| 单一阈值与工作点报告 | 上线报工作点 | 需与业务对齐精确率目标 |
| 公开基准与自建回归集 | 验收用自建集 | 标注成本高,但更可信 |
| 检测最大数限制 | 密集场景放开上限 | 与 COCO 口径不一致 |
几条实用原则:
- 报告指标必须写明口径三要素:IoU 阈值集合、插值方式、数据集划分。
- 逐类报告,绝不用平均值掩盖问题类别。
- 上线时报告业务工作点(指定精确率下的召回率),而不是笼统的 AP。
- 自建固定回归集,比刷公开榜单更能反映真实效果。
常见坑清单
- 口径混用:拿 VOC mAP@0.5 与 COCO mAP@[.5:.95] 直接比较,结论全错。
- 忘记包络化:PR 曲线不取右侧最大,AP 低估 1 到 2 个点。
- 匹配非一对一:一个真值被多个预测匹配,TP 虚高。
- 忽略 ignore 区域:难判断区域的预测被计入 FP,指标虚低。
- 坐标格式混用:xyxy 与 xywh 混用,IoU 全错但不报错。
- 空类记 0:mIoU 中两者都空的类别记 0,指标被无故拉低。
- 用像素准确率汇报分割:背景占大头,0.95 的 PA 毫无意义。
- 只报平均不报逐类:关键类别 AP 极低被平均掩盖。
- 测试集当验证集:反复调参导致指标虚高,上线打回。
- 忽略最大检测数:密集场景预测数超过 100 个,与 COCO 口径不符。
- 阈值与业务错位:离线按 0.25 评估,线上用 0.5,工作点完全不同。
- 小样本类下结论:某类测试实例只有十几个,AP 波动大不可信。
- 不记录评估配置:换个脚本指标变了,无法定位是模型还是口径。
小结
检测与分割指标的主线是:IoU 定匹配 → 置信度排序定 TP/FP → PR 曲线包络化 → 按口径积分得 AP → 宏平均跨类汇总 → 按业务选工作点。记住三个判断点:口径必须写清三要素、逐类报告才能发现问题、上线报工作点而非笼统 AP。
分割任务额外要注意 mIoU 的空类处理与类别不平衡,像素准确率不能单独作为指标。无论哪个任务,最终都要用线上回流数据构造固定回归集,把离线指标与线上效果对齐。当指标与业务目标持续错位时,问题往往不在模型,而在指标本身没有定义好。
延伸阅读
- 目标检测与 YOLO 系列工程落地 — 检测流程与 NMS 调参
- 图像分割与实例分割 — 分割任务与损失设计
- 数据标注与数据集工程 — 标注质量对指标的影响
- 模型评估与指标设计 — 通用评估方法论
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。