mmdetection3 COCO评估指标定制:分尺度、逐类AP与最佳F1

发布时间:2026/10/27 23:26:26
mmdetection3 COCO评估指标定制:分尺度、逐类AP与最佳F1

1. 原生 COCO 指标为什么不够用

1.1 一次被追问出来的评估短板

前段时间接手一个工业质检类的检测任务,模型在验证集上跑出bbox_mAP = 0.412,数字看着还行,但汇报的时候问题一个接一个砸过来:漏检的小螺丝钉算不算进去了、哪个缺陷类别拖了后腿、如果把置信度阈值从 0.3 调到 0.5 召回会掉多少、产线上允许的误检率是多少。我盯着日志里那十几行bbox_mAP / bbox_mAP_50 / bbox_mAP_75 / bbox_mAR_100才发现,mmdetection3 默认吐出来的这套数字,回答不了这些追问。

这不是框架的问题,是 COCO 这套评估协议本身的设计目标决定的——它是为了在通用数据集上做横向排名,追求的是"一个数说清楚谁更强",而不是"告诉我模型哪里弱"。我在做mmdetection3改进cocoapi评估指标这个事儿的过程中慢慢摸清了:原生 CocoMetric 提供的 12 个统计量,是 pycocotools 里COCOeval.summarize()写死的切片组合,你要的任何一个"非标"维度,都得自己从precision矩阵里挖出来。

这篇东西写给谁看:手上正在用 mmdetection3 做检测任务、跑通了训练但卡在"评估结果说不清楚"这一步的同学。不要求你读过 pycocotools 源码,但至少要能看懂val_evaluator的配置字典,知道bbox和segm是两套结果格式。我会从评估链路讲起,把三种改法(改配置、继承类、重写 COCOeval)按侵入程度从小到大铺开,代码都是能直接抄的。

1.2 业务侧真正想要的四类数字

先把需求捋清楚,不然改起来没方向。我把这些年被业务方问过的评估需求归了四类,基本能覆盖 90% 的场景:

第一类是分尺度指标。COCO 原生给了 small / medium / large 三档,边界是 32² 和 96² 像素。但工业场景里 32×32 的"小目标"可能已经算大的了,很多缺陷目标只有 8×8 甚至更小。这时候原生bbox_mAP_s会把一堆不同量级的目标混在一起算,看不出真正的短板在哪。需要自己重划面积区间,比如 0-256、256-1024、1024-9216、9216 以上(单位是像素面积)。

第二类是逐类指标。classwise=True能打开每类 AP,但输出的是一坨以类别名结尾的键,不方便直接看出"最差的 5 个类"。而且类别样本量差异极大时,宏平均(每类等权)和微平均(按样本数加权)给出的结论可能完全相反,这两个数得同时看。

第三类是工作点指标。mAP 是整条 PR 曲线下的面积,跟实际部署时选的那个置信度阈值没关系。产线关心的是"我把阈值定在 0.4,精确率多少、召回率多少、F1 多少",这就需要从评估结果里重建 PR 曲线,找到 F1 最大的那个工作点及其对应阈值。

第四类是错误归因。模型到底是漏检多还是误检多?误检集中在背景还是集中在某个易混类别上?这些需要混淆矩阵和 TP/FP/FN 的分布统计,原生指标一个都不给。

提醒一句:不要一上来就想着"把指标改得更花哨",先确认业务方真正会看的是哪两三个数。我见过把评估模块堆到两千行、最后汇报还在用 mAP 的项目,纯属浪费。

1.3 改造路线:从改配置到改匹配规则

明确了需求,接下来是选型。按侵入程度排,有这么四条路:

路线侵入程度适用场景维护成本
只调 CocoMetric 参数零只要逐类 AP、调整 maxDets极低,升级不炸
继承 CocoMetric 重写compute_metrics低加分尺度、工作点、导出 CSV低,跟版本走
写独立 Metric 类中要换掉整套流程、多数据集联合评估中
定制 COCOeval 子类高改匹配规则、类别合并、软匹配高,要盯 pycocotools 版本

我的建议是:能用第一条解决的绝不上第二条,能用第二条解决的绝不上第四条。原因很实在——pycocotools 虽然多年没大改,但COCOeval内部的evalImgs索引顺序、_paramsEval的拷贝时机这些细节,一旦你依赖上了,换个版本就得重新对一遍。而CocoMetric.compute_metrics这个口子是框架明确留给用户覆写的,稳定得多。

后面第 3 到第 5 章就按这个顺序展开,每一层都给可运行的代码。

2. mmdetection3 的评估链路与可下手的位置

2.1 从 ValLoop 到 CocoMetric 的调用顺序

改之前得先知道数据是怎么流的。mmdetection3 把训练引擎换成了 mmengine,评估这一块被拆成了三层:ValLoop负责调度、Evaluator负责管理多个 metric、CocoMetric负责具体计算。完整链路大概是这样:

  1. ValLoop.run()遍历val_dataloader,把每个 batch 喂给模型,拿到data_samples(一堆DetDataSample);
  2. 每个 batch 结束后调self.evaluator.process(data_samples=..., data_batch=...),这个调用会依次转发给每一个 metric 实例;
  3. CocoMetric.process()把DetDataSample里的pred_instances抠出来,转成 COCO 结果格式([{'image_id':..., 'category_id':..., 'bbox':..., 'score':...}, ...]),存进self.results列表;
  4. 全部跑完后Evaluator.evaluate(size)调每个 metric 的evaluate(size),内部先做分布式结果汇总(collect_device决定用 CPU 还是 GPU 通信),再调compute_metrics(self.results);
  5. CocoMetric.compute_metrics()里构造CocoEvaluator,把结果灌进去,跑evaluate()→accumulate()→summarize(),最后get_results()返回一个扁平字典,键名形如bbox_mAP、bbox_mAP_50。

关键点在第 5 步:compute_metrics是唯一需要你动的地方。前面四步框架已经处理得很干净了,包括分布式汇总——这一点很重要,因为你拿到results的时候,它已经是全量验证集的结果了,不用自己操心多卡拼接。

2.2 CocoMetric 里三个必须搞清楚的方法

CocoMetric内部真正干活的是三个钩子,我按调用顺序说:

process(data_batch, data_samples):结果格式化的地方。如果你的改进涉及"过滤低分框""类别 ID 重映射""坐标裁剪到图像边界",在这里做最合适,因为改动只影响提交给评估的结果,不动模型输出。注意这个方法在分布式下是每个 rank 各跑各的,别在这里写全局状态。

compute_metrics(results):核心。它做四件事——校验img_id是否齐全、构造CocoEvaluator、把结果灌进去跑评估、get_results()拿回指标字典。你覆写的时候,标准姿势是先metrics = super().compute_metrics(results)拿到原生结果,再从self.coco_eval里把中间量掏出来算扩展指标,最后合并成一个字典返回。这样原生指标一个不丢,扩展指标是增量。

self.coco_eval:类型是BaseCocoEvaluator,本质是个dict子类,键是iou_type('bbox'或'segm'),值才是真正的pycocotools.cocoeval.COCOeval实例。所以取中间量的写法是self.coco_eval[self.iou_type].eval['precision']。这个细节很多人卡住,因为下意识会写self.coco_eval.eval,然后报AttributeError。

注意:self.coco_eval只在compute_metrics执行期间才有值,跑完就留着上一次的结果。别在process阶段去访问它,一定是None。

2.3 pycocotools 内部:Params 与 precision 矩阵

所有扩展指标都是从COCOeval的两个东西里挖出来的:params(评估参数)和eval(评估结果缓存)。搞懂这两个,剩下都是数组切片。

params里有五个关键字段:

  • iouThrs:默认np.linspace(.5, 0.95, 10),即 0.5 到 0.95 步长 0.05,共 10 个阈值;
  • recThrs:默认 101 个点,np.linspace(.0, 1.00, 101),用于把 PR 曲线插值成固定长度的召回网格——这是 COCO 指标能跨数据集比较的根本原因;
  • maxDets:默认[1, 10, 100],控制每张图最多取多少个预测框参与评估;
  • areaRng:默认[[0, 1e10], [0, 32²], [32², 96²], [96², 1e10]],四档面积区间;
  • useCats:默认 1,表示逐类匹配。设成 0 就变成"不管类别,只要框对就算对",这个在某些"只关心定位不关心分类"的场景下有奇效。

eval里有一个核心矩阵:eval['precision'],形状是(T, R, K, A, M),分别是 IoU 阈值数、召回点数、类别数、面积区间数、maxDets 数。矩阵里每个元素是该组合下的精确率,无效位置填的是 -1 而不是 0 或 NaN。这个 -1 是个大坑,直接np.mean会把结果拉低,必须先用p[p < 0] = np.nan处理掉。

eval['recall']形状是(T, K, A, M),少了召回点那一维。

还有一个不太被提起但极好用的东西:evalImgs。它在evaluate()之后生成,是一个列表,每个元素是"某张图 + 某个类 + 某个面积区间 + 某个 maxDet"组合下的详细匹配结果,包含dtScores、dtMatches、gtIgnore等字段。想重建 PR 曲线、算最佳 F1、做错误归因,都得从这儿下手。它的索引顺序是cat → areaRng → maxDet → img,写成公式就是idx = ((k * A + a) * M + m) * I + i。这个顺序我记错过一次,排查了两小时,建议写代码时先用len(evalImgs)和K*A*M*I对一下数。

2.4 改造点定位表

把上面这些串起来,不同需求对应不同的下手位置:

想做的事下手位置难度
打开逐类 AP配置里classwise=True极低
改 maxDets配置里proposal_nums=(...)极低
加 IoU=0.9 的严格 AP重写compute_metrics,切precision的 T 维低
自定义面积分档重写compute_metrics,改params.areaRng后重跑中
最佳 F1 与对应阈值从evalImgs重建 PR 曲线中
类别合并后评估重写process做 ID 重映射中
换匹配规则(中心点/软匹配)继承COCOeval覆写evaluateImg高

3. 零侵入改法:只用配置和参数

3.1 classwise 与 proposal_nums 的正确用法

动手写代码之前,先确认参数层面能榨出多少东西。CocoMetric暴露的参数不多,但有两个经常被忽略:

val_evaluator = dict( type='CocoMetric', ann_file='data/mydata/annotations/instances_val.json', metric='bbox', classwise=True, # 打开逐类 AP proposal_nums=(100, 300, 1000), # 对应 maxDets format_only=False, )

classwise=True打开后,get_results()会额外吐出每类的 AP,键名一般是bbox_AP_<类别名>这种形式。这里有个坑:类别名里如果有空格、斜杠、中文,键名会很脏,有些日志系统或者 TensorBoard 写入时会直接报错。稳妥的做法是在数据集metainfo里把classes定义成 ASCII 下划线风格的短名,展示层再做映射。

proposal_nums这个参数名有点迷惑,它不是"提案框数量",而是直接对应 COCOeval 的maxDets。默认(100, 300, 1000),意味着 AR 会在三个召回上限下各算一次,但 AP 只取最大的那个(1000)。如果你的场景预测框特别多(比如密集小目标,一张图几千个框),默认的 1000 可能不够,会导致 AP 被截断。反过来,如果你的场景一张图最多十几个目标,把maxDets调小(比如(10, 50, 100))能显著加快评估速度,因为evaluateImg里的排序和匹配是O(D log D + D·G)的。

实测下来,一张 2000×2000 的工业图,如果模型输出 3000 个框,maxDets从 1000 调到 3000,评估耗时涨了差不多 2.5 倍。所以别盲目调大,先统计一下验证集的平均预测框数量再定。

3.2 用 metric 字段切换结果类型

metric参数决定提交给评估的是什么格式的结果:'bbox'用检测框、'segm'用分割 mask、'proposal'用于 RPN 的召回评估。这个字段很多人配错了以为指标算错了——比如实例分割模型,metric='bbox'算的是框的 AP,metric='segm'才是 mask 的 AP。两个都想要的话,配两个 metric 实例:

val_evaluator = [ dict(type='CocoMetric', ann_file=ann, metric='bbox'), dict(type='CocoMetric', ann_file=ann, metric='segm'), ]

这样Evaluator会依次调两个实例的process,各自算各自的。代价是标注 json 被加载两次、结果格式化两次,内存和耗时会翻倍。对精度要求不高的中间验证,我一般只在最后一个 epoch 加 segm。

3.3 配置样例与结果验证

把上面的东西组合起来,一个中等强度的配置大概长这样:

val_evaluator = dict( type='CocoMetric', ann_file='data/mydata/annotations/instances_val.json', metric='bbox', classwise=True, proposal_nums=(100, 300, 1000), format_only=False, outfile_prefix='work_dirs/det/val_results', # 保留原始结果 json )

outfile_prefix这个参数值得单独说:它会把你提交给 COCOeval 的预测结果落盘成.bbox.json,这个文件后面做离线分析、画 PR 曲线、跑自定义脚本都用得上,比每次重新推理一遍省事得多。文件命名规则是<prefix>.<iou_type>.json。

跑起来之后,验证一下结果对不对:

python tools/test.py work_dirs/det/det.py work_dirs/det/epoch_12.pth --work-dir work_dirs/det/eval

日志里除了常规的coco/bbox_mAP,会多出一大串coco/bbox_AP_<类名>。我习惯把日志重定向到文件,然后 grep 一下:

grep -o 'coco/bbox_AP_[^:]*: [0-9.]*' eval.log | sort -t: -k2 -n | head -5

出来就是最差的 5 个类。这个操作零成本,但信息量比单个 mAP 大得多——我遇到过整体 mAP 0.42、但某个关键类只有 0.08 的情况,如果不是打开 classwise,根本发现不了。

4. 继承 CocoMetric 扩展指标

4.1 整体骨架与 compute_metrics 的重写套路

参数层面榨干之后,就必须动代码了。主力方案是继承CocoMetric,覆写compute_metrics。骨架是这样的:

import numpy as np from mmdet.evaluation import CocoMetric from mmengine.registry import METRICS @METRICS.register_module() class DetailCocoMetric(CocoMetric): """在原生 COCO 指标基础上补充分尺度、逐类、工作点指标。""" def __init__(self, tiny_area_thr=256, # 自定义“微小目标”的上界(像素面积) small_area_thr=1024, mid_area_thr=9216, iou_list=(0.5, 0.75, 0.9), **kwargs): super().__init__(**kwargs) self.tiny_area_thr = tiny_area_thr self.small_area_thr = small_area_thr self.mid_area_thr = mid_area_thr self.iou_list = iou_list def compute_metrics(self, results): # 1) 先让父类把原生指标算完,self.coco_eval 会被填好 metrics = super().compute_metrics(results) coco_eval = self.coco_eval[self.iou_type] # 2) 原生指标已经 summarize 过,此时 precision 矩阵可用 metrics.update(self._strict_ap(coco_eval)) metrics.update(self._per_class_ap(coco_eval)) # 3) 改面积区间需要重跑 evaluate,放在最后,避免影响前面的计算 metrics.update(self._area_ap(coco_eval)) return metrics

这个骨架有三个设计要点值得展开讲。

第一,先调父类。父类的compute_metrics干了脏活累活:校验结果完整性、构造CocoEvaluator、跑evaluate/accumulate/summarize。你自己从头写一遍,很容易漏掉coco_results的格式校验(比如category_id转 int、img_id存在性检查),最后报一个莫名其妙的KeyError。

第二,所有扩展指标都是以增量的方式合并。这样即使你的扩展指标算挂了,原生指标还在,不至于整轮评估白跑。我在调试期就把每个_xxx方法用try/except包起来,出错时打印警告返回空字典,方便定位。

第三,改params的操作放最后。因为改areaRng之后必须重跑evaluate(),而重跑会覆盖eval里的precision,前面基于旧areaRng算出来的指标就废了。顺序搞反过一次,小目标 AP 全变成了整体 AP,排查了半天。

4.2 自定义面积区间:小目标到底有多小

COCO 的 32²/96² 分档对工业、遥感、医学场景基本没意义。重划区间的代码如下:

def _area_ap(self, coco_eval): p = coco_eval.params # 备份原始设置,算完必须还原,否则会污染后续可能的调用 origin_rng, origin_lbl = p.areaRng, p.areaRngLbl areas = [ [0, 1e10], [0, self.tiny_area_thr], [self.tiny_area_thr, self.small_area_thr], [self.small_area_thr, self.mid_area_thr], [self.mid_area_thr, 1e10], ] p.areaRng = areas p.areaRngLbl = ['all', 'tiny', 'small', 'mid', 'big'] try: coco_eval.evaluate() # 必须重跑,面积过滤发生在 evaluateImg 里 coco_eval.accumulate() # 重新生成 precision / recall 矩阵 finally: pass precision = coco_eval.eval['precision'].copy() # (T, R, K, A, M) precision[precision < 0] = np.nan out = {} # 只用 IoU=0.5:0.95 全区间、maxDets 取最大值 for a, lbl in enumerate(p.areaRngLbl[1:], start=1): val = np.nanmean(precision[:, :, :, a, -1]) out[f'area_AP_{lbl}'] = float(val) if not np.isnan(val) else -1.0 # 还原参数,避免影响后续逻辑 p.areaRng, p.areaRngLbl = origin_rng, origin_lbl return out

这段代码有两个必须说清楚的细节。

细节一:面积过滤对 GT 和 DT 同时生效。evaluateImg里既会按面积筛 GT,也会按面积筛预测框。也就是说,你算"小目标 AP"的时候,一个预测得很准但框特别大的结果,即使它匹配上了小 GT,也会被排除掉。这导致手算的数字经常和_area_ap对不上——很多人以为只是筛 GT,其实是双向筛。

细节二:不要用summarize()的 stats。summarize()里对areaRng的下标是硬编码的(APs 取索引 3、APm 取 4、APl 取 5),你塞了 5 个区间进去,stats 的位置全乱了。所以自定义面积之后,自己从precision矩阵里取平均,别碰stats。

关于区间的选择,我的经验是:先用一个脚本统计验证集所有 GT 框的面积分布(w * h),看分位数,比如 10%、30%、60%、90% 分位点在哪,再据此定阈值。拍脑袋定[0, 256]这种很容易出现某一档样本数为 0,nanmean出来是nan,指标直接没法看。

4.3 固定 IoU 阈值与逐类 AP 的提取

precision矩阵的 T 维对应 10 个 IoU 阈值。原生只给了 0.5 和 0.75 两个,实际业务里经常需要更严格的阈值——比如工业定位要求极高精度,IoU=0.9 下的 AP 才有参考价值。

def _strict_ap(self, coco_eval): precision = coco_eval.eval['precision'].copy() precision[precision < 0] = np.nan iou_thrs = coco_eval.params.iouThrs # (T,) out = {} for thr in self.iou_list: t = int(np.argmin(np.abs(iou_thrs - thr))) # 找最近的阈值下标 # 面积取 all(0),maxDets 取最大(-1),对 T 之外的维度求均值 val = np.nanmean(precision[t, :, :, 0, -1]) out[f'AP@{int(thr * 100)}'] = float(val) if not np.isnan(val) else -1.0 return out

这里用argmin(abs(...))而不是np.where(iou_thrs == thr),是因为linspace(.5, .95, 10)生成的浮点数可能有精度误差,直接比较相等会漏。比如 0.75 在某些 numpy 版本下算出来是 0.7500000000000001,==判断就挂了。这个坑我在别的项目里踩过,养成"浮点找下标一律用最近邻"的习惯能省很多事。

逐类 AP 的提取稍微啰嗦一点,因为要把 index 映射回类别名:

def _per_class_ap(self, coco_eval): precision = coco_eval.eval['precision'].copy() precision[precision < 0] = np.nan cat_ids = coco_eval.params.catIds # 拿到 id -> 名字的映射,键名才好看 id2name = {c['id']: c['name'] for c in self._coco_api.dataset['categories']} per_class = np.nanmean(precision[:, :, :, 0, -1], axis=(0, 1)) # (K,) out = {} valid = [] for cid, ap in zip(cat_ids, per_class): name = id2name.get(cid, str(cid)) safe_name = name.replace(' ', '_').replace('/', '_') if np.isnan(ap): out[f'AP_{safe_name}'] = -1.0 continue out[f'AP_{safe_name}'] = float(ap) valid.append(float(ap)) if valid: arr = np.array(valid) out['AP_macro'] = float(arr.mean()) # 宏平均:每类等权 out['AP_min'] = float(arr.min()) # 最差类 out['AP_p10'] = float(np.percentile(arr, 10)) # 10 分位,抗极端值 return out

宏平均 vs 微平均这个话题值得多说两句。pycocotools 原生算的 mAP 本质上是微平均——每次求均值时,样本多的类别贡献更多的格子。这在类别极不平衡的数据集上会严重失真:一个 5000 个实例的大类 AP 0.9,一个 30 个实例的小类 AP 0.1,原生 mAP 看起来还有 0.85,但小类基本等于没学会。

我的做法是三个数同时报:原生 mAP(微平均,看整体)、AP_macro(宏平均,看类别均衡性)、AP_min(最差类,看下限)。如果AP_macro和原生 mAP 差距超过 0.1,基本可以断定存在严重的长尾问题,得上重采样或者类别平衡损失了。

另外AP_p10这个指标是我自己加的,用 10 分位数代替最小值,避免某一个样本只有 2 个实例的类别把结论带偏。

4.4 从 evalImgs 重建 PR 曲线拿最佳 F1

这是整个改造里最有价值也最费劲的一块。mAP 是整条曲线下的面积,但实际部署必须选一个阈值,而"最佳工作点"这个信息原生指标完全不给。

从数学上讲,precision矩阵里的值是插值后的(在 101 个固定召回点上采样),用它反推 PR 曲线会损失信息。正确做法是从evalImgs里拿原始的dtScores和dtMatches,自己累计 TP/FP。

def _best_f1(self, coco_eval, iou_thr=0.5, area_idx=0): """按类别统计最佳 F1 与对应置信度阈值。""" pe = coco_eval._paramsEval # evaluate() 时的参数快照 img_ids = pe.imgIds cat_ids = pe.catIds A = len(pe.areaRng) M = len(pe.maxDets) I = len(img_ids) iou_thrs = pe.iouThrs t = int(np.argmin(np.abs(iou_thrs - iou_thr))) m = M - 1 # 取最大的 maxDets results = {} for k in range(len(cat_ids)): scores, matched = [], [] n_pos = 0 for i in range(I): idx = ((k * A + area_idx) * M + m) * I + i E = coco_eval.evalImgs[idx] if E is None: continue gt_ignore = E['gtIgnore'] n_pos += int((gt_ignore == 0).sum()) dt_scores = np.asarray(E['dtScores'], dtype=np.float64) if dt_scores.size == 0: continue # 已按分数降序,但重新排一次保证顺序,代价可忽略 order = np.argsort(-dt_scores) dt_scores = dt_scores[order] dt_match = np.asarray(E['dtMatches'][t])[order] scores.append(dt_scores) matched.append((dt_match > 0).astype(np.float64)) if not scores or n_pos == 0: continue scores = np.concatenate(scores) matched = np.concatenate(matched) order = np.argsort(-scores) # 全局重排,跨图合并 scores, matched = scores[order], matched[order] tp = np.cumsum(matched) fp = np.cumsum(1.0 - matched) rec = tp / max(n_pos, 1) pre = tp / np.maximum(tp + fp, 1e-9) f1 = 2 * pre * rec / np.maximum(pre + rec, 1e-9) pos = int(np.argmax(f1)) name = str(cat_ids[k]).replace(' ', '_') results[f'bestF1_{name}'] = float(f1[pos]) results[f'bestThr_{name}'] = float(scores[pos]) results[f'P@best_{name}'] = float(pre[pos]) results[f'R@best_{name}'] = float(rec[pos]) return results

几个需要说明的点:

_paramsEval是什么。COCOeval.evaluate()开头会self._paramsEval = copy.deepcopy(self.params),把当时那份参数存下来。因为evalImgs是按那份参数生成的,用_paramsEval去反推索引才安全。用self.params的话,如果你中途改过areaRng,索引就对不上了——这是我在做_area_ap时踩的坑,两个方法混着调,F1 全乱了。

为什么用< 0过滤非法值。precision矩阵无效填 -1,但evalImgs里dtMatches的无效值填的是 -1(表示没匹配上),有效匹配填的是 GT 的 id(≥1)。所以判断匹配用> 0,不能用!= 0。

跨图合并的排序问题。evalImgs是按图分开存的,要算全局 PR 曲线必须把所有图的dtScores拼起来重新排序。这一步不能省,否则算出来的是"每张图单独算 F1 再平均",跟业务上"全局阈值"的语义完全不同。

关于阈值的使用。算出来的bestThr是数据驱动的,别直接拿去上线。稳妥做法是看这个阈值在相邻几个 epoch 上的稳定性,波动大就取个保守值。我的经验是:最佳 F1 的阈值通常比 mAP 最优时的高 0.1-0.15,因为 COCO 的插值方式偏向召回。

4.5 结果落盘与可视化

指标算出来只在日志里躺一行是不够的,得能对比、能画图。我一般在compute_metrics末尾加一个 CSV 导出:

def _dump_csv(self, metrics, path='work_dirs/eval_detail.csv'): import csv with open(path, 'w', newline='') as f: w = csv.writer(f) w.writerow(['metric', 'value']) for k, v in sorted(metrics.items()): w.writerow([k, v])

配合outfile_prefix落盘的预测 json,后续想做混淆矩阵、看错误样本,都可以脱离框架用独立脚本做。这样做的好处是:评估阶段的耗时不会因为你要做各种分析而膨胀,分析可以在跑完之后慢慢做,甚至放到另一台机器上。

PR 曲线的画法很简单,把上面_best_f1里的pre、rec拿去matplotlib画就行,记得按 score 降序、加plt.step而不是plt.plot(PR 是阶梯函数)。我习惯把每个类的曲线叠在一张图上,颜色深浅按 AP 排,最差的类用红色加粗,一眼就能看出哪个类是短板。

5. 更彻底的方案:独立 COCOeval 与自定义匹配

5.1 重写 summarize 输出结构化指标

如果继承CocoMetric还不够,比如你想让summarize()直接吐出结构化字典而不是打印一堆字符串,或者想把 stats 的顺序彻底重排,那就得动COCOeval子类了。

from pycocotools.cocoeval import COCOeval import numpy as np class StructuredCOCOeval(COCOeval): def summarize(self): """不再打印,返回结构化字典;stats 的键名由我们自己定。""" if not self.eval: raise RuntimeError('请先调用 evaluate() 和 accumulate()') p = self.params out = {} # 复制官方 summarize 的逻辑,但把结果存成字典 def _set(t, a, m, key): s = self.eval['precision'][t, :, :, a, m] s = s[s > -1] # 剔除无效位 out[key] = float(np.mean(s)) if s.size else -1.0 for t_idx, thr in enumerate(p.iouThrs): for a_idx, lbl in enumerate(p.areaRngLbl): _set(t_idx, a_idx, -1, f'AP@{thr:.2f}_{lbl}') # 召回指标单独算 for m_idx, max_det in enumerate(p.maxDets): r = self.eval['recall'][:, :, 0, m_idx] r = r[r > -1] out[f'AR_maxDets{max_det}'] = float(np.mean(r)) if r.size else -1.0 self.stats_dict = out # 挂到实例上,CocoMetric 里取用 return out

注意s > -1这个过滤。官方_summarizeDets里用的是s[s > -1],而不是先把 -1 变 NaN 再nanmean。这两种写法在绝大多数情况下结果一样,但存在一个边界差异:如果某个类别完全没有 GT,precision里对应的整块都是 -1,过滤后s.size == 0,官方返回 -1,而nanmean会报警告并返回 NaN。要跟官方数字对齐,就用过滤法。

这里输出的键名AP@0.50_all这类,信息密度比原生的 12 个数字大得多——10 个 IoU × 4 个面积 = 40 个 AP 值,再加上 3 个 AR,足够画一张热力图了。热力图的横轴是 IoU 阈值,纵轴是面积档,颜色是 AP,一眼就能看出模型是"定位不准"还是"小目标不行":如果左上角(高 IoU + 大目标)颜色深、右下角浅,说明定位精细度是瓶颈;如果只有小目标那一行整体偏浅,那是分辨率或者感受野的问题。

5.2 独立 Metric 类的注册与生命周期

有时候需求不是"补几个指标",而是"整套评估流程都要换"。比如要同时评估检测和跟踪、或者要在评估时顺带算一批业务规则命中率。这时候写独立 Metric 类比硬塞进CocoMetric干净得多。

一个最小的独立 Metric 类需要实现四个方法:

from mmengine.evaluator import BaseMetric from mmengine.registry import METRICS @METRICS.register_module() class RuleMetric(BaseMetric): def __init__(self, rule_config=None, collect_device='cpu', prefix=None): super().__init__(collect_device=collect_device, prefix=prefix) self.rule_config = rule_config or {} def process(self, data_b