☰
MMDetection3扩展CocoMetric:类别AP、FPPI与面积切片
2026/10/27 23:26:07 网站建设 项目流程

评估指标这东西平时不显眼,真到了要交付、要对比、要写报告的时候,往往比模型结构本身还折磨人。最近在 mmdetection3 里做一套检测模型的迭代,原生 cocoapi 评估出来的 12 个数看着挺全,实际一用就发现不够:类别之间的表现差异看不出来,误检率压不下去,小目标上的波动被大目标平均掉了。于是花了点时间把 mmdetection3 的评估链路拆开,在 CocoMetric 外面包了一层,把类别级 AP、FPPI、自定义 IoU 匹配、面积切片这几项指标接了进来。这篇就把整个改造过程摊开写一遍,从调用链、可以下手的位置,到能直接注册进配置的代码,再到踩过的坑和排查顺序。适合已经在跑 mmdet3 训练、想自己加评估维度的人看,也适合刚接触 cocoapi、被那 12 个 stats 绕晕的读者。代码基于 3.x 的 CocoMetric 接口,不同小版本字段名可能有微调,动手前先对照本地源码。

1. 改造之前,先把 MMDetection3 的评估链路拆开

1.1 原生 12 个指标到底在算什么

pycocotools 的 COCOeval 最后吐出来的stats是一个长度 12 的数组,很多同学习惯了看bbox_mAP和bbox_mAP_50,剩下几个位置念不出名字。把索引和含义对齐一下,后面改指标才知道自己在动哪一块。

stats 索引含义备注
0AP@[.5:.95]10 个 IoU 阈值的平均,最常看的主指标
1AP@0.5宽松匹配
2AP@0.75严格匹配
3AP smallGT 面积小于 32²
4AP mediumGT 面积在 32² 到 96² 之间
5AP largeGT 面积大于 96²
6AR@1每张图最多 1 个检测
7AR@10每张图最多 10 个检测
8AR@100每张图最多 100 个检测
9AR small小目标召回
10AR medium中目标召回
11AR large大目标召回

这张表里有个容易被忽略的点:mmdetection3 的 CocoMetric 默认proposal_nums=(100, 300, 1000),而 COCO 官方评估脚本用的是(1, 10, 100)。也就是说,你在 mmdet3 日志里看到的AR@100,和官方榜单上的AR@100不是同一个东西,前者对应的是 100 这个档位,后者对应的是 100 这个档位,数值上因为 maxDets 序列整体右移,通常会更漂亮一些。跟外部结果对比时如果对不上,先查这一条,比查模型本身快得多。

再说 AP 的计算方式。cocoapi 用的是 101 点插值召回,对每个 recall 点取 precision 的最大值再平均,这和 VOC 时代的 11 点插值不一样,和后来一些框架里的连续积分也不一样。这个差异在 PR 曲线尾部抖动比较大的时候会体现出来,尤其是小数据集上,同一份预测结果换个评估库能差出零点几个点。知道这个背景,就不会轻易把不同框架的 mAP 拿来做绝对比较。

1.2 从 ValLoop 到 CocoMetric 的调用链

MMDetection3 把训练引擎换成了 MMEngine,评估这件事被拆成了 Runner、ValLoop、Evaluator、Metric 四层。跑一次验证的路径大致是这样:ValLoop 按 dataloader 迭代,每个 batch 把data_samples交给 Evaluator,Evaluator 再分发给注册进来的每个 Metric 的process方法;所有 batch 跑完之后,Evaluator 把各进程收集到的结果汇总到主进程,调用 Metric 的compute_metrics,最后写进日志。

这条链路里有两个关键约束。第一,process是每个 batch 都调一次,任何重计算都不能放在这里,否则验证时间直接翻几倍。第二,compute_metrics只在 rank0 上执行,分布式训练时其他进程只负责收集预测,所以你在里面写打印、写文件、画图都不会重复。搞清楚这两点,扩展指标的代码该往哪塞就明确了:轻量的格式转换放process,重的评估逻辑放compute_metrics。

CocoMetric 的process实际做的是把pred_instances里的 bboxes、labels、scores 按类别拆开,转成bbox_results这种列表结构,每个元素对应一个类别,里面是[x1, y1, x2, y2, score]的数组。compute_metrics拿到的是所有图片的结果列表,每个元素带img_id和bbox_results。这个结构就是扩展评估的入口,后面所有自定义指标都从这里取数据。

1.3 COCOeval 的三段式结构与可以下手的位置

COCOeval 内部是标准的三段式:evaluate()负责逐图逐类做匹配,把结果塞进self.evalImgs;accumulate()把evalImgs聚合成self.eval,里面最核心的是precision,维度是T x R x K x A x M,分别对应 IoU 阈值、召回点、类别、面积区间、最大检测数;summarize()从precision里按不同切片算出那 12 个 stats。

能下手的位置其实就四个。最外层是改params,调iouThrs、recThrs、maxDets、areaRng这些参数,不动源码就能得到很多变体指标,比如只算 IoU=0.3 的 AP,或者加一个 tiny 面积区间。第二层是重写computeIoU,把 IoU 换成 GIoU、DIoU 或者带中心点距离的相似度,适合对定位质量更敏感的场景。第三层是重写evaluateImg,改匹配策略,比如限制同一张图里同类检测的数量,或者加入 NMS 之后的二次筛选。第四层是拿到precision之后自己算,类别级 AP、PR 曲线、指定召回下的精度都属于这一类,成本最低,也最不容易出错。

我的建议是从第四层和第一层入手,能满足八成需求;computeIoU和evaluateImg属于核弹级改动,除非业务上确实需要,否则不要轻易碰,改完之后和官方数值就彻底不可比了。

2. 四类最值得落地的扩展指标

2.1 类别级 AP 与类别混淆矩阵

COCO 的 mAP 是所有类别的算术平均,这个平均对长尾分布很不友好。一个 20 类的数据集里,如果某一类只有几十个 GT,它的 AP 波动会非常大,但在总 mAP 里只占 1/20,出了问题也看不出来。最直接的办法是把每个类别的 AP 单独打出来,按数值排序,盯着最差的几个类看。

CocoMetric 本身就带classwise=True参数,打开之后compute_metrics会额外返回coco/bbox_mAP_per_class之类的字段。这个开关很多人不知道,白白自己造轮子。打开之后日志里会出现一长串类别名和对应的 AP,配合metainfo里的classes顺序就能对上。不过它输出的是文本形式,想接进实验管理工具或者画图,还是得自己从coco_eval.eval['precision']里取。

取法是按类别维度 K 做循环,对每个类别取precision[:, :, k, 0, -1],排除掉 -1 的无效值,再对召回维度求平均。这里有个细节:precision里未被触达的召回点会被填成 -1,求平均之前必须过滤,否则算出来的 AP 会偏小。另外面积维度 A 的索引 0 对应 all,最大检测维度 M 的最后一个索引对应最大的 maxDets,取这两个位置才是主指标。

混淆矩阵在检测任务里不像分类那么直观,但可以做:对每个匹配上的 TP,记录“预测类别”和“GT 类别”,正常情况下两者相同,如果不同就说明匹配逻辑有问题;对 FP,记录预测类别,统计它主要来自哪些类别的误检。这个矩阵能直接告诉你,某一类的高 AP 是不是靠压低召回换来的,以及某些类别之间是否存在系统性混淆。

2.2 FPPI 与 MR:误检敏感场景的刚需

安防、工业质检、医疗影像这些场景,评估时最关心的不是 mAP,而是“每张图平均误检多少个”和“漏检率多少”。COCO 那套指标里没有直接对应项,得自己算 FPPI(False Positive Per Image)和 MR(Miss Rate)。

FPPI 的定义很朴素:把所有检测按置信度从高到低排序,逐个累加,统计当前阈值下的 FP 总数,再除以图片总数。MR 就是 1 减去召回。把这两个量随阈值变化的曲线画出来,取 FPPI 在 0.1、1、10 这几个点上的 MR,就是所谓的 log-average miss rate,比单点指标稳定得多。

用 pycocotools 算 FPPI 有个取巧的办法:把iouThrs设成单个值,areaRng只留 all,maxDets设大一点,跑完evaluate()之后直接读evalImgs。每个元素里有dtScores、dtMatches、dtIgnore三个数组,dtMatches里大于 0 表示这个检测匹配上了 GT,等于 0 就是 FP,dtIgnore为 True 的(匹配到 crowd 或者落在面积区间外的)直接跳过。把这些数组拼起来按分数排序,就能得到全局的 TP/FP 序列。

这个做法比自己手写匹配稳,因为它复用了 cocoapi 的 crowd 处理和面积过滤逻辑,算出来的数和官方口径一致。唯一要注意的是evalImgs是扁平结构,按imgIds x catIds x areaRng x maxDets展开,跑之前把areaRng和maxDets都收窄,否则遍历量会比较大。

2.3 IoU 阈值扫描与自定义匹配规则

默认的 AP 是对 10 个 IoU 阈值取平均,但不同业务对定位精度的要求差别很大。遥感小目标可能 IoU=0.3 就算匹配,工业缺陷检测可能要 0.7 以上才认。与其看一个平均值,不如把 AP 随 IoU 变化的曲线打出来,看看模型在哪个区间开始崩。

做法很简单,构造 COCOeval 之后把params.iouThrs设成单个值,跑一遍取stats[0],外面套一层循环。注意当iouThrs只有一个元素时,summarize()里的stats[1](AP@0.5)会因为找不到 0.5 这个阈值而变成 nan,所以只取stats[0],不要顺手把其他位置也读了。

自定义匹配规则是另一条路。比如某些场景下框的尺度和长宽比更重要,用 GIoU 替代 IoU 能更好地反映定位质量。具体做法是继承COCOeval,重写computeIoU,把最后计算交并比的那段换成 GIoU 矩阵。函数的输入是imgId和catId,内部从self._gts和self._dts里取标注和检测,返回一个形状为(len(dt), len(gt))的相似度矩阵。原版里有几处对空列表和 crowd 的特殊处理,照抄的时候不要删,否则会在某些图片上抛异常。

改完之后,所有的 AP 数值都会变,而且和官方结果不可比。我的做法是保留原生 CocoMetric 的输出,把自定义匹配的结果用单独的 key 前缀打出来,比如coco_giou/bbox_mAP,这样既能看到常规指标,也能对比匹配策略带来的差异。

2.4 面积切片与长尾类别重评估

COCO 的面积划分只有 small、medium、large 三档,边界是 32² 和 96²。实际项目里这个粒度往往不够,比如无人机航拍的小目标可能集中在 8² 到 16² 之间,全被归到 small 里,看不出改进。改areaRng就能加自定义区间:

ev.params.areaRng = [ [0, 1e10], [0, 16 ** 2], [16 ** 2, 32 ** 2], [32 ** 2, 96 ** 2], [96 ** 2, 1e10], ] ev.params.areaRngLbl = ['all', 'tiny', 'small', 'medium', 'large']

注意areaRng的长度变了之后,summarize()里写死的索引就对不上了,stats[3]到stats[5]还是按原来的三档取值,会错位。所以加区间之后不要用summarize()的结果,直接从precision里按面积维度取。precision的第四个维度 A 和areaRng一一对应,索引 1 就是 tiny,索引 2 是 small,以此类推。

长尾类别重评估的思路类似:先按类别统计 GT 数量,把类别分成头部、腰部、尾部三组,分别算组内 mAP。这个指标比总体 mAP 更能反映模型在稀有类别上的真实水平。做的时候不需要改 COCOeval,拿到precision之后按类别索引分组求平均即可。分组阈值可以根据数据集的分布来定,比如 GT 数量前 20% 为头部,后 30% 为尾部。

3. 实操:写一个可直接注册的 CocoMetricRich

3.1 目录结构与注册方式

不建议直接改 mmdet 安装目录下的源码,升级或者换环境的时候会丢。比较稳妥的做法是在项目里建一个独立包,比如my_metrics/,里面放__init__.py和coco_metric_rich.py。__init__.py里把类导入进来,配置文件里用custom_imports注册:

custom_imports = dict( imports=['my_metrics.coco_metric_rich'], allow_failed_imports=False, )

类上加@METRICS.register_module(),模块名默认就是类名。配置文件里把val_evaluator的type换成CocoMetricRich,其余参数和原生 CocoMetric 保持一致,额外参数通过extra_iou_thrs这类字段传进去。

这个包的位置要能被 Python 找到。最省事的办法是放在项目根目录,训练脚本从根目录启动,sys.path里自然就有。如果项目结构比较复杂,可以在启动脚本里手动sys.path.insert(0, ...),比配环境变量可靠。

3.2 收集预测结果与构造 COCO 结果

compute_metrics拿到的results是一个列表,每个元素对应一张图,里面有img_id和bbox_results。bbox_results又是按类别拆开的列表,每个元素是[x1, y1, x2, y2, score]的数组。转成 COCO 结果格式时要注意两点:COCO 的 bbox 是[x, y, w, h],需要把右下角坐标减左上角;category_id要用self.cat_ids映射,不能直接用类别索引,因为 mmdet 内部是连续索引,而 COCO 标注里的 id 可能不连续。

def _to_coco_results(self, results): coco_results = [] for res in results: img_id = int(res['img_id']) for cls_idx, dets in enumerate(res['bbox_results']): if dets is None or len(dets) == 0: continue dets = np.asarray(dets) cat_id = int(self.cat_ids[cls_idx]) for det in dets: x1, y1, x2, y2, score = det[:5] if score < self.score_thr: continue coco_results.append({ 'image_id': img_id, 'category_id': cat_id, 'bbox': [float(x1), float(y1), float(x2 - x1), float(y2 - y1)], 'score': float(score), }) return coco_results

这段代码里score_thr直接用父类的属性,CocoMetric 初始化时会设一个默认值,通常是 0.05。如果想在扩展指标里用更低的阈值,可以在子类里单独加一个参数,不要覆盖父类的值,否则原生指标也会跟着变,两边就对不上了。

还有一个容易踩的坑:如果某张图一个检测都没有,loadRes遇到空列表会直接抛异常。所以转完之后先判断coco_results是否为空,空的话直接返回基础指标,不要往下走。

3.3 扩展评估的核心代码

完整类大概长这样,核心逻辑是先把原生结果拿到,再用同一份预测跑扩展评估:

from collections import defaultdict import numpy as np from pycocotools.cocoeval import COCOeval from mmdet.evaluation import CocoMetric from mmengine.registry import METRICS @METRICS.register_module() class CocoMetricRich(CocoMetric): def __init__(self, *args, extra_iou_thrs=(0.3, 0.5, 0.7), fppi_iou_thr=0.5, **kwargs): super().__init__(*args, **kwargs) self.extra_iou_thrs = list(extra_iou_thrs) self.fppi_iou_thr = fppi_iou_thr def _to_coco_results(self, results): # 上一节的实现 ... def _eval_single_iou(self, dt, iou): ev = COCOeval(self.coco, dt, iouType='bbox') ev.params.iouThrs = np.array([iou], dtype=np.float64) ev.params.recThrs = np.linspace(0, 1, 101) ev.params.maxDets = [100, 300, 1000] ev.evaluate() ev.accumulate() return float(ev.stats[0]) def _compute_fppi(self, dt): ev = COCOeval(self.coco, dt, iouType='bbox') ev.params.iouThrs = np.array([self.fppi_iou_thr]) ev.params.maxDets = [1000] ev.params.areaRng = [[0, 1e10]] ev.params.areaRngLbl = ['all'] ev.evaluate() scores, tps = [], [] for e in ev.evalImgs: if e is None: continue for s, m, ig in zip(e['dtScores'], e['dtMatches'], e['dtIgnore']): if ig: continue scores.append(float(s)) tps.append(1 if m > 0 else 0) if not scores: return float('nan'), float('nan') order = np.argsort(-np.asarray(scores)) tps = np.asarray(tps)[order] tp_cum = np.cumsum(tps) fp_cum = np.arange(1, len(tps) + 1) - tp_cum num_gt = len(self.coco.getAnnIds(iscrowd=False)) num_img = len(self.coco.getImgIds()) recall = tp_cum / max(num_gt, 1) fppi = fp_cum / max(num_img, 1) idx = int(np.argmin(np.abs(fppi - 1.0))) return fppi[idx], 1.0 - recall[idx] def compute_metrics(self, results): base = super().compute_metrics(results) coco_results = self._to_coco_results(results) if not coco_results: return base dt = self.coco.loadRes(coco_results) for iou in self.extra_iou_thrs: base[f'coco/bbox_AP@{iou:.2f}'] = \ self._eval_single_iou(dt, iou) fppi, mr = self._compute_fppi(dt) base['coco/bbox_FPPI'] = fppi base['coco/bbox_MR'] = mr return base

这段代码有两个性能问题需要提前知道。第一,_eval_single_iou每次都会重新evaluate(),三个阈值就是三倍时间,验证集大的时候很肉疼。第二,_compute_fppi里maxDets=1000会让evalImgs膨胀。如果只是想在训练中间看个趋势,可以只在interval触发的验证里跑扩展指标,或者把extra_iou_thrs缩到一个值。

另外num_gt的算法要注意,getAnnIds(iscrowd=False)返回的是全库非 crowd 标注数量,和 COCOeval 内部的统计口径可能有细微差别,对 FPPI 这种重视相对趋势的指标影响不大,但别拿它当精确值用。

3.4 配置接入与分布式一致性

配置文件里这样写:

val_evaluator = [ dict( type='CocoMetricRich', ann_file=data_root + 'annotations/instances_val2017.json', metric='bbox', format_only=False, classwise=True, proposal_nums=(100, 300, 1000), extra_iou_thrs=(0.3, 0.5, 0.7), fppi_iou_thr=0.5, ), ]

val_evaluator写成列表是因为 MMEngine 支持一次挂多个评估器,你可以同时保留一个原生 CocoMetric 和一个 CocoMetricRich,前者出标准指标,后者出扩展指标,互不干扰。代价是 COCOeval 会跑两遍,时间换清晰度,看你怎么权衡。

分布式训练时,compute_metrics只在主进程执行,self.coco也是每个进程各自加载的,所以扩展评估本身不会有一致性问题。真正要注意的是results的合并顺序:MMEngine 的collect_device默认是 cpu,各进程的预测按 rank 顺序拼接,img_id不会重复,loadRes不会

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询