在点云上进行深度学习:在Google Colab中实现PointNet

作者 | Nikita Karaev

来源 | Medium

编辑 | 代码医生团队

1.简介

3D数据对于自动驾驶汽车,自动驾驶机器人,虚拟现实和增强现实至关重要。与以像素阵列表示的2D图像不同,它可以表示为多边形网格,体积像素网格,点云等。

图像来自:从PyTorch中的单个2D图像创建3D模型

在当今的计算机视觉和机器学习中,90%的进展仅涉及二维图像。

1.1.点云

点云是一种广泛使用的3D数据形式,可以由深度传感器(例如LIDAR和RGB-D相机)生成。

它是3D对象的最简单表示:仅在3D空间中指向,没有连通性。点云也可以包含点的法线。

几乎所有3d扫描设备都会产生点云。

可以捕获点云的设备(Iphone 11,Asus Zenfone AR,Sony Xperia XZ1)

此外,最近苹果公司推出了带LiDAR扫描仪的Ipad Pro,可测量距周围物体的距离(最远5米)。

1.2.点云上的深度学习

因此考虑如何处理点云。CNN适用于图像。可以将它们用于3D吗?

想法:将2D卷积泛化为常规3D网格

图片来自:arxiv

这实际上有效。

https://arxiv.org/pdf/1604.03265.pdf

主要问题是表示效率低下:大小为100的立方体素网格将具有1,000,000体素。

1.3. PointNet

但是,如果尝试使用点云代替呢?

主要有三个约束:

  • 点云是无序的。算法必须对输入集的排列保持不变。
  • 如果我们旋转椅子,它仍然是椅子,对吗?网络对于不变的转换必须是不变的。
  • 网络应捕获点之间的交互。

PointNet的作者介绍了一种将所有这些属性都考虑在内的神经网络。它设法解决分类,部分和语义分割任务。

https://arxiv.org/pdf/1612.00593.pdf

图片来自:arxiv

2.实施

在本节中,将重新实现分类模式从原来的论文在谷歌Colab使用PyTorch。

可以在以下位置找到完整的笔记本:

https://arxiv.org/pdf/1612.00593.pdf

https://github.com/nikitakaraevv/pointnet/blob/master/nbs/PointNetClass.ipynb

2.1.数据集

在原始论文中,作者在ModelNet40形状分类基准上评估了PointNet。它包含来自40个对象类别的12,311个模型,分为9,843个训练和2,468个用于测试的模型。

为了简单起见,使用同一数据集的较小版本:ModelNet10。它包含来自10个类别的对象,用于训练的3,991个模型和用于测试的908个模型。

http://3dvision.princeton.edu/projects/2014/3DShapeNets/

如果想直接开始训练,别忘了打开GPU

导入必要的库:

代码语言:javascript
复制
import numpy as npimport randomimport math!pip install path.py;from path import Path

可以将数据集直接下载到Google Colab运行时:

代码语言:javascript
复制
!wget http://3dvision.princeton.edu/projects/2014/3DShapeNets/ModelNet10.zip!unzip -q ModelNet10.zip path = Path("ModelNet10")

该数据集由.off文件组成,这些文件包含由顶点和三角形面表示的网格。顶点只是3D空间中的点,每个三角形由3个顶点索引组成。

将需要一个函数来读取.off文件:

代码语言:javascript
复制
def read_off(file):    if 'OFF' != file.readline().strip():        raise('Not a valid OFF header')    n_verts, n_faces, __ = tuple([int(s) for s in file.readline().strip().split(' ')])    verts = [[float(s) for s in file.readline().strip().split(' ')] for i_vert in range(n_verts)]    faces = [[int(s) for s in file.readline().strip().split(' ')][1:] for i_face in range(n_faces)]    return verts, faces    with open(path/"bed/train/bed_0001.off", 'r') as f:    mesh = read_off(f)

这是全网格的样子:

在.off文件之一中划分网格。使用plotly创建

如您所见,这是一张床

但是,如果摆脱了面,只保留了3D点,它看起来就不再像床了!

网格顶点

实际上曲面的平坦部分不需要任何点即可进行网格构建。这就是为什么点主要位于床的角度和圆形部分的原因。

2.2.点采样

因此由于点在对象表面上的分布不均匀,因此PointNet可能难以对其进行分类。(特别是知道该点云甚至看起来都不像床)。

一个解决方案可能非常简单:统一采样对象表面上的点。

不应忘记面可以具有不同的区域。

因此可以按比例分配选择特定面的概率。这是可以做到的:

代码语言:javascript
复制
verts, faces = meshareas = np.zeros((len(faces)))verts = np.array(verts) # function to calculate triangle area by its vertices# https://en.wikipedia.org/wiki/Heron%27s_formuladef triangle_area(pt1, pt2, pt3):    side_a = np.linalg.norm(pt1 - pt2)    side_b = np.linalg.norm(pt2 - pt3)    side_c = np.linalg.norm(pt3 - pt1)    s = 0.5 * ( side_a + side_b + side_c)    return max(s * (s - side_a) * (s - side_b) * (s - side_c), 0)**0.5 # we calculate areas of all faces in our meshfor i in range(len(areas)):    areas[i] = (triangle_area(verts[faces[i][0]],                              verts[faces[i][1]],                              verts[faces[i][2]]))

的网络架构中将包含密集的层。这就是为什么要在点云中固定数量的点。从构造的分布中采样面。之后为每个选定的面采样一个点:

代码语言:javascript
复制
k = 3000# we sample 'k' faces with probabilities proportional to their areas# weights are used to create a distribution.# they don't have to sum up to one.sampled_faces = (random.choices(faces,                                weights=areas,                                k=k)) # function to sample points on a triangle surfacedef sample_point(pt1, pt2, pt3):    # barycentric coordinates on a triangle    # https://mathworld.wolfram.com/BarycentricCoordinates.html    s, t = sorted([random.random(), random.random()])    f = lambda i: s * pt1[i] + (t-s) * pt2[i] + (1-t) * pt3[i]    return (f(0), f(1), f(2)) pointcloud = np.zeros((k, 3)) # sample points on chosen faces for the point cloud of size 'k'for i in range(len(sampled_faces)):    pointcloud[i] = (sample_point(verts[sampled_faces[i][0]],                                  verts[sampled_faces[i][1]],                                  verts[sampled_faces[i][2]]))

一些面可能有多个采样点,而另一些根本没有点。

通过采样网格表面上的点创建的点云

这个点云看起来更像一张床!

2.3.扩充

考虑其他可能的问题。知道对象可以具有不同的大小,并且可以放置在坐标系统的不同部分中。

所以翻译的对象原点从它的所有点减去均值和正常化的点到单位球。为了增加训练期间的数据,绕着Z轴随机旋转对象并添加高斯噪声,如本文所述:

代码语言:javascript
复制
# normalizenorm_pointcloud = pointcloud - np.mean(pointcloud, axis=0)norm_pointcloud /= np.max(np.linalg.norm(norm_pointcloud, axis=1)) # rotation around z-axistheta = random.random() * 2. * math.pi # rotation anglerot_matrix = np.array([[ math.cos(theta), -math.sin(theta),    0],                       [ math.sin(theta),  math.cos(theta),    0],                       [0,                             0,      1]]) rot_pointcloud = rot_matrix.dot(pointcloud.T).T # add some noisenoise = np.random.normal(0, 0.02, (pointcloud.shape))noisy_pointcloud = rot_pointcloud + noise

这是归一化的床,带有旋转和噪音:

旋转点云增加了噪声

2.4.模型

好的已经完成了数据集和预处理。考虑一下模型架构。

记得结果应该对输入点排列和几何变换(如刚性变换)是不变的。

图片来自:arxiv

开始在PyTorch中实现它:

首先,张量将具有大小(batch_size, num_of_points, 3)。在这种情况下,具有共享权重的MLP只是1维卷积,内核大小为1。

为了确保变换的不变性,将T-Net预测的3x3变换矩阵应用于输入点的坐标。有趣的是,无法通过3维矩阵在3D空间中对翻译进行编码。无论如何,已经在预处理过程中将点云转换为原点。

这里重要的一点是输出矩阵的初始化。希望默认情况下它是身份,以开始训练而无需进行任何转换。因此只需向输出添加一个单位矩阵:

代码语言:javascript
复制
import torchimport torch.nn as nnimport torch.nn.functional as F class Tnet(nn.Module):   def __init__(self, k=3):      super().__init__()      self.k=k      self.conv1 = nn.Conv1d(k,64,1)      self.conv2 = nn.Conv1d(64,128,1)      self.conv3 = nn.Conv1d(128,1024,1)      self.fc1 = nn.Linear(1024,512)      self.fc2 = nn.Linear(512,256)      self.fc3 = nn.Linear(256,k*k)       self.bn1 = nn.BatchNorm1d(64)      self.bn2 = nn.BatchNorm1d(128)      self.bn3 = nn.BatchNorm1d(1024)      self.bn4 = nn.BatchNorm1d(512)      self.bn5 = nn.BatchNorm1d(256)           def forward(self, input):      # input.shape == (bs,n,3)      bs = input.size(0)      xb = F.relu(self.bn1(self.conv1(input)))      xb = F.relu(self.bn2(self.conv2(xb)))      xb = F.relu(self.bn3(self.conv3(xb)))      pool = nn.MaxPool1d(xb.size(-1))(xb)      flat = nn.Flatten(1)(pool)      xb = F.relu(self.bn4(self.fc1(flat)))      xb = F.relu(self.bn5(self.fc2(xb)))            # initialize as identity      init = torch.eye(self.k, requires_grad=True).repeat(bs,1,1)      if xb.is_cuda:        init=init.cuda()      # add identity to the output      matrix = self.fc3(xb).view(-1,self.k,self.k) + init      return matrix

应用MLP之后,将使用相同但64维的T-Net对齐提取的点要素。

为了提供置换不变性,对提取和转换的特征应用对称函数(最大池化),因此结果不再依赖于输入点的顺序。

将它们结合在一起:

代码语言:javascript
复制
class Transform(nn.Module):   def __init__(self):        super().__init__()        self.input_transform = Tnet(k=3)        self.feature_transform = Tnet(k=64)        self.conv1 = nn.Conv1d(3,64,1)         self.conv2 = nn.Conv1d(64,128,1)        self.conv3 = nn.Conv1d(128,1024,1)         self.bn1 = nn.BatchNorm1d(64)        self.bn2 = nn.BatchNorm1d(128)        self.bn3 = nn.BatchNorm1d(1024)          def forward(self, input):        matrix3x3 = self.input_transform(input)        # batch matrix multiplication        xb = torch.bmm(torch.transpose(input,1,2), matrix3x3).transpose(1,2)        xb = F.relu(self.bn1(self.conv1(xb)))         matrix64x64 = self.feature_transform(xb)        xb = torch.bmm(torch.transpose(xb,1,2), matrix64x64).transpose(1,2)         xb = F.relu(self.bn2(self.conv2(xb)))        xb = self.bn3(self.conv3(xb))        xb = nn.MaxPool1d(xb.size(-1))(xb)        output = nn.Flatten(1)(xb)        return output, matrix3x3, matrix64x64

然后,将其全部包装在一个类中,并在输出中包含最后一个MLP和LogSoftmax:

代码语言:javascript
复制
class PointNet(nn.Module):    def __init__(self, classes=10):        super().__init__()        self.transform = Transform()        self.fc1 = nn.Linear(1024, 512)        self.fc2 = nn.Linear(512, 256)        self.fc3 = nn.Linear(256, classes)         self.bn1 = nn.BatchNorm1d(512)        self.bn2 = nn.BatchNorm1d(256)        self.dropout = nn.Dropout(p=0.3)        self.logsoftmax = nn.LogSoftmax(dim=1)     def forward(self, input):        xb, matrix3x3, matrix64x64 = self.transform(input)        xb = F.relu(self.bn1(self.fc1(xb)))        xb = F.relu(self.bn2(self.dropout(self.fc2(xb))))        output = self.fc3(xb)        return self.logsoftmax(output), matrix3x3, matrix64x64

最后,将定义损失函数。由于使用LogSoftmax 来保持稳定性,因此应该应用NLLLoss而不是CrossEntropyLoss。另外,将在阶跃变换矩阵中添加两个正则化项以使其接近正交(AAᵀ= I):

代码语言:javascript
复制
def pointnetloss(outputs, labels, m3x3, m64x64, alpha = 0.0001):    criterion = torch.nn.NLLLoss()    bs = outputs.size(0)    id3x3 = torch.eye(3, requires_grad=True).repeat(bs, 1, 1)    id64x64 = torch.eye(64, requires_grad=True).repeat(bs, 1, 1)    if outputs.is_cuda:        id3x3 = id3x3.cuda()        id64x64 = id64x64.cuda()    diff3x3 = id3x3 - torch.bmm(m3x3, m3x3.transpose(1, 2))    diff64x64 = id64x64 - torch.bmm(m64x64, m64x64.transpose(1, 2))    return criterion(outputs, labels) + alpha * (torch.norm(diff3x3) + torch.norm(diff64x64)) / float(bs)

2.5.训练

最后一步!只能使用经典的PyTorch训练循环。

同样,可以在此链接后找到带有训练循环的完整Google Colab笔记本。

https://github.com/nikitakaraevv/pointnet/blob/master/nbs/PointNetClass.ipynb

看一下在GPU上训练15个纪元后的结果。训练本身大约需要3个小时,但可能会因Colab分配给当前会话的GPU的类型而异。

通过一个简单的训练循环,在13个历时之后,可以达到85%的总体验证准确性,而原始工作中 40个班级的验证准确性为89%。这里的重点是实施完整模型,而不是真正获得最佳分数。因此,将调整训练循环和其他实验作为练习。

有趣的是,模型有时会使梳妆台与床头柜,厕所与椅子,桌子与桌子混淆,这是可以理解的(厕所除外)。

3.最后的话

实现了PointNet,这是一种深度学习架构,可用于各种3D识别任务。即使在这里实现了分类模型,分段,正态估计或其他任务也只需要在模型和数据集类中进行较小的更改。

完整的笔记本

https://github.com/nikitakaraevv/pointnet/blob/master/nbs/PointNetClass.ipynb

参考文献:

[1] 查尔斯·R·齐,苏昊,莫凯春,莱昂尼达斯· 吉巴斯,PointNet:针对3D分类和分割的点集深度学习(2017),CVPR 2017

http://stanford.edu/~rqi/pointnet/

[2] 亚当·康纳·西蒙斯(Adam Conner-Simons),《基于点云的深度学习》(2019年),麻省理工学院计算机科学与人工智能实验室

http://news.mit.edu/2019/deep-learning-point-clouds-1021

[3] Loic Landrieu,3D点云的语义分割(2019年),巴黎埃斯特大学—机器学习和优化工作组

http://bezout.univ-paris-est.fr/wp-content/uploads/2019/04/Landrieu_GT_appr_opt.pdf

[4] Charles R. Qi等人,《基于3D数据的对象分类的体积和多视图CNN》(2016年),arxiv.org。

https://arxiv.org/pdf/1604.03265.pdf