LSS原理及代码解析

前言

BEV感知的核心思想是将多路传感器的感知数据转换到统一的BEV空间去提取特征,实现目标检测、语义分割、地图构建等任务,对于相机感知模块,转向BEV空间可带来很大的收益,主要体现在三个方面:

  1. BEV空间中的感知结果更易被下游使用,如预测和规划,在BEV中做相机感知,可以直接与雷达或激光雷达等感知结果结合,因为其他模块已经在使用BEV表示。
  2. BEV表示有助于过渡到传感器前融合流程,使融合过程完全由数据驱动,而纯粹依靠手工规则将2D观测升维到3D则不可扩展。
  3. 在相机重叠区域,目标可能被相机视角裁剪。Mono3D方法必须根据每个相机视点的有限信息来预测每个摄像机中的裁剪对象,并依靠后处理来抑制冗余框,BEV感知则不存在这个问题。

目前BEV感知主要出现了3类视图转换模块的方案:IPM(Inverse Perspective Mapping)、Lift-splat和Transformer。本文主要对基于Lift-splat(后文简称LS或LSS)的方案进行解析。LSS是英伟达(NVIDIA)在ECCV2020上发表的文章(Lift,Splat,Shoot:Encoding Images from Arbitrary Camera Rigs by Implicitly Unprojecting to 3D),后续很多BEV感知算法都是在LSS的基础上进行改进。

图1. 基于LSS方法的BEV论文时间图

核心思想

文章作者提出了一种端到端架构,该架构可以从任意数量的相机中直接提取给定图像数据场景中的BEV表示,其核心思想是将每张图像单独“提升(Lift)”到每个相机的视椎体中,然后将所有视椎体“溅射(Splat)”到格栅化的BEV网络中。最后一个S表示“Shoot”,该环节与运动规划有关而与感知无关,在此不作介绍。

图2. LSS总体流程图

Lift层

对于单目相机而言,一个关键的问题是如何恢复图像中的深度信息。2D图像中的每个像素点可以理解为世界空间中某点到相机中心的一条射线,仅通过图像无法确定此像素点具体来自射线上哪个位置,因为它有可能在射线上的任意位置,如图3中的P点,投影在像素平面后,丢失了深度信息。

图3. 小孔成像模型

这里说一下我个人的理解,对于人眼而言,估计物体的远近似乎并不是一件困难的事情,通过观察事物,我们可以轻而易举地了解物体的远近,但是在某些情况下也不尽然,比如在一些视觉错位图中,我们似乎也无法立刻分辨出物体的远近和尺寸,比如下面这些错觉图。

网络上的知名梗:“道理我都懂,但是鸽子为什么这么大

图4. 鸽子为什么这么大?

亦或者知名的比萨斜塔拍照姿势。究竟是斜塔近在面前?还是远处有个巨人?

图5. 比萨斜塔视觉错觉图

乍一看,我们的眼睛都被欺骗了!在这些特殊的情况下,即使是人眼也会给大脑传递出错误的信息,而需要常识判断才能纠正,那么对于AI而言更是困难。但是话又说回来了,在大部分情况下,人眼还是很可靠的,那我们又是如何获取物体的位置尺寸信息的呢?答案就是通过大量的数据训练(人眼每天都在接触外界传递的信息),人眼可以对常规的事物建立起判断的经验,也就是估计深度的能力,比如人们在学习开车的过程中,新手司机对于距离的判断往往不如老司机来的准确,也是由于训练数据不足导致估计深度的能力欠缺导致的。

说回到Lift层,论文作者的做法是,既然图像像素上的点可能对应了世界空间中这条射线上的任意一个点,那么我们就在这条射线上(也就是沿着深度方向)均匀离散地取一系列的点,然后预测这一系列点的概率分布,如果某个点的预测概率最高,那么我们就认为该点所对应的深度就是这个像素点的深度。我们将3*H*W的n张图像送入到常规的2D卷积网络(backbone+neck)中提取特征,得到了一系列的特征图,将得到的特征图与上面提到的概率分布相乘,就得到了带深度信息的特征图表示,即得到了C*D*H*W的点云特征。

图6. “Lift”过程

有了C*D*H*W的点云特征后,我们可以通过内参矩阵将图像域下的点云特征转换到各个相机的相机坐标系下,LSS中定义的深度范围为4m到44m,间隔为1m,这样每个图像特征点有D=41个可能的离散深度值,在相机域下,点云特征的形状类似于截锥体,因此我们称之为截锥点云

图7. 视椎点云示意图

有了各个相机的截锥点云后,再通过外参转换到自车坐标系下,就得到了在一个相同空间下的点云特征,下一步就是如何将各个相机的点云特征融合为一个统一的BEV特征。

图8. BEV空间下的点云

Splat层

有了视椎点云(包含空间位置和特征),就可以根据视椎点云的空间位置把每个视椎点的特征放回到BEV网格中合适的位置,组成BEV特征图。

BEV网格由200*200个格子组成,每个格子对应的物理尺寸为0.5m*0.5m。即BEV网格对应车辆前后左右各50m。

上面通过相机的内外参,已经将视椎点云转换到了自车坐标系下的空间位置,过滤掉BEV网格范围外的点,就可以将剩余有效的视椎点云分配到每个BEV格子中。

注意,在同一个BEV格子中可能会分配超过一个视椎点的情况,这是由两个原因引起的:

  1. 单张2D图像不同像素点可能投影在BEV中的同一个位置,例如垂直于地面的电线杆,它在图像中的多个像素都可能被投到同一个BEV格子中。
  2. 相邻两个相机有部分成像区域重叠(如上图所示),相机图像中的不同像素点会投影到同一个BEV格子中,例如不同相机画面中的同一个目标。

在LSS中,作者使用了sum-pooling的方法(该方法与pointpillars算法原理相同),将同一个BEV格子中的特征直接相加,产生一个可以被标准2D卷积处理的C*H*W的张量,至此,我们最终得到了一个BEV空间下的特征,有了这个特征,后续可能再接分割或者检测的head来进行相应的任务输出。

图9. BEV特征图可视化

为了提高效率,LSS采用了“累计求和”的方法来实现求和(CumSum),累计求和的具体实现过程如下所示:

图10. CumSum原理示意图

LSS代码解析

LSS模型前向推理的大致流程如下图所示:

图11. LSS前向推理流程图

create_frustum

该函数的作用是在图像域建立一个维度为D*H*W的点云,用于后续的计算,如下图的左边所示。

图12. 点云生成示意图

def create_frustum():
    # 原始图片大小  ogfH:128  ogfW:352
    ogfH, ogfW = self.data_aug_conf['final_dim']
    # 下采样16倍后图像大小  fH: 8  fW: 22
    fH, fW = ogfH // self.downsample, ogfW // self.downsample 
    # self.grid_conf['dbound'] = [4, 45, 1]
    # 在深度方向上划分网格 ds: DxfHxfW (41x8x22),ds首先是4,5,6...44的41维向量,变形成41*fH*fW,每一个fH*fW的深度值相同。
    ds = torch.arange(*self.grid_conf['dbound'], dtype=torch.float).view(-1, 1, 1).expand(-1, fH, fW)
    D, _, _ = ds.shape # D: 41 表示深度方向上网格的数量
    # 在0到351上划分22个格子 xs: DxfHxfW(41x8x22)
# 生成fH*fW feature map上每个点的坐标,注意个数是fW或fH个,但是值的范围是ogfW和ogfH以内,都是以图像为参照。
    xs = torch.linspace(0, ogfW - 1, fW, dtype=torch.float).view(1, 1, fW).expand(D, fH, fW) 
    # 在0到127上划分8个格子 ys: DxfHxfW(41x8x22)
    ys = torch.linspace(0, ogfH - 1, fH, dtype=torch.float).view(1, fH, 1).expand(D, fH, fW)  
    # D x H x W x 3
    # 拼合后,固定第一维D后,H*W*3的每个值,就表示每个位置上的xyd,只要固定D这一维,d值就被固定。
    frustum = torch.stack((xs, ys, ds), -1)  
    return nn.Parameter(frustum, requires_grad=False)

get_geometry

该函数的作用是将图像域下的点云坐标点转换到自车坐标系下,首先对于self.frustum,用post_rots,post_trans进行逆变换,因为输入图像经过了图像增强,而我们是对增强后的图像进行推理,因此需要进行逆变换来还原回原始图像空间,否则就不能使用内参矩阵,至于逆变换的旋转矩阵和平移矩阵的推导可参考这篇博文:

Lift-splat-shot算法中图像增强对应的旋转矩阵和平移向量计算

在得到对应原始图像空间的点后,将它们映射回3D空间,对于nuscence数据,LSS算法选择ego坐标系。

def get_geometry(self, rots, trans, intrins, post_rots, post_trans):
    B, N, _ = trans.shape  # B: batch size N:环视相机个数
    # undo post-transformation
    # B x N x D x H x W x 3
    # 抵消数据增强及预处理对像素的变化
    points = self.frustum - post_trans.view(B, N, 1, 1, 1, 3)
    points = torch.inverse(post_rots).view(B, N, 1, 1, 1, 3, 3).matmul(points.unsqueeze(-1))
    points = torch.cat((points[:, :, :, :, :, :2] * points[:, :, :, :, :, 2:3],points[:, :, :, :, :, 2:3]), 5)  # 将公式中的Z乘上uv,拼上深度               
    combine = rots.matmul(torch.inverse(intrins))
    points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1) # 先左乘内参的逆,再左乘rots,将camera下的坐标转到ego坐标下,最后一维没用了,去掉
    points += trans.view(B, N, 1, 1, 1, 3)
    # (bs, N, depth, H, W, 3):其物理含义
    # 每个batch中的每个环视相机图像特征点,其在不同深度下位置对应
    # 在ego坐标系下的坐标
    return points

获取特征图

在get_depth_feat函数中,通过get_eff_depth得到图像的特征图,输入的尺寸为B*N*3*H*W,得到B*N*512*H*W尺寸的特征图。

调用depthnet,其中只有一层卷积,将512维变为D+C维,其中D是41,即上面的深度个数,C是特征维度,64。将该特征图按通道维度分为两部分,depth为前41个通道,进行softmax,后64通道就是图像得到的特征,将两者相乘,得到新的new_x,维度为B*N*64*41*H*W,相当于41个深度对应的概率来加权特征,代表处于每种可能的深度时,对应的经过加权的64维特征。

voxel_pooling

接下来,得到了点云特征后,就需要进行“Splat”操作,代码中对应的函数为voxel_pooling。首先将ego坐标系下的坐标转换成BEV坐标系,BEV坐标系与ego坐标系之间的关系如下图所示:

图13. BEV坐标系及自车坐标系

这里实际上进行了一个z维度拍平的操作,不同高度的z方向上的点都被压缩到了一个高度下,即转换为BEV坐标系后,z方向上的坐标都变成了0。

batch_idx是为了区分来自不同sample的数据,一个sample中有N*D*H*W个样本,一个batch内有Nprime=B*N*D*H*W个点,相当于有batch_size长的段,每一段长N*D*H*W。

之后过滤掉坐标点不在BEV空间200*200*1范围内的点,并去掉对应的特征x,接下来,就要对落在相同格子中的点就行累加求和的操作。其原理可参考上面的图,具体的代码在cumsum_trick函数中。至此,我们最终就得到了BEV尺度的特征图,B*64*200*200。

def voxel_pooling(self, geom_feats, x):
    B, N, D, H, W, C = x.shape
    Nprime = B*N*D*H*W
    # 将特征点云展平,共有B*N*D*H*W个点,每个点包含C维特征向量
    x = x.reshape(Nprime, C)
    # 把自车坐标系下的坐标转换为体素坐标,然后展平
    geom_feats = ((geom_feats - (self.bx - self.dx/2.)) / self.dx).long()
    geom_feats = geom_feats.view(Nprime, 3)
    # 求每个点对应的batch size
    batch_ix = torch.cat([torch.full([Nprime//B, 1], ix, device=x.device, dtype=torch.long) for ix in range(B)])
    geom_feats = torch.cat((geom_feats, batch_ix), 1)
    # 过滤点范围外的点
    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]
    # 求每个点对应的体素索引,并根据索引进行排序
    ranks = geom_feats[:, 0] * (self.nx[1] * self.nx[2] * B)\
        + geom_feats[:, 1] * (self.nx[2] * B)\
        + geom_feats[:, 2] * B\
        + geom_feats[:, 3]
    sorts = ranks.argsort()
    x, geom_feats, ranks = x[sorts], geom_feats[sorts], ranks[sorts]
    # 累计求和,对体素中的点进行求和池化
    if not self.use_quickcumsum:
        x, geom_feats = cumsum_trick(x, geom_feats, ranks)
    else:
        x, geom_feats = QuickCumsum.apply(x, geom_feats, ranks)
    # final:(B x C x Z x X x Y),(1 x 64 x 1 x 200 x 200)
    final = torch.zeros((B, C, self.nx[2], self.nx[0], self.nx[1]), device=x.device)
    # 把特征赋给对应的体素中
    final[geom_feats[:, 3], :, geom_feats[:, 2], geom_feats[:, 0], geom_feats[:, 1]] = x
    # 去掉Z维度
    final = torch.cat(final.unbind(dim=2), 1)
    # final:(1,64,200,200)
    return final

图14. BEV分割图

地平线LSS方法解析

总体前向流程

地平线的LSS代码是基于bev-depth结构实现的,使用多视图的6张RGB图像作为输入,输出是目标的3D Box和BEV分割结果。首先使用2D主干网络对多视角图像获取2D 特征。然后将img_feature作为depth_net输入获得深度特征,将深度特征和img_encoder_feature 分别转换为BEV视角后生成点云特征,最后,接上任务特定的head,输出多任务结果。模型主要包括以下部分:

Part1—2D Image Encoder:图像特征提取层。使用2D主干网络(efficientnet)和FastSCNN输出不同分辨率的特征图。返回最后一层--下采样至1/16原图大小层,用于下一步投影至3D 坐标系中。

Part2—View transformer:将img_encoder_feature生成深度特征,将深度特征和img_encoder_feature 做bev 空间转换后生成视锥点云特征。

Part3—Bev transforms:对bev特征做数据增强,仅发生在训练阶段。

Part4—3D BEV Encoder:BEV特征提取层。使用2D主干网络(efficientnet)和BiFPN

Part5—BEV Decoder:分为Detection Head和Segmentation Head。得到统一的BEV特征后,使用DepthwiseSeparableFCNHead进行bev分割,分割种类为["others", "divider", "ped_crossing", "Boundary"]。使用DepthwiseSeparableCenterPointHead进行3D目标检测任务,检测的类别为["car", "truck", "bus", "barrier", "bicycle", "pedestrian"]

模型结构如下图所示,与原版的LSS相比,最大的不同是在View transformer部分,该模块的投影过程与LSS正好相反,LSS首先在图像域生成参考点,然后将参考点投影到BEV坐标系,在BEV坐标系下提取图像特征并进行融合,而地平线的LSS则首先在BEV坐标系下生成参考点,然后将参考点投影回图像域,在图像域下提取图像特征。本质上都是从图像特征图中取提取特征,但是点云的生成存在较大差异,因此本文仅对该模块进行解析。

图15. 地平线LSS前向推理流程图

_extract

模型输入view transformer模块的特征图维度为[6, 64, 16, 44] [B*N, C, H, W],首先,该特征图会分别生成深度特征和图像特征,depth_net和feat_net均为2D卷积,深度特征在经过depth_net后会进行softmax操作,这样就得到了不同距离下的深度概率分布。

def _extract(
        self, feats: torch.tensor
    ) -> Tuple[torch.tensor, torch.tensor]:
        new_feats = []
        depth = self.softmax(self.depth_net(feats))
        new_feats = self.feat_net(feats)
        return new_feats, depth 

_get_homography

接下来需要生成参考点,通过_get_homography函数我们可以得到从自车坐标系到像素坐标系的转换矩阵,该转换矩阵是在dataset中完成的,相当于融合了内外参,在该函数中,只是根据图像的缩放系数进一步调整矩阵,其实这一步操作的作用和LSS中的post_rots,post_trans类似,都是由于在图像域的图像发生了变化,需要相应的调整内参。

def _get_homography(self, meta: Dict, feat_hw: Tuple[int, int]) -> Tensor:
        # Get the ego2img homography matrix and the input
        # and original feature heights and widths
        homography = meta["ego2img"]
        orig_hw = meta["img"][0].shape[1:]
        scales = (feat_hw[0] / orig_hw[0], feat_hw[1] / orig_hw[1])
        view = np.eye(4)
        view[0, 0] = scales[1]
        view[1, 1] = scales[0]
        view = torch.tensor(view).to(device=homography.device).double()
        # Perform the matrix multiplication between
        # the view transformation matrix and the homography matrix
        homography = torch.matmul(view, homography.double())
        return homography

_gen_reference_point

_gen_reference_point部分是笔者认为最难理解的函数,这部分我们分段进行解析。

生成BEV点云

_gen_3d_points函数的作用是在BEV坐标系下生成参考点,在本例中,BEV的尺寸为128*128,车辆前后左右各51.2m,垂直z方向上下各10m,间隔为1m,共20个格子,BEV坐标系和自车坐标系与LSS中的坐标系设置相同,注意,根据BEV的网格大小建立的点云尺寸为128*128*20,但是其中的值为自车坐标系下的大小。分别生成X、Y、Z方向的坐标值,再拼接上One矩阵,方便后面的投影变换,最终输出的点云尺寸为128*128*20*4。

def _gen_3d_points(self, z_range: Tuple[int]) -> Tensor:
    # Get the minimum and maximum x and y coordinates in the BEV space
    bev_min_x, bev_max_x, bev_min_y, bev_max_y = get_min_max_coords(
            self.bev_size
    )
    W = self.grid_size[0]
    H = self.grid_size[1]
    Z = int(z_range[1] - z_range[0])
    # Generate a tensor `x` containing the x-coordinates of the grid
    x = (
        torch.linspace(bev_min_x, bev_max_x, W)
        .reshape((1, W, 1))
        .repeat(H, 1, Z)
    ).double()
    # Generate a tensor `y` containing the y-coordinates of the grid
    y = (
        torch.linspace(bev_min_y, bev_max_y, H)
        .reshape((H, 1, 1))
        .repeat(1, W, Z)
    ).double()
    # Generate a tensor `z` containing the z-coordinates of the grid
    # based on the given z_range
    z = (
        torch.linspace(self.z_range[0], self.z_range[1], Z)
        .reshape((1, 1, Z))
        .repeat(H, W, 1)
    ).double()
    # Generate a tensor `ones` containing all ones of shape (H, W, Z)
    ones = torch.ones((H, W, Z)).double()
    coords = torch.stack([x, y, z, ones], dim=-1)
    return coords
BEV点云转换

获取到BEV空间下的点云后,通过前面计算的转换矩阵homography,将自车坐标系下的点云转换到像素坐标系下得到new_coord,注意这里的new_coord = new_coord.permute((2, 0, 1, 3)),得到的是20*128*128*4(Z*H_B*W_B*4),这一步很重要,这里的20代表的是垂直方向上维度,从-10m到10m,每一层,即每一层的H_B*W_B*4,代表的是在同一个垂直高度下的bev坐标点。通过对维度进行调整,最终得到的是维度为20*6*1*128*128*4(Z*N*B*H_B*W_B*4)的点云。

H, W, Z = coords.shape[:3]
new_coords = []
for homo in homography:
    new_coord = torch.matmul(coords, homo.permute((1, 0))).float()
    new_coord = new_coord.permute((2, 0, 1, 3))
    new_coords.append(new_coord)
new_coords = torch.stack(new_coords, dim=1)
B = new_coords.shape[1] // self.num_views
new_coords = (new_coords.view(-1, B, self.num_views, H, W, 4).permute(0, 2, 1, 3, 4, 5).contiguous())

接下来,注意到前面所得到的点云是在图像域下,因此在乘以内外参后,得到的坐标实际上是(D*u,D*v,D,1),因此想要得到像素坐标还需要再除以深度D,同时,为了区分不同相机,再拼接一个不同相机索引的矩阵,得到[X, Y, D, idx]的新点云,维度为20*6*1*128*128*4(Z*N*B*H_B*W_B*4)。

d = torch.clamp(new_coords[..., 2], min=0.05)
X = (new_coords[..., 0] / d).long()
Y = (new_coords[..., 1] / d).long()
D = new_coords[..., 2].long()
idx = ((
torch.linspace(0, self.num_views - 1, self.num_views).reshape((1, self.num_views, 1, 1, 1)).repeat(Z, 1, B, H, W)).long().to(device=homography.device))
new_coords = torch.stack([X, Y, D, idx], dim=-1)
点云过滤

接下来,我们需要过滤掉无效的点云,也就是超出图像范围的点,从LSS中的视角转换模块的解析可知,每个图像得到的点云在BEV坐标系中实际上是一个截锥体,图像上的点实际上都是在一条条射线上,而我们在BEV坐标系是均匀的生成参考点,因此有大量的参考点是无效点,这里有一步操作需要注意,就是new_coords[invalid] = torch.tensor(

(feat_w - 1, feat_h - 1, self.depth, self.num_views - 1),在本例中等式右边为[43, 15, 60, 5],也就是将每个无效点的坐标变为一个很大的坐标,这样做的目的是为了在后续通过从小到大排序的方法将无效点滤除

feat_h, feat_w = feat_hw
invalid = (
(new_coords[..., 0] < 0)
| (new_coords[..., 0] >= feat_w)
| (new_coords[..., 1] < 0)
| (new_coords[..., 1] >= feat_h)
| (new_coords[..., 2] < 0)
| (new_coords[..., 2] >= self.depth)
)
new_coords[invalid] = torch.tensor((feat_w - 1, feat_h - 1, self.depth, self.num_views - 1)).to(device=homography.device)
BEV网格采样

new_coords = new_coords.view(-1, B, H_B, W_B, 4)这一行代码的信息量其实很大,本例中,我们将点云的坐标转换为(120*1*128*128*4),其中的120,表示的是20*6,还记得吗?20表示的是垂直方向上的高度,从-10m到10m,虽然当前的点云是在像素坐标系下,但是20依然表示高度的维度,而6表示的是6个相机,可以这样理解,每个高度下,有6个相机的点云是在同一个高度,如果在H_B和W_B相同,那么在BEV空间下的这个点就同时在两个相机的图像中出现,当然前提是这两个点都是有效点。接下来,使用rank来将点云的点从3维转换到1维,这一步操作似曾相识,因为在LSS中也使用了rank的技巧,但是这里的rank和LSS中的rank的作用是不同的,在这里,rank的值为该点云的深度乘以一个很大的数,加上该点云的y坐标乘以一个中等大的数,再加上该点云的x坐标乘以一个小的数,再加上不同相机的索引。这样做有以下几个用处:

  1. 可以区分不同点的XYD和idx,后续可以还原回该点的XYD和idx
  2. 由于前面已经将无效点设置成了XYD和idx的最大值,因此通过从小到大的topk操作可以剔除无效点
  3. 注意topk的dim=0,也就是说是沿着第一个维度来进行排序,而第一个维度前面解释过了,既包含了不同的高度,也包含了不同的相机,从高度的角度来理解,就是在同一个BEV格子中,沿着高度方向进行取点,而从相机视角的角度来理解,就是取不同视角的相机落在同一个BEV格子中的点,结合上面无效点的设置,完成BEV各个格子在图像域的采样。
new_coords = new_coords.view(-1, B, H, W, 4)
rank = (
new_coords[..., 2] * feat_h * feat_w * self.num_views
+ new_coords[..., 1] * feat_w * self.num_views
+ new_coords[..., 0] * self.num_views
+ new_coords[..., 3]) # filter the invalid points
rank, _ = rank.topk(self.num_points, dim=0, largest=False)
生成采样点

在上述topk采样过程中,如果设置self.num_points为10,那么就代表着在一个BEV格子中取10个点,得到的rank的维度为(10*128*128),接下来,我们将rank中坐标点解码出来,adjust_coords函数可以不予理会,没有起到作用。feat_coords的坐标是将6个相机的图像沿着H的方向进行拼接,方便后续采样的操作,如下面左图所示。而depth_coords的坐标,X_Y是将图像上的坐标XY拉直,idx_D则是沿着深度方向上拼接6个相机的深度坐标。最终得到在图像上的采样点坐标和在深度上的采样点坐标。

D = rank // (feat_h * feat_w * self.num_views)
rank = rank % (feat_h * feat_w * self.num_views)
Y = rank // (feat_w * self.num_views)
rank = rank % (feat_w * self.num_views)
X = rank // self.num_views
idx = rank % self.num_views
idx_Y = idx * feat_h + Y
feat_coords = torch.stack((X, idx_Y), dim=-1)
feat_points = adjust_coords(feat_coords, self.grid_size)
X_Y = Y * feat_w + X
idx_D = idx * self.depth + D
depth_coords = torch.stack((X_Y, idx_D), dim=-1)
depth_points = adjust_coords(depth_coords, self.grid_size)
feat_points = feat_points.view(-1, H, W, 2)
depth_points = depth_points.view(-1, H, W, 2)
_spatial_transform

在得到了采样点后,接下来就是分别在图像特征图和深度特征图上获取特征,首先,调整图像特征图的维度,从[B*N, C, H, W]转到[B,C,N*H,W];再调整深度特征图的维度,从[B*N,D,H,W]转到[B,1,N*D,H*W],这样就和上面的采样点的维度对应起来了,接着,遍历十个采样点,使用grad_sample函数从特征图中采样,这个函数其实是封装了nn.grid_sample,至于该函数的用法可参考博文【通俗易懂】详解torch.nn.functional.grid_sample函数

需要注意的是,该函数要求采样的点的坐标需要归一化到[-1, 1],意思是[-1,-1]表示被采样图像的左上角点,而[1, 1]表示被采样图像的右下角点。

通过采样后,得到的图像特征图的维度为[B,C,128,128],而深度的维度为[B,1,128,128],将两者相乘,就得到了融合深度信息的特征图,最后我们将10个采样点的特征相加,这一步完成的就是LSS中的voxel_pooling所做的事情,最终,我们得到了BEV特征图,特征图的维度为[B,C,128,128]。

def _spatial_transfom(self, feats: Tensor, points: Tensor) -> Tensor:
        feat, dfeat = feats
        fpoints, dpoints = points
        fpoints = self.quant_stub(fpoints)
        dpoints = self.dquant_stub(dpoints)
        B = feat.shape[0] // self.num_views
        C, H, W = feat.shape[1:]
        if self.training or B > 1:
            feat = feat.view(B, self.num_views, C, H, W)
            feat = feat.permute(0, 2, 1, 3, 4).contiguous()
        else:
            feat = feat.permute(1, 0, 2, 3).contiguous()
        feat = feat.view(B, C, -1, W)
        dfeat = dfeat.view(B, 1, -1, H * W)
        homo_feats = []
        for i in range(self.num_points):
            homo_feat = self.grid_sample(
                feat,
                fpoints[i * B : (i + 1) * B],
            )
            homo_dfeat = self.dgrid_sample(
                dfeat,
                dpoints[i * B : (i + 1) * B],
            )
            homo_feat = self.floatFs.mul(homo_feat, homo_dfeat)
            homo_feats.append(homo_feat)
        trans_feat = homo_feats[0]
        for f in homo_feats[1:]:
            trans_feat = self.floatFs.add(trans_feat, f)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值