一、论文背景与核心贡献

核心问题:
BEVFormer等方法需要构建稠密的BEV特征图,计算量大。能否直接用稀疏query在3D空间采样多视角特征,跳过BEV构建。

核心创新:

  1. 4D Anchor-based Sparse Feature Sampling — 3D空间anchor + 时间维度,无需BEV
  2. Deformable Feature Aggregation — 从3D anchor生成关键点,投影到多视角图像采样特征
  3. Instance Bank + Temporal Fusion — 实例级时序传播,高置信度检测结果跨帧继承
  4. Denoising Training — 加噪GT作为辅助query,加速收敛(v3)
  5. 端到端检测+跟踪 — 实例ID跨帧传播(v3)

二、整体架构

多相机图像 (6 cameras)


┌─────────────────┐
│ ResNet50 + FPN │ → 4级特征图 (stride 4,8,16,32)
└────────┬────────┘
│ feature_maps: [bs, 6, 256, H_i, W_i] × 4 levels

┌──────────────────────────────────────────────────────┐
│ Sparse4D Head (×6 decoder layers) │
│ │
│ Instance Bank: │
│ 900 learnable anchors (K-means初始化) │
│ + 600 temporal instances (上帧高置信度结果) │
│ │
│ 每层操作顺序: │
│ ┌─→ temp_gnn (时序cross-attn: 当前query→历史) │
│ ├─→ gnn (self-attn: query间交互) │
│ ├─→ norm │
│ ├─→ deformable (3D anchor→多视角特征采样) │
│ ├─→ norm → ffn → norm │
│ └─→ refine (anchor精化 + 分类) │
│ │
│ 第1层后: Instance Bank Update │
│ (temporal instances替换低置信度anchors) │
└────────┬─────────────────────────────────────────────┘

├─→ Classification: 10类 (nuScenes)
├─→ 3D BBox: [x,y,z, w,l,h, sin,cos, vx,vy,vz]
├─→ Quality: [centerness, yawness]
└─→ Instance ID (推理时, 用于跟踪)


三、核心技术模块详解

三句话总结

  1. 关键点(核心采样创新):
    13个关键点和物体几何绑定,不只是"点变多",而是"点有意义",即使部分点被遮挡,其他点仍能采到有效特征;
  2. softmax权重(核心聚合创新):
    在相机×尺度维度竞争,自动选最优视角,比DETR3D的独立sigmoid更聚焦;
  3. Instance Bank(核心时序创新):
    实例级缓存,比BEVFormer的特征图对齐精度高,置信度衰减优雅处理目标消失,天然支持Tracking,无需额外模块;
# ============================================================
# 总结
# ============================================================
print("""
【完整数据流】
anchor(几何先验)+ instance_feature(query,初始为0)
         │
         ▼
    生成关键点(与anchor的尺寸/朝向绑定)
         │
         ▼
    投影到多相机 → grid_sample → 图像特征
    [N_cam × N_pts × N_levels] 个特征
         │
         ▼ softmax权重(query预测)
    聚合为 img_feat_agg  [N_anchor, C]
         │
         ▼
    ┌─────────────────────────────┐
    │  Q = instance_feat + pos    │
    │  K = img_feat_agg  + pos    │  ← 位置编码对齐Q和K的空间感知
    │  V = img_feat_agg           │  ← 纯语义,不含位置偏置
    └─────────────────────────────┘
         │ cross_attn
         ▼
    更新 instance_feature(充满图像语义)
         │ self_attn
         ▼
    query 间协商(去重)
         │
         ▼
    cls_head → 类别
    reg_head → 偏移量 + anchor → 最终3D框
""")

"""
K-Means初始化                    全0初始化
      ↓                               ↓
   anchor  ←─────────────→  instance_feature
  (几何骨架)   共同驱动       (语义血肉)
      │         关键点生成          │
      │                             │
      ↓                             ↓
  提供位置先验                  提供语义先验
  决定去哪采样                  决定怎么聚合
      │                             │
      └──────────┬──────────────────┘
                 ↓
           图像特征采样
                 ↓
         instance_feature 更新(被图像填充)
         anchor 更新(残差精化位置)
                 ↓
            下一层 decoder
            
"""

一个核心原理介绍的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
N_anchor    = 3
N_pts       = 3      # 每个anchor的关键点数(简化,真实=13)
N_levels    = 2
IMG_H, IMG_W = 256.0, 704.0
X,Y,Z,W,L,H,SIN,COS = 0,1,2,3,4,5,6,7
anchor_names = ['轿车', '卡车', '行人']
# 图像特征(backbone+FPN输出,直接作为输入)
img_feats = [
    torch.randn(B, N_cam, C, 4, 8),   # FPN level0
    torch.randn(B, N_cam, C, 2, 4),   # FPN level1
]
# lidar2img 变换矩阵(标定文件,直接作为输入)
lidar2img = torch.tensor([[
    [[600,   0, 352,   0],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
    [[600,   0, 352, 200],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
]], dtype=torch.float32)
print("=" * 60)
print("Sparse4D 核心:关键点特征提取 + Q/K/V 交互")
print("=" * 60)
# ============================================================
# 可学习参数
# ============================================================
# anchor:3D 位置 + 形状(驱动关键点生成)
anchor = torch.tensor([
    [15.0,  0.0, 0.5,  0.9, 1.7, 0.7, 0.0, 1.0],
    [30.0, -5.0, 1.0,  1.1, 2.1, 1.4, 0.5, 0.9],
    [ 8.0,  3.0, 0.9,  0.3, 0.3, 1.7, 0.0, 1.0],
])   # [N_anchor, 8]
# instance_feature:每个 anchor 对应的特征向量(query 的初始值)
instance_feature = torch.zeros(N_anchor, C)
# 网络模块
anchor_encoder = nn.Linear(8, C)              # anchor → 位置编码
offset_net     = nn.Linear(C, N_pts * 3)      # query  → 关键点偏移
weight_net     = nn.Linear(C, N_pts * N_cam * N_levels)  # query → 采样权重
cross_attn     = nn.MultiheadAttention(C, num_heads=2, batch_first=True)
self_attn      = nn.MultiheadAttention(C, num_heads=2, batch_first=True)
cls_head       = nn.Linear(C, 3)
reg_head       = nn.Linear(C, 3)
# ============================================================
# Step 1:从 anchor 生成关键点(与物体几何绑定)
# ============================================================
print("\n【Step 1】从 anchor 生成关键点")
print("  DETR3D:1个中心点,不感知物体形状")
print("  Sparse4D:多个关键点,和物体尺寸/朝向绑定")
def generate_keypoints(anchor, instance_feature):
    """
    anchor 提供几何先验(尺寸+朝向)
    instance_feature(query)预测可学习偏移
    两者共同决定关键点在世界坐标系的位置
    """
    N = anchor.shape[0]
    size = anchor[:, [W, L, H]].exp()   # 还原真实尺寸
    # 固定关键点:相对物体中心的偏移,按尺寸缩放
    fixed = torch.tensor([
        [0.0,  0.0,  0.0],    # 中心
        [0.45, 0.0,  0.0],    # 右面
        [0.0,  0.0,  0.45],   # 顶面
    ])   # [N_pts, 3]
    fix_pts = fixed.unsqueeze(0) * size.unsqueeze(1)  # [N, N_pts, 3]
    # 可学习偏移:由 instance_feature(query)预测
    # query 越精准 → 偏移越合理 → 关键点越准确
    learn_off = (offset_net(instance_feature).sigmoid() - 0.5)
    learn_off = learn_off.view(N, N_pts, 3) * size.unsqueeze(1)
    # 固定 + 可学习(这里简化为只用固定点)
    pts = fix_pts   # [N, N_pts, 3]
    # 旋转 + 平移到世界坐标系
    sin_yaw, cos_yaw = anchor[:, SIN], anchor[:, COS]
    rot = torch.zeros(N, 3, 3)
    rot[:, 0, 0] = cos_yaw;  rot[:, 0, 1] = -sin_yaw
    rot[:, 1, 0] = sin_yaw;  rot[:, 1, 1] =  cos_yaw
    rot[:, 2, 2] = 1.0
    pts_world = (rot.unsqueeze(1) @ pts.unsqueeze(-1)).squeeze(-1)
    pts_world = pts_world + anchor[:, [X, Y, Z]].unsqueeze(1)
    # [N, N_pts, 3]
    print(f"\n  Anchor 0 ({anchor_names[0]}) 的关键点世界坐标:")
    for i, pt in enumerate(pts_world[0].detach().numpy()):
        print(f"    关键点{i}: ({pt[0]:.2f}, {pt[1]:.2f}, {pt[2]:.2f})m")
    return pts_world
keypoints = generate_keypoints(anchor, instance_feature)
print(f"\n  关键点 shape: {keypoints.shape}  [N_anchor, N_pts, 3]")
# ============================================================
# Step 2:关键点投影 + grid_sample 采样图像特征
# ============================================================
print("\n【Step 2】关键点投影 + 特征采样")
print("  每个关键点投影到多相机多尺度,采样图像特征")
def sample_img_features(keypoints, lidar2img, img_feats):
    """
    将 3D 关键点投影到图像,采样对应位置的特征。
    返回每个关键点在每个相机每个尺度的图像特征。
    """
    N, N_pts, _ = keypoints.shape
    # 齐次坐标
    ones = torch.ones(N, N_pts, 1)
    pts_homo = torch.cat([keypoints, ones], dim=-1)  # [N, N_pts, 4]
    # 投影到各相机
    l2i = lidar2img[0]   # [N_cam, 4, 4]
    pts_cam = (l2i.unsqueeze(1).unsqueeze(1) @
               pts_homo.unsqueeze(0).unsqueeze(-1)).squeeze(-1)
    # [N_cam, N, N_pts, 4]
    # 透视除法 → 归一化坐标
    eps = 1e-5
    u_norm = pts_cam[...,0] / (pts_cam[...,2]+eps) / IMG_W * 2 - 1
    v_norm = pts_cam[...,1] / (pts_cam[...,2]+eps) / IMG_H * 2 - 1
    valid  = (pts_cam[...,2]>0.1) & (u_norm.abs()<1) & (v_norm.abs()<1)
    # [N_cam, N, N_pts]
    print(f"\n  关键点可见性:")
    for q in range(N):
        for p in range(N_pts):
            cams = [c for c in range(N_cam) if valid[c,q,p]]
            print(f"    anchor{q}({anchor_names[q]}) 关键点{p}: "
                  f"可见相机{cams}")
    # grid_sample 采样各 FPN 层级
    grid = torch.stack([u_norm, v_norm], dim=-1)  # [N_cam, N, N_pts, 2]
    sampled_list = []
    for feat in img_feats:
        feat_2d = feat[0]   # [N_cam, C, H, W]
        grid_in = grid.view(N_cam, N*N_pts, 1, 2)
        s = F.grid_sample(feat_2d, grid_in, mode='bilinear',
                          padding_mode='zeros', align_corners=False)
        sampled_list.append(s.view(N_cam, C, N, N_pts))
    # [N_cam, C, N_anchor, N_pts, N_levels]
    sampled = torch.stack(sampled_list, dim=-1)
    return sampled, valid
sampled, valid = sample_img_features(keypoints.detach(), lidar2img, img_feats)
print(f"\n  采样特征 shape: {sampled.shape}")
print(f"  [N_cam={N_cam}, C={C}, N_anchor={N_anchor}, N_pts={N_pts}, N_levels={N_levels}]")
# ============================================================
# Step 3:自适应权重聚合 → 得到每个 anchor 的图像特征
# ============================================================
print("\n【Step 3】自适应权重聚合")
print("  query 预测权重,在(相机×尺度)上 softmax 竞争")
print("  将 N_pts × N_cam × N_levels 个特征聚合为1个向量")
def aggregate(sampled, valid, instance_feature):
    N = instance_feature.shape[0]
    # query 预测每个(点,相机,尺度)的权重
    raw_w = weight_net(instance_feature)
    raw_w = raw_w.view(N, N_pts, N_cam * N_levels)
    # ★ softmax 在相机×尺度维度竞争(Sparse4D关键设计)
    weights = raw_w.softmax(dim=-1)   # [N, N_pts, N_cam*N_levels]
    # mask 不可见位置
    mask = valid.permute(1,2,0)                    # [N, N_pts, N_cam]
    mask = mask.unsqueeze(-1).expand(N, N_pts, N_cam, N_levels)
    mask = mask.reshape(N, N_pts, N_cam*N_levels)
    weights = weights * mask.float()
    # 加权聚合
    sf = sampled.permute(2,3,0,4,1)               # [N, N_pts, N_cam, N_levels, C]
    sf = sf.reshape(N, N_pts, N_cam*N_levels, C)
    agg = (sf * weights.unsqueeze(-1)).sum(dim=2)  # [N, N_pts, C]
    agg = agg.sum(dim=1)                           # [N, C]
    return agg
img_feat_agg = aggregate(sampled, valid, instance_feature)
print(f"\n  聚合后图像特征 shape: {img_feat_agg.shape}  [N_anchor, C]")
print(f"  含义:每个 anchor 从多视角多尺度图像中提取的综合特征")
# ============================================================
# Step 4:Q / K / V 的构成与交互
# ============================================================
print("\n【Step 4】Q / K / V 的构成与交互")
print("""
  ┌─────────────────────────────────────────────────────┐
  │  Q = instance_feature + anchor_embed                │
  │      ↑ query当前语义    ↑ 3D位置编码               │
  │      "我现在知道什么"   + "我在3D空间哪里"          │
  │                                                     │
  │  K = img_feat_agg + anchor_embed                    │
  │      ↑ 从图像采到的特征  ↑ 3D位置编码              │
  │      "图像里这里有什么"  + "采样点在3D哪里"         │
  │                                                     │
  │  V = img_feat_agg                                   │
  │      ↑ 纯图像语义特征                               │
  │      "聚合的图像内容"(不含位置偏置)               │
  └─────────────────────────────────────────────────────┘
""")
# anchor_embed:将 anchor 的几何信息编码为特征空间的位置编码
anchor_embed = anchor_encoder(anchor)   # [N_anchor, C]
# ★ Q / K / V 构建
Q = (instance_feature + anchor_embed).unsqueeze(0)   # [1, N_anchor, C]
K = (img_feat_agg     + anchor_embed).unsqueeze(0)   # [1, N_anchor, C]
V = img_feat_agg.unsqueeze(0)                         # [1, N_anchor, C]
print(f"  Q shape: {Q.shape}  = instance_feature + anchor_embed")
print(f"  K shape: {K.shape}  = img_feat_agg     + anchor_embed")
print(f"  V shape: {V.shape}  = img_feat_agg")
# Cross-Attention:Q 从 K/V 中提取有用信息,更新 instance_feature
output, attn_w = cross_attn(Q, K, V)
# [1, N_anchor, C]
print(f"\n  cross_attn 输出 shape: {output.shape}")
print(f"\n  Attention 权重(每个 query 关注了哪些 anchor 的图像特征):")
print(f"  {'':10}", end="")
for j in range(N_anchor):
    print(f"  K{j}({anchor_names[j][:2]})", end="")
print()
for i in range(N_anchor):
    w = attn_w[0, i].detach().numpy()
    print(f"  Q{i}({anchor_names[i][:2]}):  ", end="")
    for wj in w:
        print(f"  {wj:.3f}     ", end="")
    print()
# ============================================================
# Step 5:Self-Attention + 更新 instance_feature
# ============================================================
print("\n【Step 5】Self-Attention(query 间交互)")
print("  让多个 anchor 互相感知,避免检测同一目标")
# 更新后的 instance_feature 加入 cross_attn 结果
updated_feat = instance_feature + output.squeeze(0)
# Self-Attention
q_self = updated_feat.unsqueeze(0)
self_out, _ = self_attn(q_self, q_self, q_self)
instance_feature = updated_feat + self_out.squeeze(0)
# [N_anchor, C]
print(f"  更新后 instance_feature shape: {instance_feature.shape}")
print(f"  instance_feature 范数(从0到有值):")
for i in range(N_anchor):
    print(f"    Anchor {i}({anchor_names[i]}): "
          f"{instance_feature[i].norm().item():.4f}")
# ============================================================
# Step 6:检测头输出
# ============================================================
print("\n【Step 6】检测头输出")
print("  instance_feature 已充满图像语义")
print("  直接接检测头出结果,不需要额外操作")
cls_pred  = cls_head(instance_feature)   # [N_anchor, 3]
reg_delta = reg_head(instance_feature)   # [N_anchor, 3]  dx,dy,dz
# 残差:最终位置 = anchor 参考点 + 偏移量
final_pos = anchor[:, :3] + reg_delta.detach()
print(f"\n  最终检测结果:")
cls_names = ['car', 'truck', 'ped']
for i in range(N_anchor):
    score = torch.softmax(cls_pred[i], dim=0).detach()
    ref   = anchor[i, :3].numpy()
    pred  = final_pos[i].detach().numpy()
    print(f"  Anchor {i}({anchor_names[i]}):")
    print(f"    参考点:   ({ref[0]:.1f}, {ref[1]:.1f}, {ref[2]:.1f})m")
    print(f"    预测位置: ({pred[0]:.1f}, {pred[1]:.1f}, {pred[2]:.1f})m")
    print(f"    类别:     {cls_names[score.argmax()]}  "
          f"{score.numpy().round(2)}")

Sparse4D用900个可学习的3D anchor代替BEV网格,每个anchor是11维向量(位置+尺寸+朝向+速度)。通过K-means聚类初始化让anchor分布匹配数据分布,训练中可学习微调。这避免了BEVFormer那样构建200×200的稠密BEV特征图,大幅降低计算量。

四、问题

Q1: Sparse4D为什么不需要BEV?

BEV本质是一个中间表示,目的是把多视角特征统一到一个空间。Sparse4D直接在3D世界坐标维护anchor,通过projection matrix把3D关键点投影到各个相机采样特征,跳过了BEV构建。这更高效——只在有物体的地方采样,而不是铺满整个BEV空间。

Q2:为什么是先cross attention,后self attention,这个顺序可以调换吗?

先看当前顺序的逻辑
Step1: Cross-Attention
Q = instance_feature(当前语义,初始为0)
K/V = img_feat_agg(从图像采到的特征)
作用:让每个 query 先从图像里获取信息,此时 instance_feature 还是空的或不完整的,先"充电",把图像证据装进来。
Step2: Self-Attention
Q = K = V = 更新后的 instance_feature
作用:query 间互相感知,协调去重,此时每个 query 已经有了图像语义,基于"看到了什么"来协商,更有意义。

Logo

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

更多推荐