这是一篇发布在CVPR18年的点云检测方面的文章,这一篇文章算是第一篇只用点云去做3D检测的文章,类似于pointnet的点云分割中的地位。
这里是 paper
这里是 code
个人觉得仍然可以提高的部分:
大致文章思路都是这样:3D检测应用和其重要性(自动驾驶,housekeeping robots),lidar数据特性,目前的研究方案:
RPN网络:
本文网络处理过程:
待补充
对每一个voxel来说,对里边的所有点而言均是如下操作,这里只描述对点 p i ( x i , y i , z i , r i ) p_i(x_i,y_i,z_i,r_i) pi(xi,yi,zi,ri),第四维的 r i r_i ri表达的是反射率(1)计算所有点的均值,记做 ( v x , v y , v z ) (v_x,v_y,v_z) (vx,vy,vz)(2)对于点 p i p_i pi我们采用对中心的偏移量来增大表示其坐标 p i ^ = [ x i , y i , z i , r i , x i − v x , y i − v y , z i − v z ] \widehat{p_i}=[x_i,y_i,z_i,r_i,x_i-v_x,y_i-v_y,z_i-v_z] pi =[xi,yi,zi,ri,xi−vx,yi−vy,zi−vz],(这样表达有什么好处,实则并没有信息增加,但是却把全局特征 x i , y i , z i x_i,y_i,z_i xi,yi,zi和局部残差结合到了一起)(3)对每一个的 p i ^ \widehat{p_i} pi 通过FCN转化到特征空间,特征空间表示可以将点特征整合来描述voxel所表达的表面特性。FCN层由一个线性层,一个BN,一个RELU层组成。这样就得到了point-wise-feature。得到的特征记做 f i f_i fi(4)再通过element-wise maxpooling层,结合周围的所有的点得到locally-aggregated feature: f ~ \widetilde{f} f 。(5)最后一步是将 f ~ \widetilde{f} f 和 f i f_i fi结合为 f o u t f_{out} fout。那么对于一个含有t个点的voxel,最终的特征我们采用集合表达方式为: V o u t = { f i o u t } i … t \mathbf{V}_{\mathrm{out}}=\left\{\mathbf{f}_{i}^{o u t}\right\}_{i \ldots t} Vout={fiout}i…t
这一结构比较大的作用在于(1)既保留点的特征又结合了局部结构特征,使得其可以学习shape特征。(2)不同voxel之间结合可以得到voxel-wise特征。类比于point-wise特征。
采用3D卷积的方式,采用 C o n v M D ( C i n , C o u t , k , s , p ) ConvMD(C_{in},C_{out},k,s,p) ConvMD(Cin,Cout,k,s,p)的结构来表达,含义分别是 c h a n n e l − i n , c h a n n e l − o u t , k e i s i z e ( k ∗ k ∗ k ) , s t r i d e − s i z e , p a d d i n g channel-in,channel-out,keisize(k*k*k),stride-size,padding channel−in,channel−out,keisize(k∗k∗k),stride−size,padding。基础结构有3D卷积,BN和Relu。
本文对RPN网络做一些关键性的修改。网络的整体结构如下。网路做如下描述:
如图,每一个block都存在两个cov2D层,通过第一个层的stride=2来使得feature map的 w , l 和 h w,l和h w,l和h变成 { w / 2 , l / 2 , h / 2 } \{w/2,l/2,h/2\} {w/2,l/2,h/2};在每一个CON2D后面也都会有对应的BN和RELU层。
讲每一层的输出都上采样到一个统一的size(第一个block不用上采样)
注意到上面的输出是对于socores的通道是2,分别对应中 p o s i t i v e positive positive和 n e g a t i v e negative negative的分数,而后的regression map存在14维,那是因为对于每一个回归的Bbox都用7维来表示: ( x c g , y c g , z c g , l g , w g , h g , θ g ) \left(x_{c}^{g}, y_{c}^{g}, z_{c}^{g}, l^{g}, w^{g}, h^{g}, \theta^{g}\right) (xcg,ycg,zcg,lg,wg,hg,θg),也即是中心位置+长宽高+yaw轴。这里另外两个旋转轴没有加入是默认为0,原因是地面水平。(假设加入进去是不是可以更好的定位物体,感觉是个不错的方向)。同理,我们假设预测的anchor位置为: ( x c a , y c a , z c a , l a , w a , h a , θ a ) \left(x_{c}^{a}, y_{c}^{a}, z_{c}^{a}, l^{a}, w^{a}, h^{a}, \theta^{a}\right) (xca,yca,zca,la,wa,ha,θa)。因此我们可以定义如下的残差:
Δ x = x c g − x c a d a , Δ y = y c g − y c a d a , Δ z = z c g − z c a h a Δ l = log ( l g l a ) , Δ w = log ( w g w a ) , Δ h = log ( h g h a ) Δ θ = θ g − θ a \begin{array}{l}{\Delta x=\frac{x_{c}^{g}-x_{c}^{a}}{d^{a}}, \Delta y=\frac{y_{c}^{g}-y_{c}^{a}}{d^{a}}, \Delta z=\frac{z_{c}^{g}-z_{c}^{a}}{h^{a}}} \\ {\Delta l=\log \left(\frac{l^{g}}{l^{a}}\right), \Delta w=\log \left(\frac{w^{g}}{w^{a}}\right), \Delta h=\log \left(\frac{h^{g}}{h^{a}}\right)} \\ {\Delta \theta=\theta^{g}-\theta^{a}}\end{array} Δx=daxcg−xca,Δy=daycg−yca,Δz=hazcg−zcaΔl=log(lalg),Δw=log(wawg),Δh=log(hahg)Δθ=θg−θa
其中 d a d^a da是anchor框底部的对角线长度,表示为:
d a = ( l a ) 2 + ( w a ) 2 d^{a}=\sqrt{\left(l^{a}\right)^{2}+\left(w^{a}\right)^{2}} da=(la)2+(wa)2
文章说采用 d a d^a da来对 Δ x 和 Δ y \Delta{x}和\Delta{y} Δx和Δy进行统一的归一化,为啥要采用 d a d^a da呢?(参考其引用的文献)
于是作者定义了损失函数:
L = α 1 N p o s ∑ i L c l s ( p i p o s , 1 ) + β 1 N n e g ∑ j L c l s ( p j n e g , 0 ) + 1 N p o s ∑ i L r e g ( u i , u i ∗ ) \begin{aligned} L &=\alpha \frac{1}{N_{\mathrm{pos}}} \sum_{i} L_{\mathrm{cls}}\left(p_{i}^{\mathrm{pos}}, 1\right)+\beta \frac{1}{N_{\mathrm{neg}}} \sum_{j} L_{\mathrm{cls}}\left(p_{j}^{\mathrm{neg}}, 0\right) \\ &+\frac{1}{N_{\mathrm{pos}}} \sum_{i} L_{\mathrm{reg}}\left(\mathbf{u}_{i}, \mathbf{u}_{i}^{*}\right) \end{aligned} L=αNpos1i∑Lcls(pipos,1)+βNneg1j∑Lcls(pjneg,0)+Npos1i∑Lreg(ui,ui∗)
损失函数中的前面两项是归一化判别损失,其中的 p i p o s p_i^{pos} pipos和 p j n e g p_j^{neg} pjneg分别表示 s o f t m a x softmax softmax层对 a i p o s a_i^{pos} aipos和 a j n e g a_j^{neg} ajnegd的分数。采用的是交叉熵表示, α \alpha α和 β \beta β为正定平衡系数。最后一项是回归损失,采用的是SmoothL1损失。
(1)第一步是初始化一个tensor,储存为 K × T × 7 K\times{T}\times{7} K×T×7的feature buffer。K表示一个有存在的非空的体素的最大值,T表示的每个voxel中点的最大值。7就是每一个点编码的特征维度。(2)第二步是对于点云中的任意一个点去查找它所在的voxel是否初始化,如果已经初始化了那么就检查是否大于T个点,没有的话就插入进去,否则不要这个点。假如这个voxel没有被初始化,那么久需要初始化并把这个点加入进去。(3)建立了input buffer之后呢,采用GPU的并行计算对点级别和voxel级别的VFE计算,作者提到,在VFE中的concate之后,把没有点的特征再次设置为0。最后,使用存储的坐标缓冲区,我们将计算的稀疏体素结构重新组织到密集体素网格
值得理解的是上面的图的储存示意图,Input buffer的正对面应该是 T × K T \times K T×K,经过VFE提取后,加大了特征维数,然后再采用一种稀疏的表达方式。
输入:
假设预测出的anchor在鸟瞰图中具有和gt最大的IOU或则它与gt的IOU值大于0.6就为positive.如果和所有的gt都小于0.45,就是negtative,处于0.45~0.6之间的不做处理。
这里下载的是pytorch版本的代码,新手学习可能比较快一点。后面就是记录一步一步怎么从一点不懂到把代码运行起来。
阅读程序第一步都是先找到这个文件,看看环境和文件架构等。voxelnet的pytorch版本的代码的需求如下:
# Dependencies
- `python3.5+`
- `Pytorch` (tested on 0.4.1)
- `TensorBoardX` (tested on 1.4)
- `OpenCV`
- `Pillow` (for add_image in TensorBoardX)
- `Boost` (for compiling evaluation code)
我们都采用 pip install *** -i https://pypi.tuna.tsinghua.edu.cn/simple
加上清华源就快很多了。
按照readme.md文件的指示,第一步先执行如下程序,编译box_overlaps的Cython模块:
python utils/setup.py build_ext --inplace
接着编译nms模块:
cd eval/KITTI
g++ -I path/to/boost/include -o evaluate_object_3d_offline evaluate_object_3d_offline.cpp
接着给eval文件一个可执行权限:
cd eval/KITTI
chmod +x launch_test.sh
从KITTI网页一共需要下载4个zip文件:
文件下载下来后,源代码中如下图表示,我们是放在KITTI下面,不是MD_KITTI,KITTI后面的结构和MD_KITTI也不一样。我们此时要组织的结构就是后面这个KITTI的。这里的MD_KITTI后续程序会自行安排。这里先不用管。
下载下来的数据,按照原文的说法是不能都直接用的,是需要进行裁剪的。源码中裁剪的方式大致是如下:
第一步是选择具有反射率大于0的点,也就是第四维r值大于0.然后将其置为1,这样就可以使得点云数据直接和相机投影矩阵相乘。方便后续通过image的坐标来除去多余的点云。(2)通过image的坐标参数,只把在相机前面的点(z>0)的点投影到二维上面。投影的方式是直接乘上一个从calib中得到的参数。(3)通过Image的大小来投影的2维图片来对点云进行裁剪,裁剪的同时加入了很多新的信息,包括了color(这个通过投影图片处理得到),归一化后的投影图片等。。(具体的还需要再研究)
下面为插入内容
对上面的内容进行更新一下:(1)我们下载的点云投影到相机平面的数据是calib_velo_to_cam.txt,表示的是点云到相机的定位文件。在KITTI中还有文件calib_cam_to_cam.txt(相机到相机的标定)。(2)相机和点云的坐标定义:相机(x:右,y:下,z:前) 。点云(x:前,y:左,z:上),也就证实了上文中投影在相机前面的点时采用z>0为判定条件。(3)计算点云到图像的投影矩阵,如下展开说:
参考这篇博客:
计算点云到图像的投影矩阵需要三个参数,分别是P_rect(相机内参矩阵)和R_rect(参考相机0到相机xx图像平面的旋转矩阵)以及Tr_velo_to_cam(点云到相机的[R T]外参矩阵)。
% 计算点云到图像平面的投影矩阵
R_cam_to_rect = eye(4);
R_cam_to_rect(1:3,1:3) = calib.R_rect{1}; % 参考相机0到相机xx图像平面的旋转矩阵
P_velo_to_img = calib.P_rect{cam+1}*R_cam_to_rect*Tr_velo_to_cam; % 内外参数
投影矩阵乘以点云坐标。在此之前需要把点云填充到四维的齐次坐标,也就是加1。原博客是把前三维作为输入因此需要转化为齐次坐标。投影矩阵是4* 4的。
T*p2_in’
插入内容结束
预处理的下面就只执行该文件,执行前,先修改该文件下的路径信息为你自己的路径信息。
执行:
python crop.py
这样就会输出裁剪后的点云了,并且是覆盖在之前的点云文件中。如下:
接下来就是执行生成MD_KITTI文件夹了,在这之前需要去下载一个分数据的 文件;并且把路径放置如下面图的59行和60行的意思。当然要换成你自己的路径。分割的数据在刚刚crop的training 的数据上进行的。在执行之前,需要把文件夹preproc下的split.py文件下的文件路径改成你自己的路径,如下:
然后再运行split.py就可以了
python split.py
MD_KITTI文件就此生成了。
讲一下分数据的方法。下载分割数据txt文件中随机选了很多数据名称,分割就按照这个名字分为训练数据和训练时的测试数据。
作者在这里说,这个代码只能单个GPU跑。在train之前先去config.py中改一下对应的文件路径和gpu的使用。下面就可以跑程序了,对于car的损失函数的值,在原文中的设置如下:
python train.py --alpha 1.5 --beta 1
自己是个新手,以前很少用python,也不知道理解的是否正确,有问题的话希望可以只纠正指出。主要把对代码的初步理解分为下面几个部分:
数据的数据在网络中流动,按照每一个层的方式进行记录
网络的输入是 b a t c h × N × 4 batch \times N\times 4 batch×N×4的数据格式,即是batch(代码中设置为1,即每次值输入一个Bin文件),一个bin文件中的点云数量N、每个点云的维度 ( x , y , z , r ) (x,y,z,r) (x,y,z,r)。
我们知道该层的处理是首先分区,输入点云经过裁剪后的 D × W × H = 4 × 80 × 70.4 D \times W \times H=4\times80\times70.4 D×W×H=4×80×70.4,实际上的范围可以在config.py文件中看到: X 轴 : ( 0 , 70.4 ) ; Y 轴 : ( − 4. , 40 ) ; Z 轴 : ( − 3 , 1 ) X轴:(0,70.4);Y轴:(-4.,40);Z轴:(-3,1) X轴:(0,70.4);Y轴:(−4.,40);Z轴:(−3,1);然后经过partion,代码中把每一个voxel的最大的点云量设置为 T = 35 T=35 T=35;所以我们假设一共有 k k k个非空的体素,经过这样处理后我们将点云表示为 K × T × 4 K\times T \times 4 K×T×4。
接下来就是对全局信息和局部信息的综合。通过求每一个局部voxel的点云的均值,再通过残差的方式加入到每个点的信息中,即会增加三个局部信息的维度: K × T × 7 K\times T \times 7 K×T×7。
下面就是输入到本文最重要的VFE结构了。这里的数据变换如下:
原始数据为 [ B , K , T , 7 ] [B,K,T,7] [B,K,T,7];在生成大小为 [ B , K , T , 128 ] [B,K,T,128] [B,K,T,128]的pointwise_feature经过了两个VFE层,在原文中说只用学习一半的参数,对于我这种新手来说就很难理解,看了代码才明白,实际上在每一层都有一个最大池化的cat操作。VEF层的定义如下:
self.dense = nn.Sequential(nn.Linear(self.in_channels, self.out_channels/2), nn.ReLU())#
self.batch_norm = nn.BatchNorm1d(self.units)
仅仅看上面的我们知道输出的通道值仅仅为目标输出值的一半,在后续的forward()函数中,有这样一句操作:
concatenated = torch.cat([pointwise, repeated], dim = 2)#整合了最大值结构
也就是如此操作的:
假设输入的通道数为7,目标通道数为32,VFE先学习 7 × 16 7 \times 16 7×16的矩阵变换,此时的数据shape变为 [ K , T , 16 ] [K,T,16] [K,T,16](这里数据为什么少了一维,后面会说到);然后对第三个维度 d i m = 1 dim=1 dim=1的temp,也就是每一个特征维都找最大值。最后组合一下这两个,生成shape为 [ K , T , 32 ] [K,T,32] [K,T,32]的输出
然后VFE_1,VFE_2的定义:
self.vfe1 = VFELayer(7, 32)
self.vfe2 = VFELayer(32, 128)
经过了VFE_2层后shape变为 [ B , K , T , 128 ] [B,K,T,128] [B,K,T,128]每一个点都是含有128维的。这里需要说明的是在这一份pythorch的实现代码中是没有带Batch维度的,而是采用的for循环。原因是源代码在tf下是可以在每个batch的点数目不一致的时候使用,而pytorch不行。
在FeatureNet的forward函数中传入的feature大小是 [ B , K , T , 7 ] [B,K,T,7] [B,K,T,7]的原始数据,经过了torch.cat(dim=0)后减少一个维度feature变为 [ K , T , 7 ] [K,T,7] [K,T,7](本来B=1);然后feature再取最后一个维度的最大值vmax [ K , T , 1 ] ( 为 了 产 生 m a s k , 具 体 作 用 后 面 研 究 ) [K,T,1](为了产生mask,具体作用后面研究) [K,T,1](为了产生mask,具体作用后面研究),在经过了刚刚的两个VFE后feature变为为 [ K , T , 128 ] [K,T,128] [K,T,128];voxelwise在feature第二维度(dim=1)上取最大值也就是voxelwise的shape为 [ K , 1 , 128 ] [K,1,128] [K,1,128]。在pytorch中也就是 [ K , 128 ] [K,128] [K,128]
下面这句是这一层的最后一步,首先前面的coordinate( s h a p e [ K , 4 ] shape[K,4] shape[K,4],这个表示的voxel的坐标,需要进一步研究),voxelwise的shape为 [ K , 128 ] [K,128] [K,128],这两个参数也正正好好的表达了每一个voxel的坐标和特征维度。(具体这么用四维来表达的一个voxel的坐标,需要进一步研究);最后将特征和坐标表达为稀疏表示。
outputs = torch.sparse.FloatTensor(coordinate.t(), voxelwise, torch.Size([batch_size, cfg.INPUT_DEPTH, cfg.INPUT_HEIGHT, cfg.INPUT_WIDTH, 128]))
最后得到的outpu的shape为 [ b a t c h s i z e , I N P U T − D E P T H , I N P U T − H E I G H T , I N P U T − W I D T H , 128 ] [batch_size, INPUT-DEPTH, INPUT-HEIGHT, INPUT-WIDTH, 128] [batchsize,INPUT−DEPTH,INPUT−HEIGHT,INPUT−WIDTH,128],大小也就是对应的体素总个数的shape为: [ 1 , 10 , 400 , 352 , 128 ] [1,10,400,352,128] [1,10,400,352,128],其中最后一维的含义为特征维度。上一句代码的含义就是说:每一个voxel的坐标的地方都有一个128维度的特征向量。需要指出的是原文说了会把剩下的空voxel的特征补上0.
总结一下这一层,这里做的操作就是对每一个voxel的特征进行提取,但是为了不操作空的呢,就只对非空的进行提取;同时VFE层采用了局部和全局坐标结合的方式特取特征;同时又为了解决有的voxel的点不足T个,所有最后采用最大池化表示了一个voxel的特征。
这一层在文中仅仅是一个概述,实际上呢,也是唯一一个用到3D卷积的地方了。也十分简单。
上一层的输出为: [ 1 , 10 , 400 , 352 , 128 ] [1,10,400,352,128] [1,10,400,352,128],代码为:
self.middle_layer = nn.Sequential(ConvMD(3, 128, 64, 3, (2, 1, 1,), (1, 1, 1)),
ConvMD(3, 64, 64, 3, (1, 1, 1), (0, 1, 1)),
ConvMD(3, 64, 64, 3, (2, 1, 1), (1, 1, 1)))
这里的第一个参数3表示的3D卷积的含义,原文有进行封装处理;所以这一层的shape变化为:
[ 1 , 10 , 400 , 352 , 128 ] 首 先 交 换 一 下 维 度 的 次 序 为 [ 1 , 128 , 10 , 400 , 352 ] 经 过 第 一 个 3 D 卷 积 后 为 [ 1 , 64 , 5 , 400 , 352 ] [1,10,400,352,128]首先交换一下维度的次序为[1,128,10,400,352]经过第一个3D卷积后为[1,64,5,400,352] [1,10,400,352,128]首先交换一下维度的次序为[1,128,10,400,352]经过第一个3D卷积后为[1,64,5,400,352],这里说明一下交换后的通道表示的含义为 ( B , D , H , W , C ) − > ( B , C , D , H , W ) (B, D, H, W, C) -> (B, C, D, H, W) (B,D,H,W,C)−>(B,C,D,H,W)深度这一维度是有在减少。(2)经过第二个卷积后变成 [ 1 , 64 , 4 , 400 , 352 ] [1,64,4,400,352] [1,64,4,400,352],因为其padding在深度上为0;(3)经过第三个3D卷积后为 [ 1 , 64 , 2 , 400 , 352 ] [1,64,2,400,352] [1,64,2,400,352]。
总结一下,这一层用到了3D卷积,没有其他的了。
代码中RPN和Middle层是放在一起的。代码不长,如下:
self.block1 = nn.Sequential(ConvMD(2, 128, 128, 3, (2, 2), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)))
self.deconv1 = Deconv2D(128, 256, 3, (1, 1), (1, 1))
self.block2 = nn.Sequential(ConvMD(2, 128, 128, 3, (2, 2), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)),
ConvMD(2, 128, 128, 3, (1, 1), (1, 1)))
self.deconv2 = Deconv2D(128, 256, 2, (2, 2), (0, 0))
self.block3 = nn.Sequential(ConvMD(2, 128, 256, 3, (2, 2), (1, 1)),
ConvMD(2, 256, 256, 3, (1, 1), (1, 1)),
ConvMD(2, 256, 256, 3, (1, 1), (1, 1)),
ConvMD(2, 256, 256, 3, (1, 1), (1, 1)),
ConvMD(2, 256, 256, 3, (1, 1), (1, 1)),
ConvMD(2, 256, 256, 3, (1, 1), (1, 1)))
self.deconv3 = Deconv2D(256, 256, 4, (4, 4), (0, 0))
self.prob_conv = ConvMD(2, 768, 2, 1, (1, 1), (0, 0), bn = False, activation = False)
self.reg_conv = ConvMD(2, 768, 14, 1, (1, 1), (0, 0), bn = False, activation = False)
这里顺便再把原文的RPN贴出来,对比一看就很清楚了
需要注意的是:这份代码貌似第一次卷积的时候多了一层,应该是我上面文章的fliter个数,代码中比原文多一层。
这里没什么好说的,数据在Middle layer最后输出的大小为:
[ b a t c h , 64 , 2 , 400 , 352 ] [batch, 64, 2, 400, 352] [batch,64,2,400,352],为了可以用二维的卷积,我们把D维和特征维度融合(这里的D为什么是2,是为了和pos,neg对应起来吗?),变成: [ b a t c h , 128 , 400 , 352 ] [batch, 128, 400, 352] [batch,128,400,352];
经过block1输出为 [ b a t c h , 128 , 200 , 176 ] [batch, 128, 200, 176] [batch,128,200,176];
经过block2输出为 [ b a t c h , 128 , 100 , 88 ] [batch, 128, 100, 88] [batch,128,100,88];
经过block3输出为 [ b a t c h , 128 , 50 , 44 ] [batch, 128, 50, 44] [batch,128,50,44];
这里的反卷积操作:
第一个block的输出 [ b a t c h , 128 , 200 , 176 ] [batch, 128, 200, 176] [batch,128,200,176]经过deconv1后输出为 [ b a t c h , 256 , 200 , 176 ] [batch, 256, 200, 176] [batch,256,200,176];
第二个block的输出 [ b a t c h , 128 , 100 , 88 ] [batch, 128, 100, 88] [batch,128,100,88]经过deconv2后输出为 [ b a t c h , 256 , 200 , 176 ] [batch, 256, 200, 176] [batch,256,200,176];
第三个block的输出 [ b a t c h , 256 , 50 , 44 ] [batch, 256, 50, 44] [batch,256,50,44]经过deconv3后输出为 [ b a t c h , 256 , 200 , 176 ] [batch, 256, 200, 176] [batch,256,200,176];
也就是上图所示的那样,但是呢。原文图上和代码的deconv1大小不一样,论文应该有误。
组合块 [ 1 , 768 , 200 , 176 ] [1,768,200,176] [1,768,200,176]
接下来这一步就是在对最后得到的组合块 [ 1 , 768 , 200 , 176 ] [1,768,200,176] [1,768,200,176]进行压缩为cls和reg map:
直接暴力压缩到一个通道数为2 [ b a t c h , 2 , 200 , 176 ] [batch, 2, 200, 176] [batch,2,200,176],另外一个通道数为14 [ b a t c h , 14 , 200 , 176 ] [batch, 14, 200, 176] [batch,14,200,176]。(这里为什么是2和14这个数字呢,我试着解释一下,我们知道最初的voxel个数是 400 × 352 400 \times 352 400×352个,我们的anchor是根据这个数目来的,采样的频率是2,也就是在x和y轴上每隔一个单位选择一个为anchor,所以个数是 200 × 176 200\times 176 200×176个,每一个anchor具有七个特征属性(这里考虑0度和90度的旋转的情形),那么每一个anchor就存在14个特征维度,每一个角度分配一组特征)。
def cal_anchors():
# Output:
# Anchors: (w, l, 2, 7) x y z h w l r
x = np.linspace(cfg.X_MIN, cfg.X_MAX, cfg.FEATURE_WIDTH)
y = np.linspace(cfg.Y_MIN, cfg.Y_MAX, cfg.FEATURE_HEIGHT)
cx, cy = np.meshgrid(x, y)
# All are (w, l, 2)
cx = np.tile(cx[..., np.newaxis], 2)
cy = np.tile(cy[..., np.newaxis], 2)
cz = np.ones_like(cx) * cfg.ANCHOR_Z
w = np.ones_like(cx) * cfg.ANCHOR_W
l = np.ones_like(cx) * cfg.ANCHOR_L
h = np.ones_like(cx) * cfg.ANCHOR_H
r = np.ones_like(cx)
r[..., 0] = 0 # 0
r[..., 1] = 90 / 180 * np.pi # 90
# 7 * (w, l, 2) -> (w, l, 2, 7)
anchors = np.stack([cx, cy, cz, h, w, l, r], axis = -1)
return anchors
生成的代码如上,我的理解如下:
这里需要配合config.py中的参数一起看,就很好的解释了。
np.meshgrid(x, y)
生成格网坐标;np.tile(cx[..., np.newaxis], 2)
这一句是为了得到两个一样的cx,是不是因为后续的0度和90度各需要一组?暂且这么理解。后续的内容也是每个有两组坐标。这里的z坐标是根据anchor的height来的,使用了先验知识:车都是停在路面上的。所以一开始都只是在x和y轴上进行了采样,z轴一般是固定为车心的高度。同样使用了先验的还有旋转轴,本来可以使yaw,roll和pitch;但是车只能在路面上yaw轴旋转;因此也就少了两个维度。
anchors = np.stack([cx, cy, cz, h, w, l, r], axis = -1)
这里的anchor每一个维度的含义分别是:坐标位置(x,y,z);文章设置的anchor_size(h,w,l);可动角度yaw ( r );这和在VFE中的七个特征维度没有半毛钱的关系。
这里的np.stack()之前各个特征的大小都应该是 200 × 176 × 2 200 \times 176 \times 2 200×176×2(这里直接把在x和y上的采得到的数据写上了);但是除r外,每个特征的两组数据都是一样的;经过np.stack()我们的数据会分成两个组,前后两组数据最后一个特征不同,其余的相同;形如:
[x,y,z,,h,w,l,0]和[x,y,z,,h,w,l,pi/2]
就此,anchors就算生成成功了,其对应的shape为 [ 200 , 176 , 2 , 7 ] [200,176,2,7] [200,176,2,7]
每个训练的点云文件都有自己的labels,大小不一,有的是5个,有的有17个。其shape为[bitch,gt_box_num]
;这里的gt_box_num大小不一,这也是batch在pytorch只能设置为1,其对应的gt_box_num不固定。每一个gt_box都具有15个维度。第一个维度表征是对谁的label(可以是car,也可以是pes等等),剩下的14维是特征。
因此首要的任务是对gtbox的特征转化,在代码中采用label_to_gt_box3d()
函数进行操作,具体流程如下:
(1)确定是表征哪一类的gt_box ;对于car的gtbox先转化为carmer_box
(2)转化为carmer_box:14维特征后面七个维度是carme_box的,取出来就可以了
(3)carmer_box到lidar_box:使用函数camera_to_lidar_box
;具体是对(x,y,z)的变换是先扩充到4维度再乘以变换矩阵(上文补充资料中讲过)得到在lidar的坐标(x,y,z);对旋转度r: r = − r c a m e r a − p i / 2 , 这 里 用 角 度 限 制 在 0 到 360 r=-r_{camera}-pi/2,这里用角度限制在0 到360 r=−rcamera−pi/2,这里用角度限制在0到360;其余三个l,h,w不需要变化。
既然都得到了3DBBOX,那么就可以按照损失函数求对应的损失了。先给出损失原文函数:
L = α 1 N p o s ∑ i L c l s ( p i p o s , 1 ) + β 1 N n e g ∑ j L c l s ( p j n e g , 0 ) + 1 N p o s ∑ i L r e g ( u i , u i ∗ ) \begin{aligned} L &=\alpha \frac{1}{N_{\mathrm{pos}}} \sum_{i} L_{\mathrm{cls}}\left(p_{i}^{\mathrm{pos}}, 1\right)+\beta \frac{1}{N_{\mathrm{neg}}} \sum_{j} L_{\mathrm{cls}}\left(p_{j}^{\mathrm{neg}}, 0\right) \\ &+\frac{1}{N_{\mathrm{pos}}} \sum_{i} L_{\mathrm{reg}}\left(\mathbf{u}_{i}, \mathbf{u}_{i}^{*}\right) \end{aligned} L=αNpos1i∑Lcls(pipos,1)+βNneg1j∑Lcls(pjneg,0)+Npos1i∑Lreg(ui,ui∗)
代码中是这样求损失的:
总的来说,使用的是函数cal_rpn_target()
,这个函数输入的是标签labesls([bitch,gt_box_num])
和anchors(200,176, 2, 7)
,然后把他们都分别转化到鸟视图上,进行RPN操作。这个函数的输出是shape为[batch,200,176,2]
的pos_equal_one和[batch,200,176,2]
的neg_equal_one以及表示回归的(batch, 200,176, 14)
的位置回归。后续会解释为什么是这个shape。那么具体的操作如下:(这里假设已经把labes转化为了gt_box了)
首先是把3维度转化到二维上来:
(1)把 200 × 176 × 2 200 \times 176 \times 2 200×176×2个bbox合成为二维的,即是: 70400 × 7 70400 \times 7 70400×7的大小,而gtbox大小为: 2 × 7 2\times 7 2×7;
(2)计算需要的中间变量: d a d_a da、
(3)将anchors的3DBbox转化到2DBbox中,使用函数anchor_standup_box2d()
:第一步:只需要用到7维特征中的 ( x , y , w , l ) (x,y,w,l) (x,y,w,l)四个维度,因此将其取出;第二步:根据长方形中心点和长宽的关系得到左上和右下的角点坐标;
(4)将gtbox的3Dbox转化到2Dbox中。会使用到 ( x , y , w , l , r ) (x,y,w,l,r) (x,y,w,l,r) 5个特征维度,这是因为gtbox的旋转度要对应到anchor的Box当中来,也就是一个gt_box要分成两个对应的feature_box,也就是 ( l a b e l n u m , 5 , 1 ) − > ( l a b e l n u m , 4 , 2 ) (labelnum, 5,1) -> (labelnum, 4, 2) (labelnum,5,1)−>(labelnum,4,2)的维度变化。第一步:取出5个特征维度先变成正常的3Dbox ( l a b e l n u m , 7 , 1 ) (labelnum, 7,1) (labelnum,7,1)(那5个维度以外的数据用0填补就可以了)。第二步: ( l a b e l n u m , 7 ) − > ( l a b e l n u m , 8 , 3 ) (labelnum, 7) -> (labelnum,8, 3) (labelnum,7)−>(labelnum,8,3);这一步有超级多的变换,没有深入研究。第三步: ( l a b e l n u m , 7 ) − > ( l a b e l n u m , 4 , 2 ) (labelnum, 7) -> (labelnum,4, 2) (labelnum,7)−>(labelnum,4,2);这里的操作就是直接取(labelnum,8, 3)里面的元素就可以了。
此时已经分别得到了anchor的二维box和gt的二维box,下面的步骤就是在二维上对计算的IOU进行一些筛选得到pos的anchor:
(1)使用函数
bbox_overlaps
得到IOU矩阵,shape为74600*gt_num
,第一维度对应到一共有多少个anchor,第二个维度表示gt的个数。
(2)找到每一个gt对应的IOU最大的anchor的编号id_highest
(3)在[0,gt_num]均匀采样出id_highest_gt[0,1,2,...,gt_num-1]
(4)根据id_highest和id_highest_gt创建mask[1,gt_num]
;
(5)上面求得了第一种的pos_anchor,文章中说过如果anchor对任意一个gt的IOU大于0.6,那么也算是pos。这一步就是选取那些大于0.6的anchor。得到id_pos, id_pos_gt
的组合;再和前面IOU最高的组合成id_pos,id_pos_gt
。再除去那些既是最大IOU又大于0.6的anchor。
(6)既然求了pos,也要求neg,对于小于0.45的算是neg。得到id_neg
,这里只有neg的编号,因为gt是爸爸,没有neg。
(7)接下来就要在对应的3D坐标中找到数哪些anchor是pos的,采用函数:
index_x, index_y, index_z = np.unravel_index(id_pos, (*feature_map_shape, 2)) pos_equal_one[batch_id, index_x, index_y, index_z] = 1
这里的两句就是把对应Pos的anchor变为1.
(8)下面一步就是根据上面的数据求对应特征的偏差,也就是:
Δ x = x c g − x c a d a , Δ y = y c g − y c a d a , Δ z = z c g − z c a h a Δ l = log ( l g l a ) , Δ w = log ( w g w a ) , Δ h = log ( h g h a ) Δ θ = θ g − θ a \begin{array}{l}{\Delta x=\frac{x_{c}^{g}-x_{c}^{a}}{d^{a}}, \Delta y=\frac{y_{c}^{g}-y_{c}^{a}}{d^{a}}, \Delta z=\frac{z_{c}^{g}-z_{c}^{a}}{h^{a}}} \\ {\Delta l=\log \left(\frac{l^{g}}{l^{a}}\right), \Delta w=\log \left(\frac{w^{g}}{w^{a}}\right), \Delta h=\log \left(\frac{h^{g}}{h^{a}}\right)} \\ {\Delta \theta=\theta^{g}-\theta^{a}}\end{array} Δx=daxcg−xca,Δy=daycg−yca,Δz=hazcg−zcaΔl=log(lalg),Δw=log(wawg),Δh=log(hahg)Δθ=θg−θa
这一堆东西。储存在变量targets
中,shape为[1,200,176,14]
(9)对neg的就不要求上面的残差了,只需要知道体素中哪个位置是neg的。
(10)这是后就需要想,我们怎么用得到的得到的pos进行对pos的anchor位置回归。(这个后续再说)
那我们如何根据上面的参数计算loss呢,如下:
cls_pos_loss = (-pos_equal_one * torch.log(prob_output + small_addon_for_BCE)) / pos_equal_one_sum
cls_neg_loss = (-neg_equal_one * torch.log(1 - prob_output + small_addon_for_BCE)) / neg_equal_one_sum
cls_loss = torch.sum(self.alpha * cls_pos_loss + self.beta * cls_neg_loss)
cls_pos_loss_rec = torch.sum(cls_pos_loss)
cls_neg_loss_rec = torch.sum(cls_neg_loss)
reg_loss = smooth_l1(delta_output * pos_equal_one_for_reg, targets * pos_equal_one_for_reg, self.sigma) / pos_equal_one_sum
reg_loss = torch.sum(reg_loss)
loss = cls_loss + reg_loss
(1)这里就引来了RPN的输出的
prob_output
和delta_output
用于计算损失了,这里的prob_output 是在sigmod输出的分数后的值。现在填一下前面说的输出的delta_output
怎么去回归每个anchor,在reg_loss
中的定义我们看到,传进去的三个参数:delta_output * pos_equal_one_for_reg、targets * pos_equal_one_for_reg、self.sigma。这里的pos_equal_one_for_reg只有1和0两种元素,前两个最重要的参数的含义分别是:输出的预测残差和实际的残差值。这里我们知道targets 是在三维中预测的pos anchor对gt的七个维度的残差值。也就是说回归实际上是对这个残差的拟合。
这里就基本把anchor,gt和Loss讲清楚了。后续有时间再研究一下前面的坑。后续一段时间可能要去搞一下ros了。一周后再做3D检测。