BEV:Sparse4D
一、论文背景与核心贡献
核心问题:
BEVFormer等方法需要构建稠密的BEV特征图,计算量大。能否直接用稀疏query在3D空间采样多视角特征,跳过BEV构建。
核心创新:
- 4D Anchor-based Sparse Feature Sampling — 3D空间anchor + 时间维度,无需BEV
- Deformable Feature Aggregation — 从3D anchor生成关键点,投影到多视角图像采样特征
- Instance Bank + Temporal Fusion — 实例级时序传播,高置信度检测结果跨帧继承
- Denoising Training — 加噪GT作为辅助query,加速收敛(v3)
- 端到端检测+跟踪 — 实例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 (推理时, 用于跟踪)
三、核心技术模块详解
三句话总结
- 关键点(核心采样创新):
13个关键点和物体几何绑定,不只是"点变多",而是"点有意义",即使部分点被遮挡,其他点仍能采到有效特征; - softmax权重(核心聚合创新):
在相机×尺度维度竞争,自动选最优视角,比DETR3D的独立sigmoid更聚焦; - 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 已经有了图像语义,基于"看到了什么"来协商,更有意义。
更多推荐

所有评论(0)