8
8
- 编辑中
9
9
footer : 技术共建,知识共享
10
10
date : 2025-05-25
11
- cover : assets/cover/PointNet.png
11
+ cover : assets/cover/PointNet++ .png
12
12
author :
13
13
- BinaryOracle
14
14
---
@@ -19,3 +19,289 @@ author:
19
19
20
20
# 简析PointNet++
21
21
22
+ > 论文: [ https://arxiv.org/abs/1706.02413 ] ( https://arxiv.org/abs/1706.02413 )
23
+ > TensorFlow 版本代码: [ https://github.com/charlesq34/pointnet2 ] ( https://github.com/charlesq34/pointnet2 )
24
+ > Pytorch 版本代码: [ https://github.com/yanx27/Pointnet_Pointnet2_pytorch ] ( https://github.com/yanx27/Pointnet_Pointnet2_pytorch )
25
+
26
+ ## 背景
27
+
28
+ 在PointNet中,网络对每一个点做低维到高维的映射,进行特征学习,然后把所有点映射到高维的特征通过最大池化最终表示全局特征。从本质上来说,要么对一个点做操作,要么对所有点做操作,实际上没有** 局部的概念(loal context)** 。同时缺少 local context 在** 平移不变性** 上也有局限性(世界坐标系和局部坐标系)。对点云数据做平移操作后,所有的数据都将发生变化,导致所有的特征,全局特征都不一样了。对于单个的物体还好,可以将其平移到坐标系的中心,把他的大小归一化到一个球中,但是在一个场景中有多个物体时则不好办,需要对哪个物体做归一化呢?
29
+
30
+ PointNet++ 解决了两个问题:如何生成点集的划分(Partitioning),以及如何通过局部特征学习器(Local Feature Learner)抽象点集或局部特征。
31
+
32
+ ** 生成点集的划分(Partitioning):**
33
+
34
+ 点集划分是指如何将一个大的点云分割成更小的、更易于管理的子集。这个过程类似于在传统的卷积神经网络中如何处理图像的小区域(或“patches”),以便可以在这些区域上应用局部操作。PointNet++需要一种方法来有效地将点云分割成多个部分,这样可以在每个部分上独立地学习特征。
35
+
36
+ ** 通过局部特征学习器(Local Feature Learner)抽象点集或局部特征:**
37
+
38
+ 一旦点云被划分成小的子集,PointNet++的下一个任务是学习这些子集(或局部区域)的特征。这需要一个“局部特征学习器”,它能够从每个子集中提取有用的信息或特征。这与在传统CNN中学习图像局部区域特征的过程相似。
39
+
40
+ ** 两个问题是相关联的,因为:**
41
+
42
+ 点集的划分必须产生跨分区的共同结构:为了能够在不同的局部子集上共享权重(类似于在CNN中权重共享的概念),PointNet++在进行点集划分时,需要确保这些划分具有一定的一致性或共同结构。这意味着即使是不同的局部子集,也应该以一种方式被处理,使得在它们之间可以共享学习到的特征表示的权重。这样做的目的是提高模型的效率和泛化能力,因为学习到的特征和权重可以在多个局部区域中复用。
43
+
44
+ 上述即为PointNet++设计中的两个核心挑战:
45
+ - 如何有效地对点云进行分区,以便可以在这些分区上独立地学习特征。
46
+ - 如何设计一个能够从这些局部分区中学习有用特征的机制,同时确保这些分区的处理方式允许在它们之间共享模型权重。
47
+ - 为了模仿传统卷积网络中的权重共享机制以提高学习效率和模型的泛化能力。
48
+
49
+ PointNet++选择PointNet作为局部特征学习器(它是无序点云数据特征提取的高效算法)。
50
+
51
+ > 可以理解为:PointNet++应用PointNet递归地对输入集进行嵌套分区。
52
+
53
+ ## 模型结构
54
+
55
+ ![ 以二维欧几里得空间为例,网络的分割和分类模型] ( 简析PointNet++/1.png )
56
+
57
+ 网络的每一组set abstraction layers主要包括3个部分:
58
+
59
+ - Sample layer : 对输入点进行采样,在这些点中选出若干个中心点。
60
+
61
+ - Grouping layer : 利用上一步得到的中心点将点集划分成若干个区域。
62
+
63
+ - PointNet layer : 对上述得到的每个区域进行编码,变成特征向量。
64
+
65
+ ### 层次化点集特征学习
66
+
67
+ 层次化结构由多个set abstraction layers组成,在每个层上,一组点云被处理和抽象,以产生一个更少元素的新集合。set abstraction layers 由 Sampling layer、Grouping layer 和 PointNet layer 三部分组成。
68
+
69
+ - Sampling layer :采样层 从输入点中选取一组点,定义局部区域的形心。
70
+
71
+ - Grouping layer :通过查找形心点周围的“邻近点”来构建局部区域点集。
72
+
73
+ - PointNet layer :使用mini-PointNet将局部区域编码为特征向量。
74
+
75
+ #### Sampling layer
76
+
77
+ 使用farthest point sampling(FPS)选择𝑁个点(相比于随机采样,该方法能更好的覆盖整个点集,具体选择多少个中心点以及邻域内的数量由超参数确定)
78
+
79
+ FPS是一种在点云、图像处理或其他数据集中用于抽样的算法。目的是从一个大的数据集中选出一组代表性强的点,这些点彼此之间的最小距离尽可能大。
80
+
81
+ 作者通过FPS来抽样点集中较为重要的点。(即任务是找到点云集中的局部区域的中心点)
82
+
83
+ > 可能存在的问题:计算成本、样本分布偏差(可能导致样本在高密度区域内过度集中,低密度区域则过于稀缺)、参数依赖(依赖初始点和距离度量方式的选择)、可能无法捕捉重要的几何细节。
84
+
85
+ #### Grouping layer
86
+
87
+ 文中作者通过Ball query来查询形心的邻居点。
88
+
89
+ 具体做法:给定两个超参数(每个区域中点的数量𝐾和query的半径𝑟),对于某个形心,Ball query找到该查询点在半径为𝑟范围内点,该范围确保局部区域的尺度是固定的。
90
+
91
+ 与K最近邻(kNN)查询相比,Ball query通过固定区域尺度而不是固定邻居数量来定义邻域。kNN查询寻找最近的K个邻居,但这可能导致所选邻域的实际尺寸随点的密度变化而变化,这在处理非均匀采样的数据时可能不是最优的选择。相反,Ball query通过确保每个局部区域都有一个固定的尺度,提高了模型在空间上的泛化能力。在实现时,通常会设置一个上限K,以限制每个局部区域中考虑的点的数量,以保持计算的可管理性。
92
+
93
+ > ** 可改进的地方** :对点云密度变换较为敏感、对参数选择依赖性高(半径太小可能无法有效捕获足够的局部详细,太大则可能导致不相关的点增多,使局部特征的表示不够精确)、计算效率问题、均匀性假设(Ball query是基于欧氏距离的均匀性假设)
94
+ > - 欧式距离的均匀性假设:即在欧氏空间中,两点的距离反映了这两点的实际相似度或关联度。
95
+ > - 基于以下前提:
96
+ > - 空间均匀性:空间是均匀和各向同性的,即任何方向上的度量都是等价的,距离的度量不受空间中位置的影响。
97
+ > - 距离直观性:在屋里空间或某些特定的抽象空间中,两个点之间的直线距离被认为是相似度或连接强度的直观表示。
98
+ > - 规模一致性:假设空间中所有区域的尺度或特征分布具有一定的一致性,即空间中的任何距离值具有相似的含义。
99
+
100
+ 总结: Grouping layer的任务是通过中心点找到邻居点,并将它们组织称为局部区域集。
101
+
102
+ #### PointNet layer
103
+
104
+ 局部坐标系转换:局部区域中的点转换成相对于形心的局部坐标系。
105
+
106
+ > 局部区域中的每个点将相对于形心所在位置进行调整,以反映其相对位置。
107
+
108
+ 实现方法:通过将局部区域中的每个点-形心点的坐标来实现。
109
+
110
+ 特征编码:将转换后的坐标以及点的附加特征(文中的𝐶所表示的其他信息)一起送入mini-PointNet来提取局部区域中的特征。
111
+
112
+ 输出:利用相对坐标与点特征相结合的方式可以捕获局部区域中点与点之间的关系。
113
+
114
+ #### 代码实现
115
+
116
+ PointNetSetAbstraction(点集抽象层) 是 PointNet++ 中的核心模块 , 它的作用是负责从输入的点云数据中采样关键点,构建它们的局部邻域区域,并通过一个小型 PointNet 提取这些区域的高维特征,从而实现点云的分层特征学习。
117
+
118
+ ``` python
119
+ class PointNetSetAbstraction (nn .Module ):
120
+ def __init__ (self , npoint , radius , nsample , in_channel , mlp , group_all ):
121
+ super (PointNetSetAbstraction, self ).__init__ ()
122
+ self .npoint = npoint # 采样的关键点数量
123
+ self .radius = radius # 构建局部邻域的半径
124
+ self .nsample = nsample # 每个邻域内采样的关键点数量
125
+ self .mlp_convs = nn.ModuleList()
126
+ self .mlp_bns = nn.ModuleList()
127
+ last_channel = in_channel # 输入点的特征维度
128
+ for out_channel in mlp:
129
+ self .mlp_convs.append(nn.Conv2d(last_channel, out_channel, 1 ))
130
+ self .mlp_bns.append(nn.BatchNorm2d(out_channel))
131
+ last_channel = out_channel
132
+ self .group_all = group_all
133
+
134
+ def forward (self , xyz , points ):
135
+ """
136
+ Input:
137
+ xyz: input points position data, [B, C, N]
138
+ points: input points data, [B, D, N]
139
+ Return:
140
+ new_xyz: sampled points position data, [B, C, S]
141
+ new_points_concat: sample points feature data, [B, D', S]
142
+ """
143
+ xyz = xyz.permute(0 , 2 , 1 ) # [B, N, C]
144
+ if points is not None :
145
+ points = points.permute(0 , 2 , 1 )
146
+
147
+ # 如果 group_all=True,则对整个点云做全局特征提取。
148
+ if self .group_all:
149
+ new_xyz, new_points = sample_and_group_all(xyz, points)
150
+ else :
151
+ # 否则使用 FPS(最远点采样)选关键点,再用 Ball Query 找出每个点的局部邻近点。
152
+ # 参数: 质点数量,采样半径,采样点数量,点坐标,点额外特征
153
+ new_xyz, new_points = sample_and_group(self .npoint, self .radius, self .nsample, xyz, points)
154
+ # 局部特征编码(Mini-PointNet)
155
+ # new_xyz: sampled points position data, [B, npoint, C]
156
+ # new_points: sampled points data, [B, npoint, nsample, C+D]
157
+ # 把邻域点的数据整理成适合卷积的格式 [B, C+D, nsample, npoint]
158
+ new_points = new_points.permute(0 , 3 , 2 , 1 )
159
+ # 使用多个 Conv2d + BatchNorm + ReLU 层提取特征
160
+ for i, conv in enumerate (self .mlp_convs):
161
+ bn = self .mlp_bns[i]
162
+ new_points = F.relu(bn(conv(new_points))) # [B, out_channel , nsample, npoint]
163
+
164
+ # 对每个局部区域内所有点的最大响应值进行池化,得到该区域的固定长度特征表示。
165
+ # 输出形状为 [B, out_channel, nsample],即每个查询点有一个特征向量。
166
+ new_points = torch.max(new_points, 2 )[0 ]
167
+ new_xyz = new_xyz.permute(0 , 2 , 1 ) # [B, C, npoint]
168
+ return new_xyz, new_points
169
+ ```
170
+
171
+ sample_and_group 这个函数的作用是从输入点云中:
172
+ - 采样一些关键点
173
+ - 为每个关键点构建局部邻域(局部区域)
174
+ - 提取这些局部区域中的点及其特征
175
+
176
+ ``` python
177
+ def sample_and_group (npoint , radius , nsample , xyz , points , returnfps = False ):
178
+ """
179
+ Input:
180
+ npoint: 采样的关键点数量
181
+ radius: 构建局部邻域的半径
182
+ nsample: 每个邻域内采样的关键点数量
183
+ xyz: 点云坐标数据 , [B, N, 3]
184
+ points: 点的特征数据(可选), [B, N, D]
185
+ Return:
186
+ new_xyz: 采样得到的关键点坐标, [B, npoint, nsample, 3]
187
+ new_points: 每个关键点对应的局部区域点和特征, [B, npoint, nsample, 3+D]
188
+ """
189
+ B, N, C = xyz.shape
190
+ S = npoint
191
+ # 使用 最远点采样(FPS) 从原始点云中选出 npoint 个具有代表性的点。
192
+ fps_idx = farthest_point_sample(xyz, npoint) # [B, npoint]
193
+ new_xyz = index_points(xyz, fps_idx) # [B, npoint, 3]
194
+ # 对于每个选中的关键点,使用 球查询(Ball Query) 找出它周围距离小于 radius 的所有邻近点。
195
+ # 最多保留 nsample 个点,如果不够就重复最近的点来填充。
196
+ idx = query_ball_point(radius, nsample, xyz, new_xyz)
197
+ # 把刚才找到的邻近点的坐标提取出来。
198
+ grouped_xyz = index_points(xyz, idx) # [B, npoint, nsample, 3]
199
+ # 把它们相对于关键点的位置进行归一化(平移中心到以关键点为原点的局部坐标系上)。
200
+ grouped_xyz_norm = grouped_xyz - new_xyz.view(B, S, 1 , C) # [B, npoint, nsample, 3]
201
+
202
+ # 如果有额外的点特征(比如颜色、法线等),也一并提取。
203
+ if points is not None :
204
+ grouped_points = index_points(points, idx)
205
+ # 把邻近点的坐标和特征拼接在一起,形成最终的局部区域表示。
206
+ new_points = torch.cat([grouped_xyz_norm, grouped_points], dim = - 1 ) # [B, npoint, nsample, C+D]
207
+ else :
208
+ new_points = grouped_xyz_norm
209
+
210
+ if returnfps:
211
+ return new_xyz, new_points, grouped_xyz, fps_idx
212
+ else :
213
+ return new_xyz, new_points
214
+ ```
215
+
216
+ farthest_point_sample 这个函数实现的是最远点采样(Farthest Point Sampling, FPS), 这是 PointNet++ 中用于从点云中选择具有代表性的采样点的一种策略。它的核心思想是:** 在点云中逐步选择离已选点尽可能远的点,使得采样点在整个点云空间中分布尽可能均匀** 。
217
+
218
+ ``` python
219
+ def farthest_point_sample (xyz , npoint ):
220
+ """
221
+ Input:
222
+ xyz: pointcloud data, [B, N, 3]
223
+ npoint: number of samples
224
+ Return:
225
+ centroids: sampled pointcloud index, [B, npoint]
226
+ """
227
+ device = xyz.device
228
+ B, N, C = xyz.shape
229
+ centroids = torch.zeros(B, npoint, dtype = torch.long).to(device) # 存储每次选出的“最远点”的索引。
230
+ distance = torch.ones(B, N).to(device) * 1e10 # 每个点到当前所有已选中心点的最小距离,初始设为一个极大值(1e10)。
231
+ farthest = torch.randint(0 , N, (B,), dtype = torch.long).to(device) # 初始时随机选择一个点作为第一个中心点。
232
+ batch_indices = torch.arange(B, dtype = torch.long).to(device) # 批次索引,用于快速访问每个 batch 的点。
233
+ # 重复 npoint 次,最终得到 npoint 个分布尽可能均匀的采样点索引。
234
+ for i in range (npoint):
235
+ # 将当前选中的“最远点”索引保存下来;
236
+ centroids[:, i] = farthest # (batch,npoint)
237
+ # 取出当前最远点的坐标,用于后续计算其他点到该点的距离;
238
+ centroid = xyz[batch_indices, farthest, :].view(B, 1 , 3 ) # # (batch, 1 , 3)
239
+ # 计算当前中心点与所有点之间的欧氏距离平方。
240
+ dist = torch.sum((xyz - centroid) ** 2 , - 1 ) # (batch,npoint)
241
+ # 如果某个点到新中心点的距离比之前记录的“最小距离”还小,就更新它。
242
+ mask = dist < distance
243
+ # 在 distance 中找到最大的那个距离对应的点,这就是下一个“最远点”。
244
+ distance[mask] = dist[mask] # (batch,npoint)
245
+ # 在 distance 中找到最大的那个距离对应的点,这就是下一个“最远点”。
246
+ # 返回:一个元组:(values, indices),分别是最大值和它们的位置索引。
247
+ farthest = torch.max(distance, - 1 )[1 ] # 返回位置索引
248
+ return centroids
249
+ ```
250
+
251
+ index_points 这个函数实现的是根据给定的索引 idx,从输入点云 points 中提取对应的点,形成一个新的子集。
252
+
253
+ ``` python
254
+ def index_points (points , idx ):
255
+ """
256
+ Input:
257
+ points: input points data, [B, N, C]
258
+ idx: sample index data, [B, S]
259
+ Return:
260
+ new_points:, indexed points data, [B, S, C]
261
+ """
262
+ device = points.device
263
+ B = points.shape[0 ]
264
+ view_shape = list (idx.shape)
265
+ view_shape[1 :] = [1 ] * (len (view_shape) - 1 ) # 将view_shape的形状从[B, S]变成[B, 1],便于广播
266
+ repeat_shape = list (idx.shape)
267
+ repeat_shape[0 ] = 1 # 从[B, S]变成[1, S]
268
+ # 从点云中根据索引提取特定点 (看不懂下面两行代码的话,可以先去了解一下python中的高级索引机制)。
269
+ batch_indices = torch.arange(B, dtype = torch.long).to(device).view(view_shape).repeat(repeat_shape)
270
+ new_points = points[batch_indices, idx, :] # (batch,npoint,3)
271
+ return new_points
272
+ ```
273
+ query_ball_point 这个函数的作用是从点云中找出每个查询点周围一定半径范围内的邻近点索引。这个操作被称为 球查询(Ball Query)。
274
+
275
+ ``` python
276
+ def query_ball_point (radius , nsample , xyz , new_xyz ):
277
+ """
278
+ Input:
279
+ radius: local region radius
280
+ nsample: max sample number in local region
281
+ xyz: all points, [B, N, 3]
282
+ new_xyz: query points, [B, S, 3]
283
+ Return:
284
+ group_idx: grouped points index, [B, S, nsample]
285
+ """
286
+ device = xyz.device
287
+ B, N, C = xyz.shape
288
+ _, S, _ = new_xyz.shape # 查询点数量(比如通过 FPS 得到的质心)
289
+ # 构造一个从 0 到 N-1 的索引数组,代表原始点云中每个点的“身份证号”
290
+ # 然后复制这个索引数组到每个 batch 和每个查询点上,形成 [B, S, N] 的结构
291
+ group_idx = torch.arange(N, dtype = torch.long).to(device).view(1 , 1 , N).repeat([B, S, 1 ])
292
+ # 计算每个查询点(new_xyz)与原始点(xyz)之间的平方欧氏距离
293
+ # 输出形状为 [B, S, N]:每个查询点对所有原始点的距离
294
+ sqrdists = square_distance(new_xyz, xyz)
295
+ # 把距离超过 radius^2 的点全部替换为 N(一个非法索引),表示“这些人离我太远了,我不感兴趣。”
296
+ group_idx[sqrdists > radius ** 2 ] = N
297
+ # 对每个查询点的邻近点按索引排序(因为前面有 N,所以小的才是有效点)
298
+ # 然后只保留前 nsample 个点
299
+ group_idx = group_idx.sort(dim = - 1 )[0 ][:, :, :nsample]
300
+ # 如果某个查询点附近的点太少,有些位置被标记为 N(无效)。
301
+ # 我们就用该查询点最近的那个点(第一个点)去填充这些空缺。
302
+ group_first = group_idx[:, :, 0 ].view(B, S, 1 ).repeat([1 , 1 , nsample])
303
+ mask = group_idx == N
304
+ group_idx[mask] = group_first[mask]
305
+ return group_idx # (batch,npoint,nsample)
306
+ ```
307
+
0 commit comments