4、LSS原理(一):Lift阶段——深度分布估计、特征点云生成、视锥构建

好,咱们今天正式进入LSS的核心。说实话,LSS这个网络结构刚出来的时候,我第一反应是:这玩意儿真敢想啊!把2D图像硬生生“抬升”到3D空间,再投影到鸟瞰图。但仔细琢磨之后,你会发现它的设计哲学其实非常优雅。

LSS全称是Lift-Splat-Shoot,今天我们先啃最硬的一块骨头——Lift阶段。说白了,就是怎么把一张2D图像上的像素点,变成3D空间里带特征的点云。

4.1 为什么要做“Lift”?

你想想看,摄像头拍到的图像是2D的,但自动驾驶需要的是3D空间的理解。传统的做法是直接预测3D框,但LSS走了一条更“笨”但更扎实的路:先给每个像素估计一个深度分布,然后沿着相机射线撒点

我在项目中遇到过一个问题:直接用单目深度估计网络预测深度,然后投影到3D,效果很差。为什么?因为单目深度估计本身就不准,误差会直接传递到3D空间。LSS的高明之处在于,它不预测一个确定的深度值,而是预测一个深度分布——说白了,就是告诉网络:“这个像素可能出现在这几个深度位置,你自己看着办。”

核心思想:每个2D图像特征点,对应3D空间中一条射线上的多个候选点。网络需要学习每个候选点的“存在概率”。

4.2 深度分布估计——给每个像素一个“深度概率”

具体怎么做呢?我们来看一下网络结构。

假设输入图像经过一个2D backbone(比如ResNet、EfficientNet),输出一个特征图,尺寸是 H x W x C。接下来,LSS会在这个特征图后面接一个深度预测分支。

这个分支的输出是什么?是一个 H x W x D 的张量,其中 D 是离散化的深度区间个数。比如,我们把0到50米分成41个区间,每个区间1.25米,那么 D=41

嗯,这里要注意:这个 D 不是随便选的。我建议你根据实际场景来定。高速场景需要看得远,区间可以宽一点;城区场景需要近距离精度高,区间可以密一点。

# 伪代码:深度分布估计
def predict_depth_distribution(image_features):
    # image_features: [B, C, H, W]
    # 输出: [B, D, H, W] 的深度概率分布
    
    depth_logits = Conv2D(image_features, out_channels=D)
    depth_probs = softmax(depth_logits, dim=1)  # 沿着深度维度做softmax
    return depth_probs

这里用softmax而不是sigmoid,是因为每个像素在所有深度区间上的概率之和必须为1。说白了,就是让网络在多个深度候选之间做“选择题”。

我的经验:训练初期,深度分布往往非常“平”,也就是每个深度区间的概率都差不多。这时候别慌,这是正常的。随着训练进行,网络会逐渐学会聚焦到正确的深度上。我曾经因为看到初期分布太平而调小了学习率,结果反而拖慢了收敛速度。

4.3 视锥构建——从2D到3D的桥梁

有了深度分布,下一步就是构建视锥(Frustum)。什么是视锥?说白了,就是相机视野范围内的一个锥形空间。

具体操作是这样的:

  1. 对于图像上的每个像素 (u, v),我们知道它在相机坐标系下的射线方向。
  2. 沿着这条射线,在 D 个离散深度位置上各取一个点。
  3. 这样,每个像素就变成了 D 个3D点,整个图像就变成了 H x W x D 个3D点。

这些点构成了一个“视锥点云”。注意,这个点云还不是最终的特征点云,它只是定义了3D位置。

# 伪代码:视锥点云生成
def build_frustum(intrinsics, depth_bins, H, W):
    # intrinsics: 相机内参 [3, 3]
    # depth_bins: 深度区间中心值 [D]
    # 输出: 视锥点云坐标 [H, W, D, 3]
    
    # 生成像素网格
    u_grid, v_grid = meshgrid(range(W), range(H))
    
    # 对每个像素,计算所有深度位置下的3D坐标
    frustum_points = []
    for d in depth_bins:
        # 利用相机内参反投影
        x = (u_grid - cx) * d / fx
        y = (v_grid - cy) * d / fy
        z = d
        frustum_points.append(stack([x, y, z], dim=-1))
    
    return stack(frustum_points, dim=2)  # [H, W, D, 3]

避坑指南:我曾经在构建视锥时忘记考虑图像下采样带来的坐标变化。如果backbone把特征图下采样了4倍,那么像素坐标 (u, v) 也要对应缩放4倍。否则投影到3D空间的位置会完全错位,训练根本收敛不了。

4.4 特征点云生成——把特征“贴”到3D点上

现在,我们有两样东西:

  • 视锥点云:[H, W, D, 3],每个点有3D坐标
  • 深度分布:[H, W, D],每个点有一个“存在概率”
  • 图像特征:[H, W, C],每个像素有C维特征

特征点云的生成,就是把图像特征和深度分布结合起来,赋给每个3D点。

具体公式很简单:

# 特征点云生成
def generate_feature_pointcloud(image_features, depth_probs):
    # image_features: [B, C, H, W]
    # depth_probs: [B, D, H, W]
    # 输出: [B, C, H, W, D] 的特征点云
    
    # 扩展特征维度,与深度维度对齐
    features_expanded = image_features.unsqueeze(2)  # [B, C, 1, H, W]
    features_expanded = features_expanded.expand(-1, -1, D, -1, -1)  # [B, C, D, H, W]
    
    # 深度概率扩展,与特征维度对齐
    depth_probs_expanded = depth_probs.unsqueeze(1)  # [B, 1, D, H, W]
    
    # 外积:特征乘以深度概率
    feature_pointcloud = features_expanded * depth_probs_expanded  # [B, C, D, H, W]
    
    return feature_pointcloud.permute(0, 1, 3, 4, 2)  # [B, C, H, W, D]

你可能会问:为什么要用乘法而不是加法?说白了,这是“软注意力”机制。深度概率大的位置,特征被保留;深度概率小的位置,特征被抑制。这样网络就能自动聚焦到正确的深度上。

关键理解:特征点云中的每个点,都携带了“图像特征 × 深度概率”的信息。这相当于告诉后续的Splat阶段:“我在这个3D位置有这么多特征,而且我有多大概率真的在这里。”

4.5 关于深度区间的设计细节

深度区间的设计直接影响Lift阶段的效果。我整理了一个表格,方便你对比不同方案:

方案 区间数量 区间范围 适用场景 优缺点
均匀分布 41 0~50m,每段1.25m 通用场景 实现简单,但近处精度不够
对数分布 64 近密远疏 高速场景 远处分辨率高,但近处冗余
自适应分布 可变 根据数据动态调整 复杂城区 效果好,但实现复杂

我个人习惯在城区场景用均匀分布,41个区间就够用了。如果你做的是高速NOA,建议用对数分布,因为远处需要更精细的深度区分。

4.6 小结与思考

回顾一下Lift阶段的核心流程:

  1. 从图像特征中预测每个像素的深度分布(D个概率值)
  2. 利用相机内参构建视锥点云(H×W×D个3D点)
  3. 将图像特征与深度概率相乘,生成特征点云

这个阶段最妙的地方在于:它把深度估计从“回归问题”变成了“分类问题”。回归问题容易受噪声影响,而分类问题天然具有概率解释性。我在实际部署时发现,即使深度分布预测得不够精确,只要概率分布的形状大致正确,后续的Splat阶段也能通过累积投票得到不错的BEV特征。

下一章我们会讲Splat阶段——如何把这些散乱的特征点云“拍平”到BEV网格上。到时候你会看到,Lift阶段生成的这些带概率的特征点,是怎么在BEV空间里“投票”的。

嗯,今天就到这里。如果你在实现过程中遇到深度分布不收敛的问题,可以检查一下深度区间的范围是否覆盖了场景中所有物体的距离。我曾经因为只设置了0~30米的区间,结果远处的卡车完全没被检测到——教训深刻啊。