1、前言
当前点云检测的常见方式分别有
1、将点云划分成voxel来进行检测,典型的模型有VoxelNet、SECOND等;作然而本文的作者史博士提出这种方法会出现量化造成的信息损失。
2、将点云投影到前视角或者鸟瞰图来来进行检测,包括MV3D、PIXOR、AVOD等检测模型;同时这类模型也会出现量化损失。
3、将点云直接生成伪图片,然后使用2D的方式来进行处理,这主要是PointPillar。
本文PointRCNN提出的方法,是一篇比较新颖的点云检测方法,与此前的检测模型不同,它直接根据点云分割的结果来产生候选框,并根据这些候选框的内部数据和之前的分割特征来完成物体的准确定位和分类。
论文地址:https://arxiv.org/abs/1812.04244
源论文代码地址:GitHub - sshaoshuai/PointRCNN: PointRCNN: 3D Object Proposal Generation and Detection from Point Cloud, CVPR 2019.https://github.com/sshaoshuai/PointRCNN
OpenPCDet代码地址:
https://github.com/open-mmlab/OpenPCDet/https://github.com/open-mmlab/OpenPCDet/
在完成对网络的解析后会对OpenPCDet点云检测框架中的PointRCNN网络进行代码详解。
PointRCNN在(OpenPCDet)的代码实现类结构图
主要有三个模块来实现整个网络:
PointNet2MSG:对原始的点云进行编码解码操作(PointNet++) PointHeadBox:对经过PointNet++的点云进行分类操作和box的预测 PointRCNNHead:对每个roi进行精调
2、PointRCNN的网络模块解析
PointRCNN网络结构图(图来自源论文)
当前的2D检测框架可以分为一阶和二阶两种检测模型,通常一阶模型拥有更高的精度但是缺乏对生成框的调优,二阶段模块可以根据提议框进一步调整预测结果。如果直接将2维的检测模型中二阶段直接迁移到3D的检测任务上的话,是比较困难的。因为3D检测任务拥有更大的搜索空间,并且点云数据并不是密集数据。如果使用这样的方式,像AVOD那样,需要放置20-100K个anchor在3为空间中,这种做法很蠢;如果像F-PointNet那样,采用2D的检测框架首先在图片上来生成物体的proposal,然后在这个视锥内来检测3D的物体的话,确实可以极大的减少3D空间中的搜索范围,但是也会导致很多只有在3D空间下才能看到的物体因为遮挡等原因无法在图像中得以显示,造成模型的recall不高,同时该方法也极大的依赖于2D检测器的性能。
因此PointRCNN首次直接在点云数据上分割mask(这里能直接预测mask的原因主要是3D的标注框中,可以清晰的标注出来一个GTBox中有哪些点云数据,而且在自然的3D世界中,不会出现像图片里面物体重叠的情况,因此每个点云中属于前景的mask就得到了),然后在第二阶段中对每个proposal中第一阶段学习到的特征和处在proposal中的原始点云数据进行池化操作。通过将坐标系转换到canonical coordinate system(CCS)坐标系中来进一步的优化得到box和cls的结果。
2.1、自下而上的3D建议框生成:
2.1.1 特征提取网络(point cloud network)
在PointRCNN中,首先采用Multi-Scale-Grounping 的PointNet++网络来完成对点云中前背景点的分割和proposal的生成。因为标注的数据中每个GTbox就可以清晰的标注出点云中哪些属于的前景点,哪些属于背景点;显然背景点的数量肯定是比前景点的数量要多得多的。所以作者在这里采用了focal loss来解决类别不平衡的问题。
注:(alpha和gamma都与RetinaNet中设置一样,alpha为0.25,gamma为2)
原来的PointNet++网络中并没有box的regression,所以作者在这里增加了一个回归头用于前给景点生成proposal。其中这里前背景点的分割和前景点的proposal生成是同步完成的,只不过在代码实现中,只取前景点的box。请注意,这里的proposal的box生成虽然是直接从前景点的特征来生成的,但是背景点也提供了丰富的感受野信息,因为Point Cloud Encoder和Point Cloud Decoder网络在已经将点云数据进行了融合(比如PointNet++中,Decoder根据距离权重插值的方式,来反向传播点的信息)。
经过该上述部分处理后,得到网络中所有的前景点和前景点生成的proposal。这样就解决了在3D空间中设置大量anchor的愚蠢操作了,也就减少了proposal的搜索范围。
其中在实现的过程中,每针点云数据随机采样16384个点(如果一帧点云中点的数量少于16384,则重复采样至16384)。Point Cloud Network采用PointNet++(MSG),同时作者也表明采用其他网络也是可以的,比如PointShift、PointNet、VoxelNet with sparse conv。
PointNet++ 参数设置如下:
Encoder为4个Set-Abstraction层,每层最远点采样的个数分别是4096,1024,256,64。
Decoder为4个Feature-Probagation层,用于获得每个点的特征向量来给后面的PointHeadBox网络进行每个点的分割得到哪些点属于前景点,哪些点属于背景点。
代码在:pcdet/models/backbones_3d/pointnet2_backbone.py
class PointNet2MSG(nn.Module):
def __init__(self, model_cfg, input_channels, **kwargs):
super().__init__()
self.model_cfg = model_cfg
self.SA_modules = nn.ModuleList()
channel_in = input_channels - 3
self.num_points_each_layer = []
skip_channel_list = [input_channels - 3]
# 初始化PointNet++中的SetAbstraction
for k in range(self.model_cfg.SA_CONFIG.NPOINTS.__len__()):
mlps = self.model_cfg.SA_CONFIG.MLPS[k].copy()
channel_out = 0
for idx in range(mlps.__len__()):
mlps[idx] = [channel_in] + mlps[idx]
channel_out += mlps[idx][-1]
self.SA_modules.append(
pointnet2_modules.PointnetSAModuleMSG(
npoint=self.model_cfg.SA_CONFIG.NPOINTS[k],
radii=self.model_cfg.SA_CONFIG.RADIUS[k],
nsamples=self.model_cfg.SA_CONFIG.NSAMPLE[k],
mlps=mlps,
use_xyz=self.model_cfg.SA_CONFIG.get('USE_XYZ', True),
)
)
skip_channel_list.append(channel_out)
channel_in = channel_out
# 初始化PointNet++中的feature back-probagation
self.FP_modules = nn.ModuleList()
for k in range(self.model_cfg.FP_MLPS.__len__()):
pre_channel = self.model_cfg.FP_MLPS[k + 1][-1] if k + 1 < len(self.model_cfg.FP_MLPS) else channel_out
self.FP_modules.append(
pointnet2_modules.PointnetFPModule(
mlp=[pre_channel + skip_channel_list[k]] + self.model_cfg.FP_MLPS[k]
)
)
self.num_point_features = self.model_cfg.FP_MLPS[0][-1]
# 处理点云数据,用于得到(batch,n_points,xyz)的形式
def break_up_pc(self, pc):
batch_idx = pc[:, 0] # 经过预处理后,每个点云的第一个维度数据存放的是该点云在batch中的索引
xyz = pc[:, 1:4].contiguous() # 得到所有的点云数据
features = (pc[:, 4:].contiguous() if pc.size(-1) > 4 else None) # 得到除了xyz中的其他数据,比如intensity
return batch_idx, xyz, features
def forward(self, batch_dict):
"""
Args:
batch_dict:
batch_size: int
vfe_features: (num_voxels, C)
points: (num_points, 4 + C), [batch_idx, x, y, z, ...]
Returns:
batch_dict:
encoded_spconv_tensor: sparse tensor
point_features: (N, C)
"""
# batch_size
batch_size = batch_dict['batch_size']
# (batch_size * 16384, 5)
points = batch_dict['points']
# 得到每个点云的batch索引,和所有的点云的xyz、intensity数据
batch_idx, xyz, features = self.break_up_pc(points)
# 创建一个全0的xyz_batch_cnt,用于存放每个batch中总点云个数是多少
xyz_batch_cnt = xyz.new_zeros(batch_size).int()
for bs_idx in range(batch_size):
xyz_batch_cnt[bs_idx] = (batch_idx == bs_idx).sum()
# 在训练的过程中,一个batch中的所有的点云的中点数量需要相等,
assert xyz_batch_cnt.min() == xyz_batch_cnt.max()
# shape :(batch_size, 16384, 3)
xyz = xyz.view(batch_size, -1, 3)
# shape : (batch_size, 1, 16384)
features = features.view(batch_size, -1, features.shape[-1]).permute(0, 2, 1).contiguous() \
if features is not None else None
# 定义一个用来存放经过PointNet++的数据结果,用于后面PointNet++的feature back-probagation层
l_xyz, l_features = [xyz], [features]
# 使用PointNet++中的SetAbstraction模块来提取点云的数据
for i in range(len(self.SA_modules)):
"""
最远点采样的点数
NPOINTS: [4096, 1024, 256, 64]
# BallQuery的半径
RADIUS: [[0.1, 0.5], [0.5, 1.0], [1.0, 2.0], [2.0, 4.0]]
# BallQuery内半径内最大采样点数(MSG)
NSAMPLE: [[16, 32], [16, 32], [16, 32], [16, 32]]
# MLPS的维度变换
# 其中[16, 16, 32]表示第一个半径和采样点下的维度变换,
[32, 32, 64]表示第二个半径和采样点下的维度变换,以下依次类推
MLPS: [[[16, 16, 32], [32, 32, 64]],
[[64, 64, 128], [64, 96, 128]],
[[128, 196, 256], [128, 196, 256]],
[[256, 256, 512], [256, 384, 512]]]
"""
"""
param analyze:
li_xyz shape:(batch, sample_n_points, xyz_of_centroid_of_x)
li_features shape:(batch, channel, Set_Abstraction)
detail:
1、li_xyz shape:(batch, 4096, 3), li_features shape:(batch, 32+64, 4096)
2、li_xyz shape:(batch, 1024, 3), li_features shape:(batch, 128+128, 1024)
3、li_xyz shape:(batch, 256, 3), li_features shape:(batch, 256+256, 256)
4、li_xyz shape:(batch, 64, 3), li_features shape:(batch, 512+512, 64)
"""
li_xyz, li_features = self.SA_modules[i](l_xyz[i], l_features[i])
l_xyz.append(li_xyz)
l_features.append(li_features)
# PointNet++中的feature back-probagation层,从已经知道的特征中,通过距离插值的方式来计算出上一个点集中未知点的特征信息
"""
其中:
i=[-1, -2, -3, -4]
以-1为例:
unknown的特征是上一层点集中所有的点的坐标,known是当前已知特征的点的坐标,feature是对应的特征
在已经知道的点中,找出三个与之最近的三个不知道的点,然后对这三个点根据距离计算插值,
得到插值结果(bacth, 1024, 256),再将插值得到的特征和上一层计算的特征在维度上进行
拼接的得到(bacth, 1024+512, 256),并进行一个mlp(代码实现中用的1*1的卷积完成)操作
来进行降维得到上一层的点的特征,(bacth,512, 256)。这样就将深层的信息,传递回去了。
"""
for i in range(-1, -(len(self.FP_modules) + 1), -1):
unknown = l_xyz[i - 1]
known = l_xyz[i]
unknow_feats = l_features[i - 1]
known_feats = l_features[i]
res = self.FP_modules[i](
unknown, known, unknow_feats, known_feats
) # (B, C, N)
l_features[i - 1] = res
# l_features[i - 1] = self.FP_modules[i](
# l_xyz[i - 1], l_xyz[i], l_features[i - 1], l_features[i]
# ) # (B, C, N)
"""
经过PointNet++的feature back-probagation处理后,
l_feature的结果是
[
[batch, 128, 16384],
[batch, 256, 4096],
[batch, 512, 1024],
[batch, 512, 256],
[batch, 1024, 64],
]
"""
# 将反向回传得到的原始点云数据进行维度变换 (batch, 128, 16384)--> (batch, 16384, 128)
point_features = l_features[0].permute(0, 2, 1).contiguous() # (B, N, C)
# 得到的结果存放入batch_dict (batch, 16384, 128) --> (batch * 16384, 128)
batch_dict['point_features'] = point_features.view(-1, point_features.shape[-1])
# (batch * 16384, 1) (batch * 16384, 3) 变回输入的形式
batch_dict['point_coords'] = torch.cat((batch_idx[:, None].float(), l_xyz[0].view(-1, 3)), dim=1)
return batch_dict
2.1.2 Bin-based 3D Bbox generation(论文)
在激光雷达坐标系中,一个3D的Bbox可以表示为 (x, y, z, l, w, h, θ),xyz是该Bbox的中心在雷达坐标系的中心,lwh是该Bbox的长宽高,θ是物体在BEV视角下的旋转角度。
在对点进行box生成的时候,只需要对前景点的box进行回归操作。虽然没有对背景点进行回归操作,但是在PointNet++的反向回传中,背景点也为这些前景点提供了丰富的感受野信息。
Bin-based Localization(图来自原论文)
在论文中作者使用的是Bin-based的box位置估计,就是将原来只需要回归残差的方式变成了,分类和回归,什么意思呢?如果直接对一个残差进行回归的话,就只需要让一个box预测的偏移量与该box编码的值越来越小就可以,但是这里转换成了前景点与之对应的GT在哪个bin的分类问题和在该bin中对剩余部分的回归问题。可以看上图。计算的目标公式如下:
其中x^p是GT的中心坐标,x^(p)是当前前景点的原点。S是搜索范围,论文中设置为3m,为bin的大小,设置为0.5m。C是bin的长度用来进行归一化操作。
那么这设置的话到GT的偏移就变成了由第N个bin的分类问题和第N个bin中residual regression的回归问题。论文中作者说这种方式要比直接使用L1损失函数计算的回归进度更高。
对于高度上的预测,直接使用了SmoothL1来回归之间的残差,因为高度上的变化没有这么大。
同时,对于角度的预测,也采用了基于bin的方式,将2pi分成n个bins,并分类GT在哪个bin中,和对应分类bin中的residual regression回归。
这里的box生成中使用的物体的长宽高是直接根据整体数据集中每个类别的长宽高的平局数值。并在编码box时候,直接使用该数据进行编码。
总体的损失记为:
其中Npos是前景点的总个数,和
是前景点预测的结果
和
是GT的编码结果。Fcls代表了cross-entropy classifification loss、Freg代表了smoothL1 loss。
在推理阶段,这个bin-base的预测中,只需要找到拥有最高预测置信度的bin,并加上残差的预测结果就可以。其它的参数只需要将预测的数值加上初始数值就可以。
这部分的内容实现与原论文代码仓库中的内容相同,但是在OpenPCDet中,史帅的实现并没有采用基于bin-base的方法,而是直接使用了smoothL1来进行预测;同时角度的预测也从bin-based的方式变成了residual-cos-based的方法。
详情可以看这个issue:https://github.com/open-mmlab/OpenPCDet/issues/255
2.1.2 代码中box的生成(OpenPCdet)
好了说回正文,本博客已OpenPCDet代码仓库的代码为基础,进行解析,这里已实际的代码来进行解析。
经过PointNet++后,网络得到的point feature输出为(batch * 16384, 128),接下来就需要对每个点都进行分类和回归操作。
代码在:pcdet/models/dense_heads/point_head_box.py
def forward(self, batch_dict):
"""
Args:
batch_dict:
batch_size:
point_features: (N1 + N2 + N3 + ..., C) or (B, N, C)
point_features_before_fusion: (N1 + N2 + N3 + ..., C)
point_coords: (N1 + N2 + N3 + ..., 4) [bs_idx, x, y, z]
point_labels (optional): (N1 + N2 + N3 + ...)
gt_boxes (optional): (B, M, 8)
Returns:
batch_dict:
point_cls_scores: (N1 + N2 + N3 + ..., 1)
point_part_offset: (N1 + N2 + N3 + ..., 3)
"""
# False
if self.model_cfg.get('USE_POINT_FEATURES_BEFORE_FUSION', False):
point_features = batch_dict['point_features_before_fusion']
else:
point_features = batch_dict['point_features'] # 从字典中每个点的特征 shape (batch * 16384, 128)
""" 点分类的网络详情
Sequential(
(0): Linear(in_features=128, out_features=256, bias=False)
(1): BatchNorm1d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): ReLU()
(3): Linear(in_features=256, out_features=256, bias=False)
(4): BatchNorm1d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(5): ReLU()
(6): Linear(in_features=256, out_features=3, bias=True)
)
"""
# 对每个点进行分类 (batch * 16384, num_class)
point_cls_preds = self.cls_layers(point_features)
""" 点生成proposal的网络详情,其中这里作者使用了residual-cos-based来编码θ,也就是角度被 (cos(∆θ), sin(∆θ))来编码,所以最终回归的参数是8个
Sequential(
(0): Linear(in_features=128, out_features=256, bias=False)
(1): BatchNorm1d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): ReLU()
(3): Linear(in_features=256, out_features=256, bias=False)
(4): BatchNorm1d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(5): ReLU()
(6): Linear(in_features=256, out_features=8, bias=True)
)
"""
# 点回归proposal (batch * 16384, box_code_size)
point_box_preds = self.box_layers(point_features)
# 从每个点的分类预测结果中,取出类别预测概率最大的结果 (batch * 16384, num_class) --> (batch * 16384, )
point_cls_preds_max, _ = point_cls_preds.max(dim=-1)
# 将类别预测分数经过sigmoid激活后放入字典中
batch_dict['point_cls_scores'] = torch.sigmoid(point_cls_preds_max)
# 将点的类别预测结果和回归结果放入字典中
ret_dict = {'point_cls_preds': point_cls_preds,
'point_box_preds': point_box_preds}
# 如果在训练模式下,需要根据GTBox来生成对应的前背景点,用于点云的前背景分割,给后面计算前背景分类loss
if self.training:
targets_dict = self.assign_targets(batch_dict)
# 将一个batch中所有点的GT类别结果放入字典中 shape (batch * 16384)
ret_dict['point_cls_labels'] = targets_dict['point_cls_labels']
# 将一个batch中所有点的GT_box编码结果放入字典中 shape (batch * 16384) shape (batch * 16384, 8)
ret_dict['point_box_labels'] = targets_dict['point_box_labels']
# 训练和预测都需要生成第一阶段的proposal 然后给第二阶段来refine
if not self.training or self.predict_boxes_when_training:
# 生成预测的box
point_cls_preds, point_box_preds = self.generate_predicted_boxes(
# 所有点的xyz坐标(batch*16384, 3)
points=batch_dict['point_coords'][:, 1:4],
# 所有点的预测类别(batch*16384, 3)
point_cls_preds=point_cls_preds,
# 所有点的box预测(batch*16384, 8)
point_box_preds=point_box_preds
)
batch_dict['batch_cls_preds'] = point_cls_preds # 所有点的类别预测结果 (batch * 16384, 3)
batch_dict['batch_box_preds'] = point_box_preds # 所有点的回归预测结果 (batch * 16384, 7)
batch_dict['batch_index'] = batch_dict['point_coords'][:, 0] # 所有点的在batch中的索引 (batch * 16384, )
batch_dict['cls_preds_normalized'] = False # loss计算中,是否需要对类别预测结果进行normalized
# 第一阶段生成的预测结果放入前向传播字典
self.forward_ret_dict = ret_dict
return batch_dict
1、训练阶段的前背景点target assignment
对每个点进行分类后可以得到 point_cls_preds_max,维度是(batch * 16384, num_class),并取出每个类别的最大值并进行sigmoid激活,得到 point_cls_scores,维度是(batch * 16384, )。然后进行target assignment。
target assignment的时候需要对每个GT的长宽高都延长0.2米,并将这些处于边缘0.2米的点的target设置为-1,不计算分类损失。原因是为了提高点云分割的健壮性,因为,3D的GTBox会有小的变化。
代码在:pcdet/models/dense_heads/point_head_box.py
def assign_targets(self, input_dict):
"""
Args:
input_dict:
point_features: (N1 + N2 + N3 + ..., C)
batch_size:
point_coords: (N1 + N2 + N3 + ..., 4) [bs_idx, x, y, z]
gt_boxes (optional): (B, M, 8)
Returns:
point_cls_labels: (N1 + N2 + N3 + ...), long type, 0:background, -1:ignored
point_part_labels: (N1 + N2 + N3 + ..., 3)
"""
# 得到每个点的坐标 shape(bacth * 16384, 4),其中4个维度分别是batch_id,x,y,z
point_coords = input_dict['point_coords']
# 取出gt_box,shape (batch, num_of_GTs, 8),
# 其中维度8表示 x, y, z, l, w, h, heading, class_id
gt_boxes = input_dict['gt_boxes']
# 确保维度正确
assert gt_boxes.shape.__len__() == 3, 'gt_boxes.shape=%s' % str(gt_boxes.shape)
assert point_coords.shape.__len__() in [2], 'points.shape=%s' % str(point_coords.shape)
batch_size = gt_boxes.shape[0]
# 在训练的过程中,需要忽略掉点云中离GTBox较近的点,因为3D的GTBox也会有扰动,
# 所以这里通过将每一个GT_box都在x,y,z方向上扩大0.2米,
# 来检测哪些点是属于扩大后才有的点,增强点云分割的健壮性
extend_gt_boxes = box_utils.enlarge_box3d(
gt_boxes.view(-1, gt_boxes.shape[-1]), extra_width=self.model_cfg.TARGET_CONFIG.GT_EXTRA_WIDTH
).view(batch_size, -1, gt_boxes.shape[-1])
"""
assign_stack_targets函数完成了一批数据中所有点的前背景分配,
并为每个前景点分配了对应的类别和box的7个回归参数,xyzlwhθ
"""
targets_dict = self.assign_stack_targets(
points=point_coords, gt_boxes=gt_boxes, extend_gt_boxes=extend_gt_boxes,
set_ignore_flag=True, use_ball_constraint=False,
ret_part_labels=False, ret_box_labels=True
)
return targets_dict
self.assign_stack_targets()函数用于完成一批数据中点云的前背景分配和前景点云的每个类别的平均anchor大小和GTbox编码操作 代码在:pcdet/models/dense_heads/point_head_template.py
def assign_stack_targets(self, points, gt_boxes, extend_gt_boxes=None,
ret_box_labels=False, ret_part_labels=False,
set_ignore_flag=True, use_ball_constraint=False, central_radius=2.0):
"""
Args:
points: (N1 + N2 + N3 + ..., 4) [bs_idx, x, y, z]
gt_boxes: (B, M, 8)
extend_gt_boxes: [B, M, 8]
ret_box_labels: True
ret_part_labels: Fasle
set_ignore_flag: True
use_ball_constraint: False
central_radius:
Returns:
point_cls_labels: (N1 + N2 + N3 + ...), long type, 0:background, -1:ignored
point_box_labels: (N1 + N2 + N3 + ..., code_size)
"""
assert len(points.shape) == 2 and points.shape[1] == 4, 'points.shape=%s' % str(points.shape)
assert len(gt_boxes.shape) == 3 and gt_boxes.shape[2] == 8, 'gt_boxes.shape=%s' % str(gt_boxes.shape)
assert extend_gt_boxes is None or len(extend_gt_boxes.shape) == 3 and extend_gt_boxes.shape[2] == 8, \
'extend_gt_boxes.shape=%s' % str(extend_gt_boxes.shape)
assert set_ignore_flag != use_ball_constraint, 'Choose one only!'
# 得到一批数据中batch_size的大小,以方便逐帧完成target assign
batch_size = gt_boxes.shape[0]
# 得到一批数据中所有点云的batch_id
bs_idx = points[:, 0]
# 初始化每个点云的类别,默认全0; shape (batch * 16384)
point_cls_labels = points.new_zeros(points.shape[0]).long()
# 初始化每个点云预测box的参数,默认全0; shape (batch * 16384, 8)
point_box_labels = gt_boxes.new_zeros((points.shape[0], 8)) if ret_box_labels else None
# None
point_part_labels = gt_boxes.new_zeros((points.shape[0], 3)) if ret_part_labels else None
# 逐帧点云数据进行处理
for k in range(batch_size):
# 得到一个mask,用于取出一批数据中属于当前帧的点
bs_mask = (bs_idx == k)
# 取出对应的点shape (16384, 3)
points_single = points[bs_mask][:, 1:4]
# 初始化当前帧中点的类别,默认为0 (16384, )
point_cls_labels_single = point_cls_labels.new_zeros(bs_mask.sum())
"""
points_single : (16384, 3) --> (1, 16384, 3)
gt_boxes : (batch, num_of_GTs, 8) --> (当前帧的GT, num_of_GTs, 8)
box_idxs_of_pts : (16384, ),其中点云分割中背景为-1, 前景点指向GT中的索引,
例如[-1,-1,3,20,-1,0],其中,3,20,0分别指向第0个、第3个和第20个GT
"""
# 计算哪些点在GTbox中, box_idxs_of_pts
box_idxs_of_pts = roiaware_pool3d_utils.points_in_boxes_gpu(
points_single.unsqueeze(dim=0), gt_boxes[k:k + 1, :, 0:7].contiguous()
).long().squeeze(dim=0)
# mask 表明该帧中的哪些点属于前景点,哪些点属于背景点;得到属于前景点的mask
box_fg_flag = (box_idxs_of_pts >= 0)
# 是否忽略在enlarge box中的点 True
if set_ignore_flag:
# 计算哪些点在GTbox_enlarge中
extend_box_idxs_of_pts = roiaware_pool3d_utils.points_in_boxes_gpu(
points_single.unsqueeze(dim=0), extend_gt_boxes[k:k + 1, :, 0:7].contiguous()
).long().squeeze(dim=0)
# 前景点
fg_flag = box_fg_flag
# ^为异或运算符,不同为真,相同为假,这样就可以得到真实GT enlarge后的的点了
ignore_flag = fg_flag ^ (extend_box_idxs_of_pts >= 0)
# 将这些真实GT边上的点设置为-1 loss计算时,不考虑这类点
point_cls_labels_single[ignore_flag] = -1
elif use_ball_constraint:
box_centers = gt_boxes[k][box_idxs_of_pts][:, 0:3].clone()
box_centers[:, 2] += gt_boxes[k][box_idxs_of_pts][:, 5] / 2
ball_flag = ((box_centers - points_single).norm(dim=1) < central_radius)
fg_flag = box_fg_flag & ball_flag
else:
raise NotImplementedError
# [box_idxs_of_pts[fg_flag]]取出所有点中属于前景的点,
# 并为这些点分配对应的GT_box shape (num_of_gt_match_by_points, 8)
# 8个维度分别是x, y, z, l, w, h, heading, class_id
gt_box_of_fg_points = gt_boxes[k][box_idxs_of_pts[fg_flag]]
# 将类别信息赋值给对应的前景点 (16384, )
point_cls_labels_single[fg_flag] = 1 if self.num_class == 1 else gt_box_of_fg_points[:, -1].long()
# 赋值点的类别GT结果到的batch中对应的帧位置
point_cls_labels[bs_mask] = point_cls_labels_single
# 如果该帧中GT的前景点的数量大于0
if ret_box_labels and gt_box_of_fg_points.shape[0] > 0:
# 初始化该帧中box的8个回归参数,并置0
# 此处编码为(Δx, Δy, Δz, dx, dy, dz, cos(heading), sin(heading)) 8个
point_box_labels_single = point_box_labels.new_zeros((bs_mask.sum(), 8))
# 对属于前景点的box进行编码 得到的是 (num_of_fg_points, 8)
# 其中8是(Δx, Δy, Δz, dx, dy, dz, cos(heading), sin(heading))
fg_point_box_labels = self.box_coder.encode_torch(
gt_boxes=gt_box_of_fg_points[:, :-1], points=points_single[fg_flag],
gt_classes=gt_box_of_fg_points[:, -1].long()
)
# 将每个前景点的box信息赋值到该帧中box参数预测中
# fg_point_box_labels: (num_of_GT_matched_by_point,8)
# point_box_labels_single: (16384, 8)
point_box_labels_single[fg_flag] = fg_point_box_labels
# 赋值点的回归编码结果到的batch中对应的帧位置
point_box_labels[bs_mask] = point_box_labels_single
# False
if ret_part_labels:
point_part_labels_single = point_part_labels.new_zeros((bs_mask.sum(), 3))
transformed_points = points_single[fg_flag] - gt_box_of_fg_points[:, 0:3]
transformed_points = common_utils.rotate_points_along_z(
transformed_points.view(-1, 1, 3), -gt_box_of_fg_points[:, 6]
).view(-1, 3)
offset = torch.tensor([0.5, 0.5, 0.5]).view(1, 3).type_as(transformed_points)
point_part_labels_single[fg_flag] = (transformed_points / gt_box_of_fg_points[:, 3:6]) + offset
point_part_labels[bs_mask] = point_part_labels_single
# 将每个点的类别、每个点对应box的7个回归参数放入字典中
targets_dict = {
# 将一个batch中所有点的GT类别结果放入字典中 shape (batch * 16384)
'point_cls_labels': point_cls_labels,
# 将一个batch中所有点的GT_box编码结果放入字典中 shape (batch * 16384) shape (batch * 16384, 8)
'point_box_labels': point_box_labels,
# None
'point_part_labels': point_part_labels
}
return targets_dict
其中GTbox和anchor的编码操作中使用了数据集上每个类别的平均长宽高为anchor的大小来完成编码。
这里对角度的编码采用了residual-cos-based,用角度的 torch.cos(rg), torch.sin(rg)来编码GT的heading数值。
代码在:pcdet/utils/box_coder_utils.py
class PointResidualCoder(object):
def __init__(self, code_size=8, use_mean_size=True, **kwargs):
super().__init__()
self.code_size = code_size
self.use_mean_size = use_mean_size
if self.use_mean_size:
self.mean_size = torch.from_numpy(np.array(kwargs['mean_size'])).cuda().float()
assert self.mean_size.min() > 0
def encode_torch(self, gt_boxes, points, gt_classes=None):
"""
Args:
gt_boxes: (N, 7 + C) [x, y, z, dx, dy, dz, heading, ...]
points: (N, 3) [x, y, z]
gt_classes: (N) [1, num_classes]
Returns:
box_coding: (N, 8 + C)
"""
# 每个gt_box的长宽高不得小于 1*10^-5,这里限制了一下
gt_boxes[:, 3:6] = torch.clamp_min(gt_boxes[:, 3:6], min=1e-5)
# 这里指torch.split的第二个参数 torch.split(tensor, split_size, dim=) split_size是切分后每块的大小,
# 不是切分为多少块!,多余的参数使用*cags接收。dim=-1表示切分最后一个维度的参数
xg, yg, zg, dxg, dyg, dzg, rg, *cgs = torch.split(gt_boxes, 1, dim=-1)
# 上面分割的是gt_box的参数,下面分割的是每个点的参数
xa, ya, za = torch.split(points, 1, dim=-1)
# True 这里使用基于数据集求出的每个类别的平均长宽高
if self.use_mean_size:
# 确保GT中的类别数和长宽高计算的类别是一致的
assert gt_classes.max() <= self.mean_size.shape[0]
"""
各类别的平均长宽高
车: [3.9, 1.6, 1.56],
人: [0.8, 0.6, 1.73],
自行车: [1.76, 0.6, 1.73]
"""
# 根据每个点的类别索引,来为每个点生成对应类别的anchor大小 这个anchor来自于数据集中该类别的平均长宽高
point_anchor_size = self.mean_size[gt_classes - 1]
# 分割每个生成的anchor的长宽高
dxa, dya, dza = torch.split(point_anchor_size, 1, dim=-1)
# 计算每个anchor的底面对角线距离
diagonal = torch.sqrt(dxa ** 2 + dya ** 2)
# 计算loss的公式,Δx,Δy,Δz,Δw,Δl,Δh,Δθ
# 以下的编码操作与SECOND、Pointpillars中一样
# 坐标点编码
xt = (xg - xa) / diagonal
yt = (yg - ya) / diagonal
zt = (zg - za) / dza
# 长宽高的编码
dxt = torch.log(dxg / dxa)
dyt = torch.log(dyg / dya)
dzt = torch.log(dzg / dza)
# 角度的编码操作为 torch.cos(rg)、torch.sin(rg) #
else:
xt = (xg - xa)
yt = (yg - ya)
zt = (zg - za)
dxt = torch.log(dxg)
dyt = torch.log(dyg)
dzt = torch.log(dzg)
cts = [g for g in cgs]
# 返回时,对每个GT_box的朝向信息进行了求余弦和正弦的操作
return torch.cat([xt, yt, zt, dxt, dyt, dzt, torch.cos(rg), torch.sin(rg), *cts], dim=-1)
得到的结果为
targets_dict = {
# 将一个batch中所有点的GT类别结果放入字典中 shape (batch * 16384)
'point_cls_labels': point_cls_labels,
# 将一个batch中所有点的GT_box编码结果放入字典中 shape (batch * 16384) shape (batch * 16384, 8)
'point_box_labels': point_box_labels,
# None
'point_part_labels': point_part_labels
}
2、第一阶段中的proposal生成
根据point_coords,point_cls_preds和point_box_preds来生成前景点的proposal。
代码在:pcdet/models/dense_heads/point_head_template.py
def generate_predicted_boxes(self, points, point_cls_preds, point_box_preds):
"""
Args:
points: (N, 3) 每个点的实际坐标
point_cls_preds: (N, num_class) 每个点类别的预测结果
point_box_preds: (N, box_code_size) 每个点box的回归结果
Returns:
point_cls_preds: (N, num_class)
point_box_preds: (N, box_code_size)
"""
# 得到所有点预测类别最大值的索引 (batch*16384, 3) --> (batch*16384, )
_, pred_classes = point_cls_preds.max(dim=-1)
# 根据预测的点解码其对应的box
# 第一阶段中 point_box_preds (batch * 16384, 7) 7个特征代表了 x, y, z, l, w, h, Θ
# point_box_preds (batch * 16384, 8) 8个特征代表了 x, y, z, l, w, h, cos(Θ), sin(Θ)##
point_box_preds = self.box_coder.decode_torch(point_box_preds, points, pred_classes + 1)
# 返回所有anchor的预测类别,和对应类别生成的anchor
return point_cls_preds, point_box_preds
proposal的解码操作在代码在:pcdet/utils/box_coder_utils.py
def decode_torch(self, box_encodings, points, pred_classes=None):
"""
Args:
box_encodings: (N, 8 + C) [x, y, z, dx, dy, dz, cos, sin, ...]
points: [x, y, z]
pred_classes: (N) [1, num_classes]
Returns:
"""
# 这里指torch.split的第二个参数 torch.split(tensor, split_size, dim=) split_size是切分后每块的大小,
# 不是切分为多少块!,多余的参数使用*cags接收。dim=-1表示切分最后一个维度的参数
xt, yt, zt, dxt, dyt, dzt, cost, sint, *cts = torch.split(box_encodings, 1, dim=-1)
# 得到每个点在点云中实际的坐标位置
xa, ya, za = torch.split(points, 1, dim=-1)
# True 这里使用基于数据集求出的每个类别的平均长宽高
if self.use_mean_size:
# 确保GT中的类别数和长宽高计算的类别是一致的
assert pred_classes.max() <= self.mean_size.shape[0]
# 根据每个点的类别索引,来为每个点生成对应类别的anchor大小
point_anchor_size = self.mean_size[pred_classes - 1]
# 分割每个生成的anchor的长宽高
dxa, dya, dza = torch.split(point_anchor_size, 1, dim=-1)
# 计算每个anchor的底面对角线距离
diagonal = torch.sqrt(dxa ** 2 + dya ** 2)
# loss计算中anchor与GT编码的运算:g表示gt,a表示anchor
# ∆x = (x^gt − xa^da)/diagonal --> x^gt = ∆x * diagonal + x^da
# 下同
xg = xt * diagonal + xa
yg = yt * diagonal + ya
zg = zt * dza + za
# ∆l = log(l^gt / l^a)的逆运算 --> l^gt = exp(∆l) * l^a
# 下同
dxg = torch.exp(dxt) * dxa
dyg = torch.exp(dyt) * dya
dzg = torch.exp(dzt) * dza
# 角度的解码操作为 torch.atan2(sint, cost) #
else:
xg = xt + xa
yg = yt + ya
zg = zt + za
dxg, dyg, dzg = torch.split(torch.exp(box_encodings[..., 3:6]), 1, dim=-1)
# 根据sint和cost反解出预测box的角度数值
rg = torch.atan2(sint, cost)
cgs = [t for t in cts]
return torch.cat([xg, yg, zg, dxg, dyg, dzg, rg, *cgs], dim=-1)
最终得到的结果是:
# 所有点的类别预测结果 (batch * 16384, 3)
batch_dict['batch_cls_preds'] = point_cls_preds
# 所有点的回归预测结果 (batch * 16384, 7)
batch_dict['batch_box_preds'] = point_box_preds
# 所有点的在batch中的索引 (batch * 16384, )
batch_dict['batch_index'] = batch_dict['point_coords'][:, 0]
# loss计算中,是否需要对类别预测结果进行normalized
batch_dict['cls_preds_normalized'] = False
2.2、点云区域池化:
在获得了3DBbox的proposal之后,重点变聚焦在如何优化box的位置和朝向。为了学习到更加细致的proposal特征,PointRCNN中通过池化的方式,将每个proposal中的点的特征和内部的点进行池化操作。