一. 论文核心思想

完全不构建 BEV 特征图,900 个稀疏 query 在 3D 空间自适应生成采样点,直接从多视角多尺度图像特征中提取信息,用 Adaptive Mixing 聚合,实现高效端到端检测。最核心的亮点有两个:

  1. 自适应采样点(ASTS)
    这是最核心的贡献,也是论文名字"Sparse"的来源。SparseBEV 的采样点:完全由 query 预测,自适应生成。
  2. Adaptive Mixing(AM)
    128个采样点特征 → 如何聚合?普通做法:固定权重加权求和(信息损失大),SparseBEV:query 动态生成聚合矩阵。

二. 整体架构

6个摄像头图像 × T帧 [B, T6, C, H, W]

Backbone (ResNet50/101/VoVNet/EVA02) + FPN

多尺度特征 [B, T6, G*C, H, W] (4组 × 4层级)

Query初始化: 30×30网格 → 900个query [B, 900, 10]

┌────────────────────────────────────┐
│ 6层共享权重Transformer Decoder │
│ 每层: │
│ ① Scale-Adaptive Self-Attention │
│ ② Adaptive Spatio-Temporal Sampling│
│ ③ Adaptive Mixing │
│ ④ FFN │
│ ⑤ 分类+回归+Bbox Refine │
└────────────────────────────────────┘

输出: 3D检测框 [B, 900, 10] + 类别 [B, 900, 10]

NMS-Free Top-K选取 → 最终100个检测结果


三. 核心创新

# ============================================================
# 两大核心对比总结
# ============================================================
print("""
【两大核心原理总结】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
核心1:Adaptive Spatio-Temporal Sampling (ASTS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  解决"去哪里采样"的问题
  Sparse4D:几何先验(固定面中心)
  SparseBEV:query 完全自适应预测
  offset = Linear(query_feat)        ← 数据驱动
  pts = anchor_center + offset × size ← 物体尺寸缩放
  + 速度补偿:历史帧 pts -= vel × dt
    一次采样顺带解决时序对齐
  + 只选最优视角(argmax valid)
    view_idx 归一化为第3维
    → 3D grid_sample 一次完成多视角采样
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
核心2:Adaptive Mixing (AM)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  解决"怎么聚合"的问题
  普通方法:固定权重求和(所有物体用同一套)
  SparseBEV:query 动态生成聚合矩阵(每个物体定制)
  M = Linear(query) → 通道混合矩阵  ← 定制通道组合
  S = Linear(query) → 点压缩矩阵   ← 定制点选择
  采样特征 → M(混通道)→ S(压点数)→ 256维query
  大卡车的 M/S ≠ 行人的 M/S
  → 不同物体用不同的特征聚合策略
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
两者的关系
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ASTS 解决"采哪里" → 128个高质量采样点
  AM   解决"怎么用" → 将128点压缩为1个256维特征
  缺一不可:
    采样好但聚合差 → 信息浪费
    聚合好但采样差 → 巧妇难为无米之炊
""")

一个介绍核心原理的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_query     = 3
N_groups    = 2      # 采样组数(真实=4)
N_points    = 2      # 每组采样点数(真实=4)
N_levels    = 2      # FPN层级数
N_frames    = 2      # 时序帧数(真实=8)
IMG_H, IMG_W = 256.0, 704.0
anchor_names = ['轿车', '卡车', '行人']
# 图像特征(直接作为输入)
# 多帧:[B, T*N_cam, C, H, W]
img_feats = [
    torch.randn(B, N_frames * N_cam, C, 4, 8),   # FPN level0
    torch.randn(B, N_frames * N_cam, C, 2, 4),   # FPN level1
]
# lidar2img [B, T*N_cam, 4, 4]
lidar2img = torch.tensor([[
    # frame0 cam0
    [[600,   0, 352,   0],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
    # frame0 cam1
    [[600,   0, 352, 200],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
    # frame1 cam0(同上,简化)
    [[600,   0, 352,   0],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
    # frame1 cam1
    [[600,   0, 352, 200],
     [  0, 600, 128,   0],
     [  0,   0,   1,   0],
     [  0,   0,   0,   1]],
]], dtype=torch.float32)   # [B, T*N_cam, 4, 4]
# 时间差(当前帧到各历史帧的时间间隔)
time_diff = torch.tensor([[0.0, 0.5]])   # [B, T],0.5秒间隔
print("=" * 60)
print("SparseBEV 两大核心原理 Demo")
print("=" * 60)
# ============================================================
# 初始化:anchor + query
# ============================================================
# anchor:3D位置+形状(x,y,z,w,l,h,sin,cos,vx,vy)
anchor = torch.tensor([
    [15.0,  0.0, 0.5,  1.8, 4.5, 1.5, 0.0, 1.0,  5.0, 0.0],  # 轿车,速度5m/s
    [30.0, -5.0, 1.0,  2.5, 7.0, 3.0, 0.5, 0.9,  0.0, 0.0],  # 卡车,静止
    [ 8.0,  3.0, 0.9,  0.5, 0.5, 1.7, 0.0, 1.0, -1.0, 0.5],  # 行人,慢速
])   # [N_query, 10]
# query feature:初始为0,将被图像特征填充
query_feat = torch.zeros(N_query, C)
# 网络模块
offset_net     = nn.Linear(C, N_groups * N_points * 3)  # 预测采样点偏移
scale_net      = nn.Linear(C, N_groups * N_points * N_levels)  # 预测尺度权重
param_gen      = nn.Linear(C, N_groups * (C//N_groups * C//N_groups   # M矩阵
                                        + N_points * N_frames * C//N_groups))  # S矩阵
out_proj       = nn.Linear(N_groups * N_points * N_frames * C//N_groups, C)
# ============================================================
# 核心1:Adaptive Spatio-Temporal Sampling (ASTS)
# ============================================================
print("\n" + "=" * 60)
print("【核心1】Adaptive Spatio-Temporal Sampling (ASTS)")
print("=" * 60)
print("\n关键思想:")
print("  Sparse4D:固定几何关键点(面中心),几何先验强")
print("  SparseBEV:query 完全自适应预测采样点,数据驱动")
print("  额外:速度补偿解决历史帧时序对齐问题")
def adaptive_sampling(anchor, query_feat, lidar2img, img_feats, time_diff):
    """
    ASTS 核心:
    1. query 自适应预测采样点偏移(不依赖固定几何)
    2. 速度补偿历史帧采样点位置
    3. 投影到图像,选最优视角采样
    """
    N = anchor.shape[0]
    # ── Step A:query 预测采样点偏移(自适应的关键)──────────
    # 完全由 query 决定去哪里采样
    # 不同组可以学到不同的采样策略
    raw_offset = offset_net(query_feat)               # [N, G*P*3]
    raw_offset = raw_offset.view(N, N_groups, N_points, 3)
    # sigmoid → [-0.5, 0.5],限制在物体范围内
    offset = raw_offset.sigmoid() - 0.5              # [N, G, P, 3]
    # 按物体尺寸缩放(和 Sparse4D 相同的几何约束)
    size = anchor[:, 3:6].exp()                       # [N, 3] 真实尺寸
    offset = offset * size.view(N, 1, 1, 3)           # [N, G, P, 3]
    # 平移到世界坐标
    center = anchor[:, :3]                            # [N, 3]
    pts = offset + center.view(N, 1, 1, 3)            # [N, G, P, 3]
    # 扩展到多帧 [N, G, P, T, 3]
    pts = pts.unsqueeze(3).expand(N, N_groups, N_points, N_frames, 3).clone()
    print(f"\n  【Step A】自适应采样点(当前帧,未补偿):")
    print(f"  对比 Sparse4D 固定几何点 vs SparseBEV 自适应点:")
    for q in range(N):
        for g in range(N_groups):
            for p in range(N_points):
                pt = pts[q, g, p, 0].detach().numpy()
                anc = anchor[q, :3].numpy()
                print(f"    {anchor_names[q]}{g}{p}: "
                      f"({pt[0]:.2f},{pt[1]:.2f},{pt[2]:.2f})m  "
                      f"偏离anchor({anc[0]:.1f},{anc[1]:.1f})m "
                      f"{'← 自适应,不固定' if g==0 and p==0 else ''}")
    # ── Step B:速度补偿(时序核心)──────────────────────────
    # 物体在历史帧位于不同位置
    # 必须把采样点反向偏移到历史时刻的正确位置
    vel = anchor[:, 8:10]   # [N, 2] 预测速度 (vx, vy)
    print(f"\n  【Step B】速度补偿(历史帧采样点偏移):")
    print(f"  原理:物体以速度v运动,t秒前在 pos - v×t 处")
    for t in range(N_frames):
        dt = time_diff[0, t].item()
        if dt == 0:
            continue
        # 反向偏移:历史帧采样点 = 当前位置 - 速度×时间差
        displacement = vel * dt                        # [N, 2]
        pts[:, :, :, t, 0] -= displacement[:, 0].view(N, 1, 1)
        pts[:, :, :, t, 1] -= displacement[:, 1].view(N, 1, 1)
        for q in range(N):
            vx, vy = vel[q].detach().numpy()
            before = anchor[q, :2].numpy()
            after  = pts[q, 0, 0, t].detach().numpy()
            print(f"    {anchor_names[q]} t-{dt:.1f}s: "
                  f"当前({before[0]:.1f},{before[1]:.1f})m "
                  f"→ 历史({after[0]:.2f},{after[1]:.2f})m  "
                  f"[速度({vx:.1f},{vy:.1f})m/s × {dt:.1f}s]")
    # ── Step C:投影到图像 + 选最优视角 ──────────────────────
    # pts: [N, G, P, T, 3] → 展平投影
    pts_flat = pts.reshape(N, -1, 3)                  # [N, G*P*T, 3]
    N_total  = pts_flat.shape[1]
    # 齐次坐标
    ones     = torch.ones(N, N_total, 1)
    pts_homo = torch.cat([pts_flat, ones], dim=-1)    # [N, G*P*T, 4]
    # 投影到所有相机(T帧 × N_cam个相机)
    l2i      = lidar2img[0]                           # [T*N_cam, 4, 4]
    pts_cam  = (l2i.unsqueeze(1) @
                pts_homo.unsqueeze(0).unsqueeze(-1)).squeeze(-1)
    # [T*N_cam, N, G*P*T, 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)
    # [T*N_cam, N, G*P*T]
    print(f"\n  【Step C】投影到图像,选最优视角:")
    print(f"  SparseBEV 关键设计:每个点只选1个最优视角(argmax valid)")
    print(f"  避免多视角重复采样,且可用3D grid_sample一次完成")
    # ★ 只选最优视角(SparseBEV 关键设计)
    # valid: [T*N_cam, N, G*P*T] → 在相机维度找最优
    valid_count = valid.float().sum(dim=0)            # [N, G*P*T]
    best_cam    = valid.float().argmax(dim=0)         # [N, G*P*T] 最优相机索引
    # 取最优相机的坐标
    best_u = u_norm.gather(0, best_cam.unsqueeze(0)).squeeze(0)  # [N, G*P*T]
    best_v = v_norm.gather(0, best_cam.unsqueeze(0)).squeeze(0)  # [N, G*P*T]
    # view_idx 归一化为第3维(将多视角变成3D体积的深度方向)
    view_idx_norm = best_cam.float() / (N_cam * N_frames - 1)    # [N, G*P*T]
    # 组织为采样坐标 [N, G*P*T, 3]
    sample_coords = torch.stack([best_u, best_v, view_idx_norm], dim=-1)
    print(f"\n  各采样点的最优相机选择(anchor 0 轿车):")
    for i in range(min(4, N_total)):
        cam = best_cam[0, i].item()
        u   = best_u[0, i].item()
        v   = best_v[0, i].item()
        print(f"    采样点{i}: 最优相机={cam}  "
              f"归一化坐标({u:.3f},{v:.3f})")
    # ── Step D:多尺度加权采样 ────────────────────────────────
    # 预测各FPN层级的权重(softmax竞争)
    scale_w = scale_net(query_feat)                   # [N, G*P*L]
    scale_w = scale_w.view(N, N_groups, N_points, N_levels)
    scale_w = scale_w.softmax(dim=-1)                 # [N, G, P, L]
    # 在各FPN层级采样并加权
    sampled_list = []
    for lvl, feat in enumerate(img_feats):
        feat_2d = feat[0]                             # [T*N_cam, C, H, W]
        # 用3D grid_sample(第3维=view_idx)
        # 这里简化为2D grid_sample
        grid = sample_coords[:, :, :2].view(
            N * N_total, 1, 1, 2)
        feat_flat = feat_2d.mean(0, keepdim=True).expand(
            N * N_total, -1, -1, -1)                  # 简化处理
        s = F.grid_sample(feat_flat, grid,
                          mode='bilinear',
                          padding_mode='zeros',
                          align_corners=False)
        sampled_list.append(s.view(N, N_total, C))
    # 多尺度加权求和
    sampled = torch.stack(sampled_list, dim=-1)       # [N, G*P*T, C, L]
    scale_w_exp = scale_w.view(N, N_groups*N_points, 1, N_levels)\
                         .expand_as(sampled[:, :N_groups*N_points])
    # 简化:直接对层级取平均
    sampled = sampled.mean(dim=-1)                    # [N, G*P*T, C]
    # reshape为 [N, G, P*T, C]
    sampled = sampled.view(N, N_groups, N_points * N_frames, C)
    print(f"\n  采样结果 shape: {sampled.shape}")
    print(f"  [N_query={N_query}, N_groups={N_groups}, "
          f"N_pts×T={N_points*N_frames}, C={C}]")
    return sampled
sampled = adaptive_sampling(
    anchor, query_feat, lidar2img, img_feats, time_diff
)
# ============================================================
# 核心2:Adaptive Mixing (AM)
# ============================================================
print("\n" + "=" * 60)
print("【核心2】Adaptive Mixing (AM)")
print("=" * 60)
print("\n关键思想:")
print("  普通做法:固定权重加权求和 → 信息损失大,无法定制")
print("  AM:query 动态生成聚合矩阵,每个物体定制化混合策略")
print(f"\n  输入:{N_groups}组 × {N_points*N_frames}点 × {C}维特征")
print(f"  输出:{C}维 query 特征")
def adaptive_mixing(sampled, query_feat):
    """
    Adaptive Mixing 核心:
    query 动态生成 M(通道混合)和 S(点压缩)两个矩阵
    为每个物体定制特征聚合策略
    """
    N       = query_feat.shape[0]
    G       = N_groups
    P_T     = N_points * N_frames    # 点×帧
    C_g     = C // G                 # 每组的特征维度
    # sampled: [N, G, P_T, C] → [N, G, P_T, C_g](按组分割)
    x = sampled.view(N, G, P_T, C_g)
    # ── Step A:query 生成自适应参数 ──────────────────────────
    # M:通道混合矩阵 [C_g × C_g],混合特征通道
    # S:点压缩矩阵  [P_T × out_pts],压缩采样点数量
    out_pts = P_T * 2   # 输出点数(这里简化为不压缩)
    M_size  = C_g * C_g
    S_size  = P_T * C_g
    params  = param_gen(query_feat)   # [N, G*(M_size+S_size)]
    params  = params.view(N, G, M_size + S_size)
    M = params[:, :, :M_size].view(N, G, C_g, C_g)       # [N, G, C_g, C_g]
    S = params[:, :, M_size:].view(N, G, P_T, C_g)       # [N, G, P_T, C_g]
    print(f"\n  【Step A】query 生成自适应参数:")
    print(f"  M矩阵(通道混合)shape: {M.shape}  "
          f"[N, G, C_g={C_g}, C_g={C_g}]")
    print(f"  S矩阵(点压缩)  shape: {S.shape}  "
          f"[N, G, P_T={P_T}, C_g={C_g}]")
    print(f"\n  对比不同物体的 M 矩阵差异(体现定制化):")
    for q in range(N):
        m_norm = M[q, 0].detach().norm().item()
        print(f"    {anchor_names[q]} M矩阵范数: {m_norm:.4f}  "
              f"← 每个物体的混合策略不同")
    # ── Step B:通道混合(M矩阵)────────────────────────────
    # x: [N, G, P_T, C_g]
    # M: [N, G, C_g, C_g]
    # → 对每个采样点的特征做线性变换,混合通道信息
    x_mixed = torch.matmul(x, M)    # [N, G, P_T, C_g]
    x_mixed = F.layer_norm(x_mixed, [C_g])
    x_mixed = F.relu(x_mixed) + x_mixed   # 残差
    print(f"\n  【Step B】通道混合后 shape: {x_mixed.shape}")
    print(f"  每个采样点的 {C_g} 维特征被重新混合")
    print(f"  不同物体用不同的 M 矩阵 → 定制化通道组合")
    # ── Step C:点压缩(S矩阵)──────────────────────────────
    # S: [N, G, P_T, C_g]
    # 用 S 对点维度做加权聚合(压缩 P_T 个点)
    # 这里简化:用 S 做注意力加权
    attn  = (S * x_mixed).sum(dim=-1, keepdim=True)  # [N, G, P_T, 1]
    attn  = attn.softmax(dim=2)                       # 在点维度归一化
    x_agg = (x_mixed * attn).sum(dim=2)              # [N, G, C_g]
    print(f"\n  【Step C】点压缩后 shape: {x_agg.shape}")
    print(f"  {P_T} 个采样点被压缩为1个向量")
    print(f"  S 矩阵决定每个点的贡献权重 → 定制化点选择")
    # ── Step D:输出投影 ─────────────────────────────────────
    x_out = x_agg.reshape(N, G * C_g)                # [N, C]
    x_out = out_proj(x_out)                           # [N, C]
    # 残差连接
    output = query_feat + x_out
    print(f"\n  【Step D】最终输出 shape: {output.shape}  [N_query, C]")
    return output
updated_query = adaptive_mixing(sampled, query_feat)
print(f"\n  更新后 query 范数(从0到有值):")
for q in range(N_query):
    print(f"    {anchor_names[q]}: "
          f"{updated_query[q].norm().item():.4f}")
# ============================================================
# 检测头
# ============================================================
print("\n" + "=" * 60)
print("【检测头】query → 类别 + 3D框")
print("=" * 60)
cls_head = nn.Linear(C, 3)
reg_head = nn.Linear(C, 3)
cls_pred  = cls_head(updated_query)
reg_delta = reg_head(updated_query)
final_pos = anchor[:, :3] + reg_delta.detach()
cls_names = ['car', 'truck', 'ped']
for q in range(N_query):
    score = torch.softmax(cls_pred[q], dim=0).detach()
    ref   = anchor[q, :3].numpy()
    pred  = final_pos[q].detach().numpy()
    print(f"  {anchor_names[q]}:")
    print(f"    参考点:   ({ref[0]:.1f},{ref[1]:.1f},{ref[2]:.1f})m")
    print(f"    预测位置: ({pred[0]:.2f},{pred[1]:.2f},{pred[2]:.2f})m")
    print(f"    类别:     {cls_names[score.argmax()]}  "
          f"{score.detach().numpy().round(2)}")

四. 问答

Q1: SparseBEV vs BEVFormer的核心区别?

答:BEVFormer构建密集的100×100 BEV特征图,每个BEV格点都attend图像特征,复杂度O(H_bev ×
W_bev)。SparseBEV完全不构建BEV图,每个query直接在3D空间生成采样点投影到图像,复杂度O(Q ×
P),Q=900远小于BEV的10000。

Q2: 为什么叫"SparseBEV"但没有BEV?

答: "Sparse"指的是稀疏采样策略 — 不在BEV空间密集采样,而是在query位置稀疏采样。虽然没有显式BEV特征图,但query的3D位置检测是在BEV坐标系下。

Logo

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

更多推荐