评估指标这东西平时不显眼真到了要交付、要对比、要写报告的时候往往比模型结构本身还折磨人。最近在 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 阈值的平均最常看的主指标1AP0.5宽松匹配2AP0.75严格匹配3AP smallGT 面积小于 32²4AP mediumGT 面积在 32² 到 96² 之间5AP largeGT 面积大于 96²6AR1每张图最多 1 个检测7AR10每张图最多 10 个检测8AR100每张图最多 100 个检测9AR small小目标召回10AR medium中目标召回11AR large大目标召回这张表里有个容易被忽略的点mmdetection3 的 CocoMetric 默认proposal_nums(100, 300, 1000)而 COCO 官方评估脚本用的是(1, 10, 100)。也就是说你在 mmdet3 日志里看到的AR100和官方榜单上的AR100不是同一个东西前者对应的是 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交给 EvaluatorEvaluator 再分发给注册进来的每个 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.evalImgsaccumulate()把evalImgs聚合成self.eval里面最核心的是precision维度是T x R x K x A x M分别对应 IoU 阈值、召回点、类别、面积区间、最大检测数summarize()从precision里按不同切片算出那 12 个 stats。能下手的位置其实就四个。最外层是改params调iouThrs、recThrs、maxDets、areaRng这些参数不动源码就能得到很多变体指标比如只算 IoU0.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 本身就带classwiseTrue参数打开之后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 那套指标里没有直接对应项得自己算 FPPIFalse Positive Per Image和 MRMiss Rate。FPPI 的定义很朴素把所有检测按置信度从高到低排序逐个累加统计当前阈值下的 FP 总数再除以图片总数。MR 就是 1 减去召回。把这两个量随阈值变化的曲线画出来取 FPPI 在 0.1、1、10 这几个点上的 MR就是所谓的 log-average miss rate比单点指标稳定得多。用 pycocotools 算 FPPI 有个取巧的办法把iouThrs设成单个值areaRng只留 allmaxDets设大一点跑完evaluate()之后直接读evalImgs。每个元素里有dtScores、dtMatches、dtIgnore三个数组dtMatches里大于 0 表示这个检测匹配上了 GT等于 0 就是 FPdtIgnore为 True 的匹配到 crowd 或者落在面积区间外的直接跳过。把这些数组拼起来按分数排序就能得到全局的 TP/FP 序列。这个做法比自己手写匹配稳因为它复用了 cocoapi 的 crowd 处理和面积过滤逻辑算出来的数和官方口径一致。唯一要注意的是evalImgs是扁平结构按imgIds x catIds x areaRng x maxDets展开跑之前把areaRng和maxDets都收窄否则遍历量会比较大。2.3 IoU 阈值扫描与自定义匹配规则默认的 AP 是对 10 个 IoU 阈值取平均但不同业务对定位精度的要求差别很大。遥感小目标可能 IoU0.3 就算匹配工业缺陷检测可能要 0.7 以上才认。与其看一个平均值不如把 AP 随 IoU 变化的曲线打出来看看模型在哪个区间开始崩。做法很简单构造 COCOeval 之后把params.iouThrs设成单个值跑一遍取stats[0]外面套一层循环。注意当iouThrs只有一个元素时summarize()里的stats[1]AP0.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. 实操写一个可直接注册的 CocoMetricRich3.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_importsFalse, )类上加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_thr0.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, iouTypebbox) ev.params.iouThrs np.array([iou], dtypenp.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, iouTypebbox) 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(iscrowdFalse)) 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[fcoco/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里maxDets1000会让evalImgs膨胀。如果只是想在训练中间看个趋势可以只在interval触发的验证里跑扩展指标或者把extra_iou_thrs缩到一个值。另外num_gt的算法要注意getAnnIds(iscrowdFalse)返回的是全库非 crowd 标注数量和 COCOeval 内部的统计口径可能有细微差别对 FPPI 这种重视相对趋势的指标影响不大但别拿它当精确值用。3.4 配置接入与分布式一致性配置文件里这样写val_evaluator [ dict( typeCocoMetricRich, ann_filedata_root annotations/instances_val2017.json, metricbbox, format_onlyFalse, classwiseTrue, proposal_nums(100, 300, 1000), extra_iou_thrs(0.3, 0.5, 0.7), fppi_iou_thr0.5, ), ]val_evaluator写成列表是因为 MMEngine 支持一次挂多个评估器你可以同时保留一个原生 CocoMetric 和一个 CocoMetricRich前者出标准指标后者出扩展指标互不干扰。代价是 COCOeval 会跑两遍时间换清晰度看你怎么权衡。分布式训练时compute_metrics只在主进程执行self.coco也是每个进程各自加载的所以扩展评估本身不会有一致性问题。真正要注意的是results的合并顺序MMEngine 的collect_device默认是 cpu各进程的预测按 rank 顺序拼接img_id不会重复loadRes不会
阅读完成 · 觉得有帮助?