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/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