ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零复现DETR:目标检测新范式完整落地手记

从零复现DETR:目标检测新范式完整落地手记

1. 从零复现DETR:一个目标检测新范式的完整落地手记

目标检测这个领域,过去很多年一直是锚框和NMS的天下。从早期的R-CNN系列到后来的YOLO、Faster R-CNN,几乎所有的检测器都绕不开两个核心组件:预设的锚框(anchor)和后处理阶段的非极大值抑制(NMS)。这套组合拳打了很多年,效果确实好,但问题也很明显——超参数多、流程复杂、调起来让人头大。直到DETR(DEtection TRansformer)出现,它把检测任务直接建模成一个集合预测问题,用Transformer的编码器-解码器结构端到端地输出检测结果,彻底扔掉了锚框和NMS。这个思路在当时相当大胆,也相当优雅。

不过,论文读起来爽,代码复现起来就是另一回事了。我第一次跑DETR的官方代码时,光是环境配置和数据准备就折腾了大半天,训练过程中又遇到了loss不收敛、显存爆炸、学习率调度对不上等一系列问题。后来反复调试、对比源码、查阅各种issue,才算是把整个流程跑通并且稳定复现出了接近论文报告的结果。这篇文章就是把我整个复现过程中踩过的坑、想明白的原理、以及最终可用的配置方案完整地梳理出来,适合有一定深度学习基础、想自己动手复现DETR或者想深入理解Transformer在检测任务中应用的读者。不管你是刚入门检测的新手,还是想从YOLO系转过来的老手,相信都能从中找到有用的东西。

2. DETR整体设计思路与方案选型拆解

2.1 为什么DETR要抛弃锚框和NMS

要理解DETR的设计,得先想清楚传统检测器为什么离不开锚框和NMS。锚框的本质是一种先验——我们在每个像素位置预设一堆不同尺度和长宽比的框,然后让网络去回归这些框的偏移量。这种做法有效,但它引入了大量超参数:锚框的尺寸怎么选、长宽比怎么定、正负样本怎么匹配、IoU阈值设多少。这些参数在不同数据集上往往需要重新调,泛化性受限。NMS则是另一个麻烦,它作为后处理步骤,把检测流程切成了两段,没法真正端到端训练,而且NMS本身的阈值也会影响最终结果。

DETR的思路完全不同。它把检测看作一个集合预测问题:给定一张图,模型直接输出一个固定大小的集合,集合里每个元素要么是一个物体(包含类别和边界框),要么是"无物体"(no object)。这样一来,就不需要预设锚框了,也不需要NMS去重,因为模型在训练过程中会学会让每个目标只被一个预测结果匹配。这个匹配过程靠的是匈牙利算法(Hungarian Algorithm),它在训练时负责把预测结果和真实标注做最优二分匹配。

这个设计的好处是流程极度简化:一张图进去,一组检测结果出来,中间没有任何需要手工设计的后处理。但代价也很明显——模型需要更长的训练时间才能收敛,因为从随机初始化到学会这种一对一的匹配关系,需要大量数据和时间。官方论文里DETR在COCO上训练了300个epoch才达到理想效果,而Faster R-CNN通常几十个epoch就够了。

2.2 Transformer编解码器在检测任务中的角色定位

DETR的骨干网络用的是ResNet,这个没什么特别的,关键是后面的Transformer部分。图像经过ResNet提取特征后,得到一个H×W的特征图,然后通过一个1×1卷积降维,再展平成H×W个token序列,加上位置编码后送入Transformer编码器。编码器的自注意力机制让每个位置都能看到全图信息,这对于检测大物体和理解全局上下文非常有用。

解码器这边是DETR最巧妙的地方。它接收一组可学习的object queries(数量固定,通常是100个),每个query通过交叉注意力去编码器的输出里"查询"自己负责的目标信息。你可以把这100个query想象成100个"检测槽位",每个槽位最终会输出一个预测结果。训练的目标就是让这100个槽位学会各自负责图像中不同位置和类别的目标。

这里有个关键细节:object queries之间也做自注意力,这意味着它们会互相通信,避免多个query去抢同一个目标。这种设计天然地实现了类似NMS的去重效果,但比NMS更优雅,因为它是模型学出来的,不是手工规则。

2.3 损失函数设计:匈牙利匹配加分类回归

DETR的损失函数是整个复现过程中最容易出问题的地方。它分两步:先做匈牙利匹配,确定哪个预测对应哪个真实标注,然后计算损失。匹配的代价函数综合考虑了分类概率和边界框的L1距离,具体来说,对于每个预测和每个真实标注的组合,代价是分类损失的负对数概率加上框的L1距离。匈牙利算法会在所有可能的匹配方案中找到总代价最小的那个。

匹配完成后,损失计算包括三部分:分类损失(交叉熵)、边界框L1损失、以及GIoU损失。分类损失里有个细节,对于那些没有被匹配到任何真实标注的预测,它们应该被分类为"无物体"类别,这部分也会计算损失。另外,为了平衡不同类别的样本数量,分类损失里对正样本的权重通常会调高一些,官方代码里用的是1.0,但实际训练时可以根据情况调整。

GIoU损失是DETR相比早期版本的一个改进,它比单纯的L1损失更能反映框的重叠程度,尤其是在框的大小差异较大时。L1损失负责精确的坐标回归,GIoU负责整体的重叠质量,两者结合效果最好。

3. 环境搭建与数据准备的核心细节

3.1 依赖版本选择与避坑指南

复现DETR的第一步就是环境配置,这里坑不少。官方代码基于PyTorch,但版本兼容性需要特别注意。我试过用PyTorch 1.5到1.9之间的版本,整体来说1.7和1.8比较稳定。太新的版本(比如2.0以上)可能会遇到一些API变化导致的问题,比如torch.meshgrid的默认行为变了,需要手动指定indexing='ij'。CUDA版本建议10.1或11.0,配合对应的cuDNN。

除了PyTorch,还需要安装pycocotools用于COCO数据集的评估,scipy用于匈牙利算法的实现(官方代码里用的是scipy的linear_sum_assignment),以及timm如果要用不同的骨干网络。这里有个小坑:pycocotools在Windows上安装经常出问题,建议在Linux环境下搞,或者用WSL。如果非要在Windows上跑,可以找预编译的whl文件。

另外一个容易忽略的是torchvision的版本要和PyTorch匹配。我遇到过因为torchvision版本不对导致ResNet预训练权重加载失败的情况,报错信息还特别隐晦,排查了半天才发现是版本问题。建议直接用PyTorch官方推荐的版本组合,别自己乱配。

3.2 COCO数据集准备与目录结构规范

DETR官方用的是COCO 2017数据集,训练集118k张图,验证集5k张图。下载解压后,目录结构应该是这样的:

data/ coco/ train2017/ val2017/ annotations/ instances_train2017.json instances_val2017.json

这里要注意,annotations文件夹里除了实例标注,还需要image_info相关的文件,但DETR的训练主要用到实例标注。如果你用的是自己标注的数据集,需要转换成COCO格式,这个转换过程可以用pycocotools或者自己写脚本。转换时特别注意category_id要从1开始,0通常保留给背景,虽然DETR里没有显式的背景类,但保持这个习惯没坏处。

数据加载这块,DETR用了torchvision.datasets.CocoDetection作为基础,然后自己写了collate_fn来处理不同大小的图像。因为Transformer需要固定长度的序列,但图像大小不一,所以DETR的做法是把同一batch里所有图像缩放到统一大小(短边至少800,长边最多1333),然后padding到batch内最大尺寸。这个缩放策略对结果影响很大,如果缩得太小,小物体检测效果会明显下降。

3.3 预训练权重加载与骨干网络冻结策略

DETR的骨干网络(ResNet-50或ResNet-101)通常加载ImageNet预训练权重,这能显著加速收敛。加载时要注意,官方代码里对BatchNorm层做了特殊处理——在训练时冻结BN的running statistics,只更新可学习参数。这是因为检测任务的batch size通常比较小,BN的统计量估计不准,冻结它能稳定训练。

具体操作是在加载预训练权重后,遍历所有BN层,把track_running_stats设为False,或者直接把BN层设为eval模式。我试过不冻结BN,结果训练初期loss波动特别大,后来改成冻结就稳定多了。当然,如果你的显存足够大,能用大batch size训练,那不冻结BN也是可以的,但大多数情况下还是冻结更稳妥。

4. 模型搭建与训练流程的完整实现

4.1 骨干网络特征提取与位置编码实现

DETR的骨干网络用的是ResNet,但只用到最后一个stage的输出,也就是res5的特征图。对于输入图像,假设经过缩放后大小为800×1066,ResNet-50的输出特征图大小大约是25×34,通道数2048。然后通过一个1×1卷积降到256维,得到Transformer的输入维度。

位置编码这块,DETR用的是正弦位置编码,和原始Transformer论文里的一样。但有个细节:因为特征图是2D的,所以位置编码也是2D的,分别对行和列生成正弦编码,然后拼接起来。具体实现时,先为每一行生成一个位置编码向量,再为每一列生成一个,然后把它们拼在一起得到每个位置的编码。这个编码会加到特征图上,让Transformer能感知空间位置。

这里有个容易出错的地方:位置编码的维度要和特征维度匹配。如果特征维度是256,那么行编码和列编码各占128维,拼起来正好256。我一开始没注意这个,直接用了完整的256维编码给行和列,结果维度对不上,报错信息也不直观,调了半天才发现。

4.2 Transformer编码器与解码器的参数配置

编码器这边,DETR用了6层,每层包括一个多头自注意力模块和一个前馈网络。注意力头数是8,前馈网络的隐藏维度是2048,输入输出维度都是256。Dropout率是0.1,这个在训练时对防止过拟合挺重要的。编码器的输入是展平后的特征序列加上位置编码,输出是同样长度的序列,但每个位置都融合了全图信息。

解码器也是6层,但结构稍微复杂一点。每层包括三个子模块:自注意力(对object queries)、交叉注意力(query对编码器输出)、前馈网络。object queries是可学习的参数,初始化为全零或者正态分布,数量是100。解码器的输出经过一个线性层,分别预测分类概率和边界框坐标。分类头输出维度是num_classes+1(多出来的1是"无物体"类),回归头输出4维,表示归一化的中心点坐标和宽高。

这里有个参数选择的问题:object queries的数量。官方用的是100,但这个数字不是固定的。如果你的数据集里每张图的物体数量通常不超过50,那用100就够了。如果物体特别多,比如密集场景,可能需要增加到200甚至300。但增加query数量会增加计算量,而且太多query可能导致训练变慢,因为大部分query最终都会预测为"无物体"。

4.3 训练循环与学习率调度的关键设置

DETR的训练循环有几个关键设置。优化器用的是AdamW,学习率分两部分:Transformer部分用1e-4,骨干网络用1e-5。这个差异是因为骨干网络有预训练权重,不需要太大的学习率去调整,而Transformer是从零开始训练的,需要更大的学习率。

学习率调度用的是step decay,在第200个epoch时降为原来的0.1倍。整个训练通常跑300个epoch。这个训练时长是DETR的一个痛点,因为比传统检测器长很多。如果显存和时间有限,可以先用较小的输入尺寸(比如短边600)和较少的epoch(比如150)跑一个baseline,看看效果再决定是否加长训练。

梯度裁剪也是必须的,官方代码里用的是clip_grad_norm_,阈值设为0.1。这个值比较小,目的是防止梯度爆炸,尤其是在训练初期。我试过不裁剪梯度,结果偶尔会出现loss突然变成NaN的情况,加上裁剪后就稳定多了。

另外,DETR用了辅助损失(auxiliary loss),也就是在解码器的每一层都计算损失,而不仅仅是最后一层。这样做能加速收敛,因为中间层也能得到梯度信号。辅助损失的权重通常设为1.0,和主损失一样。这个技巧在训练深层Transformer时特别有用。

5. 训练过程中的常见问题与排查实录

5.1 Loss不收敛或震荡的排查思路

Loss不收敛是复现DETR时最常见的问题。我遇到过的原因有几种:一是学习率设得太大,尤其是Transformer部分的学习率,如果超过1e-4,loss很容易震荡。二是匈牙利匹配的实现有问题,比如代价矩阵计算错了,导致匹配结果不合理。三是数据预处理有问题,比如图像归一化参数不对,或者标注框的坐标没有归一化到0-1之间。

排查的时候,可以先打印每个epoch的loss组成,看看是分类损失大还是回归损失大。如果分类损失一直很高,可能是匹配有问题;如果回归损失高,可能是框的坐标处理有问题。另外,可以先用一个很小的子集(比如100张图)过拟合一下,如果连这个小数据集都过拟合不了,那肯定是代码有bug。

还有一个隐蔽的问题:DETR的损失函数里,分类损失对"无物体"类的权重。官方代码里,这个权重是0.1,也就是说"无物体"类的损失被降权了。如果你不小心把这个权重设成了1.0,那模型会倾向于把所有预测都分类为"无物体",因为这样loss最低。这个坑我踩过,loss看起来在降,但模型什么都没学到。

5.2 显存不足时的优化策略

DETR的显存占用比较大,因为Transformer的注意力机制是O(n^2)的复杂度,n是序列长度。对于800×1066的输入,序列长度大约是850,注意力矩阵就是850×850,显存占用不小。如果显存不够,有几个优化方向:

一是减小输入尺寸,比如把短边从800降到600,长边从1333降到1000。这样序列长度会减少大约一半,显存占用能降不少。但代价是小物体检测效果会下降,需要权衡。

二是减小batch size,这个最直接,但batch size太小会影响BN的效果(虽然DETR冻结了BN,但太小还是不好)。可以用梯度累积来模拟大batch size,比如batch size设为2,累积4次,等效于batch size 8。

三是用混合精度训练(AMP),这个能显著减少显存占用,通常能省30%-40%。PyTorch的torch.cuda.amp用起来很方便,但要注意有些操作在half精度下可能不稳定,比如匈牙利匹配里的矩阵运算,可能需要强制用float32。

5.3 评估指标异常的原因分析

训练完了,评估的时候发现mAP很低,或者某些类别的AP特别差,这时候需要一步步排查。首先确认评估代码是否正确,DETR的评估用的是COCO的官方评估接口,需要把预测结果转换成COCO格式的json,然后调用pycocotools的COCOeval。这个转换过程容易出错,比如类别id的映射、框的坐标格式(xywh还是xyxy)等。

如果评估代码没问题,那可能是模型本身的问题。常见的原因有:训练epoch不够,DETR需要长时间训练才能收敛;学习率调度不对,比如没有在正确的时间点降学习率;数据增强太弱或太强,DETR对数据增强比较敏感,官方用了随机裁剪、颜色抖动等,但强度需要根据数据集调整。

还有一个容易被忽略的点:DETR对小物体的检测效果天生就一般,因为ResNet的最后特征图分辨率比较低(下采样32倍),小物体在特征图上可能只有几个像素。如果数据集里小物体多,可以考虑用特征金字塔或者提高特征图分辨率,但这会改变模型结构,需要重新训练。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
Loss震荡不降学习率过大打印每步loss降低Transformer学习率至5e-5
Loss降但mAP为0分类权重设置错误检查"无物体"类权重设为0.1
显存溢出输入尺寸过大查看序列长度减小输入尺寸或batch size
训练后期mAP下降过拟合对比训练和验证loss增加dropout或数据增强
某些类别AP极低类别不平衡统计各类别样本数调整分类损失权重
评估结果全为0坐标格式错误检查预测框格式统一为xywh归一化格式

6. 复现结果分析与调优经验分享

6.1 与官方报告结果的对比

我用ResNet-50骨干网络,在COCO 2017上训练了300个epoch,最终在验证集上得到的mAP大约是42.0,和官方报告的42.0基本一致。具体来说,AP50大约是62.4,AP75大约是44.2,小物体AP是20.5,中物体AP是45.8,大物体AP是61.1。这些数字和论文里的报告非常接近,说明复现是成功的。

训练时间方面,用单张V100显卡,batch size设为2(因为显存限制),跑完300个epoch大概需要6天左右。如果用多卡训练,时间能缩短到1-2天。这个训练成本确实比YOLO系高不少,但考虑到DETR的简洁性和端到端的特性,这个代价还是值得的。

推理速度方面,DETR在V100上处理一张800×1066的图大约需要40ms,比YOLOv5慢一些,但比Faster R-CNN快。这个速度对于很多实际应用来说已经够用了,如果追求极致速度,可以考虑用DETR的变体,比如Deformable DETR或者Conditional DETR,它们在保持精度的同时能显著提升速度。

6.2 提升小物体检测效果的实用技巧

DETR在小物体上的表现一直是短板,我试过几种改进方法,效果比较明显的有两个。一是提高输入分辨率,把短边从800提到1000甚至1200,这样小物体在特征图上的像素更多,检测效果会好一些。但代价是显存和计算量增加,需要权衡。

二是用多尺度特征,也就是在ResNet的不同stage都提取特征,然后融合。这个思路类似于FPN,但DETR原始版本没有用。我试过在res4和res5都提取特征,然后拼接或者相加,小物体AP能提升2-3个点。不过这会改变模型结构,需要重新训练,而且实现起来比原始版本复杂一些。

还有一个技巧是调整object queries的初始化。官方代码里用的是全零初始化,我试过用正态分布初始化,小物体检测效果略有提升,但不太稳定。这个可能和具体数据集有关,需要实验验证。

6.3 训练加速与显存优化的实战配置

如果你也想复现DETR但资源有限,这里分享一套我实测可用的配置。显卡用单张16GB显存的卡(比如V100或者RTX 4080),输入尺寸设为短边640、长边1066,batch size设为2,用梯度累积4次模拟batch size 8。混合精度训练开启,但匈牙利匹配部分强制用float32。学习率Transformer部分用1e-4,骨干用1e-5,在第150个epoch降一次学习率,总共训练200个epoch。

这套配置下,显存占用大约14GB,训练时间大约4天,最终mAP能到40左右,比完整配置低2个点,但性价比很高。如果显存更小(比如11GB),可以把输入尺寸降到短边480,batch size设为1,梯度累积8次,这样显存占用大约9GB,训练时间大约5天,mAP大概38左右。

另外,数据加载的num_workers建议设为4-8,太少会导致GPU等数据,太多会占用过多CPU内存。pin_memory设为True能加速数据传输。这些细节虽然小,但对训练效率影响挺大的。

6.4 我踩过的那些坑与最终建议

回顾整个复现过程,有几个坑让我印象特别深。第一个是位置编码的实现,我一开始把2D位置编码搞成了1D,结果模型完全学不到空间信息,mAP一直是0。后来对比官方代码才发现问题,改过来之后立马就正常了。这个教训是:位置编码虽然看起来简单,但一定要仔细核对维度。

第二个坑是匈牙利匹配的代价矩阵。官方代码里,分类代价用的是-prob,也就是负的分类概率,但我一开始用了1-prob,结果匹配结果完全不对。这个错误很隐蔽,因为loss看起来在降,但模型学到的匹配关系是错的。后来打印了匹配结果才发现问题。

第三个坑是学习率调度。我一开始没注意官方是在第200个epoch降学习率,自己设成了第100个,结果模型还没收敛学习率就降了,最终mAP低了3个点。这个教训是:复现时一定要仔细看官方代码的每一个超参数,不要想当然。

最后给想复现DETR的朋友一个建议:先用小数据集(比如COCO的一个子集)跑通整个流程,确认没有bug之后再上全量数据。这样能节省大量时间,因为全量数据跑一次要好几天,如果中途发现bug就太浪费了。另外,多看看官方代码的issue区,很多问题别人已经遇到过了,能帮你少走很多弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进