多类目标检测项目跑到验收阶段时,最常被问到的一句话是"哪个类别拖后腿了"。可当你打开 MMDetection 3.x 的训练日志,看到的是bbox_mAP、bbox_mAP_50、bbox_mAP_75、bbox_mAP_s/m/l加一串bbox_mAR_*,十几行数字整整齐齐,唯独没有单个类别的成绩。这些数字是 COCO 格式数据集下所有类别平摊之后的结果,一个稀烂的小类和十个表现优秀的大类混在一起求平均,均值看起来还行,实际上线后某些类根本没法用。问题不在于框架不给,而在于大多数人没有把classwise这个开关打开,或者打开了却不知道怎么把结果从控制台里捞出来做分析。这篇内容围绕 MMDetection 3.x + COCO 格式标注这套组合,讲清楚每类 mAP 是怎么算出来的、在哪几个位置可以打开、开完之后常见的数据对齐坑,以及拿到数字后怎么判断该补数据还是改模型。适合已经跑通tools/train.py、但还停留在只看总 mAP 阶段的读者,也适合需要向非技术同事汇报分类型效果的场景。
1. 默认日志只给聚合值,背后的计算链路决定了你看到什么
这个问题得从 COCO 评估的数学结构说起,不然你会误以为框架"藏"了数据,其实数据一直都在,只是没有被打印出来。
1.1 那十几行数字是把类别维度平均掉的产物
COCO 的评估核心在pycocotools里,COCOeval.accumulate()算完之后会得到一个名为precision的四维张量,形状是[T, R, K, A, M]。T是 IoU 阈值数量,从 0.50 到 0.95 每隔 0.05 取一个,共 10 个;R是召回率采样点,101 个;K是类别数;A是面积范围,依次为 all、small、medium、large;M是每张图最多保留的检测框数量,通常是 1、10、100。总 mAP 的计算方式,是把这个张量里所有>-1的有效值全部取出来,直接求算术平均。
关键就在"全部"这两个字上。类别维度K被一视同仁地摊平了,不管这个类有五千个标注框还是只有二十个,在求平均时权重完全一样。所以当你的数据集里有person这种上万实例的大类,也有stapler这种只有三十来个实例的长尾类,总 mAP 完全无法反映后者的真实水平。更麻烦的是,如果某个类在验证集里压根没有真值标注,它在precision里对应的整片区域都是-1,会被自动剔除出平均,于是总 mAP 反而可能比"所有类都参与计算"时更高,这是一种相当隐蔽的失真。
理解了这个张量结构,后面的操作就顺理成章了:想拿到单类成绩,本质上就是固定K这一维的索引,再去求剩下的平均。classwise参数做的事情,就是从precision[:, :, idx, 0, -1]这个切片里取值求均值,注意第三个维度是类别下标,第四个维度固定为 0 表示 area=all,第五个维度固定为 -1 表示 maxDets=100。也就是说,控制台里打出来的单类 AP,和总 mAP 用的是同一套 IoU 阈值区间,只是不再跨类平均。
1.2 从 2.x 到 3.x,评估入口整体换了一套机制
如果你之前用过旧版本,最直观的困惑是--eval bbox这个命令行参数不见了。旧版本的评估依赖EvalHook和DistEvalHook,挂在 runner 上,训练结束后再靠tools/test.py --eval触发。3.x 把这套机制整体交给了mmengine的BaseMetric体系,评估器变成配置里的普通字段,挂在val_evaluator和test_evaluator上,训练过程中的验证由ValLoop驱动,间隔由val_interval控制,起始轮次由val_begin控制。
这个改动带来的直接影响是:评估行为完全由配置决定,命令行不再能随口指定bbox还是segm。CocoMetric的metric字段接受字符串或列表,写metric=['bbox', 'segm']就会同时算检测框和分割掩码两套指标。每个CocoMetric实例内部维护一个COCO对象,初始化时读取ann_file,并用metainfo['classes']去反查cat_ids,这个反查过程很关键,后面讲坑的时候会重点展开。
CocoMetric的执行流程分三步:process()逐批收集预测结果并按图像聚合,collect_results()把各进程的结果汇总(单卡走 CPU 版本,多卡走分布式版本,超大验证集会临时落盘),compute_metrics()把结果喂给COCOeval,跑完evaluate、accumulate、summarize三步之后整理成字典。classwise的开关就是在最后这一步起作用的。
1.3 打开 classwise 之后,metrics 字典里多了什么
把classwise=True打开之后,compute_metrics()会在原有十二项指标之外,额外遍历cat_ids,对每个类别单独算一次平均精度,并把结果塞进返回的metrics字典。键名形如coco/<类别名>_bbox_mAP,具体前缀取决于prefix字段的取值,不同小版本的拼装细节可能有一点点出入,但规律是统一的:前缀 + 类别名 + 任务类型 + 指标名。
这里有个容易忽略的细节:这个单类 AP 和总 mAP 在数值上不是简单的组成部分关系。假设三个类别的 AP 分别是 0.80、0.60、0.10,总 mAP 是 0.50,但如果你拿某个类的 AP 去乘以类别数,得到的结果和总 mAP 对不上,原因就是前面说的,总 mAP 在求平均时已经把无效值剔除了,参与平均的类别数可能小于你的类别总数。
另外,metrics字典被mmengine的日志系统接管后,会以表格形式打印。类别数一多,表格会横向铺满整个终端,一行里塞进几十个键值对,肉眼基本没法读。所以在实际项目中,我不建议在训练过程中一直开着classwise,更常见的做法是训练期只看聚合指标,训练结束后单独跑一次测试把详细结果导出来。这个取舍在下一节会具体讲。
2. 三个位置可以打开开关,选哪个取决于你现在处于哪个阶段
classwise不是只有一个入口,配置文件、命令行覆盖、独立测试脚本这三条路都能到,但代价和适用场景完全不同。
2.1 改配置文件:最稳但需要动源文件
标准做法是在数据集的配置文件里找到val_evaluator和test_evaluator两处,都加上classwise=True。这两处必须同时改,因为训练中验证走的是val_evaluator,而tools/test.py走的是test_evaluator,只改一处的话,你以为改好了,实际跑测试时还是老样子。
# 以 COCO 格式自定义数据集为例 metainfo = dict( classes=('class_a', 'class_b', 'class_c', 'class_d'), palette=[(220, 20, 60), (119, 11, 32), (0, 0, 142), (0, 0, 230)] ) val_evaluator = dict( type='CocoMetric', ann_file='data/mydata/annotations/instances_val.json', metric=['bbox'], classwise=True, format_only=False, outfile_prefix='work_dirs/mydata/val_result' ) test_evaluator = dict( type='CocoMetric', ann_file='data/mydata/annotations/instances_val.json', metric=['bbox'], classwise=True, format_only=False, outfile_prefix='work_dirs/mydata/test_result' )metric字段要按你的任务类型写。纯检测写['bbox'];实例分割写['bbox', 'segm'],这时候classwise会对两种任务都输出分类型结果,日志长度直接翻倍。如果你的标注文件里没有segmentation字段却写了segm,评估阶段会直接崩,报错信息通常指向缺失的键,排查时优先看这一条。
outfile_prefix的作用是给格式化输出指定文件前缀,配合format_only使用。很多人误以为设了这个前缀就能拿到指标文件,实际上它只控制预测结果转成 COCO json 时的落盘路径,指标本身还是只打在日志里。这一点下一节会详细说。
2.2 训练期间就开着:日志变长,但能看收敛趋势
有时候你不想等训练完,中途就想知道某个难类有没有在进步。那就让val_evaluator带着classwise=True,配合val_interval控制频率。我一般会把val_interval设成 3 到 5,太频繁的话每轮验证都要跑一遍完整的COCOeval,大验证集上单次耗时可能比一个训练 epoch 还长。
日志膨胀的程度取决于类别数。四五个类还好,表格两行就排完了;八十类的数据集,每次验证会打出几十行,混在训练日志里非常难读。有个实操上的小技巧:训练期间把classwise关掉,只在最后几轮打开,或者干脆训练完单独跑测试,这样日志干净,排查收敛问题时不会被无关信息干扰。
还要注意验证集和训练集的标注文件通常是分开的两个 json。如果你的val_evaluator里ann_file指到了训练集上,指标会虚高得离谱,而且classwise输出的每类 AP 也会跟着虚高,这种错误在只看总 mAP 时反而不容易发现,因为数值"好看"不会引起警觉。
2.3 训完单独跑 tools/test.py:最推荐的路径
绝大多数场景下,最省事的路径是训练完之后单独跑一次测试。好处是配置可以临时覆盖,不用动源文件,也不会污染训练日志。
python tools/test.py \ configs/mydata/faster-rcnn_r50_fpn_1x.py \ work_dirs/faster-rcnn_r50_fpn_1x/epoch_12.pth \ --work-dir work_dirs/faster-rcnn_r50_fpn_1x_test \ --cfg-options test_evaluator.classwise=True--cfg-options后面接的是点号分隔的嵌套键,test_evaluator.classwise=True会在配置加载之后覆盖对应字段。这个机制对临时试验特别友好,你想对比开和不开classwise的结果,跑两条命令就行,配置文件一个字都不用改。
如果你还想同时把预测结果落盘,方便后续用pycocotools自己重算,加一个--out:
python tools/test.py \ configs/mydata/faster-rcnn_r50_fpn_1x.py \ work_dirs/faster-rcnn_r50_fpn_1x/epoch_12.pth \ --work-dir work_dirs/mydata_test \ --out work_dirs/mydata_test/result.pkl \ --cfg-options test_evaluator.classwise=True这里的result.pkl装的是逐图的预测结果(框坐标、类别、分数),不是指标。很多人打开这个文件找 mAP,找了半天没找到,就是因为混淆了"预测结果"和"评估指标"这两样东西。指标只在日志和runner.test()的返回值里。
多卡环境下把python换成bash tools/dist_test.sh,后面跟配置文件、权重、卡数,其余参数一致。多卡测试除了快,还有个附带好处:collect_results走的是分布式版本,中间结果分片存放,遇到超大验证集不容易把单进程内存吃满。
2.4 用几行脚本直接把 metrics 拿进内存
不想解析日志的话,可以直接写一个测试脚本,用Runner跑完之后拿返回值。这种方式最适合需要把结果写进报告、存成 CSV、或者接后续分析流程的场景。
from mmengine.config import Config from mmengine.runner import Runner cfg = Config.fromfile('configs/mydata/faster-rcnn_r50_fpn_1x.py') cfg.load_from = 'work_dirs/faster-rcnn_r50_fpn_1x/epoch_12.pth' cfg.test_evaluator.classwise = True runner = Runner.from_cfg(cfg) metrics = runner.test() for k, v in metrics.items(): if 'bbox_mAP' in k and k != 'coco/bbox_mAP': print(k, round(float(v), 4))Runner.from_cfg会自动根据配置里的test_dataloader和test_evaluator组装TestLoop,runner.test()跑完后返回的字典就是完整指标。这段代码的好处是可以直接在 notebook 里跑,一边看数字一边改配置做对比试验,比反复改文件、反复执行命令行要顺手得多。
需要注意的是,如果配置里缺test_dataloader或者test_evaluator,Runner.from_cfg会直接报错。有些项目的配置文件只写了val_dataloader和val_evaluator,这时候要么把这两项复制一份改名,要么在脚本里手动补上:
cfg.test_dataloader = cfg.val_dataloader cfg.test_evaluator = cfg.val_evaluator cfg.test_evaluator.classwise = True3. 名字对不上、数值打出 nan:多类评估最容易翻车的几个点
classwise本身不复杂,真正花时间的是前面那些数据对齐的准备工作。这一节列的四个问题,每一个我都实际遇到过,排查过程也都不算短。
3.1 metainfo 的 classes 和 json 里的 categories 是两套独立的东西
这是最高频的问题。CocoMetric初始化时会调用self.coco.get_cat_ids(cat_names=self.metainfo['classes']),用类别名去annotations.json的categories数组里逐个匹配,匹配不到会直接抛异常。也就是说,你配置里写的类别名必须和 json 里categories[i]['name']一字不差,包括大小写、空格、连字符。
实际踩过的几个具体情形:配置里写traffic_light,json 里是traffic light,中间是空格不是下划线,直接报错;配置里写Dress,json 里是dress,大小写不一致,同样报错;还有一种更隐蔽的,json 里某个类别的name字段带有首尾空格,肉眼完全看不出来,print(repr(name))才能发现。遇到检查不出问题的报错,先干这件事:
import json with open('data/mydata/annotations/instances_val.json') as f: ann = json.load(f) for c in ann['categories']: print(repr(c['id']), repr(c['name']))把输出和配置文件里的classes元组并排对照,一眼就能看出差异。
cat_id本身不要求连续,也不要求从 1 开始。数据集里出现id: 3, 7, 11这样跳号是正常的,CocoDataset在加载标注时会建立cat2label映射,把原始cat_id映射到 0 到 N-1 的连续训练标签。真正要求对齐的是数量和名称:categories的数量必须等于metainfo['classes']的长度,名称必须逐一匹配。
还有一个容易忽略的顺序问题。在较新的版本里CocoMetric提供了一个sort_categories参数,默认值为False,此时cat_ids按metainfo['classes']的顺序排列,classwise的输出顺序自然也就跟着配置走。如果你的配置是从别处拷来的,classes顺序和 json 里categories的顺序不一致,虽然不会报错,但打印出来的类别名和你的直觉顺序会对不上,分析时容易看错行。把sort_categories=True打开可以让顺序跟随标注文件,或者在写配置时就手动对齐两边的顺序,二选一。
3.2 某个类打出 nan 或者 -1,先分清是哪一种
classwise输出里出现nan是常见现象,成因有三种,处理方式完全不同:
| 现象 | 成因 | 判断方法 | 处理 |
|---|---|---|---|
| 该行全是 nan | 验证集里这个类没有真值标注 | 统计 json 中该category_id的annotation数量 | 该类别无法评估,需补标注或从评估中排除 |
| 该类 AP 为 nan,其他类正常 | 模型在这个类上没有产生任何超过分数阈值的预测 | 检查result.pkl里该label的预测数量 | 检查训练是否收敛、是否有该类的训练样本 |
| 全部类别都异常低 | area或iscrowd字段异常 | 抽查几条标注 | 重新生成标注文件 |
第一种情况最容易被误判成"模型完全学不会这个类"。实际上如果你的验证集里恰恰没有这个类的样本,COCOeval在evaluateImg阶段就把它标成忽略,precision里对应区域全部是-1,取均值时取到空数组,返回nan。这时候无论模型训多久,结果都是nan,跟模型能力无关。
第二种情况反过来,真值有,但模型一个框都没输出。常见于分数阈值过高、该类别在训练集里样本极少、或者类别名虽然对上了但训练标签生成过程中出现了偏移。定位方法很简单,把result.pkl读出来统计一下每个label的预测框数量,和验证集里每个类的真值数量放一起对比:
import pickle from collections import Counter with open('work_dirs/mydata_test/result.pkl', 'rb') as f: results = pickle.load(f) pred_cnt = Counter() for item in results: for lab in item['pred_instances']['labels']: pred_cnt[int(lab)] += 1 print(sorted(pred_cnt.items()))result.pkl的结构在不同版本里略有差异,有些版本存的是DataSample序列化后的字典,有些存的是纯列表,先打印一条看结构再写统计逻辑,比照着文档猜要快。
3.3 area 和 iscrowd:手写转换脚本最容易漏掉的两列
自己写脚本把其他格式转成 COCO 时,绝大多数人只关心image_id、category_id、bbox这三样,area和iscrowd往往被随手填成 0 或者干脆不写。后果是classwise的单类 AP 看起来正常,但bbox_mAP_s、bbox_mAP_m、bbox_mAP_l全线异常,或者干脆全为 -1。
原因是 COCO 在按面积划分评估范围时,读的是标注里的area字段,不是bbox算出来的宽高乘积。bbox模式下标准做法是area = bbox[2] * bbox[3],分割模式下是掩码的像素面积。如果所有标注的area都是 0,那所有真值都会被归到 small 区间,medium 和 large 两个区间没有样本,对应指标自然算不出来。
iscrowd同理,漏写这个字段会让评估器在匹配时把本该忽略的区域当成普通目标,person这类密集场景下的分数会明显偏低。生成标注时老老实实把这两个字段补上:
ann = dict( id=ann_id, image_id=img_id, category_id=cat_id, bbox=[x, y, w, h], area=float(w * h), iscrowd=0, segmentation=[] # 纯检测任务可以留空 )segmentation留空数组在纯检测任务里没问题,但如果评估时metric里带了segm,就会在这一步报错。
3.4 验证集太大的时候,结果收集本身会变成瓶颈
CocoMetric的collect_results在单卡下走的是 CPU 版本,把每个 batch 的结果累积在内存里。验证集有几千张图时还好,上万张图、每张图几百个检测框的情况下,内存占用会相当可观。多卡跑的时候走的是分布式版本,结果会临时写到tmpdir指定的目录,再由主进程读回来合并,这时候tmpdir所在磁盘的剩余空间也得留意。
如果跑到一半出现内存不足或者磁盘写满的报错,优先检查这两个地方。tmpdir可以在配置里指定成容量大的分区:
test_evaluator = dict( type='CocoMetric', ann_file='...', metric=['bbox'], classwise=True, collect_device='cpu', prefix='coco' )collect_device设成cpu是默认值,一般不用改。真正需要调的是多卡测试时的进程数,卡数越多,单卡分到的图越少,峰值内存越低。
4. 把控制台里的散装数字整理成可排序的表格
拿到每类 AP 只是第一步,接下来要做的是把这些数字变成能排序、能对比、能贴进报告的形态。
4.1 解析日志:最省事但对格式敏感的办法
训练或测试结束后,work-dir下会有一个.log文件,里面记录了完整的指标输出。用正则把类别和数值抓出来写进 CSV,是成本最低的方案:
import re import pandas as pd pattern = re.compile(r'coco/(\w+)_bbox_mAP:\s*([\d.]+|-?nan)') rows = [] with open('work_dirs/mydata_test/20240101_120000.log') as f: for line in f: for name, val in pattern.findall(line): if name in ('bbox',): continue try: rows.append({'class': name, 'ap': float(val)}) except ValueError: rows.append({'class': name, 'ap': None}) df = pd.DataFrame(rows).drop_duplicates(subset='class', keep='last') df = df.sort_values('ap', ascending=True) df.to_csv('per_class_ap.csv', index=False)drop_duplicates(keep='last')是为了处理日志里出现多次验证记录的情况,保留最后一次的结果。正则里的\w+要求类别名只包含字母数字下划线,如果你的类别名里有中文或者连字符,需要把字符类放宽成[^_]+之类的写法,否则会抓不到。
这种办法的缺点是对日志格式敏感。框架版本升级、日志格式化配置变化,都可能导致正则失效。所以我在正式项目里更倾向于直接从runner.test()的返回值取数,稳定性高得多。
4.2 用 pycocotools 自己重算,顺便拿到 AP50 和 AP75
classwise只给单类的AP@[.5:.95]。如果你还想知道每个类的AP50和AP75,光靠classwise是拿不到的,得自己调COCOeval。前提是先用format_only模式把预测结果导成 COCO json:
python tools/test.py \ configs/mydata/faster-rcnn_r50_fpn_1x.py \ work_dirs/faster-rcnn_r50_fpn_1x/epoch_12.pth \ --format-only \ --cfg-options test_evaluator.outfile_prefix=work_dirs/mydata_test/pred执行完会在work_dirs/mydata_test/下生成pred.bbox.json。然后自己跑一遍评估:
import numpy as np from pycocotools.coco import COCO from pycocotools.cocoeval import COCOeval ann = COCO('data/mydata/annotations/instances_val.json') dt = ann.loadRes('work_dirs/mydata_test/pred.bbox.json') ev = COCOeval(ann, dt, 'bbox') ev.evaluate() ev.accumulate() ev.summarize() prec = ev.eval['precision'] # [T, R, K, A, M] table = [] for i, cid in enumerate(ev.params.catIds): name = ann.loadCats(cid)[0]['name'] p_all = prec[:, :, i, 0, -1] def mean_valid(arr): v = arr[arr > -1] return float(np.mean(v)) if v.size else float('nan') table.append({ 'class': name, 'AP': mean_valid(p_all), 'AP50': mean_valid(p_all[0]), 'AP75': mean_valid(p_all[5]), 'APs': mean_valid(prec[:, :, i, 1, -1]), 'APm': mean_valid(prec[:, :, i, 2, -1]), 'APl': mean_valid(prec[:, :, i, 3, -1]), }) import pandas as pd df = pd.DataFrame(table).sort_values('AP') print(df.to_string(index=False)) df.to_csv('per_class_full.csv', index=False)这段代码的价值在于一次性把每个类在不同 IoU 阈值、不同尺度下的表现全拿出来了。某个类AP50很高但AP75很低,说明框的位置不够准,边界回归有问题;某个类APl正常但APs很差,说明小目标检测能力不足,可以考虑调整anchor尺度或者用更高分辨率的输入。
ev.params.catIds的顺序和ann.loadCats的返回顺序是一致的,这个索引关系必须严格对齐,因为prec张量的第三个维度就是按catIds顺序排列的。如果你在构建COCOeval之后手动改过params.catIds,张量就错位了,得到的类别名会张冠李戴且不会报错,这是个很危险的静默错误。
4.3 两套结果的交叉校验
classwise打出来的单类 AP 和你自己用COCOeval重算的AP列,理论上应该完全一致。如果对不上,说明中间某个环节出了问题:可能是format_only导出时用了不同的分数阈值,可能是maxDets设置不一致,也可能是classwise的cat_ids顺序和params.catIds顺序不同导致标签错位。
我习惯把两边结果做个差值的绝对值,超过 0.001 就逐行核对。这个小校验动作救过我好几次,尤其是改动过test_evaluator配置之后,肉眼看不出的偏差全靠它兜底。
5. 数字到手之后,怎么判断该补数据还是调模型
每类 AP 是一个结果,不是一个答案。真正难的判断在后面:某个类的分数低,到底是标注质量的问题、样本量的问题,还是模型结构的问题。
5.1 AP 的分布形状比平均分本身更有信息量
把所有类按 AP 从低到高排一遍,看分布形态。如果大部分类集中在 0.6 到 0.8 之间,只有一两个类掉到 0.2 以下,大概率是这两类的数据问题:标注框不完整、类别定义有歧义、样本量太少、或者和其他类的外观过于相似导致混淆。如果整体呈现平滑的阶梯状下降,从 0.8 一路递减到 0.3,那更可能是长尾分布的固有特性,需要从采样策略、损失函数权重这些角度入手。
还有一种典型形态是两极分化:一半类很高,一半类很低,中间几乎没有过渡。这种情况我遇到过两次,一次是类别标注标准在两批数据之间发生了变化,另一次是不同类别来自不同的采集设备,图像质量差异明显。这两种问题都不是靠改模型能解决的,必须回到数据上去。
排名本身也要结合样本量看。一个只有 50 个实例的类,AP 波动范围可能从 0.1 到 0.6 都正常,因为评估结果的置信区间非常宽。反过来,一个上万实例的类 AP 只有 0.35,那就是实打实的问题,样本量足够,说明模型确实没学好或者标注确实有问题。
5.2 AP 低和 AR 低指向的是不同的问题
单看 AP 容易误判,把同一类的召回率拉出来对比会清楚很多。COCO 的 AR 张量形状是[T, K, A, M],没有 recall 维度,取值方式和 AP 类似。判断逻辑大致是这样:
AP 低、AR 也低,说明模型根本没检出这个类的目标,属于检出能力问题。可能原因包括训练样本太少、anchor尺度和目标的实际尺寸不匹配、该类的特征被其他类压制。处理方向是补充样本、调整anchor配置、或者检查num_classes和标签映射有没有错位。
AP 低、AR 不低,说明框检出来了,但排序或者定位有问题。这种情况下模型有能力找到目标,只是置信度排序不好,或者框的位置偏差大。处理方向是调整正负样本分配策略、检查IoU阈值设置、看看是不是 NMS 参数过于激进把正确框压掉了。
还有个细节值得单独看:小目标 AP。如果某个类的整体 AP 还行,但APs特别差,说明大框检得准,小目标全靠蒙。这在远景监控、航拍这类场景里非常普遍,单纯增加数据量收益有限,更有效的是提高输入分辨率或者改用更适合小目标的检测头。
5.3 把低分类别的图捞出来看,比看指标有效得多
数字只能告诉你哪里有问题,不能告诉你是什么问题。我通常会针对 AP 最低的两三个类,从验证集里随机抽二三十张包含该类标注的图,跑一遍推理,把预测框和真值框画在同一张图上对比。真值是实线框,预测按分数从高到低用不同颜色,一眼就能看出是漏检、误检、还是框位置偏移。
from mmdet.apis import init_detector, inference_detector import mmcv config = 'configs/mydata/faster-rcnn_r50_fpn_1x.py' checkpoint = 'work_dirs/faster-rcnn_r50_fpn_1x/epoch_12.pth' model = init_detector(config, checkpoint, device='cuda:0') result = inference_detector(model, 'data/mydata/images/val/xxx.jpg') # result 为 DetDataSample,取 pred_instances 自行可视化inference_detector返回的是DetDataSample,里面pred_instances包含bboxes、scores、labels三个字段,配合mmdet.visualization.DetLocalVisualizer可以直接出图。自己写可视化逻辑的时候注意metainfo里的palette要和类别数对齐,颜色数不够会报索引越界。
看图的过程中几个高频发现:标注框只框住了目标的一部分(比如只框了人的上半身);同一个目标被标成了两个不同的类;某些类别的目标几乎全是遮挡状态,标注时被标成了iscrowd从而不计入评估。这些问题在指标上表现为"这个类怎么训都上不去",看图五分钟就能定位。
5.4 类别混淆可以用预测结果的标签分布侧面验证
有一种情况是类别之间本身难以区分,比如两种外观极其相似的零件。这时候单类 AP 会同时偏低,但两者加起来的总量是对的。验证方法很直接:统计模型在所有验证图上的预测标签分布,再和真值标签分布对比。如果 A 类的预测数量远少于真值,B 类的预测数量远多于真值,而 A+B 的总量基本吻合,基本可以确认是混淆。
确认之后的选择有两个:一是合并这两个类,如果业务上不需要区分的话,这是最省事的做法;二是针对性补充能区分两者的样本,特别是处于决策边界的困难样本。前者成本低见效快,后者效果上限更高,具体选哪个取决于业务对细分类别的实际需求。
6. 几个我在实际项目里攒下来的小习惯
配置层面,我现在会固定维护两份评估配置,一份是训练期用的轻量版,只算聚合指标;一份是验收用的完整版,开着classwise并且导出预测结果。两份配置通过_base_继承同一个主干,只在评估器那几行有差异,切换成本很低,也不会出现"忘了改回去"的情况。
跑测试之前,我会先花一分钟做三件事:确认ann_file指向的是验证集而不是训练集;确认 json 里categories的名称和metainfo['classes']完全一致;确认每条标注都有合法的area和iscrowd。这三件事挡掉了我在评估环节遇到过的绝大部分问题,比事后对着nan排查要省太多时间。
还有一个关于类别数量的经验:类别数超过二三十之后,控制台输出基本失去可读性,与其盯着终端看,不如直接把runner.test()的返回值 dump 成 json,再用脚本生成图表。柱状图按 AP 排序,横轴类别纵轴分数,低于某个阈值的柱子标红,拿去和团队同步进度时比贴一堆日志截图清楚得多。
最后提一句关于复现的:每次改动评估配置或者数据集标注之后,一定要把上一次的per_class_ap.csv保留下来。单类 AP 的变化趋势能反映出很多单次结果看不出来的信息,比如某次"优化"之后总 mAP 涨了 0.5,但拆开看是三个大类各涨了 2 个点、五个小类各跌了 1 个点,这种此消彼长的情况,只看聚合值是完全发现不了的。