文章目录
- 1 使用PointNet++实现语义场景分割
- 1.1 PointNet++原理概述
- 1.2 PointNet++网络训练
- 2 使用DBSCAN实现语义对象实例化
- 2.1 DBSCAN聚类算法的原理
- 2.2 使用DBSCAN算法实例化人形对象
- 3 结语
- 参考
本篇是AVP系列的第二篇,主要讲述如何利用标注好 Person和 Others的点云数据进行 PointNet2语义分割网络的训练,以及将分割结果利用 DBSCAN算法实例化。
某一时刻的可视化如下,从低到高分别是无标注点云、有标注点云、分割点云、和实例化点云:
对于一段时间而言,分割实例化的可视化效果如下:
第一篇传送门:【AVP项目01】bag数据读取、深度图转融合点云、点云标注
1 使用PointNet++实现语义场景分割
1.1 PointNet++原理概述
PointNet相对于以往的点云处理网络,能够直接对点云数据进行处理,而不需要将其体素化或重映射到二维相机平面进行处理,因而具有端到端的处理特性,且因其简单高效而被广泛应用于Classification、Part Seg、Semantic Seg等场合。
PointNet从点云对象的无序性出发,要求网络具有置换不变性,从而设计出max pooling的方法来构造网络结构,同时,为了提升网络的性能,在最大池化前,先使用mlp多层感知器对输入的每个点进行升维处理:
f
(
x
1
,
.
.
.
,
x
n
)
≈
g
(
h
(
x
1
)
,
.
.
.
h
(
x
2
)
)
f({x_1,...,x_n})\approx g(h(x_1),...h(x_2))
f(x1,...,xn)≈g(h(x1),...h(x2))
其中
x
x
x为输入的点,
f
f
f为神经网络等效的函数,
g
g
g为最大池化,
h
h
h为多层感知器。
PointNet网络的典型结构如下,用于分割任务时的输出为
(
n
×
m
)
(n\times m)
(n×m),指示每个点属于各个分割部位的概率:
但是,从上图也可以得知,PointNet对每个点分别处理,最后融合全局信息,但是过程之中没有考虑到点与点之间的局部信息,而 PointNet++ 正是基于此开发改进的。
PointNet++ 不再对独立的点进行处理,而是改为对 点簇 进行处理。在输入网络前,先按照最大距离采样固定数目的点,再在给定半径下把原先的所有点分组至固定数目的点簇(有固定的簇内点数),对每个点簇使用与给定半径相对应的mlp网络(因为输入是固定尺寸的数据,因而可以使用CNN升维)提取特征后(此时点簇特征与PointNet中的点特征等效),再输入给PointNet网络进行训练。
而为了减少采样点簇数目和点增加/减少等的干扰,增加网络的鲁棒性,实际过程中常采样多次不同数目的固定点,并在每个固定数目点的情况下,分多种半径,以及与之对应簇内点数和mlp网络,在每种情况下进行分组提取特征(MSG, multi-scale grouping),最后再将各固定数目点各给定半径下的特征级联起来以丰富特征,然后再输入PointNet网络进行训练。
当然,上述说法只是原理性的,在具体代码处理时稍有不同。
1.2 PointNet++网络训练
本项目使用的 PointNet++ 网络基于pytorch,开源项目的链接如下:Pointnet_Pointnet2_pytorch.
这里选择的网络是 pointnet2_part_seg_* 系列,*表示ssg/msg,这是 PointNet++ 的部件分割网络模型,实际上场景语义分割的原理与部件分割的原理比较类似,且这里只需要分割出停车场中的人形骨架,较为简单,因而只要构造好输入数据的形式,使用部件分割的网络去完成语义场景分割的任务也是可行的。
训练过程的超参数如下表:
变量名 | 含义 | 值 |
---|---|---|
num_classes(NC) | 类别数 | 1 |
num_part(NP) | 分割部件数 | 2 |
num_traindataset | 训练集数目 | 353(75%) |
num_testdataset | 测试集数目 | 118(25%) |
channel (C) | 每个点的维度 | 3 |
npoint(N) | 采样点数 | 4096 |
radius_list | 采样半径列表 | [0.1, 0.2, 0.4] |
nsample_list | 簇内点数列表 | [32, 64, 128] |
mlp_list | mlp网络列表 | [[32, 32, 64], [64, 64, 128], [64, 96, 128]] |
normal | 是否使用法向量 | False |
epoch | 训练轮次 | 50 |
batch_size(B) | 批次大小 | 16 |
optimizer | 优化器 | Adam |
learning_rate | 学习率 | 0.001 |
lr_decay | 学习率衰减比率 | 0.5 |
step_size | 学习率衰减步长 | 20 |
在正式开始训练前,由于Pointnet_Pointnet2_pytorch项目中的代码是部件分割代码是针对ShapeNet数据集设计的,其网络的部分参数也是针对此设计的,因而如果要把代码用于自己的项目,我们需要对部分代码进行修改,以pointnet2_part_seg_msg模型为例,对模型文件的代码做以下5处修改(注释中有说明修改原因):
class get_model(nn.Module):
# 1 修改初始化,把num_classes该外num_part, 并另增加num_classes参数,
# 以后数据集变化就不需要改网络内部结构了
#def __init__(self, num_classes, normal_channel=False):
def __init__(self, num_part, num_classes=16, normal_channel=False):
super(get_model, self).__init__()
# 2 添加这几行以共享类内变量
self.num_part = num_part
self.num_classes = num_classes
if normal_channel:
additional_channel = 3
else:
additional_channel = 0
self.normal_channel = normal_channel
self.sa1 = PointNetSetAbstractionMsg(512, [0.1, 0.2, 0.4], [32, 64, 128], 3+additional_channel, [[32, 32, 64], [64, 64, 128], [64, 96, 128]])
self.sa2 = PointNetSetAbstractionMsg(128, [0.4,0.8], [64, 128], 128+128+64, [[128, 128, 256], [128, 196, 256]])
self.sa3 = PointNetSetAbstraction(npoint=None, radius=None, nsample=None, in_channel=512 + 3, mlp=[256, 512, 1024], group_all=True)
self.fp3 = PointNetFeaturePropagation(in_channel=1536, mlp=[256, 256])
self.fp2 = PointNetFeaturePropagation(in_channel=576, mlp=[256, 128])
# 3 这里150 = 128 + (16+6) 其中16为num_classes
#self.fp1 = PointNetFeaturePropagation(in_channel=150+additional_channel, mlp=[128, 128])
self.fp1 = PointNetFeaturePropagation(in_channel=128+6+num_classes+additional_channel, mlp=[128, 128])
self.conv1 = nn.Conv1d(128, 128, 1)
self.bn1 = nn.BatchNorm1d(128)
self.drop1 = nn.Dropout(0.5)
# 4 最后分类的输出,由于上文改num_classes为num_part
# self.conv2 = nn.Conv1d(128, num_classes, 1)
self.conv2 = nn.Conv1d(128, num_part, 1)
def forward(self, xyz, cls_label):
# Set Abstraction layers
B,C,N = xyz.shape
if self.normal_channel:
l0_points = xyz
l0_xyz = xyz[:,:3,:]
else:
l0_points = xyz
l0_xyz = xyz
l1_xyz, l1_points = self.sa1(l0_xyz, l0_points)
l2_xyz, l2_points = self.sa2(l1_xyz, l1_points)
l3_xyz, l3_points = self.sa3(l2_xyz, l2_points)
# Feature Propagation layers
l2_points = self.fp3(l2_xyz, l3_xyz, l2_points, l3_points)
l1_points = self.fp2(l1_xyz, l2_xyz, l1_points, l2_points)
# 5 这里16表示num_classes
# cls_label_one_hot = cls_label.view(B, 16, 1).repeat(1, 1, N)
cls_label_one_hot = cls_label.view(B, self.num_classes, 1).repeat(1, 1, N)
l0_points1 = torch.cat([cls_label_one_hot,l0_xyz,l0_points], 1)
l0_points = self.fp1(l0_xyz, l1_xyz, l0_points1, l1_points)
# FC layers
feat = F.relu(self.bn1(self.conv1(l0_points)))
x = self.drop1(feat)
x = self.conv2(x)
x = F.log_softmax(x, dim=1)
x = x.permute(0, 2, 1)
return x, l3_points
相应地,在train_partseg.py主程序中也要对模型的定义做相应修改:
#classifier = MODEL.get_model(num_part, normal_channel=args.normal).cuda()
classifier = MODEL.get_model(num_part, num_classes, normal_channel=args.normal).cuda()
接下来,用num_part,num_classes和normal初始化 pointnet2_part_seg_* 模型,其前向传播网络的输入为 ( B , C , N ) (B,C,N) (B,C,N)的点云数据和一个 ( B , N C ) (B,NC) (B,NC)的one-hot形式的类别选择矩阵,其输出为 ( B , N , N P ) (B,N,NP) (B,N,NP)的分割概率矩阵。
为获取 ( B , N C ) (B,NC) (B,NC)的one-hot形式的类别选择矩阵,可以使用以下程序:
def to_categorical(y, num_classes):
""" 1-hot encodes a tensor """
#y为(B,1)的类别向量
new_y = torch.eye(num_classes)[y.cpu().data.numpy(),]
if (y.is_cuda):
return new_y.cuda()
return new_y #把一个批次中的各类别cls变成一个类似于mask的矩阵(B,NC)
为获取与AVP场景相适配的 ( B , C , N ) (B,C,N) (B,C,N)的点云数据,构造一个AVPDataLoaderRandom的数据集类如下:
def pc_normalize(pc): #数据的xyz经过了标准化再输入的模型
centroid = np.mean(pc, axis=0)
pc = pc - centroid
m = np.max(np.sqrt(np.sum(pc**2, axis=1)))
pc = pc / m
return pc
class AVPDataLoaderRandom(torch.utils.data.Dataset):
def __init__(self, root='./data/data_pcd_merge_label_use_random', npoints=4096, split='trainval', normal_channel=False):
self.root = root
self.npoints = npoints
self.split = split
self.normal_channel = normal_channel
self.classes = {'Scene': 0}
self.datanames = [f for f in os.listdir(root) if f.endswith('.txt')]
train_datanames, test_datanames = train_test_split(self.datanames, test_size=0.25, shuffle=True, random_state=0)
if split=='trainval': #存储划分的序列名称,方便后期测试
self.datanames = train_datanames
np.savetxt(os.path.join(root, 'list/train_list.txt'), train_datanames, fmt='%s')
elif split=='test':
self.datanames = test_datanames
np.savetxt(os.path.join(root, 'list/test_list.txt'), test_datanames, fmt='%s')
def __getitem__(self, idx):
dataname = self.datanames[idx]
datapath = os.path.join(self.root, dataname)
data = np.loadtxt(datapath)
choice = np.random.choice(len(data), self.npoints, replace=True) #随机采样
data = data[choice,:]
# data = farthest_point_sample(data, self.npoints) #最远点采样
xyz = pc_normalize(data[:,0:3].astype(np.float32)) #(npoint,3)
cls = np.asarray(self.classes['Scene']).astype(np.float32)#(1)
seg = data[:,-1].astype(np.int32)#(npoint,1)
return xyz, cls, seg
def __len__(self):
return len(self.datanames)
注意在AVPDataLoaderRandom中已经对点云的位置坐标做了归一化处理。
最后,改写train_partseg.py中超参数的部分即可开始训练。
训练完成后,pointnet2_part_seg_msg在测试集上的性能如下:
指标 | 值 |
---|---|
Accuracy | 0.99979 |
instance avg mIOU | 0.99720 |
avg time cost | 0.87726 |
另外,同样的方式,对pointnet2_part_seg_ssg网络模型进行训练,该网络没有采用多半径分组(MSG)的策略,其在测试集上的性能如下:
指标 | 值 |
---|---|
Accuracy | 0.99949 |
instance avg mIOU | 0.99002 |
avg time cost | 0.49573 |
可以看出相比于MSG方法,虽然SSG准确指标稍有下降,但是其运行时间大幅衰减,更能够满足实时性要求,故而最终选用pointnet2_part_seg_ssg网络训练的模型。
另外,从评价指标上看,由于人形点云Person与其他场景点云Others的数量有较大差距,因而上述准确性指标可能会有失偏颇。兴许可以通过划定初始点云的范围以集中在人形点云出现的空间来加以改善。
2 使用DBSCAN实现语义对象实例化
2.1 DBSCAN聚类算法的原理
DBSCAN(Density-Based Spatial Clustering of Applications with Noise)是一种基于密度的聚类算法,因其不需要预先指定聚类数目,对孤点不敏感的特点,而广泛应用于发现具有任意形状的聚类。它的主要原理可以概括为以下几个方面:
- 核心点(Core Point):在给定半径 ε(epsilon)内,包含至少 MinPts(最小点数)个点的点。
- 边界点(Border Point):在某个核心点的 ε 邻域内,但没有足够的点成为核心点。
- 噪声点(Noise Point):既不是核心点也不是边界点的点。
聚类的形成:
从一个随机选择的未访问点开始,检查其邻域内的点。如果该点是核心点,它会形成一个新聚类,算法会递归地访问该核心点的邻域内的所有点:如果邻域内包含其他核心点,则会将这些核心点所形成的聚类也合并到当前聚类中;如果初始选择的点不是核心点,算法将标记为噪声点,继续选择下一个未访问的点。
2.2 使用DBSCAN算法实例化人形对象
考虑到分割出人形的点云后,各个人形实例之间大都有足够的间隙,从而可以形成不同的点簇(实例),因而考虑使用聚类算法将其实例化。
算法设计主要使用open3D中集成的DBSCAN算法,并按照点簇大小重新安排其聚类号,再进行颜色映射,将噪点映射成黑色,其他点按照聚类号映射颜色。
def label_form(labels):
'''将dbscan的聚类结果按点簇的大小重新对应聚类号(除噪点外)'''
label_counts = Counter(labels)
tmp = []
for label, count in label_counts.items():
if label != -1:
tmp.append(count)
reflection = {}
for i, item in enumerate(sorted(tmp)):
reflection[item] = i
new_labels = np.zeros_like(labels, dtype=int)
for label, count in label_counts.items():
mask = labels == np.ones_like(labels) * label
if label != -1: # 忽略噪声点
new_labels[mask] = reflection[count] # 将聚类内的点赋值为该聚类的点数
else:
new_labels[mask] = -1
return new_labels
labels = np.array(o3d.geometry.PointCloud.cluster_dbscan(pcds3, eps=0.5, min_points=10, print_progress=True)) #open3d库中的聚类号
new_labels = label_form(labels) #按点簇大小重新指定聚类号
max_label = new_labels.max()
print('Cluter num: %d.'%(max_label+1))
colors = np.zeros((len(labels), 3)) # 初始化颜色数组
for i,item in enumerate(new_labels):
if item!=-1: #噪声为黑色
colors[i,:] = plt.get_cmap("tab20")(item/ (max_label if max_label > 0 else 1))[0:3]
pcds3.colors = o3d.utility.Vector3dVector(colors)
该实例化算法的优点是简单,抗干扰能力强,且满足实时性要求,但缺点是对于超参数(给定半径 ϵ \epsilon ϵ和最小点数 M i n P t s MinPts MinPts)比较敏感,且对于紧挨着的人形实例不具有实例化的效果。兴许可以通过增加初始的采样点,适当减小 ϵ \epsilon ϵ和调整 M i n P t s MinPts MinPts来解决,亦或是通过设置点簇大小的阈值,并对超过阈值的点簇实例再次运用分割算法(收集利用超过阈值的点云实例重新训练)来加以解决。
3 结语
对于标注好的AVP场景数据,使用pointnet2_part_seg_ssg模型进行人形语义分割,并利用DBSCAN聚类算法实例化,从测试集指标上,在准确率和实时性上都达到了较好的效果。
选定0074时间戳下的点云,进行语义分割和实例化可视化,如下图,从低到高分别是无标注点云、有标注点云、分割点云、和实例化点云。
参考
- Xu Yan, Pointnet/Pointnet++ Pytorch, https://github.com/yanx27/Pointnet_Pointnet2_pytorch, 2019.