作为点云方向的开山之作,从论文角度进行自我理解的剖析。
PointNet:
论文名称:《PointNet:Deep Learning on Point Sets for 3D Classification and Segmentation》
一、首先引入点云的概念
1.1目前存在的三维数据的表现形式
1.Point Cloud(点云):
点云是一个数据集,由n个D维的点组成,数据集中的每个点代表X、Y、Z几何坐标和一个强度值,这个强度值根据物体表面反射率记录返回信号的强度。D=3时,一般代表(x,y,z)的坐标;D=6时,代表(x,y,z)及其三个法向量。
2.Mesh(栅格):
由正方形面片和三角形面片组成。
3.Volumetric(体素):
体素类似于一个从二维像素点扩展到三维立方体单元的图像,解决了点云的无序性和非结构化的问题。
4.Multi-view images(多视角):
从BEV地图生成一组高度准确的3D候选框,并将其投影到多个视图的特征地图(例如, LiDAR前视图图像、RGB图像)。 然后,他们将来自不同视图的这些区域特征组合起来,以预测定向的3D边界框。
图1:四种三维数据下提取的兔子形状
1.2为什么要引入点云
经典的卷积架构需要高度规则的输入数据格式,然而最初获得的点云数据需要进行一系列相关操作,从而转换为相应的输入数据格式,这也使得生成的数据不必要的庞大,且引入了可能掩盖数据自然不变性的量化伪像,故提出直接处理点云的PointNet架构。
1.3 点云的两大特性(在PointNet中提到)
1.置换不变性
定理1:大概意思是任意一个在Hausdorff空间上连续的函数,都可以被这样的PointNet(vanilla)无限的逼近。 扩展知识:PointNet(vanilla):我们先将点云的每个点映射到一个冗余的高维空间后,再去进行max的对称函数操作,这样损失的特征相对较少,根据这样的思路设计出PoineNet的雏形。
2.旋转不变性
给予点云一个旋转,所有xyz坐标都变了,但是代表的还是同一个物体。 在论文中,作者引入T-Net网络结构去学习点云的旋转,将物体校准,剩下交给PoinetNet(vanilla)进行分类和分割。(这次校准,在Softmax训练中引入一个正则化惩罚项,希望其尽可能将特征变换矩阵限制为接近于一个正交矩阵,其不会丢失输入中的信息,优化变得更加稳定)
二、PointNet的处理流程
①输入为一帧的全部点云数据的集合,表示为一个n×3的2D张量,其中n代表点云的数量,3对应xyz的坐标。
②输入数据先通过和一个T-Net学习到的转换矩阵相乘来对齐,保证了模型的对特定空间转换的不变性。
③通过多次MLP对各点云数据进行特征提取后,再用一个T-Net对特征对齐。
④在特征的各个维度上执行maxpooling操作来得到最终的全局特征。
⑤对分类任务,将全局特征通过MLP来预测最后的分类分数;对分割任务,将全局特征和之前学习到的各点云的局部特征进行串联,再通过MLP得到每个数据点的分类结果。
三、PointNet架构
图2:分类网络将 n 个点作为输入,应用输入和特征变换,然后通过最大池化聚合点特征。输出是 k 个类的分类分数。分割网络是分类网络的扩展。它连接全局和局部特征并输出每个点的分数。 “mlp”代表多层感知器,括号中的数字是层大小。 Batchnorm 用于 ReLU 的所有层。 Dropout 层用于分类网中的最后一个mlp。
3.1 transform
第一次:3×3 transform,目的是将输入的点云进行对齐,位姿改变,使改变后的位姿更适合分类/分割。
第二次:64×64 transform,对64维特征进行对齐。因为前边进行了逐点的mlp,以获取相关的特征。
3.2 mlp
mlp(多层感知机)用于提取点云特征,在这里使用共享权重的卷积。
3.3 max pooling
汇总所有点云信息,进行最大池化,得到点云的全局信息。
3.4 Segmentation Network
将局部与全局信息拼接,形成一个n×1088的张量,通过两次的mlp,得到最后的n×m结果(n个点,每个点有m个语义子类别),并输入分割分数。
3.5 cls.loss
利用交叉熵损失函数:cls.loss=cls+seg+L2(transform,原图中的正交变换)。
3.6 T-Net网络结构
将输入的点云数据作为nx3x1单通道图像,接三次卷积和一次池化后,再reshape为1024个节点,然后接两层全连接,网络除最后一层外都使用了ReLU激活函数和批标准化。
四、实验结果
4.1 3D对象分类
表1:基于数据集ModelNet40的分类结果。
根据表中数据我们的方法和基于多视图的方法 (MVCNN [23]) 之间仍然存在很小的差距,我们认为这是由于丢失了渲染图像可以捕获的精细几何细节。
4.2 3D 对象部分分割
3D对象部分分割:将给定的3D扫描或网络模型,为每个点或面分配零件类别标签(例如椅子退、杯柄)。
表2:3D对象部分分割,基于ShapeNet零件数据集的分割结果,指标是点的mIoU(%)。
图3:语义分割的定性结果。顶行是带颜色的输入点云。底行是在与输入相同的相机视点中显示的输出语义分割结果(在点上)。
在图3中,展示了完整数据和部分数据的定性结果。可以看出,虽然部分数据相当具有挑战性,但预测是合理的。
4.3 场景中的语义分割
基于the Stanford 3d semantic parsing数据集,其中需要注意我们的PointNet预测的每个点都由XYZ、RGB和相对于房间的归一化位置(从0到1)的9维向量表示。而使用手工点特征的基线,其基线提取相同的9维局部特征和三个额外特征:局部点密度、局部曲率和法线。
表3:场景中语义分割的结果。指标是13个类别(结构和家具元素加上杂乱)的平均 IoU 和按点计算的分类准确度。
我们使用标准 MLP 作为分类器。结果如表3所示,我们的 PointNet 方法明显优于基线方法。
表4:场景中 3D 对象检测的结果。指标是在 3D 体积中计算的阈值 IoU 0.5 的平均精度。
基于我们网络的语义分割输出,我们进一步构建了一个使用连接组件进行对象建议的3D对象检测系统。我们在表4中与之前的最先进方法进行了比较。之前的方法基于滑动形状方法(使用CRF后处理),支持向量机在体素网格中的局部几何特征和全局房间上下文特征上进行训练。我们的方法在报告的家具类别上大大优于它。
图4:语义分割的定性结果。顶行是带颜色的输入点云。底行是在与输入相同的相机视点中显示的输出语义分割结果(在点上)。
4.4 稳健性(鲁棒性)
图5:PointNet 稳健性测试。该指标是 ModelNet40 测试集上的整体分类准确率。左:删除点。最远是指对原来的1024个点进行最远采样。中:插入。异常值均匀分布在单位球体中。右:扰动。独立地向每个点添加高斯噪声。
根据图5显示结果来看,该PointNet网络在数据丢失到50%,仍然有很好的稳定性。其次在训练期间碰到异常点也很稳健。最后证明网络对点的扰动有鲁棒性。
4.5 时空复杂性
表5:用于 3D 数据分类的深层架构的时间和空间复杂度。其中FLOP代表浮点运算,M代表百万,Subvolume 和 MVCNN 对来自多个旋转或视图的输入数据使用池化,否则它们的性能会差很多,PointNet (vanilla) 是没有输入和特征转换的分类 PointNet。
PointNet 更具可扩展性——它的空间和时间复杂度为 O(N)—与输入点的数量成线性关系。然而,由于卷积在计算时间上占主导地位,多视图方法的时间复杂度随图像分辨率呈正方形增长,而基于体积卷积的方法随体积大小呈三次方增长。
五、主要代码
5.1 transform的两种T-Net网络结构:
# STN3d:T-Net 3*3 transform
# 类似mini-PointNet
class STN3d(nn.Module):
def __init__(self, channel):
super(STN3d, self).__init__()
# torch.nn.Conv1d(in_channels输入通道数,out_channels输出通道数,kernel_size卷积核的大小,stride=1步长,padding=0边缘填充的值,dilation=1,groups=1,bias=True是否要添加偏移量)
self.conv1 = torch.nn.Conv1d(channel, 64, 1) # 利用一维卷积,channel可能为3或6
self.conv2 = torch.nn.Conv1d(64, 128, 1)
self.conv3 = torch.nn.Conv1d(128, 1024, 1)
self.fc1 = nn.Linear(1024, 512)
self.fc2 = nn.Linear(512, 256)
self.fc3 = nn.Linear(256, 9) # 9=3*3
self.relu = nn.ReLU()
self.bn1 = nn.BatchNorm1d(64) # BatchNorm1d:批归一化
self.bn2 = nn.BatchNorm1d(128)
self.bn3 = nn.BatchNorm1d(1024)
self.bn4 = nn.BatchNorm1d(512)
self.bn5 = nn.BatchNorm1d(256)
def forward(self, x):
batchsize = x.size()[0]
x = F.relu(self.bn1(self.conv1(x)))
x = F.relu(self.bn2(self.conv2(x)))
x = F.relu(self.bn3(self.conv3(x)))
x = torch.max(x, 2, keepdim=True)[0] # 做最大池化
x = x.view(-1, 1024) # 参数拉直(展平)
x = F.relu(self.bn4(self.fc1(x)))
x = F.relu(self.bn5(self.fc2(x)))
x = self.fc3(x)
# 展平的对角矩阵
iden = Variable(torch.from_numpy(np.array([1, 0, 0, 0, 1, 0, 0, 0, 1]).astype(np.float32))).view(1, 9).repeat(
batchsize, 1)
if x.is_cuda:
iden = iden.cuda()
x = x + iden # 仿射变换
x = x.view(-1, 3, 3)
return x
# STNkd:T-Net 64*64 transform,k默认为64
class STNkd(nn.Module):
def __init__(self, k=64):
super(STNkd, self).__init__()
self.conv1 = torch.nn.Conv1d(k, 64, 1)
self.conv2 = torch.nn.Conv1d(64, 128, 1)
self.conv3 = torch.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.relu = nn.ReLU()
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)
self.k = k
def forward(self, x):
batchsize = x.size()[0]
x = F.relu(self.bn1(self.conv1(x)))
x = F.relu(self.bn2(self.conv2(x)))
x = F.relu(self.bn3(self.conv3(x)))
x = torch.max(x, 2, keepdim=True)[0]
x = x.view(-1, 1024)
x = F.relu(self.bn4(self.fc1(x)))
x = F.relu(self.bn5(self.fc2(x)))
x = self.fc3(x)
iden = Variable(torch.from_numpy(np.eye(self.k).flatten().astype(np.float32))).view(1, self.k * self.k).repeat(
batchsize, 1)
if x.is_cuda:
iden = iden.cuda()
x = x + iden
x = x.view(-1, self.k, self.k)
return x
5.2 PointNet编码器和对特征转换矩阵做正则化:
# PointNet编码器
class PointNetEncoder(nn.Module):
def __init__(self, global_feat=True, feature_transform=False, channel=3):
super(PointNetEncoder, self).__init__()
self.stn = STN3d(channel)
self.conv1 = torch.nn.Conv1d(channel, 64, 1)
self.conv2 = torch.nn.Conv1d(64, 128, 1)
self.conv3 = torch.nn.Conv1d(128, 1024, 1)
self.bn1 = nn.BatchNorm1d(64)
self.bn2 = nn.BatchNorm1d(128)
self.bn3 = nn.BatchNorm1d(1024)
self.global_feat = global_feat
self.feature_transform = feature_transform
if self.feature_transform:
self.fstn = STNkd(k=64)
def forward(self, x):
B, D, N = x.size() # batchsize , 3(xyz坐标)或6(xyz坐标+法向量) , 1024(一个物体所取的点的数目)
trans = self.stn(x) # STN3d:T-Net 3*3
x = x.transpose(2, 1) # 交换一个tensor的两个维度
if D > 3:
# feature = x[:, :, 3:]
# x = x[:, :, :3]
x, feature = x.spilt(3, dim=2) #将x(tensor类型的张量),切割成3部分,dim:切割的维度
#对输入的点云进行输入转换(input,transform)
#input transform:计算两个tensor的矩阵乘法
#bmm是两个三维张量相乘,两个输入tensor的维度是(b*n*m)和(b*m*p)
#第一维b代表batch size,输出为:(b*n*p)
x = torch.bmm(x, trans)
if D > 3:
x = torch.cat([x, feature], dim=2) #cat拼接
x = x.transpose(2, 1)
x = F.relu(self.bn1(self.conv1(x))) #MLP
if self.feature_transform:
trans_feat = self.fstn(x) #STNkd:T-Net
x = x.transpose(2, 1)
x = torch.bmm(x, trans_feat)
x = x.transpose(2, 1)
else:
trans_feat = None
pointfeat = x #局部特征
x = F.relu(self.bn2(self.conv2(x))) #MLP
x = self.bn3(self.conv3(x)) #MLP
x = torch.max(x, 2, keepdim=True)[0] #最大池化得到全局特征
x = x.view(-1, 1024) #展平
if self.global_feat: #是否需要返回全局特征
return x, trans, trans_feat
else: #返回局部特征和全局特征的拼接
x = x.view(-1, 1024, 1).repeat(1, 1, N)
return torch.cat([x, pointfeat], 1), trans, trans_feat
#对特征转换矩阵做正则化
def feature_transform_reguliarzer(trans):
d = trans.size()[1]
I = torch.eye(d)[None, :, :] #torch.eye(n,m=None,out=None)返回一个二维张量,对角线全为1,其余位置全为0
if trans.is_cuda:
I = I.cuda()
#正则化损失函数
loss = torch.mean(torch.norm(torch.bmm(trans, trans.transpose(2, 1)) - I, dim=(1, 2)))
return loss
5.3 PointNet进行物体分类
class get_model(nn.Module):
def __init__(self, k=40, normal_channel=True):
super(get_model, self).__init__()
if normal_channel: #判断法向量
channel = 6
else:
channel = 3
self.feat = PointNetEncoder(global_feat=True, feature_transform=True, channel=channel)
self.fc1 = nn.Linear(1024, 512)
self.fc2 = nn.Linear(512, 256)
self.fc3 = nn.Linear(256, k)
self.dropout = nn.Dropout(p=0.4) #Dropout以一定概率关闭某些节点,避免过拟合
self.bn1 = nn.BatchNorm1d(512) #归一化
self.bn2 = nn.BatchNorm1d(256)
self.relu = nn.ReLU()
def forward(self, x):
x, trans, trans_feat = self.feat(x)
x = F.relu(self.bn1(self.fc1(x)))
x = F.relu(self.bn2(self.dropout(self.fc2(x))))
x = self.fc3(x)
x = F.log_softmax(x, dim=1) #0是对列做归一化,1是对行做归一化。在softmax的结果上再做多一次log运算
return x, trans_feat
class get_loss(torch.nn.Module):
def __init__(self, mat_diff_loss_scale=0.001):
super(get_loss, self).__init__()
self.mat_diff_loss_scale = mat_diff_loss_scale
def forward(self, pred, target, trans_feat):
#NLLLoss的输入是一个对数概率向量和一个目标标签,它不会计算对数概率
#适合网络的最后一层是log_softmax
#损失函数nn.CrossEntropyLoss() 与 NLLLoss相同,唯一的不同是它去做softmax
loss = F.nll_loss(pred, target) #分类损失
mat_diff_loss = feature_transform_reguliarzer(trans_feat) #特征变换的正则化损失
#总的损失函数
total_loss = loss + mat_diff_loss * self.mat_diff_loss_scale
return total_loss
5.4 PointNet进行部件分割
class get_model(nn.Module):
def __init__(self, part_num=50, normal_channel=True): #部件数量50个(ShapeNet)
super(get_model, self).__init__()
if normal_channel: #是否有法向量信息
channel = 6
else:
channel = 3
self.part_num = part_num
self.stn = STN3d(channel)
self.conv1 = torch.nn.Conv1d(channel, 64, 1)
self.conv2 = torch.nn.Conv1d(64, 128, 1)
self.conv3 = torch.nn.Conv1d(128, 128, 1)
self.conv4 = torch.nn.Conv1d(128, 512, 1)
self.conv5 = torch.nn.Conv1d(512, 2048, 1)
self.bn1 = nn.BatchNorm1d(64)
self.bn2 = nn.BatchNorm1d(128)
self.bn3 = nn.BatchNorm1d(128)
self.bn4 = nn.BatchNorm1d(512)
self.bn5 = nn.BatchNorm1d(2048)
self.fstn = STNkd(k=128) #维度128
self.convs1 = torch.nn.Conv1d(4944, 256, 1)
self.convs2 = torch.nn.Conv1d(256, 256, 1)
self.convs3 = torch.nn.Conv1d(256, 128, 1)
self.convs4 = torch.nn.Conv1d(128, part_num, 1)
self.bns1 = nn.BatchNorm1d(256)
self.bns2 = nn.BatchNorm1d(256)
self.bns3 = nn.BatchNorm1d(128)
def forward(self, point_cloud, label):
B, D, N = point_cloud.size()
trans = self.stn(point_cloud)
point_cloud = point_cloud.transpose(2, 1)
if D > 3:
point_cloud, feature = point_cloud.split(3, dim=2)
point_cloud = torch.bmm(point_cloud, trans)
if D > 3:
point_cloud = torch.cat([point_cloud, feature], dim=2)
point_cloud = point_cloud.transpose(2, 1)
out1 = F.relu(self.bn1(self.conv1(point_cloud)))
out2 = F.relu(self.bn2(self.conv2(out1)))
out3 = F.relu(self.bn3(self.conv3(out2)))
trans_feat = self.fstn(out3)
x = out3.transpose(2, 1)
net_transformed = torch.bmm(x, trans_feat)
net_transformed = net_transformed.transpose(2, 1)
out4 = F.relu(self.bn4(self.conv4(net_transformed)))
out5 = self.bn5(self.conv5(out4))
out_max = torch.max(out5, 2, keepdim=True)[0] #最大池化,max后为2048个元素
out_max = out_max.view(-1, 2048) #参数拉直(展直)
out_max = torch.cat([out_max,label.squeeze(1)],1)
expand = out_max.view(-1, 2048+16, 1).repeat(1, 1, N) #16个物体类别
concat = torch.cat([expand, out1, out2, out3, out4, out5], 1) #局部特征和全局特征拼接
net = F.relu(self.bns1(self.convs1(concat)))
net = F.relu(self.bns2(self.convs2(net)))
net = F.relu(self.bns3(self.convs3(net)))
net = self.convs4(net)
net = net.transpose(2, 1).contiguous() #交换两个维度,并用torch.contiguous()开辟一块新的内存空间存放变换之后的数据,并会真正改变Tensor的内容,按照变换之后的顺序存放数据。
net = F.log_softmax(net.view(-1, self.part_num), dim=-1)
net = net.view(B, N, self.part_num) # [Batch, pointNum, 50(部件数)]
return net, trans_feat
class get_loss(torch.nn.Module):
def __init__(self, mat_diff_loss_scale=0.001):
super(get_loss, self).__init__()
self.mat_diff_loss_scale = mat_diff_loss_scale
def forward(self, pred, target, trans_feat):
loss = F.nll_loss(pred, target)
mat_diff_loss = feature_transform_reguliarzer(trans_feat)
total_loss = loss + mat_diff_loss * self.mat_diff_loss_scale
return total_loss
5.5 PointNet进行场景分割
class get_model(nn.Module):
def __init__(self, num_class):
super(get_model, self).__init__()
self.k = num_class #类别数
self.feat = PointNetEncoder(global_feat=False, feature_transform=True, channel=9)
self.conv1 = torch.nn.Conv1d(1088, 512, 1)
self.conv2 = torch.nn.Conv1d(512, 256, 1)
self.conv3 = torch.nn.Conv1d(256, 128, 1)
self.conv4 = torch.nn.Conv1d(128, self.k, 1)
self.bn1 = nn.BatchNorm1d(512)
self.bn2 = nn.BatchNorm1d(256)
self.bn3 = nn.BatchNorm1d(128)
def forward(self, x):
batchsize = x.size()[0]
n_pts = x.size()[2]
x, trans, trans_feat = self.feat(x)
x = F.relu(self.bn1(self.conv1(x)))
x = F.relu(self.bn2(self.conv2(x)))
x = F.relu(self.bn3(self.conv3(x)))
x = self.conv4(x)
x = x.transpose(2,1).contiguous()
x = F.log_softmax(x.view(-1,self.k), dim=-1)
x = x.view(batchsize, n_pts, self.k)
return x, trans_feat
class get_loss(torch.nn.Module):
def __init__(self, mat_diff_loss_scale=0.001):
super(get_loss, self).__init__()
self.mat_diff_loss_scale = mat_diff_loss_scale
def forward(self, pred, target, trans_feat, weight):
loss = F.nll_loss(pred, target, weight = weight)
mat_diff_loss = feature_transform_reguliarzer(trans_feat)
total_loss = loss + mat_diff_loss * self.mat_diff_loss_scale
return total_loss
if __name__ == '__main__':
model = get_model(13)
xyz = torch.rand(12, 3, 2048)
(model(xyz))
六、总结
PointNet作为最先使用点云作为处理数据,但由于其基本思想是学习每个点的空间编码,然后将所有单个点特征聚合为全局点云签名。根据其设计,PointNet 不会捕获由度量引起的局部结构。然而,事实证明,利用局部结构对于卷积架构的成功非常重要。
因此,后来在PointNet基础上引入PointNet++,通过利用度量空间距离,网络能够随着上下文尺度的增加而学习局部特征。