目录

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 流程
衡准
范例
def gen_dx_bx(xbound, ybound, zbound):
    # delta
    dx = torch.Tensor([row[2] for row in [xbound, ybound, zbound]])
    # base ?: center of bound
    bx = torch.Tensor([row[0] + row[2] / 2.0 for row in [xbound, ybound, zbound]])
    # number
    nx = torch.LongTensor(
        [(row[1] - row[0]) / row[2] for row in [xbound, ybound, zbound]]
    )
    return dx, bx, nx

class QuickCumsum(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x, geom_feats, ranks):
        # 在 Nprime 轴累积求和
        x = x.cumsum(0)
        # 标记各组最后一个元素的索引,并作为后续数据保留依据
        # 判断后一索引的 rank 与当前索引的 rank 相比是否跳变
        # ... 标记跳变处为 1 ,且最后一个值始终保留初始值 1
        kept = torch.ones(x.shape[0], device=x.device, dtype=torch.bool)
        kept[:-1] = ranks[1:] != ranks[:-1]

        # 按各组最后一个元素的索引取累积特征和相应坐标
        x, geom_feats = x[kept], geom_feats[kept]
        # 排除累积的前序组的特征
        x = torch.cat((x[:1], x[1:] - x[:-1]))

        # save kept for backward
        ctx.save_for_backward(kept)

        # no gradient for geom_feats
        ctx.mark_non_differentiable(geom_feats)

        return x, geom_feats

    @staticmethod
    def backward(ctx, gradx, gradgeom):  # PyTorch autograd 要求输入参数匹配 forward 的输出参数
        (kept,) = ctx.saved_tensors
        # 从跳变索引恢复出原始分组信息
        back = torch.cumsum(kept, 0)  # [0, 0, 1, 0, 1, 1] -> [0, 0, 1, 1, 2, 3]
        back[kept] -= 1  # [0, 0, 1, 1, 2, 3] -> [0, 0, 0, 1, 1, 2]

        # 因为 forward 中是组内求和,而 $\frac{\partial \text{sum}}{\partial x_i} = 1$
        # ... 所以 backward 中每个元素的梯度就是对应组的输出梯度
        val = gradx[back]

        return val, None, None  # 输出参数匹配 forward 的输入参数,不需要梯度的参数返回 None
        #      ↑x   ↑geom ↑ranks

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, geom_feats = QuickCumsum.apply(feats, coords, ranks)

    # 创建空的特征网格数据
    C = x.shape[1]
    final = torch.zeros((B, C, D, H, W), device=x.device)
    # 将聚合后的特征按照索引赋到网格(高级索引赋值)
    # 此处切片作为左值是直接写回地址
    final[geom_feats[:, 3], :, geom_feats[:, 2], geom_feats[:, 0], geom_feats[:, 1]] = x

    return final  # (B, C, D, H, W)

class BaseTransform(nn.Module):

    def __init__(...):
        ...
        dx, bx, nx = gen_dx_bx(self.xbound, self.ybound, self.zbound)
        self.dx = nn.Parameter(dx, requires_grad=False)
        self.bx = nn.Parameter(bx, requires_grad=False)
        self.nx = nn.Parameter(nx, requires_grad=False)
        ...

    @force_fp32()
    def bev_pool(self, geom_feats, x):
        B, N, D, H, W, C = x.shape
        Nprime = B * N * D * H * W

        # flatten x
        x = x.reshape(Nprime, C)  # (B x N x D x H x W, C)

        # flatten indices
        ## 将 geom_feats 的三维坐标转到三维网格中得到索引,并使用 long() 将其长整化
        geom_feats = ((geom_feats - (self.bx - self.dx / 2.0)) / self.dx).long()
        geom_feats = geom_feats.view(Nprime, 3)  # (B x N x D x H x W, 3)
        ## 为各 Batch 的数据创建 Batch 索引
        batch_ix = torch.cat(
            [
                torch.full([Nprime // B, 1], ix, device=x.device, dtype=torch.long)
                for ix in range(B)
            ]
        )  # (B x N x D x H x W, 1)
        geom_feats = torch.cat((geom_feats, batch_ix), 1)  # (B x N x D x H x W, 4)

        # filter out points that are outside box
        kept = (
            (geom_feats[:, 0] >= 0)
            & (geom_feats[:, 0] < self.nx[0])
            & (geom_feats[:, 1] >= 0)
            & (geom_feats[:, 1] < self.nx[1])
            & (geom_feats[:, 2] >= 0)
            & (geom_feats[:, 2] < self.nx[2])
        )
        x = x[kept]
        geom_feats = geom_feats[kept]

        x = bev_pool(x, geom_feats, B, self.nx[2], self.nx[0], self.nx[1])  # (B, C, D, H, W)

        # collapse Z
        # 按 D 维展开,并在 C 维上拼接(同一深度的通道相邻)
        # 等价于 permute(0, 2, 1, 3, 4).contiguous().view(B, -1, H, W)
        # 等效于 view(B, -1, H, W) ,下游可自动适应任意通道排序
        final = torch.cat(x.unbind(dim=2), 1)

        return final

    @force_fp32()
    def forward(...):
        ...
        x = self.bev_pool(geom, x)
        ...
探微

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}}$$ Q2: Splat 的实现范例ranks 的映射设计有什么技巧?

求和池化的关键步骤是将同一网格中的所有特征求和,这要求快速聚合同一组内的特征点。有几种思路:

  • groupby 风格的先分组,后求和方案
  • add 方式直接根据所属网格累加
  • rank+cumsum 方式先排序后累加
方案思路优势劣势
groupby基于哈希将数据分组,然后组内操作
pandas 中使用较多
可读性较好随机访问,不利于 GPU 部署
add预分配累加空间,如 $(B,H,W,D)$
根据坐标直接在该空间内累加特征
直观、易于理解同一地址上的累加无法并行,需加锁
累加顺序不同可能产生浮点舍入差异
无特征的网格也会占用内存,浪费空间
数据在内存中不连续,访问效率低
rank+cumsum将坐标单射生成 rank ,并基于此排序
使用 cumsum 求和
并以 rank 跳变处的结果增量为组内和
重排后内存连续
顺序确定,累加结果一致
仅操作存在数据的网格
存在分组并行求和的基础
经典实现中并行度不够
增加排序和重排内存步骤

本文中使用上述 rank+cumsum 方案,即先将坐标单射到一维实数空间,生成 rank ,再基于该 rank 排序后重排内存。

四维坐标 $(x,y,z,idx)$ 的各维度取值范围为:$x \in [0, H), y \in [0, W), z \in [0, D), idx \in [0, B)$ ,需要构建一个单射将该四维坐标集合转到一维实数集。

可以参考进制设计,如 $v = x \times 10^2 + y \times 10^1 + z \times 10^0$ ,只要 $x,y,z$ 取值小于基数(Radix)(即 $10$ ),通过位权(Place Value)可以将不同维度的信息“调制”到一起,且互不影响。

最简单的思路是设计一个足够大的进制,比如取 $N=max(H,W,D,B)$ ,但以 $N$ 为基数显然对数值空间存在巨大浪费,在实践中也更容易越界,因此混合进制(Mixed Radix)更为合适。

对上述四维坐标,定义映射 $v=x \times (W \cdot D \cdot B) + y \times (D \cdot B) + z \times B + idx \times 1$ 。对于坐标 $(x_1,y_1,z_1,idx_1)$ 和 $(x_2,y_2,z_2,idx_2)$ ,可以证明:

若 $$x_1(W \cdot D \cdot B) + y_1(D \cdot B) + z_1 \cdot B + idx_1 = x_2(W \cdot D \cdot B) + y_2(D \cdot B) + z_2 \cdot B + idx_2$$ 则整理得 $$(x_1 - x_2)(W \cdot D \cdot B) + (y_1 - y_2)(D \cdot B) + (z_1 - z_2) \cdot B + (idx_1 - idx_2) = 0$$ 若 $x_1 \neq x_2$ ,则 $|(x_1 - x_2)(W \cdot D \cdot B)| \in \left\{W \cdot D \cdot B,2 \cdot W \cdot D \cdot B,\dots,(H-1) \cdot W \cdot D \cdot B\right\}$ ,其最小值为 $W \cdot D \cdot B$ ,而其余项的最大值为 $(W-1)(D \cdot B) + (D-1) \cdot B + (B-1) = W \cdot D \cdot B - 1$ 。可见其余项无法“淹没”该项,余下诸项递推可证。

因此,只需确保各位位权为低位所有基数的乘积,则该混合进制可以将有限多维数据单射到一维。

进一步思考,是否依据各维的大小调整权重可以使得映射值的最大值更小?或者说映射的数据更密集?

投射 / 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 聚类获得,因此能覆盖直行、转弯、变道、停车等常见驾驶模式。

思路

总结