PointNet++笔记
pointnet++论文题目为:PointNet++: Deep Hierarchical Feature Learning on Point Sets in a Metric Space。
在这篇文章中,作者对pointnet进行了一些改进,因为原始的pointnet对于规模较大的点云时,性能就显得不够了。在论文的摘要开头也指出了这一点:“However, by design PointNet does not capture local structures induced by the metric space points live in, limiting its ability to recognize fine-grained patterns and generalizability to complex scenes.”。因此,本文提出了一种层次的网络结构,在输入点集的部分循环地应用了pointnet(这也算是借鉴了resnet的一种思想吧),并且在摘要中作者也指出,这种方法可以捕获细粒度的局部结构。不仅如此,作者也指出:由于点集以不同密度进行采样,这会导致在均匀密度上训练的网络性能大大降低,所以,作者提出了集合学习层来自适应地组合多个尺度的特征。
根据这个摘要,我们可以大致的得出:pointnet++这个方法主要是提升了对点的集合的局部细节捕捉的一个能力,下面会详细分析论文中的具体细节和代码。
Introduction部分
这一部分主要是对网络结构进行了一个大体的介绍,以及需要解决的问题,文中指出:“The design of PointNet++ has to address two issues: how to generate the partitioning of the point set, and how to abstract sets of points or local features through a local feature learner.” 翻译过来就是pointnet++需要解决如何生成点集的划分,以及如何通过局部特征学习器抽象点集或局部的特征。
对于后者,作者使用了pointnet作为局部特征学习器。对于前者,如何生成点集的划分呢?事实上,作者将每个分区定义为底层欧几里得空间中的一个邻域球,通过最远点采样(FPS)方法在输入的点集中选择质心。这种方法比volumetric CNNs更加有效。
接下来我们看一下FPS的代码:
def farthest_point_sample(xyz, npoint):
"""
Input:
xyz: point cloud data, [B, N, 3]
npoint: number of samples
Return:
centroids: sampled point cloud index, [B, npoint]
"""
device = xyz.device
B, N, C = xyz.shape
# 先初始化一个全为0的矩阵
centroids = torch.zeros(B, npoint, dtype=torch.long).to(device)
# 距离初始化为一个非常大的值
distance = torch.ones(B, N).to(device) * 1e10
# 生成0-N范围内的数,(B,)是形状,一维张量
farthest = torch.randint(0, N, (B,), dtype=torch.long).to(device)
batch_indices = torch.arange(B, dtype=torch.long).to(device)
# 循环选取各个点
for i in range(npoint):
# 初始化每列, 先随机选取一个值作为初始中心点
centroids[:, i] = farthest
# 选取中心点
centroid = xyz[batch_indices, farthest, :].view(B, 1, 3)
# 我们计算其与中心点之间的欧氏距离的平方
dist = torch.sum((xyz - centroid) ** 2, -1)
mask = dist < distance
distance[mask] = dist[mask]
# 选择距离最远的点作为下一个中心点,torch.max 返回最大值及其索引,这里选择索引部分。
farthest = torch.max(distance, -1)[1]
return centroids
首先我们需要明确FPS的目的是什么,就是要找到一组点,以这些点作为质心,再通过一个半径范围进行后续处理。那么FPS函数返回的就是一组质心点。在代码中,npoints代表我每个batch需要选取的点的个数。
Method部分
由于这个方法中也运用了pointnet的部分,所以不做过多赘述。下面来看一下网络的整体架构:
从图中不难看出,层次结构由许多抽象级别集合组成,在每个级别,都会对一组点进行处理和抽象,生成一个包含更少元素的新集合,每个级别由三个关键层组成:采样层、分组层和PointNet层。采样层从输入点中选择一组点,这定义了局部区域的质心。然后,分组层通过查找质心周围的“相邻”点来构造局部区域集。 PointNet 层使用迷你 PointNet 将局部区域模式编码为特征向量。这个层我们把它简称为SGP层。
Sampling layer
给定点集 {x1, x2, ..., xn},使用最远点采样(FPS)方法,选择点的子集 {xi1 , xi2 , ..., xim },使得xij是最远点,其中xi为随机挑选的一个点,这部分后续会在代码里讲到。
Grouping layer
该层的输入是大小为** N × (d + C)** 的点集和大小为 N ' × d 的质心集的坐标。输出是大小为 N ′× K × (d + C) 的点集组,其中每个组对应于一个局部区域,K 是质心点邻域中的点数。
PointNet layer
在该层中,输入是数据大小为 N '× K ×(d+C) 的点的 N ' 局部区域。输出中的每个局部区域都是由其质心和编码质心邻域的局部特征抽象的。输出数据大小为 N ′ × (d + C′)。
另外,在这一层中,原文中还有这样一段话:
也就是说,我们首先要将局部区域中的点的坐标转换为相对于质心点的局部坐标系。
Robust Feature Learning under Non-Uniform Sampling Density 部分
这部分是非均匀采样密度下的鲁棒特征学习
点云数据大多数都是不均匀的,有稀疏也有密集的地方,在密集数据中学习的特征可能无法推广到稀疏采样区域。因此,针对稀疏点云训练的模型可能无法识别细粒度的局部结构。
针对这一问题,作者提出了密度自适应 PointNet 层,当输入采样密度发生变化时,该层学习组合不同尺度区域的特征。将具有密度自适应 PointNet 层的分层网络称为 PointNet++。在PointNet++中,每个抽象级别都会提取多个尺度的局部模式,并根据局部点密度将它们智能地组合起来。在对局部区域进行分组并结合不同尺度的特征方面,作者提出了两种类型的密度自适应层,如下所示。
接下来我们对这两种方法进行分析。
感觉MSG和MRG还是主要看代码,下面就给出原文的翻译。
Multi-scale grouping (MSG) 多尺度分组.
如图 3(a)所示,捕获多尺度模式的一种简单但有效的方法是应用不同尺度的分组层,然后根据 PointNet 提取每个尺度的特征。不同尺度的特征连接起来形成多尺度特征。
我们训练网络学习结合多尺度特征的优化策略。这是通过对每个实例以随机概率随机丢弃输入点来完成的,我们称之为随机输入丢弃。具体来说,对于每个训练点集,我们选择从 [0, p] 中均匀采样的丢弃率 θ,其中 p ≤ 1。对于每个点,我们以概率 θ 随机丢弃一个点。在实践中,我们设置 p = 0.95 以避免生成空点集。在此过程中,我们向网络提供了各种稀疏性(由 θ 引起)和不同均匀性(由 dropout 的随机性引起)的训练集。在测试过程中,我们保留所有可用点。
Multi-resolution grouping (MRG) 多分辨率分组.
这种方法是对应MSG的一种改进,下面给出原文翻译:
上面的 MSG 方法的计算成本很高,因为它在每个质心点的大规模邻域中运行本地 PointNet。特别是,由于在最低级别质心点的数量通常相当大,因此时间成本非常高。
在这里,我们提出了一种替代方法,可以避免如此昂贵的计算,但仍然保留根据点的分布属性自适应聚合信息的能力。在图 3 (b) 中,某个级别 Li 的区域特征是两个向量的串联。通过使用设定的抽象级别从较低级别 Li−1 总结每个子区域的特征,获得一个向量(图中左侧)。另一个向量(右)是使用单个PointNet直接处理局部区域中的所有原始点获得的特征。
当局部区域的密度较低时,第一向量可能不如第二向量可靠,因为计算第一向量的子区域包含更稀疏的点并且更容易遭受采样不足。在这种情况下,第二个向量的权重应该更高。另一方面,当局部区域的密度较高时,第一向量提供更精细细节的信息,因为它具有在较低级别递归地以更高分辨率进行检查的能力。
与 MSG 相比,该方法在计算上更加高效,因为我们避免了在最低级别的大规模邻域中进行特征提取。
感觉读起来很抽象是不是,反正我自己是这么感觉的,后面还是看看代码吧。
Point Feature Propagation for Set Segmentation 用于集合分割的点特征分割
这段讲的是分割的部分,作者采用基于距离的插值和跨级跳跃链接的分层传播策略,这里原文的翻译是:在特征传播级别中,我们将点特征从 Nl × (d + C) 点传播到 Nl−1 点,其中 Nl−1 和 Nl(其中 Nl ≤ Nl−1)是集合抽象级别的输入和输出的点集大小湖我们通过在 Nl−1 点的坐标处插值 Nl 点的特征值 f 来实现特征传播。在插值的众多选择中,我们使用基于 k 个最近邻的反距离加权平均值(如方程 2 中,默认情况下我们使用 p = 2,k = 3)。然后,将 Nl−1 点上的插值特征与来自设置抽象级别的跳跃链接点特征连接起来。然后连接的特征通过“单位点网”,这类似于 CNN 中的一对一卷积。应用一些共享的全连接层和 ReLU 层来更新每个点的特征向量。重复该过程,直到我们将特征传播到原始点集。
后续我们看代码进一步理解
对于代码部分,我们拿一个训练的过程来分析,在文件结构中,可以看到有train_partseg.py和train_semseg.py,前者是对shapenet进行训练的代码,后者是针对于S3DIS这种场景分割的,我们拿后者来看。
首先,在train_semseg.py的大约111行处,注释着MODEL LOADING,我们就在这里开始看,前面的部分就是一些参数的设置和数据集加载部分,不做过多的解释,之后会新开一篇专题来讲解S3DIS这种数据集加载的代码部分。
下面是pointnet2.utils.py部分的代码注释,后面那个类也就是上面图中公式部分。
class PointNetSetAbstractionMsg(nn.Module):
def __init__(self, npoint, radius_list, nsample_list, in_channel, mlp_list):
super(PointNetSetAbstractionMsg, self).__init__()
self.npoint = npoint # 要抽取的关键点数目
self.radius_list = radius_list # 一个列表,表示不同尺度下的球形查询半径
self.nsample_list = nsample_list # 表示每个半径内要采样的点数目。
self.conv_blocks = nn.ModuleList()
self.bn_blocks = nn.ModuleList()
for i in range(len(mlp_list)):
convs = nn.ModuleList() # 用于存储当前尺度下的所有卷积层。
bns = nn.ModuleList() # 用于存储当前尺度下的所有批量归一化层。
last_channel = in_channel + 3 # 三维坐标被视为附加通道。
for out_channel in mlp_list[i]:
# 在当前尺度下的卷积层列表中添加一个二维卷积层(Conv2d)。该卷积层的输入通道数为 last_channel,输出通道数为
# out_channel,卷积核大小为 1×1。
convs.append(nn.Conv2d(last_channel, out_channel, 1))
bns.append(nn.BatchNorm2d(out_channel))
last_channel = out_channel
self.conv_blocks.append(convs) # 在最外层循环的每次迭代中,将当前尺度的卷积层列表 convs 添加到 conv_blocks 中。
self.bn_blocks.append(bns)
def forward(self, xyz, points):
"""
Input:
xyz: input points position data, [B, C(通常仅仅是xyz), N]
points: input points data, [B, D(包括xyz), N]
Return:
new_xyz: sampled points position data, [B, C(3), S]
new_points_concat: sample points feature data, [B, D'(新的通道数), S]
"""
# 维度交换,通常通道数在最后
xyz = xyz.permute(0, 2, 1)
if points is not None:
points = points.permute(0, 2, 1)
B, N, C = xyz.shape
S = self.npoint
# 这里 S 是即将采样的点的个数,返回一组最新的点云数据。
new_xyz = index_points(xyz, farthest_point_sample(xyz, S))
new_points_list = []
# 这个循环处理多尺度特征抽象(MSG),每个半径对应不同的尺度。
for i, radius in enumerate(self.radius_list):
K = self.nsample_list[i] # 获取当前尺度下要取样的点数量。
# 以当前的 radius 和 K 为参数,查找在原始点云 xyz 中距离 new_xyz 中每个点不超过 radius 的 K 个邻居点的索引。
# group_idx 是形状为 [B, S, K] 的张量,表示在每个批次中每个新点对应的 K 个邻居点的索引。
group_idx = query_ball_point(radius, K, xyz, new_xyz)
# 使用 index_points 函数,根据 group_idx 索引从 xyz 中提取邻居点的坐标,得到 grouped_xyz。
# grouped_xyz 的形状为 [B, S, K, C],表示在每个批次中,S 个新点的 K 个邻居点的坐标。
grouped_xyz = index_points(xyz, group_idx)
# 对邻居点的坐标进行归一化处理,减去新点的坐标,使得邻居点的坐标相对新点的坐标。
grouped_xyz -= new_xyz.view(B, S, 1, C)
# 检查是否有额外的点特征数据 points(除了 xyz 之外的特征)。
if points is not None:
grouped_points = index_points(points, group_idx)
# 将邻居点的特征 grouped_points 与相对坐标 grouped_xyz 连接在一起,形成新的特征张量。
# 新的 grouped_points 的形状为 [B, S, K, D+C]。
grouped_points = torch.cat([grouped_points, grouped_xyz], dim=-1)
else:
# 在这种情况下,只使用相对坐标 grouped_xyz 作为特征。
grouped_points = grouped_xyz
grouped_points = grouped_points.permute(0, 3, 2, 1) # [B, D, K, S]
# 卷积操作 这里相当于sgp的pointnet部分。
for j in range(len(self.conv_blocks[i])):
conv = self.conv_blocks[i][j]
bn = self.bn_blocks[i][j]
grouped_points = F.relu(bn(conv(grouped_points)))
# 对第 2 维(即邻居点的维度 K)进行最大池化操作,提取局部区域内的最强特征,
# 得到新的特征张量 new_points,其形状为 [B, D', S],D' 是卷积后输出的特征维度。
new_points = torch.max(grouped_points, 2)[0] # [B, D', S]
new_points_list.append(new_points)
new_xyz = new_xyz.permute(0, 2, 1)
new_points_concat = torch.cat(new_points_list, dim=1)
# 返回所有点集和对应特征
return new_xyz, new_points_concat
# 特征传播部分,也就是上采样模块。
class PointNetFeaturePropagation(nn.Module):
def __init__(self, in_channel, mlp):
super(PointNetFeaturePropagation, self).__init__()
self.mlp_convs = nn.ModuleList()
self.mlp_bns = nn.ModuleList()
last_channel = in_channel
for out_channel in mlp:
self.mlp_convs.append(nn.Conv1d(last_channel, out_channel, 1))
self.mlp_bns.append(nn.BatchNorm1d(out_channel))
last_channel = out_channel
def forward(self, xyz1, xyz2, points1, points2):
"""
Input:
xyz1: input points position data, [B, C, N]
xyz2: sampled input points position data, [B, C, S]
points1: input points data, [B, D, N]
points2: input points data, [B, D, S]
Return:
new_points: upsampled points data, [B, D', N]
"""
# 这里xyz1和xyz2分别是输入点位置数据和采样点位置数据。
xyz1 = xyz1.permute(0, 2, 1)
xyz2 = xyz2.permute(0, 2, 1)
points2 = points2.permute(0, 2, 1)
B, N, C = xyz1.shape
_, S, _ = xyz2.shape
# if S == 1:: 如果采样点 S 的数量为 1,直接将 points2 复制 N 次,以匹配输入点的数
# 量。这种情况下,不需要插值,因为所有输入点都对应相同的采样点。
if S == 1:
interpolated_points = points2.repeat(1, N, 1)
else:
# 计算 xyz1 和 xyz2 之间的欧氏距离,返回一个形状为 [B, N, S] 的距离矩阵 dists。
dists = square_distance(xyz1, xyz2)
dists, idx = dists.sort(dim=-1)
# dists[:, :, :3], idx[:, :, :3]: 仅取最近的 3 个采样点的距离和索引,形状变为 [B, N, 3]。
dists, idx = dists[:, :, :3], idx[:, :, :3] # [B, N, 3]
# dist_recip = 1.0 / (dists + 1e-8):
# 计算距离的倒数 dist_recip,距离越近,权重越大。
dist_recip = 1.0 / (dists + 1e-8)
# norm = torch.sum(dist_recip,
# dim=2, keepdim=True): 计算所有倒数的总和,用于归一化。
norm = torch.sum(dist_recip, dim=2, keepdim=True)
# 计算归一化后的权重,使得权重之和为1。
weight = dist_recip / norm
# 使用加权插值法计算每个输入点的插值特征
interpolated_points = torch.sum(index_points(points2, idx) * weight.view(B, N, 3, 1), dim=2)
if points1 is not None:
points1 = points1.permute(0, 2, 1)
new_points = torch.cat([points1, interpolated_points], dim=-1)
else:
new_points = interpolated_points
new_points = new_points.permute(0, 2, 1)
# 全连接层 卷积
for i, conv in enumerate(self.mlp_convs):
bn = self.mlp_bns[i]
new_points = F.relu(bn(conv(new_points)))
return new_points