Lift-Splat-Shoot这篇文章作为BEV领域的入门文章,啃下来还是废了不少功夫,在读代码的过程中自己也有了很多的心得体会,不写下来的话,不出3天肯定忘光光。以前学习的时候,会有意识的去做笔记本之类的,但是太久(太久,指短短几天)不翻开的话,再看的时候连自己为什么会记下这些东西都不记得了,可见笔记写的太细了也不是一件好事,毕竟自己的字有时候自己看都费劲。但是读代码这种东西,理解的都是特别私人的,碎片化的,难以一两句总结的。不比平时学习的那样,写几个公式就行了。
从东西湖搬到江夏,朋友们说还以为你又出国了,我说,”不是爱能跨越远距离吗? 不是多远都要在一起吗?“,然后朋友们到现在还在当哑巴,真是一群酒囊饭桶!没用的东西 !也罢, 反正元宵节这天也没人约我出去玩,写博客打发时间。
LSS 论文地址:点我看Nvidia16面纯英文论文
LSS github仓库:代码仓库,点击就送
我认为该论文的主旨讲的是如何将一组摄像头拍摄到的车身周围环境图片,转换成鸟瞰图,实现图像特征到BEV特征的转换,解决多相机融合的问题,这个这个确实不太好表达出来,只可意会,只可意会。
这里需要提前搞清楚的是图像坐标到相机坐标的转换过程:
式子中的第二个矩阵,即为相机的内参矩阵,第一个矩阵是像素在图像中的坐标,第三个矩阵是空间下的坐标, λ \lambda λ尺度因子此处可以理解为相机坐标系的深度,实际上,考虑到还会有旋转和平移的情况, λ \lambda λ并不能等价于Z。
基于本人通俗又浅薄的理解,论文通过Lift手段实现了将图像中像素的二维坐标(x,y)转换成空间中的点,但是像素的深度是不清楚的,论文中的办法是对每个像素都生成一个可能的深度表示(从4到45)。我们结合代码来理解。
整个代码的主要逻辑集中在models.py下的LiftSplatShoot类中。先贴出该类的初始化函数:
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'], # grid_conf 从train.py例传进来
)
#dx tensor([0.5000,0.5000,20.000])
#bx tensor([-49.7500,49.7500,0.0000])
#nx tensor([200,200,1])
# bx-dx/2. tensor([-50.,-50.,-10.])
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
# frustum 视锥
self.frustum = self.create_frustum()
self.D, _, _, _ = self.frustum.shape
self.camencode = CamEncode(self.D, self.camC, self.downsample) #特征提取 background 到深度的估计 对应的lift操作
self.bevencode = BevEncode(inC=self.camC, outC=outC) # 输出结果
# toggle using QuickCumsum vs. autograd
self.use_quickcumsum = True
在初始化函数中,主要声明了该类接下来会使用到的一些参数,再来看看该类的forward函数:
def forward(self, x, rots, trans, intrins, post_rots, post_trans):
# x:[4,6,3,128,352] 输入图像
# rots:由相机坐标系->车身坐标系的旋转矩阵,rots = (bs, N, 3, 3);
#
# trans:由相机坐标系->车身坐标系的平移矩阵,trans=(bs, N, 3);
#
# intrinsic:相机内参,intrinsic = (bs, N, 3, 3);
#
# post_rots:由图像增强引起的旋转矩阵,post_rots = (bs, N, 3, 3);
#
# post_trans:由图像增强引起的平移矩阵,post_trans = (bs, N, 3);
x = self.get_voxels(x, rots, trans, intrins, post_rots, post_trans)
x = self.bevencode(x) # CNN 结构
return x
forward函数首先调用了get_voxels() 函数, 让我康康你怎么操作的:
def get_voxels(self, x, rots, trans, intrins, post_rots, post_trans):
geom = self.get_geometry(rots, trans, intrins, post_rots, post_trans) # 得到点云从2d到3d的银蛇
x = self.get_cam_feats(x) # 特征提取
x = self.voxel_pooling(geom, x) # splat 操作
return x
终于结束了层层的调用,现在进入第一个主题,get_gemetry() 函数:
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
# undo post-transformation 增广
# B x N x D x H x W x 3
# 3 - xs, ys, lamda
#消除图像增强以及预处理对象像素的变化
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
# xs, ys, lamda -> xs * lamda, ys*lamda, lamda
points = torch.cat((points[:, :, :, :, :, :2] * points[:, :, :, :, :, 2:3],
points[:, :, :, :, :, 2:3]
), 5) # 对点云中预测的宽度和高度上的栅格坐标,将其乘以深度上的栅格坐标
combine = rots.matmul(torch.inverse(intrins))
# 除以内参矩阵,乘以相机坐标系到车身坐标系的旋转矩阵rots
points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1)
#加上相机坐标系到车身坐标系的平移矩阵
points += trans.view(B, N, 1, 1, 1, 3)
# points -> B*N*D*H*W * 3 3 -> [X,Y,Z]
return points
怎么有个self.frustum? 这是什么东西?哦原来是在初始化函数时自动调用了self.create_frustum() 函数。
def create_frustum(self): # 为每一张图片生成一个棱台状(frustum)的点云
# make grid in image plane
# 数据增强后图片大小 ogfH:128 ogfW:352
ogfH, ogfW = self.data_aug_conf['final_dim']
# 下采样16倍后图像大小 fH: 128/16=8 fW: 352/16=22
fH, fW = ogfH // self.downsample, ogfW // self.downsample
'''
ds: 在深度方向上划分网格
dbound: [4.0, 45.0, 1.0]
arange后-> [4.0,5.0,6.0,...,44.0]
view后(相当于reshape操作)-> (41x1x1)
expand后(扩展张量中某维数据的尺寸)-> ds: DxfHxfW(41x8x22)
'''
ds = torch.arange(*self.grid_conf['dbound'], dtype=torch.float).view(-1, 1, 1).expand(-1, fH, fW)
D, _, _ = ds.shape # D: 41 表示深度方向上网格的数量
'''
xs: 在宽度方向上划分网格
linspace 后(在[0,ogfW)区间内,均匀划分fW份)-> [0,16,32..336] 大小=fW(22)
view后-> 1x1xfW(1x1x22)
expand后-> xs: DxfHxfW(41x8x22)
'''
xs = torch.linspace(0, ogfW - 1, fW, dtype=torch.float).view(1, 1, fW).expand(D, fH, fW)
'''
ys: 在高度方向上划分网格
linspace 后(在[0,ogfH)区间内,均匀划分fH份)-> [0,16,32..112] 大小=fH(8)
view 后-> 1xfHx1 (1x8x1)
expand 后-> ys: DxfHxfW (41x8x22)
'''
ys = torch.linspace(0, ogfH - 1, fH, dtype=torch.float).view(1, fH, 1).expand(D, fH, fW)
'''
frustum: 把xs,ys,ds堆叠到一起
stack后-> frustum: DxfHxfWx3
堆积起来形成网格坐标, frustum[d,h,w,0]就是(h,w)位置,深度为d的像素的宽度方向上的栅格坐标
'''
frustum = torch.stack((xs, ys, ds), -1)
return nn.Parameter(frustum, requires_grad=False)
可以看到,该函数生成了一个棱台状的点云,生成的锥点是基于图像坐标系的,我的理解是,将原始图像以16下采样后,划分为8x22的栅格,并对每一格分配一个[4,45]的值,即深度,从而将图片从128x352转换成了41x8xx22x3的数据,并将其输出。frustum[d,h,w,0]意味着(h,w) 位置,深度为d的像素在宽度上的栅格坐标,frustum[d,h,w,1]意味着(h,w) 位置,深度为d的像素在高度上的栅格坐标,frustum[d,h,w,2]也就意味着(h,w) 位置,深度为d的像素在深度上的栅格坐标。
理解了self.frustum之后,再来看看get_geometry() 函数。该函数的任务是将锥点由相机坐标系下的(x,y,z) 转化成自车坐标系下的点云坐标,即完成图像坐标—相机坐标—车身坐标的转换。先对frustum生成的点云消除图像增强以及预处理对象像素的变化,随后对点云中预测的宽度和高度上的栅格坐标,将其乘以深度上的栅格坐标,从而达到前文中”一些需要提前知道的“里提到的图像坐标到相机坐标的转换:
对应的意义就是,u,v乘以 λ \lambda λ,也就是说 λ \lambda λ的值对应的就是深度值,因为深度值有41种(从4到45),对应的意义就是在[i,j]栅格中深度为d的像素,转化到相机坐标系时的坐标。但是这还没完,还没有除以内参矩阵。
最后fustum除以内参矩阵,乘以相机坐标系到车身坐标系的旋转和平移矩阵后,就得到了自车坐标系下像素的坐标。返回的Points的维度为BxNxDxHxWx3。
其物理意义是,完成了图像栅格中,不同深度的像素对应于自车坐标系的位置。 get_geometry函数到此就结束了,返回值备存放在了geom中。get_voxels()函数在调用完了get_geometry后,紧接着调用了get_cam_feats() 函数。
先来看看代码:
def get_cam_feats(self, x):
"""Return B x N x D x H/downsample x W/downsample x C
"""
B, N, C, imH, imW = x.shape
x = x.view(B*N, C, imH, imW)
x = self.camencode(x) # 生成C*D的lift 矩阵 x:24*64*41*8*22
x = x.view(B, N, self.camC, self.D, imH//self.downsample, imW//self.downsample) # 将前两维切开,4*6*64*41*8*22
x = x.permute(0, 1, 3, 4, 5, 2) # x: 4*6*41*8*22*64
return x
如果不注意的话,一眼就扫过去了,可这里面的东西,比上一个还多!get_cam_feats()调用了self.camencode()函数来提取带有深度的特征。让我们一个一个看,在models.py里定义了CamEncode类,其初始化函数如下:
class CamEncode(nn.Module):
def __init__(self, D, C, downsample):
super(CamEncode, self).__init__()
self.D = D
self.C = C
self.trunk = EfficientNet.from_pretrained("efficientnet-b0")
self.up1 = Up(320+112, 512)
self.depthnet = nn.Conv2d(512, self.D + self.C, kernel_size=1, padding=0)
前向函数则如下:
def forward(self, x):
depth, x = self.get_depth_feat(x)
return x
调用了get_depth_feat()
def get_depth_feat(self, x):
x = self.get_eff_depth(x)
# Depth
x = self.depthnet(x)
depth = self.get_depth_dist(x[:, :self.D]) # 前面d个为depth
new_x = depth.unsqueeze(1) * x[:, self.D:(self.D + self.C)].unsqueeze(2) # 得到一个[C D] 大小的矩阵
return depth, new_x
def get_depth_dist(self, x, eps=1e-20):
return x.softmax(dim=1)
其中,get_eff_depth()通过efficient net提取特征,调用了Up函数上采样到了512,输出数据大小为24x512x8x22。随后经过了1x1卷积,将数据维度变化为24x105x8x22,105=C+D,接下来调用了get_depth_dist()函数来对前面的D个维度进行softmax,这里前41个维度为深度维,后面64个代表特征图上不同位置对应的图像特征。返回的depth维度是24x41x8x22。最后在将depth与后面的64维做外积,得到维度为24x64x41x8x22的new_x,并将其返回。回到get_cam_feats中,将前24维拆成4x6,在颠倒顺序,输出一个维度维4x6x41x8x22x64的x。到此处,get_cam_feats也完成了他的使命。
我们来评判一下get_cam_feats的丰功伟绩,该函数将作为深度预测的前41个数据经过了softmax,再与后64的特征维度做外积,这样训练出来的特征就是与深度相乘的特征。这个值越接近于1,也就说明越有可能是这个深度上的,也就是说选出了最有可能的深度。毫无疑问,这里对应的就是Lift操作。
利用了深度方向的概率密度和图像特征外积构建的特征点云。我对输出x的理解是,每个栅格,对应深度的概率和图像特征的积。即完成了对每个栅格点的距离的预测。
get_voxels()函数已经调用完了前两个函数,得到了geom和x。其中geom是图像栅格点到车身坐标的不同深度的坐标(X,Y,Z),而x是对深度进行了预测的图像特征点云。接下来的voxel_pooling()函数将利用这两个值来构建BEV特征。geom_feats的维度为BxNxDxHxWx3, x的维度为BxNxDxHxWxC。以下是voxel_pooling()的代码:
def voxel_pooling(self, geom_feats, x): # geom_feats 点云从2维到3维坐标映射表 (B*N*D*fH*fW*3)
# splat
# x -> 4 * 6 *41* 8 * 22 *64 # 生成C*D的lift
B, N, D, H, W, C = x.shape
Nprime = B*N*D*H*W # BNDHW 全都不管 只留下c 173184
# flatten x
x = x.reshape(Nprime, C)
# flatten indices
# geom_feats [X,Y,Z] - (self.bx - self.dx/2.) -> 平移 使值变为正数 【0-200,0-200,0-20】
geom_feats = ((geom_feats - (self.bx - self.dx/2.)) / self.dx).long() # 将[-50,50] [-10 10]的范围平移到[0,100] [0,20],计算栅格坐标并取整
geom_feats = geom_feats.view(Nprime, 3) # 将像素映射关系同样展平 geom_feats: B*N*D*H*W x 3 (173184 x 3)
# 前面不应该把batch B 考虑,将其还原出来,并拼接
batch_ix = torch.cat([torch.full([Nprime//B, 1], ix,
device=x.device, dtype=torch.long) for ix in range(B)]) # 每个点对应于哪个batch
geom_feats = torch.cat((geom_feats, batch_ix), 1) # geom_feats: B*N*D*H*W x 4(173184 x 4), geom_feats[:,3]表示batch_id
# filter out points that are outside box 筛选操作 因为已经平移了 所以将所有小于0,大于200的XY, 小于0大于1的Z都过滤
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]
# get tensors from the same voxel next to each other
# 把bev相同位置的点合在一起
# geom_feats -> x,y,z,b
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] # 给每一个点一个rank值,rank相等的点在同一个batch,并且在在同一个格子里面
sorts = ranks.argsort() # 按照rank排序,这样rank相近的点就在一起了
x, geom_feats, ranks = x[sorts], geom_feats[sorts], ranks[sorts]
# cumsum trick
#给每个点赋予一个ranks,ranks相等的点在同一个batch中,也在同一个栅格中,将ranks排序并返回排序的索引以至于x,geom_feats,ranks都是按照ranks排序的。
# 接着进行挑选,在同一个格子的所有点的特征相加,放在栅格中。
if not self.use_quickcumsum:
x, geom_feats = cumsum_trick(x, geom_feats, ranks)
else:
x, geom_feats = QuickCumsum.apply(x, geom_feats, ranks) # 一个batch的一个格子里只留一个点 x: 29072 x 64 geom_feats: 29072 x 4
# griddify (B x C x Z x X x Y) 2d -> 3d C -> 特征柱
final = torch.zeros((B, C, self.nx[2], self.nx[0], self.nx[1]), device=x.device) # final: 4 x 64 x 1 x 200 x 200
final[geom_feats[:, 3], :, geom_feats[:, 2], geom_feats[:, 0], geom_feats[:, 1]] = x # 将x按照栅格坐标放到final中
# collapse Z
final = torch.cat(final.unbind(dim=2), 1) # 消除掉z维
return final # final: 4 x 64 x 200 x 200
这里首先将x展平,将其变为(Nprime,C)大小的数据,意义是所有栅格的图像像素的所有可能深度的特征,有点拗口哈,不理解也没关系。再将grid_conf中,将车身关注范围的长宽从[-50,50]变换到[0,100],高度从[-10,10]变化到[0,20],空间坐标以米为单位,车身感兴趣的空间范围长宽都为正负50,将geom中的点的空间坐标转换成栅格坐标并取整后单位变成了单位1,一个各自代表了0.5m。此时(X,Y,Z)变成了栅格坐标,之后同样像x一样展平。找出展平后每个geom点对应着哪个batch,将其拼接到geom_feats后面,注意最后一个维度,geom[:,0]代表着车身坐标系下点的X值,geom[:,1]代表着车身坐标系下点的Y值,geom[:,2]代表着车身坐标系下点的Z值,而geom[:,4]则代表着车身坐标系下点的batch_id值。
kept用于剔除掉边界线外的点,X,Y范围从0到199,Z只留下等于0的点。 最后给每个留下来的栅格坐标赋予ranks值,ranks相同的点意味着在同一个batch中,也在同一个栅格里。将ranks排序后返回索引,使得geom_feats和x都按照ranks排序。接着调用cumsum_trick或QuickCumsum来完成splat操作。这两个函数在计算上好像没有太大区别,好像QuickCumsum可以通过保留一些东西来加速吧?具体的没有细看。
这里首先对排好序后的图像特征,即x进行累加。随后对区间索引,即geom筛选出前后ranks不等的位置。然后对x和geom中rank相等的点,只留下最后一个,即一个batch值保留最后一个点,最后再错位相减,此时的值为与其rank相等的点的特征和,相当于把同一个栅格的特征进行了求和并返回。意义为将相同栅格中的特征直接累加计算。
在voxel_pooling的最后,创建了一个大小为4x64x1x200x200的final,并依据geom_feats和x之前排序后的一 一对应关系填入final。并消除掉z维,输出大小为4x64x200x200。即BEV鸟瞰图特征。在forward中,最后调用了BevEncoder做进一步的特征融合和语义分割的结果预测,用resnet提取特征,最后返回4x1x200x200的俯瞰图。
class BevEncode(nn.Module):
def __init__(self, inC, outC):
super(BevEncode, self).__init__()
trunk = resnet18(pretrained=False, zero_init_residual=True)
self.conv1 = nn.Conv2d(inC, 64, kernel_size=7, stride=2, padding=3,
bias=False)
self.bn1 = trunk.bn1
self.relu = trunk.relu
self.layer1 = trunk.layer1
self.layer2 = trunk.layer2
self.layer3 = trunk.layer3
self.up1 = Up(64+256, 256, scale_factor=4)
self.up2 = nn.Sequential(
nn.Upsample(scale_factor=2, mode='bilinear',
align_corners=True),
nn.Conv2d(256, 128, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.Conv2d(128, outC, kernel_size=1, padding=0),
)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x1 = self.layer1(x)
x = self.layer2(x1)
x = self.layer3(x)
x = self.up1(x, x1)
x = self.up2(x)
return x
以上就是我对LSS这篇论文浅薄的理解,也是糅合了网上资料的百家之长。自认为还是写的比较详细。说起来当时进入AI这个领域也是无心之举。大概一年前的这个时候,我和我的法国宅男朋友Alexandre走在回家路上,那时学校要求从软件开发,信号处理及AI,网络安全中选择接下来的研究方向。我们俩一直搭档做实验,虽然不想承认,但他的代码能力上确实在我之上,我苦于用法语学数学,实在是难以忍受,想都没想就选择了软件开发方向。我还以为这下子又可以黄金搭档了!结果他却选择了信号处理及AI方向,我问他你软件能力那么强,又肯耐心的改bug(甚至是帮我改哈哈哈),又肯耐心的写报告(我的散装法语写的报告可以把老师气的发pyq),为啥不选择软件方向呢?他一头好像摇滚乐手一样长长的卷发遮住了他的脸,跟我说他觉得这个比较有趣!我嘴上附和着,心里却独白,像什么语言模型,alphago,人脸识别这种奇伟瑰怪固然有趣,但是一想到这山峰底下是高耸雪山,满满的都是矩阵啊线代啊算来算去,学这些对我来说如履薄冰,我才不傻。
去年7月份回国,来到了武汉理工大学,因为是交换生,所以可以选择相关的专业,那么以就业和实用为导向,肯定选择计算机啊!但是法国学校有要求的,一定要修够多少学分,又不让跟着老师做实验。而理工大给我选择的专业实在不多,只能东凑凑,西改改,就把人工智能,机器学习这种课都选上了。后面又卡bug找到学院的院长,准备进他团队混混资历,而他又恰巧是人工智能方向。万事俱备,我心想那就试试这个方向吧!结果就一发不可收拾了,什么嘛,根本没我想象中那么难,自己探索这个方向也很有趣。我多少理解了Alexandre的用心。也不知道啥时候能再和这个摇滚乐手见面,下次见面,一定要告诉他我也来整这个方向了哈哈哈!明天就实习了,希望这六个月能充实又顺利吧。