LSS-Lift.Splat,Shoot
-
論文題目:Lift, Splat, Shoot: Encoding Images From Arbitrary Camera Rigs by Implicitly Unprojecting to 3D
-
代碼:https://github.com/nv-tlabs/lift-splat-shoot
-
概括:先做深度估計和特征融合,然后投影到 BEV 視圖中,在 BEV 視圖中做特征融合,在融合后的特征圖上做檢測、規劃等任務。
目錄
- LSS-Lift.Splat,Shoot
- 一、概括說明
- 1、Lift:首先提取圖像特征,估計每個像素的深度分布估計(每個像素對應每個深度的特征向量)
- 2、Splat:將lift的二點特征壓縮為BEV特征
- 3、Shoot:基于BEV特征,進行路徑規劃。利用深度分布信息,生成點云,并利用點云信息進行3D重建
- 二、算法步驟
- 1、生成視錐,并根據相機內外參將視錐中的點投影到ego坐標系;
- 2、get_cam_feats:對環視圖像進行特征提取camera_features,并構建圖像特征點云
- 3、利用ego坐標系下的坐標點與圖像特征點云,利用Voxel Pooling構建BEV特征
- 三、總結
- 四、參考鏈接
一、概括說明
1、Lift:首先提取圖像特征,估計每個像素的深度分布估計(每個像素對應每個深度的特征向量)
將2D圖像(W×H×3W \times H \times 3W×H×3)增加深度信息,升維得到3D(W×H×DW \times H \times DW×H×D),為學習不同深度的維特征,得到W×H×D×CW \times H \times D \times CW×H×D×C維的視錐點云。
步驟:
-
通過向量c∈RCc\in R^{C}c∈RC和深度概率α\alphaα的外積,得到每個像素對應每個深度的特征向量αic\alpha_{i}cαi?c
-
通過像素估計到的每個深度特征為:(u,v,di,αic)(u,v,d_{i},\alpha_{i}c)(u,v,di?,αi?c)
-
結合相機成像原理,已知內外參的情況下,可求出像素對應的3D坐標
di(u;v;1)=K[Rt](x;y;z;1)=T(x;y;z;1)d_{i}(u;v;1)= K[R t](x;y;z;1)=T(x;y;z;1)di?(u;v;1)=K[Rt](x;y;z;1)=T(x;y;z;1)
(x;y;z;1)=diT?1(u;v;1)(x;y;z;1) = d_{i}T^{-1}(u;v;1)(x;y;z;1)=di?T?1(u;v;1) -
每個像素對應的3D特征向量即完成Lift過程,?(x,y,z)=αic\phi(x,y,z) = \alpha_{i}c?(x,y,z)=αi?c
總結:lift過程是通過估計深度來實現2D特征轉化為3D特征;此處的深度估計有幾種不同的選擇,優缺點如下:
特征向量形式 | 按預測概率分布 | 均勻分布 | One-hot |
---|---|---|---|
優勢 | 可識別深度,特征轉換更精準 | 計算快,無學習參數 | 深度估計的識別度更高,實際為估計每個像素對應的點云 |
劣勢 | 需要MLP做概率估計 | 深度估計無差異化 | 魯棒性不好 |
2、Splat:將lift的二點特征壓縮為BEV特征
將圖像特征投影到BEV空間中,采用cumsum Trick 的方法將特征求和,得到C×X×維度C \times X \times 維度C×X×維度的BEV特征。
實際為均勻采樣點云;每個點的特征為?(x,y,z)=αic\phi(x, y,z) = \alpha_{i}c?(x,y,z)=αi?c
難點分析:1、多相機存在場合趨于,特征壓縮時不應有差異;2、點數量多,百萬級別的點(±50m范圍,采樣間隔為0.5m)
解決:
- 參考PointPillars將BEV空間劃分為H×WH \times WH×W的網格;
- 對每個估計的3D點(x,y,z),將其特征歸屬至最近的pillar
- 利用歸屬到某個特定pillar的特征進行求和sum pooling
求和是因為考慮到多個視錐點云落在同一個BEV格柵Grid的情況,會出現以下兩種情況:
- 不同高度的視錐點云會落在同一個柵格中,比如電線桿上的不同像素點
- 不同相機間存在重疊overlap,不同相機觀測到的同一個物體,會落在同一個BEV Grid中
3、Shoot:基于BEV特征,進行路徑規劃。利用深度分布信息,生成點云,并利用點云信息進行3D重建
將軌跡通過模板映射到BEV空間中,并計算軌跡的損失。
圖像 backbone 采用 EfficientNet,通過預訓練得到深度估計,需要標記檢測出的物體在BEV視角下的投影
監督真值是實例分割結果、可行駛區域,Loss 定義為預測結果與 groud truth 的交叉熵
在LSS源碼中,其感知范圍,BEV單元格大小,BEV下的網格尺寸如下:
輸入圖像大小為 128×352128 \times 352128×352
-
感知范圍
xxx軸方向的感知范圍 -50m ~ 50m;yyy軸方向的感知范圍 -50m ~ 50m;zzz軸方向的感知范圍 -10m ~ 10m; -
BEV單元格大小
xxx軸方向的單位長度 0.5m;yyy軸方向的單位長度 0.5m;zzz軸方向的單位長度 20m; -
BEV的網格尺寸
200×200×1200 \times 200 \times 1200×200×1; -
深度估計范圍
由于LSS需要顯式估計像素的離散深度,論文給出的范圍是4m ~ 45m,間隔為1m,也就是算法會估計41個離散深度; -
模型使用參數
imgs:輸入的環視相機圖片,imgs = (bs,N,3,H,W)(bs, N, 3, H, W)(bs,N,3,H,W),NNN代表環視相機個數,nuSence為N=6N=6N=6;
rots:由相機坐標系->車身坐標系的旋轉矩陣,rots=(bs,N,3,3)rots = (bs, N, 3, 3)rots=(bs,N,3,3);
trans:由相機坐標系->車身坐標系的平移矩陣,trans=(bs,N,3)trans=(bs, N, 3)trans=(bs,N,3);
intrinsic:相機內參,intrinsic=(bs,N,3,3)intrinsic = (bs, N, 3, 3)intrinsic=(bs,N,3,3);
post_rots:由圖像增強引起的旋轉矩陣,postrots=(bs,N,3,3)post_rots = (bs, N, 3, 3)postr?ots=(bs,N,3,3);
post_trans:由圖像增強引起的平移矩陣,posttrans=(bs,N,3)post_trans = (bs, N, 3)postt?rans=(bs,N,3);
binimgs:由于LSS做的是語義分割任務,所以會將真值目標投影到BEV坐標系,將預測結果與真值計算損失;具體而言,在binimgs中對應物體的bbox內的位置為1,其他位置為0;
二、算法步驟
? LSS算法的五個步驟如下:
- 1、生成視錐,并根據相機內外參將視錐中的點投影到ego坐標系;
- 2、對環視圖像進行特征提取,并構建圖像特征點云;
- 3、利用變換后的ego坐標系的點與圖像特征點云利用voxel pooling構建BEV特征;
- 4、對生成的BEV特征利用BEV Encoder做進一步的特征融合;
- 5、利用特征融合后的BEV特征完成語義分割
? 模型整體初始化函數,主要分為以下三個模塊:
-
CamEncode:圖像特征提取
-
BevEncode:BEV特征檢測
-
frustum:視錐,用于圖像點云坐標和BEV柵格間的坐標轉換
class LiftSplatShoot(nn.Module):def __init__(self, grid_conf, data_aug_conf, outC):super(LiftSplatShoot, self).__init__()self.grid_conf = grid_confself.data_aug_conf = data_aug_confdx, bx, nx = gen_dx_bx(self.grid_conf['xbound'], self.grid_conf['ybound'],self.grid_conf['zbound']) # ['xbound']:最小值、最大值以及步長# dx:X軸、Y軸和Z軸方向上的每個體素的大小 bx:整個網格中最開始的那個體素的中心位置# nx:每個軸上體素的數量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 = 16self.camC = 64self.frustum = self.create_frustum() # 1self.D, _, _, _ = self.frustum.shapeself.camencode = CamEncode(self.D, self.camC, self.downsample) # 2self.bevencode = BevEncode(inC=self.camC, outC=outC) # 3# toggle using QuickCumsum vs. autogradself.use_quickcumsum = Truedef get_voxels(self, x, rots, trans, intrins, post_rots, post_trans):# x: (B, N); rots, trans:相機外參,以旋轉矩陣和平移矩陣形式表示# intrins:相機內參; post_rots,post_trans:圖像增強時使用的旋轉矩陣和平移矩陣,用于在模型訓練時撤銷圖像增強引入的位姿變化geom = self.get_geometry(rots, trans, intrins, post_rots, post_trans)x = self.get_cam_feats(x)x = self.voxel_pooling(geom, x)return xdef forward(self, x, rots, trans, intrins, post_rots, post_trans):x = self.get_voxels(x, rots, trans, intrins, post_rots, post_trans)x = self.bevencode(x)return x
1、生成視錐,并根據相機內外參將視錐中的點投影到ego坐標系;
? 生成視錐的代碼如下:
def create_frustum():# 原始圖片大小 ogfH:128 ogfW:352ogfH, ogfW = self.data_aug_conf['final_dim']# 下采樣16倍(self.downsample)后圖像大小 fH: 8 fW: 22fH, fW = ogfH // self.downsample, ogfW // self.downsample # self.grid_conf['dbound'] = [4, 45, 1]# 在深度方向上劃分網格,即每個點的深度 ds: DxfHxfW (41x8x22)ds = torch.arange(*self.grid_conf['dbound'], dtype=torch.float).view(-1, 1, 1).expand(-1, fH, fW)"""1. torch.linspace(0, ogfW - 1, fW, dtype=torch.float)tensor([0.0000, 16.7143, 33.4286, 50.1429, 66.8571, 83.5714, 100.2857,117.0000, 133.7143, 150.4286, 167.1429, 183.8571, 200.5714, 217.2857,234.0000, 250.7143, 267.4286, 284.1429, 300.8571, 317.5714, 334.2857,351.0000])2. torch.linspace(0, ogfH - 1, fH, dtype=torch.float)tensor([0.0000, 18.1429, 36.2857, 54.4286, 72.5714, 90.7143, 108.8571,127.0000])"""# 在0到351上劃分22個格子 xs: DxfHxfW(41x8x22)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# 堆積起來形成網格坐標, frustum[k,i,j,0]就是(i,j)位置,深度為k的像素的寬度方向上的柵格坐標 frustum: DxfHxfWx3frustum = torch.stack((xs, ys, ds), -1) return nn.Parameter(frustum, requires_grad=False)
? 錐點由圖像坐標系向自車坐標系進行坐標轉化這一過程主要涉及到相機的內外參數,對應代碼中的函數為get_geometry()。
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# 1.抵消數據增強及預處理對像素的變化 RX+T=Y X=R^{-1}(Y-T)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# 但是自認為由于轉換過程是線性的,所以反歸一化是在圖像坐標系完成的,然后再利用求完逆的內參投影回相機坐標系# 轉換到真實坐標系再乘以內存去畸變,需要注意的是,上一步得到的 xy 是單位深度下的相機坐標,不同深度對應的 xy 是一樣的,因此需要乘以深度d才能得到真實世界的坐標points = torch.cat((points[:, :, :, :, :, :2] * points[:, :, :, :, :, 2:3],points[:, :, :, :, :, 2:3]), 5) # 反歸一化# 通過外參轉換到 BEV 坐標系下 (R(intrins)^-1)x + t = ycombine = rots.matmul(torch.inverse(intrins))points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1)points += trans.view(B, N, 1, 1, 1, 3)# (bs, N, depth, H, W, 3):其物理含義# 每個batch中的每個環視相機圖像特征點,其在不同深度下位置對應在ego坐標系下的坐標return points
2、get_cam_feats:對環視圖像進行特征提取camera_features,并構建圖像特征點云
- a)利用Efficientnet-B0主干網絡對環視圖像進行特征提取
? 輸入的環視圖像 (bs,N,3,H,W)(bs, N, 3, H, W)(bs,N,3,H,W),在進行特征提取之前,會將前兩個維度進行合并,一起提取特征,對應維度變換為 (bs,N,3,H,W)→(bs?N,3,H,W)(bs, N, 3, H, W) \rightarrow (bs * N, 3, H, W)(bs,N,3,H,W)→(bs?N,3,H,W);其輸出的多尺度特征尺寸大小如下:
level0 = (bs * N, 16, H / 2, W / 2)
level1 = (bs * N, 24, H / 4, W / 4)
level2 = (bs * N, 40, H / 8, W / 8)
level3 = (bs * N, 112, H / 16, W / 16)
level4 = (bs * N, 320, H / 32, W / 32)
- b)對其中的后兩層特征進行融合,豐富特征的語義信息,融合后的特征尺寸大小為(bs?N,512,H/16,W/16)(bs * N, 512, H / 16, W / 16)(bs?N,512,H/16,W/16)
Step1: 對最后一層特征升采樣到倒數第二層大小;
level4 -> Up -> level4' = (bs * N, 320, H / 16, W / 16)Step2:對主干網絡輸出的后兩層特征進行concat;
cat(level4', level3) -> output = (bs * N, 432, H / 16, W / 16)Step3:對concat后的特征,利用ConvLayer卷積層做進一步特征擬合;ConvLayer(output) -> output' = (bs * N, 512, H / 16, W / 16)其中ConvLayer層構造如下:
"""Sequential((0): Conv2d(432, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)(1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)(2): ReLU(inplace=True)(3): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)(4): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)(5): ReLU(inplace=True)
)"""
- c)估計深度方向的概率分布(用41維數據表示),并輸出特征圖每個位置的語義特征 (用64維的特征表示),整個過程用1×11 \times 11×1卷積層實現
整體pipeline
output' -> Conv1x1 -> x = (bs * N, 41+64=105, H / 16, W / 16)a)步驟輸出的特征:
output = Tensor[(bs * N, 512, H / 16, W / 16)]b)步驟使用的1x1卷積層:
Conv1x1 = Conv2d(512, 105, kernel_size=(1, 1), stride=(1, 1))c)步驟輸出的特征以及對應的物理含義:
x = Tensor[(bs * N, 105, H / 16, W / 16)]
第二維的105個通道分成兩部分;第一部分:前41個維度代表不同深度上41個離散深度;第二部分:后64個維度代表特征圖上的不同位置對應的語義特征;
-
d)對c)步驟估計出來的離散深度利用softmax()函數計算深度方向的概率密度
-
e)利用得到的深度方向的概率密度和語義特征通過外積運算構建圖像特征點云
# d)步驟得到的深度方向的概率密度
depth = x[:, :self.D] = (bs * N, 41, H / 16, W / 16) -> unsqueeze -> (bs * N, 1, 41, H / 16, W / 16)# c)步驟得到的特征,選擇后64維是預測出來的語義特征
x[:, self.D:(self.D + self.C)] = (bs * N, 64, H / 16, W / 16) -> unsqueeze(2) -> (bs * N, 64, 1, H / 16, W / 16)# 概率密度和語義特征做外積,構建圖像特征點云
# (bs * N, 1, 41, H / 16, W / 16) * (bs * N, 64, 1, H / 16, W / 16) -> (bs * N, 64, 41, H / 16, W / 16)
new_x = depth.unsqueeze(1) * x[:, self.D:(self.D + self.C)].unsqueeze(2)
# new_x = x[:, self.D:(self.D + self.C)].unsqueeze(2) * depth.unsqueeze(1)
3、利用ego坐標系下的坐標點與圖像特征點云,利用Voxel Pooling構建BEV特征
def voxel_pooling(self, geom_feats, x):# geom_feats:(B x N x D x H x W x 3):在ego坐標系下的坐標點;# x:(B x N x D x fH x fW x C):圖像點云特征B, N, D, H, W, C = x.shapedNprime = B * N * D * H * W # 將特征點云展平,一共有 B*N*D*H*W 個點 -> (B*N*D*H*W, C)x = x.reshape(Nprime, C) # flatten indicesgeom_feats = ((geom_feats - (self.bx - self.dx/2.)) / self.dx).long() # ego下的空間坐標轉換到體素坐標(計算柵格坐標并取整)geom_feats = geom_feats.view(Nprime, 3) # 將體素坐標同樣展平,geom_feats: (B*N*D*H*W, 3)batch_ix = torch.cat([torch.full([Nprime//B, 1], ix,device=x.device, dtype=torch.long) for ix in range(B)]) # 每個點對應于哪個batchgeom_feats = torch.cat((geom_feats, batch_ix), 1) # geom_feats: (B*N*D*H*W, 4)# filter out points that are outside box# 過濾掉在邊界線之外的特征點 x:0~199 y: 0~199 z: 0kept = (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 otherranks = 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()x, geom_feats, ranks = x[sorts], geom_feats[sorts], ranks[sorts] # 按照rank排序,這樣rank相近的點就在一起了# cumsum trick x: 64 x 1 x 200 x 200if 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)final = torch.zeros((B, C, self.nx[2], self.nx[0], self.nx[1]), device=x.device) # final: bs x 64 x 1 x 200 x 200final[geom_feats[:, 3], :, geom_feats[:, 2], geom_feats[:, 0], geom_feats[:, 1]] = x # 將x按照柵格坐標放到final中# collapse Zfinal = torch.cat(final.unbind(dim=2), 1) # 消除掉z維return final # final: bs x 64 x 200 x 200
- 采用cumsum_trick完成Voxel Pooling運算,代碼如下:
class QuickCumsum(torch.autograd.Function):@staticmethoddef forward(ctx, x, geom_feats, ranks):x = x.cumsum(0) # 求前綴和kept = torch.ones(x.shape[0], device=x.device, dtype=torch.bool) kept[:-1] = (ranks[1:] != ranks[:-1]) # 篩選出ranks中前后rank值不相等的位置x, geom_feats = x[kept], geom_feats[kept] # rank值相等的點只留下最后一個,即一個batch中的一個格子里只留最后一個點x = torch.cat((x[:1], x[1:] - x[:-1])) # x后一個減前一個,還原到cumsum之前的x,此時的一個點是之前與其rank相等的點的feature的和,相當于把同一個格子的點特征進行了sum, 1000 + (679-167) = 1512# save kept for backwardctx.save_for_backward(kept)# no gradient for geom_featsctx.mark_non_differentiable(geom_feats)return x, geom_feats
- 對生成的BEV特征利用BEV Encoder做進一步的特征融合 + 語義分割結果預測
? a)對BEV特征先利用ResNet-18進行多尺度特征提取,輸出的多尺度特征尺寸如下:
level0:(bs, 64, 100, 100)
level1: (bs, 128, 50, 50)
level2: (bs, 256, 25, 25)
? b)對輸出的多尺度特征進行特征融合 + 對融合后的特征實現BEV網格上的語義分割
Step1: level2 -> Up (4x) -> level2' = (bs, 256, 100, 100)Step2: concat(level2', level0) -> output = (bs, 320, 100, 100)Step3: ConvLayer(output) -> output' = (bs, 256, 100, 100)'''ConvLayer的配置如下
Sequential((0): Conv2d(320, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)(1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)(2): ReLU(inplace=True)(3): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)(4): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)(5): ReLU(inplace=True)
)'''Step4: Up2(output') -> final = (bs, 1, 200, 200) # 第二個維度的1就代表BEV每個網格下的二分類結果
'''Up2的配置如下
Sequential((0): Upsample(scale_factor=2.0, mode=bilinear)(1): Conv2d(256, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)(2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)(3): ReLU(inplace=True)(4): Conv2d(128, 1, kernel_size=(1, 1), stride=(1, 1))
)'''
? 最后就是將輸出的語義分割結果與binimgs的真值標注做基于像素的交叉熵損失,從而指導模型的學習過程。
三、總結
?1、精度低,對內外參敏感,魯棒性差
- 地平面假設在實際工況中,只有較少的場景下滿足,且距離越遠效果越差;
- 對有高度的目標,投影后被拉長,畸變嚴重
?2、成本低
- 特征轉換過程非常直觀,計算量相對比較小。
四、參考鏈接
[1] https://zhuanlan.zhihu.com/p/589146284
[2]https://mp.weixin.qq.com/s?__biz=MjM5NDQwNzMxOA==&mid=2650930587&idx=1&sn=4209bfa3f3a6a9816965ddc839f3cbb5&scene=21&poc_token=HBoBamij0VvmKL9LA_G_VA6K1QDijMvSKDbsCrj1