简介:这份 PPT 围绕 End-to-End Object Detection with Transformers 论文展开,面向准备组会汇报、课程展示或复试讲解的机器学习与计算机视觉学习者,梳理 DETR 如何把目标检测转化为直接集预测问题,从而摆脱 Anchor 与 NMS 等手工设计模块。压缩包内仅含 1 个 pptx 文件,体积约 3.44MB,按研究背景、研究目的、研究过程与结论四部分组织页面,配有模型整体结构图与关键模块示意。内容具体拆解了 CNN 主干特征提取、Transformer 编码器与解码器、object queries 以及前馈网络预测检测框和类别标签的完整流程,并说明二分图匹配损失、位置编码,以及 COCO 上精度与 Faster R-CNN 相当、可推广至全景分割等要点,可直接改写为讲稿或对照论文阅读。目前已有 1094 人学习下载,适合希望快速建立 DETR 整体框架认识、补齐端到端检测基础的中级学习者。
1. 目标检测的后处理税:删掉 NMS 和 Anchor 之后,模型要靠什么撑住
「端到端目标检测」这个说法在 Transformer 进入检测领域之前,一直是一张没完全兑现的空头支票。RCNN 系列和 YOLO 系列把检测拆成了一串代理任务:先在图上铺满先验框,让每个框预测自己离真值有多远,再把成千上万个框按置信度排序,最后靠非极大值抑制把同一个目标身上的重复框一个个掐掉。调模型的时间里,有很大一部分其实花在 anchor 的尺寸比例和 NMS 的阈值上。
DETR 的结论因此显得反直觉:把这两样手工设计的东西一起删掉,在 COCO 上它的准确率和检测速度与 Faster R-CNN 相当,而且几乎不用改结构就能推广到全景分割。
反直觉的点不在于「Transformer 很能打」,而在于 NMS 一直在替模型还一笔债——模型自己没能力区分「同一个目标的第 1 个预测」和「第 2 个预测」。删掉 NMS,就意味着模型必须在一次前向里把去重这件事做完。这笔债总得有人接,接住它的正是下面要拆的那套集合预测机制。
2. 把检测改写成集合预测:为什么「无序输出」才是真正的敌人
2.1 anchor → 偏移回归 → NMS:那条链条在替模型还什么债
传统检测器的思路是「先验覆盖 + 局部修正」。整幅图上出现目标的位置不可预知,那就用滑动窗口或者预设好的、不同大小不同长宽比的参照框(anchor)把图铺满,让每个参照框回答两件事:我这里面有没有目标、目标相对我偏移了多少。分类头负责前者,回归头负责后者,最后靠 NMS 把指向同一个目标的多个高分框压成一个。
这条链条上有三处人工先验,每一处都在补同一个洞:
第一处是 anchor 的尺寸和长宽比。它们是超参,换一个数据集分布(比如从以行人为主换成以航拍小目标为主)就得重新聚类一遍,否则正样本的 IoU 分布会整体偏掉。第二处是正负样本的划分阈值。几千上万个 anchor 里通常只有几十个是正样本,正负极度不平衡,于是又得引入采样策略或者 focal loss 这类专门对付不平衡的损失。第三处是 NMS 本身。它是串行后处理,阈值一动结果就变;密集场景里两个真实目标挨得近,NMS 很容易把其中一个当成重复框误删。
把这三处串起来看,会发现它们的存在理由高度一致:模型没办法直接输出一个「数量可变、彼此不重复」的目标集合。它只能输出一堆带分数的候选,然后把「选出正确的那几个」这件事外包给后处理。误差在这个过程中一层层累积,每一层的人工设计都在替下一层擦屁股。
2.2 固定 N 个槽位与 ∅ 类:一次前向输出全集
DETR 换了个问法。它不再问「图里有几个目标、分别在哪」,而是先划出固定数量的 N 个槽位(论文里 N 取 100,远大于一张图里典型的目标数),让解码器一次性输出这 N 个槽位各自装了什么。
每个槽位的输出包含两部分:一个类别分布,一个归一化的框坐标。关键在于类别分布多出来的那一维——一个特殊的 ∅ 类,表示「这个槽位里没有目标」。图像里只有 3 个目标,那剩下的 97 个槽位就应该预测 ∅。
这一步看着只是工程上的小技巧,实际上把两个难题同时消化掉了。数量可变变成了固定长度,模型不用再回答「有几个」,只需要回答「这 N 个槽位分别是什么」。去重也变成了模型自己的责任:如果两个槽位都预测了同一只狗,那必然有一个槽位本该输出 ∅ 却输出了狗,训练时会被 ∅ 类的负对数似然直接惩罚。
这里有个容易被忽略的约束:N 必须显著大于图像中的典型目标数。因为槽位是一对一分配的(下一节会讲),如果 N 小于真实目标数,就会出现「有 GT 没槽位接」的情况,损失函数里这部分直接丢失,模型永远学不会检测超过 N 个目标。
2.3 顺序无关 → 必须一对一匹配:集合损失的由来
到这一步,真正的麻烦才浮出来。N 个槽位的输出是一个集合,不是序列。第 1 个槽位预测了狗,第 7 个槽位也预测了狗,从集合的角度看这两者是等价的——你把它们的位置互换,整个输出集合没有变化。
既然等价,就不能按索引直接和 GT 算 loss。否则同一个模型在同一次前向里,会因为槽位编号不同收到完全矛盾的梯度:这一次第 1 号槽位该学狗,下一次第 1 号槽位该学猫,模型只会学成一锅粥。
解决办法是在计算损失之前,先在预测集合和 GT 集合之间求一个最优的一一对应。给定 N 个预测和 M 个 GT(M ≤ N),构造一个 N×M 的代价矩阵,用匈牙利算法找一组配对,使总代价最小。这就是二分图匹配。
import torch from scipy.optimize import linear_sum_assignment @torch.no_grad() def hungarian_match(pred_logits, pred_boxes, gt_labels, gt_boxes, w_cls=1.0, w_l1=5.0, w_giou=2.0): """ pred_logits: [N, C+1] 最后一维是 ∅ 类的 logits pred_boxes: [N, 4] 归一化的 (cx, cy, w, h) gt_labels: [M] M <= N gt_boxes: [M, 4] 返回 row/col:第 i 个 GT 唯一对应第 row[i] 个预测槽位 """ prob = pred_logits.softmax(-1) # [N, C+1] cost_cls = -prob[:, gt_labels] # 取 GT 类别的概率再取负,形状 [N, M] # L1 项直接衡量坐标差,对绝对位置敏感 cost_l1 = torch.cdist(pred_boxes, gt_boxes, p=1) # [N, M] # GIoU 项衡量重合度,对尺度不敏感,弥补 L1 的偏置 cost_giou = 1.0 - giou(pred_boxes[:, None, :], gt_boxes[None, :, :]) cost = w_cls * cost_cls + w_l1 * cost_l1 + w_giou * cost_giou # 匈牙利算法要求 numpy 输入,且整个过程不参与梯度回传 row, col = linear_sum_assignment(cost.detach().cpu().numpy()) return row, col这段代码有几个参数值得说清楚。w_cls / w_l1 / w_giou三项的权重决定了匹配时更看重类别还是更看重位置,论文里给的是 1 : 5 : 2,位置项的权重明显更高——因为在训练早期分类还没学出来,光靠类别概率匹配会乱套,得让框的位置先起主导作用。@torch.no_grad()不能省,匹配本身是个离散优化问题,没有梯度,不 detach 会在反向传播时报错或者白白占显存。linear_sum_assignment返回的是行索引和列索引,长度都是 min(N, M),也就是每个 GT 都能分到一个唯一的槽位。
匹配完之后才计算真正的损失,而且只对匹配上的槽位回传框损失和类别损失,其余槽位全部按 ∅ 类算分类损失。这样一套下来,一对一的性质被硬编码进了训练目标里:一个 GT 只可能分给一个槽位,所以一个目标在输出里最多出现一次,重复预测从源头上就不存在。
因果链到这里就闭合了。集合预测要求输出无序,无序要求损失定义在集合上,集合上的损失要求先做一一匹配,一一匹配保证了不重复,不重复就不需要 NMS。NMS 不是被「优化掉」的,是被这套机制在逻辑上排除了。这类思路在机器学习里并不新鲜,结构化预测任务(机器翻译、语音识别)早就这么干,DETR 做的是把它挪到检测上,并且证明它 work。
3. 从特征图到 token 序列:backbone、1×1 卷积与位置编码的耦合
3.1 C×H×W → d×H×W:降维的动机
backbone 的输出是一个[B, 2048, H, W]的张量,H 和 W 是原图长宽各缩小 32 倍后的尺寸。这 2048 个通道里,每个通道对应一种被卷积核提取出来的特征响应,空间分辨率已经被压得很低,但语义信息很密。
进 Transformer 之前要先过一层 1×1 卷积,把通道数从 2048 压到 d(论文里 d = 256)。这个操作的动机不是「让特征更好」,而是算力。self-attention 的计算量对序列长度是平方级的,序列长度等于 H×W;如果直接在 2048 维上做注意力,每个 token 的向量维度太大,投影矩阵的参数量和显存占用都会撑不住。降到 256 是在保留空间分辨率和砍掉通道冗余之间取的折中。
值得注意的是 DETR 没有做 FPN 式的多尺度融合,整张图只在一个尺度上做检测。这个选择省掉了一堆上采样和特征拼接的复杂度,代价是小目标的检测能力——在 1/32 的分辨率上,一个 16×16 像素的小目标只剩半个像素,基本没法看。这条伏笔后面还会出现。
3.2 reshape 与位置编码:为什么 transformer 必须补上这一课
import torch import torch.nn as nn class BackboneToSequence(nn.Module): def __init__(self, c_in=2048, d_model=256): super().__init__() # 1x1 卷积只改通道数,不动空间尺寸 self.reduce = nn.Conv2d(c_in, d_model, kernel_size=1) self.norm = nn.GroupNorm(32, d_model) def forward(self, feat, pos): # feat: [B, 2048, H, W] x = self.norm(self.reduce(feat)) # [B, d_model, H, W] h, w = x.shape[-2:] # 空间维度摊平成序列:transformer 只认 (L, B, d) 的布局 x = x.flatten(2).permute(2, 0, 1) # [H*W, B, d_model] pos = pos.flatten(2).permute(2, 0, 1) # 位置编码必须用完全相同的顺序展开 return x, pos, (h, w)flatten(2)把[B, d, H, W]压成[B, d, H*W],permute(2, 0, 1)再换成[H*W, B, d]。这个序列的顺序是空间上从左到右、从上到下的扫描顺序,第 0 个 token 是左上角,最后一个 token 是右下角。
问题在于,Transformer 的 self-attention 是排列等变的(permutation equivariant):你把 token 的顺序打乱,输出只是跟着打乱,模型内部没有任何机制能从「第几个 token」里读出「这两个 token 在图上相邻」。对语言模型来说这不是问题,因为位置信息本身就可以靠位置编码补,而且语言的顺序是天然的一维的;对图像来说,空间结构是二维的,丢了就真的丢了。
所以必须显式注入位置信息。DETR 用的是固定的 2D 正弦位置编码:把 d_model 的维度分成两半,一半编码 x 方向的正弦余弦,一半编码 y 方向的正弦余弦,然后逐元素加到 token 上。固定编码而不是可学习编码,好处是能泛化到训练时没见过的分辨率。
这里有个非常容易踩的坑:位置编码的 flatten 和 permute 顺序必须和特征图严格一致。两者都是[B, d, H, W]的形状,写错一行代码不会报任何错,模型照样能训,只是位置信息全部错位,AP 会掉一大截却找不到原因。后面的排查章节会专门说这个。
3.3 位置编码加在哪:encoder 输入 vs decoder 输出的消融证据
论文做过一组消融,把位置编码分别从 encoder 输入和 decoder 输出里拿掉,看 AP 怎么变。
拿掉 encoder 输入的空间位置编码,AP 下降幅度非常大,属于伤筋动骨的级别。原因不难理解:encoder 的全局注意力承担的职责是把实例「分离」开——同一个目标的不同部位、不同目标之间的边界,都要靠全局注意力来建立区分。如果连「谁在左边谁在右边」都不知道,注意力权重就退化成对所有 token 特征值的加权池化,实例之间的空间关系直接消失,encoder 输出的特征就退化成了一堆语义标签的堆叠。
decoder 那侧的输出位置编码拿掉,影响没有 encoder 那么致命,但同样显著。decoder 里每个 object query 需要知道自己当前关注的是图像上的哪个位置,才能把框的边界细化下去。没有位置编码,query 只能靠特征内容本身去猜位置,回归精度的上限就被锁死了。
一个更稳的理解方式:位置编码不是装饰,它是 Transformer 在视觉任务上「看见空间」的唯一通道。凡是在图像上跑的 Transformer,位置编码写错或者位置加错,基本都是静默失败——不报错、能收敛、指标差一口气。
4. Object Queries 与解码器:N 个槽位如何学会各看各的
4.1 并行解码:一次前向完成 N 个目标的推理
解码器的输入有两路。一路是 encoder 输出的[H*W, B, d]序列,承载整幅图的上下文信息。另一路是 N 个 object queries——一个形状为[N, d]的可学习 embedding,随机初始化,跟着整个模型一起训练。
每个 query 在解码器里走两层注意力:
先是 query 之间的 self-attention。N 个 query 互相看,交换信息,目的是协调分工,别让两个 query 去抢同一个目标。然后是 encoder-decoder attention(也叫 cross-attention),query 作为 Q,encoder 输出作为 K 和 V,从整幅图的特征里去取自己需要的那部分信息。
和 NLP 里的解码器有个本质区别:这里是并行解码的,一次前向 N 个槽位同时出结果,不像自回归那样一个 token 一个 token 地生成。因为没有先后依赖,也就不需要 causal mask——每个 query 都能看到全部 N 个 query,包括「排在自己后面」的那些。
4.2 object query 的空间偏好:它到底在「问」什么
论文里把每个 query 的 cross-attention 图可视化出来,能看到一个很有意思的现象:不同的 query 在图像上表现出不同的空间偏好。有的 query 稳定地盯着左下角,有的盯着中间偏下的位置,有的覆盖范围更大一些、更像在关注一个整体的轮廓。
这说明 query 学到的不是随机噪声,而是一种「我负责哪块区域」的软分工。训练过程中,模型为了最小化匹配后的损失,自然会把不同的 query 分配到不同的空间位置和尺度上——如果两个 query 长期关注同一块区域,那它们必然有一个拿不到匹配,只能学 ∅,梯度会把它推到别处去。
这种分工是软性的,不是硬绑定。query 和空间位置之间没有固定映射表,它是数据驱动出来的偏好,换一个数据集分布,偏好会跟着变。但正因为是软性的,它才能同时兼顾「覆盖全面」和「按需聚焦」——一张图里只有左上角有目标时,负责右下角的那些 query 会自动输出 ∅,不会强行编一个框出来。
某种意义上,这是对 NMS 那个「去重」职能的替代:NMS 是在输出之后用贪心算法强行去重,query 之间的 self-attention 是在输出之前就通过信息交换完成了软性去重。前者是事后补锅,后者是事前分工。
4.3 FFN 头:分类头、框回归头与归一化坐标
每个 decoder 输出的 d 维 embedding 会过一个共享的 FFN。结构是三层的:一层线性投影、一个 ReLU、一层 d 维隐藏层,最后一层线性投影到输出维度。所有 N 个槽位共用同一套参数,这是参数效率上的考虑——如果每个槽位配一个独立的头,参数量会膨胀 N 倍,而且每个头分到的训练信号会非常稀疏。
分类头输出 C+1 维,走 softmax,多出来的那一维就是 ∅ 类。回归头输出 4 维,是归一化的(cx, cy, w, h):中心点坐标相对于整幅图的位置,宽高相对于整幅图的大小,全部落在 [0, 1] 区间内。
用归一化坐标而不是像素坐标,是因为不同图像的尺寸差异很大。如果用像素坐标,回归目标的范围会从几十到几千不等,FFN 得去适应这种跨度极大的分布,训练会很不稳定。归一化之后所有回归目标都被压到同一个量纲上,网络只需要学「相对位置」这一件事。
顺带说一句,∅ 类的作用和传统检测器里的 background 类很像,但语义不完全一样。background 是在 anchor 的语境下说的「这个先验框里是背景」,∅ 是在集合的语境下说的「这个槽位没有分配到目标」。后者是集合预测这套框架能成立的前提,不是可选项。
4.4 损失:L1 + GIoU 的组合动机
损失只在匹配上的槽位上计算。分类部分用负对数似然(交叉熵),框部分用 L1 和 GIoU 的线性组合。这个组合不是拍脑袋定的,两项各自补对方的短板。
def box_xyxy(b, eps=1e-7): """(cx, cy, w, h) -> (x1, y1, x2, y2)""" cx, cy, w, h = b.unbind(-1) return torch.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], dim=-1) def giou(pred, gt, eps=1e-7): p, g = box_xyxy(pred), box_xyxy(gt) lt = torch.max(p[..., :2], g[..., :2]) # 交集左上角 rb = torch.min(p[..., 2:], g[..., 2:]) # 交集右下角 wh = (rb - lt).clamp(min=0) # 负值截断为 0,处理不重叠的情况 inter = wh[..., 0] * wh[..., 1] area_p = (p[..., 2] - p[..., 0]) * (p[..., 3] - p[..., 1]) area_g = (g[..., 2] - g[..., 0]) * (g[..., 3] - g[..., 1]) union = area_p + area_g - inter + eps iou = inter / union # 最小闭包框:能同时包住两个框的最小矩形 cw = torch.max(p[..., 2], g[..., 2]) - torch.min(p[..., 0], g[..., 0]) ch = torch.max(p[..., 3], g[..., 3]) - torch.min(p[..., 1], g[..., 1]) enclose = cw * ch + eps return iou - (enclose - union) / enclose # GIoU ∈ (-1, 1]只用 L1 的问题在于,它对框的大小不敏感,但对绝对坐标的偏差很敏感。一个大框偏 10 个像素可能完全无所谓,一个小框偏 10 个像素就已经离谱了,可 L1 给出的惩罚是一样的。结果就是小目标那部分的梯度被大目标淹没了,模型倾向于先把大框调准,小框放任不管。
只用 IoU 也有问题。两个框完全不重叠时 IoU 恒为 0,梯度也是 0,训练早期大量预测框和 GT 不重叠,模型根本得不到「往哪个方向调整」的信号,收敛会慢到不可接受。
GIoU 的做法是引入最小闭包框。即使两个框不重叠,闭包框的面积仍然是一个有限值,(enclose - union) / enclose这一项会给出一个非零的梯度,告诉模型「你离目标还有多远」。重叠之后这一项趋近于 0,退化成普通 IoU,精度调节就交给 L1 去做。
论文的消融给得很直接:只用 L1 时结果很差;L1 和 GIoU 组合之后,中等目标和大目标的 AP 都有改善。这个组合现在是检测领域的标准配置,不少后续工作沿用下来基本没动过。
5. 复现 DETR 时的常见坑:五个现象、原因与排查路径
5.1 坑一:训了五十个 epoch 指标还是个位数,怀疑代码写错了
现象:按常规检测器的经验训 30 到 50 个 epoch,AP 停在个位数不动,loss 曲线看着在降,但验证指标不涨。
原因:DETR 的收敛速度比传统检测器慢一个量级。它没有 anchor 提供的先验,模型要从零学会「哪个槽位该看哪块区域」,这个分工的形成需要大量迭代。原论文在 COCO 上训了 500 个 epoch,配了大规模数据增强(随机裁剪、颜色抖动、多尺度训练),才算把指标拉满。
解决:先别怀疑代码,把训练周期拉到 300 个 epoch 以上再看。同时确认两件事——backbone 的学习率要比 Transformer 部分小一个量级(通常差 10 倍),以及学习率 schedule 有没有在正确的位置做衰减。这两点写错比模型结构写错更常见。
5.2 坑二:直接把预测按索引和 GT 算 loss,训练震荡得厉害
现象:loss 一路剧烈震荡,模型偶尔能出几个像样的框,但整体不收敛。
原因:输出是集合,槽位索引没有语义。不经过匈牙利匹配就直接按索引算 loss,等于强行给每个槽位指派一个固定的 GT,而模型并不知道这个指派规则,每一轮收到的梯度方向都在变。
解决:确认三件事——匹配过程在no_grad里、用的是linear_sum_assignment这类真正求最优匹配的算法而不是贪心近似、代价矩阵的三项权重和论文一致(1 : 5 : 2)。另外检查一下匹配之后,没匹配上的槽位是不是真的按 ∅ 类在算分类损失,漏掉这一步会直接破坏一对一的性质。
5
本文还有配套的精品资源,点击获取