BEVFusion Multi-Task Multi-Sensor Fusion with Unified Bird's-Eye View Representation
记录论文《BEVFusion: Multi-Task Multi-Sensor Fusion with Unified Bird’s-Eye View Representation》阅读过程中的一些思考。
简介
BEVFusion 将相机和 LiDAR 各自独立编码为 BEV 表示后在 BEV 空间中融合,统一了多模态传感器的融合范式,证明了 BEV 是多传感器融合的天然中间表示。
传统多传感器融合方法通常有两种思路。一种是得益于之前 2D 视觉感知的巨大成功,以 Camera 为中心,将 LiDAR 点云投影到图像中,并使用 CNN 系的方法处理 RGB-D 数据;另一种则是之后流行的 point-level 方法,其使用 语义标签(semantic labels)、CNN 特征或者来自相机的虚拟点(virtual points)来增强LiDAR 点云的信息,再使用以 LiDAR 为基础的检测器做 3D 边界框检测。但这两种思路都有各自棘手的问题,前者在将 LiDAR 投影到图像时引入了严重的几何失真,这会让诸如 3D 目标识别 之类的几何向(geometric-oriented)的任务效率低下;而后者虽然在大规模检测任务集上展现了卓越的性能,但在语义向(semantic-oriented)的任务上表现乏力,究其原因是图像中密集的语义特征被稀疏的 LiDAR 点(尤其是稀疏的 LiDAR 或者 Radar)采样后大量丢失。

BEVFusion 打破了这种范式,提出用模态特定(modality-specific)的编码器独立编码提取各模态特征,并将这些特征统一到 BEV 表示空间后再做融合。
概念
统一 BEV 表征 / Unified Bird’s-Eye View Representation
来自不同传感器的特征可能存在不同的视图,如图像特征是透视视图、典型的 LiDAR/Radar 特征是 3D 或 Bird's-Eye View 视图。甚至对于图像特征,其本身可能都包含诸如前视、后视、环视等等多种不同视角。这种视图差异让同一元素在不同特征空间中的位置迥异从而导致特征融合变得困难。
不同于前述的统一到图像的 2D 空间或 LiDAR/Radar 的 3D 空间的方案,本文提出统一 BEV 表征(Unified Bird's-Eye View Representation)将特征统一到 BEV(Bird's-Eye View) 空间。
衡准
优秀的统一表征空间应该:
- 所有传感器的特征能尽量信息无损的转换到该空间
- 能够适配不同的任务类型
相比于以 2D 图像或 3D LiDAR/Radar 为中心的方案,统一 BEV 表征:
- 几何-语义互补:
LiDAR提供的精确的几何结构、相机提供的丰富的语义纹理,两者在转换后都被保留,并在BEV空间中自然互补 - 任务无关性:
统一 BEV 表征可以同时支持检测、分割等几乎所有感知任务,因为它们的输出也是在BEV空间
探微
Q1: 为什么“对齐”到图像空间会损失几何信息而“对齐”到点云空间会损失语义信息?
A:
对齐到图像空间:在由对齐后的
RGB-D数据驱动的深度图中,相邻像素在3D空间中可能相距很远,它们的特征在卷积时会被强制混合对齐到点云空间:因为
LiDAR/Radar点云相比图像非常稀疏,将相机特征投影到点云,会导致大量的图像特征被丢弃(如 $32$ 线LiDAR只能匹配约 $5\%$ 的图像特征),如远处的交通标志、地面标线、行人的部分身体等等
Q2: BEVFusion 与其他 BEV 融合方法(如北大的同名 BEVFusion)有什么区别?
A: 同期发表了两篇名为 BEVFusion 的工作:
- MIT 版(本文):重点在于诊断并解决
视角变换的效率瓶颈(提出高效 BEV 池化),强调任务无关的统一框架设计 - 北大版(arXiv:2205.13790):侧重于设计自适应的特征级融合策略,关注检测精度提升
两者的核心思想,即在 BEV 空间融合多模态特征——是一致的。
视角变换 / View Transformation
视角变换(View Transformation),或相机到鸟瞰视角变换(Camera-to-BEV Transformation),是将相机图像特征从透视视角转换到 BEV 空间的关键步骤。

BEVFusion 沿用了 LSS 的方法(参见):对每个像素预测离散深度分布 $\alpha \in \Delta^{|D|-1}$ ,与上下文特征 $\boldsymbol{c}$ 做外积生成三维特征视锥,再通过 BEV 池化坍缩到 BEV 平面。然而,论文发现这一流程中的 BEV 池化虽然简单但却是整个流水线的效率瓶颈。在原始 LSS 实现中,视角变换中 BEV 池化在 RTX 3090 上耗时约 $500\text{ms}$(剩余部分仅耗时 $100\text{ms}$ 左右),即超过 $80\%$ 的计算耗时。
因此 BEVFusion 从预计算和间隔约简/区间归约(interval reduction)两个角度来优化 BEV 池化。
预计算:将相机
特征视锥中的每个点关联到BEV网格是BEV 池化的第一步。与LiDAR点不同,在相机经过恰当的标定后(且相机内外参保持不变),特征视锥中的各点在三维空间中坐标是确定的。基于此- 预先计算
特征视锥中的每个点的三维坐标和其关联的BEV网格的索引 - 按网格索引排序所有点并记录每个点的排名
因而在推理阶段,仅需按照排名重新排序所有特征点,这使得同一
BEV网格内的点在张量(Tensor)表达中物理连续。- 预先计算
间隔约简:在将特征点关联到
BEV网格后,BEV 池化的下一步是在每个网格中使用对称函数(Symmetric Function)(函数输出与输入的顺序无关,常见的有 $mean,max,sum,\dots$)聚合这些特征。如上图(b)所示,当前LSS实现中这步是先在所有点上计算前缀和(Prefix Sum),也叫累加和(Cumulative Sum, Cumsum),再在网格索引变化时减去边界值。但累加和操作在GPU上需要进行树约简(Tree Reduction)并产生大量未使用的部分和(partial sum),这两者都不必要。因此BEVFusion实现了一个可以在BEV网格上并行化的专用GPU kernel,即它为每个网格分配一个GPU线程并独立并行计算网格的区间和并返回结果。其- 消除了输出之间的依赖性,因此无需执行
树约简 - 避免了将
部分和写入DRAM
最终,如图(c),将特征聚合的延迟从 $500ms$ 左右减到了 $2ms$ 左右。
- 消除了输出之间的依赖性,因此无需执行
经过这两种优化, BEVFusion 在没有引入任何“近似”的前提下,视角变换的延迟从约 $500\text{ms}$ 降低到约 $12\text{ms}$(加速 $\boldsymbol{> 40\times}$)。
衡准
视角变换本身不是 BEVFusion 的方法创新(该步骤来自 LSS),但作者对其效率瓶颈的诊断与优化是本文的核心工程贡献之一。该优化纯属工程层面,不改变模型的数学行为(输出完全一致),但对实际部署至关重要,解决了模型工程部署延迟过高的核心障碍。
范例
CUDA核函数实现的Python绑定
// bev_pool_cpu.cpp
#include <torch/torch.h>
#include <c10/cuda/CUDAGuard.h>
// CUDA function declarations
void bev_pool(int b, int d, int h, int w, int n, int c, int n_intervals, const float* x,
const int* geom_feats, const int* interval_starts, const int* interval_lengths, float* out);
void bev_pool_grad(int b, int d, int h, int w, int n, int c, int n_intervals, const float* out_grad,
const int* geom_feats, const int* interval_starts, const int* interval_lengths, float* x_grad);
/*
Function: pillar pooling (forward, cuda)
Args:
x : input features, FloatTensor[n, c]
geom_feats : input coordinates, IntTensor[n, 4]
interval_lengths : starting position for pooled point, IntTensor[n_intervals]
interval_starts : how many points in each pooled point, IntTensor[n_intervals]
Return:
out : output features, FloatTensor[b, d, h, w, c]
*/
at::Tensor bev_pool_forward(
const at::Tensor _x,
const at::Tensor _geom_feats,
const at::Tensor _interval_lengths,
const at::Tensor _interval_starts,
int b, int d, int h, int w
) {
int n = _x.size(0);
int c = _x.size(1);
int n_intervals = _interval_lengths.size(0);
const at::cuda::OptionalCUDAGuard device_guard(device_of(_x));
const float* x = _x.data_ptr<float>();
const int* geom_feats = _geom_feats.data_ptr<int>();
const int* interval_lengths = _interval_lengths.data_ptr<int>();
const int* interval_starts = _interval_starts.data_ptr<int>();
auto options =
torch::TensorOptions().dtype(_x.dtype()).device(_x.device());
at::Tensor _out = torch::zeros({b, d, h, w, c}, options);
float* out = _out.data_ptr<float>();
bev_pool(
b, d, h, w, n, c, n_intervals, x,
geom_feats, interval_starts, interval_lengths, out
);
return _out;
}
/*
Function: pillar pooling (backward, cuda)
Args:
out_grad : input features, FloatTensor[b, d, h, w, c]
geom_feats : input coordinates, IntTensor[n, 4]
interval_lengths : starting position for pooled point, IntTensor[n_intervals]
interval_starts : how many points in each pooled point, IntTensor[n_intervals]
Return:
x_grad : output features, FloatTensor[n, 4]
*/
at::Tensor bev_pool_backward(
const at::Tensor _out_grad,
const at::Tensor _geom_feats,
const at::Tensor _interval_lengths,
const at::Tensor _interval_starts,
int b, int d, int h, int w
) {
int n = _geom_feats.size(0);
int c = _out_grad.size(4);
int n_intervals = _interval_lengths.size(0);
const at::cuda::OptionalCUDAGuard device_guard(device_of(_out_grad));
const float* out_grad = _out_grad.data_ptr<float>();
const int* geom_feats = _geom_feats.data_ptr<int>();
const int* interval_lengths = _interval_lengths.data_ptr<int>();
const int* interval_starts = _interval_starts.data_ptr<int>();
auto options =
torch::TensorOptions().dtype(_out_grad.dtype()).device(_out_grad.device());
at::Tensor _x_grad = torch::zeros({n, c}, options);
float* x_grad = _x_grad.data_ptr<float>();
bev_pool_grad(
b, d, h, w, n, c, n_intervals, out_grad,
geom_feats, interval_starts, interval_lengths, x_grad
);
return _x_grad;
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("bev_pool_forward", &bev_pool_forward,
"bev_pool_forward");
m.def("bev_pool_backward", &bev_pool_backward,
"bev_pool_backward");
}CUDA核函数实现
// bev_pool_cuda.cu
#include <stdio.h>
#include <stdlib.h>
/*
Function: pillar pooling
Args:
b : batch size
d : depth of the feature map
h : height of pooled feature map
w : width of pooled feature map
n : number of input points
c : number of channels
n_intervals : number of unique points
x : input features, FloatTensor[n, c]
geom_feats : input coordinates, IntTensor[n, 4]
interval_lengths : starting position for pooled point, IntTensor[n_intervals]
interval_starts : how many points in each pooled point, IntTensor[n_intervals]
out : output features, FloatTensor[b, d, h, w, c]
*/
__global__ void bev_pool_kernel(int b, int d, int h, int w, int n, int c, int n_intervals,
const float *__restrict__ x,
const int *__restrict__ geom_feats,
const int *__restrict__ interval_starts,
const int *__restrict__ interval_lengths,
float* __restrict__ out) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int index = idx / c;
int cur_c = idx % c;
if (index >= n_intervals) return;
int interval_start = interval_starts[index];
int interval_length = interval_lengths[index];
const int* cur_geom_feats = geom_feats + interval_start * 4;
const float* cur_x = x + interval_start * c + cur_c;
float* cur_out = out + cur_geom_feats[3] * d * h * w * c +
cur_geom_feats[2] * h * w * c + cur_geom_feats[0] * w * c +
cur_geom_feats[1] * c + cur_c;
float psum = 0;
for(int i = 0; i < interval_length; i++){
psum += cur_x[i * c];
}
*cur_out = psum;
}
/*
Function: pillar pooling backward
Args:
b : batch size
d : depth of the feature map
h : height of pooled feature map
w : width of pooled feature map
n : number of input points
c : number of channels
n_intervals : number of unique points
out_grad : gradient of the BEV fmap from top, FloatTensor[b, d, h, w, c]
geom_feats : input coordinates, IntTensor[n, 4]
interval_lengths : starting position for pooled point, IntTensor[n_intervals]
interval_starts : how many points in each pooled point, IntTensor[n_intervals]
x_grad : gradient of the image fmap, FloatTensor
*/
__global__ void bev_pool_grad_kernel(int b, int d, int h, int w, int n, int c, int n_intervals,
const float *__restrict__ out_grad,
const int *__restrict__ geom_feats,
const int *__restrict__ interval_starts,
const int *__restrict__ interval_lengths,
float* __restrict__ x_grad) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int index = idx / c;
int cur_c = idx % c;
if (index >= n_intervals) return;
int interval_start = interval_starts[index];
int interval_length = interval_lengths[index];
const int* cur_geom_feats = geom_feats + interval_start * 4;
float* cur_x_grad = x_grad + interval_start * c + cur_c;
const float* cur_out_grad = out_grad + cur_geom_feats[3] * d * h * w * c +
cur_geom_feats[2] * h * w * c + cur_geom_feats[0] * w * c +
cur_geom_feats[1] * c + cur_c;
for(int i = 0; i < interval_length; i++){
cur_x_grad[i * c] = *cur_out_grad;
}
}
void bev_pool(int b, int d, int h, int w, int n, int c, int n_intervals, const float* x,
const int* geom_feats, const int* interval_starts, const int* interval_lengths, float* out) {
bev_pool_kernel<<<(int)ceil(((double)n_intervals * c / 256)), 256>>>(
b, d, h, w, n, c, n_intervals, x, geom_feats, interval_starts, interval_lengths, out
);
}
void bev_pool_grad(int b, int d, int h, int w, int n, int c, int n_intervals, const float* out_grad,
const int* geom_feats, const int* interval_starts, const int* interval_lengths, float* x_grad) {
bev_pool_grad_kernel<<<(int)ceil(((double)n_intervals * c / 256)), 256>>>(
b, d, h, w, n, c, n_intervals, out_grad, geom_feats, interval_starts, interval_lengths, x_grad
);
}- 算法实现
class QuickCumsumCuda(torch.autograd.Function):
@staticmethod
def forward(ctx, x, geom_feats, ranks, B, D, H, W):
# 标记各组首个元素的索引,并作为后续数据保留依据
kept = torch.ones(x.shape[0], device=x.device, dtype=torch.bool)
kept[1:] = ranks[1:] != ranks[:-1]
# 计算各组起始索引、各组长度
interval_starts = torch.where(kept)[0].int()
interval_lengths = torch.zeros_like(interval_starts)
interval_lengths[:-1] = interval_starts[1:] - interval_starts[:-1]
interval_lengths[-1] = x.shape[0] - interval_starts[-1]
geom_feats = geom_feats.int()
# 调用 CUDA 核函数,按组并行聚合特征
out = bev_pool_ext.bev_pool_forward(
x,
geom_feats,
interval_lengths,
interval_starts,
B,
D,
H,
W,
)
ctx.save_for_backward(interval_starts, interval_lengths, geom_feats)
ctx.saved_shapes = B, D, H, W
return out
@staticmethod
def backward(ctx, out_grad):
interval_starts, interval_lengths, geom_feats = ctx.saved_tensors
B, D, H, W = ctx.saved_shapes
out_grad = out_grad.contiguous()
x_grad = bev_pool_ext.bev_pool_backward(
out_grad,
geom_feats,
interval_lengths,
interval_starts,
B,
D,
H,
W,
)
return x_grad, None, None, None, None, None, None
def bev_pool(feats, coords, B, D, H, W):
assert feats.shape[0] == coords.shape[0]
# 将坐标(含批索引) $\in\mathbb{R}^4$ 映射到实数 $\mathbb{R}$ 空间,
# ... 且满足:1)同一网格的点映射后值相等 2)不同网格的点映射后值不等
#
# $x \in [0, H)$
# $y \in [0, W)$
# $z \in [0, D)$
# $idx \in [0, B)$
ranks = (
coords[:, 0] * (W * D * B) # x
+ coords[:, 1] * (D * B) # y
+ coords[:, 2] * B # z
+ coords[:, 3] # batch idx
)
indices = ranks.argsort()
feats, coords, ranks = feats[indices], coords[indices], ranks[indices]
x = QuickCumsumCuda.apply(feats, coords, ranks, B, D, H, W)
x = x.permute(0, 4, 1, 2, 3).contiguous() # (B, D, H, W, C) -> (B, C, D, H, W)
return x
# 其余实现参考 https://review.yirami.xyz/job/lift-splat-shoot-encoding-images-from-arbitrary-camera-rigs-by-implicitly-unprojecting-to-3d/ 的相关范例探微
Q1: 为什么视角变换会成为效率瓶颈?
A: LSS 的 BEV 池化需要将大量三维特征点(约 $N_\text{cam} \times H \times W \times |D|$ 个)分配到 BEV 网格中并求和。
原始实现依赖计算出特征点的三维坐标后使用类似 torch.scatter_add 这种原子加法(Atomic Add)操作来将属于同一 BEV 网格的点聚集在一起。该原子操作(Atomic Operation)在硬件实现上本质是一种硬件锁, GPU 硬件为了保证“原子性”会对同一地址的访问进行串行化处理。
虽然有开源实现中使用累加和的方式做了一定的优化,但都存在 GPU 并行效率低下的问题。
Q2: BEVFusion 这种预计算的优化方式是否无法适应车辆行驶过程中动态变化的相机外参?
A: 对于固定安装的车载相机阵列,相机相对于车体的外参在行驶过程中保持不变(即相机-车体变换矩阵是固定的),在大多数论文( LSS 、 BEVFormer 、 BEVFusion )的用法中 BEV 平面的坐标系都是固定在车体坐标系上,从这个角度说预计算的优化是有效的。
但在工程实践中,相机对地平面的外参是随自车姿态变化而变化的。俯仰(Pitch)/横滚(roll)的变化通常会带来几方面的问题:
- 当车辆颠簸导致车体平面与地平面不再平行时,目标在
统一 BEV 表征中的位置会发生偏移,且距离越远越显著 BEVFormer这种涉及时序融合的方案,其对齐问题更显著,如在将历史特征变换到当前时刻时忽略了俯仰/横滚的影响,也会导致时序特征的抖动
因此,实践中,往往需要通过 IMU 补偿或者其它的地平面估计方法来动态调整 BEV 平面。
Q3: 该优化是否也适用于 LSS 的训练阶段?
A: 部分适用。
预计算可以在训练中使用(同一 batch 内内外参不变),但训练阶段需要保留完整的计算图用于反向传播,因此自定义 GPU kernel 的加速效果不如推理阶段显著。
全卷积融合 / Fully-Convolutional Fusion
在获得相机 BEV 特征 $B_\text{cam}$ 和 LiDAR BEV 特征 $B_\text{lidar}$ 后,BEVFusion 使用一个轻量级的卷积 BEV 编码器进行融合,具体为:
- 逐元素拼接:将 $B_\text{cam}$ 和 $B_\text{lidar}$ 沿通道维度拼接,得到 $B_\text{concat} \in \mathbb{R}^{2C \times H \times W}$
- 卷积融合:通过若干层
卷积将拼接特征整合为统一的融合特征 $B_\text{fused} \in \mathbb{R}^{C \times H \times W}$
其中第二步使用基于卷积的融合模块是为了弥补因为深度估计不准确导致的图像 BEV 特征与 LiDAR BEV 特征的空间错位。
衡准
该融合方式的设计故意保持简单,即仅用拼接 + 卷积,而非复杂的注意力或门控等机制。其意在表明,当两个模态各自的 BEV 编码足够好时,简单的融合策略就能取得优异的效果。可以从侧面验证 BEV 空间作为统一表示的有效性。
不过作者也表示,这种方式受深度估计影响,如果将来这部分精度有所提高,本方法也能受益。
探微
Q1: 如果某个传感器缺失(如 LiDAR 故障),该融合框架是否仍能工作?
A: 框架本身不直接处理传感器缺失的情况。但由于两个分支是独立编码的,理论上可以通过将缺失模态的 BEV 特征置零来实现优雅降级(graceful degradation),性能会退化到单模态水平。后续工作(如 BEVFusion++ )对传感器鲁棒性做了进一步探索。
多任务头 / Multi-Task Heads
BEVFusion 的一个关键设计目标是任务无关性,即统一的 BEV 特征 $B_\text{fused}$ 可以同时支持多种下游任务:
- 3D 目标检测:在 $B_\text{fused}$ 上接
CenterPoint等检测头,预测3D bounding box - BEV 地图分割:在 $B_\text{fused}$ 上接分割头,逐
BEV网格预测语义类别
两个任务共享同一个 BEV 编码器和融合模块,仅在最后一步使用不同的任务头。
衡准
任务无关性是 BEVFusion 区别于早期融合方法的重要特点。传统方法通常为特定任务(如检测)设计特定的融合策略,切换任务需要重新设计。而 BEVFusion 的统一 BEV 表示使得添加新任务只需要新增一个任务头,骨干和融合模块完全复用。
探微
Q1: 多个任务之间是否会互相影响?
A: 在论文实验中,同时训练检测和分割两个任务不会导致显著的性能冲突,反而有轻微的互相促进作用。这说明 BEV 特征同时编码了足够的几何和语义信息来支撑多种任务。但对于更多或更异质的任务(如运动预测),是否仍然如此需要进一步验证。
思路
多传感器(多模态)融合过程

上图清晰的展示了 BEVFusion 处理多传感器(多模态)融合的思路。
- 在各模态上对各自传感器的输入做特征提取
- 结合各传感器的特点,设计将特征转换到一个统一空间,即
BEV - 综合各传感器提取的特征,并进行编码得到融合特征
- 基于融合特征设计不同的任务头,以实现不同的任务目标
特征提取与统一
这一阶段对应上述的 1 、2 两步,旨在统一多模态到同一语义空间,需要面向输入做差异化的操作。
激光雷达 BEV 特征
张量视角
| Architecture | Topology | Shape | Typical | Comment |
|---|---|---|---|---|
| Input | $\left[B,\ N_{samples},\ C_{lidar}\right]$ | $\left[1,\ ?,\ 5\right]$ | ||
| Preprocess | (Voxelization) | $\left[B,\ N_{voxels}(,\ M_{samples}),\ C_{lidar},\ Coord\right]$ | $\left[1,\ ?(,\ 10),\ 5,\ 3\right]$ | $(, 10)$ :池化后无 $B$ 和 $Coord$ 并作 $1$ 维 |
| Backbone | (SparseEncoder) - - - - conv input - - - - SubMConv3d BatchNorm1d ReLU - - - - encoder layer - - - - - - - - conv output - - - - SparseConv3d BatchNorm1d ReLU | $\left\{B,\ C_{lidar},\ X_{lidar},\ Y_{lidar},\ Z_{lidar}\right\}$ - - - - - - - - - conv input - - - - - - - - - $\left\{B,\ C_{lidar},\ X_{lidar},\ Y_{lidar},\ Z_{lidar}\right\}$ - - - - - - - - - encoder layer - - - - - - - - $\left\{B,\ C_{bev},\ X_{bev},\ Y_{bev},\ Z^{'}_{bev}\right\}$ - - - - - - - - - conv output - - - - – - - - $\left\{B,\ C_{bev},\ X_{bev},\ Y_{bev},\ Z_{bev}\right\}$ | $\left\{1,\ 5,\ 1440,\ 1440,\ 41\right\}$ - - - - - - - conv input - - - - - - - - $\left\{1,\ 5,\ 1440,\ 1440,\ 41\right\}$ - - - - - - encoder layer - - - - - - - $\left\{1,\ 16,\ 1440,\ 1440,\ 41\right\}$ $\left\{1,\ 32,\ 720,\ 720,\ 21\right\}$ $\left\{1,\ 64,\ 360,\ 360,\ 11\right\}$ $\left\{1,\ 128,\ 180,\ 180,\ 5\right\}$ $\left\{1,\ 128,\ 180,\ 180,\ 5\right\}$ - - - - - - - conv output - - - - - - - $\left\{1,\ 128,\ 180,\ 180,\ 2\right\}$ | $\left\{\right\}$ : 表示稀疏矩阵 |
对于激光雷达,其特征提取与转换过程可分为三个部分:
1. 输入(Input)
接收 $B$ 批(与 Camera 的数据批大小相同)数量分别为 $N_{samples}$ 的激光雷达原始采样点,其中每个采样点都有 $C_{lidar}$ 个通道的原始特征(如空间位置 $x,\ y,\ z$ ,反射强度等信号)。
2. 预处理(Preprocess)
对于激光雷达,也叫体素化(Voxelization),它逐批对采样点数据进行基于空间位置的分组。
首先设定三维感兴趣区域(Region of Interest, ROI)和体素尺寸(如区域 $x\in(-54, 54),\ y\in(-54,54),\ z\in(-5,3)$ 尺寸 $(0.075, 0.075, 0.2)$ ),如此体素空间形状 $\left(X_{lidar},Y_{lidar}, Z_{lidar}\right)$ 就确定了,且每个体素都可以用一个三维整数坐标 $Coord$ 来索引。但体素总数非常庞大,且实际场景中点云数量分布不均,一方面有些地方点非常密集,一方面很多区域没有点,如果不做处理,计算将非常低效。对此,常见的有硬体素化(Hard Voxelization)和动态体素化(Dynamic Voxelization)两种实现策略。其中硬体素化比较经典,它一方面限制单个体素容纳最多 $M_{samples}$ 个采样点(超出则随机或末位丢弃),一方面限制(有效)体素最多 $N_{voxels}$ 个(超出则随机丢弃),论文的硬体素化实现分 CPU 版本(hard_voxelize_cpu) 和 GPU 版本(hard_voxelize_gpu)。这种方法的问题是存在信息截断,且由于包含非确定性采样,模型的推理结果可能有微小抖动。而动态体素化则不限定 $M_{samples}$ 和 $N_{voxels}$ ,其利用(如哈希表等)在采样点和体素间建立映射,虽然实现复杂,但可以减小信息损失,且非常适合硬件加速部署,论文提供的动态体素化实现同样分 CPU 版本(dynamic_voxelize_cpu) 和 GPU 版本(dynamic_voxelize_gpu)。
在将采样点分派到体素后,常见做法会对体素内采样点的特征做平均池化(Average Pooling)。
3. 骨干(Backbone)
对于激光雷达,这一骨干可称之为稀疏编码。这一步主要在特征空间上进行三维卷积,不过为了节省显存和算力,从基于稠密卷积优化为基于稀疏卷积。稀疏编码过程可分为三个阶段,即卷积输入、编码、卷积输出。
- 卷积输入
- 首先将特征构造为稀疏张量(论文实现采用
spconv.SparseConvTensor,注意该类内部解耦了空间几何结构和特征张量) - 再通过稀疏三维卷积(
Fsp.indice_subm_conv)将特征从 $5$ 升到 $16$ 维(如设定 $16$ 组 $5$ 通道的 $3\times 3\times 3$ 的3D卷积核) - 最后再依次经过稀疏序贯模型(
SubMConv3d、BatchNorm1d、ReLU)
- 首先将特征构造为稀疏张量(论文实现采用
- 编码
- 这一阶段通过多层次的稀疏卷积完成编码(以下分析以论文实现为例)
- 其构建了 $4$ 层,每一层(除最后一层)执行的操作类似,通过卷积逐
stage下采样并升通道 - 这一阶段的稀疏卷积模块可以分为两类,保持维度的(
SparseBasicBlock)和升维的(SparseSequential),其中SparseBasicBlock由SubMConv3d、BatchNorm1d、SubMConv3d、BatchNorm1d、ReLU序贯堆叠SparseSequential由SparseConv3d、BatchNorm1d、ReLU序贯堆叠
- 前三层由两个
SparseBasicBlock(维度不变)模块和一个SparseSequential(维度翻倍)模块序贯堆叠 - 第四层(最后一层)由两个
SparseBasicBlock(维度不变)模块序贯堆叠(即丢弃前三层中最后一个升维模块)
- 卷积输出
- 通过稀疏卷积的非对称卷积核将特征在 $Z$ 轴进行空间下采样(从 $5$ 减到 $2$ ),避免后续在 $Z$ 轴展开时特征维数爆炸
- 在 $Z$ 轴展开特征(即合并空间 $Z$ 轴到特征维)
细节
稀疏编码
Q: 在三维卷积过程中为什么有效数据量没有扩散?类比普通二维卷积中,若卷积核 size 不为 $1$ ,原始有效输入附近也会因为卷积操作变得有输出。
A: 这是因为模型使用了子流形稀疏卷积(Submanifold Sparse Convolution, SSC),它是一种专门用于处理高维稀疏数据(如 3D 点云)的特殊卷积操作。它的核心规则是:只有当卷积核的中心覆盖在原始的活跃像素(或点)上时,才计算输出,并且输出的稀疏结构与输入完全一致。相比而言,普通稀疏卷积则会扩散。
模型中在不同的阶段同时使用了两种 SubMConv3d 和 SparseConv3d,其中
- 同
stage内的层用SubMConv3d保证有效数据量不变 - 而
stage之间的过渡用SparseConv3d且 $stride=2$ ,虽然有效数据可能扩散,但空间减半
视觉 BEV 特征
张量视角
| Architecture | Topology | Shape | Typical | Comment |
|---|---|---|---|---|
| Input | $\left[B,\ N_{cam},\ C_{img},\ H_{img},\ W_{img}\right]$ | $\left[1,\ 6,\ 3,\ 256,\ 704\right]$ | $B$: 数据批大小 $N_{cam}$: 相机数量 $C_{img}$: 图像通道数 $H_{img}$: 图像像素高度 $W_{img}$: 图像像素宽度 | |
| Stem | $\left[B\times N_{cam},\ C_{img},\ H_{img},\ W_{img}\right]$ | $\left[6,\ 3,\ 256,\ 704\right]$ | ||
| Backbone | (Swin-T) | $\left\{ \begin{array}{l} \left[B\times N_{cam},\ 2 \times C_{emb},\ \frac{H_{img}}{8},\ \frac{W_{img}}{8}\right] \\\ \left[B\times N_{cam},\ 4 \times C_{emb},\ \frac{H_{img}}{16},\ \frac{W_{img}}{16}\right] \\\ \left[B\times N_{cam},\ 8 \times C_{emb},\ \frac{H_{img}}{32},\ \frac{W_{img}}{32}\right] \end{array} \right.$ | $\left\{ \begin{array}{l} \left[6,\ 192,\ 32,\ 88\right] \\\ \left[6,\ 384,\ 16,\ 44\right] \\\ \left[6,\ 768,\ 8,\ 22\right] \end{array} \right.$ | $C_{emb}$: 嵌入通道数 |
| Neck | (GeneralizedLSSFPN) Conv2d(k=1) BatchNorm2d ReLU - - - - - - - Conv2d(k=3) BatchNorm2d ReLU | $\left\{ \begin{array}{l} \left[B\times N_{cam},\ C_{neck},\ \frac{H_{img}}{8},\ \frac{W_{img}}{8}\right] \\\ \left[B\times N_{cam},\ C_{neck},\ \frac{H_{img}}{16},\ \frac{W_{img}}{16}\right] \end{array} \right.$ | $\left\{ \begin{array}{l} \left[6,\ 256,\ 32,\ 88\right] \\\ \left[6,\ 256,\ 16,\ 44\right] \end{array} \right.$ | 自顶向下 将上层特征 上采样并与下一层拼接 作为卷积输入 经两次卷积输出 |
| VTransform | (LSSTransform/DepthLSSTransform) - - - - dtransform - - - - - Conv2d(i=1, o=8, k=1) BatchNorm2d ReLU Conv2d(i=8, o=32, k=5) BatchNorm2d ReLU Conv2d(i=32, o=64, k=5) BatchNorm2d ReLU - - - - - depthnet - - - - - - Conv2d(i=320, o=256, k=3) BatchNorm2d ReLU Conv2d(i=256, o=256, k=3) BatchNorm2d ReLU Conv2d(i=256, o=198, k=1) - - - - - - - - - - - - - - - - - - … - - - - - - - - - - - - - - - - - - Conv2d(i=80, o=80, k=3, s=1) BatchNorm2d ReLU Conv2d(i=80, o=80, k=3, s=2) BatchNorm2d ReLU Conv2d(i=80, o=80, k=3, s=1) BatchNorm2d ReLU | - - - - - - - - - - dtransform - - - - - - - - - - $\left[B\times N_{cam},\ C_{dep},\ \frac{H_{img}}{8},\ \frac{W_{img}}{8}\right]$ - - - - - - - - - - - depthnet - - - - - - - - - - - $\left[B\times N_{cam},\ D+C_{bev}^{img},\ \frac{H_{img}}{8},\ \frac{W_{img}}{8}\right]$ - - - - - - - - - - - - - - - - - - - - - - - - - - - - $\left[B,\ N_{cam},\ D,\ \frac{H_{img}}{8},\ \frac{W_{img}}{8},\ C_{bev}^{img}\right]$ $\left[B,\ C_{bev}^{img},\ X_{bev}^{img},\ Y_{bev}^{img}\right]$ - - - - - - - - - - - - - - - - - - - - - - - - - - - - $\left[B,\ C_{bev}^{img},\ X_{bev},\ Y_{bev}\right]$ | - - - - dtransform - - - - - $\left[6,\ 64,\ 32,\ 88\right]$ - - - - - depthnet - - - - - - $\left[6,\ 198,\ 32,\ 88\right]$ - - - - - - - - - - - - - - - - - $\left[1,\ 6,\ 118,\ 32,\ 88,\ 80\right]$ $\left[1,\ 80,\ 360,\ 360\right]$ - - - - - - - - - - - - - - - - - $\left[1,\ 80,\ 180,\ 180\right]$ | $C_{dep}$: 卷积后深度特征通道数 $C_{bev}^{img}$: 视觉 BEV 空间的特征通道数$X_{bev}^{img}$: 视觉 BEV 纵向网格数$Y_{bev}^{img}$: 视觉 BEV 横向网格数$D$: 预测深度分布通道数 「可选」将 LiDAR 深度等信息按位置投影到稀疏图像空间获取三维视锥坐标到笛卡尔三维坐标的映射 通过 dtransform 提炼深度信息并对齐分辨率到图像分辨率拼接图像和深度特征并使用 depthnet 提炼将前 $D$ 通道通过 Softmax 回归后作为预测深度概率分布并将剩余通道作为 上下文特征对深度分布和特征做外积得到每个可能深度的特征 结合三维映射将特征投影到 BEV 空间并池化在 BEV 特征空间下采样 |