概述
首先,本文是基于点云,并且将点云处理成体素
的3D目标检测网络。
本文提出了一种基于体素的特征提取方法,将点云中点的信息与体素代表的局部信息融合,最终得到更具有表征能力的特征,该特征经过3D卷积用于RPN网络中得到目标检测的结果。
提出动机与贡献:
- 以往的基于体素的方法都是手工设计体素的特征,然后使用3D卷积处理,本文借鉴PointNet的方法,提出一种体素特征编码方法VFE,实现端到端。
- 采用一种简单的偏航角编码方法,也就是直接回归偏航角,不做复杂的偏航角编码。
细节
网络结构
主要包含三个部分,首先是特征学习网络,中间的卷积层以及最后的RPN网络用于目标检测结果的生成。
特征学习网络
首先将点云均匀地划分
为3D体素,接着是grouping
将点云分配到所属的体素中。由于点云本身的稀疏性,大多数的体素内都是没有点的,剩下部分体素内点的数量也是不定的。接下来是Sampling
,对于点数不足
T
T
T的体素添加0,对于那些点数超过
T
T
T的体素,我们采样
T
T
T个点,一方面减小计算量,另一方面使得点云的密度分布均匀一点。(有点暴力啊)
注:
上述操作完成之后,数据的shape就是
(
N
,
T
,
C
)
(N,T,C)
(N,T,C)了,其中N表示非空体素的个数,T是体素内的点数,C是每个点对应的特征数。
Voxel Feature Encoding(VFE)
:这个部分解决问题是的是怎样对体素进行特征提取。首先找到体素内的质心,坐标为体素内所有点坐标的平均值,然后给体素内的所有点添加三个额外的特征,他们是当前点距离质心的相对坐标差(
Δ
x
,
Δ
y
,
Δ
z
\Delta x,\Delta y,\Delta z
Δx,Δy,Δz)。因此,点的shape变为
(
N
,
T
,
C
+
3
)
(N,T,C+3)
(N,T,C+3)了。接下来就是通过全连接层提取点的特征得到
(
N
,
T
,
C
1
)
(N,T,C_1)
(N,T,C1)了,再往后就是类似于残差的过程,对上一步得到的特征沿着点数方向做max pool得到全局特征,然后再将这个特征concat到所有上一步得到的特征中,得到
(
N
,
T
,
2
∗
C
1
)
(N,T,2*C_1)
(N,T,2∗C1)了(类似于pointNet的分割头呀)
堆叠多次VFE模块得到更丰富的特征表示,之后经过一个全连接+一个max pool得到最终的全局点云特征(N,1,128),其中N是非空体素的数量。
稀疏张量表示:
将N个点的特征映射回原来3D体素表示中,得到一个很稀疏的4D张量。
卷积层
使用3D卷积处理来自特征学习网络的稀疏张量,得到shape是 ( 64 , 2 , x , y ) (64,2,x,y) (64,2,x,y),因为KITTI等数据集的检测任务中,物体没有在3D空间中的高度方向进行堆叠,没有出现一个车在另一个车的上方这种情况,所以我们直接将他处理成 ( 64 ∗ 2 , x , y ) (64*2,x,y) (64∗2,x,y)(变成鸟瞰图),这样的话RPN层的设计就会简单很多并且anchor的数量也会少很多,并且还能添加高度方向上的感受野。
RPN网络
这部分说是RPN,但感觉上好像就是单阶段目标检测算法的处理方式,根据特征图直接得到anchor的分类和回归结果(7个参数,6个坐标回归参数以及一个角度回归参数)。
注:
在3D世界中,每个类别的物体大小相对固定,所以直接使用了基于KITTI数据集上每个类别的平均长宽高作为anchor大小。也就是每个类别一个anchor。
损失函数
这部分的损失函数其实和faster rcnn的损失函数一样,正负样本的损失以及边界框回归参数的损失相加就是总损失。这里的分类损失用得是交叉熵损失,回归损失用的是SmoothL1损失。
简单实现
主要参考:https://github.com/skyhehe123/VoxelNet-pytorch
注1:
代码就是大佬的,我只是将网络结构和loss两部分拿了出来,单独测试了一下,并且加了点比较详细的备注。但是其中有一个部分代码我实在没看懂,也跑不通,就注释掉了,不影响数据的shape,但是不能真实使用,要是有大佬看懂了跑通了千万留言教教我 呜呜呜
注2:
实现的分类貌似是2分类的,我也尝试改一下的,但是发现比较麻烦(主要是loss部分,还是我菜了),后面就改回来了
网络结构:
import torch.nn as nn
import torch.nn.functional as F
import torch
from torch.autograd import Variable
from config import config as cfg
# conv2d + bn + relu
class Conv2dBlock(nn.Module):
def __init__(self, in_channels, out_channels, k, s, p, activation=True, batch_norm=True):
super(Conv2dBlock, self).__init__()
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=k, stride=s, padding=p)
if batch_norm:
self.bn = nn.BatchNorm2d(out_channels)
else:
self.bn = None
self.activation = activation
def forward(self, x):
x = self.conv(x)
if self.bn is not None:
x = self.bn(x)
if self.activation:
return F.relu(x, inplace=True)
else:
return x
# conv3d + bn + relu
class Conv3dBlock(nn.Module):
def __init__(self, in_channels, out_channels, k, s, p, batch_norm=True):
super(Conv3dBlock, self).__init__()
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size=k, stride=s, padding=p)
if batch_norm:
self.bn = nn.BatchNorm3d(out_channels)
else:
self.bn = None
def forward(self, x):
x = self.conv(x)
if self.bn is not None:
x = self.bn(x)
return F.relu(x, inplace=True)
# Fully Connected Network
class FCN(nn.Module):
def __init__(self, cin, cout):
super(FCN, self).__init__()
self.cout = cout
self.linear = nn.Linear(cin, cout)
self.bn = nn.BatchNorm1d(cout)
def forward(self, x):
# KK is the stacked k across batch
kk, t, _ = x.shape
x = self.linear(x.view(kk * t, -1))
x = F.relu(self.bn(x))
return x.view(kk, t, -1)
# Voxel Feature Encoding layer
class VFE(nn.Module):
def __init__(self, cin, cout):
super(VFE, self).__init__()
assert cout % 2 == 0
self.units = cout // 2
self.fcn = FCN(cin, self.units)
def forward(self, x, mask):
# 点级的特征提取 (N, T, cin)->(N, T, cout/2)
pwf = self.fcn(x)
# 局部特征提取:将n个点聚合成一个点 (N, T, cout/2)->(N, cout/2)->(N, 1, cout/2)->(N, T, cout/2)
# 其中max函数是在第1个维度上取最大值,所有第一个维度消失了
# unsqueeze函数在不改变数据的情况下,在指定位置上添加一个维度上去
# repeat(x,y,z)表示在第0个维度重复x次,依次类推;函数指定的维度和源数据维度不一致时会广播
laf = torch.max(pwf, 1)[0].unsqueeze(1).repeat(1, cfg.T, 1)
# 点级特征和局部特征concat
# (N, T, cout / 2)->(N, T, cout)
pwcf = torch.cat((pwf, laf), dim=2)
# apply mask 对于没有特征的点,强制特征继续保持0
# (N, T)->(N, T,1)->(N, T, cout)
mask = mask.unsqueeze(2).repeat(1, 1, self.units * 2)
# (N, T, cout)
pwcf = pwcf * mask.float()
return pwcf
# Stacked Voxel Feature Encoding
class SVFE(nn.Module):
def __init__(self):
super(SVFE, self).__init__()
self.vfe_1 = VFE(7, 32)
self.vfe_2 = VFE(32, 128)
self.fcn = FCN(128, 128)
def forward(self, x):
# 这一步做的是拿到体素中所有点对应特征的最大值,看看是不是0(也就是获得哪些点是有特征的哪些点是没有特征的)
# torch.max(x,2) 返回两个元组,分别是第二个维度上的最大值以及他们对应的索引
# torch.ne(x,y) 用于比较x与y中的每一个元素(会广播),如果对应元素相同返回fasle,不同返回true
# mask (N, T)
mask = torch.ne(torch.max(x, 2)[0], 0)
# (N, T, 7)->(N, T, 32)
x = self.vfe_1(x, mask)
# (N, T, 32)->(N, T, 128)
x = self.vfe_2(x, mask)
# (N, T, 128)->(N, T, 128)
x = self.fcn(x)
# 最终的max pool得到全局的点云信息 (N, T, 128)->(N, 128)
x = torch.max(x, 1)[0]
return x
# Convolutional Middle Layer
class CML(nn.Module):
def __init__(self):
super(CML, self).__init__()
self.conv3d_1 = Conv3dBlock(128, 64, 3, s=(2, 1, 1), p=(1, 1, 1))
self.conv3d_2 = Conv3dBlock(64, 64, 3, s=(1, 1, 1), p=(0, 1, 1))
self.conv3d_3 = Conv3dBlock(64, 64, 3, s=(2, 1, 1), p=(1, 1, 1))
def forward(self, x):
# 3D卷积中的尺寸计算公式:(D_in+2padding-dilation(kernel_size-1)-1)/stride+1,默认dilation是1的
# (N, 128, D, H, W)->(N, 64, D/2, H, W)
x = self.conv3d_1(x)
# (N, 64, D/2, H, W)->(N, 64, D/2-2, H, W)
x = self.conv3d_2(x)
# (N, 64, D/2-2, H, W)->(N, 64,(D/2-2)/2, H, W)
x = self.conv3d_3(x)
return x
# Region Proposal Network
class RPN(nn.Module):
def __init__(self):
super(RPN, self).__init__()
self.block_1 = [Conv2dBlock(128, 128, 3, 2, 1)]
self.block_1 += [Conv2dBlock(128, 128, 3, 1, 1) for _ in range(3)]
self.block_1 = nn.Sequential(*self.block_1)
self.block_2 = [Conv2dBlock(128, 128, 3, 2, 1)]
self.block_2 += [Conv2dBlock(128, 128, 3, 1, 1) for _ in range(5)]
self.block_2 = nn.Sequential(*self.block_2)
self.block_3 = [Conv2dBlock(128, 256, 3, 2, 1)]
self.block_3 += [Conv2dBlock(256, 256, 3, 1, 1) for _ in range(5)]
self.block_3 = nn.Sequential(*self.block_3)
self.deconv_1 = nn.Sequential(nn.ConvTranspose2d(256, 256, 4, 4, 0), nn.BatchNorm2d(256))
self.deconv_2 = nn.Sequential(nn.ConvTranspose2d(128, 256, 2, 2, 0), nn.BatchNorm2d(256))
self.deconv_3 = nn.Sequential(nn.ConvTranspose2d(128, 256, 1, 1, 0), nn.BatchNorm2d(256))
self.score_head = Conv2dBlock(768, cfg.anchors_per_position, 1, 1, 0, activation=False, batch_norm=False)
self.reg_head = Conv2dBlock(768, 7 * cfg.anchors_per_position, 1, 1, 0, activation=False, batch_norm=False)
def forward(self, x):
# (N, 128, H, W)->(N, 128, H/2, W/2)
x = self.block_1(x)
x_skip_1 = x
# (N, 128, H/2, W/2)->(N, 128, H/4, W/4)
x = self.block_2(x)
x_skip_2 = x
# (N, 128, H/4, W/4)->(N, 256, H/8, W/8)
x = self.block_3(x)
# 反卷积(转置卷积)计算公式:
# 输出大小 = (输入大小 − 1) * Stride + Filter - 2 * Padding H/2-4+4
# (N, 256, H/8, W/8)->(N, 256, H/2, W/2)
x_0 = self.deconv_1(x)
# (N, 128, H/4, W/4)->(N, 256, H/2, W/2)
x_1 = self.deconv_2(x_skip_2)
# (N, 128, H/2, W/2)->(N, 256, H/2, W/2)
x_2 = self.deconv_3(x_skip_1)
# (N, 3*256, H/2, W/2)
x = torch.cat((x_0, x_1, x_2), 1)
# (N, anchors_per_position, H/2, W/2),(N, 7*anchors_per_position, H/2, W/2)
return self.score_head(x), self.reg_head(x)
class VoxelNet(nn.Module):
def __init__(self):
super(VoxelNet, self).__init__()
self.svfe = SVFE()
self.cml = CML()
self.rpn = RPN()
# sparse_features:(N, 128),coords:(NUM,3)
def voxel_indexing(self, sparse_features, coords):
dim = sparse_features.shape[-1]
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# (128,2,10,400,352)
dense_feature = Variable(torch.zeros(dim, cfg.N, cfg.D, cfg.H, cfg.W).to(device))
# 这步是将我们通过堆叠VFE操作得到的稀疏特征映射到4D张量中,得到稀疏的4D张量 但是我着实看不懂,还疯狂报错
# dense_feature[:, coords[:, 0], coords[:, 1], coords[:, 2], coords[:, 3]] = sparse_features
return dense_feature.transpose(0, 1)
# sparse_features是所有体素对应的点特征集合,shape是(N, T, 4+3),这里的N是体素个数,T是体素中的最大点数,4+3是每个点对应的4个原始特征+3个相对于体素点质心的坐标偏移
# voxel_coords的shape是(NUM,3)其中3对应的是(D, H, W),这里的NUM是点云的总数
def forward(self, voxel_features, voxel_coords):
# 特征学习网络=堆叠多次VFE模块得到全局点云特征+将这个全局点云特征使用稀疏的4D张量表示,方便后续3D卷积特征提取
# (N, T, 4+3)->(N, 128)
vwfs = self.svfe(voxel_features)
# (N, 128)->(batch_size, 128, D, H, W)
vwfs = self.voxel_indexing(vwfs, voxel_coords)
# 3D卷积网络
# (batch_size, 128, D, H, W)->(batch_size, 64,(D/2-2)/2, H, W)
cml_out = self.cml(vwfs)
# RPN网络
# 合并特征维度和深度维度得到鸟瞰图, 对鸟瞰图使用2D卷积得到bbox对应的分类得分和bbox回归参数
# (batch_size, 64,(D/2-2)/2, H, W)->(N, 64*(D/2-2)/2, H, W)->(batch_size, anchors_per_position, H/2, W/2),(N, 7*anchors_per_position, H/2, W/2)
psm, rm = self.rpn(cml_out.view(cfg.N, -1, cfg.H, cfg.W))
return psm, rm
def main():
N, T=2,35 # 体素个数以及每个体素中的最大点数
voxel_features=torch.randn((N, T, 4+3))
voxel_coords=torch.randint(0,5,(100,3))
net=VoxelNet()
psm, rm =net(voxel_features, voxel_coords)
print(psm.shape, rm.shape)
if __name__ == '__main__':
main()
配置文件:
import math
import numpy as np
class config:
# classes
class_list = ['Car', 'Van']
# batch size
N=2
# maxiumum number of points per voxel
T=35
# voxel size
vd = 0.4
vh = 0.2
vw = 0.2
# points cloud range
xrange = (0, 70.4)
yrange = (-40, 40)
zrange = (-3, 1)
# voxel grid
W = math.ceil((xrange[1] - xrange[0]) / vw)
H = math.ceil((yrange[1] - yrange[0]) / vh)
D = math.ceil((zrange[1] - zrange[0]) / vd)
# iou threshold
pos_threshold = 0.6
neg_threshold = 0.45
# anchors: (200, 176, 2, 7) x y z h w l r
x = np.linspace(xrange[0]+vw, xrange[1]-vw, W//2)
y = np.linspace(yrange[0]+vh, yrange[1]-vh, H//2)
cx, cy = np.meshgrid(x, y)
# all is (w, l, 2)
cx = np.tile(cx[..., np.newaxis], 2)
cy = np.tile(cy[..., np.newaxis], 2)
cz = np.ones_like(cx) * -1.0
w = np.ones_like(cx) * 1.6
l = np.ones_like(cx) * 3.9
h = np.ones_like(cx) * 1.56
r = np.ones_like(cx)
r[..., 0] = 0
r[..., 1] = np.pi/2
anchors = np.stack([cx, cy, cz, h, w, l, r], axis=-1)
anchors_per_position = 2
# non-maximum suppression
nms_threshold = 0.1
score_threshold = 0.96
损失函数:
import torch
import torch.nn as nn
import torch.nn.functional as F
class VoxelLoss(nn.Module):
def __init__(self, alpha, beta):
super(VoxelLoss, self).__init__()
self.smoothl1loss = nn.SmoothL1Loss(size_average=False)
self.alpha = alpha
self.beta = beta
# psm是bbox对应的分类得分,(N, anchors_per_position, H/2, W/2)
# rm是bbox回归参数,(N, 7*anchors_per_position, H/2, W/2)
# pos_equal_one是正样本01图,(N,H/2,W/2,anchors_per_position)
# neg_equal_one是负样本01图,(N,H/2,W/2,anchors_per_position)
# targets是真实的回归参数,(N,H/2,W/2,7*anchors_per_position)
def forward(self, rm, psm, pos_equal_one, neg_equal_one, targets):
# (N, anchors_per_position, H/2, W/2)->(N, H/2, W/2,anchors_per_position)
p_pos = F.sigmoid(psm.permute(0,2,3,1))
# (N, 7*anchors_per_position, H/2, W/2)->(N, H/2, W/2,7*anchors_per_position)
# contiguous方法使得rm存储的形状也进行变化,方便后面的view方法
rm = rm.permute(0,2,3,1).contiguous()
# (N, H / 2, W / 2, 7 * anchors_per_position)->(N, H/2, W/2,anchors_per_position,7)
rm = rm.view(rm.size(0),rm.size(1),rm.size(2),-1,7)
# (N,H/2,W/2,7*anchors_per_position)->(N,H/2,W/2,anchors_per_position,7)
targets = targets.view(targets.size(0),targets.size(1),targets.size(2),-1,7)
# (N,anchors_per_positionH/2,W/2,anchors_per_position)->(N,H/2,W/2,anchors_per_position,7)
# pos_equal_one.dim()获取pos_equal_one的维度,这里得到的是4
# expand函数用于维数的拓展,-1表示当前维度维数不变
pos_equal_one_for_reg = pos_equal_one.unsqueeze(pos_equal_one.dim()).expand(-1,-1,-1,-1,7)
# 只有正样本才有边界框回归损失
rm_pos = rm * pos_equal_one_for_reg
targets_pos = targets * pos_equal_one_for_reg
reg_loss = self.smoothl1loss(rm_pos, targets_pos)
reg_loss = reg_loss / (pos_equal_one.sum() + 1e-6)
# 样本的分类损失-(1/n)*y*log(y^)
cls_pos_loss = -pos_equal_one * torch.log(p_pos + 1e-6)
cls_pos_loss = cls_pos_loss.sum() / (pos_equal_one.sum() + 1e-6)
cls_neg_loss = -neg_equal_one * torch.log(1 - p_pos + 1e-6)
cls_neg_loss = cls_neg_loss.sum() / (neg_equal_one.sum() + 1e-6)
conf_loss = self.alpha * cls_pos_loss + self.beta * cls_neg_loss
return conf_loss, reg_loss