文章的算法简述:
求出camera坐标系的Zc后,依据坐标转换的矩阵公式,反解三维世界下的具体坐标,【Lift: Latent Depth Distribution】这样所有点就会汇聚为一个密集的三维集合,最后拍平则成为BEV feature【Splat:Pillar Pooling】提出Lift-Splat网络,保留了上述3个对称性的特点,且是端到端可微的方法。首先通过生成棱台形状的上下文特征点云,将图像“提升(lift)”到3D,然后将这些棱台“splat(可理解为投影)”到参考平面,以便于进行运动规划的下游任务 。
将坐标的后处理部分,移植到了深度学习的模型中
2D转3D的基础知识:
Lift:潜在的深度分布(Lift: Latent Depth Distribution)
对每个图像进行单独处理,获得每个2d像素点在3d空间的特征。将深度信息转换为参考坐标系坐标,但与每个像素相关的“深度”本质上是模糊的。我们提出的解决方案是为每个像素生成所有可能深度的表示。
由于空间中的点有可能落到同一个像素上,造成图像深度的模糊性,因此LSS直接预测每一个采样点的深度分布,通过深度分布对相应的图像特征进行加权。【对D方向,也就是深度的每一个像素格进行加权】每一个采样点,都有对应的加权特征,有对应的几何深度,通过内外参将其投影到BEV视角下,得到BEV空间特征。
Splat:柱体池化 (Splat: Pillar Pooling)
通过像素的2D坐标值和深度值,以及相机的内参和外参,计算出像素在车身坐标系中的3D坐标。把每个点分配到离他最近的Pillars中,然后执行求和池化得到一个C×H×W 的张量,再对该张量进行CNN操作得到鸟瞰图的预测结果。
Models代码解析:
每一行代码解释与作用在注释中,过于基础的没注明,搜一下就行。代码下方是对整个代码块的以及涉及的方法做解释与说明。
UP:上采样层将输入特征图的分辨率放大,然后通过卷积层序列提取和整合特征
class Up(nn.Module):
def __init__(self, in_channels, out_channels, scale_factor=2):
super().__init__()
self.up = nn.Upsample(scale_factor=scale_factor, mode='bilinear',# 双线性插值上采样
align_corners=True)
self.conv = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
def forward(self, x1, x2):
x1 = self.up(x1)
x1 = torch.cat([x2, x1], dim=1)
return self.conv(x1)
CamEncode:
class CamEncode(nn.Module):
def __init__(self, D, C, downsample):
super(CamEncode, self).__init__()
self.D = D
self.C = C
# 使用预训练的 EfficientNet-B0 模型作为特征提取器
self.trunk = EfficientNet.from_pretrained("efficientnet-b0")
self.up1 = Up(320+112, 512) # # 创建一个上采样对象,输入通道数为 320+112,输出通道数为 512
self.depthnet = nn.Conv2d(512, self.D + self.C, kernel_size=1, padding=0)
def get_depth_dist(self, x, eps=1e-20):
return x.softmax(dim=1) # 将X第一个维度进行softmax操作,获取深度分布
def get_depth_feat(self, x): # 获取深度特征
x = self.get_eff_depth(x) # # 调用 get_eff_depth 函数,获取有效的深度特征
# 将深度特征通过 depthnet 卷积层进行处理
x = self.depthnet(x)
# x的前self.D输入get_depth_dist计算深度分布
depth = self.get_depth_dist(x[:, :self.D])
# 特征图 x 中取出从 self.D 到 self.D + self.C 的通道的数据,然后将其与深度分布 depth 进行相乘,生成新的特征图 new_x
new_x = depth.unsqueeze(1) * x[:, self.D:(self.D + self.C)].unsqueeze(2)
return depth, new_x
def get_eff_depth(self, x):
# adapted from https://github.com/lukemelas/EfficientNet-PyTorch/blob/master/efficientnet_pytorch/model.py#L231
endpoints = dict() # 定义一个字典,保存每一阶段的特征图
# Stem
x = self.trunk._swish(self.trunk._bn0(self.trunk._conv_stem(x))) # 卷积、批量归一化和 swish 激活函数一系列操作
prev_x = x
# Blocks
for idx, block in enumerate(self.trunk._blocks):
drop_connect_rate = self.trunk._global_params.drop_connect_rate
# 根据当前 block 的索引动态调整drop_connect_rate,一种正则化手段
if drop_connect_rate:
drop_connect_rate *= float(idx) / len(self.trunk._blocks) # scale drop connect_rate
x = block(x, drop_connect_rate=drop_connect_rate)
# 如果当前 block 进行了下采样操作(即特征图的尺寸发生了变化),那么将前一个特征图保存到endpoints 字典中。
if prev_x.size(2) > x.size(2):
endpoints['reduction_{}'.format(len(endpoints)+1)] = prev_x
prev_x = x
# Head
endpoints['reduction_{}'.format(len(endpoints)+1)] = x # 将最后一个特征图保存到 endpoints 字典中
x = self.up1(endpoints['reduction_5'], endpoints['reduction_4']) # 对最后两个阶段的特征图进行上采样操作
return x
def forward(self, x):
depth, x = self.get_depth_feat(x)
return x
class LiftSplatShoot(nn.Module):
def __init__(self, grid_conf, data_aug_conf, outC):
# 网格配置 数据增强配置
super(LiftSplatShoot, self).__init__()
self.grid_conf = grid_conf
self.data_aug_conf = data_aug_conf
# 网格大小 起始位置 网格数量
dx, bx, nx = gen_dx_bx( self.grid_conf['xbound'],
self.grid_conf['ybound'],
self.grid_conf['zbound'],
) # xyzbound定义网格边界
# dbnx转换为pytorch的Parameter对象,且False规定不需要随着训练而更新此参数
self.dx = nn.Parameter(dx, requires_grad=False)
self.bx = nn.Parameter(bx, requires_grad=False)
self.nx = nn.Parameter(nx, requires_grad=False)
self.downsample = 16 # 下采样因子
self.camC = 64 # maybe摄像机的数量
self.frustum = self.create_frustum() # 创建视锥
self.D, _, _, _ = self.frustum.shape # 把视锥的第一个维度赋予D,占位符表:除却D参数不关心其值
self.camencode = CamEncode(self.D, self.camC, self.downsample)
self.bevencode = BevEncode(inC=self.camC, outC=outC)
# toggle using QuickCumsum vs. autograd
self.use_quickcumsum = True
下采样方法有很多种,以下是一些常见的方法:
-
最近邻插值(Nearest-neighbor interpolation):这是最简单的一种方法,它直接选择最近的像素作为下采样后的像素值。
-
双线性插值(Bilinear interpolation):这种方法考虑了像素周围的四个像素,通过它们的加权平均值来计算下采样后的像素值。
-
双三次插值(Bicubic interpolation):这种方法考虑了像素周围的16个像素,通过它们的加权平均值来计算下采样后的像素值。
-
平均池化(Average pooling):这种方法计算一定区域内的像素值的平均值作为下采样后的像素值。这是卷积神经网络中常用的一种下采样方法。
-
最大池化(Max pooling):这种方法选择一定区域内的最大像素值作为下采样后的像素值。这也是卷积神经网络中常用的一种下采样方法。
def create_frustum(self): # make grid in image plane ogfH, ogfW = self.data_aug_conf['final_dim'] # 从数据增强配置中获取图像的最终尺寸 fH, fW = ogfH // self.downsample, ogfW // self.downsample # 计算下采样的尺寸 ds = torch.arange(*self.grid_conf['dbound'], dtype=torch.float).view(-1, 1, 1).expand(-1, fH, fW) D, _, _ = ds.shape # torch.linspace(start, end, steps, dtype)创建一维等差数列 数列元素数量fw view:改变形状为三维1, 1, fW # 为x,y轴上每一个像素点赋予坐标后,在深度方向上扩展网格,使其称为3d网格 xs = torch.linspace(0, ogfW - 1, fW, dtype=torch.float).view(1, 1, fW).expand(D, fH, fW) 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 frustum = torch.stack((xs, ys, ds), -1) # torch.stack函数,将xs、ys和ds这三个张量在最后一个维度(由-1指定)上堆叠起来 return nn.Parameter(frustum, requires_grad=False)
def get_geometry(self, rots, trans, intrins, post_rots, post_trans):
"""Determine the (x,y,z) locations (in the ego frame)
of the points in the point cloud.
Returns B x N x D x H/downsample x W/downsample x 3
"""
B, N, _ = trans.shape # 获取变换矩阵的形状,B:批次大小,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))
# cam_to_ego
#0 将点的x和y坐标乘以其Zc,然后将结果与Zc连接起来:得到新的坐标且深度都为1
# 摄像机坐标-->归一化摄像机坐标
points = torch.cat((points[:, :, :, :, :, :2] * points[:, :, :, :, :, 2:3],
points[:, :, :, :, :, 2:3]
), 5)
# 计算旋转矩阵和内参矩阵的逆的乘积
#1 :见代码下方解释
# cots与内参矩阵作乘法赋予combine
# 此时combine成为变换矩阵:能实现图像坐标系到世界坐标系的转换
combine = rots.matmul(torch.inverse(intrins))
# 将combine改变维度与points作乘法,完成坐标系转换
points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1)
# 完成张量的平移——3D空间
points += trans.view(B, N, 1, 1, 1, 3)
# B N H W 3 最后的[1]被删除 3————xyz
return points
# 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))
因为之前对点云进行了一些变换(这些变换由post_rots
和post_trans
表示),现在需要撤销这些变换,以便回到原始的坐标系或状态。保证点云能够参与下方的运算。
#0 : 为什么要归一化摄像机坐标: 因为摄像机坐标是xyz三个维度,是由其在图像平面上的位置和其深度(即距离摄像机的距离)决定的。而我们肯定是希望坐标都存在于z=1这个平面【即相同深度】
#1:旋转矩阵rots
描述了自我坐标系相对于世界坐标系的旋转,而内参矩阵intrins
的逆则用于将点从归一化的摄像机坐标系转换回原始的摄像机坐标系。所以,当我们将这两个矩阵相乘时,我们得到的是一个可以直接将点从摄像机坐标系转换到自我【ego】坐标系的变换矩阵。
def get_cam_feats(self, x):
# 对输入的图像进行特征提取,并将提取的特征映射回原来的空间
"""Return B x N x D x H/downsample x W/downsample x C
"""
# 获取X图像尺寸信息后,改变维度
B, N, C, imH, imW = x.shape
x = x.view(B*N, C, imH, imW)
# 卷积编码:提取图像特征
x = self.camencode(x)
# 特征提取完毕,恢复输入形状:6维-->4维
x = x.view(B, N, self.camC, self.D, imH//self.downsample, imW//self.downsample)
# 调整张量的维度顺序,permute的作用
x = x.permute(0, 1, 3, 4, 5, 2)
return x
池化操作:分两部分解析
(一)
def voxel_pooling(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)
# flatten indices
# 将几何特征的值调整到一个新的范围后转为长整数型,变换几何特征维度
geom_feats = ((geom_feats - (self.bx - self.dx/2.)) / self.dx).long()
geom_feats = geom_feats.view(Nprime, 3)
# 创建批次,以记录每个几何特征对应的编号
#2
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)
#2 :创建一个形状为[Nprime//B, 1]
、值全为ix
的张量。这个张量的设备和数据类型与输入的x
相同。 .full:torch.full(size, fill_value, dtype=None, device=None, requires_grad=False)
0:新张量的形状 1:标量,新张量元素的值 3:默认全局设备 4:是否计算新张量梯度。默认False
(二)
# 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]
[kept]布尔型的掩码,该掩码表示每个几何特征是否在给定的范围。x、y、z坐标是否在[0, self.nx[0])
、[0, self.nx[1])
、[0, self.nx[2])
这三个区间内。
x = x[kept]
这行代码是在使用kept
来索引x
,也就是说,它会创建一个新的x
,这个新的x
只包含原来x
中对应kept
中值为True
的元素。
# get tensors from the same voxel next to each other
# 分别计算每个几何特征在xyz、批次方向的索引,加权求和得到现行索引ranks
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]
# 将ranks中的元素进行从小到大排序,并返回相应序列元素的数组下标
sorts = ranks.argsort()
# 将x, geom_feats, ranks数组元素按照从小到大排列
# # x[sorts]这行代码是在使用sorts来索引x。它会创建一个新的x,这个新的x的元素顺序与sorts中的元素顺序相对应
x, geom_feats, ranks = x[sorts], geom_feats[sorts], ranks[sorts]
# cumsum trick
# 根据self.use_quickcumsum的值来选择使用cumsum_trick函数还是QuickCumsum.apply方法来计算累积和
if not self.use_quickcumsum:
x, geom_feats = cumsum_trick(x, geom_feats, ranks)
else:
x, geom_feats = QuickCumsum.apply(x, geom_feats, ranks)
# griddify (B x C x Z x X x Y)
# 创建零向量,形状为B x C x Z x X x Y
final = torch.zeros((B, C, self.nx[2], self.nx[0], self.nx[1]), device=x.device)
#3
final[geom_feats[:, 3], :, geom_feats[:, 2], geom_feats[:, 0], geom_feats[:, 1]] = x
# collapse Z
# 将final沿着第三个维度(即Z维度)解绑,得到一个包含多个张量的元组,每个张量都是final在Z维度上的一个切片。Z维度上的信息就被折叠到了C维度上,Z维度的大小变为1,而C维度的大小变为原来的C维度和Z维度的大小之和
final = torch.cat(final.unbind(dim=2), 1)
return final
#3:final[geom_feats[:, 3], :, geom_feats[:, 2], geom_feats[:, 0], geom_feats[:, 1]] = x
在final
的特定位置上填充x
的值。这些位置由geom_feats
的各列决定。例如geom_feats[:, 3]代表的是B:批次大小中的索引。那么B中索引为geom_feats[:, 3]的元素就会被x中的元素替代。
cumsum_trick函数与QuickCumsum.apply在tool模块中解析。
总结整个(二)代码块的作用:
-
计算每个几何特征在xyz、批次方向的索引,加权求和得到线性索引
ranks
。 -
将
ranks
中的元素进行从小到大排序,并返回相应序列元素的数组下标sorts
。 -
将
x
、geom_feats
、ranks
数组元素按照从小到大排列。 -
根据
self.use_quickcumsum
的值来选择使用cumsum_trick
函数还是QuickCumsum.apply
方法来计算累积和。 -
创建一个全零的五维张量
final
,形状为(B, C, self.nx[2], self.nx[0], self.nx[1])
。 -
将
x
中的值赋给final
的特定位置。这些位置由geom_feats
的各列决定。 -
将
final
沿着第三个维度(即Z维度)解绑,得到一个包含多个张量的元组,每个张量都是final
在Z维度上的一个切片。然后将这些张量沿着第二个维度(即C维度)拼接起来。这样,原来在Z维度上的信息就被折叠到了C维度上,Z维度的大小变为1,而C维度的大小变为原来的C维度和Z维度的大小之和。 -
返回处理后的
final
张量。
———————————————————12.9更新————————————————————