检测与分割的评估指标

本文系统讲解检测与分割的评估指标,回答 mAP 到底怎么算、COCO 与 VOC 口径差在哪、mIoU 为什么会被少数类拖垮等实战问题。覆盖 IoU 与匹配规则、PR 曲线与插值方式、AP50 与 AP75 与 mAP@[.5:.95]、分割的 mIoU 与 Dice 与像素准确率、类别不平衡下的指标选择、阈值与 NMS 的影响、以及离线指标与线上效果的差距,并给出两段可运行指标代码。

引言

模型训完了,第一个问题是「它到底有多好」。检测与分割的指标看似标准,实际处处是口径陷阱:同一个模型在 VOC 口径下 mAP 0.85,在 COCO 口径下可能只有 0.55;同一份代码换个插值方式,AP 差 2 个点;mIoU 报 0.9 但业务上不可用,因为关键类别 IoU 只有 0.3。

这些不是理论问题,而是每天都在发生的工程事故。团队之间对比模型时,如果口径不一致,结论完全不可比;把指标当验收标准时,如果指标与业务目标错位,模型上线后会立刻打回。

本文把检测与分割的指标拆到可手算的粒度:先讲匹配规则与 IoU,再讲 AP 的插值方式与 COCO/VOC 差异,然后讲分割指标与类别不平衡,最后讨论阈值、NMS 的影响以及离线与线上的差距。检测的整体流程见 目标检测与 YOLO 系列工程落地 ,分割任务见 图像分割与实例分割 。

目录

  1. 为什么指标口径容易出错
  2. IoU 的计算与常见变体
  3. 匹配规则:排序与一对一
  4. PR 曲线与 AP 的插值方式
  5. COCO 与 VOC 的口径差异
  6. AP50、AP75 与 mAP@[.5:.95]
  7. 代码:手写 VOC 风格 AP
  8. 代码:COCO 风格 AP 与 IoU 扫描
  9. 分割指标:mIoU、Dice 与像素准确率
  10. 类别不平衡下的指标选择
  11. 置信度阈值与 NMS 的影响
  12. 离线指标与线上效果的差距

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 的计算需要先把预测与真值配对。标准流程是:

  1. 把所有预测按置信度从高到低排序。
  2. 依次取出预测,与该类尚未被匹配的真值框算 IoU,取最大者。
  3. 若最大 IoU ≥ 阈值,判为 TP 并标记该真值已匹配;否则判为 FP。
  4. 未被匹配的真值算 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 的口径差异

维度VOCCOCO
IoU 阈值单一 0.50.5 到 0.95 步长 0.05
插值点11 点(2007)或全点101 点
面积划分无small/medium/large 分组
最大检测数无限制每图每类 100
困难样本有 difficult 标记用 ignore 区域
主指标mAP@0.5mAP@[.5:.95]
类别数2080

最大检测数是个容易被忽略的差异: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 / F12·交集 / (预测 + 真值)医学常用,小目标更敏感
AP_mask掩码 IoU 阈值下的 AP实例分割
PQSQ × 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 的空类处理与类别不平衡,像素准确率不能单独作为指标。无论哪个任务,最终都要用线上回流数据构造固定回归集,把离线指标与线上效果对齐。当指标与业务目标持续错位时,问题往往不在模型,而在指标本身没有定义好。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机视觉」更多文章

  1. 多模态视觉语言模型
  2. 3D 视觉:点云与深度估计
  3. 视频理解与多目标跟踪