py4dgeo 0.7.0__cp39-cp39-macosx_14_0_arm64.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.
@@ -0,0 +1,474 @@
1
+ from py4dgeo.util import Py4DGeoError
2
+
3
+ from copy import deepcopy
4
+ import dataclasses
5
+ import numpy as np
6
+
7
+ import _py4dgeo
8
+
9
+
10
+ @dataclasses.dataclass(frozen=True)
11
+ class Transformation:
12
+ """A transformation that can be applied to a point cloud"""
13
+
14
+ affine_transformation: np.ndarray
15
+ reduction_point: np.ndarray
16
+
17
+
18
+ def _fit_transform(A, B, reduction_point=None):
19
+ """Find a transformation that fits two point clouds onto each other"""
20
+
21
+ assert A.shape == B.shape
22
+
23
+ # get number of dimensions
24
+ m = A.shape[1]
25
+
26
+ centroid_A = np.mean(A, axis=0)
27
+ centroid_B = np.mean(B, axis=0)
28
+
29
+ # Apply the reduction_point if provided
30
+ if reduction_point is not None:
31
+ centroid_A -= reduction_point
32
+ centroid_B -= reduction_point
33
+
34
+ AA = A - centroid_A
35
+ BB = B - centroid_B
36
+
37
+ H = np.dot(AA.T, BB)
38
+ U, _, Vt = np.linalg.svd(H)
39
+ R = np.dot(Vt.T, U.T)
40
+ t = centroid_B.T - np.dot(R, centroid_A.T)
41
+ # special reflection case
42
+ if np.linalg.det(R) < 0:
43
+ Vt[2, :] *= -1
44
+ R = np.dot(Vt.T, U.T)
45
+
46
+ # homogeneous transformation
47
+ T = np.identity(4)
48
+ T[:3, :3] = R
49
+ T[:3, 3] = t
50
+ return T
51
+
52
+
53
+ def iterative_closest_point(
54
+ reference_epoch, epoch, max_iterations=50, tolerance=0.00001, reduction_point=None
55
+ ):
56
+ """Perform an Iterative Closest Point algorithm (ICP)
57
+
58
+ :param reference_epoch:
59
+ The reference epoch to match with.
60
+ :type reference_epoch: py4dgeo.Epoch
61
+ :param epoch:
62
+ The epoch to be transformed to the reference epoch
63
+ :type epoch: py4dgeo.Epoch
64
+ :param max_iterations:
65
+ The maximum number of iterations to be performed in the ICP algorithm
66
+ :type max_iterations: int
67
+ :param tolerance:
68
+ The tolerance criterium used to terminate ICP iteration.
69
+ :type tolerance: float
70
+ :param reduction_point:
71
+ A translation vector to apply before applying rotation and scaling.
72
+ This is used to increase the numerical accuracy of transformation.
73
+ :type reduction_point: np.ndarray
74
+ """
75
+
76
+ # Ensure that reference_epoch has its KDTree built
77
+ if reference_epoch.kdtree.leaf_parameter() == 0:
78
+ reference_epoch.build_kdtree()
79
+
80
+ # Apply the default for the registration point
81
+ if reduction_point is None:
82
+ reduction_point = np.array([0, 0, 0])
83
+
84
+ # Make a copy of the cloud to be transformed.
85
+ cloud = epoch.cloud.copy()
86
+ prev_error = 0
87
+
88
+ for _ in range(max_iterations):
89
+ neighbor_arrays = np.asarray(reference_epoch.kdtree.nearest_neighbors(cloud, 1))
90
+ indices, distances = np.split(neighbor_arrays, 2, axis=0)
91
+
92
+ indices = np.squeeze(indices.astype(int))
93
+ distances = np.squeeze(distances)
94
+
95
+ # Calculate a transform and apply it
96
+ T = _fit_transform(
97
+ cloud, reference_epoch.cloud[indices, :], reduction_point=reduction_point
98
+ )
99
+ _py4dgeo.transform_pointcloud_inplace(
100
+ cloud, T, reduction_point, np.empty((1, 3))
101
+ )
102
+
103
+ # Determine convergence
104
+ mean_error = np.mean(np.sqrt(distances))
105
+
106
+ if np.abs(prev_error - mean_error) < tolerance:
107
+ break
108
+ prev_error = mean_error
109
+
110
+ return Transformation(
111
+ affine_transformation=_fit_transform(epoch.cloud, cloud),
112
+ reduction_point=reduction_point,
113
+ )
114
+
115
+
116
+ def point_to_plane_icp(
117
+ reference_epoch, epoch, max_iterations=50, tolerance=0.00001, reduction_point=None
118
+ ):
119
+ """Perform a point to plane Iterative Closest Point algorithm (ICP), based on Gauss-Newton method for computing the least squares solution
120
+
121
+ :param reference_epoch:
122
+ The reference epoch to match with. This epoch has to have calculated normals.
123
+ :type reference_epoch: py4dgeo.Epoch
124
+ :param epoch:
125
+ The epoch to be transformed to the reference epoch
126
+ :type epoch: py4dgeo.Epoch
127
+ :param max_iterations:
128
+ The maximum number of iterations to be performed in the ICP algorithm
129
+ :type max_iterations: int
130
+ :param tolerance:
131
+ The tolerance criterium used to terminate ICP iteration.
132
+ :type tolerance: float
133
+ :param reduction_point:
134
+ A translation vector to apply before applying rotation and scaling.
135
+ This is used to increase the numerical accuracy of transformation.
136
+ :type reduction_point: np.ndarray
137
+ """
138
+
139
+ from py4dgeo.epoch import Epoch
140
+
141
+ # Ensure that Epoch has calculated normals
142
+ if reference_epoch.normals is None:
143
+ raise Py4DGeoError(
144
+ "Normals for this Reference Epoch have not been calculated! Please use Epoch.calculate_normals or load externally calculated normals."
145
+ )
146
+
147
+ # Ensure that reference_epoch has its KDTree built
148
+ if reference_epoch.kdtree.leaf_parameter() == 0:
149
+ reference_epoch.build_kdtree()
150
+
151
+ # Apply the default for the registration point
152
+ if reduction_point is None:
153
+ reduction_point = np.array([0, 0, 0])
154
+
155
+ # Make a copy of the cloud to be transformed.
156
+ trans_epoch = epoch.copy()
157
+
158
+ prev_error = 0
159
+ for _ in range(max_iterations):
160
+ neighbor_arrays = np.asarray(
161
+ reference_epoch.kdtree.nearest_neighbors(trans_epoch.cloud, 1)
162
+ )
163
+ indices, distances = np.split(neighbor_arrays, 2, axis=0)
164
+
165
+ indices = np.squeeze(indices.astype(int))
166
+ distances = np.squeeze(distances)
167
+
168
+ # Calculate a transform and apply it
169
+ T = _py4dgeo.fit_transform_GN(
170
+ trans_epoch.cloud,
171
+ reference_epoch.cloud[indices, :],
172
+ reference_epoch.normals[indices, :],
173
+ )
174
+ trans_epoch.transform(
175
+ Transformation(affine_transformation=T, reduction_point=reduction_point)
176
+ )
177
+
178
+ # Determine convergence
179
+ mean_error = np.mean(np.sqrt(distances))
180
+ if np.abs(prev_error - mean_error) < tolerance:
181
+ break
182
+ prev_error = mean_error
183
+
184
+ return Transformation(
185
+ affine_transformation=_py4dgeo.fit_transform_GN(
186
+ epoch.cloud,
187
+ trans_epoch.cloud,
188
+ trans_epoch.normals,
189
+ ),
190
+ reduction_point=reduction_point,
191
+ )
192
+
193
+
194
+ def calculate_bounding_box(point_cloud):
195
+ """
196
+ Calculate the bounding box of a point cloud.
197
+
198
+ Parameters:
199
+ - point_cloud: NumPy array with shape (N, 3), where N is the number of points.
200
+
201
+ Returns:
202
+ - min_bound: 1D array representing the minimum coordinates of the bounding box.
203
+ - max_bound: 1D array representing the maximum coordinates of the bounding box.
204
+ """
205
+ min_bound = np.min(point_cloud, axis=0)
206
+ max_bound = np.max(point_cloud, axis=0)
207
+
208
+ return min_bound, max_bound
209
+
210
+
211
+ def calculate_bounding_box_change(
212
+ bounding_box_min, bounding_box_max, transformation_matrix
213
+ ):
214
+ """Calculate the change in kdtree bounding box corners after applying a transformation matrix.
215
+ Parameters:
216
+ - bounding_box_min: 1D array representing the minimum coordinates of the bounding box.
217
+ - bounding_box_max: 1D array representing the maximum coordinates of the bounding box.
218
+ - transformation_matrix: 2D array representing the transformation matrix.
219
+ Returns:
220
+ - max_change: The maximum change in the bounding box corners.
221
+ """
222
+
223
+ # Convert bounding box to homogeneous coordinates
224
+ bounding_box_min_homogeneous = np.concatenate((bounding_box_min, [1]))
225
+ bounding_box_max_homogeneous = np.concatenate((bounding_box_max, [1]))
226
+ bounding_box_min_homogeneous = np.reshape(bounding_box_min_homogeneous, (4, 1))
227
+ bounding_box_max_homogeneous = np.reshape(bounding_box_max_homogeneous, (4, 1))
228
+
229
+ # Calculate the change in bounding box corners
230
+ bb_c2p1 = np.dot(transformation_matrix, bounding_box_min_homogeneous)
231
+ bb_c2p2 = np.dot(transformation_matrix, bounding_box_max_homogeneous)
232
+
233
+ dif_bb_pmin = np.sum(np.abs(bb_c2p1[:3] - bounding_box_min_homogeneous[:3]))
234
+ dif_bb_pmax = np.sum(np.abs(bb_c2p2[:3] - bounding_box_max_homogeneous[:3]))
235
+
236
+ return max(dif_bb_pmin, dif_bb_pmax)
237
+
238
+
239
+ def calculate_dis_threshold(epoch1, epoch2):
240
+ """Calculate the distance threshold for the next iteration of the registration method
241
+ Parameters:
242
+ - epoch1: The reference epoch.
243
+ - epoch2: Stable points of epoch.
244
+ Returns:
245
+ - dis_threshold: The distance threshold.
246
+ """
247
+ neighbor_arrays = np.asarray(epoch1.kdtree.nearest_neighbors(epoch2.cloud, 1))
248
+ indices, distances = np.split(neighbor_arrays, 2, axis=0)
249
+ distances = np.squeeze(distances)
250
+
251
+ if indices.size > 0:
252
+ # Calculate mean distance
253
+ mean_dis = np.mean(np.sqrt(distances))
254
+
255
+ # Calculate standard deviation
256
+ std_dis = np.sqrt(np.mean((mean_dis - distances) ** 2))
257
+
258
+ dis_threshold = mean_dis + 1.0 * std_dis
259
+
260
+ return dis_threshold
261
+
262
+
263
+ def icp_with_stable_areas(
264
+ reference_epoch,
265
+ epoch,
266
+ initial_distance_threshold,
267
+ level_of_detection,
268
+ reference_supervoxel_resolution,
269
+ supervoxel_resolution,
270
+ min_svp_num=10,
271
+ reduction_point=None,
272
+ ):
273
+ """Perform a registration method
274
+
275
+ :param reference_epoch:
276
+ The reference epoch to match with. This epoch has to have calculated normals.
277
+ :type reference_epoch: py4dgeo.Epoch
278
+ :param epoch:
279
+ The epoch to be transformed to the reference epoch
280
+ :type epoch: py4dgeo.Epoch
281
+ :param initial_distance_threshold:
282
+ The upper boundary of the distance threshold in the iteration. It can be (1) an empirical value manually set by the user according to the approximate accuracy of coarse registration,
283
+ or (2) calculated by the mean and standard of the nearest neighbor distances of all points.
284
+ :type initial_distance_threshold: float
285
+ :param level_of_detection:
286
+ The lower boundary (minimum) of the distance threshold in the iteration.
287
+ It can be (1) an empirical value manually set by the user according to the approximate uncertainty of laser scanning measurements in different scanning configurations and scenarios
288
+ (e.g., 1 cm for TLS point clouds in short distance and 4 cm in long distance, 8 cm for ALS point clouds, etc.),
289
+ or (2) calculated by estimating the standard deviation from local modeling (e.g., using the level of detection in M3C2 or M3C2-EP calculations).
290
+ :type level_of_detection: float
291
+ :param reference_supervoxel_resolution:
292
+ The approximate size of generated supervoxels for the reference epoch.
293
+ It can be (1) an empirical value manually set by the user according to different surface geometries and scanning distance (e.g., 2-10 cm for indoor scenes, 1-3 m for landslide surface),
294
+ or (2) calculated by 10-20 times the average point spacing (original resolution of point clouds). In both cases, the number of points in each supervoxel should be at least 10 (i.e., minSVPnum = 10).
295
+ :type reference_supervoxel_resolution: float
296
+ :param supervoxel_resolution:
297
+ The same as `reference_supervoxel_resolution`, but for a different epoch.
298
+ :type supervoxel_resolution: float
299
+ :param min_svp_num:
300
+ Minimum number of points for supervoxels to be taken into account in further calculations.
301
+ :type min_svp_num: int
302
+ :param reduction_point:
303
+ A translation vector to apply before applying rotation and scaling.
304
+ This is used to increase the numerical accuracy of transformation.
305
+ :type reduction_point: np.ndarray
306
+
307
+ """
308
+
309
+ from py4dgeo.epoch import as_epoch
310
+
311
+ # Ensure that reference_epoch has its KDTree build
312
+ if reference_epoch.kdtree.leaf_parameter() == 0:
313
+ reference_epoch.build_kdtree()
314
+
315
+ # Ensure that epoch has its KDTree build
316
+ if epoch.kdtree.leaf_parameter() == 0:
317
+ epoch.build_kdtree()
318
+
319
+ # Ensure that Epoch has calculated normals
320
+ # Ensure that Epoch has calculated normals
321
+ if reference_epoch.normals is None:
322
+ raise Py4DGeoError(
323
+ "Normals for this Reference Epoch have not been calculated! Please use Epoch.calculate_normals or load externally calculated normals."
324
+ )
325
+
326
+ # Ensure that Epoch has calculated normals
327
+
328
+ # Ensure that Epoch has calculated normals
329
+ if epoch.normals is None:
330
+ raise Py4DGeoError(
331
+ "Normals for this Epoch have not been calculated! Please use Epoch.calculate_normals or load externally calculated normals."
332
+ )
333
+
334
+ # Apply the default for the registration point
335
+ if reduction_point is None:
336
+ reduction_point = np.array([0, 0, 0])
337
+
338
+ if initial_distance_threshold <= level_of_detection:
339
+ initial_distance_threshold = level_of_detection
340
+
341
+ transMatFinal = np.identity(4) # Identity matrix for initial transMatFinal
342
+ stage3 = stage4 = 0
343
+ epoch_copy = epoch.copy() # Create copy of epoch for applying transformation
344
+
345
+ k = 50 # Number of nearest neighbors to consider in supervoxel segmentation
346
+
347
+ clouds_pc1, _, centroids_pc1, _ = _py4dgeo.segment_pc_in_supervoxels(
348
+ reference_epoch,
349
+ reference_epoch.kdtree,
350
+ reference_epoch.normals,
351
+ reference_supervoxel_resolution,
352
+ k,
353
+ min_svp_num,
354
+ )
355
+ (
356
+ clouds_pc2,
357
+ normals2,
358
+ centroids_pc2,
359
+ boundary_points_pc2,
360
+ ) = _py4dgeo.segment_pc_in_supervoxels(
361
+ epoch, epoch.kdtree, epoch.normals, supervoxel_resolution, k, min_svp_num
362
+ )
363
+
364
+ centroids_pc1 = as_epoch(np.array(centroids_pc1))
365
+ centroids_pc1.build_kdtree()
366
+ centroids_pc2 = np.array(centroids_pc2)
367
+ boundary_points_pc2 = np.concatenate(boundary_points_pc2, axis=0)
368
+
369
+ _, reference_distances = np.split(
370
+ np.asarray(reference_epoch.kdtree.nearest_neighbors(reference_epoch.cloud, 2)),
371
+ 2,
372
+ axis=0,
373
+ )
374
+ basicRes = np.mean(np.squeeze(reference_distances))
375
+ dis_threshold = initial_distance_threshold
376
+
377
+ while stage4 == 0:
378
+ cor_dist_ct = _py4dgeo.compute_correspondence_distances(
379
+ centroids_pc1, centroids_pc2, clouds_pc1, len(reference_epoch.cloud)
380
+ )
381
+ # Calculation BP2-CT1
382
+ cor_dist_bp = _py4dgeo.compute_correspondence_distances(
383
+ centroids_pc1,
384
+ boundary_points_pc2,
385
+ clouds_pc1,
386
+ len(reference_epoch.cloud),
387
+ )
388
+ # calculation BP2- CP1
389
+ cor_dist_pc = _py4dgeo.compute_correspondence_distances(
390
+ reference_epoch,
391
+ boundary_points_pc2,
392
+ clouds_pc1,
393
+ len(reference_epoch.cloud),
394
+ )
395
+
396
+ stablePC2 = [] # Stable supervoxels
397
+ normPC2 = [] # Stable supervoxel's normals
398
+
399
+ dt_point = dis_threshold + 2 * basicRes
400
+
401
+ for i in range(len(centroids_pc2)):
402
+ if cor_dist_ct[i] < dis_threshold and all(
403
+ cor_dist_bp[j + 6 * i] < dis_threshold
404
+ and cor_dist_pc[j + 6 * i] < dt_point
405
+ for j in range(6)
406
+ ):
407
+ stablePC2.append(clouds_pc2[i])
408
+ normPC2.append(normals2[i])
409
+
410
+ # Handle empty stablePC2
411
+ if len(stablePC2) == 0:
412
+ raise Py4DGeoError(
413
+ "No stable supervoxels found! Please adjust the parameters."
414
+ )
415
+
416
+ stablePC2 = np.vstack(stablePC2)
417
+ stablePC2 = as_epoch(stablePC2)
418
+ normPC2 = np.vstack(normPC2)
419
+ stablePC2.normals_attachment(normPC2)
420
+ trans_mat_cur_obj = point_to_plane_icp(
421
+ reference_epoch,
422
+ stablePC2,
423
+ max_iterations=50,
424
+ tolerance=0.00001,
425
+ reduction_point=reduction_point,
426
+ )
427
+
428
+ trans_mat_cur = trans_mat_cur_obj.affine_transformation
429
+
430
+ # BB
431
+ initial_min_bound, initial_max_bound = calculate_bounding_box(epoch_copy.cloud)
432
+ max_bb_change = calculate_bounding_box_change(
433
+ initial_min_bound, initial_max_bound, trans_mat_cur
434
+ )
435
+ # update DT
436
+ if stage3 == 0 and max_bb_change < 2 * level_of_detection:
437
+ stage3 = 1
438
+ elif dis_threshold == level_of_detection:
439
+ stage4 = 1
440
+
441
+ if stage3 == 0:
442
+ dis_threshold = calculate_dis_threshold(reference_epoch, stablePC2)
443
+ if dis_threshold <= level_of_detection:
444
+ dis_threshold = level_of_detection
445
+
446
+ if stage3 == 1 and stage4 == 0:
447
+ dis_threshold = 0.8 * dis_threshold
448
+ if dis_threshold <= level_of_detection:
449
+ dis_threshold = level_of_detection
450
+
451
+ # update values and apply changes
452
+ # Apply the transformation to the epoch
453
+ epoch_copy.transform(
454
+ Transformation(
455
+ affine_transformation=trans_mat_cur, reduction_point=reduction_point
456
+ )
457
+ )
458
+ _py4dgeo.transform_pointcloud_inplace(
459
+ centroids_pc2, trans_mat_cur, reduction_point, np.empty((1, 3))
460
+ )
461
+ _py4dgeo.transform_pointcloud_inplace(
462
+ boundary_points_pc2, trans_mat_cur, reduction_point, np.empty((1, 3))
463
+ )
464
+ for i in range(len(clouds_pc2)):
465
+ _py4dgeo.transform_pointcloud_inplace(
466
+ clouds_pc2[i], trans_mat_cur, reduction_point, np.empty((1, 3))
467
+ )
468
+
469
+ transMatFinal = trans_mat_cur @ transMatFinal
470
+
471
+ return Transformation(
472
+ affine_transformation=transMatFinal,
473
+ reduction_point=reduction_point,
474
+ )