第一次看到 DETR 这篇论文时,我盯着标题里的 "DEtection TRansformer" 看了很久。那是 2020 年,Transformer 在自然语言处理领域已经封神,但把它搬进目标检测,还是让很多人觉得这是一次大胆的试水。毕竟过去十年,检测领域被 CNN 和 anchor 体系统治得太久了。直到我自己动手跑通 DETR 的推理和训练,才真正意识到它的价值不在于某一个精度数字的提升,而在于它把目标检测重新定义成了一个集合预测问题,让检测头第一次摆脱了 NMS(非极大值抑制)、anchor 设计这些手工组件的束缚。
这篇文章我会从 DETR 的设计思路、Transformer 架构在其中的工作原理、如何用自己的数据训练 DETR,再到实际训练中踩过的坑和后续改进方向,完整梳理一遍。适合刚接触 DETR 的读者、想用 Transformer 做视觉任务的工程师,以及在目标检测领域做技术选型的研究人员。看过之后,你至少能明白 DETR 到底改了什么、为什么这么改、以及它凭什么能在检测领域站稳脚跟。
1. DETR 在检测任务里到底改了什么:从 NMS 到集合预测
1.1 传统检测器的“手工设计”困境
在聊 DETR 之前,得先搞清楚一个前提:传统检测器为什么让研究者头疼。以 Faster R-CNN 为代表的 two-stage 方法,要先通过 RPN 生成候选框,再对候选框做分类和回归;以 YOLO、SSD 为代表的 one-stage 方法,则依赖在特征图上预定义的 anchor 框,通过回归 anchor 与真实框的偏移来预测目标。这里的核心问题在于:anchor 的尺度、长宽比、数量都需要针对数据集精心设计,COCO 上表现好的参数换了数据集可能要重新调;而 NMS 后处理本身也带着一堆超参数,比如 IoU 阈值选 0.5 还是 0.7,对最终结果影响非常大。
这些设计在工程上被验证了无数次,很稳定,但始终绕不开一个尴尬:检测框的数量是动态变化的,而模型输出的候选框是固定且冗余的。于是我们必须在最后一步通过 NMS 把重叠的框合并掉。NMS 这个操作本身不可微,它是检测流程里的一个“外挂”,你没法让 loss 直接告诉模型“你的框和另一个框重复了,你应该主动让位”。整个系统是碎裂的:前段用深度网络学特征,中段用各种手工规则筛框,后段再接一个不可微的处理逻辑。
我记得第一次在实习项目里调 NMS 阈值时,发现目标密集场景下阈值稍微调低一点,漏检率就明显上升,调高一点又出现大量重复框。那种感觉就像你在做饭时发现菜谱里少写了一步关键调味,只能凭感觉试。DETR 所做的,正是要彻底抹掉这套手工后处理。
1.2 把检测重新定义为集合预测
DETR 的核心视角非常朴素:检测任务的本质,是给定一张图片,输出一个“目标集合”,集合里每个元素包含类别和边界框坐标。既然是集合,就应该没有顺序概念,也不应该有重复元素。传统检测器输出的是一堆带冗余的框,所以需要 NMS 去冗余;DETR 则希望设计一个模型,让它端到端地直接输出一个干净集合。
这个思路的转化很有意思。你不再预测一堆可能重叠的候选框,而是固定预测 N 个框(DETR 里默认 N=100),并让模型学习如何把这 100 个预测对应到图里的真实目标上。如果图里只有两个目标,剩下的 98 个预测都要学会输出“无目标”(用一个特殊的 no object 类别表示)。这就是集合预测的直观含义。
但这里有个关键问题:模型输出的 100 个预测是无序的,你怎么定义它和真实目标的匹配关系?总不能随便把第一个预测和第一个真实框比较吧。DETR 的答案是用二分图匹配。我拿生活中举个例子:如果 5 个实习生要分配 5 个任务,每个人的技能熟练度不同,如何让总效率最高?这就是一个典型的指派问题,可以用匈牙利算法求解。DETR 在训练时也做同样的事:用匈牙利算法找出预测框和真实框之间代价最小的匹配组合,然后只对被匹配上的预测计算损失。
二分图匹配的引入,本质上是把“模型自己学会去重”这件事交给了 loss。因为匹配过程是一对一的,同一个真实目标只会被一个预测框匹配,模型为了降低 loss,就必须学会让多个预测框不要同时瞄准同一个目标。这也是 DETR 推理时不需要 NMS 的根本原因。
1.3 匹配代价与损失函数的设计细节
匹配代价函数的设计直接决定了训练时模型看到的“反馈信号”。DETR 的匹配代价由三部分组成:分类的负对数概率、边界框的 L1 距离、以及 GIoU 损失。注意,匹配阶段用的代价与最终的训练损失可以不同,这给了设计者很大的自由度。
为什么用 L1 距离还要叠加 GIoU?单看 L1 距离有个问题:它对大框和小框的误差尺度不敏感。一个 500×500 的大框偏移 20 个像素,和一个 50×50 的小框偏移 20 个像素,L1 距离完全一样,但后者显然是更严重的错误。GIoU 则能反映两个框的重叠程度和形状差异,它对尺度不敏感,更适合衡量检测框的定位质量。所以在匹配代价里,DETR 用了权重为 2 的 L1 损失加权重为 1 的 GIoU 损失,让分类、定位和形状约束共同参与最优匹配的决策。
最终训练损失同样包含三部分:分类用交叉熵,框回归用 L1 加 GIoU。这里有一个小细节:对于匹配到 no object 的预测,分类损失会计算,但框回归损失会被忽略,因为那些“无目标”的预测并没有有意义的框坐标。这个处理让模型可以安心地学会“不输出任何框”,而不是为了强行预测一个框而制造噪声。
| 对比维度 | 传统检测器(Faster R-CNN / YOLO 等) | DETR |
|---|---|---|
| 候选框生成 | 需要 RPN 或 anchor 预设 | 无需 anchor,直接集合预测 |
| 后处理 | 需要 NMS 且不可微 | 无需 NMS,匹配天然去重 |
| 检测头结构 | 多分支、需要手工设计 | Transformer 编解码统一处理 |
| 训练管线 | 多阶段/多任务、相对复杂 | 端到端,一次性训练 |
| 对数据集适配 | anchor 参数需重新设计 | 无需 anchor,语义建模更通用 |
2. Transformer 架构在 DETR 里怎么工作:编码器与解码器的分工
2.1 自注意力机制为什么适合做全局上下文建模
要理解 DETR,就必须理解 Transformer 的核心组件——自注意力机制。当年我第一次看 Attention Is All You Need 这篇文章时,最困惑的是 Q、K、V 这些概念到底在干什么。后来我找到一个比较容易理解的类比:假设你在一个非常拥挤的教室里找人,你手里有一张朋友的照片(这就是 Query),你逐个看教室里每个人的脸(每个人的脸就是 Key),当某张脸和照片比较像时,你会在心里多留意几分(算出一个注意力权重),然后把对应的人的信息(Value)放进你的记忆里,最终综合所有人的信息形成“我要找的人在哪个方向”的判断。
在 DETR 的编码器里,图片被切成很多个“块”(patch,或者说是特征图上的一个位置),每个块都对应一个特征向量。自注意力让每个块都能去看全局所有块的信息,并加权聚合。这个特性非常关键:传统 CNN 的感受野是逐步扩大的,底层特征只能看到局部区域,如果一个目标被另一个目标部分遮挡,局部特征往往不够用;而自注意力一步到位,让每个位置都能感知到全图的上下文。
DETR 用 Transformer 编码器的另一个隐藏优势在这里体现得很明显:它天然适合处理遮挡和重叠目标。比如检测人群场景里的多个人,彼此身体重叠严重时,CNN 特征容易混淆边界,而 Transformer 编码器通过自注意力可以建模“人与周围环境”的长程依赖关系,让模型理解“这里虽然叠了一块,但其实是两个不同的人”。
2.2 编码器:CNN backbone 与空间位置编码的结合
DETR 不是把原始像素直接丢给 Transformer 的,而是先用一个 CNN backbone(ResNet-50 或 ResNet-101)提取特征图。假设输入图片是 800×800,经过 ResNet 的 C5 层,特征图会缩小到 25×25(缩放倍数为 32)。这个特征图每个位置代表原图一个 32×32 区域的语义信息,通道数通常是 2048。
接下来要把二维特征图转换成一维序列。DETR 做的很简单:把 25×25 的空间维度直接展平成 625 个位置,每个位置的维度是 2048。经过一个 1×1 卷积把通道降到 d_model=256,就得到了 Transformer 编码器的输入序列。这里要特别注意:Transformer 本身是顺序不敏感的,如果直接把展平的特征输入,模型完全不知道这些特征在原图上的空间位置关系。所以必须在输入端加入位置编码。
DETR 使用的是空间位置编码(spatial positional encoding),它不是对序列顺序编码,而是对二维空间坐标编码。具体做法是:分别生成一个高方向的编码和一个宽方向的编码,然后把它们拼接到通道维上。我稍后会详细说明位置编码的计算过程。这个设计很聪明,它让编码器在计算注意力时,既能感知特征内容,又能感知特征在图像中的相对位置,相当于同时看到了“这是什么”和“它在哪里”。
2.3 解码器:object queries 是 DETR 里最精髓的设计
很多初学者看 DETR 的代码时,最看不懂的就是解码器输入的 object queries(对象查询)。它其实是一组可学习的参数,形状是 100×256,代表 100 个随机初始化的查询向量。在训练过程中,模型会慢慢把这 100 个向量训练成“100 个不同目标的探测器”。
我习惯把 object queries 理解为“一组带着任务清单的调查员”。每个调查员都有一个固定的调查目标(比如一个负责“找图中左上角的大型物体”,另一个负责“找图中中间偏右的小型物体”),他们拿着自己的问题清单(query),去图片特征里逐一比对(cross-attention),找到最符合自己问题的区域,然后汇报结果。
解码器内部有两层注意力:第一层是 self-attention,让 100 个 query 之间相互沟通,避免多个 query 重复关注同一个目标;第二层是 cross-attention,让每个 query 去查询编码器输出的图像特征。两层配合,效果就是 100 个 query 在学习的过程中自动分化,每个 query 负责图里的一种目标。这也是为什么 DETR 不需要 NMS:当某个 query 选中了一个目标,其他 query 在 self-attention 阶段就会收到“这里已被占据”的信号,从而转向其他区域。
刚入门的人可能会问:为什么不直接把 100 个 object queries 设成 anchor 的语义版本?其实二者有本质区别。anchor 是硬编码的、固定的几何先验;object queries 是数据驱动的、可学习的语义先验。模型根据训练数据自己决定这 100 个查询应该关注什么样的尺度、位置和类别组合,适应性更强。
2.4 位置编码 PE 计算与输入嵌入表示
顺着热词里反复出现的“嵌入表示层 PE 计算”说明一下。Transformer 的位置编码最常见的方案是三角函数绝对位置编码,公式如下:
对于位置 pos 和维度 i(d_model 为特征维度):
- 当 i 为偶数时:PE(pos, 2i) = sin(pos / 10000^(2i / d_model))
- 当 i 为奇数时:PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))
用 Python 实现一段可以直接跑的位置编码生成代码:
import numpy as np def positional_encoding(max_len, d_model): pe = np.zeros((max_len, d_model)) position = np.arange(0, max_len).reshape(-1, 1) div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model)) pe[:, 0::2] = np.sin(position * div_term) pe[:, 1::2] = np.cos(position * div_term) return pe # 生成一个长度为 50、维度为 256 的位置编码 pe = positional_encoding(50, 256) print(pe.shape) # (50, 256)这段代码的关键点在于 div_term 的构造。np.arange(0, d_model, 2)生成 0, 2, 4... 等偶数索引,分母上的 10000^(2i/d_model) 可以通过exp(2i * -(log(10000) / d_model))变换得到,数值上比直接算 10000 的幂次更稳定。这样每位位置都能获得一个唯一的编码向量,不同位置的编码向量之间有明确的相对关系。
为什么用不同频率的正弦余弦组合,而不是直接用整数索引作为编码?因为整数编码的数值范围容易随序列长度变化,且相邻编码之间的差值不规整;而正弦余弦函数的值域固定在 [-1, 1],且不同频率的组合可以让模型更容易学到相对位置关系。你可以这样理解:这个编码就像给每个位置发放了一张带有坐标刻度的地图,地图上标明了“我是第几个位置”,而且这种标注方式对任意序列长度都有效。
DETR 里的空间位置编码本质上也是用这个思路生成的,只不过增加了一个维度。代码里常见的做法是分别生成 height 方向和 width 方向的位置编码,再用repeat_interleave或拼接的方式组合起来,最终加到像素特征上。这一步虽然是“一行代码”的事,但直接决定了检测精度,很多人训练 DETR 效果不好,回头检查发现是位置编码的广播维度出错了。
3. 实操:用自己的数据训练和推理 DETR
3.1 环境准备与依赖安装
DETR 官方代码是基于 PyTorch 的,配置环境并不复杂。建议使用 Python 3.8 以上,PyTorch 1.7 以上都可以。安装依赖:
pip install torch torchvision pip install scipy pip install pycocotoolsDETR 训练时需要 COCO 格式的数据集,所以 pycocotools 是必需品。这里有个小坑:Windows 上直接安装 pycocotools 经常报编译错误,建议安装pycocotools-windows的预编译版本,或者干脆使用 Linux 环境做训练。我自己第一次在 Windows 上配环境浪费了大半天,后面果断换到 Linux 服务器上跑,几分钟就搞定了。
3.2 数据准备:从自定义标注到 COCO 格式
DETR 的训练脚本默认读取 COCO 格式的 JSON 标注。COCO 格式比较复杂,但核心就三个字段:images(每张图的 id、宽高、文件名)、annotations(每个目标的 image_id、类别 id、bbox)、categories(类别 id 到名称的映射)。
如果你用的是 LabelImg 或 labelme 标注的数据,通常得到的是 Pascal VOC 格式或单张图的 JSON。你需要写一个转换脚本,把标注统一转成 COCO 格式。一个简化的转换逻辑如下:
import json import os from PIL import Image def convert_to_coco(images_dir, annotations_dir, classes, output_path): coco = {"images": [], "annotations": [], "categories": []} for idx, cls in enumerate(classes, start=1): coco["categories"].append({"id": idx, "name": cls}) ann_id = 1 for img_id, filename in enumerate(os.listdir(images_dir), start=1): if not filename.lower().endswith((".jpg", ".png")): continue img = Image.open(os.path.join(images_dir, filename)) w, h = img.size coco["images"].append({ "id": img_id, "file_name": filename, "width": w, "height": h }) # 这里假设每个图片对应一个同名的 labelme json 文件 label_file = os.path.join(annotations_dir, filename.rsplit(".", 1)[0] + ".json") if not os.path.exists(label_file): continue with open(label_file, "r", encoding="utf-8") as f: label_data = json.load(f) for shape in label_data["shapes"]: # labelme 坐标是 [x1, y1, x2, y2] 的形式 x1, y1 = shape["points"][0] x2, y2 = shape["points"][1] cx, cy, bw, bh = (x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1 cls_name = shape["label"] cls_id = classes.index(cls_name) + 1 coco["annotations"].append({ "id": ann_id, "image_id": img_id, "category_id": cls_id, "bbox": [cx, cy, bw, bh], "area": bw * bh, "iscrowd": 0 }) ann_id += 1 with open(output_path, "w", encoding="utf-8") as f: json.dump(coco, f, indent=2)一个我踩过坑的细节:COCO 的 bbox 格式是[x_center, y_center, width, height],不是左上角和右下角。DETR 的代码在处理预测框时输出的是中心点加宽高的归一化坐标,所以数据标注阶段保持统一格式可以省掉很多麻烦。
如果你的数据集和目标检测无关,只是想快速验证 DETR 流程,可以直接下载 COCO 2017 的一个子集,或者用你自己已有的任何目标检测数据集,转换成上述格式即可。
3.3 训练参数与损失计算过程
DETR 官方训练命令是这样启动的:
python -m torch.distributed.launch --nproc_per_node=8 --use_env main.py \ --coco_path /path/to/coco \ --output_dir /output/dir \ --batch_size 2 \ --lr 1e-4 \ --weight_decay 1e-4 \ --epochs 500 \ --lr_drop 400这些参数看着简单,但每个都值得唠唠。
--lr 1e-4是官方针对 batch size=32(8 卡×每卡 2 张)设计的。如果你只有单卡、每张图片的训练 batch size 是 2,那总 batch size 变成了 2,学习率还按 1e-4 用,训练大概率会不稳定。我采用的经验公式是让学习率随 batch size 做线性缩放:
lr = 1e-4 × (batch_size / 32)
比如单卡 batch size 为 2 时,学习率取 6.25e-6 左右起步比较安全。这不是什么高深理论,而是分布式训练里常见的“线性缩放规则”:梯度是多个样本的平均,batch size 越小,梯度噪声相对越大,过大的学习率会导致 loss 震荡。
--epochs 500是 DETR 的一个显著特征。它不像 YOLO 那样 300 epoch 以内就能出不错的效果,DETR 收敛确实更慢,官方实验需要 500 epoch 才能达到 42 AP 的水平。训练中途你会发现 loss 下降得很慢,可能在 100 个 epoch 时还看起来一般,这是 Transformer 训练的正常现象。--lr_drop 400表示在第 400 个 epoch 时学习率衰减 10 倍,帮助 loss 在后期进一步收敛。
训练过程中你可以在日志里看到类似这样的输出:
Epoch 0: loss 12.34, loss_ce 3.56, loss_bbox 4.12, loss_giou 4.66 Epoch 50: loss 6.12, loss_ce 1.80, loss_bbox 2.05, loss_giou 2.27loss_ce 是分类损失,loss_bbox 是 L1 回归损失,loss_giou 是 GIoU 损失。观察这三个分量的变化可以判断训练状态:如果 loss_ce 降不下去但 bbox 在降,可能是类别定义或数据标注有问题;如果 loss_giou 震荡剧烈,可能是学习率偏大。
3.4 推理与可视化
推理是感受 DETR 能力最直观的方式。用 torch.hub 可以直接加载官方预训练模型:
import torch import torchvision.transforms as T from PIL import Image model = torch.hub.load('facebookresearch/detr:main', 'detr_resnet50', pretrained=True) model.eval() transform = T.Compose([ T.Resize(800), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) img = Image.open('test.jpg').convert('RGB') inputs = transform(img).unsqueeze(0) with torch.no_grad(): outputs = model(inputs) logits = outputs['pred_logits'] # (1, 100, 92) boxes = outputs['pred_boxes'] # (1, 100, 4)这里的pred_logits形状是 (1, 100, 92),100 是 object queries 数量,92 是 COCO 的 91 个类别加一个“无目标”类。pred_boxes是归一化到 [0, 1] 的坐标,格式为 (center_x, center_y, width, height)。
从输出到可视化还需要做一步:根据类别置信度过滤低分框。这一步非常轻量,只需要取每个 query 预测概率最大的类别,然后过滤掉概率低于阈值的结果,完全不需要 NMS。我第一次跑通时特意对比了有 NMS 和无 NMS 的差异,发现结果几乎一致,这让我真正理解了集合预测“天然去重”的设计意图。
4. 训练 DETR 最容易踩的坑与改进方向
4.1 收敛慢、小目标检测差:DETR 最大的槽点
DETR 在 COCO 上的 AP 能达到 42 左右,但训练时长是传统检测器的数倍。500 epoch 的训练在 8 张 V100 上也要跑好几天。更让人头疼的是小目标检测效果,DETR 在小目标上的 AP 明显低于 Faster R-CNN。原因不难理解:ResNet 的 C5 特征图只有原图的 1/32 分辨率,一个小目标可能只占几个像素,经过下采样后在特征图上几乎消失了。自注意力虽然能看全局,但前提是信息还在特征图里,如果信息在主干阶段就丢了,再强的注意力也救不回来。
改进方法也很明确:一是用更高分辨率的输入,或者用类似 FPN(特征金字塔)的结构保留多尺度特征;二是改用 Deformable DETR,它对多尺度特征图做可变形注意力,在 50 个 epoch 内就能超过 DETR 的 300 epoch 效果。如果你做业务落地,我建议直接以 Deformable DETR 作为起点,省时省力。
4.2 显存占用过高和注意力矩阵的平方复杂度
Transformer 的自注意力复杂度是序列长度的平方。在 DETR 里,如果特征图是 25×25,序列长度 625,注意力矩阵就是 625×625,这还可以接受;但如果输入分辨率增加到 1600,特征图变成 50×50,序列长度 2500,注意力矩阵直接膨胀到 2500×2500,显存压力呈指数级上升。
训练时遇到 OOM(显存不足)最直接的解决办法是降低输入分辨率或减小 batch size。但降分辨率会损害小目标检测性能,所以更建议用梯度累积来模拟较大的 batch size,而不是单纯调小 batch。另一个思路是用可变形注意力替代标准注意力,它只对稀疏采样的 K 个关键点做计算,复杂度从平方降到线性。
4.3 常见问题速查表
我把实际中遇到的高频问题整理成一张表,方便快速定位:
| 现象 | 可能原因 | 排查与解决方向 |
|---|---|---|
| 训练 loss 下降极慢 | Transformer 本身收敛慢,或学习率偏低 | 检查学习率缩放,必要时用 warmup;考虑 Deformable DETR |
| 小目标 AP 特别低 | 特征图分辨率不足,目标信息丢失 | 提高输入分辨率,或引入 FPN 结构,或换 Swin Transformer 等层级化骨干网络 |
| 一个目标被多个框重复框住 | object queries 之间没有“沟通”好 | 检查解码器 self-attention 参数;适当增大 loss_giou 权重 |
| 显存不足 OOM | 注意力矩阵过大 | 降低分辨率、减小 batch、梯度累积;换可变形注意力 |
| 推理结果有大量低置信度框 | 过滤阈值偏低 | 提高置信度阈值;检查类别定义是否一致 |
表中提到的 Swin Transformer 值得多说一句:它是带有层级结构和窗口注意力的 Transformer 骨干网络,作为 DETR 的 backbone 使用时,能在保持全局建模能力的同时恢复部分局部先验,对小目标和密集场景更友好。Vision Transformer(ViT)也一样,虽然最初是为分类设计的,但在检测任务中做 backbone 也验证过不少场景。这些模型本质上都在解决同一个问题:如何让 Transformer 在视觉任务里既保持全局上下文建模能力,又不丢失局部细粒度信息。
4.4 后续改进方向与 DETR 家族
Deformable DETR、Conditional DETR、DINO 这几个名字,在做技术预研时一定会反复遇到。Deformable DETR 用可变形交叉注意力替代标准交叉注意力,大幅降低计算量,解决了收敛慢的问题;Conditional DETR 通过条件空间查询,把解码器的 cross-attention 变成依赖目标空间位置的查询,进一步加快收敛;DINO 则引入对比去噪训练,在 COCO 上把 AP 做到了 63 以上,同时训练 epoch 大幅缩减。
这一系列改进让我明白了一件事:DETR 真正的价值不只是它本身的好坏,而是它开辟了一个端到端检测的范式。后续各种变体都是在这个范式上做的优化,有的从注意力结构入手,有的从训练策略入手,有的从多尺度特征入手。如果你现在要做检测任务的技术选型,不应该问“DETR 好不好”,而应该问“我是要做一块干净、稳定的基线,还是追求极致精度”。
我个人在实际操作中的最大感受是,DETR 是一个需要耐心对待的模型。第一次训练它时,我看着前 200 个 epoch 平平无奇的 loss 曲线,差点中途弃坑,直到第 300 个 epoch 之后指标才明显抬头。这种“慢热”和传统 CNN 检测器的体验完全不同。如果只是验证思路,建议直接用官方 COCO 预训练权重做推理,先感受一下端到端检测带来的流畅体验;如果确实需要在自己的数据上训练,也请先准备好足够的 GPU 算力,或者直接转向 Deformable DETR 这类更高效的变体。检测这个领域卷了很多年,DETR 最大的贡献是让人明白:检测器里的手工设计,不是不能消失,而是我们还没有找到让模型自己学会“做选择”的正确方式。