DETR端到端目标检测:从NMS、Anchor到集合预测与匈牙利匹配
简介这份 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 个 GTM ≤ 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_cls1.0, w_l15.0, w_giou2.0): pred_logits: [N, C1] 最后一维是 ∅ 类的 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, C1] cost_cls -prob[:, gt_labels] # 取 GT 类别的概率再取负形状 [N, M] # L1 项直接衡量坐标差对绝对位置敏感 cost_l1 torch.cdist(pred_boxes, gt_boxes, p1) # [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_in2048, d_model256): super().__init__() # 1x1 卷积只改通道数不动空间尺寸 self.reduce nn.Conv2d(c_in, d_model, kernel_size1) 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-attentionquery 作为 Qencoder 输出作为 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 倍而且每个头分到的训练信号会非常稀疏。分类头输出 C1 维走 softmax多出来的那一维就是 ∅ 类。回归头输出 4 维是归一化的(cx, cy, w, h)中心点坐标相对于整幅图的位置宽高相对于整幅图的大小全部落在 [0, 1] 区间内。用归一化坐标而不是像素坐标是因为不同图像的尺寸差异很大。如果用像素坐标回归目标的范围会从几十到几千不等FFN 得去适应这种跨度极大的分布训练会很不稳定。归一化之后所有回归目标都被压到同一个量纲上网络只需要学「相对位置」这一件事。顺带说一句∅ 类的作用和传统检测器里的 background 类很像但语义不完全一样。background 是在 anchor 的语境下说的「这个先验框里是背景」∅ 是在集合的语境下说的「这个槽位没有分配到目标」。后者是集合预测这套框架能成立的前提不是可选项。4.4 损失L1 GIoU 的组合动机损失只在匹配上的槽位上计算。分类部分用负对数似然交叉熵框部分用 L1 和 GIoU 的线性组合。这个组合不是拍脑袋定的两项各自补对方的短板。def box_xyxy(b, eps1e-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, eps1e-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(min0) # 负值截断为 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 个 epochAP 停在个位数不动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本文还有配套的精品资源点击获取