一、论文核心思想(30秒版)

DETR3D将DETR的集合预测范式引入多视角3D检测:用可学习的3D参考点代替2D
anchor,通过3D→2D投影从多视角图像中采样特征,实现端到端的NMS-free 3D检测。
核心创新:不在图像空间做检测,而是在3D空间设置query
→ 每个query学习一个3D参考点
→ 将3D参考点投影到各相机图像上
→ 在投影位置采样特征
→ Transformer Decoder解码出3D框
关键区别于LSS/BEVDepth:
BEVDepth: 图像 → 显式深度 → BEV特征 → 检测 (自底向上)
DETR3D: 3D query → 投影到图像 → 采样特征 → 解码 (自顶向下)

二、整体架构(Pipeline)

输入: 6路环视相机图像 [B, 6, 3, H, W]

┌─────────── 图像编码 ──────────────────┐
│  GridMask数据增强 (prob=0.7)           │
│  ResNet101-DCN Backbone                │
│  FPN Neck → 4级多尺度特征              │
│  输出: list of [B, 6, 256, Hi, Wi]     │
└──────────────────┬───────────────────┘
                   │
┌──────── Detr3DTransformer ─────────────┐  ← 注意: 只有Decoder, 没有Encoder!
│                                        │
│  Object Queries: 900个可学习query       │
│  ┌──────────────────────────────────┐  │
│  │ query_embed [900, 512]           │  │
│  │ → split → query_pos [900, 256]   │  │
│  │            query [900, 256]      │  │
│  │                                  │  │
│  │ query_pos → Linear → sigmoid     │  │
│  │ → reference_points [B, 900, 3]   │  │  ← 3D参考点(归一化到[0,1])
│  └──────────────────────────────────┘  │
│                                        │
│  Decoder × 6层:                        │
│    ├─ Self-Attention (query间交互)      │
│    ├─ Cross-Attention ← 核心创新       │
│    │   ├─ 3D参考点 → lidar2img投影     │
│    │   ├─ 6相机×4尺度 grid_sample采样  │
│    │   ├─ 学习注意力权重 + 有效性mask  │
│    │   └─ 加权求和 + 位置编码          │
│    ├─ FFN                              │
│    └─ [可选] Box Refinement更新参考点   │
│                                        │
│  输出: hs [6, B, 900, 256]             │
│        reference_points每层更新         │
└──────────────────┬───────────────────┘
                   │
┌──────── 检测头(每层独立)──────────────┐
│  cls_branch: Linear×2 + LN + ReLU → 10 │  ← 10类
│  reg_branch: Linear×2 + ReLU → 10      │  ← cx,cy,w,l,cz,h,sin,cos,vx,vy
│                                         │
│  输出坐标解码:                           │
│    cx,cy: reg_pred + reference_point_xy │  ← 残差学习!
│    cz:    reg_pred + reference_point_z  │
│    sigmoid → 反归一化到真实坐标          │
└─────────────────────────────────────────┘
                   │
┌──────── 后处理(NMS-Free)─────────────┐
│  sigmoid(cls_score) → topK(300)         │
│  → denormalize_bbox → range filter      │
│  无需NMS!                               │
└─────────────────────────────────────────┘

三、核心代码逐模块精讲

DETR3D核心流程图:

# ============================================================
# 整体流程总结
# ============================================================
print("""
【流程总结】
query_embed(可学习)
    │
    ├─ query_pos → Linear → sigmoid → 3D参考点
    │                                    │
    │                              lidar2img 投影
    │                                    │
    │                           图像像素坐标(u,v)
    │                                    │
    │                            grid_sample 采样
    │                                    │
    └─ query  ──→ Linear → 注意力权重 ──→ 加权聚合
                                         │
                                     BEV特征
                                         │
                            ┌────────────┴──────────┐
                         cls_head               reg_head
                            │                      │
                         类别得分           偏移量 + 参考点
                                                   │
                                               最终3D框
关键设计:
  ① 参考点  从3D空间出发,不需要深度估计
  ② mask    不可见相机权重=0,自动过滤噪声
  ③ 残差    预测偏移而非绝对坐标,收敛更稳
""")



在 ONNX 中的表现
FastBEV 的 ONNX 图:
  [图像特征]  [BEV网格坐标(常量)]
       └──────────┘
            ↓
        grid_sample
            ↓
        BEV 特征
DETR3D 的 ONNX 图:
  [图像特征]  [query_embed(常量)]
                    ↓
               ref_net(权重也是常量)
                    ↓
               参考点坐标
       └──────────┘
            ↓
        grid_sample
            ↓
        采样特征 + query → cross_attention → 更新 query
                                                  ↓
                                             检测结果
---
唯一的区别
BEV网格坐标:
  人工设计,均匀网格
  含义固定:就是空间坐标
  训练前后不变
query_embed:
  训练前:随机初始化
  训练后:每个 query 学会了"负责哪类目标/哪个区域"
  推理时固定,但它的值是训练出来的,不是人工设计的
---
一句话总结
对,query_embed 就是一个可训练的常量输入。
FastBEV 的 BEV 网格坐标 = 人工设计的常量
DETR3D 的 query_embed   = 训练出来的常量
推理阶段两者没有区别,都是固定数据送进网络。
差异只在于 query_embed 的值是通过训练数据学出来的。

DETR3D 核心思想完整 Demo:

import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(42)
# ============================================================
# 基础参数
# ============================================================
B, N_cam, C = 1, 2, 8      # batch, 相机数, 特征维度
N_query = 3                  # object query 数量
N_levels = 2                 # FPN 层级数
pc_range = [-51.2, -51.2, -5.0, 51.2, 51.2, 3.0]  # 感知范围
# ============================================================
# 准备工作(直接作为输入)
# ============================================================
# 1. 图像特征(模拟 ResNet+FPN 输出)
img_feats = [
    torch.randn(B, N_cam, C, 4, 11),   # FPN level0: [B, N_cam, C, H, W]
    torch.randn(B, N_cam, C, 2,  6),   # FPN level1
]
# 2. lidar2img 变换矩阵(标定文件读取,这里直接给)
#    含义:将 LiDAR 坐标系下的 3D 点投影到图像像素坐标
#    = 相机内参 K × 外参 [R|t]
lidar2img = torch.tensor([[
    # 前视相机:目标在正前方能看到
    [[600,   0, 352, 0],
     [  0, 600, 128, 0],
     [  0,   0,   1, 0],
     [  0,   0,   0, 1]],
    # 右视相机:目标在右前方能看到
    [[600,   0, 352, 300],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
]], dtype=torch.float32)  # [B, N_cam, 4, 4]
IMG_H, IMG_W = 256.0, 704.0
print("输入准备完毕")
print(f"  img_feats: {[f.shape for f in img_feats]}")
print(f"  lidar2img: {lidar2img.shape}")
# ============================================================
# 核心1:Object Query 初始化
# ============================================================
print("\n【核心1】Object Query 初始化")
# query_embed 是唯一的可学习参数入口,分为两部分:
#   query_pos → 生成 3D 参考点("我在哪里")
#   query     → 携带语义信息("我是什么")
query_embed   = nn.Parameter(torch.randn(N_query, 2 * C))
query_pos, query = query_embed[:, :C], query_embed[:, C:]
# query_pos 通过线性层 + sigmoid 生成归一化 [0,1] 的 3D 参考点
ref_net           = nn.Linear(C, 3)
reference_points  = ref_net(query_pos).sigmoid()  # [N_query, 3]
# 反归一化看真实坐标
def denorm(ref):
    r = ref.clone()
    r[..., 0] = r[..., 0] * (pc_range[3]-pc_range[0]) + pc_range[0]
    r[..., 1] = r[..., 1] * (pc_range[4]-pc_range[1]) + pc_range[1]
    r[..., 2] = r[..., 2] * (pc_range[5]-pc_range[2]) + pc_range[2]
    return r
ref_real = denorm(reference_points.detach())
print(f"  3D 参考点(真实坐标/m):")
for i, (x, y, z) in enumerate(ref_real.numpy()):
    print(f"    Query {i}: x={x:.1f}  y={y:.1f}  z={z:.1f}")
# ============================================================
# 核心2:3D→2D 投影 + 特征采样
# ============================================================
print("\n【核心2】3D→2D 投影 + 特征采样")
def feature_sampling(img_feats, reference_points, lidar2img):
    N_query = reference_points.shape[0]
    # Step A: 归一化坐标 → 真实3D坐标 → 齐次坐标 [1, N_query, 4]
    ref = denorm(reference_points.unsqueeze(0))
    ref = torch.cat([ref, torch.ones(1, N_query, 1)], dim=-1)
    # Step B: 投影到每个相机
    # [B, N_cam, 4, 4] × [B, 1, N_query, 4] → [B, N_cam, N_query, 4]
    ref_cam = (lidar2img.unsqueeze(2) @ ref.unsqueeze(1).unsqueeze(-1)).squeeze(-1)
    print(f"  投影后图像坐标(除z前):")
    for cam in range(N_cam):
        for q in range(N_query):
            v = ref_cam[0, cam, q].detach().numpy()
            print(f"    cam{cam} query{q}: u*z={v[0]:.0f}  v*z={v[1]:.0f}  z={v[2]:.2f}")
    # Step C: 透视除法 → 归一化到 [-1,1]
    eps = 1e-5
    depth_valid = ref_cam[..., 2:3] > eps
    ref_2d = ref_cam[..., :2] / (ref_cam[..., 2:3] + eps)
    ref_norm = torch.stack([
        ref_2d[..., 0] / IMG_W * 2 - 1,
        ref_2d[..., 1] / IMG_H * 2 - 1,
    ], dim=-1)   # [B, N_cam, N_query, 2]
    # Step D: 有效性 mask(z>0 且在图像范围内)
    mask = depth_valid & (ref_norm[..., 0:1].abs() < 1) \
                       & (ref_norm[..., 1:2].abs() < 1)
    # [B, N_cam, N_query, 1]
    print(f"\n  有效性 mask(该 query 在该相机是否可见):")
    for cam in range(N_cam):
        for q in range(N_query):
            u = ref_2d[0, cam, q, 0].item()
            v = ref_2d[0, cam, q, 1].item()
            valid = mask[0, cam, q, 0].item()
            print(f"    cam{cam} query{q}: 像素({u:.0f},{v:.0f})"
                  f"  {'✓' if valid else '✗ 不可见'}")
    # Step E: grid_sample 在各 FPN 层级采样特征
    # 这是 Cross Attention 中"取 K 和 V"的过程
    sampled_list = []
    for feat in img_feats:                          # feat: [B, N_cam, C, H, W]
        B_, N_, C_, H_, W_ = feat.shape
        grid = ref_norm.view(B_*N_, N_query, 1, 2)  # grid_sample 要求 [B, H_out, W_out, 2]
        s = F.grid_sample(feat.view(B_*N_, C_, H_, W_),
                          grid, mode='bilinear',
                          padding_mode='zeros', align_corners=False)
        # s: [B*N_cam, C, N_query, 1]
        sampled_list.append(s.view(B_, N_, C_, N_query))
    # [B, N_cam, C, N_query, N_levels]
    sampled = torch.stack(sampled_list, dim=-1)
    # sampled 就是 K 和 V(从图像特征中取出的内容)
    return sampled, mask
sampled_feats, mask = feature_sampling(
    img_feats, reference_points.detach(), lidar2img
)
print(f"\n  采样特征 shape: {sampled_feats.shape}")
print(f"  [B={B}, N_cam={N_cam}, C={C}, N_query={N_query}, N_levels={N_levels}]")
# ============================================================
# 核心3:注意力加权聚合
# ============================================================
print("\n【核心3】注意力加权聚合")
# 由 query 预测每个相机×每个层级的权重
# 这是 Cross Attention 中"Q 和 K 计算相似度"的过程
attn_net = nn.Linear(C, N_cam * N_levels)
nn.init.zeros_(attn_net.weight)
nn.init.zeros_(attn_net.bias)   # 初始 sigmoid(0)=0.5,所有权重相等
attn_w = attn_net(query).view(N_query, N_cam, N_levels)
attn_w = torch.sigmoid(attn_w)
# query 就是 Q
# attn_net 替代了标准 attention 的 Q@K^T
# [N_query, N_cam, N_levels]
# 不可见相机的权重强制为 0(sigmoid × mask)
# mask: [B, N_cam, N_query, 1] → [N_query, N_cam, 1]
mask_w = mask[0].permute(1, 0, 2).expand_as(attn_w)
attn_w = attn_w * mask_w.float()
print(f"  注意力权重(×mask后):")
for q in range(N_query):
    for cam in range(N_cam):
        w = attn_w[q, cam].detach().numpy().round(3)
        m = mask_w[q, cam, 0].item()
        print(f"    query{q} cam{cam}: {w}"
              f"{'  ← masked' if not m else ''}")
# sampled_feats: [B, N_cam, C, N_query, N_levels]
# 加权求和:对 N_cam 和 N_levels 维度求和
sf = sampled_feats[0].permute(2, 0, 3, 1)
# [N_query, N_cam, N_levels, C]
aw = attn_w.unsqueeze(-1)
# [N_query, N_cam, N_levels, 1]
# 这是 Cross Attention 中"用权重加权 V"的过程
bev_feat = (sf * aw).sum(dim=1).sum(dim=1)
# [N_query, C]
print(f"\n  聚合后 BEV 特征: {bev_feat.shape}  [N_query={N_query}, C={C}]")
# ============================================================
# 核心4:检测头(残差回归)
# ============================================================
print("\n【核心4】检测头 —— 残差回归")
cls_head = nn.Linear(C, 3)   # 3类
reg_head = nn.Linear(C, 3)   # 预测 (dx, dy, dz) 偏移量
cls_pred = cls_head(bev_feat)          # [N_query, 3]
reg_pred = reg_head(bev_feat)          # [N_query, 3]
# 残差:pred = sigmoid(reg_pred + inverse_sigmoid(ref_point))
# 为什么这样:直接预测绝对坐标范围大难收敛
#             预测相对参考点的偏移,网络只需学"修正量"
def inv_sigmoid(x, eps=1e-5):
    x = x.clamp(eps, 1-eps)
    return torch.log(x / (1 - x))
final_norm = (reg_pred + inv_sigmoid(reference_points.detach())).sigmoid()
final_real = denorm(final_norm)
print(f"  检测结果:")
cls_names = ['car', 'ped', 'bg']
for q in range(N_query):
    score = torch.softmax(cls_pred[q], dim=0).detach()
    pred_cls = score.argmax().item()
    ref  = ref_real[q].numpy()
    pred = final_real[q].detach().numpy()
    delta = pred - ref
    print(f"  Query {q}:")
    print(f"    参考点:   ({ref[0]:.1f}, {ref[1]:.1f}, {ref[2]:.1f})m")
    print(f"    预测偏移: ({delta[0]:.2f}, {delta[1]:.2f}, {delta[2]:.2f})m")
    print(f"    最终位置: ({pred[0]:.1f}, {pred[1]:.1f}, {pred[2]:.1f})m")
    print(f"    类别:     {cls_names[pred_cls]}  {score.numpy().round(2)}")

区别:
BEVDepth:先建立完整BEV地图,再检测
需要深度估计,特征稠密
DETR3D: 直接为每个目标找图像证据
不需要深度,特征稀疏(只采样N_query个点)

四、问题

Q1: DETR3D为什么只用Decoder没有Encoder?

标准DETR的Encoder用于图像特征的全局self-attention交互。DETR3D中:

  • 图像特征已经通过ResNet+FPN充分提取
  • 6路相机的特征交互通过cross-attention的多相机采样间接实现
  • 去掉Encoder大幅降低计算量(6路高分辨率特征做self-attention代价极高)

Q2: 3D参考点投影到图像上可能遇到什么问题?

  1. 投影到相机后方(z≤0):用mask屏蔽
  2. 投影到图像外:用范围[-1,1]的mask屏蔽
  3. 一个3D点可能在多个相机上可见(重叠区域):attention权重自动学习选择最优相机
  4. 一个3D点可能在所有相机上都不可见(被遮挡/范围外):该query倾向于预测背景
  5. 投影精度依赖标定:内外参不准会导致采样位置偏移

Q3: attention_weights为什么初始化为0?

constant_init(self.attention_weights, val=0., bias=0.)

  • sigmoid(0) = 0.5 → 初始时所有相机/层级的权重相等
  • 训练过程中逐渐学会哪些相机/层级对每个query更重要
  • 避免初始权重过大导致训练不稳定

Q4: 逐层监督(auxiliary loss)的作用?

每层Decoder都计算loss → 缓解深层Transformer的梯度消失问题,加速收敛。

  • 浅层学粗略定位
  • 深层学精细回归
  • 类似FPN的多尺度监督思想

Q5: DETR3D收敛慢怎么解决?

DETR系列的通病(原始DETR需要500 epoch),DETR3D的缓解方法:

  • Box Refinement:逐层更新参考点,每层从更好的位置出发
  • 逐层辅助loss:每层都有监督信号
  • CBGS(类别平衡采样):缓解类别不平衡
  • GridMask增强:正则化防过拟合
  • 后续改进:DN-DETR的去噪训练、DAB-DETR的直接anchor学习

Q6: normalize_bbox中为什么尺寸要取log?

w = bboxes[…, 3:4].log() # width
l = bboxes[…, 4:5].log() # length
h = bboxes[…, 5:6].log() # height

  • 尺寸范围很大(0.5m~20m),直接回归数值范围大
  • 取log后压缩到较小范围,L1 loss更均衡
  • 小目标和大目标的尺寸误差在log空间中被等比例对待

Q7为什么用sigmoid而不是softmax?

  • softmax会强制所有相机权重加和为1 → 当目标只在1个相机可见时,其他相机的噪声特征也被强制分配权重
  • sigmoid让每个权重独立 → mask=0的相机自然被屏蔽,可见相机的权重也可以灵活调整
  • 配合mask使用:sigmoid × mask,不可见相机直接置零

六、衔接

DETR3D和BEV检测是两种不同范式,对比着讲能展示系统性理解:

▎ “LSS-based的BEV方案(类似BEVDepth),而不是DETR3D这类query-based方案,核心考量是:
▎ 1. 量产部署:BEV方案主要是标准卷积算子,部署友好;DETR3D的动态attention和匈牙利匹配在芯片上难以高效实现
▎ 2. 多任务扩展:BEV特征图可以同时给检测、分割、OCC等任务使用,而DETR3D的稀疏query表示难以直接做密集任务
▎ 3. 精度:加了深度监督后BEV方案精度不输query-based
▎ 4. 但DETR3D思路的优势在于:不需要显式深度估计、NMS-free更优雅、理论上更易端到端训练”

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐