DETR实战:用Transformer重构目标检测的工程实现与代码精解
如果你在过去几年里一直用Faster R-CNN、YOLO这类基于锚框(Anchor)的检测器,第一次看到DETR(Detection Transformer)的论文时,可能会觉得有点“离经叛道”。它直接把整个检测流程简化成了一个集合预测问题,扔掉了非极大值抑制(NMS)和锚框生成这些我们习以为常的组件。但当你真正动手去复现它,把代码跑起来,看着Transformer Encoder-Decoder输出的那一串预测框时,那种感觉又很奇妙——原来目标检测还可以这样玩。
这篇文章不会重复论文里的公式推导,那些你在很多阅读笔记里都能找到。我想和你聊的,是作为一个需要快速上手、甚至要在自己项目里尝试DETR的开发者,真正应该关心什么。我们会深入Facebook官方代码库的核心模块,掰开揉碎了看匈牙利损失是怎么用PyTorch高效实现的,Transformer编解码器结构里那些容易被忽略的细节,以及和Faster R-CNN在工程实现上的本质差异。当然,我也会分享一个可以直接在Colab上运行的Notebook,还有我调试过程中踩过的那些坑和解决方案。
1. 环境准备与代码库概览
在开始写任何代码之前,先把环境搭对能省去后面一大堆麻烦。DETR官方代码库(facebookresearch/detr)对PyTorch和Torchvision的版本有一定要求,太新或太旧都可能出问题。
我个人的经验是,创建一个独立的conda环境是最稳妥的做法。下面是我在Linux和MacOS上验证过的配置:
# 创建并激活环境
conda create -n detr python=3.8
conda activate detr
# 安装PyTorch(请根据你的CUDA版本选择,这里以CUDA 11.3为例)
pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 --extra-index-url https://download.pytorch.org/whl/cu113
# 安装DETR核心依赖
pip install pycocotools
pip install scipy
pip install tqdm
pip install matplotlib
注意:如果你用的是Windows系统,
pycocotools的安装可能会稍微麻烦一些,可能需要从源码编译或者寻找预编译的wheel文件。一个变通方法是先安装pycocotools-windows这个包。
装好环境后,把官方代码库clone下来:
git clone https://github.com/facebookresearch/detr.git
cd detr
这个代码库的结构非常清晰,主要文件就那几个,我们快速过一遍:
models/detr.py: 这是核心中的核心,定义了整个DETR模型的结构。DETR类就在这里。models/transformer.py: Transformer编码器和解码器的具体实现。和原始Transformer论文里的结构略有不同,需要仔细看。models/position_encoding.py: 位置编码的实现,包括正弦编码和可学习的位置编码。models/backbone.py: 主干网络,默认是ResNet。engine.py: 训练和评估的主要循环逻辑。main.py: 训练脚本的入口。
对于只想先跑通推理的开发者,我建议先从 demo.py 或 hubconf.py 入手。官方提供了通过TorchHub加载预训练模型的便捷方式:
import torch
model = torch.hub.load('facebookresearch/detr', 'detr_resnet50', pretrained=True)
model.eval()
这行代码会下载一个在COCO数据集上预训练好的DETR-ResNet50模型。但别急着庆祝,这里有个小坑:下载的模型文件默认会放在 ~/.cache/torch/hub 目录下,如果网络不好可能会失败。你可以手动下载 .pth 文件,然后用 torch.load() 加载。
2. 核心架构拆解:从Backbone到FFN的完整数据流
理解DETR,最好的方式就是跟着一张图片从输入到输出的完整路径走一遍。我们假设输入是一张 3x800x600 的RGB图像。
第一步:CNN主干特征提取
图像首先进入一个CNN主干网络(默认是ResNet-50)。这里和Faster R-CNN最大的不同在于,DETR去掉了FPN(特征金字塔网络)。它只取ResNet最后一个stage的输出(通常是C5特征),得到一个低分辨率的特征图。具体来说,如果ResNet的下采样倍数是32,那么输入 800x600 的图像,得到的特征图尺寸就是 2048x25x19(因为 800/32=25, 600/32≈18.75,向下取整为19)。
# 在backbone.py中,你会看到类似这样的逻辑
class BackboneBase(nn.Module):
def forward(self, x):
# x: [batch_size, 3, H, W]
features = self.body(x) # 通过ResNet
# 只取最后一层特征,例如: [batch_size, 2048, H/32, W/32]
return features[-1]
第二步:特征降维与序列化
拿到 2048x25x19 的特征图后,用一个 1x1 卷积把通道数从2048降到隐藏维度 d(论文中 d=256)。这一步很关键,因为Transformer的计算复杂度是序列长度的平方,降低通道数能显著减少计算量。降维后得到 256x25x19 的特征图。
接着,要把这个空间特征图“拍平”成一个序列。把 25x19 的空间网格展开,得到 475 个特征向量,每个向量的维度是256。所以,输入Transformer Encoder的序列形状是 [475, batch_size, 256](注意,有些实现会把batch维度放在前面,这里按PyTorch Transformer的常见习惯)。
第三步:位置编码注入
Transformer本身是置换等变的(permutation-equivariant),它需要位置信息来理解序列中元素的顺序。DETR使用了正弦位置编码(和原始Transformer一样),但这里是2D的,因为我们的序列来自空间网格。位置编码的形状也是 [475, 1, 256],会加到序列特征上。
# position_encoding.py 中的核心片段
class PositionEmbeddingSine(nn.Module):
def forward(self, x):
# x: [batch_size, d, H, W]
not_mask = torch.ones((B, H, W), device=x.device)
y_embed = not_mask.cumsum(1, dtype=torch.float32) # 垂直方向位置
x_embed = not_mask.cumsum(2, dtype=torch.float32) # 水平方向位置
# ... 后续计算正弦编码
pos = torch.cat([pos_y, pos_x], dim=3).flatten(2).permute(2, 0, 1)
# pos: [H*W, batch_size, d]
return pos
第四步:Transformer Encoder
编码器由多个标准的Transformer Encoder层堆叠而成(论文中用了6层)。每一层都包含一个多头自注意力机制和一个前馈网络。自注意力让特征图中的每个位置都能看到所有其他位置的信息,这是DETR能够进行全局推理、避免NMS的关键。
一个容易混淆的点是:Encoder的输出形状和输入形状完全一致,还是 [475, batch_size, 256]。它没有改变序列长度,只是对每个位置的特征进行了增强和融合。
第五步:Transformer Decoder与Object Queries
Decoder是DETR最具创新性的部分之一。它接收两个输入:
- Encoder输出的增强特征序列(
[475, batch_size, 256]) - Object Queries:一组可学习的嵌入向量,数量是
N(论文中N=100)。你可以把它们想象成N个“问题”,每个问题都在问:“图像里有没有一个物体?如果有,它在哪里?是什么?”
Object Queries的形状是 [N, 256],在训练开始时随机初始化,随着训练过程学习到如何“询问”关于物体的信息。Decoder通过交叉注意力机制,让每个Object Query去“查看”Encoder输出的所有位置,最终输出 N 个解码后的特征,形状为 [N, batch_size, 256]。
第六步:预测头(FFN)
每个解码后的特征(对应一个Object Query)会独立地通过两个前馈网络:
- 分类头:一个线性层,输出
(num_classes + 1)个分数,加1是因为有个“无物体”类别(∅)。 - 回归头:一个3层的MLP(256->256->256->4),输出归一化的边界框坐标
(center_x, center_y, width, height),坐标值都在0到1之间。
# detr.py 中预测头的简化示意
class DETR(nn.Module):
def __init__(self, num_classes, num_queries=100):
self.class_embed = nn.Linear(hidden_dim, num_classes + 1)
self.bbox_embed = MLP(hidden_dim, hidden_dim, 4, 3)
def forward(self, decoder_output):
# decoder_output: [N, batch_size, 256]
outputs_class = self.class_embed(decoder_output) # [N, batch_size, num_classes+1]
outputs_coord = self.bbox_embed(decoder_output).sigmoid() # [N, batch_size, 4]
return outputs_class, outputs_coord
到这里,模型的前向传播就完成了。输出是100个预测框(每个框有类别分数和坐标),不管图像中实际有几个物体,它都固定输出100个。那些没有对应真实物体的预测,会被训练成“无物体”类别。
3. 匈牙利损失:一对一匹配的工程实现细节
DETR训练中最精妙的部分就是匈牙利损失(Hungarian Loss)。它的核心思想是:在计算损失之前,先为每个真实物体找到一个唯一的预测框来匹配,确保一对一,没有重复。
为什么这是一对一匹配如此重要?回想一下Faster R-CNN,它使用锚框(anchor)和真实框进行匹配时,一个真实框可以匹配多个锚框(IoU大于阈值),这是一种一对多的关系。训练后,多个锚框可能预测出同一个物体,所以需要NMS来去重。而DETR从设计上就要求一个真实物体只匹配一个预测框,这样在推理时自然就不会有重复预测,NMS也就没必要了。
匹配代价的计算
在代码里,匹配过程发生在 matcher.py 的 HungarianMatcher 类中。对于一张图片,假设我们有:
N个预测结果(N=100),每个预测包含类别概率分布p((num_classes+1)维)和边界框坐标b(4维)。M个真实物体(M通常远小于100),每个真实物体有类别标签c和边界框坐标g。为了匹配,我们会把真实物体集合填充到大小N,多出来的用“无物体”(∅)填充。
匹配的目标是找到一个排列 σ,使得总代价最小:
总代价 = Σ_i [ 分类代价(y_i, y_hat_{σ(i)}) + 框回归代价(y_i, y_hat_{σ(i)}) ]
具体到代码实现,代价矩阵 C 的大小是 [N, N](因为真实集合也填充到了N)。其中每个元素 C[i, j] 表示第 i 个预测与第 j 个真实物体(或∅)的匹配代价。
分类代价很简单:-p_i(c_j),即预测 i 对真实类别 c_j 的预测概率的负值。如果是 ∅,则分类代价是 -p_i(∅)。
框回归代价是 L1 损失和 GIoU 损失的加权和:
框回归代价 = λ_L1 * ||b_i - g_j||_1 + λ_giou * (1 - GIoU(b_i, g_j))
其中 λ_L1 和 λ_giou 是超参数,论文中分别设为5和2。注意,只有当真实物体不是 ∅ 时才计算框回归代价。
匈牙利算法的应用
计算出 N x N 的代价矩阵后,我们使用匈牙利算法(在SciPy中为 linear_sum_assignment)来找到最优的一对一匹配。这个算法会返回两个索引数组 row_ind 和 col_ind,告诉我们哪个预测匹配哪个真实物体。
# matcher.py 中的关键代码(简化版)
from scipy.optimize import linear_sum_assignment
class HungarianMatcher(nn.Module):
@torch.no_grad()
def forward(self, outputs, targets):
# 计算批次中每张图片的代价矩阵
indices = []
for i in range(batch_size):
cost_matrix = self.compute_cost_matrix(outputs[i], targets[i])
# 使用匈牙利算法找到最小代价匹配
row_ind, col_ind = linear_sum_assignment(cost_matrix.cpu())
indices.append((row_ind, col_ind))
return indices
损失计算
找到匹配后,就可以计算匈牙利损失了。损失函数同样由两部分组成:
- 分类损失:对于每个匹配对,计算预测类别相对于真实类别的负对数似然。对于匹配到
∅的预测,其分类损失的权重会降低10倍(论文中设为0.1),这是为了缓解正负样本不平衡问题。 - 框回归损失:对于匹配到真实物体(非
∅)的预测,计算其预测框与真实框的L1损失和GIoU损失的加权和。
这里有一个工程上的细节:GIoU 损失的计算需要确保框坐标是 (x1, y1, x2, y2) 格式,且坐标值在0到1之间。DETR预测的是归一化的中心坐标和宽高 (cx, cy, w, h),需要先转换。
# 框坐标转换示例
def box_cxcywh_to_xyxy(x):
# x: [cx, cy, w, h]
x_c, y_c, w, h = x.unbind(-1)
b = [(x_c - 0.5 * w), (y_c - 0.5 * h),
(x_c + 0.5 * w), (y_c + 0.5 * h)]
return torch.stack(b, dim=-1)
下表对比了DETR的匈牙利损失和Faster R-CNN中常见的匹配与损失计算方式:
| 对比项 | DETR (匈牙利损失) | Faster R-CNN (典型实现) |
|---|---|---|
| 匹配原则 | 全局一对一最优匹配(匈牙利算法) | 局部一对多匹配(基于IoU阈值) |
| 是否需要NMS | 否,匹配机制天然避免重复 | 是,后处理去除重叠框 |
| 正负样本定义 | 匹配到的为正,其余为负(∅类) | IoU > 阈值为正,< 阈值为负 |
| 分类损失 | 对所有预测计算(负样本权重低) | 通常只对正负样本子集计算 |
| 回归损失 | 仅对匹配到真实物体的预测计算 | 仅对正样本计算 |
| 超参数敏感性 | 相对较低,主要调整损失权重 | 对锚框尺度、长宽比、IoU阈值敏感 |
从工程角度看,匈牙利匹配的计算开销比基于IoU的匹配要大,尤其是当 N 较大时(O(N^3) 复杂度)。但实际中 N=100 还算可以接受,而且这一步只在训练时进行,推理时不需要。
4. 与Faster R-CNN的工程实现对比与迁移思考
虽然DETR在概念上很简洁,但当你真正要实现或修改它时,会发现和Faster R-CNN这类传统检测器在工程思路上有不少差异。理解这些差异,能帮助你在自己的项目中更好地应用或改进DETR。
数据加载与预处理
Faster R-CNN通常需要复杂的预处理流水线,包括:
- 多尺度训练(图像随机缩放)
- 颜色抖动、随机翻转等数据增强
- 锚框的生成与匹配
DETR的预处理则简单得多:
- 图像被缩放到固定大小(如800x1333,保持长宽比)
- 简单的随机裁剪和翻转
- 不需要生成锚框
这是因为DETR的Object Queries是可学习的,它们隐式地学会了关注图像中可能包含物体的位置。在代码中,你会看到DETR的数据加载器比Faster R-CNN的简单。
训练技巧与超参数
DETR的训练被很多人吐槽“慢”且“难调”。论文中提到在16个V100 GPU上训练300个epoch需要3天。这背后有几个原因:
- Transformer需要更长的训练周期:相比CNN,Transformer通常需要更多的迭代次数才能收敛。
- 学习率策略不同:DETR使用了带有热启动(warmup)的AdamW优化器,学习率会先线性增加到峰值,然后再余弦衰减。
- 梯度裁剪:为了防止梯度爆炸,DETR默认使用了梯度裁剪(
max_norm=0.1)。
如果你要在自己的数据集上微调DETR,以下是一些经验性的调参建议:
- 学习率:通常比Faster R-CNN小一个数量级。对于微调,可以从
1e-4或5e-5开始尝试。 - 批量大小:尽可能大。DETR对批量大小比较敏感,大批量有助于稳定训练。
- Backbone冻结:如果数据集较小,可以考虑冻结Backbone(ResNet)的前几层,只训练后面的层和Transformer部分。
- Object Queries数量:默认100个对于大多数场景够用。如果图像中物体数量通常很少(<10),可以适当减少以节省计算;如果物体非常密集,可能需要增加。
部署与推理优化
在部署时,DETR有一个明显的优势:推理流程是确定性的,没有NMS这样的后处理。这意味着你可以更容易地将整个模型导出为ONNX或TorchScript格式。
但DETR的Transformer部分计算量较大,尤其是在高分辨率图像上。序列长度 H*W 会很大(例如 800x1333 的图像,序列长度约为 25x42=1050)。自注意力的计算复杂度是 O((H*W)^2),这会成为瓶颈。
一些实用的优化思路:
- 降低序列长度:使用更激进的下采样(如步长64),或者借鉴PVT、Swin Transformer等思路,引入金字塔结构。
- 稀疏注意力:只计算局部窗口内的注意力,或者使用线性注意力近似。
- 量化与剪枝:对模型进行量化(INT8)和剪枝,减少模型大小和计算量。
下面是一个简单的推理示例,展示如何将DETR的原始输出转换为直观的检测结果:
import torch
import torchvision.transforms as T
from PIL import Image
import matplotlib.pyplot as plt
import matplotlib.patches as patches
def detect(image_path, model, transform, threshold=0.7):
# 加载并预处理图像
img = Image.open(image_path).convert('RGB')
img_tensor = transform(img).unsqueeze(0) # [1, 3, H, W]
# 推理
with torch.no_grad():
outputs = model(img_tensor)
# 解析输出
probas = outputs['pred_logits'].softmax(-1)[0, :, :-1] # 去掉“无物体”类
keep = probas.max(-1).values > threshold
# 转换框坐标 (cx, cy, w, h) -> (x1, y1, x2, y2) 并缩放到原图尺寸
bboxes_scaled = rescale_bbox(outputs['pred_boxes'][0, keep], img.size)
scores = probas[keep].max(-1).values
labels = probas[keep].argmax(-1)
return bboxes_scaled, scores, labels
def rescale_bbox(bboxes, orig_size):
"""将归一化的框坐标转换回原图尺度"""
# bboxes: [N, 4] in (cx, cy, w, h) normalized
# 转换为 (x1, y1, x2, y2) 并缩放到原图
# ... 具体转换代码
return bboxes_pixel
常见报错与解决方案
在实际复现或使用DETR时,你可能会遇到一些典型问题:
-
CUDA内存不足:尤其是输入图像较大时。可以尝试:
- 减小批量大小
- 使用梯度累积(accumulate gradients)
- 启用混合精度训练(AMP)
-
损失为NaN或不收敛:
- 检查学习率是否过高,尝试减小学习率或增加warmup步数
- 确保数据预处理正确,特别是框坐标的归一化
- 检查梯度裁剪是否生效
-
预测框全部为“无物体”:
- 可能是训练不充分,尝试增加训练轮数
- 检查匈牙利匹配的实现是否正确,特别是代价矩阵的计算
- 确认数据集中标注格式与代码期望的格式一致
-
推理速度慢:
- 尝试使用更小的Backbone(如ResNet-18)
- 减少Encoder/Decoder的层数
- 使用TensorRT或ONNX Runtime进行推理加速
5. 进阶话题:DETR的变体与实战技巧
原始的DETR只是一个开始,后续的研究提出了许多改进版本,解决了一些原始模型的问题(如训练慢、小物体检测差)。了解这些变体,能让你在实战中有更多选择。
Deformable DETR:加速收敛与提升小物体检测
Deformable DETR是DETR最重要的改进之一,它引入了可变形注意力(Deformable Attention)机制。核心思想是:每个查询(Query)只关注特征图上的一小部分关键采样点,而不是所有位置。这带来了两个好处:
- 计算量大幅降低:从
O((H*W)^2)降到O((H*W)*K),其中K是采样点数(通常为4)。 - 收敛更快:通常只需要原始DETR 1/10 的训练时间就能达到相当的性能。
如果你对训练速度有要求,或者数据集中小物体较多,Deformable DETR是更好的选择。它的代码结构与原版DETR类似,主要改动在注意力模块。
Conditional DETR:更快的收敛
Conditional DETR通过让Object Queries显式地编码位置信息,改善了Decoder的注意力聚焦。具体来说,它将Object Query分解为内容部分和位置部分,让位置部分参与注意力权重的计算。这样,每个查询能更准确地关注到潜在物体所在的区域,加速了训练收敛。
DAB-DETR:更直观的查询设计
DAB-DETR(DETR with Dynamic Anchor Boxes)让Object Queries直接对应到动态的锚框((cx, cy, w, h)),而不是抽象的嵌入。这使得查询的物理意义更明确,也进一步加快了收敛速度。
实战技巧:在自己的数据集上微调DETR
假设你有一个自定义的数据集,格式可能是COCO或VOC,或者完全自定义。微调DETR的步骤大致如下:
-
准备数据加载器:你需要实现一个继承自
torch.utils.data.Dataset的类,返回图像和标注。标注至少需要包含:boxes: 边界框,格式为[x1, y1, x2, y2]或[cx, cy, w, h],需要归一化到[0, 1]。labels: 类别标签,整数,从0开始。image_id: 图像唯一标识(可选,用于评估)。
-
修改类别数:DETR预训练模型是在COCO上训练的(91类,包括背景)。如果你自己的数据集类别数不同,需要修改分类头的输出维度:
model = torch.hub.load('facebookresearch/detr', 'detr_resnet50', pretrained=True) num_classes = 10 # 你的数据集类别数 model.num_classes = num_classes # 修改分类头 in_features = model.class_embed.in_features model.class_embed = nn.Linear(in_features, num_classes + 1) # +1 for "no object" -
调整训练超参数:对于微调,通常:
- 学习率设为预训练的
1/10到1/5。 - 训练轮数减少(如50-100轮)。
- 可以考虑冻结Backbone的前几层。
- 学习率设为预训练的
-
处理类别不平衡:如果你的数据集中某些类别很少,可以调整分类损失的权重。DETR本身通过降低“无物体”类别的权重来缓解不平衡,但对于前景类别间的不平衡,可能需要额外处理。
性能调优与监控
训练DETR时,除了看损失下降,还要关注一些关键指标:
- 匹配质量:可以统计每个epoch中,匹配到真实物体的预测比例。如果这个比例一直很低,说明模型可能没学到有效的表示。
- AP曲线:特别是AP@50和AP@75,观察模型在不同IoU阈值下的表现。
- 训练时间:记录每个epoch的时间,如果发现训练速度异常慢,检查是否有计算瓶颈。
我在实际项目中发现,DETR对于数据质量的要求比Faster R-CNN更高。如果数据集中有大量模糊、遮挡的小物体,DETR的表现可能会下降。这时,结合一些数据增强技巧(如Mosaic、MixUp)可能会有帮助。
最后,如果你真的被DETR的训练时间劝退,但又喜欢它的端到端设计,不妨看看RT-DETR这类实时检测版本,它们在速度和精度之间做了很好的权衡。
1096



被折叠的 条评论
为什么被折叠?



