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 以上单位是像素面积。第二类是逐类指标。classwiseTrue能打开每类 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负责具体计算。完整链路大概是这样ValLoop.run()遍历val_dataloader把每个 batch 喂给模型拿到data_samples一堆DetDataSample每个 batch 结束后调self.evaluator.process(data_samples..., data_batch...)这个调用会依次转发给每一个 metric 实例CocoMetric.process()把DetDataSample里的pred_instances抠出来转成 COCO 结果格式[{image_id:..., category_id:..., bbox:..., score:...}, ...]存进self.results列表全部跑完后Evaluator.evaluate(size)调每个 metric 的evaluate(size)内部先做分布式结果汇总collect_device决定用 CPU 还是 GPU 通信再调compute_metrics(self.results)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_typebbox或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配置里classwiseTrue极低改 maxDets配置里proposal_nums(...)极低加 IoU0.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( typeCocoMetric, ann_filedata/mydata/annotations/instances_val.json, metricbbox, classwiseTrue, # 打开逐类 AP proposal_nums(100, 300, 1000), # 对应 maxDets format_onlyFalse, )classwiseTrue打开后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 的召回评估。这个字段很多人配错了以为指标算错了——比如实例分割模型metricbbox算的是框的 APmetricsegm才是 mask 的 AP。两个都想要的话配两个 metric 实例val_evaluator [ dict(typeCocoMetric, ann_fileann, metricbbox), dict(typeCocoMetric, ann_fileann, metricsegm), ]这样Evaluator会依次调两个实例的process各自算各自的。代价是标注 json 被加载两次、结果格式化两次内存和耗时会翻倍。对精度要求不高的中间验证我一般只在最后一个 epoch 加 segm。3.3 配置样例与结果验证把上面的东西组合起来一个中等强度的配置大概长这样val_evaluator dict( typeCocoMetric, ann_filedata/mydata/annotations/instances_val.json, metricbbox, classwiseTrue, proposal_nums(100, 300, 1000), format_onlyFalse, outfile_prefixwork_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_thr256, # 自定义“微小目标”的上界像素面积 small_area_thr1024, mid_area_thr9216, 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 {} # 只用 IoU0.5:0.95 全区间、maxDets 取最大值 for a, lbl in enumerate(p.areaRngLbl[1:], start1): val np.nanmean(precision[:, :, :, a, -1]) out[farea_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]这种很容易出现某一档样本数为 0nanmean出来是nan指标直接没法看。4.3 固定 IoU 阈值与逐类 AP 的提取precision矩阵的 T 维对应 10 个 IoU 阈值。原生只给了 0.5 和 0.75 两个实际业务里经常需要更严格的阈值——比如工业定位要求极高精度IoU0.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[fAP{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[fAP_{safe_name}] -1.0 continue out[fAP_{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_thr0.5, area_idx0): 按类别统计最佳 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], dtypenp.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[fbestF1_{name}] float(f1[pos]) results[fbestThr_{name}] float(scores[pos]) results[fPbest_{name}] float(pre[pos]) results[fRbest_{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, pathwork_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.plotPR 是阶梯函数。我习惯把每个类的曲线叠在一张图上颜色深浅按 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, fAP{thr:.2f}_{lbl}) # 召回指标单独算 for m_idx, max_det in enumerate(p.maxDets): r self.eval[recall][:, :, 0, m_idx] r r[r -1] out[fAR_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。这两种写法在绝大多数情况下结果一样但存在一个边界差异如果某个类别完全没有 GTprecision里对应的整块都是 -1过滤后s.size 0官方返回 -1而nanmean会报警告并返回 NaN。要跟官方数字对齐就用过滤法。这里输出的键名AP0.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_configNone, collect_devicecpu, prefixNone): super().__init__(collect_devicecollect_device, prefixprefix) self.rule_config rule_config or {} def process(self, data_b
阅读完成 · 觉得有帮助?