primlib 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primlib/__init__.py +6 -0
- primlib/_version.py +24 -0
- primlib/line_set.py +435 -0
- primlib/point_cloud.py +711 -0
- primlib/triangle_mesh.py +1910 -0
- primlib/utils.py +53 -0
- primlib/vertices.py +604 -0
- primlib/visualizer.py +179 -0
- primlib-0.3.0.dist-info/METADATA +124 -0
- primlib-0.3.0.dist-info/RECORD +13 -0
- primlib-0.3.0.dist-info/WHEEL +5 -0
- primlib-0.3.0.dist-info/licenses/LICENSE +22 -0
- primlib-0.3.0.dist-info/top_level.txt +1 -0
primlib/point_cloud.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike
|
|
7
|
+
from scipy.sparse import csr_array
|
|
8
|
+
from scipy.sparse.csgraph import connected_components
|
|
9
|
+
from scipy.spatial import KDTree
|
|
10
|
+
from trafolib.trafo3d import Trafo3d
|
|
11
|
+
|
|
12
|
+
from primlib.vertices import Vertices
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PointCloud(Vertices):
|
|
16
|
+
"""
|
|
17
|
+
A class representing a point cloud of 3d points.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
vertices: ArrayLike | None = None,
|
|
23
|
+
normals: ArrayLike | None = None,
|
|
24
|
+
colors: ArrayLike | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Constructor
|
|
27
|
+
|
|
28
|
+
:param vertices: Vertices/points of the point cloud, shape (n, 3)
|
|
29
|
+
:param normals: Normals for each point, shape (n, 3), normalized
|
|
30
|
+
:param colors: RGB colors for each point, shape (n, 3), values [0.0..1.0]
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(vertices, normals, colors)
|
|
33
|
+
|
|
34
|
+
def __len__(self) -> int:
|
|
35
|
+
"""Number of points in point cloud
|
|
36
|
+
:returns: Number of points
|
|
37
|
+
"""
|
|
38
|
+
return self._vertices.shape[0]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def size(self) -> int:
|
|
42
|
+
"""Number of points in point cloud
|
|
43
|
+
:returns: Number of points
|
|
44
|
+
"""
|
|
45
|
+
return self._vertices.shape[0]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def empty(self) -> bool:
|
|
49
|
+
"""Check if point cloud is empty
|
|
50
|
+
:returns: True if point cloud has no points
|
|
51
|
+
"""
|
|
52
|
+
return self.size == 0
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
"""Get string representation of point cloud
|
|
57
|
+
:returns: String representation
|
|
58
|
+
"""
|
|
59
|
+
if self.empty:
|
|
60
|
+
return "PointCloud(empty)"
|
|
61
|
+
pmin = self.min()
|
|
62
|
+
pmax = self.max()
|
|
63
|
+
result = (
|
|
64
|
+
f"PointCloud({self.size:_} vertices: "
|
|
65
|
+
+ f"X[{pmin[0]:.3f}..{pmax[0]:.3f}], "
|
|
66
|
+
+ f"Y[{pmin[1]:.3f}..{pmax[1]:.3f}], "
|
|
67
|
+
+ f"Z[{pmin[2]:.3f}..{pmax[2]:.3f}]"
|
|
68
|
+
)
|
|
69
|
+
if self.has_normals:
|
|
70
|
+
result += ", normals"
|
|
71
|
+
if self.has_colors:
|
|
72
|
+
result += ", colors"
|
|
73
|
+
result += ")"
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def create_plane(
|
|
78
|
+
cls,
|
|
79
|
+
scale: float | ArrayLike = 1.0,
|
|
80
|
+
resolution: int | ArrayLike = (10, 10),
|
|
81
|
+
) -> "PointCloud":
|
|
82
|
+
"""Create point cloud with points arranged as grid in a 2d plane
|
|
83
|
+
|
|
84
|
+
Grid is located in X/Y plane and starts at (0.0, 0.0, 0.0) and
|
|
85
|
+
extends to (scale, scale, 0.0).
|
|
86
|
+
|
|
87
|
+
:param scale: Scale of grid for X and Y as single float or 2d
|
|
88
|
+
:param resolution: Number of points in X and Y
|
|
89
|
+
:returns: Resulting point cloud
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(scale, float):
|
|
92
|
+
scale = np.array((scale, scale))
|
|
93
|
+
else:
|
|
94
|
+
scale = np.asarray(scale)
|
|
95
|
+
if scale.size != 2:
|
|
96
|
+
raise Exception("Provide proper scale.")
|
|
97
|
+
if isinstance(resolution, int):
|
|
98
|
+
resolution = np.array((resolution, resolution), dtype=int)
|
|
99
|
+
else:
|
|
100
|
+
resolution = np.asarray(resolution, dtype=int)
|
|
101
|
+
if resolution.size != 2:
|
|
102
|
+
raise Exception("Provide proper resolution.")
|
|
103
|
+
xs = np.linspace(0.0, 1.0, resolution[0])
|
|
104
|
+
ys = np.linspace(0.0, 1.0, resolution[1])
|
|
105
|
+
(
|
|
106
|
+
xx,
|
|
107
|
+
yy,
|
|
108
|
+
) = np.meshgrid(xs, ys, indexing="xy")
|
|
109
|
+
vertices = np.empty((resolution[0] * resolution[1], 3))
|
|
110
|
+
vertices[:, 0] = scale[0] * xx.flatten()
|
|
111
|
+
vertices[:, 1] = scale[1] * yy.flatten()
|
|
112
|
+
vertices[:, 2] = 0.0
|
|
113
|
+
return cls(vertices)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def create_grid(
|
|
117
|
+
cls,
|
|
118
|
+
scale: float | ArrayLike = 1.0,
|
|
119
|
+
resolution: int | ArrayLike = (10, 10, 10),
|
|
120
|
+
) -> "PointCloud":
|
|
121
|
+
"""Create point cloud with point arranged as 3d grid
|
|
122
|
+
|
|
123
|
+
Grid starts at (0.0, 0.0, 0.0) and extends to (scale, scale, scale).
|
|
124
|
+
|
|
125
|
+
:param scale: Scale of grid for X, Y and Z as single float or 3d
|
|
126
|
+
:param resolution: Number of points in X, Y and Z
|
|
127
|
+
:returns: Resulting point cloud
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(scale, float):
|
|
130
|
+
scale = np.array((scale, scale, scale))
|
|
131
|
+
else:
|
|
132
|
+
scale = np.asarray(scale)
|
|
133
|
+
if scale.size != 3:
|
|
134
|
+
raise Exception("Provide proper scale.")
|
|
135
|
+
if isinstance(resolution, int):
|
|
136
|
+
resolution = np.array((resolution, resolution, resolution), dtype=int)
|
|
137
|
+
else:
|
|
138
|
+
resolution = np.asarray(resolution, dtype=int)
|
|
139
|
+
if resolution.size != 3:
|
|
140
|
+
raise Exception("Provide proper resolution.")
|
|
141
|
+
xs = np.linspace(0.0, 1.0, resolution[0])
|
|
142
|
+
ys = np.linspace(0.0, 1.0, resolution[1])
|
|
143
|
+
zs = np.linspace(0.0, 1.0, resolution[2])
|
|
144
|
+
xx, yy, zz = np.meshgrid(xs, ys, zs, indexing="xy")
|
|
145
|
+
vertices = np.empty((resolution[0] * resolution[1] * resolution[2], 3))
|
|
146
|
+
vertices[:, 0] = scale[0] * xx.flatten()
|
|
147
|
+
vertices[:, 1] = scale[1] * yy.flatten()
|
|
148
|
+
vertices[:, 2] = scale[2] * zz.flatten()
|
|
149
|
+
return cls(vertices)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def create_random(
|
|
153
|
+
cls,
|
|
154
|
+
scale: float | ArrayLike = 1.0,
|
|
155
|
+
num_points: int = 1000,
|
|
156
|
+
rng: np.random.Generator | None = None,
|
|
157
|
+
) -> "PointCloud":
|
|
158
|
+
"""Create point cloud of random points
|
|
159
|
+
|
|
160
|
+
Points are uniformly distributed between (0.0, 0.0, 0.0)
|
|
161
|
+
and (scale, scale, scale).
|
|
162
|
+
|
|
163
|
+
:param scale: Scale of grid for X, Y and Z as single float or 3d
|
|
164
|
+
:param rng: NumPy random number generator
|
|
165
|
+
:returns: Resulting point cloud
|
|
166
|
+
"""
|
|
167
|
+
if isinstance(scale, float):
|
|
168
|
+
scale = np.array((scale, scale, scale))
|
|
169
|
+
else:
|
|
170
|
+
scale = np.asarray(scale)
|
|
171
|
+
if scale.size != 3:
|
|
172
|
+
raise Exception("Provide proper scale.")
|
|
173
|
+
if rng is None:
|
|
174
|
+
rng = np.random.default_rng()
|
|
175
|
+
vertices = np.empty((num_points, 3))
|
|
176
|
+
vertices[:, 0] = rng.uniform(0.0, scale[0], num_points)
|
|
177
|
+
vertices[:, 1] = rng.uniform(0.0, scale[1], num_points)
|
|
178
|
+
vertices[:, 2] = rng.uniform(0.0, scale[2], num_points)
|
|
179
|
+
return PointCloud(vertices)
|
|
180
|
+
|
|
181
|
+
def select(self, inlier_mask_or_indices: np.ndarray) -> None:
|
|
182
|
+
"""Select points from point cloud by mask or indices
|
|
183
|
+
|
|
184
|
+
:param inlier_mask_or_indices: Inlier point mask or indices
|
|
185
|
+
"""
|
|
186
|
+
inlier_mask_or_indices = np.asarray(inlier_mask_or_indices)
|
|
187
|
+
if inlier_mask_or_indices.size == 0:
|
|
188
|
+
self._vertices = np.zeros((0, 3))
|
|
189
|
+
self._normals = np.zeros((0, 3))
|
|
190
|
+
self._colors = np.zeros((0, 3))
|
|
191
|
+
else:
|
|
192
|
+
self._vertices = self._vertices[inlier_mask_or_indices, :]
|
|
193
|
+
if self.has_normals:
|
|
194
|
+
self._normals = self._normals[inlier_mask_or_indices, :]
|
|
195
|
+
if self.has_colors:
|
|
196
|
+
self._colors = self._colors[inlier_mask_or_indices, :]
|
|
197
|
+
|
|
198
|
+
def get_selected(self, inlier_mask_or_indices: np.ndarray) -> "PointCloud":
|
|
199
|
+
"""Get new point cloud of selected points by mask or indices
|
|
200
|
+
|
|
201
|
+
:param inlier_mask_or_indices: Inlier point mask or indices
|
|
202
|
+
:returns: Resulting point cloud
|
|
203
|
+
"""
|
|
204
|
+
result = self.copy()
|
|
205
|
+
result.select(inlier_mask_or_indices)
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
def filter_radius_outliers(self, num_points: int, radius: float) -> np.ndarray:
|
|
209
|
+
"""Radius outlier filtering for point cloud
|
|
210
|
+
|
|
211
|
+
:param num_points: Minimum number of neighbors to keep point
|
|
212
|
+
:param radius: Radius defining point neighborhood
|
|
213
|
+
:returns: Inlier mask
|
|
214
|
+
"""
|
|
215
|
+
counts = self.kdtree().query_ball_point(
|
|
216
|
+
self._vertices, r=radius, return_length=True, workers=-1
|
|
217
|
+
)
|
|
218
|
+
return counts >= num_points
|
|
219
|
+
|
|
220
|
+
def estimate_normals(self, cluster_size=30) -> None:
|
|
221
|
+
"""Estimate normals for each point of point cloud
|
|
222
|
+
|
|
223
|
+
For each point uses a neighborhood of points to estimate normals
|
|
224
|
+
|
|
225
|
+
Direction of normals is computed but orientation may be wrong.
|
|
226
|
+
|
|
227
|
+
:param cluster_size: Number of neighboring points
|
|
228
|
+
"""
|
|
229
|
+
if cluster_size < 3:
|
|
230
|
+
raise ValueError("cluster_size must be at least 3")
|
|
231
|
+
if cluster_size > self.size:
|
|
232
|
+
raise ValueError("cluster_size cannot exceed point cloud size")
|
|
233
|
+
_, indices = self.kdtree().query(self.vertices, k=cluster_size, workers=-1)
|
|
234
|
+
points = self.vertices[indices]
|
|
235
|
+
centroids = np.mean(points, axis=1, keepdims=True)
|
|
236
|
+
C = points - centroids
|
|
237
|
+
covariances = np.matmul(C.transpose(0, 2, 1), C) / (cluster_size - 1)
|
|
238
|
+
_, v = np.linalg.eigh(covariances)
|
|
239
|
+
self.normals = v[:, :, 0]
|
|
240
|
+
|
|
241
|
+
def orient_normals_towards_position(self, position: ArrayLike) -> None:
|
|
242
|
+
"""Orients normals towards a 3d position (e.g. a camera)
|
|
243
|
+
|
|
244
|
+
:param position: 3d position
|
|
245
|
+
"""
|
|
246
|
+
towards_position = np.asarray(position) - self._vertices
|
|
247
|
+
dots = np.sum(self._normals * towards_position, axis=1)
|
|
248
|
+
flip_mask = dots < 0
|
|
249
|
+
self._normals[flip_mask] *= -1
|
|
250
|
+
|
|
251
|
+
def segment_model(
|
|
252
|
+
self,
|
|
253
|
+
model_fit: Callable[[ArrayLike], dict],
|
|
254
|
+
model_abs_distance: Callable[[ArrayLike, dict], np.ndarray],
|
|
255
|
+
threshold: float,
|
|
256
|
+
num_samples: int,
|
|
257
|
+
num_iterations: int,
|
|
258
|
+
rng: np.random.Generator | None = None,
|
|
259
|
+
) -> tuple[dict, np.ndarray]:
|
|
260
|
+
"""Segment point cloud using RANSAC based on model
|
|
261
|
+
|
|
262
|
+
Function does not check weather num_samples is large enough
|
|
263
|
+
to estimate model
|
|
264
|
+
|
|
265
|
+
:param model_fit: Model fitting/estimation function: Takes n
|
|
266
|
+
points as input and returns model parameters as dict
|
|
267
|
+
:param model_abs_distance: Model distance function: Takes n
|
|
268
|
+
points and model parameters as dict as input and returns
|
|
269
|
+
absolute distances of model to points
|
|
270
|
+
:param threshold: Distance threshold to determine which points
|
|
271
|
+
are considered outliers
|
|
272
|
+
:param num_samples: Number of random samples used to estimate model
|
|
273
|
+
:param num_iterations: Number of attempts in RANSAC to find
|
|
274
|
+
model with more inliers
|
|
275
|
+
:param rng: NumPy random number generator
|
|
276
|
+
:returns: Model parameters and boolean inlier mask
|
|
277
|
+
"""
|
|
278
|
+
if rng is None:
|
|
279
|
+
rng = np.random.default_rng()
|
|
280
|
+
best_inlier_mask = np.zeros(self.size, dtype=bool)
|
|
281
|
+
for _ in range(num_iterations):
|
|
282
|
+
indices = rng.choice(self.size, num_samples, replace=False)
|
|
283
|
+
model = model_fit(self._vertices[indices])
|
|
284
|
+
distances = model_abs_distance(self._vertices, model)
|
|
285
|
+
inlier_mask = distances <= threshold
|
|
286
|
+
if np.sum(inlier_mask) > np.sum(best_inlier_mask):
|
|
287
|
+
best_inlier_mask = inlier_mask
|
|
288
|
+
if not np.any(best_inlier_mask):
|
|
289
|
+
raise RuntimeError("RANSAC could not find a valid model")
|
|
290
|
+
return model_fit(self._vertices[best_inlier_mask]), best_inlier_mask
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _model_fit_plane(points: ArrayLike) -> dict:
|
|
294
|
+
"""Helper method to estimate 3d plane from points
|
|
295
|
+
|
|
296
|
+
The plane is parameterized as Hesse normal form:
|
|
297
|
+
E(x,y,z): nx * x + ny * y + nz * z + d = 0
|
|
298
|
+
with "normal": (nx, ny, nz) the normal vector of the plane
|
|
299
|
+
and "distance": d the distance between the plane and
|
|
300
|
+
origin (0, 0, 0).
|
|
301
|
+
|
|
302
|
+
:param points: n sample points, shape (n, 3)
|
|
303
|
+
:returns: Plane model as a dict
|
|
304
|
+
"""
|
|
305
|
+
points = np.asarray(points)
|
|
306
|
+
centroid = np.mean(points, axis=0)
|
|
307
|
+
centered_points = points - centroid
|
|
308
|
+
_, _, Vt = np.linalg.svd(centered_points)
|
|
309
|
+
n = Vt[-1]
|
|
310
|
+
d = -np.dot(centroid, n)
|
|
311
|
+
if d < 0:
|
|
312
|
+
n = -n
|
|
313
|
+
d = -d
|
|
314
|
+
return {"normal": n, "distance": d}
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _model_abs_distance_plane(points: ArrayLike, model: dict) -> np.ndarray:
|
|
318
|
+
"""Helper method to estimate distance between points and 3d plane
|
|
319
|
+
|
|
320
|
+
The plane is parameterized as Hesse normal form:
|
|
321
|
+
E(x,y,z): nx * x + ny * y + nz * z + d = 0
|
|
322
|
+
with "normal": (nx, ny, nz) the normal vector of the plane
|
|
323
|
+
and "distance": d the distance between the plane and
|
|
324
|
+
origin (0, 0, 0).
|
|
325
|
+
|
|
326
|
+
:param points: n sample points, shape (n, 3)
|
|
327
|
+
:param model: Plane model as a dict
|
|
328
|
+
:returns: Absolute distances, shape (n,)
|
|
329
|
+
"""
|
|
330
|
+
return np.abs(np.dot(points, model["normal"]) + model["distance"])
|
|
331
|
+
|
|
332
|
+
def segment_plane(
|
|
333
|
+
self,
|
|
334
|
+
threshold: float,
|
|
335
|
+
num_samples: int,
|
|
336
|
+
num_iterations: int,
|
|
337
|
+
rng: np.random.Generator | None = None,
|
|
338
|
+
) -> tuple[dict, np.ndarray]:
|
|
339
|
+
"""Segment 3d plane in point cloud using RANSAC
|
|
340
|
+
|
|
341
|
+
:param threshold: Distance threshold to determine which points
|
|
342
|
+
are considered outliers
|
|
343
|
+
:param num_samples: Number of random samples used to estimate model
|
|
344
|
+
:param num_iterations: Number of attempts in RANSAC to find
|
|
345
|
+
model with more inliers
|
|
346
|
+
:param rng: NumPy random number generator
|
|
347
|
+
:returns: Model parameters and boolean inlier mask
|
|
348
|
+
"""
|
|
349
|
+
if num_samples < 3:
|
|
350
|
+
raise Exception("Not enough samples to fit a plane")
|
|
351
|
+
return self.segment_model(
|
|
352
|
+
PointCloud._model_fit_plane,
|
|
353
|
+
PointCloud._model_abs_distance_plane,
|
|
354
|
+
threshold,
|
|
355
|
+
num_samples,
|
|
356
|
+
num_iterations,
|
|
357
|
+
rng,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _model_fit_sphere(points: ArrayLike) -> dict:
|
|
362
|
+
"""Helper method to estimate 3d sphere from points
|
|
363
|
+
|
|
364
|
+
The sphere is parameterized with "center": (x0, y0, z0)
|
|
365
|
+
the center of the sphere and "radius": the radius of
|
|
366
|
+
the sphere.
|
|
367
|
+
|
|
368
|
+
:param points: n sample points, shape (n, 3)
|
|
369
|
+
:returns: Sphere model as a dict
|
|
370
|
+
"""
|
|
371
|
+
points = np.asarray(points)
|
|
372
|
+
# Set up design matrix A and target vector b for algebraic sphere fitting
|
|
373
|
+
# with center=(x0, y0, z0) and radius R
|
|
374
|
+
# 2*x0*x + 2*y0*y + 2*z0*z + D = x^2 + y^2 + z^2
|
|
375
|
+
# where D = R^2 - x0^2 - y0^2 - z0^2
|
|
376
|
+
A = np.column_stack((2 * points, np.ones(len(points))))
|
|
377
|
+
b = np.sum(points**2, axis=1)
|
|
378
|
+
# Solve the least squares problem: A * [x0, y0, z0, D]^T = b
|
|
379
|
+
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
|
380
|
+
x0, y0, z0, d = params
|
|
381
|
+
center = np.array([x0, y0, z0])
|
|
382
|
+
radius_squared = d + np.dot(center, center)
|
|
383
|
+
radius = np.sqrt(max(0.0, radius_squared))
|
|
384
|
+
return {"center": center, "radius": radius}
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _model_abs_distance_sphere(points: ArrayLike, model: dict) -> np.ndarray:
|
|
388
|
+
"""Helper method to estimate distance between points and 3d sphere
|
|
389
|
+
|
|
390
|
+
The sphere is parameterized with "center": (x0, y0, z0)
|
|
391
|
+
the center of the sphere and "radius": the radius of
|
|
392
|
+
the sphere.
|
|
393
|
+
|
|
394
|
+
:param points: n sample points, shape (n, 3)
|
|
395
|
+
:param model: Sphere model as a dict
|
|
396
|
+
:returns: Absolute distances, shape (n,)
|
|
397
|
+
"""
|
|
398
|
+
distances_to_center = np.linalg.norm(points - model["center"], axis=-1)
|
|
399
|
+
return np.abs(distances_to_center - model["radius"])
|
|
400
|
+
|
|
401
|
+
def segment_sphere(
|
|
402
|
+
self,
|
|
403
|
+
threshold: float,
|
|
404
|
+
num_samples: int,
|
|
405
|
+
num_iterations: int,
|
|
406
|
+
rng: np.random.Generator | None = None,
|
|
407
|
+
) -> tuple[dict, np.ndarray]:
|
|
408
|
+
"""Segment 3d sphere in point cloud using RANSAC
|
|
409
|
+
|
|
410
|
+
:param threshold: Distance threshold to determine which points
|
|
411
|
+
are considered outliers
|
|
412
|
+
:param num_samples: Number of random samples used to estimate model
|
|
413
|
+
:param num_iterations: Number of attempts in RANSAC to find
|
|
414
|
+
model with more inliers
|
|
415
|
+
:param rng: NumPy random number generator
|
|
416
|
+
:returns: Model parameters and boolean inlier mask
|
|
417
|
+
"""
|
|
418
|
+
if num_samples < 4:
|
|
419
|
+
raise Exception("Not enough samples to fit a sphere")
|
|
420
|
+
return self.segment_model(
|
|
421
|
+
PointCloud._model_fit_sphere,
|
|
422
|
+
PointCloud._model_abs_distance_sphere,
|
|
423
|
+
threshold,
|
|
424
|
+
num_samples,
|
|
425
|
+
num_iterations,
|
|
426
|
+
rng,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def random_downsample(
|
|
430
|
+
self, keep_ratio: float, rng: np.random.Generator | None = None
|
|
431
|
+
) -> "PointCloud":
|
|
432
|
+
"""Downsample point cloud by randomly selecting points to keep
|
|
433
|
+
|
|
434
|
+
Downsampling a point cloud with 100 points and keep_ratio=0.13 keeps
|
|
435
|
+
13 random points and removes the other 87.
|
|
436
|
+
|
|
437
|
+
:param keep_ratio: Ratio of points to keep, between 0.0 and 1.0
|
|
438
|
+
:param rng: NumPy random number generator
|
|
439
|
+
:return: Downsampled point cloud
|
|
440
|
+
"""
|
|
441
|
+
if rng is None:
|
|
442
|
+
rng = np.random.default_rng()
|
|
443
|
+
keep_ratio = np.clip(keep_ratio, 0.0, 1.0)
|
|
444
|
+
num_keep = np.round(keep_ratio * self.size).astype(int)
|
|
445
|
+
keep_indices = rng.choice(self.size, num_keep, replace=False)
|
|
446
|
+
return self.get_selected(keep_indices)
|
|
447
|
+
|
|
448
|
+
def voxel_downsample(self, voxel_size: float) -> "PointCloud":
|
|
449
|
+
"""Downsample point cloud using voxel downsampling.
|
|
450
|
+
|
|
451
|
+
Points within the same voxel are replaced by their centroid.
|
|
452
|
+
Colors are averaged and normals are averaged and normalized.
|
|
453
|
+
|
|
454
|
+
:param voxel_size: Size of voxel as a scalar
|
|
455
|
+
:return: Downsampled point cloud
|
|
456
|
+
"""
|
|
457
|
+
if voxel_size <= 0:
|
|
458
|
+
raise ValueError("voxel_size must be positive")
|
|
459
|
+
if self.empty:
|
|
460
|
+
return self.copy()
|
|
461
|
+
# Determine voxel indices
|
|
462
|
+
voxel_indices = np.floor(self._vertices / voxel_size).astype(int)
|
|
463
|
+
# Group points by voxel
|
|
464
|
+
_, inverse_indices = np.unique(voxel_indices, axis=0, return_inverse=True)
|
|
465
|
+
num_voxels = inverse_indices.max() + 1
|
|
466
|
+
# Count points per voxel
|
|
467
|
+
counts = np.bincount(inverse_indices)
|
|
468
|
+
counts = counts[:, None]
|
|
469
|
+
# Aggregate vertices
|
|
470
|
+
vertices = np.zeros((num_voxels, 3))
|
|
471
|
+
np.add.at(vertices, inverse_indices, self._vertices)
|
|
472
|
+
vertices /= counts
|
|
473
|
+
# Aggregate colors
|
|
474
|
+
colors = None
|
|
475
|
+
if self.has_colors:
|
|
476
|
+
colors = np.zeros((num_voxels, 3))
|
|
477
|
+
np.add.at(colors, inverse_indices, self._colors)
|
|
478
|
+
colors /= counts
|
|
479
|
+
# Aggregate normals
|
|
480
|
+
normals = None
|
|
481
|
+
if self.has_normals:
|
|
482
|
+
normals = np.zeros((num_voxels, 3))
|
|
483
|
+
np.add.at(normals, inverse_indices, self._normals)
|
|
484
|
+
normals /= counts
|
|
485
|
+
# Normalize averaged normals
|
|
486
|
+
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
|
|
487
|
+
# Handle opposing normals (resulting in zero-length vectors)
|
|
488
|
+
valid = lengths[:, 0] > 1e-12
|
|
489
|
+
normals[valid] /= lengths[valid]
|
|
490
|
+
normals[~valid] = (
|
|
491
|
+
self._normals[np.argmax(np.bincount(inverse_indices)[inverse_indices])]
|
|
492
|
+
if False
|
|
493
|
+
else normals[~valid]
|
|
494
|
+
)
|
|
495
|
+
return PointCloud(vertices, normals, colors)
|
|
496
|
+
|
|
497
|
+
def distances(
|
|
498
|
+
self, other: "ArrayLike | PointCloud"
|
|
499
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
500
|
+
"""Compute unsigned distance of point cloud "other" to "self"
|
|
501
|
+
|
|
502
|
+
For every point in "other" we find the closest point in "self" and
|
|
503
|
+
the distance between them.
|
|
504
|
+
|
|
505
|
+
This distance calculation is not symmetrical, self.distances(other)
|
|
506
|
+
is not the same as other.distances(self).
|
|
507
|
+
|
|
508
|
+
:param other: Other point cloud with n points
|
|
509
|
+
:returns: Distances of shape (n,) and Indices of nearest
|
|
510
|
+
points in self as integer
|
|
511
|
+
"""
|
|
512
|
+
kdtree = self.kdtree()
|
|
513
|
+
if isinstance(other, PointCloud):
|
|
514
|
+
points = other.vertices
|
|
515
|
+
else:
|
|
516
|
+
points = np.asarray(other, dtype=float)
|
|
517
|
+
dists, indices = kdtree.query(points, 1, workers=-1)
|
|
518
|
+
return dists, indices
|
|
519
|
+
|
|
520
|
+
def chamfer_distance(self, other: "ArrayLike | PointCloud") -> float:
|
|
521
|
+
"""Computes Chamfer distance between two point clouds
|
|
522
|
+
|
|
523
|
+
:param other: Other point cloud
|
|
524
|
+
:returns: Chamfer distance
|
|
525
|
+
"""
|
|
526
|
+
dist0, _ = self.distances(other)
|
|
527
|
+
if isinstance(other, PointCloud):
|
|
528
|
+
dist1, _ = other.distances(self)
|
|
529
|
+
else:
|
|
530
|
+
dist1, _ = PointCloud(vertices=other).distances(self)
|
|
531
|
+
return float(0.5 * np.mean(dist0) + 0.5 * np.mean(dist1))
|
|
532
|
+
|
|
533
|
+
def estimate_transform(self, other: "ArrayLike | PointCloud") -> Trafo3d:
|
|
534
|
+
"""Estimate rigid transformation between two point clouds
|
|
535
|
+
|
|
536
|
+
Point clouds have to have the same number of points and the first
|
|
537
|
+
point in "self" should correspond with the first point in "other"
|
|
538
|
+
and so on.
|
|
539
|
+
|
|
540
|
+
Solves the constraint orthogonal Procrustes problem using the
|
|
541
|
+
Kabsch-Umeyama algorithm.
|
|
542
|
+
|
|
543
|
+
:param other: Other point cloud with n points
|
|
544
|
+
:returns: Transformation from self to other
|
|
545
|
+
"""
|
|
546
|
+
if isinstance(other, PointCloud):
|
|
547
|
+
other = other.vertices
|
|
548
|
+
else:
|
|
549
|
+
other = np.asarray(other)
|
|
550
|
+
if self.size != other.shape[0]:
|
|
551
|
+
raise Exception(
|
|
552
|
+
"Needs two corresponding point clouds of same size "
|
|
553
|
+
"to estimate transform"
|
|
554
|
+
)
|
|
555
|
+
# Centroids
|
|
556
|
+
centroid_A = np.mean(other, axis=0)
|
|
557
|
+
centroid_B = np.mean(self._vertices, axis=0)
|
|
558
|
+
# De-mean point clouds
|
|
559
|
+
AA = other - centroid_A
|
|
560
|
+
BB = self._vertices - centroid_B
|
|
561
|
+
# Covariance matrix
|
|
562
|
+
H = AA.T @ BB
|
|
563
|
+
# Singular value decomposition
|
|
564
|
+
U, _, Vt = np.linalg.svd(H)
|
|
565
|
+
R = Vt.T @ U.T
|
|
566
|
+
# Handle reflection case (det(R) == -1)
|
|
567
|
+
if np.linalg.det(R) < 0:
|
|
568
|
+
Vt[-1, :] *= -1
|
|
569
|
+
R = Vt.T @ U.T
|
|
570
|
+
return Trafo3d(t=centroid_B - R @ centroid_A, mat=R)
|
|
571
|
+
|
|
572
|
+
def icp(
|
|
573
|
+
self,
|
|
574
|
+
other: "ArrayLike | PointCloud",
|
|
575
|
+
max_iterations: int = 100,
|
|
576
|
+
tolerance: float = 1e-6,
|
|
577
|
+
) -> Trafo3d:
|
|
578
|
+
"""Point cloud registration using Iterative Closest Point (ICP)
|
|
579
|
+
|
|
580
|
+
No correspondences between point clouds needed. If the point clouds
|
|
581
|
+
are far apart, algorithm may end in local minimum. Use other methods
|
|
582
|
+
(like keypoint/feature matching) first for a better initial value.
|
|
583
|
+
|
|
584
|
+
Warning: ICP is not necessarily a symmetric operation, so
|
|
585
|
+
self.icp(other) != other.icp(self).
|
|
586
|
+
|
|
587
|
+
:param other: Other point cloud with n points
|
|
588
|
+
:param max_iterations: Number maximum number of iterations
|
|
589
|
+
:param tolerance: If mean distance improvement from iteration to
|
|
590
|
+
iteration is smaller than tolerance, stop iteration
|
|
591
|
+
:returns: Transformation from self to other
|
|
592
|
+
"""
|
|
593
|
+
if isinstance(other, PointCloud):
|
|
594
|
+
other = other.vertices
|
|
595
|
+
else:
|
|
596
|
+
other = np.asarray(other)
|
|
597
|
+
prev_err = np.inf
|
|
598
|
+
if self.size >= other.shape[0]:
|
|
599
|
+
# Move "other" until it fits to "self"
|
|
600
|
+
# Optimizes distances from transformed other to self
|
|
601
|
+
kdtree = self.kdtree()
|
|
602
|
+
dst = other.copy()
|
|
603
|
+
for _ in range(max_iterations):
|
|
604
|
+
distances, indices = kdtree.query(dst)
|
|
605
|
+
trafo = self.get_selected(indices).estimate_transform(dst)
|
|
606
|
+
dst = trafo * dst
|
|
607
|
+
err = np.abs(np.mean(distances))
|
|
608
|
+
if np.abs(prev_err - err) < tolerance:
|
|
609
|
+
break
|
|
610
|
+
prev_err = err
|
|
611
|
+
return PointCloud(other).estimate_transform(dst).inverse
|
|
612
|
+
else:
|
|
613
|
+
# Move "self" until it fits to "other"
|
|
614
|
+
# Optimizes distances from transformed self to other
|
|
615
|
+
kdtree = KDTree(other)
|
|
616
|
+
src = self._vertices.copy()
|
|
617
|
+
for _ in range(max_iterations):
|
|
618
|
+
distances, indices = kdtree.query(src)
|
|
619
|
+
trafo = PointCloud(other[indices]).estimate_transform(src)
|
|
620
|
+
src = trafo * src
|
|
621
|
+
err = np.abs(np.mean(distances))
|
|
622
|
+
if np.abs(prev_err - err) < tolerance:
|
|
623
|
+
break
|
|
624
|
+
prev_err = err
|
|
625
|
+
return self.estimate_transform(src)
|
|
626
|
+
|
|
627
|
+
def cluster_dbscan(self, eps: float, min_points: int) -> np.ndarray:
|
|
628
|
+
"""Cluster point cloud using DBSCAN algorithm
|
|
629
|
+
|
|
630
|
+
Ester, M. and Kriegel, H.-P. and Sander J. and Xu X.: A density-based
|
|
631
|
+
algorithm for discovering clusters in large spatial databases
|
|
632
|
+
with noise, 1996.
|
|
633
|
+
|
|
634
|
+
:param eps: Maximum distance between two points to be considered neighbors
|
|
635
|
+
:param min_points: Minimum number of points (including the point itself)
|
|
636
|
+
within eps for a point to be a core point
|
|
637
|
+
"""
|
|
638
|
+
# Count number of neighbors for each point in eps environment
|
|
639
|
+
kdtree = self.kdtree()
|
|
640
|
+
neighbors = kdtree.query_ball_point(self._vertices, r=eps, workers=-1)
|
|
641
|
+
num_neighbors = np.fromiter(
|
|
642
|
+
(len(nb) for nb in neighbors), dtype=int, count=self.size
|
|
643
|
+
)
|
|
644
|
+
# Core points have at least min_points neighbors
|
|
645
|
+
is_core = num_neighbors >= min_points
|
|
646
|
+
# Build graph containing only core-to-core connections
|
|
647
|
+
rows = np.repeat(np.arange(self.size), num_neighbors)
|
|
648
|
+
cols = np.concatenate(neighbors) if self.size > 0 else np.empty(0, dtype=int)
|
|
649
|
+
keep = is_core[rows] & is_core[cols]
|
|
650
|
+
core_rows = rows[keep]
|
|
651
|
+
core_cols = cols[keep]
|
|
652
|
+
graph = csr_array(
|
|
653
|
+
(np.ones(core_rows.shape[0], dtype=np.int8), (core_rows, core_cols)),
|
|
654
|
+
shape=(self.size, self.size),
|
|
655
|
+
)
|
|
656
|
+
# Calculate connected components using core points only
|
|
657
|
+
_, comp = connected_components(graph, directed=False, return_labels=True)
|
|
658
|
+
# Initialize labels: -1 means noise
|
|
659
|
+
labels = np.full(self.size, -1, dtype=np.int64)
|
|
660
|
+
# Assign cluster labels to core points
|
|
661
|
+
labels[is_core] = comp[is_core]
|
|
662
|
+
# Assign border points to neighboring core points
|
|
663
|
+
for i in np.flatnonzero(~is_core):
|
|
664
|
+
core_neighbors = np.asarray(neighbors[i], dtype=int)
|
|
665
|
+
core_neighbors = core_neighbors[is_core[core_neighbors]]
|
|
666
|
+
if core_neighbors.size > 0:
|
|
667
|
+
labels[i] = labels[core_neighbors[0]]
|
|
668
|
+
# Relabel clusters consecutively starting at zero
|
|
669
|
+
clustered = labels >= 0
|
|
670
|
+
_, labels[clustered] = np.unique(labels[clustered], return_inverse=True)
|
|
671
|
+
if False:
|
|
672
|
+
# 2D visualization projected into X/Y plane;
|
|
673
|
+
# use for illustration with few points only
|
|
674
|
+
_, ax = plt.subplots()
|
|
675
|
+
is_noise = labels == -1
|
|
676
|
+
is_border = ~is_core & ~is_noise
|
|
677
|
+
# Core points: Data points that have at least min_points
|
|
678
|
+
# neighbors within a radius eps
|
|
679
|
+
ax.plot(
|
|
680
|
+
self._vertices[is_core, 0],
|
|
681
|
+
self._vertices[is_core, 1],
|
|
682
|
+
"go",
|
|
683
|
+
label="Core",
|
|
684
|
+
)
|
|
685
|
+
# Border points: Points that have fewer neighbors than min_points,
|
|
686
|
+
# but fall within the eps radius of a core point
|
|
687
|
+
ax.plot(
|
|
688
|
+
self._vertices[is_border, 0],
|
|
689
|
+
self._vertices[is_border, 1],
|
|
690
|
+
"yo",
|
|
691
|
+
label="Border",
|
|
692
|
+
)
|
|
693
|
+
# Points that are neither core nor border points
|
|
694
|
+
ax.plot(
|
|
695
|
+
self._vertices[is_noise, 0],
|
|
696
|
+
self._vertices[is_noise, 1],
|
|
697
|
+
"ro",
|
|
698
|
+
label="Noise",
|
|
699
|
+
)
|
|
700
|
+
# Draw graph connections
|
|
701
|
+
for row, col in zip(*graph.nonzero(), strict=True):
|
|
702
|
+
ax.plot(
|
|
703
|
+
(self._vertices[row, 0], self._vertices[col, 0]),
|
|
704
|
+
(self._vertices[row, 1], self._vertices[col, 1]),
|
|
705
|
+
"-k",
|
|
706
|
+
)
|
|
707
|
+
for pos, label in zip(self._vertices[:, 0:2], labels, strict=True):
|
|
708
|
+
ax.text(*pos, f"{label}")
|
|
709
|
+
ax.legend()
|
|
710
|
+
plt.show()
|
|
711
|
+
return labels
|