作者 | 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
导入必要的库:
import numpy as npimport randomimport math!pip install path.py;from path import Path
可以将数据集直接下载到Google Colab运行时:
!wget http://3dvision.princeton.edu/projects/2014/3DShapeNets/ModelNet10.zip!unzip -q ModelNet10.zip path = Path("ModelNet10")
该数据集由.off文件组成,这些文件包含由顶点和三角形面表示的网格。顶点只是3D空间中的点,每个三角形由3个顶点索引组成。
将需要一个函数来读取.off文件:
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可能难以对其进行分类。(特别是知道该点云甚至看起来都不像床)。
一个解决方案可能非常简单:统一采样对象表面上的点。
不应忘记面可以具有不同的区域。
因此可以按比例分配选择特定面的概率。这是可以做到的:
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]]))
的网络架构中将包含密集的层。这就是为什么要在点云中固定数量的点。从构造的分布中采样面。之后为每个选定的面采样一个点:
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轴随机旋转对象并添加高斯噪声,如本文所述:
# 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空间中对翻译进行编码。无论如何,已经在预处理过程中将点云转换为原点。
这里重要的一点是输出矩阵的初始化。希望默认情况下它是身份,以开始训练而无需进行任何转换。因此只需向输出添加一个单位矩阵:
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对齐提取的点要素。
为了提供置换不变性,对提取和转换的特征应用对称函数(最大池化),因此结果不再依赖于输入点的顺序。
将它们结合在一起:
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:
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):
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