“DETR训练自己的数据集”这句话,我最近半年回复过不只一遍。很多开发者第一次接触DETR,都是从公开数据集跑Demo开始的,等真到了拿自己的业务数据训练那一步,环境、标注格式、参数配置、loss不下降,每个环节都能卡上几天。这篇文章我就用自己折腾的一个小型目标检测项目做完整复盘,从装环境到数据转换,再到实际训练和推理,把走过的弯路和最后的稳定方案都摊开讲。
这篇内容适合两类人:一类是已经在用Faster R-CNN、YOLO这类传统检测器,想试试DETR但不知道从哪下手的;另一类是跑通了Demo,但换成自己的数据后却发现训练效果一塌糊涂的。只要你手头有一张能跑的显卡,照着文章里的步骤来,大概率能少踩一半坑。
1. 环境组合:版本兼容是第一道门槛
1.1 为什么不能全选最新版本
DETR这个模型不是新东西了,它的官方代码仓库已经挺长时间没有大更新。很多人遇到的第一道坎不是模型本身,而是环境和它不兼容。尤其是timm这个库,DETR的backbone权重要从timm里借,timm版本一升级,ResNet的输出行为在某些版本上会变,轻则shape对不上,重则直接AttributeError。
我实测下来最稳的一套组合是:Python 3.8.x、PyTorch 1.10.x、torchvision 0.11.x、timm 0.5.4,再加pycocotools 2.0.x。这套搭配我从头到尾没有遇到过一次因为库版本导致的报错。
提示:不要用Python 3.10以上版本去硬跑老代码,虽然大概率也能跑,但没必要在一个不确定的变量上浪费时间。先跑通,再考虑升级。
1.2 装完以后先跑一次环境验证
环境装好以后,别直接开训,先花两分钟验证一下。最简单的验证方法就是导入依赖库,再加一段最小推理:
python -c "import torch, torchvision, timm, pycocotools; print(torch.__version__, torchvision.__version__, timm.__version__); print('env ok')"如果这步过了,再跑一次官方仓库自带的Demo脚本,随便拿一张图片做一次推理。能出框,说明环境链路通了。如果卡在这一步,我遇到的实际情况里,九成是timm版本太新,降到0.5.4就好了。
还有一个小坑提醒:官方仓库是源码形式,不是PyPI包,直接pip install是装不出完整的可执行环境的。正确做法是克隆下来,把仓库根目录加入PYTHONPATH,然后在该目录下运行训练脚本。
2. 数据集先过格式关:DETR只认COCO风格
2.1 一个容易忽略的前提:类别编号和bbox计算
DETR的官方训练脚本默认读取COCO风格的JSON标注。很多人用VOC的XML或YOLO的TXT用习惯了,第一次看COCO的JSON结构会有点懵。核心就三块:
images:每张图的文件名、id、宽高annotations:每个目标框属于哪张图、哪个类别、bbox坐标、面积categories:类别id和名字的映射
这里有一个特别容易踩的坑:COCO的bbox是[x, y, width, height]格式,不是VOC那种[x1, y1, x2, y2]。很多人在转换脚本里直接复制VOC的坐标过去,结果框的位置全偏了。
另一个坑是类别id。COCO格式里类别id必须从1开始连续编号,不要把0号留出来当背景。DETR的数据加载器在读取标注的时候,对类别id为0的样本会做特殊处理,和背景索引概念容易混淆,最后表现出来就是训练过程看起来正常,但验证集指标一塌糊涂。
2.2 从VOC标注转COCO JSON的最小脚本
如果你用的是某款开源标注工具,导出COCO格式时通常会自动生成大部分字段。但如果你手头是VOC格式的老数据,就得自己写转换脚本。我贴一段自己常用的转换逻辑,重点看bbox换算和过滤逻辑:
import os import json import xml.etree.ElementTree as ET from PIL import Image def voc_to_coco(xml_dir, img_dir, output_json): images, annotations, categories = [], [], [] cat_map = {"person": 1, "car": 2, "dog": 3} # 按你的实际类别改 ann_id = 1 for idx, xml_name in enumerate(sorted(os.listdir(xml_dir))): if not xml_name.endswith(".xml"): continue xml_path = os.path.join(xml_dir, xml_name) tree = ET.parse(xml_path) root = tree.getroot() img_path = os.path.join(img_dir, root.find("filename").text) w, h = Image.open(img_path).size image_id = idx + 1 images.append({ "file_name": root.find("filename").text, "id": image_id, "width": w, "height": h, }) for obj in root.findall("object"): name = obj.find("name").text if name not in cat_map: continue bndbox = obj.find("bndbox") x1 = float(bndbox.find("xmin").text) y1 = float(bndbox.find("ymin").text) x2 = float(bndbox.find("xmax").text) y2 = float(bndbox.find("ymax").text) w_box = x2 - x1 h_box = y2 - y1 # 坏样本直接跳过,后面会细说 if w_box <= 0 or h_box <= 0: continue annotations.append({ "id": ann_id, "image_id": image_id, "category_id": cat_map[name], "bbox": [x1, y1, w_box, h_box], "area": w_box * h_box, "iscrowd": 0, "segmentation": [], }) ann_id += 1 categories = [{"id": v, "name": k} for k, v in cat_map.items()] coco_json = {"images": images, "annotations": annotations, "categories": categories} with open(output_json, "w", encoding="utf-8") as f: json.dump(coco_json, f, ensure_ascii=False)注意我用Image.open重新读了一次图片宽高,而不是直接从XML里拿size,因为XML里的size有时候和实际图片不一致,训练时图像加载器负责对齐就会出问题。另外segmentation字段可以直接给空列表,DETR训练不需要分割掩码,iscrowd给0就行。
2.3 标注脏数据:训练前必须过滤掉的坏样本
这一节很重要。我在帮一个小伙伴看数据集的时候,发现训练loss一直掉不下去,最后定位到标注里有一个bbox的width是负数。这种脏样本在训练时会让损失计算产生异常值,表现为loss突然跳高或者出现NaN。
建议在转换脚本里就做好过滤:bbox宽高小于等于0的跳过、坐标超出图片边界的clip到边界内、类别名不匹配的跳过。宁可在准备阶段多花十分钟写过滤逻辑,也别在训练跑了一半的时候去翻一千张标注图。
3. 训练命令与参数取舍:不要照抄COCO默认值
3.1 一次完整训练的启动命令
数据准备好以后,目录结构建议按这样组织:
data/ mydata/ annotations/ train.json val.json train2017/ val2017/注意train2017和val2017这两个文件夹的名字可以自己定,但要在传入参数时保持一致。官方代码里图像路径是根据--coco_path加固定的子目录拼出来的,比如data/mydata/train2017/xxx.jpg,所以文件夹名字必须和脚本里写的一致,或者你手动改掉。
启动命令我一般是这么写的:
python main.py \ --dataset_file coco \ --coco_path data/mydata \ --output_dir outputs/ \ --resume checkpoints/detr_resnet50_finetune.pth \ --epochs 50 \ --lr_drop 40 \ --batch_size 2 \ --num_workers 4 \ --num_classes 3逐行解释一下:
--resume,很多人以为只能用来断点续训,其实也可以传一个预训练权重的路径,训练开始前会自动加载。--epochs 50,官方COCO上是300个epoch起跳,那是大规模数据集的需求,小数据集完全没必要。我自己的经验是50个epoch已经能看到比较理想的结果。--lr_drop 40,在第40个epoch时学习率下降10倍,帮助loss在后期稳定收敛。--batch_size 2,显卡不够大就老实降batch,后面会说学习率怎么配合调整。--num_classes,把DETR的类别头改成你自己的类别数,这个参数在配置文件里不写就会默认用COCO的80类,模型能跑但输出维度对不上。
3.2 backbone、num_queries和显卡预算的平衡
DETR默认使用ResNet-50作为backbone,参数大概在4000多万。如果显卡显存比较充裕,比如20G以上,可以尝试ResNet-101,效果会好一些,但训练时间也肉眼可见地变长。
还有一个参数是--num_queries,默认值是100。它的含义是DETR每次预测最多输出100个框。如果你的业务场景每张图里就三五个目标,可以把num_queries减到50甚至30,训练速度和显存占用都会改善。反过来,如果场景是密集人群、密集货架这种,100个query可能不够用,要适当往上调。
这里有一个取舍逻辑:query数量不等于最终输出数量,模型会在训练过程中学会让多余的query预测“空”。但在实际训练中,query越多,计算量越大、收敛越慢,所以按需调整是对的。
3.3 学习率、epoch和lr_drop怎么配合
官方默认学习率是1e-4,对应的batch size是16。很多人在一张显卡上只能跑到batch size 2或者4,如果学习率还维持1e-4,训练很容易震荡或者直接NaN。
一个简单有效的经验法则:学习率大致按batch size比例缩放。比如batch size从16降到4,学习率就降到1e-4的四分之一,也就是2.5e-5,最多也不要超过5e-5。我实测在小batch下用2.5e-5,loss曲线的下降过程非常平稳。
注意:学习率太小会让loss前几十个step看起来完全不动,这时候别急着调大学习率,先确认预训练权重有没有正确加载。
还有一点,如果你从零开始训练而不是加载预训练权重,那么loss前几十个epoch都不会有太漂亮的下降。DETR的收敛本来就比传统检测器慢,这是架构特性决定的,不是你的参数写错了。
4. 训崩了怎么办:loss不降、NaN和显存告急的排查
4.1 loss曲线不降:优先检查特征层有没有被预训练
A同学的例子很典型。他在自己的数据集上从零开始训练DETR,跑了100个epoch,分类loss始终掉不到理想区间,框的位置也不准。后来我让他加载了公开数据集的预训练权重,前30个epoch的下降趋势就完全不一样了。
排查链路是这样的:
- 先确认
--resume指向的权重路径是否存在,文件大小是否正常。 - 看训练日志里的
Loading state dict...,如果某些key加载失败,比如class_embed、query_embed这些输出层参数,是正常的,因为类别数变了,这些层本来就要重训。 - 但是backbone层和transformer部分的参数如果大量加载失败,那就要检查权重文件是否下完整、路径是否指对。
DETR的特征提取能力基本都靠预训练权重,输出头可以重训,特征层要是丢了预训练信息,小数据集上几乎不可能靠随机初始化训出来。
4.2 NaN:大多数情况是数据的问题
NaN出现的时候,先把锅甩给学习率之前,先检查数据。我遇到过的NaN情况里,脏数据占了大头。
最常见的几种原因:
- 标注文件里存在宽高为0的bbox。
- 坐标值超出了图片尺寸范围,导致归一化后出现负值。
- 类别id不连续或超出
num_classes的范围。 segmentation字段里填了非法多边形坐标。
作为预防,除了在数据预处理时候过滤,还可以在训练代码里加上梯度裁剪。DETR官方代码里没有默认开启梯度裁剪,但在小数据集上我建议自己加上:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)把max_norm设为10就够了,太大起不到保护作用,太小会影响正常收敛。
4.3 显存告急与小目标场景的取舍
DETR默认把输入图像缩放到最长边800像素,如果你的数据集是1080P甚至更高分辨率的监控画面,不调整策略直接开训,显存爆炸是家常便饭。
办法有几个:
- 把
--batch_size降到1。 - 在
transforms.py里把max_size改小,比如从1333改成800以下。 - 减少
num_queries。
还有一个策略层面的提醒:DETR本身在检测小目标方面并不擅长。如果你的业务里大量目标小于32×32像素,直接拿原始分辨率训练,效果通常不如传统检测器。我的建议是先做图像切片或者把输入分辨率调大,让模型至少能“看清”小目标,再谈训练技巧。
4.4 看评估结果时重点看哪几个指标
训练完以后官方脚本会自动跑COCO评估,输出一长串指标。我建议先看AP50,再看全部类别的平均AP。小数据集上平均AP可能会偏低,但AP50只要逼近0.7以上,这个模型就有实用价值。
如果AP50还行、大目标AP高、小目标AP特别低,说明模型学得还不够细,优先考虑多训几个epoch或者调高输入分辨率,而不是去调网络结构。
5. 训练收尾:checkpoint加载、推理坐标还原与部署边界
5.1 从checkpoint恢复推理的最小代码
训练结束后,--output_dir下会保存最新的pth权重。写推理脚本的时候,要注意加载方式与训练时保持一致:
import torch from models import build_model from util.misc import nested_tensor_from_tensor_list model, criterion, postprocessors = build_model( num_classes=3, device="cuda" ) checkpoint = torch.load("outputs/checkpoint.pth", map_location="cuda") model.load_state_dict(checkpoint["model_state_dict"]) model.eval()如果你保存过model_state_dict和optimizer_state_dict分开的版本,注意load_state_dict时传入的是model部分,不是整个checkpoint字典。很多人会在这一步把dict直接丢进去,结果报一堆missing key。
5.2 把输出坐标从归一化值还原成原图坐标
DETR的pred_boxes输出的是中心点归一化坐标,格式是[cx, cy, w, h],每个值都在0到1之间。还原成原图像素坐标的公式是:
def denormalize_boxes(boxes, orig_w, orig_h): x1 = (boxes[:, 0] - boxes[:, 2] / 2) * orig_w y1 = (boxes[:, 1] - boxes[:, 3] / 2) * orig_h x2 = (boxes[:, 0] + boxes[:, 2] / 2) * orig_w y2 = (boxes[:, 1] + boxes[:, 3] / 2) * orig_h return torch.stack([x1, y1, x2, y2], dim=-1)同时注意pred_logits要先过一个softmax,然后按阈值过滤低置信度的query。实操中我会把阈值设在0.7左右,再结合类别过滤逻辑一起用。这部分和传统检测器的NMS后处理不太一样,DETR的输出天然不需要NMS,直接按分数截断就行,这算是它省事的地方。
5.3 部署边界:PyTorch直接推理 vs ONNX导出
现阶段如果是内部工具或服务,直接用PyTorch推理完全够用。DETR的encoder-decoder结构导出ONNX会比常规CNN检测器麻烦一些,主要在于num_queries变成动态维度时,导出需要额外处理。如果没有明确的边缘设备部署需求,我不建议一开始就折腾ONNX,先把PyTorch那条链路跑稳更重要。
我在实际使用中发现,把DETR接进业务代码最花时间的不是模型本身,而是“预测框的语义化”:原图坐标还原、类别映射、置信度阈值调整、帧率限制。这些逻辑单独抽一个封装函数,会省去后面大量的重复工作。
这个内容后续还可以扩展的方向:换用Deformable DETR来降低收敛难度、利用分段训练策略做更大分辨率输入,或者用蒸馏方式把DETR压到更小模型上部署。不过那都是后话了,先把当前这条路径跑通,比什么都重要。