4. 基于LSS的BEV转换

好,咱们进入正题。这一章聊的是LSS——Lift-Splat-Shoot。说实话,我第一次看到这个论文标题的时候,觉得这名字起得真够形象的。把像素“举起来”,然后“撒”到BEV空间里,最后“射击”做规划。今天咱们重点讲前两步:Lift和Splat。

LSS的核心思想,说白了就是:每个像素都可能对应3D空间中的一条射线上的任意一点。你想想看,单目相机是没有深度信息的,一个2D像素点,它可能是近处的一个小物体,也可能是远处的一个大物体。那怎么办?LSS的做法是——不猜一个确定的深度,而是估计一个深度分布。

深度分布估计:让每个像素学会“猜”深度

我个人习惯把深度分布估计理解成“给每个像素发一张彩票”。这张彩票上写着:你有多少概率在1米处,多少概率在2米处……一直到最大深度。

具体怎么做呢?网络会为每个像素预测一个D维的向量,D就是离散化的深度bin数量。比如我们把0到50米分成100个bin,每个bin代表0.5米的间隔。那每个像素就会输出100个概率值,加起来等于1。

关键点:这个深度分布不是后处理的,而是和特征一起端到端学习的。我在项目中遇到过,如果深度分布预测不准,后面的BEV特征就会像“鬼影”一样飘忽不定。

代码实现上,通常是这样:

# 伪代码:深度分布估计
# image_features: [B, C, H, W]  图像特征
# depth_logits: [B, D, H, W]    深度logits

depth_probs = softmax(depth_logits, dim=1)  # 沿深度维度做softmax
# 现在 depth_probs 每个像素的D个值加起来等于1

嗯,这里要注意:softmax是在深度维度上做的,不是通道维度。我刚开始写的时候搞反过,结果训练出来的模型深度分布全乱套了。

外积:把2D特征“举”到3D空间

有了深度分布,下一步就是“Lift”——把2D特征提升到3D。这里用到了一个很巧妙的操作:外积。

你想想看,我们有两个东西:

  • 深度分布:[D, H, W],每个像素有D个深度概率
  • 图像特征:[C, H, W],每个像素有C维特征

外积就是把它们组合起来,得到[D, C, H, W]的3D特征体。具体来说,对于每个空间位置(h,w),我们把深度分布向量[D]和特征向量[C]做外积,得到一个D×C的矩阵。

我的理解:外积的本质是“给每个深度bin分配一份特征”。如果某个深度bin的概率高,那这个bin对应的特征就会被保留;概率低,特征就会被抑制。这就像是在说:“我觉得这个像素有80%的概率在5米处,所以我把80%的特征放在5米那个bin里。”

代码实现:

# 外积操作
# depth_probs: [B, D, H, W]
# image_features: [B, C, H, W]

# 扩展维度做外积
depth_probs = depth_probs.unsqueeze(2)  # [B, D, 1, H, W]
image_features = image_features.unsqueeze(1)  # [B, 1, C, H, W]

# 外积:逐元素相乘
lifted_features = depth_probs * image_features  # [B, D, C, H, W]

我曾经在这个地方踩过坑——内存爆炸。你看,如果D=100,C=64,H=128,W=256,那这个lifted_features的尺寸就是[B, 100, 64, 128, 256]。一个batch size为4的话,光这个张量就要占好几个GB。所以实际工程中,我们通常会做降采样或者用更小的特征图。

体素池化:把3D特征“撒”到BEV网格

好了,现在我们有了一堆3D特征点。每个点都知道自己在3D空间中的位置(通过深度bin对应的距离和相机内外参计算出来),也知道自己的特征值。下一步就是“Splat”——把这些点“撒”到BEV网格上。

体素池化的过程其实很简单:

  1. 把3D空间划分成X×Y×Z的体素网格(BEV通常只关心X和Y,Z可以压缩或保留多层)
  2. 对于每个3D特征点,计算它落在哪个体素里
  3. 同一个体素内的所有特征点,做池化操作(通常是求和或平均)

避坑指南:我曾经遇到过一个问题——多个相机拍摄同一个区域时,同一个体素会被多个特征点命中。如果直接求和,重叠区域的特征值会异常大。后来我改成了平均池化,效果好了很多。当然,也有用最大池化的,看具体场景。

代码实现的核心逻辑:

# 体素池化伪代码
# lifted_features: [B, D, C, H, W]  提升后的3D特征
# 通过相机内外参,计算每个特征点的3D坐标 (x, y, z)

# 1. 生成所有特征点的3D坐标
# 2. 将坐标离散化到体素网格
# 3. 根据体素索引做池化

# 简化版:使用scatter_add实现
bev_features = torch.zeros(B, C, X, Y, device=device)
for b in range(B):
    # voxel_indices: [N, 2]  N个特征点的(x,y)网格索引
    # features: [N, C]
    bev_features[b] = scatter_add(features, voxel_indices, dim=0)

这里有个细节:Z轴怎么处理? 有些方法直接把Z轴压缩掉,只保留BEV的X和Y。但我在做行人检测时发现,行人的高度信息其实挺重要的。所以我会保留2-3层Z轴,比如地面层、中间层、高层,分别对应不同的物体类别。

整体流程梳理

咱们用一张图来总结LSS的Lift-Splat流程:

LSS Lift-Splat 流程 输入图像 多相机 RGB [B, 3, H, W] 特征提取 图像特征 [B, C, H, W] 深度分布 [B, D, H, W] (共享Backbone) 外积 Lift 深度 ⊗ 特征 [B, D, C, H, W] (3D特征体) 体素池化 Splat [B, C, X, Y] (BEV特征) 关键步骤说明: 1. 输入:多相机图像,每个像素需要预测深度分布 2. 特征提取:共享Backbone同时输出图像特征和深度logits 3. 外积:深度分布与图像特征做外积,得到3D特征体 4. 坐标变换:利用相机内外参,将3D特征映射到BEV网格 5. 体素池化:同一网格内的特征做池化(求和/平均/最大) 输出:稠密BEV特征图

这张图把整个流程串起来了。从多相机图像输入,到特征提取和深度估计,再到外积生成3D特征体,最后体素池化得到BEV特征。每一步都有对应的张量形状变化,方便大家对照代码理解。

实际工程中的注意事项

最后,分享几个我在实际部署LSS时踩过的坑:

  • 深度bin的分布:不要均匀分布!近处的物体需要更精细的深度分辨率。我一般用对数分布或者根据相机参数自适应调整。
  • 多相机重叠区域:体素池化时,重叠区域的特征会叠加。如果相机标定有误差,同一个物体在不同相机下的深度估计可能不一致,导致特征“撕裂”。
  • 计算效率:外积操作会产生巨大的中间张量。我建议在工程实现时,用稀疏化或者分块处理来降低显存占用。

一个小技巧:如果你发现BEV特征在边缘区域特别模糊,可以试试在深度估计时增加一个“不确定性”分支。让网络自己判断哪些深度预测是可靠的,哪些是不可靠的。我在一次项目中用了这个方法,BEV特征的清晰度提升了将近15%。

好了,LSS的Lift-Splat原理就讲到这里。核心就是三个东西:深度分布估计、外积、体素池化。理解透了,你就能自己实现一个BEV感知的baseline了。