做目标检测项目,十有八九绕不开MMDetection。最近我在搞一个钢材表面缺陷检测的小项目,需要把MMDetection3.0和自定义数据集这套训练流程完整走一遍:图片自己拍,标注自己标,标注完还得转成模型能吃的格式,然后改配置、调参数、盯loss、看mAP。3.0相比2.x改动非常大,配置体系、底层Runner、依赖库全换了,网上很多教程还停留在旧版本,照着抄第一轮就会在环境那一步报错。这篇文章就把我从环境搭建、数据标注、配置改写,到最终训练出可用模型的全过程完整记录下来。如果你正准备把手里的自定义数据集塞进MMDetection3.0,照这条路线走,应该能省下不少折腾的时间。
1. 环境准备:版本不对,后面全是泪
1.1 MMDetection 3.0 和 2.x 到底差在哪
先说结论:千万别用老教程里的安装方式去装3.0。MMDetection 2.x 依赖的是mmcv-full,而 3.x 一开始就换成了mmengine。mmengine 相当于一个全新的训练引擎,把所有训练循环、Hook、Logger 都统一了,这就是为什么 2.x 的很多脚本和配置在 3.x 里跑不起来。
所以3.0的安装链条比2.x多了一层:mmdet依赖mmcv和mmengine,mmcv又依赖 PyTorch 和 CUDA。任何一个环节版本对不上,后面就是无穷无尽的报错。这里先给出一套我实测能跑通的组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.8 | 3.8/3.9最稳,太新的版本容易遇到依赖兼容问题 |
| PyTorch | 1.13.1 | 与CUDA 11.7配对 |
| CUDA | 11.7 | 驱动版本够新就行 |
| mmcv | 2.1.0 | 必须 2.x,不能装1.x |
| mmengine | 0.10.0 | mmcv 2.x会自动带上 |
| mmdet | 3.2.0 | 用git clone源码方式安装 |
这个组合我跑了分类、检测、分割好几套流程,没有出现奇奇怪怪的兼容性问题。当然 PyTorch 2.x 搭配新版 mmcv 2.2+ 也可以,但新手阶段没必要追求最新,稳定第一。
1.2 安装步骤与环境验证
安装命令按顺序执行:
conda create -n mmdet3 python=3.8 -y conda activate mmdet3 pip install torch==1.13.1 torchvision==0.14.1 --index-url https://download.pytorch.org/whl/cu117 pip install -U openmim mim install mmengine mim install "mmcv>=2.0.0" git clone https://github.com/open-mmlab/mmdetection.git cd mmdetection pip install -v -e .这里有个关键点:mmcv一定要用mim install来装,不要直接pip install mmcv。直接pip装默认是CPU版本,训练时一跑就报CUDA错误。mim工具会自动检测你的CUDA和PyTorch版本,帮你选对应的预编译包,省心很多。
如果GitHub拉取速度不理想,可以把最后四行换成 gitee 镜像地址来clone,路径和源码保持一致就行,这个不算丢人,省时间才是正事。
装完之后一定要验一下环境:
python -c "import mmdet, mmcv, mmengine; print(mmdet.__version__, mmcv.__version__, mmengine.__version__)"能同时输出三个版本号,说明环境基本通了。如果只装了mmdet没装mmengine,这里第一行就会报ModuleNotFoundError。我见过太多人卡在这一步,花了大半天排查配置问题,结果就是环境没配对。
2. 数据准备:从一堆图片到COCO格式
2.1 标注工具怎么选
环境搞定后,真正耗费时间的是数据。我这次做的是钢材表面缺陷检测,总共6个类别:crazing(裂纹)、inclusion(夹杂)、patches(麻点)、pitted_surface(氧化铁皮压入)、rolled-in_scale(轧制氧化皮)、scratches(划伤)。这些缺陷在工业场景里形态差异挺大,有的是细长线条,有的是团块状,标注的时候非常考验耐心。
标注工具我对比过几个,最终结论是看你用检测还是分割模型:
| 工具 | 输出格式 | 上手难度 | 是否支持自动标注 | 适合场景 |
|---|---|---|---|---|
| LabelImg | VOC XML | 低 | 否 | 纯检测框,快速上手 |
| X-AnyLabeling | VOC/COCO/YOLO | 中 | 是 | 需要实例分割或多格式导出 |
| labelme | JSON多边形 | 中 | 否 | 多边形分割标注 |
| Label Studio | 多格式 | 中高 | 是 | 团队协作、多模态数据 |
如果你只需要目标检测框,LabelImg 就够了,界面简单,画框效率高。如果后面想跑 Mask R-CNN 这类实例分割模型,建议直接用 X-AnyLabeling,它支持导出带多边形坐标的JSON,再转成COCO的segmentation字段,省得二次标注。
标注时有几个细节特别容易踩坑:
- 文件名不要有中文、不要有空格,统一用英文字母加数字命名,后面所有脚本都会省事。
- 类别名统一用小写英文,不要一会儿
Crazing一会儿crazing,转换脚本匹配不上会静默丢数据。 - 一张图有多个目标就画多个框,框要尽量贴合目标边缘,不要为了省事框大一圈,会直接影响模型收敛。
- 边缘处只露出一半的缺陷对象,建议直接跳过不标,或者标了之后在筛选阶段去掉,不然会给模型传递很差的监督信号。
2.2 标注转COCO:一个脚本搞定
MMDetection3.0对COCO格式支持最友好,几乎开箱即用。我建议不管标注工具导出什么格式,都统一转成COCO JSON。COCO标注文件的核心就是三个数组:images存图片信息,annotations存框和多边形,categories存类别ID和名称。
LabelImg 默认导出的是Pascal VOC XML格式,我写了一个转换脚本,核心逻辑就是遍历XML文件,读取每个目标的bbox坐标,换算成COCO格式的[x, y, width, height],然后填进JSON:
import json import os import xml.etree.ElementTree as ET from PIL import Image label_map = { 'crazing': 1, 'inclusion': 2, 'patches': 3, 'pitted_surface': 4, 'rolled-in_scale': 5, 'scratches': 6, } def convert_voc_to_coco(xml_dir, img_dir, json_out): images = [] annotations = [] ann_id = 1 xml_files = sorted([f for f in os.listdir(xml_dir) if f.endswith('.xml')]) for img_id, xml_file in enumerate(xml_files, start=1): tree = ET.parse(os.path.join(xml_dir, xml_file)) root = tree.getroot() img_name = root.find('filename').text img_path = os.path.join(img_dir, img_name) if not os.path.exists(img_path): continue with Image.open(img_path) as img: width, height = img.size images.append({ 'id': img_id, 'file_name': img_name, 'width': width, 'height': height, }) for obj in root.findall('object'): cls = obj.find('name').text if cls not in label_map: continue category_id = label_map[cls] bndbox = obj.find('bndbox') xmin = float(bndbox.find('xmin').text) ymin = float(bndbox.find('ymin').text) xmax = float(bndbox.find('xmax').text) ymax = float(bndbox.find('ymax').text) w = xmax - xmin h = ymax - ymin if w <= 0 or h <= 0: continue annotations.append({ 'id': ann_id, 'image_id': img_id, 'category_id': category_id, 'bbox': [xmin, ymin, w, h], 'area': w * h, 'iscrowd': 0, }) ann_id += 1 categories = [{'id': v, 'name': k} for k, v in label_map.items()] coco = { 'images': images, 'annotations': annotations, 'categories': categories, } with open(json_out, 'w') as f: json.dump(coco, f) if __name__ == '__main__': convert_voc_to_coco('labels/xml', 'images', 'instances_train.json')这个脚本有几个地方我特意做了保护:w <= 0 or h <= 0的框直接跳过,避免脏数据污染训练;图片文件不存在时跳过而不是让脚本崩溃;label_map里没有的类别直接continue,防止XML里混入未定义的对象。
如果你用的是 X-AnyLabeling 或 labelme,导出的是带多边形坐标的JSON,那就需要额外处理segmentation字段。一定要把annotations里的bbox和area从多边形顶点坐标计算出来,不要直接填0。area在训练时用于计算一些统计量,填错会影响部分模型的表现。
2.3 目录组织与数据检查
转换完成后,目录结构建议这样组织:
data/steel/ ├── annotations/ │ ├── instances_train.json │ └── instances_val.json ├── images/ │ ├── train/xxx.jpg │ └── val/xxx.jpg训练集和验证集按7:3或8:2划分,划分时注意打乱顺序,直接把前几百张归为训练、后几百张归为验证是不行的,模型会学不到泛化能力。验证集不需要太大,但要保证每个类别至少都有几张。
数据检查这一步很多人会跳过,我强烈建议写一个统计脚本,看看每个类别各有多少个框、验证集里每张图的框数量分布。我之前就遇到过验证集里某个类别一个都没有的情况,mAP指标全红了还不知道问题在哪。
检查几个关键点:
- 类别ID是否从1开始(0是COCO格式里保留给背景的)。
- 每张图的
file_name是否和images目录下的实际文件名完全一致。 - 图片尺寸和JSON里记录的宽高是否一致。
- 训练集和验证集的图片是否有交叉,交叉了就是数据泄漏,指标虚高没有意义。
3. 修改配置:让模型认识你的数据集
3.1 认识3.0的配置继承体系
MMDetection3.0的配置文件体系和2.x有本质区别。3.0的配置是“继承+覆盖”的,一个config文件可以同时继承多个基础配置,比如模型结构、数据集、优化策略、运行时参数都是独立文件,通过_base_字段聚合到一起。
这样做的好处是:自定义数据集根本不需要从头写config,只需要继承官方现成的基础配置,然后覆盖掉数据集路径和类别数就行了。我第一次用3.0时还在到处找“数据集类要怎么写”,后来才发现根本不用重新注册数据集类,直接用BaseDataset加上metainfo就能搞定。
自定义config文件建议放在configs/steel/目录下,和官方配置分开管理,避免污染源码目录。
3.2 自定义config完整示例
以 Faster R-CNN + ResNet50 FPN 为例,完整配置文件如下:
_base_ = [ '../_base_/models/faster-rcnn_r50_fpn.py', '../_base_/datasets/coco_detection.py', '../_base_/schedules/schedule_1x.py', '../_base_/default_runtime.py' ] data_root = 'data/steel/' metainfo = dict( classes=('crazing', 'inclusion', 'patches', 'pitted_surface', 'rolled-in_scale', 'scratches'), palette=[(220, 20, 20), (0, 0, 220), (0, 220, 220), (220, 0, 220), (220, 220, 0), (20, 220, 0)] ) train_dataloader = dict( batch_size=4, num_workers=2, dataset=dict( data_root=data_root, metainfo=metainfo, ann_file='annotations/instances_train.json', data_prefix=dict(img='images/train/') ) ) val_dataloader = dict( batch_size=4, num_workers=2, dataset=dict( data_root=data_root, metainfo=metainfo, ann_file='annotations/instances_val.json', data_prefix=dict(img='images/val/') ) ) test_dataloader = val_dataloader val_evaluator = dict( type='CocoMetric', ann_file=data_root + 'annotations/instances_val.json', metric='bbox' ) test_evaluator = val_evaluator model = dict( roi_head=dict( bbox_head=dict(num_classes=6) ) ) load_from = 'checkpoints/faster_rcnn_r50_fpn_1x_coco_20200130-047c8118.pth'逐段说下这几处改动的含义:
metainfo里定义的classes必须和转换脚本里的label_map完全一致,顺序都不能乱。palette是每个类别在可视化时的颜色,3.0里不写也能跑,但写上有助于区分结果。
train_dataloader和val_dataloader里的ann_file、data_prefix要仔细对。data_root是统一的前缀,ann_file相对data_root是annotations/instances_train.json,图片路径相对data_root是images/train/。路径写错最常见的报错就是FileNotFoundError,而且经常是训练跑了一会儿才报。
model里的num_classes=6是整份配置里最容易漏改的一处。Faster R-CNN 的改动位置在roi_head.bbox_head,如果你是RetinaNet就是bbox_head.num_classes,Mask R-CNN还得加一行roi_head.mask_head.num_classes。漏了的话会报一个很显眼的错:The num_classes (80) in Shared2FCBBoxHead does not match the classes (6) in metainfo,看到这个就知道去哪改了。
3.3 预训练权重与加载方式
load_from指定的是预训练权重路径。用COCO预训练权重做初始值,对小数据集效果提升非常明显,相当于模型已经知道“什么是边缘、什么是纹理”,只需要在你的数据集上微调最后几层就行。
我把权重文件提前下载好放到checkpoints/目录,然后在load_from里写本地路径。如果你不想用预训练,直接删掉这一行就行,但效果会明显变差,尤其是数据量只有几百张的时候。
这里有个经验:预训练权重的下载速度取决于网络状况,如果经常断,建议用支持断点续传的下载工具,或者找镜像地址。不要在load_from里写一个很长的URL命令等它慢慢下,浪费训练机时间。
4. 开始训练:跑通第一个epoch
4.1 训练命令与日志监控
环境、数据、配置都准备齐了,终于可以启动训练了。在mmdetection源码根目录执行:
python tools/train.py configs/steel/faster-rcnn_r50_fpn_steel.py --work-dir work_dirs/steel --auto-scale-lr--work-dir指定训练日志和权重保存目录,建议每个项目单独建一个,避免多个实验混在一起。--auto-scale-lr是3.0里很实用的功能:当你把batch size从默认值调小后,学习率会自动按比例调整,否则大学习率配小batch容易让loss直接飞成nan。
启动后会在终端打印出模型结构、参数量、数据集统计信息。第一次跑建议盯着日志看前几个iteration,重点关注loss_rpn_cls、loss_cls、loss_bbox这几个值。正常情况下loss会从几开始,随着训练逐步下降,如果一开始就是几个很大的数字,后面很难收敛。
训练日志会同步写到work_dirs/steel/下的.log文件里,可以另开一个终端实时查看:
tail -f work_dirs/steel/20240201_120000.log日志里每一行都是类似这样的格式:
Epoch(train) [1][ 50/200] lr: 0.0010 eta: 1:50:00 time: 0.45 data_time: 0.08 memory: 2350 loss_rpn_cls: 0.2301 loss_cls: 0.5123 loss_bbox: 0.3124time是单次迭代耗时,data_time是数据加载耗时。如果data_time占time的很大比例,说明数据加载是瓶颈,可以调大num_workers或者检查硬盘读取速度。
4.2 显存不足与训练卡死的排查
训练中最常见的问题就是显存爆掉。我用的显卡是24G显存,batch_size=4完全没问题,但如果你的卡只有8G,就需要做三件事:
一是调小batch_size,比如从4改成2,甚至1。二是开启混合精度训练,在训练命令后面加--amp,支持AMP的模型会有明显加速且显存占用降低。三是配置梯度累积,在config里改:
optim_wrapper = dict(accumulative_counts=4)这个的意思是每4个batch累积一次梯度再更新参数,等效于把batch size从2扩展成8,显存却不会成倍增长。
训练卡死不报错的情况也要留意。有一次我num_workers调成8,数据加载直接卡住不动,进程占用CPU但就是不跑。把num_workers调回2或4就好了。另外shuffle在验证集dataloader里要设成False,不然每次验证结果不可复现。
4.3 训练提速的小技巧
如果你只是想快速验证流程有没有跑通,有几个让训练跑快的小技巧:
- 先用小模型测试,比如把backbone改成
faster-rcnn_r18_fpn,跑一个epoch只要十几分钟,验证数据、配置没问题后再换大模型正式训练。 - 数据增强不要一开始就上全套,MMDetection默认的数据增强已经够用,增加额外增强只会拖慢速度。
- 把验证间隔调大一点,比如每5个epoch验证一次,能省出不少时间。在
default_runtime.py里改default_hooks.checkpoint.interval和训练循环的val_interval。 - 日志打印频率默认是50个iter一次,不用改,看趋势足够了。
5. 评估与推理:看模型效果到底行不行
5.1 用test.py做指标评估
训练完12个epoch之后,模型权重保存在work_dirs/steel/epoch_12.pth。此时需要用测试集评估最终效果,在mmdetection根目录执行:
python tools/test.py configs/steel/faster-rcnn_r50_fpn_steel.py work_dirs/steel/epoch_12.pth --eval bbox--eval bbox表示只评估检测框的指标。如果你的任务是分割模型,改成--eval bbox segm就能同时评估分割指标。
输出会给出一个表格:
IoU metric: bbox Average Precision (AP) @[ IoU=0.50:0.95 | area= all | maxDets=100 ] = 0.421 Average Precision (AP) @[ IoU=0.50 | area= all | maxDets=100 ] = 0.683 Average Precision (AP) @[ IoU=0.75 | area= all | maxDets=100 ] = 0.462 Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.173 Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.451 Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.538钢材缺陷很多是细小的裂纹,属于小目标范畴,所以我会特别关注AP_small这一行。第一次跑出来的AP_small往往很低,只有0.1几,这时候不要慌,这是小目标检测的正常现象。可以尝试把输入图片的尺寸调大,比如把config里的img_scale从(1000, 600)调整成(1333, 800),小目标的特征会更明显。
5.2 单图推理与结果可视化
指标看着差不多之后,用真实图片验证一下效果,把模型跑在单张测试图上:
from mmdet.apis import init_detector, inference_detector config_file = 'configs/steel/faster-rcnn_r50_fpn_steel.py' checkpoint_file = 'work_dirs/steel/epoch_12.pth' # 初始化模型 model = init_detector(config_file, checkpoint_file, device='cuda:0') # 推理单张图片 img_path = 'data/steel/images/val/ISIC_0000001.jpg' result = inference_detector(model, img_path) # 可视化并保存 model.show_result(img_path, result, out_file='result.jpg')在MMDetection3.0里,show_result会自动从模型的metainfo读取类别名称和配色,不需要手动传class_names,这点比2.x方便很多。如果你在Jupyter Notebook里跑,可以直接传入show=True在notebook里显示图片。
打开result.jpg之后,重点关注几个方面:有没有漏检、有没有误检,框和目标的贴合程度怎么样。如果模型把背景当成缺陷,大概率是训练数据里背景太杂;如果某些缺陷类别普遍漏检,大概率是这类样本量太少或者形态差异太大,需要针对性补充数据。
6. 高频报错与避坑速查
6.1 高频报错对症速查表
整个流程走下来,下面这些报错是我自己踩过、或者帮同事排查时遇到的最高频的问题,整理成一张表:
| 报错信息 | 可能原因 | 解决方案 |
|---|---|---|
ModuleNotFoundError: No module named 'mmcv' | 环境没装mmcv,或装的是CPU版 | 用mim install "mmcv>=2.0.0"重装 |
AttributeError: 'NoneType' object has no attribute 'xxx' | 数据路径配错,图片或标注没读到 | 逐级检查data_root、ann_file、data_prefix |
AssertionError: The num_classes (80) in ... | 忘了改roi_head.bbox_head.num_classes | 按模型类型找到对应head,把类别数改成你自己的 |
RuntimeError: CUDA out of memory | batch_size太大或显存不足 | 调小batch、开--amp、设置梯度累积 |
FileNotFoundError: xxx.json | 标注文件路径和config不一致 | 确认JSON文件确实存在,检查路径拼接 |
KeyError: 'xxx is not in the model registry' | 模型组件未注册,或config名写错 | 检查_base_引用的模型文件是否存在 |
ValueError: could not convert string to float: 'xxx' | XML里有异常字符,或标注文件编码有问题 | 检查对应XML文件内容,统一UTF-8编码 |
这里面最恶心的是第二种,报错信息指不到具体位置,提示NoneType但不说哪里为空。我当时的排查办法是在config里把data_root的每一层路径都手动ls一遍,最终发现是images/train/目录名写成了train_images/,一个字母之差浪费了我半小时。
6.2 我踩过的那些隐蔽的坑
除了上面能直接定位的报错,还有一些坑是“不报错但结果不对”,这类更危险。
第一是验证集太小。我第一次只分了15张图做验证,结果mAP每次评估波动非常大,同一套权重连续评估两次都能差好几个点。后来把验证集扩到50张左右,指标才稳定下来。
第二是类别名大小写不统一。标注时有人用了Crazing,有人用了crazing,转换脚本里label_map只匹配小写,结果一部分目标被静默丢弃。这类问题不报错,只有统计每个类别的框数量时才能发现。
第三是忘记设置验证评估指标。训练完之后跑tools/test.py,没加--eval bbox,程序只加载模型然后直接结束,什么指标都不输出。这时候不是模型坏了,是命令少了参数。
第四是可视化时类别颜色看不出差异。palette里如果用了相近的颜色,多类别同时出现时根本分不清谁是谁。我后来统一用红、绿、蓝、黄、紫、青这类高区分度颜色,视觉效果一下就好了。
写在最后
整套流程跑下来,我的一个明显感受是:MMDetection 3.0 的配置体系虽然初看复杂,但只要理解了“继承+覆盖”这个核心思想,自定义数据集反而比2.x更省事。最花时间的不是代码,而是数据整理和版本对齐。建议第一次跑的时候,不要把目标定在“我要上什么高大上的模型”,先用Faster R-CNN或RetinaNet把全套流程跑顺一次,比刷十个教程都管用。我自己的习惯是流程跑通之后,马上把数据转换脚本和config模板存好,下次换个数据集,改改路径和类别就完事。这篇基本把我踩过的坑都写进去了,如果你在实操中遇到别的问题,欢迎留言交流。