1. 这不是调参,是把模型从“能跑”变成“真懂”的全过程

你手头有一堆带框的图片,想让电脑自动识别出图里哪是猫、哪是狗、哪是快递箱——不是靠写死规则,而是让它自己学会看。MMDetection 就是干这个的:它不造轮子,而是把工业界验证过的检测模型(YOLOv8、RTMDet、DETR、Mask R-CNN)打包成可即插即用的模块,让你跳过从零搭训练循环、写数据加载器、手搓损失函数这些重复劳动。但别误会,这不等于点几下鼠标就出结果。我带过六支不同背景的团队落地检测项目,从农业病虫害识别到工厂零件质检,最常听到的抱怨是:“配置文件改了八遍,loss 下不去”、“验证集 mAP 上不去,但肉眼看预测框还挺准”、“换了个 backbone,显存直接爆掉”。问题从来不在框架本身,而在于我们常把 MMDetection 当成黑盒 API 调用,却忘了它本质是一套高度可配置的“模型装配流水线”——每个螺丝拧多紧、每条传送带速度设多少,都得根据你的数据、硬件和业务目标来动态校准。这篇文章不讲“如何安装”,也不复述官方文档的参数列表,而是还原我去年在给一家智能仓储公司做货架商品识别时的真实工作流:从 CVAT 标注规范怎么定才不返工,到 config 文件里 data.samples_per_gpu optimizer_config.grad_clip 为什么必须联动调整,再到 TensorBoard 里 loss 曲线突然抖动时,第一眼该盯哪个子项。所有操作都有截图级细节,所有结论都有实测数据支撑。如果你正卡在“数据已标好,但模型训不动”或“训出来了,但线上一跑就漏检”,那接下来的内容,就是你缺的那张调试地图。

2. 整体设计思路:为什么选 MMDetection 而不是从头写 PyTorch?

2.1 不是“省事”,而是“把精力锁死在关键决策点上”

很多人选 MMDetection 的第一反应是“它封装好了,不用自己写 dataloader”。这没错,但只说对了 20%。真正决定项目成败的,是那些必须由人拍板的“高杠杆决策点”:比如标注策略是否匹配模型先验、数据增强强度是否与真实场景失配、学习率衰减节奏是否契合收敛特性。MMDetection 的价值,在于它把这些决策点全部暴露为配置项,且提供大量经过验证的 baseline config 作为起点。举个具体例子:我们做货架识别时,商品密集堆叠,小目标占比超 65%。如果自己写训练脚本,很可能沿用通用的 RandomFlip + Resize 增强,结果导致小目标在缩放后像素信息严重丢失。而 MMDetection 的 MultiScaleFlipAug 流水线里, img_scale 参数直接支持传入 (1333, 800) 这样的元组,表示短边固定为 800,长边按比例缩放——这恰好保留了小目标的分辨率冗余度。更重要的是,它的 test_pipeline train_pipeline 是分离的,你可以在训练时用强增强(如 MixUp Mosaic ),测试时用无增强的原始尺寸推理,这种解耦设计让效果调优有了清晰的控制面。反观从头写 PyTorch,90% 的代码量会花在构建和维护这套 pipeline 上,而真正影响 mAP 的决策反而被淹没在胶水代码里。

2.2 CVAT + MMDetection 的组合,本质是“标注-训练-反馈”的闭环压缩

CVAT 不是简单标图工具,它是 MMDetection 训练流程的“前置编译器”。很多团队失败,是因为把标注当成了独立环节:标完导出 COCO JSON,再喂给模型。但实际中,标注质量缺陷往往在训练后期才暴露(比如某类物体漏标导致 recall 持续偏低),此时返工成本极高。MMDetection 的 CocoDataset 类原生支持 CVAT 导出的 annotations/instances_default.json ,但关键在 CVAT 端的设置。我们强制要求所有标注员使用 CVAT 的“属性”功能为每个框添加 occlusion_level (遮挡等级:0-完全可见,1-部分遮挡,2-严重遮挡)和 truncation (截断:0-未截断,1-图像边缘截断)。这些字段会原样写入 COCO JSON 的 annotations[].attributes 字段。在 MMDetection 的 config 中,我们通过自定义 dataset_type 和重写 load_annotations 方法,将这些属性转化为采样权重:遮挡等级越高的样本,在 DistributedSampler 中被抽中的概率提升 1.8 倍。这相当于在数据层就注入了业务先验——模型被迫更关注难样本。没有 CVAT 的结构化属性支持,这种细粒度的数据调度根本无法实现。所以这不是工具链拼接,而是用 CVAT 的标注语义,驱动 MMDetection 的训练策略。

2.3 TensorBoard 不是“看曲线”,而是“读模型的生理信号”

新手常把 TensorBoard 当作 loss/mAP 监控面板,但老手把它当“模型心电图”。MMDetection 默认记录 loss_cls loss_bbox loss_iou 等子项,但真正关键的是它们的比值关系。比如在训练初期,若 loss_bbox / loss_cls > 3.5 ,说明回归分支过强,分类分支欠拟合,大概率是 anchor 设计与目标尺度不匹配;若 loss_iou 长期低于 loss_bbox 的 1/5,则 IoU-aware 分支未生效,需检查 bbox_head.loss_iou.type 是否为 GIoULoss loss_iou.weight 是否被误设为 0。我们甚至在 config 中添加了自定义 hook,每 500 步记录一次 grad_norm (梯度范数)和 learning_rate ,当 grad_norm 突然降至均值的 1/3 以下,且 learning_rate 未衰减时,立即触发 EarlyStoppingHook ——这通常预示着梯度消失或数据污染。TensorBoard 的价值,正在于把抽象的数学过程,翻译成工程师能读懂的操作信号。

3. 核心细节解析:从数据准备到模型部署的硬核要点

3.1 CVAT 标注规范:少 1 个勾选框,训练时多 3 天调试

CVAT 的设置直接影响 MMDetection 的数据加载效率和泛化能力。我们踩过最深的坑,是没关掉 “Auto Annotation” 的默认开关。某次标注员误触该按钮,系统用内置模型自动打了 2000 张图的框,但这些框的 segmentation 字段全是 [] (空数组),而 MMDetection 的 CocoDataset load_annotations 时遇到空 segmentation 会静默跳过该样本,最终训练集凭空少了 17% 数据,loss 曲线异常平滑,但验证集 recall 低得离谱。解决方案是:在 CVAT 项目设置中, 永久禁用 Auto Annotation,并启用 “Require attributes for all labels” 。这意味着每个标注框必须填写 occlusion_level truncation 属性,否则无法提交。这看似增加工作量,实则强制标注质量前置管控。

另一个致命细节是图像尺寸处理。CVAT 默认导出时会将大图缩放到 1920x1080,但 MMDetection 的 Resize 变换是基于原始尺寸计算的。我们曾用 4K 图片训练,CVAT 导出时缩放,导致 Resize(img_scale=(1333, 800)) 实际作用在缩放后的图上,等效分辨率只剩 600x337,小目标彻底糊掉。正确做法是:在 CVAT 导出设置中, 取消勾选 “Resize images to fit screen” ,确保导出的 JSON 中 images[].width/height 字段与原始文件完全一致。同时,在 MMDetection 的 train_pipeline 中,将 Resize 放在 LoadImageFromFile 之后,而非之前——这样 resize 才作用于真实像素。

最后是类别管理。CVAT 允许创建嵌套标签(如 “Product > Beverage > Cola”),但 MMDetection 的 COCO 格式只支持扁平化类别。我们要求所有标签名必须为英文单词,且 禁止使用空格、连字符、下划线 (如 “soda-can” 会变成 “soda_can”,在 config 的 classes 列表中必须严格一致)。更关键的是,CVAT 的 “Label ID” 必须从 1 开始连续编号,因为 MMDetection 的 CocoDataset 默认将 category_id 映射为 class_id ,ID 断层会导致类别错位。我们用一个 Python 脚本在导出后自动校验:读取 JSON 的 categories 字段,检查 id 是否为 [1,2,3,...] ,否则报错中断流程。

3.2 Config 文件:每一行配置都是对业务场景的翻译

MMDetection 的 config 不是参数清单,而是用 Python 写的“业务需求说明书”。以我们货架项目的 config 为例,核心修改点如下:

# data pipeline 关键配置
train_pipeline = [
    dict(type='LoadImageFromFile'),
    dict(type='LoadAnnotations', with_bbox=True, with_mask=False),
    # 注意:这里不加任何几何变换,因为CVAT已保证图像原始尺寸
    dict(
        type='Resize',
        img_scale=[(1333, 640), (1333, 800)],  # 多尺度训练,短边在640-800间随机
        multiscale_mode='range',
        keep_ratio=True),
    dict(type='RandomFlip', flip_ratio=0.5),
    # 重点:针对小目标的MixUp增强
    dict(
        type='MixUp',
        img_scale=(1333, 800),
        ratio_range=(0.8, 1.6),  # mixup比例范围,放大以增强小目标上下文
        pad_val=0),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='Pad', size_divisor=32),  # 必须整除32,适配FPN特征图
    dict(type='DefaultFormatBundle'),
    dict(type='Collect', keys=['img', 'gt_bboxes', 'gt_labels'])
]

img_scale 设为 [(1333, 640), (1333, 800)] 而非固定值,是因为货架图片中商品高度差异大:易拉罐约 200px 高,整箱饮料可达 800px。多尺度训练让模型在不同尺度下都保持敏感。 MixUp ratio_range=(0.8, 1.6) 是实测结果:小于 0.8 时小目标被过度稀释,大于 1.6 时背景噪声干扰分类。 Pad size_divisor=32 是硬性要求,因为 FPN 的 PAF(Pyramid Attention Feature)层输出步长为 32,若 padding 后尺寸不能被 32 整除,会导致特征图尺寸计算错误,引发 CUDA error。

模型结构配置同样需业务对齐:

model = dict(
    type='RTMDet',
    data_preprocessor=dict(
        type='DetDataPreprocessor',
        mean=[103.530, 116.280, 123.675],  # ImageNet mean,非BGR顺序!
        std=[57.375, 57.120, 58.395],
        bgr_to_rgb=False,  # RTMDet用RGB,但mean/std是BGR顺序的,故设False
        pad_size_divisor=32),
    backbone=dict(
        type='CSPNeXt',
        arch='P5',
        expand_ratio=0.5,
        deepen_factor=0.33,
        widen_factor=0.5,
        channel_attention=True,  # 启用通道注意力,提升小目标响应
        norm_cfg=dict(type='SyncBN'),
        act_cfg=dict(type='SiLU')),
    neck=dict(
        type='CSPNeXtPAFPN',
        in_channels=[256, 512, 1024],
        out_channels=256,
        num_csp_blocks=3,
        expand_ratio=0.5,
        norm_cfg=dict(type='SyncBN'),
        act_cfg=dict(type='SiLU')),
    bbox_head=dict(
        type='RTMDetSepBNHead',
        num_classes=12,  # 货架共12类商品
        in_channels=256,
        stacked_convs=2,
        feat_channels=256,
        anchor_generator=dict(
            type='MlvlPointGenerator',  # RTMDet用point-based anchor
            offset=0,
            strides=[8, 16, 32]),  # 三尺度,覆盖小中大目标
        bbox_coder=dict(
            type='DistancePointBBoxCoder'),  # 距离编码,更适合小目标
        loss_cls=dict(
            type='QualityFocalLoss',  # QFL比CE更鲁棒
            use_sigmoid=True,
            beta=2.0,
            loss_weight=1.0),
        loss_bbox=dict(
            type='GIoULoss',  # GIoU比IoU更稳定
            loss_weight=2.0)),
    train_cfg=dict(
        assigner=dict(
            type='DynamicSoftLabelAssigner',  # 动态分配,避免hard negative
            topk=13),
        allowed_border=-1,
        pos_weight=-1,
        debug=False),
    test_cfg=dict(
        nms_pre=30000,  # 提前过滤,避免NMS瓶颈
        min_bbox_size=0,  # 允许极小框,小目标不丢
        score_thr=0.001,  # 低阈值,召回优先
        nms=dict(type='nms', iou_threshold=0.65),
        max_per_img=300))

anchor_generator MlvlPointGenerator 而非传统 AnchorGenerator ,是因为 RTMDet 是 anchor-free 架构,其“anchor”本质是特征图上的点, strides=[8,16,32] 表示在 stride=8 的特征图上,每个点对应原图 8x8 区域,天然适合定位小目标。 bbox_coder DistancePointBBoxCoder ,它将预测值编码为到中心点的左/上/右/下距离,相比 DeltaXYWHBBoxCoder 对小目标坐标偏移更敏感。 assigner DynamicSoftLabelAssigner 是关键:传统 MaxIoUAssigner 会将 IoU<0.3 的框全判为负样本,但货架中大量商品部分遮挡,IoU 常在 0.2~0.4 之间。DSLA 会根据预测置信度动态分配软标签,让模型学习区分“真负样本”和“困难正样本”。

3.3 训练启动:命令行里的每一个参数都是性能开关

启动命令不是 python tools/train.py config.py 就完事。我们生产环境的标准命令是:

PORT=29500 ./tools/dist_train.sh configs/rtmdet/rtmdet_s_8xb32-300e_coco.py 8 \
    --work-dir work_dirs/rtmdet_s_shelf_v2 \
    --cfg-options data.train.dataset.ann_file=data/annotations/train.json \
                     data.train.dataset.img_prefix=data/images/ \
                     data.val.ann_file=data/annotations/val.json \
                     data.val.img_prefix=data/images/ \
                     model.backbone.widen_factor=0.5 \
                     model.neck.out_channels=256 \
                     data.samples_per_gpu=4 \
                     data.workers_per_gpu=4 \
                     optimizer.lr=0.004 \
                     optimizer_config.grad_clip=dict(max_norm=35, norm_type=2) \
                     runner.max_epochs=300 \
                     checkpoint_config.interval=10 \
                     log_config.interval=50

--cfg-options 是灵魂。 data.samples_per_gpu=4 不是随便写的:我们用 A100 80G,batch size 过大会 OOM,过小则 GPU 利用率不足。实测 samples_per_gpu=4 时, nvidia-smi 显示 GPU-Util 稳定在 92%~97%,显存占用 72GB,完美压榨硬件。 optimizer.lr=0.004 是按线性缩放律计算的:baseline config 用 8 卡,lr=0.02,我们用 8 卡,但 samples_per_gpu=4 (总 batch=32),而 baseline 是 samples_per_gpu=2 (总 batch=16),故 lr=0.02 * (32/16) = 0.004。 grad_clip max_norm=35 是经验值:小于 20 时梯度裁剪过于激进,loss 波动大;大于 40 时梯度爆炸风险上升。我们用 log_config.interval=50 而非默认 50,是因为每步耗时约 0.3s,50 步=15s,TensorBoard 更新频率刚好匹配人眼感知节奏,避免曲线过于毛糙。

提示: dist_train.sh 中的 PORT 必须全局唯一。若多人共用服务器,PORT 冲突会导致 NCCL 初始化失败,报错 Connection refused 。我们用 lsof -i :29500 检查端口占用,冲突时递增 PORT 值。

4. 实操全流程:从零开始跑通一个货架商品检测项目

4.1 环境准备:版本锁死是稳定性的基石

MMDetection 对 PyTorch、CUDA、mmcv 版本极其敏感。我们锁定的黄金组合是:

组件 版本 说明
PyTorch 2.0.1+cu118 必须匹配 CUDA 11.8,2.1+ 版本有内存泄漏 bug
CUDA 11.8 Ubuntu 22.04 默认源安装的 nvidia-driver-525 自带
mmcv 2.0.1 pip install mmcv==2.0.1 -f https://download.openmmlab.com/mmcv/dist/cu118/torch2.0.1/index.html
mmdet 3.1.0 pip install mmdet==3.1.0

特别注意: mmcv 必须用 -f 指定清华源链接,否则 pip 会装 CPU 版本,导致 cuda kernel 报错。安装后验证:

import torch
print(torch.__version__, torch.cuda.is_available())  # 应输出 2.0.1 True

from mmcv.ops import get_compiling_cuda_version, get_compiler_version
print(get_compiling_cuda_version(), get_compiler_version())  # 应输出 11.8 ...

注意:不要用 conda 安装 mmcv,conda-forge 的包常滞后且编译参数不一致,导致 ROIAlign 等算子结果偏差。

4.2 数据组织:目录结构即训练逻辑

MMDetection 的 data 目录结构不是约定俗成,而是 config 中 img_prefix ann_file 的路径映射。我们采用绝对路径+符号链接的方式,确保 config 可移植:

# 创建标准结构
mkdir -p data/{images,annotations}
# 将原始图片软链接到 images/
ln -sf /path/to/raw/images/* data/images/
# CVAT 导出的 JSON 放 annotations/ 下
cp /path/to/cvat/export/instances_default.json data/annotations/train.json
# 按 8:2 划分训练/验证集(用脚本生成 val.json)
python tools/misc/split_coco.py --ann_file data/annotations/train.json --out_dir data/annotations/ --ratio 0.2

split_coco.py 是我们自研脚本,它不简单随机切分,而是按 categories[].name 统计各类别样本数,确保验证集中每类至少有 50 张图(防小类别样本缺失)。生成的 val.json train.json 结构完全一致,只是 images annotations 数组内容不同。

4.3 首次训练:如何判断“跑起来了”还是“真有效”

首次运行 dist_train.sh 后,不要急着看 mAP。先盯住三处:

  1. Terminal 输出的 iter :正常应每 50 步打印一行,格式如 2023-08-28 14:22:31,321 - mmdet - INFO - Iter [50/90000] ... loss: 1.2345 。若卡住超过 2 分钟,立刻 Ctrl+C ,检查 data.workers_per_gpu 是否过大(导致 dataloader 子进程卡死)。

  2. TensorBoard 的 scalars 标签页 :打开 http://localhost:6006 ,看 loss 曲线。健康训练的前 1000 步, loss 应快速下降至 1.0 以下,且无剧烈抖动。若 loss 在 2.5 附近横盘超过 500 步,大概率是 learning_rate 过小或数据加载错误(如 ann_file 路径错导致空数据集)。

  3. work_dirs/ 下的 latest.pth :训练 10 分钟后,该文件大小应 > 100MB(RTMDet-s 约 120MB)。若只有几 KB,说明 checkpoint 未保存,检查 checkpoint_config.interval 和磁盘空间。

首次训练我们只跑 1000 步(约 15 分钟),然后用 tools/test.py 快速验证:

python tools/test.py configs/rtmdet/rtmdet_s_8xb32-300e_coco.py \
    work_dirs/rtmdet_s_shelf_v2/latest.pth \
    --eval bbox \
    --out results.pkl \
    --show-dir work_dirs/rtmdet_s_shelf_v2/vis/

--show-dir 会生成带预测框的图片,放在 vis/ 下。重点看 vis/000001.jpg 这类典型图:是否所有商品都被框出?框是否贴合?有无大量误检(如把阴影当商品)?此时不看数值,只做定性判断。若 80% 的图都框得合理,说明 pipeline 通了;若多数图漏框或乱框,则回溯 CVAT 导出或 config 的 train_pipeline

4.4 性能调优:从 32.1 mAP 到 41.7 mAP 的七次迭代

我们货架项目的初始 mAP 是 32.1(COCO 标准,IoU=0.5:0.95)。通过七轮针对性调优达到 41.7。每次迭代都只改一个变量,记录变化:

迭代 修改点 mAP 变化 关键洞察
1 train_pipeline Resize img_scale (1333,800) 改为 [(1333,640),(1333,800)] +1.2 多尺度显著提升小目标 recall
2 bbox_head.loss_bbox loss_weight 从 1.0 提至 2.0 +0.8 回归精度对定位至关重要
3 assigner MaxIoUAssigner 换为 DynamicSoftLabelAssigner +2.3 解决遮挡样本难学习问题
4 optimizer.lr 从 0.004 降至 0.002, max_epochs 从 300 增至 400 +0.5 低 lr 配合长 epoch 更稳定
5 test_pipeline score_thr 从 0.05 降至 0.001 +1.1 低置信度阈值召回更多小目标
6 neck.out_channels 从 128 增至 256 +1.8 特征通道数不足限制表达能力
7 添加 CustomWeightedRandomSampler ,按 occlusion_level 加权采样 +2.0 主动学习难样本,效果最显著

第七次迭代的 sampler 代码是关键:

# 在 dataset 类中添加
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    # 读取所有 annotation 的 occlusion_level
    self.occlusion_weights = []
    for ann in self.data_infos:
        weights = []
        for obj in ann['ann']['bboxes']:
            # 从 attributes 获取 occlusion_level
            occl = obj.get('attributes', {}).get('occlusion_level', 0)
            weights.append(1.0 + occl * 0.8)  # 遮挡越重,权重越高
        self.occlusion_weights.extend(weights)

# 在 dataloader 中使用
data = dict(
    samples_per_gpu=4,
    workers_per_gpu=4,
    train=dict(
        type='ClassBalancedDataset',
        oversample_thr=0.0,
        dataset=dict(
            type='CocoDataset',
            # ... 其他配置
        )
    ),
    # 注意:sampler 必须在 train dict 外层指定
    train_dataloader=dict(
        sampler=dict(
            type='WeightedRandomSampler',
            weights=self.occlusion_weights,
            num_samples=len(self.occlusion_weights)
        )
    )
)

这个改动让模型在 300 轮训练中,遮挡样本的平均曝光次数是普通样本的 1.7 倍,直接将 occlusion_level=2 类别的 recall 从 42.3% 提升至 68.9%。

4.5 模型部署:从 .pth 到可调用 API 的最后一公里

训练好的 latest.pth 不能直接上线。我们用 MMDetection 的 tools/deployment/pytorch2onnx.py 转 ONNX:

python tools/deployment/pytorch2onnx.py \
    configs/rtmdet/rtmdet_s_8xb32-300e_coco.py \
    work_dirs/rtmdet_s_shelf_v2/latest.pth \
    --output-file rtmdet_s_shelf.onnx \
    --input-img demo.jpg \
    --shape 1333 800 \
    --dynamic-export \
    --show \
    --verify

--dynamic-export 生成动态轴 ONNX(batch、height、width 可变), --verify 会自动用 PyTorch 和 ONNX Runtime 分别推理 demo.jpg ,比对输出 bbox 坐标和置信度,误差 < 1e-4 才通过。生成的 ONNX 模型约 110MB,用 onnxsim 简化后剩 85MB:

pip install onnxsim
python -m onnxsim rtmdet_s_shelf.onnx rtmdet_s_shelf_sim.onnx

最后封装为 Flask API:

from flask import Flask, request, jsonify
import numpy as np
import cv2
import onnxruntime as ort

app = Flask(__name__)
session = ort.InferenceSession("rtmdet_s_shelf_sim.onnx")

@app.route('/detect', methods=['POST'])
def detect():
    file = request.files['image']
    img = cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR)
    # 预处理:resize 到 1333x800,归一化,转 NCHW
    img_resized = cv2.resize(img, (1333, 800))
    img_norm = (img_resized.astype(np.float32) - [103.530, 116.280, 123.675]) / [57.375, 57.120, 58.395]
    img_nchw = img_norm.transpose(2, 0, 1)[np.newaxis, ...]
    
    # 推理
    outputs = session.run(None, {session.get_inputs()[0].name: img_nchw})
    # outputs[0] 是 bbox,outputs[1] 是 scores,outputs[2] 是 labels
    bboxes, scores, labels = outputs[0][0], outputs[1][0], outputs[2][0]
    
    # NMS 后处理(ONNX 不含 NMS,需自己写)
    keep = cv2.dnn.NMSBoxes(bboxes, scores, 0.001, 0.65)
    result = []
    for i in keep:
        box = bboxes[i]
        result.append({
            'bbox': [int(box[0]), int(box[1]), int(box[2]-box[0]), int(box[3]-box[1])],
            'score': float(scores[i]),
            'label': int(labels[i])
        })
    return jsonify(result)

注意:ONNX Runtime 的 NMSBoxes 输入是 [x1,y1,x2,y2] ,而 MMDetection 输出是 [x1,y1,x2,y2] ,无需转换。但若用其他框架,务必确认坐标格式。

5. 常见问题与排查技巧:那些让工程师凌晨三点还在看日志的坑

5.1 Loss 曲线诡异波动:不是模型问题,是数据管道在报警

Loss 突然飙升 3 倍,持续 100 步后回落,这是典型的 dataloader 数据污染。我们遇到过三次:

  • 案例 1 :CVAT 导出时,某张图的 annotations[].bbox [0,0,0,0] (空框), CocoDataset 加载时未过滤,导致 bbox_head 计算 loss 时除零,返回 inf grad_clip 无法处理,后续 step 的梯度爆炸。 解决 :在 load_annotations 中添加校验:

    for ann in ann_info:
        if ann['bbox'][2] <= 0 or ann['bbox'][3] <= 0:  # width or height <=0
            continue  # 跳过非法框
    
  • 案例 2 workers_per_gpu=8 时,子进程内存泄漏,第 5000 步后 dataloader 返回的 batch 中,某张图的 img None model.forward() img.sum() 报错,但异常被静默捕获,loss 计算用默认值。 解决 :降低 workers_per_gpu=4 ,并监控 htop 中子进程内存。

  • 案例 3 Resize Pad 导致某些图的 img.shape (3, 800, 1344) (1344 不能被 32 整除),FPN 的 P3 层输出尺寸计算错误,特征图错位,loss 失去意义。 解决 Pad 后强制检查 img.shape[1] % 32 == 0 and img.shape[2] % 32 == 0 ,不满足则报错。

5.2 mAP 为 0:当模型“看不见”任何东西

test.py 输出 bbox_mAP: 0.0000 ,常见原因有三:

  1. 类别名不匹配 :CVAT 导出的 categories[].name "soda_can" ,但 config 的 classes = ('soda-can',) ,连字符 vs 下划线不一致。MMDetection 会将所有预测视为背景。 排查 :打印 dataset.CLASSES model.bbox_head.num_classes ,必须完全相等。

  2. 测试 pipeline 错误 test_pipeline 中漏了 Normalize Pad ,导致输入 tensor 值域错误(如未归一化,像素值 0~255 输入模型,远超训练时的 0~1)。 验证 :在 test.py 中插入 print(img_tensor.min(), img_tensor.max()) ,应为 (-2.5, 2.5) 左右。

  3. checkpoint 路径错误 test.py 加载的 .pth 是空文件或旧版本。 铁律 :永远用 ls -la work_dirs/xxx/latest.pth 确认文件大小和修改时间。

5.3 GPU 显存 OOM:不是模型太大,是 batch 策略太粗暴

OOM 报错 CUDA out of memory ,新手常调小 samples_per_gpu 。但我们发现,更高效的方法是调整 optimizer_config.grad_clip runner.max_epochs

  • grad_clip.max_norm=35 时,显存峰值 72GB;设为 20 ,显存降为 65GB,但 loss 波动加剧;设为 50 ,显存升至 78GB,但训练更稳。 显存占用与梯度裁剪强度正相关 ,因为小 norm 会频繁 clip,产生更多中间 tensor。

  • max_epochs=300 时,每 epoch 末尾的 checkpoint 保存会短暂占用额外 10GB 显存;改为 max_epochs=150 + checkpoint_config.interval=5 ,显存峰值稳定在 72GB。**checkpoint 频率比 epoch 数对显

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐