做目标检测的应该都经历过这个瞬间训练日志里的mAP明明爬到0.85评估也没报错可当你把测试集里随便一张图拎出来却看到模型把背景框了个大框或者把两个挨在一起的同类目标并成了一个。指标这个东西是统计值它不会告诉你哪张图出了问题。mmdetection的项目做到后面test和visualize test result就成了每天都离不开的操作——跑通测试脚本只是第一步把检测结果画回原图你才能看清模型的真实水平。这篇文章就基于我自己在几个检测模型上的实操经历把mmdetection里从测试到可视化的完整流程、常用命令和踩过的坑整理一遍。1. test.py从启动到出报告的完整链路——先看懂脚本再动手很多人拿到mmdetection之后第一步就是复制官方训练命令开始跑。等到训练结束需要测试时也只是照着README敲一行python tools/test.py。但真到了要可视化、要分析结果的时候报错或者结果对不上往往就是因为对这条链路缺乏整体认识。所以我建议先别急着敲命令花十分钟把test.py在执行什么搞清楚。1.1 测试脚本的五个阶段tools/test.py做的事情可以拆成五个环节读取配置、构建数据和模型、加载权重、前向推理、结果汇总。用一段伪代码来表达就是config Config.fromfile(your_cfg.py) model build_detector(cfg.model) load_checkpoint(model, checkpoint_path) # 加载训练好的权重 dataset build_dataset(cfg.data.test) # 构建测试集按test_pipeline处理图片 results single_gpu_test(model, dataset) # 逐图前向收集每张图的预测结果 eval_metrics eval_map(results, dataset) # 或用不同eval函数计算mAP save_results(results, out_file) # 保存为pkl/json配合--out或--format-only注意single_gpu_test返回的results是一整个列表长度等于测试集的图片数量。每一张图的结果通常是以类别为单位的检测框列表例如共有三个类别每个类别就是一个N×5的矩阵矩阵的每一行是[x1, y1, x2, y2, score]。这个结构后面做可视化、做分析时非常重要别把它当成一个简单数组。1.2 “测试”不等于“评估”在mmdetection里“测试”和“评估”经常被混用但严格来说它们是两件事测试test指模型在数据上进行前向推理得到预测框这过程仅仅产生原始输出。评估eval指拿预测框和标注框做比较计算mAP、Recall等指标。它发生在测试之后需要额外的标注信息。tools/test.py里的--eval bbox就是把这两件事一起做了。如果你只想看结果图、不想算指标完全可以不加--eval只加--show-dir。如果你只想拿指标、不保存结果就用--eval bbox不带--out。理解了测试和评估的边界后面处理可视化时才不会搞混可视化需要的是测试阶段产出的框而不是指标本身。2. 实战命令逐个说清结果如何保存、指标如何计算2.1 最常用的一组参数在mmdetection 2.x系列里测试命令的核心参数基本是这样cd /path/to/mmdetection python tools/test.py \ configs/my_project/custom_det_cfg.py \ work_dirs/custom_det_cfg/best_bbox_mAP_epoch_12.pth \ --out results/val_result.pkl \ --eval bbox四个部分各说各的configs/my_project/custom_det_cfg.py模型结构、数据路径、测试pipeline都从它来。测试用的config必须和训练时一致尤其不能换数据集路径。work_dirs/.../best_bbox_mAP_epoch_12.pth训练过程中生成的权重文件。如果权重是官方发布的也能直接用。但要注意版本匹配后面会专门说。--out results/val_result.pkl把测试结果保存成pickle文件。这个文件后续做可视化、做二次分析都能复用强烈建议每次测试都带上。--eval bbox计算COCO格式的bbox指标。如果模型是带掩膜分割的Mask R-CNN想连分割结果一起评估就写--eval bbox segm。如果你不指定--out测试完的结果只在内存里用一下计算完指标就丢掉了。这太可惜因为后面对比不同权重的表现、生成可视化的画框图、统计漏检误检都需要原始预测结果。所以我的习惯是只要测试就固定带上--out。2.2 指标输出怎么看加上--eval bbox之后终端会打印类似这样的内容loading annotations into memory... Done (t0.30s) creating index... Indexing format 2014 mAP: 0.832 AP50: 0.905 AP75: 0.871 mAP_s: 0.412 mAP_m: 0.816 mAP_l: 0.911这些指标不是孤立的数字它们代表不同的严苛程度mAP在IoU阈值0.5到0.95之间取平均值是COCO最核心的指标。它最严格参数量大不大、框准不准都会体现在这里。AP50IoU阈值固定为0.5只看框大概准不准对定位精度的要求比较宽松。很多工程落地场景更关注它。AP75IoU阈值0.75比AP50严格得多能反映边界框的精细程度。看到指标之后别只看一个mAP就下结论。如果AP50很高、mAP偏低说明模型定位不够精细框画得“差不多但不够好”。如果mAP_s明显低于mAP_l说明小目标检测是短板之后做可视化时就要重点看小目标。2.3 多卡测试和输出COCO格式JSON单卡测试速度如果不够可以用多卡。mmdetection提供了现成脚本bash tools/dist_test.sh \ configs/my_project/custom_det_cfg.py \ work_dirs/custom_det_cfg/best.pth \ 4 \ --out results/val_result.pkl \ --eval bbox最后那个数字4是GPU数量。多卡测试时要注意训练时如果是用--launcher pytorch启动的测试时保持一致否则机器间通信、随机种子都可能对不上。我遇到过单卡和多卡结果有微小差异的情况原因是分布式模式下部分算子比如NMS在不同卡上的顺序略有不同所以正式发布结果时我建议用固定的启动方式跑一次全量测试。如果想把结果输出成COCO格式的json给第三方工具用可以用python tools/test.py \ configs/my_project/custom_det_cfg.py \ work_dirs/custom_det_cfg/best.pth \ --format-only \ --options jsonfile_prefixresults/val_pred这样会在results/下生成一个val_pred.bbox.json里面是标准COCO数据格式的检测结果每条记录包含image_id、category_id、bbox、score。包括一些自动标注工具、模型分析平台都吃这种格式后续对接会方便很多。3. 把检测框画回原图的三种姿势测试做完理论上你已经有了全部预测结果。接下来就是把框画回原图从“数字”变成“能看的图”。这一步常用的有三种姿势适合不同场景。3.1 姿势一test.py自带的可视化适合快速出图我不想重新跑推理的时候通常会直接用test.py的--show-dir参数python tools/test.py \ configs/my_project/custom_det_cfg.py \ work_dirs/custom_det_cfg/best.pth \ --show-dir visualization/ \ --show-score-thr 0.3 \ --eval bbox--show-dir会把每张测试图片的画框结果保存到指定目录--show-score-thr是显示框的置信度阈值默认0.3。注意在远程服务器上跑测试时--show这个参数会直接弹出窗口很可能打不开推荐用--show-dir保存成图片再回传看。这个方式最大的优点是零额外代码适合快速看一遍结果整体效果。不过它的缺点是可控性差阈值是全局统一的想只挑某几张图看或者想换个颜色、加个标签都不方便。所以批量排查问题时用精细分析还是得靠下面的方式。3.2 姿势二用inference_detector做单图精细排查这是我最常用的方式。它可以反复拿同一张图测不同阈值也可以直观地对比不同权重文件在同一张图上的表现。核心代码很短from mmdet.apis import init_detector, inference_detector, show_result_pyplot config_file configs/my_project/custom_det_cfg.py checkpoint_file work_dirs/custom_det_cfg/best.pth model init_detector(config_file, checkpoint_file, devicecuda:0) # 给定图片路径推理并画框 img data/demo/000001.jpg result inference_detector(model, img) show_result_pyplot(model, img, result, score_thr0.3)如果服务器没有图形界面show_result_pyplot会报错可以用model.show_result直接把画好的图存成文件model.show_result( img, result, out_fileoutputs/000001_vis.jpg, score_thr0.3 )result的结构前面已经说过是每类一个N×5数组的列表。正因为这个结构清晰你可以完全不依赖官方可视化函数自己用OpenCV画框做自定义展示。比如我要在图上同时显示类别名和置信度就写一个小函数遍历每个类别的框自行cv2.rectangle和cv2.putText。这种自由空间是姿势一给不了的。3.3 姿势三从pkl文件二次加工不重跑模型有时候你已经用--out保存了结果文件不想再重新推理一遍这时可以直接读取pkl来做分析和可视化。思路是从config里构建测试集获取每张图的文件名然后把结果文件和图片一一对应画框。import mmcv import numpy as np # 读取之前保存的结果 results mmcv.load(results/val_result.pkl) # 打印第一张图结果的结构先看清楚再写后续逻辑 print(type(results[0]))不同版本、不同模型的results[0]结构会略有差异。2.x系列里单阶段和两阶段模型通常返回的是bbox_result它是一个list每个类别对应一个N×5的ndarray如果是Mask R-CNN还会多一个segm_result。因此最稳妥的做法是先打印、再解析不要上来就写死下标。拿到图片名列表后自己画框很简单import cv2 def draw_detections(img_path, bbox_result, score_thr0.3): img cv2.imread(img_path) for class_id, bboxes in enumerate(bbox_result): for bbox in bboxes: x1, y1, x2, y2, score bbox[:5] if score score_thr: continue cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText( img, fcls_{class_id}:{score:.2f}, (int(x1), max(0, int(y1) - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2 ) return img这种方式的好处是不重跑模型尤其测试集很大、测试一次要好几个小时的时候迭代画图脚本能省大量时间。缺点是要自己处理图片路径、类别名称映射这些杂事。所以我一般只在大规模分析时用它日常单图调试还是走姿势二。4. 可视化之后才会暴露出的五个问题把框画出来之后很多隐藏问题就藏不住了。下面这几个问题是我在实际跑测试可视化时反复遇到的每条都值得单独说。4.1 阈值太固定该分开看两次--show-score-thr 0.3这个默认值会让低置信度的框不显示。很多新手会把“没画框”误认为“模型没检测到”实际只是被阈值过滤了。反过来阈值设太低画面上全是密密麻麻的小框又看不清主要目标。我的经验是把阈值分成两档看0.1或更低用来发现漏检看模型有没有“模模糊糊知道这里有目标”但置信度不高的框。0.6或更高用来发现误检看模型最有信心时框得准不准、有没有把背景当目标。如果你只出一次图很容易被某个阈值带来的视觉假象带偏。所以我在每次测试时都会固定跑两遍可视化一遍低阈值、一遍高阈值分别归档到不通目录再结合指标一起判断模型质量。4.2 类别名错乱训练和测试的class定义不一致自定义数据集训练完测试时supervision或者可视化标签经常对不上。常见表现是模型明明框对了但图上的类别名是乱的或者类别名的显示顺序和训练时完全相反。根源在于训练时CLASSES定义在数据集的某个地方而测试可视化用的是config里model.test_cfg、data.test的信息。如果两边不一致模型输出的类别索引和可视化类别名就无法对应。解决办法是在config的data.test里显式指定类别列表data dict( traindict(classes(cat, dog, bird)), valdict(classes(cat, dog, bird)), testdict(classes(cat, dog, bird)) )另外如果类别顺序和训练数据集里定义的不一样同样会导致错乱。这个坑很隐蔽因为mAP照样能算出来只是可视化标签不对。我刚接触自定义数据集时就在这上面浪费过一晚上。4.3 框错位问题多半出在自己写的预处理预测框画在图上位置明显偏了甚至整个框平移出一截。大多数情况不是模型的问题而是测试pipeline被你改坏了。mmdetection的test_pipeline会把图片做resize、padding并把scale_factor、pad_shape等信息记录到img_meta里。模型输出的预测框在缩放后的坐标系上但后处理会根据img_meta把这些框映射回原图坐标最终拿到原图尺寸上的坐标。如果你自己写了一套预处理比如直接cv2.resize之后再喂给模型但没有把尺度信息写进img_meta后续坐标映射就会出错。最稳的解法是不要手写图片预处理而是通过config修改test_pipeline让官方数据加载流程去处理。这样可视化、评估、分析用的都是同一套坐标逻辑。4.4 显存不足测试也要关注samples_per_gpu测试阶段虽然不反向传播但显存占用依然不小。如果模型是Mask R-CNN这类带分割分支的显存占用可能比纯检测模型高出一截。遇到CUDA out of memory时不要急着换显卡先检查这两项data.test.samples_per_gpu默认可能继承了训练的值如果训练时是2或4测试时改成1通常就能跑。data.test.workers_per_gpu数据加载线程数太大也可能造成内存峰值。通过命令行临时修改也是可以的python tools/test.py \ configs/my_project/custom_det_cfg.py \ work_dirs/custom_det_cfg/best.pth \ --out results/result.pkl \ --eval bbox \ --cfg-options data.test.samples_per_gpu1 data.test.workers_per_gpu1可视化时那么多框一起画如果图很大、框很多OpenCV绘图本身也可能吃掉不少内存这时候把绘制过程拆成一张一张处理比一次性批量处理更稳。4.5 版本错配权重文件离开训练环境就翻车拿官方发布的.pth文件在自己的环境里测试报了一堆Keys not match或者Unexpected key in state_dict这种事太常见了。根因是mmdetection版本演进很快不同版本的state_dict结构、模块命名、甚至算子的行为都可能变化。我自己遇到过一次训练环境是2.18某个同事的测试环境是2.25加载同一个权重进去模型前向跑出来的框数量都变了mAP差了好几个点。排查半天才发现是版本差异导致的。所以测试时尽量用训练时完全相同的mmdetection、mmcv版本。从外部拿权重时先确认权重对应的config文件版本。如果必须跨版本至少打印一遍state_dict里model部分的键名和当前模型对齐别盲目load。这几个问题不是每次都会一起出现但只要出现了画出来的图基本都不能看。好在它们都是“可以避免的坑”提前知道测试流程会顺很多。5. 让结果文件生出更多价值——pkl和json的进阶玩法当你能稳定地把测试结果跑出来、可视化也正常之后下一步就是学会“消费”这份结果文件。毕竟一张张看图效率太低把结果聚合起来才能发现系统性问题。5.1 从pkl里提炼指标、框数量分布、类别分布mmdetection的mmcv.load可以直接把pkl读回来。拿到results之后可以做的分析很多统计每张图的框数量看模型是“乱框一堆”还是“框得很少”。按类别汇总每个类别的平均置信度找出模型最没把握的类别。结合标注文件统计每个类别的TP/FP数量找出最容易误检的类别。比如统计框数量分布import mmcv import numpy as np results mmcv.load(results/val_result.pkl) num_boxes [] for bbox_result in results: # bbox_result 可能是 [bbox_list] 结构取bbox_list bbox_list bbox_result[0] total sum(len(b) for b in bbox_list) num_boxes.append(total) print(平均每张图框数:, np.mean(num_boxes)) print(最多一张图框数:, np.max(num_boxes))这种统计比肉眼翻图高效得多。如果某张图的框数量异常多那就把它挑出来重点看往往就是误检集中地。5.2 生成“漏检候选集”漏检是目标检测里最头疼的问题。用pkl结果可以做一个非常实用的工具把置信度介于0.1和0.3之间的框单独画出来输出到一个“待人工复核”目录。这些框很有可能是被阈值压下去的真实目标也可能是模糊的误检。import cv2 def export_low_score_candidates(img_path, bbox_result, out_dir): img cv2.imread(img_path) for class_id, bboxes in enumerate(bbox_result): for bbox in bboxes: score bbox[4] if 0.1 score 0.3: x1, y1, x2, y2 map(int, bbox[:4]) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.putText(img, f{score:.2f}, (x1, y1), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2) out_name img_path.split(/)[-1] cv2.imwrite(f{out_dir}/{out_name}, img)把这类图交给业务方或标注团队二次确认能快速定位“模型其实会只是不自信”和“模型完全不会”的边界。5.3 视频流的可视化模型测试不一定只针对图片。有人会把模型接到摄像头或视频文件上做实时检测本质上是逐帧调用inference_detector再逐帧画框显示。可以参考这个简化的流程import cv2 from mmdet.apis import init_detector, inference_detector model init_detector(cfg_file, weight_file, devicecuda:0) cap cv2.VideoCapture(test_video.mp4) while cap.isOpened(): ret, frame cap.read() if not ret: break result inference_detector(model, frame) frame model.show_result(frame, result, score_thr0.3) cv2.imshow(mmdet video, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()注意逐帧推理速度通常不会太快。如果帧率要求高最好加上跳帧、更小的输入尺寸或者用TensorRT这类工具优化模型不然只能看幻灯片。视频可视化最大的价值在于观察时序上的稳定性——单张图上模型可能偶尔漏一帧但视频里连续漏好几帧说明不是偶然而是某些特殊姿态或角度没学好。最后分享一个我自己沉淀下来的习惯每次测试我都会固定把result.pkl保存下来然后做两版可视化低阈值一版、高阈值一版。低阈值那版用来发现漏检高阈值那版用来发现误检。这样模型问题基本一眼就能分辨是“学习不充分”还是“决策阈值不合适”。如果你的测试结果看起来总觉得差一点先别急着换模型结构按这个流程把测试、可视化、结果分析这套链路理顺往往很快就能定位到问题所在。