目录

Lift, Splat, Shoot Encoding Images From Arbitrary Camera Rigs by Implicitly Unprojecting to 3D

记录论文《Lift, Splat, Shoot: Encoding Images From Arbitrary Camera Rigs by Implicitly Unprojecting to 3D》阅读过程中的一些思考。

简介

论文获取:arXiv | 网盘备份 | 参考译文

LSS 通过学习逐像素深度概率分布,首次实现了从任意多视角图像到 BEV 表示的端到端可微转换,解决了多相机数据对齐和场景理解中的遮挡问题,是 BEV 感知领域的开山之作。

BEV 感知

LSS 方法的核心是独立地将各相机的图像提升(lift)到各自的特征视锥(frustum of features),然后将这些特征视锥溅射(splat)到一个光栅化(rasterized)鸟瞰图网格(bird's-eye-view grid)中,最后将模板轨迹投射(shoot)到网络输出的鸟瞰代价地图(cost map),这证实了模型学到的密集表示能够支持可解释的端到端运动规划(motion planning)

传统计算机视觉算法的输出通常是坐标系无关的(如分类),或与输入图像一个坐标系的预测(如目标检测语义分割全景分割)。这与自动驾驶中感知模型从来自不同坐标系的多传感器接受输入并处理成一个新的基于自车坐标系的预测输出,以供下游规划模块使用的范式不匹配,参考下图。

传统方式与新方式

有多种简单实用的策略可以将单图像处理范式扩展到多图像处理领域,如先将单图像检测器分别应用到所有输入图像,再结合各自内外参将检测结果转到自车坐标系。这类拓展方法有三个重要对称特性:

  • 平移等变性(Translation equivariance):如果平移图像中所有的像素坐标,那么输出将平移相同的量。全卷积单图像目标检测器基本具备该性质,而多图像扩展版本也继承了这一性质。
  • 置换不变性(Permutation invariance):最终输出与这些相机的顺序无关
  • 自车坐标系等距等变性(Ego-frame isometry equivariance):无论捕获这张图像的相机与主车的相对位置如何,这张图像都会检出同样的目标。换句话说,平移或旋转自车坐标系,那输出也会相应的平移或旋转。

这种简单方法存在的问题是,对单图像检测器使用后处理会阻断自车坐标系下的预测一直回传到传感器输入的微分(梯度)链路。因此,模型无法以数据驱动的方式学习跨相机融合信息的最佳方式。这同样意味着无法基于反向传播利用下游规划器的反馈自动提升感知系统。

而本文提出的方法不仅保留了上述 $3$ 个对称特性,而且满足端到端可微

概念

约定

给定 $n$ 个图像 $\left\{X_k \in \mathbb{R}^{3 \times H \times W}\right\}_n$ ,它们各自有外参 $E_k \in \mathbb{R}^{3 \times 4}$ 和内参 $I_k \in \mathbb{R}^{3 \times 3}$ ,内外参矩阵共同定义了从参考坐标系 $(x,y,z)$ 到局部像素坐标系 $(h,w,d)$ 的映射。本文就是要在 BEV 坐标系 $y \in \mathbb{R}^{C \times X \times Y}$ 内找到场景的光栅化表达。

提升 / Lift:潜在深度分布(Latent Depth Distribution)

Lift 单独将每个相机的图像从(相机)局部坐标系“提升”到所有相机共享的三维坐标系。这一步的难点在于转换到三维坐标系需要深度信息而单目相机图像像素的深度是模糊不定的,为此,Lift 创新地为每个像素生成所有可能深度的表示。

对于其中一个外参为 $E$ ,内参为 $I$ 的图像 $X$ ,其中的像素 $p$ 在像素坐标系下的坐标为 $(h,w)$ 。

首先为每个像素关联 $|D|$ 个点 $\left\{(h,w,d)\in\mathbb{R}^3\,|\,d\in D \right\}$ ,其中 $D$ 是一组定义为 $\left\{d_0+\Delta,\dots,d_0+|D|\Delta\right\}$ 的离散深度。注意,该变换没有可学习的参数,只是为给定图像简单地创建了一个尺寸为 $D\cdot H\cdot W$ 的大点云。这相当于多视图合成(multi-view synthesis)中的多平面图像(multi-plane image, MPI),只是这里每个平面的特征是抽象的(经过 backbone 输出的特征)向量,而不是真正的 $(r,g,b,\alpha)$ 像素值。

点云中每个点的上下文特征向量是被参数化来匹配注意力离散深度推理的概念。网络为每个像素 $p$ 预测一个上下文特征向量 $\boldsymbol{c}\in\mathbb{R}^C$ 和一个深度分布向量 $\alpha\in\Delta^{|D|-1}$ 。而点 $p_d$ 对应的特征向量 $\boldsymbol{c}_d\in\mathbb{R}^C$ 是通过像素 $p$ 共享的上下文向量缩放得到,即 $$\boldsymbol{c}_d = \alpha_d\boldsymbol{c}$$ 网络对深度分布向量 $\alpha$ 的预测结果分两种情形:

  • 独热(one-hot)向量:类似于伪激光雷达,仅在单个深度 $d^*$ 处的上下文特征向量非零
  • (深度上)均匀分布向量:类似于 OFT ,所有深度点处的上下文特征向量全部相同,即与深度无关

因此,网络在理论上能够在将上下文特征向量放在 BEV 表达下的指定位置或者(在深度信息模糊时)将上下文特征向量在整个空间射线中传播之间做出选择。

总的来说,Lift 就是对于每幅图像生成一个函数(映射) $g_c:(x,y,z) \in \mathbb{R}^3 \rightarrow c \in \mathbb{R}^C$ 使得在任意空间位置都可以查询得到一个上下文特征向量,这一过程如下图所示

Lift 步骤
衡准
范例
class BaseTransform(nn.Module):

    def __init__(...):
        ...
        self.frustum = self.create_frustum()
        ...

    @force_fp32()
    def create_frustum(self):
        iH, iW = self.image_size
        fH, fW = self.feature_size

        ds = (
            torch.arange(*self.dbound, dtype=torch.float)  # 按起、终点及步长生成深度序列
            .view(-1, 1, 1)  # 在图像宽、高方向插入维度
            .expand(-1, fH, fW)  # 在宽、高维度广播数据(不复制仅引用)
        )
        D, _, _ = ds.shape

        xs = (
            torch.linspace(0, iW - 1, fW, dtype=torch.float)
            .view(1, 1, fW)
            .expand(D, fH, fW)
        )
        ys = (
            torch.linspace(0, iH - 1, fH, dtype=torch.float)
            .view(1, fH, 1)
            .expand(D, fH, fW)
        )

        frustum = torch.stack((xs, ys, ds), -1)  # 堆叠为 D x H x W x 3
        return nn.Parameter(frustum, requires_grad=False)

    @force_fp32()
    def get_geometry(
        self,
        camera2lidar_rots,
        camera2lidar_trans,
        intrins,
        post_rots,
        post_trans,
        **kwargs,
    ):
        B, N, _ = camera2lidar_trans.shape

        # undo post-transformation
        ## undo translation
        ## 此处存在维度广播:(D, H, W, 3) - (B, N, 1, 1, 1, 3) -> (B, N, D, H, W, 3)
        points = self.frustum - post_trans.view(B, N, 1, 1, 1, 3)
        ## undo rotation
        ## 使用 unsqueeze 在最后插入新维度以右对齐,再做矩阵乘法
        ## (B, N, 1, 1, 1, 3, 3) x (B, N, D, H, W, 3, 1) -> (B, N, D, H, W, 3, 1)
        points = (
            torch.inverse(post_rots)
            .view(B, N, 1, 1, 1, 3, 3)
            .matmul(points.unsqueeze(-1))
        )
        # cam_to_lidar
        # formula reference: https://review.yirami.xyz/academic/camera_calibration/#4-%E5%9D%90%E6%A0%87%E7%B3%BB%E8%BD%AC%E6%8D%A2
        points = torch.cat(
            (
                points[:, :, :, :, :, :2] * points[:, :, :, :, :, 2:3],
                points[:, :, :, :, :, 2:3],
            ),
            5,
        )  # (B, N, D, H, W, 3, 1)
        combine = camera2lidar_rots.matmul(torch.inverse(intrins))  # (B, N, 3, 3) x (B, N, 3, 3)
        points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1)  # (B, N, 1, 1, 1, 3, 3) x (B, N, D, H, W, 3, 1) -> (B, N, D, H, W, 3)
        points += camera2lidar_trans.view(B, N, 1, 1, 1, 3)  # (B, N, D, H, W, 3)

        # apply lidar augmentation transform
        ## rotation
        if "extra_rots" in kwargs:
            extra_rots = kwargs["extra_rots"]
            points = (
                extra_rots.view(B, 1, 1, 1, 1, 3, 3)
                .repeat(1, N, 1, 1, 1, 1, 1)
                .matmul(points.unsqueeze(-1))
                .squeeze(-1)
            )
        ## translation
        if "extra_trans" in kwargs:
            extra_trans = kwargs["extra_trans"]
            points += extra_trans.view(B, 1, 1, 1, 1, 3).repeat(1, N, 1, 1, 1, 1)

        return points  # (B, N, D, H, W, 3)

    @force_fp32()
    def forward(...):
        ...
        # rots = camera2ego[..., :3, :3]
        # trans = camera2ego[..., :3, 3]
        intrins = camera_intrinsics[..., :3, :3]
        post_rots = img_aug_matrix[..., :3, :3]
        post_trans = img_aug_matrix[..., :3, 3]
        # lidar2ego_rots = lidar2ego[..., :3, :3]
        # lidar2ego_trans = lidar2ego[..., :3, 3]
        camera2lidar_rots = camera2lidar[..., :3, :3]
        camera2lidar_trans = camera2lidar[..., :3, 3]

        extra_rots = lidar_aug_matrix[..., :3, :3]
        extra_trans = lidar_aug_matrix[..., :3, 3]

        geom = self.get_geometry(
            camera2lidar_rots,
            camera2lidar_trans,
            intrins,
            post_rots,
            post_trans,
            extra_rots=extra_rots,
            extra_trans=extra_trans,
        )  # (B, N, D, H, W, 3)

@VTRANSFORMS.register_module()
class LSSTransform(BaseTransform):

    def __init__(...):
        super().__init__(...)
        # 此处使用同一卷积网络将 D 和 C 合并输出,即
        # ... 分别取前 D 个通道为深度分布预测,后 C 个通道为特征预测,是为了
        # ... 让深度预测和特征预测进行共享和交互,使得
        # 1. 某个区域的特征有助于预测深度
        # 2. 深度信息反过来能改善特征表示
        # 此外,从使用上看,学习深度分布是为了**加权**特征,不是独立使用的估计
        self.depthnet = nn.Conv2d(in_channels, self.D + self.C, 1)
        ...

    @force_fp32()
    def get_cam_feats(self, x):
        B, N, C, fH, fW = x.shape

        # 将 B, N 看作一个维度,因为后续 nn.Conv2d() 的 `Tensor` 参数只接受 4 维
        #
        # nn.Conv2d() 在语义上是对输入 (B, C, H, W) 在
        # ... 1. 在空间维度 (H, W) 上应用相同的卷积核
        # ... 2. 在样本维度 (C, H, W) 上应用相同的参数
        # 此处将 B 和 N 看作一体,符合语义
        x = x.view(B * N, C, fH, fW)

        # nn.Module 中在 __call__() 中调用了 self.forward()
        x = self.depthnet(x)  # (B x N, D + C, H, W)
        # do softmax for depth prediction
        depth = x[:, : self.D].softmax(dim=1)  # (B x N, D, H, W)
        # calculate the weighted features
        x = depth.unsqueeze(1) * x[:, self.D : (self.D + self.C)].unsqueeze(2)  # (B x N, 1, D, H, W) x (B x N, C, 1, H, W) -> (B x N, C, D, H, W)

        x = x.view(B, N, self.C, self.D, fH, fW)  # (B, N, C, D, H, W)
        x = x.permute(0, 1, 3, 4, 5, 2)  # (B, N, D, H, W, C)
        return x
探微

Q1: 深度分布 $\alpha\in\Delta^{|D|-1}$ 的含义是什么?

A:记号 $\Delta^{|D|-1}$ 是数学中的概率单纯形(Probability Simplex),是指 $|D|$ 维空间中,所有分量非负且分量之和为 $1$ 的向量集合,即 $$\Delta^{|D|-1} = \left\{ \alpha \in \mathbb{R}^{|D|} \;\middle|\; \alpha_d \geq 0,\; \sum_{d=1}^{|D|} \alpha_d = 1 \right\}$$ 如 $|D|=41$ ,则 $\alpha$ 为一个 $41$ 维向量,其通常是一个由 softmax 得到的表示该像素在 $41$ 个离散深度值上的概率分布。

Q2: 深度点 $p_d$ 的特征向量 $\boldsymbol{c}_d = \alpha_d\boldsymbol{c}$ 有什么含义?

A:与像素 $p$ 对应的指定深度的点 $p_d$ 通过概率 $\alpha_d$ 加权该像素共享的上下文特征向量,通过学习,让真实深度处点的特征向量趋近上下文特征向量 $\boldsymbol{c}$ ,从而将特征“放”在了正确的三维位置。

Q3: LSS 是如何在有限的 $|D|$ 上平衡深度的范围与精度?

从 $D$ 的集合定义 $\left\{d_0+\Delta,\dots,d_0+|D|\Delta\right\}$ 来看,其对超出范围的目标是截断的,即直接忽略。通过调整 $\Delta$ 的大小可以在范围和精度间取舍,通过调整 $D$ 的大小可以同时扩展范围和精度。

LSS 中的 $D$ 属于均匀间隔,具有明显的局限,后续有一些工作做了改进:

  • 非均匀深度采样:近处密、远处疏(符合透视投影的特性——近处的深度精度比远处更重要)
  • BEVDepth:引入显式深度监督(用 LiDAR 点云作为深度真值),让深度分布预测更准确,缓解了有限 $|D|$ 下的精度问题
  • 自适应深度范围:根据场景动态调整 $d_0$ 和 $\Delta$
溅射 / Splat:柱状池化(Pillar Pooling)

Splat 这步借鉴 pointpillars 架构把 Lift 中得到的三维点云转换到 BEV 平面。首先,在预定的 BEV 视角范围内按照精度划分出一个个小网格,它们具有无穷的高度,也被称为体素(Voxel)。然后将 Lift 得到的三维点云按就近原则分配到各自的柱状体素中,并在体素范围内对点的特征进行求和池化(sum pooling),得到一个尺寸 $C\times X\times Y$ 的张量。最后对其施加标准卷积(CNN)操作进行推理,得到 BEV 下的特征图以用于语义分割规划

整个 Lift-Splat 架构流程如下图所示

Lift-Splat 流程
衡准
探微

Q1: Splat 过程中分配三维点到最近的 pillar 的操作似乎不可微,为什么 LSS 模型还是端到端可微?

A:决定这个分配的参数,包括像素坐标 $(h,w)$ ,离散深度 $d$ ,相机内参 $I$ ,相机外参 $E$ 这些都是不可学习的参数,因此哪个三维点落入哪个 pillar前向传播前就确定好的静态映射,无需参与梯度计算。

可将该映射定义为一个稀疏指派矩阵 $A\in\{0,1\}^{P\times N}$ ,其中 $P$ 为 pillar 数,而 $N$ 为总点数,因而 $A_{p,n} = 1$ 就表示第 $n$ 个点属于第 $p$ 个 pillar ,它是一个常量矩阵。

因此,柱状池化过程亦可以用矩阵形式表达 $$\text{BEV} = A \cdot F$$ 其中, $F\in\mathbb{R}^{N\times C}$ 是所有点的特征矩阵。

反向传播时,结合其逐元素展开形式 $$\text{BEV}_{p,c} = \sum_n A_{p,n}\, F_{n,c}$$ 不难得出 $$\frac{\partial \mathcal{L}}{\partial F_{n,c}} = \sum_p \frac{\partial \mathcal{L}}{\partial \text{BEV}_{p,c}} \cdot \frac{\partial \text{BEV}_{p,c}}{\partial F_{n,c}} = \sum_p A_{p,n} \cdot \frac{\partial \mathcal{L}}{\partial \text{BEV}_{p,c}}$$ 写成矩阵形式,即为 $$\frac{\partial \mathcal{L}}{\partial F} = A^\top \cdot \frac{\partial \mathcal{L}}{\partial \text{BEV}}$$

投射 / Shoot:运动规划(Motion Planning)

Shoot 这步的核心是从纯视觉输入端到端地学到代价地图(cost map),从而实现可解释的运动规划

文中将规划(planning)定义为:在给定传感器观测 $o$ 的条件下,预测主车在 $K$ 个模板轨迹上的概率分布 $p(\tau|o)$ ,其中模板轨迹集合为 $$\mathcal{T} = \{\tau_i\}_K = \{\{x_j, y_j, t_j\}_T\}_K$$ 为了利用代价地图的空间结构,文中将模板轨迹上的分布定义为如下的 Boltzmann 分布形式 $$p(\tau_i|o) = \frac{\exp\left(-\displaystyle\sum_{x_i,y_i \in \tau_i} c_o(x_i, y_i)\right)}{\displaystyle\sum_{\tau \in \mathcal{T}} \exp\left(-\displaystyle\sum_{x_i,y_i \in \tau} c_o(x_i, y_i)\right)}$$ 其中 $c_o(x,y)$ 是网络根据观测 $o$ 预测的代价地图在位置 $(x,y)$ 处的值。每条轨迹的代价即为其路径经过的所有位置的代价之和,代价越低的轨迹概率越高。

训练时,给定真值轨迹,先计算其与模板轨迹集合 $\mathcal{T}$ 中各模板的 $L_2$ 距离并取最近邻作为标签,然后用交叉熵损失优化专家轨迹的对数概率。在推理时,通过将不同的模板轨迹投射代价地图上评分,选择代价最低的轨迹作为输出。

实际应用中,模板轨迹集合通过对大量专家轨迹运行 K-Means 聚类获得,如下图所示

投射轨迹模板
衡准
探微

Q1: 为什么用 Boltzmann 分布而非直接回归轨迹?

A:Boltzmann 分布将规划问题转化为在模板轨迹上的分类问题,其优势在于:

  • 学到的 $c_o(x,y)$ 是一个可解释的空间代价函数,可以直观地可视化哪些区域是高代价的(如障碍物、道路边界)
  • 相比 NMP 中的硬间距(hard-margin)损失,Boltzmann 分布形式的交叉熵损失更加稳定
  • 轨迹的评分完全由空间代价决定,模型天然具备对新轨迹模板的泛化能力

Q2: 模板轨迹集合 $\mathcal{T}$ 的设计有什么考量?

A:论文中使用 $K=1000$ 个模板,每条轨迹长 $5$ 秒、以 $0.25$ 秒为间隔采样路径点。模板通过对 nuScenes 训练集中所有自车轨迹做 K-Means 聚类获得,因此能覆盖直行、转弯、变道、停车等常见驾驶模式。

思路

总结