py4dgeo 0.7.0__cp312-cp312-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.
py4dgeo/pbm3c2.py ADDED
@@ -0,0 +1,3870 @@
1
+ import colorsys
2
+ import random
3
+ import typing
4
+ import pprint
5
+ import logging
6
+
7
+ import numpy as np
8
+
9
+ from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
10
+ from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
11
+ from sklearn.utils.multiclass import unique_labels
12
+
13
+ from sklearn.pipeline import Pipeline
14
+ from sklearn.compose import make_column_selector
15
+
16
+ from sklearn.ensemble import RandomForestClassifier
17
+ from sklearn import set_config
18
+
19
+ set_config(display="diagram")
20
+
21
+ from abc import ABC, abstractmethod
22
+
23
+ from py4dgeo import Epoch
24
+ from py4dgeo.util import Py4DGeoError, find_file
25
+
26
+ try:
27
+ from vedo import *
28
+
29
+ interactive_available = True
30
+ except ImportError:
31
+ interactive_available = False
32
+
33
+ __all__ = [
34
+ "Viewer",
35
+ "BaseTransformer",
36
+ "PerPointComputation",
37
+ "Segmentation",
38
+ "ExtractSegments",
39
+ "BuilderExtended_y",
40
+ "BuilderExtended_y_Visually",
41
+ "ClassifierWrapper",
42
+ "PBM3C2",
43
+ "build_input_scenario2_without_normals",
44
+ "build_input_scenario2_with_normals",
45
+ "PBM3C2WithSegments",
46
+ "set_interactive_backend",
47
+ "generate_random_extended_y",
48
+ "LLSV_PCA_COLUMNS",
49
+ "SEGMENTED_POINT_CLOUD_COLUMNS",
50
+ "SEGMENT_COLUMNS",
51
+ "generate_extended_y_from_prior_knowledge",
52
+ "generate_possible_region_pairs",
53
+ "DEFAULT_NO_SEGMENT",
54
+ "DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT",
55
+ "add_no_corresponding_seg",
56
+ "config_epoch0_as_segments",
57
+ ]
58
+
59
+ logger = logging.getLogger("py4dgeo")
60
+
61
+ pp = pprint.PrettyPrinter(depth=4)
62
+
63
+ config_epoch0_as_segments = {
64
+ "get_pipeline_options": True,
65
+ "epoch0_Transform_PerPointComputation__skip": True,
66
+ "epoch0_Transform_Segmentation__skip": True,
67
+ "epoch0_Transform_Second_Segmentation__skip": True,
68
+ "epoch0_Transform_ExtractSegments__skip": True,
69
+ }
70
+
71
+
72
+ class LLSV_PCA_COLUMNS:
73
+ X_COLUMN = 0
74
+ Y_COLUMN = 1
75
+ Z_COLUMN = 2
76
+ EPOCH_ID_COLUMN = 3
77
+ EIGENVALUE0_COLUMN = 4
78
+ EIGENVALUE1_COLUMN = 5
79
+ EIGENVALUE2_COLUMN = 6
80
+ EIGENVECTOR_0_X_COLUMN = 7
81
+ EIGENVECTOR_0_Y_COLUMN = 8
82
+ EIGENVECTOR_0_Z_COLUMN = 9
83
+ EIGENVECTOR_1_X_COLUMN = 10
84
+ EIGENVECTOR_1_Y_COLUMN = 11
85
+ EIGENVECTOR_1_Z_COLUMN = 12
86
+ EIGENVECTOR_2_X_COLUMN = 13
87
+ EIGENVECTOR_2_Y_COLUMN = 14
88
+ EIGENVECTOR_2_Z_COLUMN = 15
89
+ LLSV_COLUMN = 16
90
+ NUMBER_OF_COLUMNS = 17
91
+
92
+
93
+ class SEGMENTED_POINT_CLOUD_COLUMNS:
94
+ X_COLUMN = 0
95
+ Y_COLUMN = 1
96
+ Z_COLUMN = 2
97
+ EPOCH_ID_COLUMN = 3
98
+ EIGENVALUE0_COLUMN = 4
99
+ EIGENVALUE1_COLUMN = 5
100
+ EIGENVALUE2_COLUMN = 6
101
+ EIGENVECTOR_0_X_COLUMN = 7
102
+ EIGENVECTOR_0_Y_COLUMN = 8
103
+ EIGENVECTOR_0_Z_COLUMN = 9
104
+ EIGENVECTOR_1_X_COLUMN = 10
105
+ EIGENVECTOR_1_Y_COLUMN = 11
106
+ EIGENVECTOR_1_Z_COLUMN = 12
107
+ EIGENVECTOR_2_X_COLUMN = 13
108
+ EIGENVECTOR_2_Y_COLUMN = 14
109
+ EIGENVECTOR_2_Z_COLUMN = 15
110
+ LLSV_COLUMN = 16
111
+ SEGMENT_ID_COLUMN = 17
112
+ STANDARD_DEVIATION_COLUMN = 18
113
+ NUMBER_OF_COLUMNS = 19
114
+
115
+
116
+ class SEGMENT_COLUMNS:
117
+ X_COLUMN = 0
118
+ Y_COLUMN = 1
119
+ Z_COLUMN = 2
120
+ EPOCH_ID_COLUMN = 3
121
+ EIGENVALUE0_COLUMN = 4
122
+ EIGENVALUE1_COLUMN = 5
123
+ EIGENVALUE2_COLUMN = 6
124
+ EIGENVECTOR_0_X_COLUMN = 7
125
+ EIGENVECTOR_0_Y_COLUMN = 8
126
+ EIGENVECTOR_0_Z_COLUMN = 9
127
+ EIGENVECTOR_1_X_COLUMN = 10
128
+ EIGENVECTOR_1_Y_COLUMN = 11
129
+ EIGENVECTOR_1_Z_COLUMN = 12
130
+ EIGENVECTOR_2_X_COLUMN = 13
131
+ EIGENVECTOR_2_Y_COLUMN = 14
132
+ EIGENVECTOR_2_Z_COLUMN = 15
133
+ LLSV_COLUMN = 16
134
+ SEGMENT_ID_COLUMN = 17
135
+ STANDARD_DEVIATION_COLUMN = 18
136
+ NR_POINTS_PER_SEG_COLUMN = 19
137
+ NUMBER_OF_COLUMNS = 20
138
+
139
+
140
+ # default value, for the points that are part of NO segment ( e.g. -1 )
141
+ DEFAULT_NO_SEGMENT = -1
142
+
143
+ # default standard deviation value for points that are not "core points"
144
+ DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT = -1
145
+
146
+
147
+ def set_interactive_backend(backend="vtk"):
148
+ """Set the interactive backend for selection of correspondent segments.
149
+
150
+ All backends that can be used with the vedo library can be given here.
151
+ E.g. the following backends are available: vtk, ipyvtk, k3d, 2d, ipygany, panel, itk
152
+ """
153
+ if interactive_available:
154
+ from vedo import settings
155
+
156
+ settings.default_backend = backend
157
+
158
+
159
+ def _extract_from_additional_dimensions(
160
+ epoch: Epoch,
161
+ column_names: typing.List[str],
162
+ required_number_of_columns: typing.List[int] = [],
163
+ ):
164
+ """
165
+ Build a numpy array using 'column_names' which are part of the 'additional_dimensions' field.
166
+ The result will maintain the same order or the columns found in 'column_names'.
167
+
168
+ :param epoch:
169
+ Epoch class.
170
+ :param column_names:
171
+ list[ str ]
172
+ :param required_number_of_columns:
173
+ list[ int ]
174
+ default [] , any number of parameter found gets accepted.
175
+ The number of columns required to consider the output valid.
176
+ :return
177
+ numpy array
178
+ """
179
+
180
+ result = np.empty(shape=(epoch.cloud.shape[0], 0), dtype=float)
181
+
182
+ for column in column_names:
183
+ if column in epoch.additional_dimensions.dtype.names:
184
+ result = np.concatenate(
185
+ (result, epoch.additional_dimensions[column]), axis=1
186
+ )
187
+ else:
188
+ logger.debug(
189
+ f"Column '{column}' not found during _extract_from_additional_dimensions()"
190
+ )
191
+
192
+ assert (
193
+ required_number_of_columns == []
194
+ or result.shape[1] in required_number_of_columns
195
+ ), "The number of column found is not a valid one."
196
+
197
+ return result
198
+
199
+
200
+ def angle_difference_compute(normal1, normal2):
201
+ """
202
+
203
+ :param normal1:
204
+ unit vector
205
+ :param normal2:
206
+ unit vector
207
+ :return:
208
+ numpy array of angles in degrees.
209
+ """
210
+
211
+ # normal1, normal2 have to be unit vectors ( and that is the case as a result of the SVD process )
212
+ return np.arccos(np.clip(np.dot(normal1, normal2), -1.0, 1.0)) * 180.0 / np.pi
213
+
214
+
215
+ def geodesic_distance(v1, v2):
216
+ """
217
+ Compute the shortest angular distance between 2 unit vectors.
218
+
219
+ :param v1:
220
+ unit vector
221
+ :param v2:
222
+ unit vector
223
+ :return:
224
+ numpy array of angles in degrees.
225
+ """
226
+
227
+ return min(
228
+ np.arccos(np.clip(np.dot(v1, v2), -1.0, 1.0)) * 180.0 / np.pi,
229
+ np.arccos(np.clip(np.dot(v1, -v2), -1.0, 1.0)) * 180.0 / np.pi,
230
+ )
231
+
232
+
233
+ class Viewer:
234
+ def __init__(self):
235
+ self.sets = []
236
+ self.plt = Plotter(axes=3)
237
+
238
+ @staticmethod
239
+ def HSVToRGB(h, s, v):
240
+ """
241
+ Convert from HSV ( Hue Saturation Value ) color to RGB ( Red Blue Green )
242
+
243
+ :param h:
244
+ float [0, 1]
245
+ :param s:
246
+ float [0, 1]
247
+ :param v:
248
+ float [0, 1]
249
+ :return:
250
+ tuple [ float, float, float ]
251
+ """
252
+ return colorsys.hsv_to_rgb(h, s, v)
253
+
254
+ @staticmethod
255
+ def get_distinct_colors(n):
256
+ """
257
+ Return a python list of 'n' distinct colors.
258
+
259
+ :param n:
260
+ number of colors.
261
+ :return:
262
+ python list of tuple [ float, float, float ]
263
+ """
264
+
265
+ huePartition = 1.0 / (n + 1)
266
+ return [
267
+ Viewer.HSVToRGB(huePartition * value, 1.0, 1.0) for value in range(0, n)
268
+ ]
269
+
270
+ @staticmethod
271
+ def read_np_ndarray_from_xyz(input_file_name: str) -> np.ndarray:
272
+ """
273
+ The reconstructed np.ndarray.
274
+ :param input_file_name:
275
+ The output file name.
276
+ :return:
277
+ np.ndarray
278
+ """
279
+
280
+ # Resolve the given path
281
+ filename = find_file(input_file_name)
282
+
283
+ # Read it
284
+ try:
285
+ logger.info(f"Reading np.ndarray from file '{filename}'")
286
+ np_ndarray = np.genfromtxt(filename, delimiter=",")
287
+ except ValueError:
288
+ raise Py4DGeoError("Malformed file: " + str(filename))
289
+
290
+ return np_ndarray
291
+
292
+ @staticmethod
293
+ def segmented_point_cloud_visualizer(
294
+ X: np.ndarray, columns=SEGMENTED_POINT_CLOUD_COLUMNS
295
+ ):
296
+ """
297
+ Visualize a segmented point cloud. ( the resulting point cloud after the segmentation process )
298
+
299
+ :param X:
300
+ numpy array (n_points, 19) with the following column structure:
301
+ [
302
+ x, y, z ( 3 columns ),
303
+ EpochID ( 1 column ),
304
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
305
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
306
+ Lowest local surface variation ( 1 column ),
307
+ Segment_ID ( 1 column ),
308
+ Standard deviation ( 1 column )
309
+ ]
310
+ :return:
311
+ """
312
+
313
+ X_Y_Z_Columns = [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
314
+
315
+ viewer = Viewer()
316
+
317
+ nr_segments = int(X[:, columns.SEGMENT_ID_COLUMN].max())
318
+ colors = Viewer.get_distinct_colors(nr_segments + 1)
319
+
320
+ for i in range(0, nr_segments + 1):
321
+ mask = X[:, columns.SEGMENT_ID_COLUMN] == float(i)
322
+ # x,y,z
323
+ set_cloud = X[mask, :][:, X_Y_Z_Columns]
324
+
325
+ viewer.sets = viewer.sets + [Points(set_cloud, colors[i], alpha=1, r=10)]
326
+
327
+ viewer.plt.show(viewer.sets).close()
328
+
329
+ @staticmethod
330
+ def segments_visualizer(X: np.ndarray, columns=SEGMENT_COLUMNS):
331
+ """
332
+ Segments visualizer.
333
+
334
+ :param X:
335
+ Each row is a segment, numpy array (1, 20) with the following column structure:
336
+ [
337
+ x, y, z ( 3 columns ), -> segment, core point
338
+ EpochID ( 1 column ),
339
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
340
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
341
+ Lowest local surface variation ( 1 column ),
342
+ Segment_ID ( 1 column ),
343
+ Standard deviation ( 1 column ),
344
+ Number of points found in Segment_ID segment ( 1 column )
345
+ ]
346
+ :param columns:
347
+ """
348
+
349
+ viewer = Viewer()
350
+
351
+ nr_segments = X.shape[0]
352
+ colors = [(1, 0, 0), (0, 1, 0)]
353
+
354
+ viewer.plt.add_callback("KeyPress", viewer.toggle_transparency)
355
+
356
+ for i in range(0, nr_segments):
357
+ if X[i, columns.EPOCH_ID_COLUMN] == 0:
358
+ color = colors[0]
359
+ else:
360
+ color = colors[1]
361
+
362
+ ellipsoid = Ellipsoid(
363
+ pos=(X[i, 0], X[i, 1], X[i, 2]),
364
+ axis1=[
365
+ X[i, columns.EIGENVECTOR_0_X_COLUMN]
366
+ * X[i, columns.EIGENVALUE0_COLUMN]
367
+ * 0.5,
368
+ X[i, columns.EIGENVECTOR_0_Y_COLUMN]
369
+ * X[i, columns.EIGENVALUE0_COLUMN]
370
+ * 0.5,
371
+ X[i, columns.EIGENVECTOR_0_Z_COLUMN]
372
+ * X[i, columns.EIGENVALUE0_COLUMN]
373
+ * 0.3,
374
+ ],
375
+ axis2=[
376
+ X[i, columns.EIGENVECTOR_1_X_COLUMN]
377
+ * X[i, columns.EIGENVALUE1_COLUMN]
378
+ * 0.5,
379
+ X[i, columns.EIGENVECTOR_1_Y_COLUMN]
380
+ * X[i, columns.EIGENVALUE1_COLUMN]
381
+ * 0.5,
382
+ X[i, columns.EIGENVECTOR_1_Z_COLUMN]
383
+ * X[i, columns.EIGENVALUE1_COLUMN]
384
+ * 0.5,
385
+ ],
386
+ axis3=[
387
+ X[i, columns.EIGENVECTOR_2_X_COLUMN] * 0.1,
388
+ X[i, columns.EIGENVECTOR_2_Y_COLUMN] * 0.1,
389
+ X[i, columns.EIGENVECTOR_2_Z_COLUMN] * 0.1,
390
+ ],
391
+ res=24,
392
+ c=color,
393
+ alpha=1,
394
+ )
395
+ # ellipsoid.caption(txt=str(i), size=(0.1, 0.05))
396
+ ellipsoid.id = X[i, columns.SEGMENT_ID_COLUMN]
397
+ ellipsoid.epoch = X[i, columns.EPOCH_ID_COLUMN]
398
+ ellipsoid.isOn = True
399
+
400
+ viewer.sets = viewer.sets + [ellipsoid]
401
+
402
+ viewer.plt.show(
403
+ viewer.sets,
404
+ Text2D(
405
+ "'z' - toggle transparency on/off "
406
+ "'g' - toggle on/off red ellipsoids "
407
+ "'d' - toggle on/off green ellipsoids",
408
+ pos="top-left",
409
+ bg="k",
410
+ s=0.7,
411
+ ),
412
+ ).close()
413
+
414
+ def toggle_transparency(self, evt):
415
+ if evt.keyPressed == "z":
416
+ logger.info("transparency toggle")
417
+ for segment in self.sets:
418
+ if segment.alpha() < 1.0:
419
+ segment.alpha(1)
420
+ else:
421
+ segment.alpha(0.5)
422
+ self.plt.render()
423
+
424
+ if evt.keyPressed == "g":
425
+ logger.info("toggle red")
426
+ for segment in self.sets:
427
+ if segment.epoch == 0:
428
+ if segment.isOn:
429
+ segment.off()
430
+ else:
431
+ segment.on()
432
+ segment.isOn = not segment.isOn
433
+ self.plt.render()
434
+
435
+ if evt.keyPressed == "d":
436
+ logger.info("toggle green")
437
+ for segment in self.sets:
438
+ if segment.epoch == 1:
439
+ if segment.isOn:
440
+ segment.off()
441
+ else:
442
+ segment.on()
443
+ segment.isOn = not segment.isOn
444
+ self.plt.render()
445
+
446
+
447
+ def generate_random_extended_y(
448
+ X,
449
+ extended_y_file_name="locally_generated_extended_y.csv",
450
+ ratio=1 / 3,
451
+ low=0,
452
+ high=1,
453
+ columns=SEGMENT_COLUMNS,
454
+ ):
455
+ """
456
+ Generate a subset (1/3 from the total possible pairs) of random tuples of segments ID
457
+ (where each ID gets to be extracted from a different epoch) which are randomly labeled
458
+ between low and high)
459
+
460
+ :param X:
461
+ (n_segments, 20)
462
+ Each row contains a segment.
463
+ :param extended_y_file_name:
464
+ Name of the file where the serialized result is saved.
465
+ :param ratio:
466
+ The size of the pairs' subset size.
467
+ :param low:
468
+ Default minimum random value used as label
469
+ :param high:
470
+ Default maximum random value used as label
471
+ :param columns:
472
+ Column mapping used by each segment.
473
+ :return:
474
+ numpy (m_pairs, 3)
475
+ Where each row contains tuples of set0 segment id, set1 segment id, rand 0/1.
476
+ """
477
+
478
+ mask_epoch0 = X[:, columns.EPOCH_ID_COLUMN] == 0
479
+ mask_epoch1 = X[:, columns.EPOCH_ID_COLUMN] == 1
480
+
481
+ epoch0_set = X[mask_epoch0, :] # all
482
+ epoch1_set = X[mask_epoch1, :] # all
483
+
484
+ ratio = np.clip(ratio, 0, 1)
485
+ nr_pairs = round(min(epoch0_set.shape[0], epoch1_set.shape[0]) * ratio)
486
+
487
+ indx0_seg_id = random.sample(range(epoch0_set.shape[0]), nr_pairs)
488
+ indx1_seg_id = random.sample(range(epoch1_set.shape[0]), nr_pairs)
489
+
490
+ set0_seg_id = epoch0_set[indx0_seg_id, columns.SEGMENT_ID_COLUMN]
491
+ set1_seg_id = epoch1_set[indx1_seg_id, columns.SEGMENT_ID_COLUMN]
492
+
493
+ rand_y = list(np.random.randint(low, high + 1, nr_pairs))
494
+
495
+ np.savetxt(
496
+ extended_y_file_name,
497
+ np.array([set0_seg_id, set1_seg_id, rand_y]).T,
498
+ delimiter=",",
499
+ )
500
+
501
+ return np.array([set0_seg_id, set1_seg_id, rand_y]).T
502
+
503
+
504
+ def generate_possible_region_pairs(
505
+ segments: np.ndarray, seg_id0_seg_id1_label: np.ndarray
506
+ ):
507
+ """
508
+ :param segments:
509
+ numpy array of shape (n_segments, segment_size)
510
+ :param seg_id0_seg_id1_label:
511
+ extended_y, numpy array (n_pairs, 3)
512
+ where each row contains: (id_segment_epoch0, id_segment_epoch1, label=0/1)
513
+ :return:
514
+ numpy array of shape (m_pairs, 7) where each row contain:
515
+ pairs_of_points[i, :3] -> a proposed position of a segment from epoch 0
516
+ pairs_pf_points[i, 3:] -> a proposed position of a segment from epoch 1
517
+ label: 0/1
518
+ """
519
+
520
+ segment_pairs = seg_id0_seg_id1_label
521
+
522
+ out = np.empty(shape=(0, 7))
523
+ for row in range(segment_pairs.shape[0]):
524
+ id_epoch0 = int(segment_pairs[row, 0])
525
+ id_epoch1 = int(segment_pairs[row, 1])
526
+ label = int(segment_pairs[row, 2])
527
+ points = np.hstack(
528
+ (
529
+ segments[id_epoch0, 0:3] + np.random.normal(0, 1),
530
+ segments[id_epoch1, 3:6] + np.random.normal(0, 1),
531
+ label,
532
+ )
533
+ )
534
+ out = np.vstack((out, points))
535
+
536
+ return out
537
+
538
+
539
+ def generate_extended_y_from_prior_knowledge(
540
+ segments: np.ndarray,
541
+ pairs_of_points: np.ndarray,
542
+ threshold_max_distance: float,
543
+ columns=SEGMENT_COLUMNS,
544
+ ) -> np.ndarray:
545
+ """
546
+ :param segments:
547
+ numpy array of shape (n_segments, segment_size)
548
+ :param pairs_of_points:
549
+ numpy array of shape (m_pairs, 7) where each row contain:
550
+ pair_of_points[i, :3] -> a proposed position of a segment from epoch 0
551
+ pair_pf_points[i, 3:] -> a proposed position of a segment from epoch 1
552
+ label: 0/1
553
+ :param threshold_max_distance:
554
+ the radios accepted threshold for possible segments
555
+ :param columns:
556
+ Column mapping used by each segment.
557
+ :return:
558
+ extended_y, numpy array (n_pairs, 3)
559
+ where each row contains: (id_segment_epoch0, id_segment_epoch1, label=0/1)
560
+ """
561
+
562
+ extended_y = np.empty(shape=(0, 3), dtype=float)
563
+
564
+ # split points(segments) between epoch0 and epoch1
565
+ epoch0_mask = segments[:, columns.EPOCH_ID_COLUMN] == 0
566
+ epoch1_mask = segments[:, columns.EPOCH_ID_COLUMN] == 1
567
+
568
+ X_Y_Z_Columns = [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
569
+
570
+ epoch0_set = segments[epoch0_mask][X_Y_Z_Columns]
571
+ epoch1_set = segments[epoch1_mask][X_Y_Z_Columns]
572
+
573
+ # generate kd-tree for each of the 2 sets
574
+ epoch0 = Epoch(epoch0_set.T)
575
+ epoch0.build_kdtree()
576
+ epoch1 = Epoch(epoch1_set.T)
577
+ epoch1.build_kdtree()
578
+
579
+ # search for the near segments and build the 'extended y'
580
+ for row in pairs_of_points:
581
+ seg_epoch0, seg_epoch1, label = np.split(ary=row, indices_or_sections=[3, 6])
582
+ label = label[0]
583
+
584
+ candidates_seg_epoch0 = epoch0.kdtree.radius_search(
585
+ seg_epoch0, threshold_max_distance
586
+ )
587
+ candidates_seg_epoch1 = epoch1.kdtree.radius_search(
588
+ seg_epoch1, threshold_max_distance
589
+ )
590
+
591
+ if len(candidates_seg_epoch0) > 0 and len(candidates_seg_epoch1) > 0:
592
+ indx_min_epoch0 = candidates_seg_epoch0[
593
+ np.linalg.norm(
594
+ epoch0.cloud[candidates_seg_epoch0] - seg_epoch0
595
+ ).argmin()
596
+ ]
597
+ indx_min_epoch1 = candidates_seg_epoch1[
598
+ np.linalg.norm(
599
+ epoch1.cloud[candidates_seg_epoch0] - seg_epoch1
600
+ ).argmin()
601
+ ]
602
+
603
+ extended_y = np.vstack(
604
+ (
605
+ extended_y,
606
+ # index segment epoch0 , index segment epoch1, label=0/1
607
+ np.array([indx_min_epoch0, indx_min_epoch1, label]),
608
+ )
609
+ )
610
+
611
+ return extended_y
612
+
613
+
614
+ def add_no_corresponding_seg(
615
+ segments: np.ndarray,
616
+ extended_y: np.ndarray = None,
617
+ threshold_max_distance: float = 3,
618
+ algorithm="closest",
619
+ extended_y_file_name: str = "extended_y.csv",
620
+ columns=SEGMENT_COLUMNS,
621
+ ):
622
+ """
623
+ :param segments:
624
+ numpy array of shape (n_segments, segment_size)
625
+ :param extended_y:
626
+ numpy array (n_pairs, 3)
627
+ where each row contains: (id_segment_epoch0, id_segment_epoch1, label=0/1)
628
+ :param threshold_max_distance:
629
+ the radios accepted threshold for possible segments
630
+ :param algorithm:
631
+ closest - select the closest segment, not used as the corresponding segment
632
+ random - select a random segment, from proximity (threshold_max_distance),
633
+ not used already as a corresponding segment
634
+ :param columns:
635
+ Column mapping used by each segment.
636
+ :param extended_y_file_name:
637
+ In case 'extended_y' is None, this file is used as input fallback.
638
+ :return:
639
+ extended_y, numpy array (n_pairs, 3)
640
+ where each row contains: (id_segment_epoch0, id_segment_epoch1, label=0/1)
641
+ """
642
+
643
+ assert (
644
+ algorithm == "closest" or algorithm == "random"
645
+ ), "'selection' parameter can be 'closest/random'"
646
+
647
+ if extended_y is None:
648
+ # Resolve the given path
649
+ filename = find_file(extended_y_file_name)
650
+ # Read it
651
+ try:
652
+ logger.info(
653
+ f"Reading tuples of (segment epoch0, segment epoch1, label) from file '{filename}'"
654
+ )
655
+ extended_y = np.genfromtxt(filename, delimiter=",")
656
+ except ValueError:
657
+ raise Py4DGeoError("Malformed file: " + str(filename))
658
+
659
+ # construct the corresponding pairs from the set of all pairs
660
+ extended_y_with_label_1 = extended_y[extended_y[:, 2] == 1]
661
+
662
+ new_extended_y = np.empty(shape=(0, 3), dtype=float)
663
+
664
+ # split points(segments) between epoch0 and epoch1
665
+ epoch0_mask = segments[:, columns.EPOCH_ID_COLUMN] == 0
666
+ epoch1_mask = segments[:, columns.EPOCH_ID_COLUMN] == 1
667
+
668
+ # compute index of each segment as part of 'segments'
669
+ epoch0_index = np.asarray(epoch0_mask).nonzero()[0]
670
+ epoch1_index = np.asarray(epoch1_mask).nonzero()[0]
671
+
672
+ X_Y_Z_Columns = [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
673
+
674
+ epoch0_set = segments[epoch0_mask][X_Y_Z_Columns]
675
+ epoch1_set = segments[epoch1_mask][X_Y_Z_Columns]
676
+
677
+ epoch0_set = epoch0_set.T
678
+ # generate kd-tree
679
+ epoch1 = Epoch(epoch1_set.T)
680
+ epoch1.build_kdtree()
681
+
682
+ # search for the near segments and build the 'extended y'
683
+ # for row in pairs_of_points:
684
+ for index, row in enumerate(epoch0_set):
685
+ index_seg_epoch0 = epoch0_index[index]
686
+
687
+ candidates_seg_epoch1 = epoch1.kdtree.radius_search(row, threshold_max_distance)
688
+
689
+ indexes_seg_epoch1 = epoch1_index[candidates_seg_epoch1]
690
+
691
+ if len(indexes_seg_epoch1) == 0:
692
+ continue
693
+
694
+ if algorithm == "closest":
695
+ index = (
696
+ (extended_y[:, 0] == index_seg_epoch0)
697
+ & (extended_y[:, 1] == indexes_seg_epoch1[0])
698
+ & (extended_y[:, 2] == 1)
699
+ )
700
+
701
+ if not index.any():
702
+ new_extended_y = np.vstack(
703
+ (new_extended_y, (index_seg_epoch0, indexes_seg_epoch1[0], 0))
704
+ )
705
+ else:
706
+ if len(candidates_seg_epoch1) > 1:
707
+ new_extended_y = np.vstack(
708
+ (new_extended_y, (index_seg_epoch0, indexes_seg_epoch1[1], 0))
709
+ )
710
+
711
+ if algorithm == "random":
712
+ while True:
713
+ rand_point = np.random.randint(low=0, high=len(indexes_seg_epoch1))
714
+ index = (
715
+ (extended_y[:, 0] == index_seg_epoch0)
716
+ & (extended_y[:, 1] == indexes_seg_epoch1[rand_point])
717
+ & (extended_y[:, 2] == 1)
718
+ )
719
+
720
+ if not index.any():
721
+ new_extended_y = np.vstack(
722
+ (
723
+ new_extended_y,
724
+ (index_seg_epoch0, indexes_seg_epoch1[rand_point], 0),
725
+ )
726
+ )
727
+ break
728
+
729
+ return np.vstack((extended_y, new_extended_y))
730
+
731
+
732
+ class BaseTransformer(TransformerMixin, BaseEstimator, ABC):
733
+ def __init__(self, skip=False, output_file_name=None, columns=None):
734
+ """
735
+ :param skip:
736
+ Whether the current transform is applied or not.
737
+ :param output_file_name:
738
+ File where the result of the 'Transform()' method, a numpy array, is dumped.
739
+ """
740
+
741
+ self.skip = skip
742
+ self.output_file_name = output_file_name
743
+ self.columns = columns
744
+ super(BaseTransformer, self).__init__()
745
+
746
+ @abstractmethod
747
+ def _fit(self, X, y=None):
748
+ """
749
+ X : {array-like, sparse matrix}, shape (n_samples, n_features)
750
+ The training input samples.
751
+ y : None
752
+ There is no need of a target in a transformer, yet the pipeline API
753
+ requires this parameter.
754
+ Returns
755
+ -------
756
+ self : object
757
+ Returns self.
758
+ """
759
+ pass
760
+
761
+ @abstractmethod
762
+ def _transform(self, X):
763
+ """
764
+ X : {array-like, sparse-matrix}, shape (n_samples, n_features)
765
+ The input samples.
766
+ Returns
767
+ -------
768
+ X_transformed : array, shape (n_samples, n_features)
769
+ """
770
+ pass
771
+
772
+ def fit(self, X, y=None):
773
+ if self.skip:
774
+ return self
775
+
776
+ X = check_array(X, accept_sparse=True)
777
+ self.n_features_ = X.shape[1]
778
+
779
+ # Return the transformer
780
+ logger.info("Transformer Fit")
781
+
782
+ return self._fit(X, y)
783
+
784
+ def transform(self, X):
785
+ """
786
+ param: X
787
+ numpy array
788
+ """
789
+
790
+ if self.skip:
791
+ if self.output_file_name != None:
792
+ logger.debug(
793
+ f"The output file, {self.output_file_name} "
794
+ f"was set but the transformation process is skipped! (no output)"
795
+ )
796
+ return X
797
+
798
+ # Check if fit had been called
799
+ check_is_fitted(self, "n_features_")
800
+
801
+ # Input validation
802
+ X = check_array(X, accept_sparse=True)
803
+
804
+ # Check that the input is of the same shape as the one passed
805
+ # during fit.
806
+ if X.shape[1] != self.n_features_:
807
+ raise Py4DGeoError(
808
+ "Shape of input is different from what was seen in `fit`"
809
+ )
810
+
811
+ logger.info("Transformer Transform")
812
+
813
+ out = self._transform(X)
814
+
815
+ if self.output_file_name != None:
816
+ np.savetxt(self.output_file_name, out, delimiter=",")
817
+ logger.info(f"Saving Transform output in file: {self.output_file_name}")
818
+
819
+ return out
820
+
821
+
822
+ class PerPointComputation(BaseTransformer):
823
+ def __init__(
824
+ self, skip=False, radius=10, output_file_name=None, columns=LLSV_PCA_COLUMNS
825
+ ):
826
+ """
827
+
828
+ :param skip:
829
+ Whether the current transform is applied or not.
830
+ :param radius:
831
+ The radius used to extract the neighbour points using KD-tree.
832
+ :param output_file_name:
833
+ File where the result of the 'Transform()' method, a numpy array, is dumped.
834
+ """
835
+
836
+ super(PerPointComputation, self).__init__(
837
+ skip=skip, output_file_name=output_file_name, columns=columns
838
+ )
839
+ self.radius = radius
840
+
841
+ def _llsv_and_pca(self, x, X):
842
+ """
843
+ Compute PCA (implicitly, the normal vector as well) and lowest local surface variation
844
+ for point "x" using the set "X" as input.
845
+
846
+ :param x:
847
+ a reference to a row, part of the returned structure, of the following form:
848
+ [
849
+ x, y, z,
850
+ EpochID,
851
+ Eigenvalues( 3 columns ),
852
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
853
+ Lowest local surface variation ( 1 column )
854
+ ]
855
+ :param X:
856
+ Subset of the point cloud of numpy array (m_samples, 3), that is found around 'x'(x,y,z) inside a 'radius'.
857
+ :return:
858
+ return a populated x with
859
+ (Eigenvalues, Eigenvectors, Lowest local surface variation)
860
+ """
861
+
862
+ size = X.shape[0]
863
+
864
+ # compute mean
865
+ X_avg = np.mean(X, axis=0)
866
+ B = X - np.tile(X_avg, (size, 1))
867
+
868
+ # Find principal components (SVD)
869
+ U, S, VT = np.linalg.svd(B.T / np.sqrt(size), full_matrices=0)
870
+
871
+ x[-13:] = np.hstack(
872
+ (
873
+ S.reshape(1, -1),
874
+ U[:, 0].reshape(1, -1),
875
+ U[:, 1].reshape(1, -1),
876
+ U[:, 2].reshape(1, -1),
877
+ (S[-1] / np.sum(S)).reshape(1, -1),
878
+ )
879
+ )
880
+
881
+ return x
882
+
883
+ def _fit(self, X, y=None):
884
+ """
885
+
886
+ :param X:
887
+ :param y:
888
+ :return:
889
+ """
890
+
891
+ return self
892
+
893
+ def _transform(self, X):
894
+ """
895
+ Extending X matrix by adding eigenvalues, eigenvectors, and lowest local surface variation columns.
896
+
897
+ :param X:
898
+ A numpy array with x, y, z, EpochID columns.
899
+ :return:
900
+ numpy matrix extended containing the following columns:
901
+ x, y, z ( 3 columns ),
902
+ EpochID ( 1 column ),
903
+ Eigenvalues( 3 columns ),
904
+ Eigenvectors( 3 columns ) X 3 [ in descending normal order ],
905
+ Lowest local surface variation( 1 column )
906
+ """
907
+
908
+ X_Y_Z_Columns = [
909
+ self.columns.X_COLUMN,
910
+ self.columns.Y_COLUMN,
911
+ self.columns.Z_COLUMN,
912
+ ]
913
+
914
+ mask_epoch0 = X[:, self.columns.EPOCH_ID_COLUMN] == 0
915
+ mask_epoch1 = X[:, self.columns.EPOCH_ID_COLUMN] == 1
916
+
917
+ epoch0_set = X[mask_epoch0, :-1]
918
+ epoch1_set = X[mask_epoch1, :-1]
919
+
920
+ # currently, Epoch class doesn't accept the empty point set, as a constructor parameter.
921
+ _epoch = [
922
+ Epoch(epoch_set)
923
+ for epoch_set in [epoch0_set, epoch1_set]
924
+ if epoch_set.shape[0] > 0
925
+ ]
926
+
927
+ for current_epoch in _epoch:
928
+ current_epoch.build_kdtree()
929
+
930
+ # add extra columns
931
+ # Eigenvalues( 3 columns ) |
932
+ # Eigenvectors( 3 columns ) X 3 [ in descending normal order ] |
933
+ # Lowest local surface variation( 1 column )
934
+ # Total 13 new columns
935
+ new_columns = np.zeros((X.shape[0], 13))
936
+ X = np.hstack((X, new_columns))
937
+
938
+ # this process can be parallelized!
939
+ return np.apply_along_axis(
940
+ lambda x: self._llsv_and_pca(
941
+ x,
942
+ _epoch[int(x[self.columns.EPOCH_ID_COLUMN])].cloud[
943
+ _epoch[int(x[self.columns.EPOCH_ID_COLUMN])].kdtree.radius_search(
944
+ x[X_Y_Z_Columns], self.radius
945
+ )
946
+ ],
947
+ ),
948
+ 1,
949
+ X,
950
+ )
951
+
952
+
953
+ class Segmentation(BaseTransformer):
954
+ def __init__(
955
+ self,
956
+ skip=False,
957
+ radius=2,
958
+ angle_diff_threshold=1,
959
+ distance_3D_threshold=1.5,
960
+ distance_orthogonal_threshold=1.5,
961
+ llsv_threshold=1,
962
+ roughness_threshold=5,
963
+ max_nr_points_neighborhood=100,
964
+ min_nr_points_per_segment=5,
965
+ with_previously_computed_segments=False,
966
+ output_file_name=None,
967
+ columns=SEGMENTED_POINT_CLOUD_COLUMNS,
968
+ ):
969
+ """
970
+
971
+ :param skip:
972
+ Whether the current transform is applied or not.
973
+ :param radius:
974
+ The radius used to extract the neighbour points using KD-tree during segmentation process.
975
+ :param angle_diff_threshold:
976
+ Angular deviation threshold for a point candidate’s local normal vector to the normal vector
977
+ of the initial seed point.
978
+ :param distance_3D_threshold:
979
+ Norm 2 distance threshold of the point candidate to the current set of points,
980
+ during the segmentation process.
981
+ :param distance_orthogonal_threshold:
982
+ Orthogonal distance threshold of the point candidate to the current plane segment
983
+ used during the segmentation process.
984
+ :param llsv_threshold:
985
+ The threshold on local surface variation.
986
+ :param roughness_threshold:
987
+ Threshold on local roughness.
988
+ :param max_nr_points_neighborhood:
989
+ The maximum number of points in the neighborhood of the point the candidate used
990
+ for checking during the segmentation process.
991
+ :param min_nr_points_per_segment:
992
+ The minimum number of points required to consider a segment as valid.
993
+ :param with_previously_computed_segments:
994
+ Used for differentiating between the first and the second segmentation.
995
+ ( must be refactored!!! )
996
+ param output_file_name:
997
+ File where the result of the 'Transform()' method, a numpy array, is dumped.
998
+ """
999
+
1000
+ super(Segmentation, self).__init__(
1001
+ skip=skip, output_file_name=output_file_name, columns=columns
1002
+ )
1003
+
1004
+ self.radius = radius
1005
+ self.angle_diff_threshold = angle_diff_threshold
1006
+ self.distance_3D_threshold = distance_3D_threshold
1007
+ self.distance_orthogonal_threshold = distance_orthogonal_threshold
1008
+ self.llsv_threshold = llsv_threshold
1009
+ self.roughness_threshold = roughness_threshold
1010
+ self.max_nr_points_neighborhood = max_nr_points_neighborhood
1011
+ self.min_nr_points_per_segment = min_nr_points_per_segment
1012
+ self.with_previously_computed_segments = with_previously_computed_segments
1013
+
1014
+ def angle_difference_check(self, normal1, normal2):
1015
+ """
1016
+ Check whether the angle between 2 normalized vectors is less than
1017
+ the used segmentation threshold "angle_diff_threshold" (in degrees)
1018
+
1019
+ :param normal1:
1020
+ normalized vector
1021
+ :param normal2:
1022
+ normalized vector
1023
+ :return:
1024
+ True/False
1025
+ """
1026
+
1027
+ # normal1, normal2 have to be unit vectors (and that is the case as a result of the SVD process)
1028
+ return angle_difference_compute(normal1, normal2) <= self.angle_diff_threshold
1029
+
1030
+ def distance_3D_set_check(
1031
+ self, point, segment_id, X, X_Y_Z_Columns, SEGMENT_ID_COLUMN
1032
+ ):
1033
+ """
1034
+ Check whether the distance between the candidate point and all the points, currently part of the segment
1035
+ is less than the 'distance_3D_threshold'.
1036
+
1037
+ :param point:
1038
+ Numpy array (3, 1) candidate point during segmentation process.
1039
+ :param segment_id:
1040
+ The segment ID for which the candidate point is considered.
1041
+ :param X:
1042
+ numpy array (n_samples, 19)
1043
+ :param X_Y_Z_Columns:
1044
+ python list containing the indexes of the X,Y,Z columns.
1045
+ :param SEGMENT_ID_COLUMN:
1046
+ The column index used as the segment id.
1047
+ :return:
1048
+ True/False
1049
+ """
1050
+
1051
+ point_mask = X[:, SEGMENT_ID_COLUMN] == segment_id
1052
+
1053
+ # can be optimized by changing the norm
1054
+ return (
1055
+ np.min(
1056
+ np.linalg.norm(X[point_mask, :3] - point.reshape(1, 3), ord=2, axis=1)
1057
+ )
1058
+ <= self.distance_3D_threshold
1059
+ )
1060
+
1061
+ def compute_distance_orthogonal(self, candidate_point, plane_point, plane_normal):
1062
+ """
1063
+ Compute the orthogonal distance between the candidate point and the segment represented by its plane.
1064
+
1065
+ :param candidate_point:
1066
+ numpy array (3, 1) candidate point during segmentation process.
1067
+ :param plane_point:
1068
+ numpy array (3, 1) representing a point (most likely, a core point), part of the segment.
1069
+ :param plane_normal:
1070
+ numpy array (3, 1)
1071
+ :return:
1072
+ True/False
1073
+ """
1074
+
1075
+ d = -plane_point.dot(plane_normal)
1076
+ distance = (plane_normal.dot(candidate_point) + d) / np.linalg.norm(
1077
+ plane_normal
1078
+ )
1079
+
1080
+ return distance
1081
+
1082
+ def distance_orthogonal_check(self, candidate_point, plane_point, plane_normal):
1083
+ """
1084
+ Check whether the orthogonal distance between the candidate point and the segment represented by its plane
1085
+ is less than the 'distance_orthogonal_threshold'.
1086
+
1087
+ :param candidate_point:
1088
+ numpy array (3, 1) candidate point during segmentation process.
1089
+ :param plane_point:
1090
+ numpy array (3, 1) representing a point (most likely, a core point), part of the segment.
1091
+ :param plane_normal:
1092
+ numpy array (3, 1)
1093
+ :return:
1094
+ True/False
1095
+ """
1096
+
1097
+ distance = self.compute_distance_orthogonal(
1098
+ candidate_point, plane_point, plane_normal
1099
+ )
1100
+ return distance - self.distance_orthogonal_threshold <= 0
1101
+
1102
+ def lowest_local_surface_variance_check(self, llsv):
1103
+ """
1104
+ Check lowest local surface variance threshold.
1105
+
1106
+ :param llsv:
1107
+ lowest local surface variance
1108
+ :return:
1109
+ True/False
1110
+ """
1111
+
1112
+ return llsv <= self.llsv_threshold
1113
+
1114
+ def _fit(self, X, y=None):
1115
+ """
1116
+
1117
+ :param X:
1118
+ :param y:
1119
+ :return:
1120
+ """
1121
+
1122
+ return self
1123
+ pass
1124
+
1125
+ def _transform(self, X):
1126
+ """
1127
+ It applies the segmentation process.
1128
+
1129
+ :param X:
1130
+ Rows of points from Epoch0 and Epoch1
1131
+ X = (
1132
+ X0, Rows of points from Epoch 0
1133
+ X1 Rows of points from Epoch 1 <- OPTIONAL
1134
+ )
1135
+ It is assumed that rows for Epoch0 and Epoch1 are not interleaved!
1136
+
1137
+ numpy array (n_points, 17) with the following column structure:
1138
+ [
1139
+ x, y, z ( 3 column ),
1140
+ EpochID ( 1 column ),
1141
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
1142
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
1143
+ Lowest local surface variation ( 1 column )
1144
+ ]
1145
+ :return:
1146
+ numpy array (n_points, 19) with the following column structure:
1147
+ [
1148
+ x, y, z ( 3 columns ),
1149
+ EpochID ( 1 column ),
1150
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
1151
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
1152
+ Lowest local surface variation ( 1 column ),
1153
+ Segment_ID ( 1 column ),
1154
+ Standard deviation ( 1 column )
1155
+ ]
1156
+ """
1157
+
1158
+ X_Y_Z_Columns = [
1159
+ self.columns.X_COLUMN,
1160
+ self.columns.Y_COLUMN,
1161
+ self.columns.Z_COLUMN,
1162
+ ]
1163
+
1164
+ Normal_Columns = [
1165
+ self.columns.EIGENVECTOR_2_X_COLUMN,
1166
+ self.columns.EIGENVECTOR_2_Y_COLUMN,
1167
+ self.columns.EIGENVECTOR_2_Z_COLUMN,
1168
+ ]
1169
+
1170
+ # the new columns are added only if they weren't already been added previously
1171
+ if not self.with_previously_computed_segments:
1172
+ new_column_segment_id = np.full(
1173
+ (X.shape[0], 1), DEFAULT_NO_SEGMENT, dtype=float
1174
+ )
1175
+ X = np.hstack((X, new_column_segment_id))
1176
+
1177
+ new_column_std_deviation = np.full(
1178
+ (X.shape[0], 1), DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT, dtype=float
1179
+ )
1180
+ X = np.hstack((X, new_column_std_deviation))
1181
+
1182
+ mask_epoch0 = X[:, self.columns.EPOCH_ID_COLUMN] == 0
1183
+ mask_epoch1 = X[:, self.columns.EPOCH_ID_COLUMN] == 1
1184
+
1185
+ assert (
1186
+ mask_epoch0.shape[0] > 0
1187
+ ), "The input X must contain at least elements from Epoch 0, e.g. EPOCH_ID_COLUMN==0"
1188
+
1189
+ logger.debug(
1190
+ f"'X' contains {np.count_nonzero(mask_epoch0)} elements from Epoch 0 "
1191
+ f"and {np.count_nonzero(mask_epoch1)} from Epoch 1"
1192
+ )
1193
+
1194
+ epoch0_set = X[mask_epoch0][:, X_Y_Z_Columns]
1195
+ epoch1_set = X[mask_epoch1][:, X_Y_Z_Columns]
1196
+
1197
+ _epoch = [
1198
+ Epoch(epoch_set)
1199
+ for epoch_set in [epoch0_set, epoch1_set]
1200
+ if epoch_set.shape[0] > 0
1201
+ ]
1202
+
1203
+ for current_epoch in _epoch:
1204
+ current_epoch.build_kdtree()
1205
+
1206
+ # sort by the "Lowest local surface variation"
1207
+ sort_indx_epoch0 = X[mask_epoch0, self.columns.LLSV_COLUMN].argsort()
1208
+ sort_indx_epoch1 = X[mask_epoch1, self.columns.LLSV_COLUMN].argsort()
1209
+ sort_indx_epoch = [sort_indx_epoch0, sort_indx_epoch1]
1210
+
1211
+ # it is assumed that the rows for Epoch0 and rows for Epoch1 are not interleaved!
1212
+ offset_in_X = [0, sort_indx_epoch0.shape[0]]
1213
+
1214
+ # initialization required between multiple Segmentations
1215
+ seg_id = np.max(X[:, self.columns.SEGMENT_ID_COLUMN])
1216
+
1217
+ for epoch_id in range(len(_epoch)):
1218
+ for indx_row in sort_indx_epoch[epoch_id] + offset_in_X[epoch_id]:
1219
+ # no part of a segment yet
1220
+ if X[indx_row, self.columns.SEGMENT_ID_COLUMN] < 0:
1221
+ seg_id += 1
1222
+ X[indx_row, self.columns.SEGMENT_ID_COLUMN] = seg_id
1223
+
1224
+ cumulative_distance_for_std_deviation = 0
1225
+ nr_points_for_std_deviation = 0
1226
+
1227
+ indx_kd_tree_list = _epoch[epoch_id].kdtree.radius_search(
1228
+ X[indx_row, X_Y_Z_Columns], self.radius
1229
+ )[: self.max_nr_points_neighborhood]
1230
+ for indx_kd_tree in indx_kd_tree_list:
1231
+ if (
1232
+ X[
1233
+ indx_kd_tree + offset_in_X[epoch_id],
1234
+ self.columns.SEGMENT_ID_COLUMN,
1235
+ ]
1236
+ < 0
1237
+ and self.angle_difference_check(
1238
+ X[indx_row, Normal_Columns],
1239
+ X[indx_kd_tree + offset_in_X[epoch_id], Normal_Columns],
1240
+ )
1241
+ and self.distance_3D_set_check(
1242
+ X[indx_kd_tree + offset_in_X[epoch_id], X_Y_Z_Columns],
1243
+ seg_id,
1244
+ X,
1245
+ X_Y_Z_Columns,
1246
+ self.columns.SEGMENT_ID_COLUMN,
1247
+ )
1248
+ and self.distance_orthogonal_check(
1249
+ X[indx_kd_tree + offset_in_X[epoch_id], X_Y_Z_Columns],
1250
+ X[indx_row, X_Y_Z_Columns],
1251
+ X[indx_row, Normal_Columns],
1252
+ )
1253
+ and self.lowest_local_surface_variance_check(
1254
+ X[
1255
+ indx_kd_tree + offset_in_X[epoch_id],
1256
+ self.columns.LLSV_COLUMN,
1257
+ ]
1258
+ )
1259
+ ):
1260
+ X[
1261
+ indx_kd_tree + offset_in_X[epoch_id],
1262
+ self.columns.SEGMENT_ID_COLUMN,
1263
+ ] = seg_id
1264
+ cumulative_distance_for_std_deviation += (
1265
+ self.compute_distance_orthogonal(
1266
+ X[
1267
+ indx_kd_tree + offset_in_X[epoch_id],
1268
+ X_Y_Z_Columns,
1269
+ ],
1270
+ X[indx_row, X_Y_Z_Columns],
1271
+ X[indx_row, Normal_Columns],
1272
+ )
1273
+ ** 2
1274
+ )
1275
+ nr_points_for_std_deviation += 1
1276
+
1277
+ nr_points_segment = np.count_nonzero(
1278
+ X[:, self.columns.SEGMENT_ID_COLUMN] == seg_id
1279
+ )
1280
+
1281
+ # not enough points or 'roughness_threshold' exceeded
1282
+ if (
1283
+ nr_points_segment < self.min_nr_points_per_segment
1284
+ or cumulative_distance_for_std_deviation
1285
+ / nr_points_for_std_deviation
1286
+ >= self.roughness_threshold
1287
+ ):
1288
+ mask_seg_id = X[:, self.columns.SEGMENT_ID_COLUMN] == seg_id
1289
+ X[mask_seg_id, self.columns.SEGMENT_ID_COLUMN] = (
1290
+ DEFAULT_NO_SEGMENT
1291
+ )
1292
+ # since we don't have a new segment
1293
+ seg_id -= 1
1294
+ else:
1295
+ X[indx_row, self.columns.STANDARD_DEVIATION_COLUMN] = (
1296
+ cumulative_distance_for_std_deviation
1297
+ / nr_points_for_std_deviation
1298
+ )
1299
+ return X
1300
+
1301
+
1302
+ class PostPointCloudSegmentation(BaseTransformer):
1303
+ def __init__(
1304
+ self,
1305
+ skip=False,
1306
+ compute_normal=True,
1307
+ output_file_name=None,
1308
+ columns=SEGMENTED_POINT_CLOUD_COLUMNS,
1309
+ ):
1310
+ """
1311
+ :param skip:
1312
+ Whether the current transform is applied or not.
1313
+ :param compute_normal:
1314
+
1315
+ :param output_file_name:
1316
+ File where the result of the 'Transform()' method, a numpy array, is dumped.
1317
+ :param columns:
1318
+
1319
+ """
1320
+
1321
+ super().__init__(skip=skip, output_file_name=output_file_name, columns=columns)
1322
+ self.compute_normal = compute_normal
1323
+
1324
+ def compute_distance_orthogonal(self, candidate_point, plane_point, plane_normal):
1325
+ """
1326
+ Compute the orthogonal distance between the candidate point and the segment represented by its plane.
1327
+
1328
+ :param candidate_point:
1329
+ numpy array (3, 1) candidate point during segmentation process.
1330
+ :param plane_point:
1331
+ numpy array (3, 1) representing a point (most likely, a core point), part of the segment.
1332
+ :param plane_normal:
1333
+ numpy array (3, 1)
1334
+ :return:
1335
+ True/False
1336
+ """
1337
+
1338
+ d = -plane_point.dot(plane_normal.T)
1339
+ distance = (plane_normal.dot(candidate_point) + d) / np.linalg.norm(
1340
+ plane_normal
1341
+ )
1342
+
1343
+ return distance
1344
+
1345
+ def pca_compute_normal_and_mean(self, X):
1346
+ """
1347
+ Perform PCA.
1348
+ The order of the eigenvalues and eigenvectors is consistent.
1349
+
1350
+ :param X:
1351
+ numpy array of shape (n_points, 3) with [x, y, z] columns.
1352
+ :return:
1353
+ a tuple of:
1354
+ Eig. values as numpy array of shape (1, 3)
1355
+ Eig. vector0 as numpy array of shape (1, 3)
1356
+ Eig. vector1 as numpy array of shape (1, 3)
1357
+ Eig. vector2 (normal vector) as numpy array of shape (1, 3)
1358
+ Position of the normal vector as numpy array of shape (1, 3),
1359
+ approximated as the mean of the input points.
1360
+ """
1361
+
1362
+ size = X.shape[0]
1363
+
1364
+ # compute mean
1365
+ X_avg = np.mean(X, axis=0)
1366
+ B = X - np.tile(X_avg, (size, 1))
1367
+
1368
+ # Find principal components (SVD)
1369
+ U, S, VT = np.linalg.svd(B.T / np.sqrt(size), full_matrices=0)
1370
+
1371
+ assert S[0] != 0, "eig. value should not be 0!"
1372
+ assert S[1] != 0, "eig. value should not be 0!"
1373
+ assert S[2] != 0, "eig. value should not be 0!"
1374
+
1375
+ # Eig. values,
1376
+ # Eig. Vector0, Eig. Vector1, Eig. Vector2( the norma vector),
1377
+ # 'position' of the normal vector
1378
+ return (
1379
+ S.reshape(1, -1),
1380
+ U[:, 0].reshape(1, -1),
1381
+ U[:, 1].reshape(1, -1),
1382
+ U[:, 2].reshape(1, -1),
1383
+ X_avg.reshape(1, -1),
1384
+ )
1385
+
1386
+ def _fit(self, X, y=None):
1387
+ """
1388
+ :param X:
1389
+ :param y:
1390
+ :return:
1391
+ """
1392
+
1393
+ return self
1394
+ pass
1395
+
1396
+ def _transform(self, X):
1397
+ """
1398
+
1399
+ :param X:
1400
+ :return:
1401
+ """
1402
+
1403
+ X_Y_Z_Columns = [
1404
+ self.columns.X_COLUMN,
1405
+ self.columns.Y_COLUMN,
1406
+ self.columns.Z_COLUMN,
1407
+ ]
1408
+
1409
+ Eigval = [
1410
+ self.columns.EIGENVALUE0_COLUMN,
1411
+ self.columns.EIGENVALUE1_COLUMN,
1412
+ self.columns.EIGENVALUE2_COLUMN,
1413
+ ]
1414
+
1415
+ Eigvec0 = [
1416
+ self.columns.EIGENVECTOR_0_X_COLUMN,
1417
+ self.columns.EIGENVECTOR_0_Y_COLUMN,
1418
+ self.columns.EIGENVECTOR_0_Z_COLUMN,
1419
+ ]
1420
+
1421
+ Eigvec1 = [
1422
+ self.columns.EIGENVECTOR_1_X_COLUMN,
1423
+ self.columns.EIGENVECTOR_1_Y_COLUMN,
1424
+ self.columns.EIGENVECTOR_1_Z_COLUMN,
1425
+ ]
1426
+
1427
+ Normal_Columns = [
1428
+ self.columns.EIGENVECTOR_2_X_COLUMN,
1429
+ self.columns.EIGENVECTOR_2_Y_COLUMN,
1430
+ self.columns.EIGENVECTOR_2_Z_COLUMN,
1431
+ ]
1432
+
1433
+ highest_segment_id_used = int(X[:, self.columns.SEGMENT_ID_COLUMN].max())
1434
+
1435
+ for i in range(0, highest_segment_id_used + 1):
1436
+ mask = X[:, self.columns.SEGMENT_ID_COLUMN] == float(i)
1437
+ # extract all points, that are part of the same segment
1438
+ set_cloud = X[mask, :][:, X_Y_Z_Columns]
1439
+
1440
+ eig_values, e0, e1, normal, position = self.pca_compute_normal_and_mean(
1441
+ set_cloud
1442
+ )
1443
+
1444
+ indexes = np.where(mask == True)[0]
1445
+
1446
+ # compute the closest point from the current segment to calculated "position"
1447
+ indx_min_in_indexes = np.linalg.norm(
1448
+ x=set_cloud - position, axis=1
1449
+ ).argmin()
1450
+ indx_min_in_X = indexes[indx_min_in_indexes]
1451
+
1452
+ X[indx_min_in_X, Eigval] = eig_values
1453
+ X[indx_min_in_X, Eigvec0] = e0
1454
+ X[indx_min_in_X, Eigvec1] = e1
1455
+
1456
+ if self.compute_normal:
1457
+ X[indx_min_in_X, Normal_Columns] = normal
1458
+
1459
+ cumulative_distance_for_std_deviation = 0
1460
+ nr_points_for_std_deviation = indexes.shape[0] - 1
1461
+ for indx in indexes:
1462
+ cumulative_distance_for_std_deviation += (
1463
+ self.compute_distance_orthogonal(
1464
+ X[indx, X_Y_Z_Columns],
1465
+ X[indx_min_in_X, X_Y_Z_Columns],
1466
+ normal.reshape(1, -1),
1467
+ )
1468
+ ** 2
1469
+ )
1470
+
1471
+ X[indx_min_in_X, self.columns.STANDARD_DEVIATION_COLUMN] = (
1472
+ cumulative_distance_for_std_deviation / nr_points_for_std_deviation
1473
+ )
1474
+
1475
+ return X
1476
+
1477
+
1478
+ class ExtractSegments(BaseTransformer):
1479
+ def __init__(self, skip=False, output_file_name=None, columns=SEGMENT_COLUMNS):
1480
+ """
1481
+
1482
+ :param skip:
1483
+ Whether the current transform is applied or not.
1484
+ :param output_file_name:
1485
+ File where the result of the 'Transform()' method, a numpy array, is dumped.
1486
+ """
1487
+
1488
+ super(ExtractSegments, self).__init__(
1489
+ skip=skip, output_file_name=output_file_name, columns=columns
1490
+ )
1491
+
1492
+ def _fit(self, X, y=None):
1493
+ """
1494
+
1495
+ :param X:
1496
+ :param y:
1497
+ :return:
1498
+ """
1499
+
1500
+ return self
1501
+ pass
1502
+
1503
+ def _transform(self, X):
1504
+ """
1505
+ Transform the numpy array of 'point cloud' to numpy array of 'segments' and extend the structure by adding
1506
+ a new column containing the 'number of points found in Segment_ID'. During this process, only one point
1507
+ that is part of a segment ( the core point ) is maintained, while all the others are discarded.
1508
+
1509
+ :param X:
1510
+ numpy array (n_points, 19) with the following column structure:
1511
+ [
1512
+ x, y, z ( 3 columns ), -> point from cloud
1513
+ EpochID ( 1 column ),
1514
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
1515
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
1516
+ Lowest local surface variation ( 1 column ),
1517
+ Segment_ID ( 1 column ),
1518
+ Standard deviation ( 1 column )
1519
+ ]
1520
+ :return:
1521
+ numpy array (n_segments, 20) with the following column structure:
1522
+ [
1523
+ x, y, z ( 3 columns ), -> segment, core point
1524
+ EpochID ( 1 column ),
1525
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
1526
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
1527
+ Lowest local surface variation ( 1 column ),
1528
+ Segment_ID ( 1 column ),
1529
+ Standard deviation ( 1 column ),
1530
+ Number of points found in Segment_ID segment ( 1 column )
1531
+ ]
1532
+ """
1533
+
1534
+ max_segment_id = int(X[:, self.columns.SEGMENT_ID_COLUMN].max())
1535
+ X_Segments = np.empty(
1536
+ (int(max_segment_id) + 1, self.columns.NUMBER_OF_COLUMNS), dtype=float
1537
+ )
1538
+
1539
+ for i in range(0, max_segment_id + 1):
1540
+ mask = X[:, self.columns.SEGMENT_ID_COLUMN] == float(i)
1541
+ set_cloud = X[mask, :] # all
1542
+ nr_points = set_cloud.shape[0]
1543
+
1544
+ # arg_min = set_cloud[:, LLSV_COLUMN].argmin()
1545
+ # X_Segments[i, :-1] = set_cloud[arg_min, :]
1546
+
1547
+ # find the CoM point, e.g. the point which has STD != DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT
1548
+ mask_std = (
1549
+ set_cloud[:, self.columns.STANDARD_DEVIATION_COLUMN]
1550
+ != DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT
1551
+ )
1552
+ set_cloud_std = set_cloud[mask_std, :]
1553
+ assert (
1554
+ set_cloud_std.shape[0] == 1
1555
+ ), "Only one element within a segment should have the standard deviation computed!"
1556
+ X_Segments[i, :-1] = set_cloud_std[0, :]
1557
+
1558
+ X_Segments[i, self.columns.NR_POINTS_PER_SEG_COLUMN] = nr_points
1559
+
1560
+ return X_Segments
1561
+
1562
+
1563
+ class BuilderExtended_y(ABC):
1564
+ def __init__(self, columns):
1565
+ super().__init__()
1566
+ self.columns = columns
1567
+
1568
+ @abstractmethod
1569
+ def generate_extended_y(self, X):
1570
+ """
1571
+ Generates tuples of ( segment index epoch 0, segment index epoch 1, 0/1 label )
1572
+
1573
+ :param X:
1574
+ numpy array of shape (n_segments, segment_features_size) containing all the segments for both,
1575
+ epoch 0 and epoch 1. Each row is a segment.
1576
+ :return:
1577
+ numpy array with shape (n_segments, 3)
1578
+ """
1579
+ pass
1580
+
1581
+
1582
+ class BuilderExtended_y_Visually(BuilderExtended_y):
1583
+ def __init__(self, columns=SEGMENT_COLUMNS):
1584
+ super(BuilderExtended_y_Visually, self).__init__(columns=columns)
1585
+
1586
+ self.current_pair = [None] * 2
1587
+ self.constructed_extended_y = np.empty(shape=(0, 3))
1588
+
1589
+ def toggle_transparenct(self, evt):
1590
+ if evt.keyPressed == "z":
1591
+ # transparency toggle
1592
+ for segment in self.sets:
1593
+ if segment.alpha() < 1.0:
1594
+ segment.alpha(1)
1595
+ else:
1596
+ segment.alpha(0.5)
1597
+ self.plt.render()
1598
+
1599
+ if evt.keyPressed == "g":
1600
+ # toggle red
1601
+ for segment in self.sets:
1602
+ if segment.epoch == 0:
1603
+ if segment.isOn == True:
1604
+ segment.off()
1605
+ else:
1606
+ segment.on()
1607
+ segment.isOn = not segment.isOn
1608
+ self.plt.render()
1609
+
1610
+ if evt.keyPressed == "d":
1611
+ # toggle green
1612
+ for segment in self.sets:
1613
+ if segment.epoch == 1:
1614
+ if segment.isOn == True:
1615
+ segment.off()
1616
+ else:
1617
+ segment.on()
1618
+ segment.isOn = not segment.isOn
1619
+ self.plt.render()
1620
+
1621
+ def controller(self, evt):
1622
+ """
1623
+
1624
+ :param evt:
1625
+ :return:
1626
+ """
1627
+ if not evt.actor:
1628
+ # no hit, return
1629
+ return
1630
+ logger.debug("point coords =%s", str(evt.picked3d), exc_info=1)
1631
+ if evt.isPoints:
1632
+ logger.debug("evt.actor = s", str(evt.actor))
1633
+ self.current_pair[int(evt.actor.epoch)] = evt.actor.id
1634
+
1635
+ if self.current_pair[0] != None and self.current_pair[1] != None:
1636
+ # we have a pair
1637
+ self.add_pair_button.status(self.add_pair_button.states[1])
1638
+ else:
1639
+ # we don't
1640
+ self.add_pair_button.status(self.add_pair_button.states[0])
1641
+
1642
+ def event_add_pair_button(self):
1643
+ """
1644
+
1645
+ :return:
1646
+ """
1647
+
1648
+ if self.current_pair[0] != None and self.current_pair[1] != None:
1649
+ try:
1650
+ self.constructed_extended_y = np.vstack(
1651
+ (
1652
+ self.constructed_extended_y,
1653
+ np.array(
1654
+ [
1655
+ self.current_pair[0],
1656
+ self.current_pair[1],
1657
+ int(self.label.status()),
1658
+ ]
1659
+ ),
1660
+ )
1661
+ )
1662
+
1663
+ self.current_pair[0] = None
1664
+ self.current_pair[1] = None
1665
+ self.label.status(self.label.states[0]) # firs state "None"
1666
+
1667
+ self.add_pair_button.switch()
1668
+ except:
1669
+ logger.error("You must select 0 or 1 as label")
1670
+
1671
+ def segments_visualizer(self, X):
1672
+ """
1673
+
1674
+ :param X:
1675
+ :return:
1676
+ """
1677
+
1678
+ self.sets = []
1679
+
1680
+ nr_segments = X.shape[0]
1681
+ colors = [(1, 0, 0), (0, 1, 0)]
1682
+ self.plt = Plotter(axes=3)
1683
+
1684
+ self.plt.add_callback("EndInteraction", self.controller)
1685
+ self.plt.add_callback("KeyPress", self.toggle_transparenct)
1686
+
1687
+ for i in range(0, nr_segments):
1688
+ # mask = X[:, 17] == float(i)
1689
+ # set_cloud = X[mask, :3] # x,y,z
1690
+
1691
+ if X[i, self.columns.EPOCH_ID_COLUMN] == 0:
1692
+ color = colors[0]
1693
+ else:
1694
+ color = colors[1]
1695
+
1696
+ # self.sets = self.sets + [Points(set_cloud, colors[i], alpha=1, r=10)]
1697
+
1698
+ # self.sets = self.sets + [ Point( pos=(X[i, 0],X[i, 1],X[i, 2]), r=15, c=colors[i], alpha=1 ) ]
1699
+ ellipsoid = Ellipsoid(
1700
+ pos=(X[i, 0], X[i, 1], X[i, 2]),
1701
+ axis1=[
1702
+ X[i, self.columns.EIGENVECTOR_0_X_COLUMN]
1703
+ * X[i, self.columns.EIGENVALUE0_COLUMN]
1704
+ * 0.5,
1705
+ X[i, self.columns.EIGENVECTOR_0_Y_COLUMN]
1706
+ * X[i, self.columns.EIGENVALUE0_COLUMN]
1707
+ * 0.5,
1708
+ X[i, self.columns.EIGENVECTOR_0_Z_COLUMN]
1709
+ * X[i, self.columns.EIGENVALUE0_COLUMN]
1710
+ * 0.3,
1711
+ ],
1712
+ axis2=[
1713
+ X[i, self.columns.EIGENVECTOR_1_X_COLUMN]
1714
+ * X[i, self.columns.EIGENVALUE1_COLUMN]
1715
+ * 0.5,
1716
+ X[i, self.columns.EIGENVECTOR_1_Y_COLUMN]
1717
+ * X[i, self.columns.EIGENVALUE1_COLUMN]
1718
+ * 0.5,
1719
+ X[i, self.columns.EIGENVECTOR_1_Z_COLUMN]
1720
+ * X[i, self.columns.EIGENVALUE1_COLUMN]
1721
+ * 0.5,
1722
+ ],
1723
+ axis3=[
1724
+ X[i, self.columns.EIGENVECTOR_2_X_COLUMN] * 0.1,
1725
+ X[i, self.columns.EIGENVECTOR_2_Y_COLUMN] * 0.1,
1726
+ X[i, self.columns.EIGENVECTOR_2_Z_COLUMN] * 0.1,
1727
+ ],
1728
+ res=24,
1729
+ c=color,
1730
+ alpha=1,
1731
+ )
1732
+
1733
+ ellipsoid.id = X[i, self.columns.SEGMENT_ID_COLUMN]
1734
+ ellipsoid.epoch = X[i, self.columns.EPOCH_ID_COLUMN]
1735
+ ellipsoid.isOn = True
1736
+ self.sets = self.sets + [ellipsoid]
1737
+
1738
+ self.label = self.plt.add_button(
1739
+ lambda: self.label.switch(),
1740
+ states=["Label (0/1)", "0", "1"], # None
1741
+ c=["w", "w", "w"],
1742
+ bc=["bb", "lr", "lg"],
1743
+ pos=(0.90, 0.25),
1744
+ size=24,
1745
+ )
1746
+
1747
+ self.add_pair_button = self.plt.add_button(
1748
+ self.event_add_pair_button,
1749
+ states=["Select pair", "Add pair"],
1750
+ c=["w", "w"],
1751
+ bc=["lg", "lr"],
1752
+ pos=(0.90, 0.15),
1753
+ size=24,
1754
+ )
1755
+
1756
+ self.plt.show(
1757
+ self.sets,
1758
+ Text2D(
1759
+ "Select multiple pairs of red-green Ellipsoids with their corresponding labels (0/1) "
1760
+ "and then press 'Select pair'\n"
1761
+ "'z' - toggle transparency on/off 'g' - toggle on/off red ellipsoids 'd' toggle on/off red ellipsoids",
1762
+ pos="top-left",
1763
+ bg="k",
1764
+ s=0.7,
1765
+ ),
1766
+ ).close()
1767
+ return self.constructed_extended_y
1768
+
1769
+ def generate_extended_y(self, X):
1770
+ """
1771
+ :param X:
1772
+ :return:
1773
+ """
1774
+
1775
+ return self.segments_visualizer(X)
1776
+
1777
+
1778
+ class ClassifierWrapper(ClassifierMixin, BaseEstimator):
1779
+ def __init__(
1780
+ self,
1781
+ neighborhood_search_radius=3,
1782
+ threshold_probability_most_similar=0.8,
1783
+ diff_between_most_similar_2=0.1,
1784
+ classifier=RandomForestClassifier(),
1785
+ columns=SEGMENT_COLUMNS,
1786
+ ):
1787
+ """
1788
+ :param neighborhood_search_radius:
1789
+ Maximum accepted Euclidean distance for any candidate segments.
1790
+ :param threshold_probability_most_similar:
1791
+ Lower bound probability threshold for the most similar ( candidate ) plane.
1792
+ :param diff_between_most_similar_2:
1793
+ Lower bound threshold of difference between first 2, most similar planes.
1794
+ :param classifier:
1795
+ The classifier used, default is RandomForestClassifier. ( sk-learn )
1796
+ :param columns:
1797
+ Column mapping used by seg_epoch0 and seg_epoch0
1798
+ """
1799
+
1800
+ super().__init__()
1801
+
1802
+ self.neighborhood_search_radius = neighborhood_search_radius
1803
+ self.threshold_probability_most_similar = threshold_probability_most_similar
1804
+ self.diff_between_most_similar_2 = diff_between_most_similar_2
1805
+ self.classifier = classifier
1806
+ self.columns = columns
1807
+
1808
+ def compute_similarity_between(
1809
+ self, seg_epoch0: np.ndarray, seg_epoch1: np.ndarray
1810
+ ) -> np.ndarray:
1811
+ """
1812
+ Similarity function between 2 segments.
1813
+
1814
+ :param seg_epoch0:
1815
+ segment from epoch0, numpy array (1, 20) with the following column structure:
1816
+ [
1817
+ x, y, z ( 3 columns ), -> segment, core point
1818
+ EpochID ( 1 column ),
1819
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
1820
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
1821
+ Lowest local surface variation ( 1 column ),
1822
+ Segment_ID ( 1 column ),
1823
+ Standard deviation ( 1 column ),
1824
+ Number of points found in Segment_ID segment ( 1 column )
1825
+ ]
1826
+ :param seg_epoch1:
1827
+ segment from epoch1, same structure as 'seg_epoch0'
1828
+ :return:
1829
+ numpy array of shape (6,) containing:
1830
+ angle, -> angle between plane normal vectors
1831
+ points_density_diff, -> difference between points density between pairs of segments
1832
+ eigen_value_smallest_diff, -> difference in the quality of plane fit (smallest eigenvalue)
1833
+ eigen_value_largest_diff, -> difference in plane extension (largest eigenvalue)
1834
+ eigen_value_middle_diff, -> difference in orthogonal plane extension (middle eigenvalue)
1835
+ nr_points_diff, -> difference in number of points per plane
1836
+ """
1837
+
1838
+ Normal_Columns = [
1839
+ self.columns.EIGENVECTOR_2_X_COLUMN,
1840
+ self.columns.EIGENVECTOR_2_Y_COLUMN,
1841
+ self.columns.EIGENVECTOR_2_Z_COLUMN,
1842
+ ]
1843
+
1844
+ angle = angle_difference_compute(
1845
+ seg_epoch0[Normal_Columns], seg_epoch1[Normal_Columns]
1846
+ )
1847
+
1848
+ points_density_seg_epoch0 = seg_epoch0[
1849
+ self.columns.NR_POINTS_PER_SEG_COLUMN
1850
+ ] / (
1851
+ seg_epoch0[self.columns.EIGENVALUE0_COLUMN]
1852
+ * seg_epoch0[self.columns.EIGENVALUE1_COLUMN]
1853
+ )
1854
+
1855
+ points_density_seg_epoch1 = seg_epoch1[
1856
+ self.columns.NR_POINTS_PER_SEG_COLUMN
1857
+ ] / (
1858
+ seg_epoch1[self.columns.EIGENVALUE0_COLUMN]
1859
+ * seg_epoch1[self.columns.EIGENVALUE1_COLUMN]
1860
+ )
1861
+
1862
+ points_density_diff = abs(points_density_seg_epoch0 - points_density_seg_epoch1)
1863
+
1864
+ eigen_value_smallest_diff = abs(
1865
+ seg_epoch0[self.columns.EIGENVALUE2_COLUMN]
1866
+ - seg_epoch1[self.columns.EIGENVALUE2_COLUMN]
1867
+ )
1868
+ eigen_value_largest_diff = abs(
1869
+ seg_epoch0[self.columns.EIGENVALUE0_COLUMN]
1870
+ - seg_epoch1[self.columns.EIGENVALUE0_COLUMN]
1871
+ )
1872
+ eigen_value_middle_diff = abs(
1873
+ seg_epoch0[self.columns.EIGENVALUE1_COLUMN]
1874
+ - seg_epoch1[self.columns.EIGENVALUE1_COLUMN]
1875
+ )
1876
+
1877
+ nr_points_diff = abs(
1878
+ seg_epoch0[self.columns.NR_POINTS_PER_SEG_COLUMN]
1879
+ - seg_epoch1[self.columns.NR_POINTS_PER_SEG_COLUMN]
1880
+ )
1881
+
1882
+ return np.array(
1883
+ [
1884
+ angle,
1885
+ points_density_diff,
1886
+ eigen_value_smallest_diff,
1887
+ eigen_value_largest_diff,
1888
+ eigen_value_middle_diff,
1889
+ nr_points_diff,
1890
+ ]
1891
+ )
1892
+
1893
+ def _build_X_similarity(self, y_row, X):
1894
+ """
1895
+
1896
+ :param y_row:
1897
+ numpy array of ( segment epoch0 id, segment epoch1 id, label(0/1) )
1898
+ :param X:
1899
+ numpy array of shape (n_segments, segment_features_size) containing all the segments for both,
1900
+ epoch 0 and epoch 1. Each row is a segment.
1901
+ :return:
1902
+ numpy array containing the similarity value between 2 segments.
1903
+ """
1904
+
1905
+ seg_epoch0 = X[int(y_row[0]), :]
1906
+ seg_epoch1 = X[int(y_row[1]), :]
1907
+
1908
+ return self.compute_similarity_between(seg_epoch0, seg_epoch1)
1909
+
1910
+ def fit(self, X, y):
1911
+ """
1912
+ This method takes care of the learning process by training the chosen 'classifier', using labeled data.
1913
+
1914
+ :param X:
1915
+ numpy array (n_segments, segment_size)
1916
+ :param y:
1917
+ numpy array of shape (m_extended_y, 3) where 'extended y' has the following structure:
1918
+ ( tuples of index segment from epoch0, index segment from epoch1, label(0/1) )
1919
+ """
1920
+
1921
+ X_similarity = np.apply_along_axis(
1922
+ lambda y_row: self._build_X_similarity(y_row, X), 1, y
1923
+ )
1924
+
1925
+ # Check that X and y have correct shape
1926
+ # X_similarity, y = check_X_y(X_similarity, y, multi_output=True)
1927
+
1928
+ # Store the classes seen during fit
1929
+ # self.classes_ = unique_labels(y[:, 2])
1930
+
1931
+ # self.X_ = X_similarity
1932
+ # self.y_ = y[:, 2]
1933
+
1934
+ logger.info(f"Fit ClassifierWrapper")
1935
+
1936
+ # Return the classifier
1937
+ return self.classifier.fit(X_similarity, y[:, 2])
1938
+
1939
+ def predict(self, X):
1940
+ """
1941
+ For a set of segments from epoch 0 and epoch 1 it computes which one corresponds.
1942
+
1943
+ :param X:
1944
+ numpy array (n_segments, 20) with the following column structure:
1945
+ [
1946
+ x, y, z ( 3 columns ), -> segment, core point
1947
+ EpochID ( 1 column ),
1948
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
1949
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
1950
+ Lowest local surface variation ( 1 column ),
1951
+ Segment_ID ( 1 column ),
1952
+ Standard deviation ( 1 column ),
1953
+ Number of points found in Segment_ID segment ( 1 column )
1954
+ ]
1955
+
1956
+ :return:
1957
+ numpy array where each row contains a pair of segments:
1958
+ [segment epoch 0, segment epoch 1]
1959
+ """
1960
+
1961
+ # Check if fit had been called
1962
+ # check_is_fitted(self, ["X_", "y_"])
1963
+
1964
+ # Input validation
1965
+ # X = check_array(X)
1966
+
1967
+ mask_epoch0 = X[:, self.columns.EPOCH_ID_COLUMN] == 0
1968
+ mask_epoch1 = X[:, self.columns.EPOCH_ID_COLUMN] == 1
1969
+
1970
+ epoch0_set = X[mask_epoch0, :] # all
1971
+ epoch1_set = X[mask_epoch1, :] # all
1972
+
1973
+ self.epoch1_segments = Epoch(
1974
+ epoch1_set[
1975
+ :, [self.columns.X_COLUMN, self.columns.Y_COLUMN, self.columns.Z_COLUMN]
1976
+ ]
1977
+ )
1978
+ self.epoch1_segments.build_kdtree()
1979
+
1980
+ list_segments_pair = np.empty((0, epoch0_set.shape[1] + epoch1_set.shape[1]))
1981
+
1982
+ # this operation can be parallelized
1983
+ for epoch0_set_row in epoch0_set:
1984
+ list_candidates = self.epoch1_segments.kdtree.radius_search(
1985
+ epoch0_set_row, self.neighborhood_search_radius
1986
+ )
1987
+
1988
+ list_classified = np.array(
1989
+ [
1990
+ self.classifier.predict_proba(
1991
+ self.compute_similarity_between(
1992
+ epoch0_set_row, epoch1_set[candidate, :]
1993
+ ).reshape(1, -1)
1994
+ )[0][1]
1995
+ for candidate in list_candidates
1996
+ ]
1997
+ )
1998
+
1999
+ if len(list_classified) < 2:
2000
+ continue
2001
+
2002
+ most_similar = list_classified.argsort()[-2:]
2003
+
2004
+ if (
2005
+ most_similar[1] >= self.threshold_probability_most_similar
2006
+ and abs(most_similar[1] - most_similar[0])
2007
+ >= self.diff_between_most_similar_2
2008
+ ):
2009
+ list_segments_pair = np.vstack(
2010
+ (
2011
+ list_segments_pair,
2012
+ np.hstack(
2013
+ (epoch0_set_row, epoch1_set[most_similar[-1], :])
2014
+ ).reshape(1, -1),
2015
+ )
2016
+ )
2017
+
2018
+ return list_segments_pair
2019
+
2020
+
2021
+ class PBM3C2:
2022
+ def __init__(
2023
+ self,
2024
+ per_point_computation=PerPointComputation(),
2025
+ segmentation=Segmentation(),
2026
+ second_segmentation=Segmentation(),
2027
+ extract_segments=ExtractSegments(),
2028
+ classifier=ClassifierWrapper(),
2029
+ ):
2030
+
2031
+ logger.warning(
2032
+ f"This method is in experimental stage and undergoing active development."
2033
+ )
2034
+
2035
+ """
2036
+ :param per_point_computation:
2037
+ lowest local surface variation and PCA computation. (computes the normal vector as well)
2038
+ :param segmentation:
2039
+ The object used for the first segmentation.
2040
+ :param second_segmentation:
2041
+ The object used for the second segmentation.
2042
+ :param extract_segments:
2043
+ The object used for building the segments.
2044
+ :param classifier:
2045
+ An instance of ClassifierWrapper class. The default wrapped classifier used is sk-learn RandomForest.
2046
+ """
2047
+
2048
+ self._per_point_computation = per_point_computation
2049
+ self._segmentation = segmentation
2050
+ self._second_segmentation = second_segmentation
2051
+ self._extract_segments = extract_segments
2052
+ self._classifier = classifier
2053
+
2054
+ self._second_segmentation.set_params(with_previously_computed_segments=True)
2055
+
2056
+ def _reconstruct_input_with_normals(self, epoch, epoch_id, columns):
2057
+ """
2058
+ It is an adapter from [x, y, z, N_x, N_y, N_z, Segment_ID] column structure of input 'epoch'
2059
+ to an output equivalent with the following pipeline computation:
2060
+ ("Transform LLSV_and_PCA"), ("Transform Segmentation"), ("Transform Second Segmentation")
2061
+
2062
+ Note: When comparing distance results between this notebook and the base algorithm notebook, you might notice,
2063
+ that results do not necessarily agree even if the given segmentation information is exactly
2064
+ the same as the one computed in the base algorithm.
2065
+ This is due to the reconstruction process in this algorithm being forced to select the segment position
2066
+ (exported as the core point) from the segment points instead of reconstructing the correct position
2067
+ from the base algorithm.
2068
+
2069
+ :param epoch:
2070
+ Epoch object where each row has the following format: [x, y, z, N_x, N_y, N_z, Segment_ID]
2071
+ :param epoch_id:
2072
+ is 0 or 1 and represents one of the epochs used as part of distance computation.
2073
+ :param columns:
2074
+
2075
+ :return:
2076
+ numpy array of shape (n_points, 19) with the following column structure:
2077
+ [
2078
+ x,y,z, -> Center of the Mass
2079
+ EPOCH_ID_COLUMN, ->0/1
2080
+ Eigenvalue 1, Eigenvalue 2, Eigenvalue 3,
2081
+ Eigenvector0 (x,y,z),
2082
+ Eigenvector1 (x,y,z),
2083
+ Eigenvector2 (x,y,z), -> Normal vector
2084
+ LLSV_COLUMN, -> lowest local surface variation
2085
+ SEGMENT_ID_COLUMN,
2086
+ STANDARD_DEVIATION_COLUMN
2087
+ ]
2088
+ """
2089
+
2090
+ # x, y, z, N_x, N_y, N_z, Segment_ID
2091
+ assert epoch.shape[1] == 3 + 3 + 1, "epoch size mismatch!"
2092
+
2093
+ return np.hstack(
2094
+ (
2095
+ epoch[:, :3], # x,y,z X 3
2096
+ np.full(
2097
+ (epoch.shape[0], 1), epoch_id, dtype=float
2098
+ ), # EPOCH_ID_COLUMN X 1
2099
+ np.full((epoch.shape[0], 3), 0, dtype=float), # Eigenvalue X 3
2100
+ np.full(
2101
+ (epoch.shape[0], 6), 0, dtype=float
2102
+ ), # Eigenvector0, Eigenvector1 X 6
2103
+ epoch[:, 3:6], # Eigenvector2 X 3
2104
+ np.full((epoch.shape[0], 1), 0, dtype=float).reshape(
2105
+ -1, 1
2106
+ ), # LLSV_COLUMN
2107
+ epoch[:, -1].reshape(-1, 1), # SEGMENT_ID_COLUMN
2108
+ np.full(
2109
+ (epoch.shape[0], 1),
2110
+ DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT,
2111
+ dtype=float,
2112
+ ).reshape(
2113
+ -1, 1
2114
+ ), # STANDARD_DEVIATION_COLUMN
2115
+ )
2116
+ )
2117
+
2118
+ def _reconstruct_input_without_normals(self, epoch, epoch_id, columns):
2119
+ """
2120
+ It is an adapter from [x, y, z, Segment_ID] column structure of input 'epoch'
2121
+ to an output equivalent with the following pipeline computation:
2122
+ ("Transform LLSV_and_PCA"), ("Transform Segmentation"), ("Transform Second Segmentation")
2123
+
2124
+ Note: When comparing distance results between this notebook and the base algorithm notebook, you might notice,
2125
+ that results do not necessarily agree even if the given segmentation information is exactly
2126
+ the same as the one computed in the base algorithm.
2127
+ This is due to the reconstruction process in this algorithm being forced to select the segment position
2128
+ (exported as the core point) from the segment points instead of reconstructing the correct position
2129
+ from the base algorithm.
2130
+
2131
+ :param epoch:
2132
+ Epoch object where each row contains by: [x, y, z, Segment_ID]
2133
+ :param epoch_id:
2134
+ is 0 or 1 and represents one of the epochs used as part of distance computation.
2135
+ :param columns
2136
+
2137
+ :return:
2138
+ numpy array of shape (n_points, 19) with the following column structure:
2139
+ [
2140
+ x,y,z, -> Center of the Mass
2141
+ Epoch_ID, ->0/1
2142
+ Eigenvalue 1, Eigenvalue 2, Eigenvalue 3,
2143
+ Eigenvector0 (x,y,z),
2144
+ Eigenvector1 (x,y,z),
2145
+ Eigenvector2 (x,y,z), -> Normal vector
2146
+ LLSV, -> lowest local surface variation
2147
+ Segment_ID,
2148
+ Standard deviation
2149
+ ]
2150
+ """
2151
+
2152
+ # [x, y, z, Segment_ID] or [x, y, z, N_x, N_y, N_z, Segment_ID]
2153
+ assert (
2154
+ epoch.shape[1] == 3 + 1 or epoch.shape[1] == 3 + 3 + 1
2155
+ ), "epoch size mismatch!"
2156
+
2157
+ return np.hstack(
2158
+ (
2159
+ epoch[:, :3], # x,y,z X 3
2160
+ np.full(
2161
+ (epoch.shape[0], 1), epoch_id, dtype=float
2162
+ ), # EPOCH_ID_COLUMN X 1
2163
+ np.full((epoch.shape[0], 3), 0, dtype=float), # Eigenvalue X 3
2164
+ np.full(
2165
+ (epoch.shape[0], 6), 0, dtype=float
2166
+ ), # Eigenvector0, Eigenvector1 X 6
2167
+ np.full((epoch.shape[0], 3), 0, dtype=float), # Eigenvector2 X 3
2168
+ np.full((epoch.shape[0], 1), 0, dtype=float).reshape(
2169
+ -1, 1
2170
+ ), # LLSV_COLUMN
2171
+ epoch[:, -1].reshape(-1, 1), # SEGMENT_ID_COLUMN
2172
+ np.full(
2173
+ (epoch.shape[0], 1),
2174
+ DEFAULT_STD_DEVIATION_OF_NO_CORE_POINT,
2175
+ dtype=float,
2176
+ ).reshape(
2177
+ -1, 1
2178
+ ), # STANDARD_DEVIATION_COLUMN
2179
+ )
2180
+ )
2181
+
2182
+ @staticmethod
2183
+ def _print_default_parameters(kwargs, pipeline_param_dict):
2184
+ """
2185
+ :param kwargs:
2186
+ :param pipeline_param_dict
2187
+ """
2188
+
2189
+ # print the default parameters
2190
+ if ("get_pipeline_options", True) in kwargs.items():
2191
+ logger.info(
2192
+ f"----\n "
2193
+ f"The default parameters are:\n "
2194
+ f"{pp.pformat(pipeline_param_dict)} \n"
2195
+ f"----\n"
2196
+ )
2197
+ del kwargs["get_pipeline_options"]
2198
+
2199
+ @staticmethod
2200
+ def _overwrite_pipeline_parameters(
2201
+ kwargs, pipeline, message="The pipeline parameters after overwriting are:"
2202
+ ):
2203
+ """
2204
+ :param kwargs:
2205
+ :param pipeline:
2206
+
2207
+ :return: unused_kwargs
2208
+ The unused parameters / not found as part of 'pipeline'
2209
+ """
2210
+
2211
+ unused_kwargs = {}
2212
+
2213
+ # if we have parameters
2214
+ if len(kwargs.items()) > 0:
2215
+ pipeline_params = pipeline.get_params()
2216
+ # overwrite the default parameters
2217
+ for key, value in kwargs.items():
2218
+ if key in pipeline_params.keys():
2219
+ pipeline.set_params(**{key: value})
2220
+ logger.debug(f"The pipeline parameter '{key}' is now '{value}'")
2221
+ else:
2222
+ unused_kwargs[key] = value
2223
+ logger.info(
2224
+ f"----\n "
2225
+ f"{message} \n"
2226
+ f"{pp.pformat(pipeline.get_params())} \n"
2227
+ f"----\n"
2228
+ )
2229
+ else:
2230
+ logger.info("No pipeline parameter is overwritten")
2231
+
2232
+ return unused_kwargs
2233
+
2234
+ def generate_extended_labels_interactively(
2235
+ self,
2236
+ epoch0: typing.Union[Epoch, None] = None,
2237
+ epoch1: typing.Union[Epoch, None] = None,
2238
+ builder_extended_y: BuilderExtended_y_Visually = BuilderExtended_y_Visually(),
2239
+ **kwargs,
2240
+ ) -> typing.Union[typing.Tuple[np.ndarray, np.ndarray], None]:
2241
+ """
2242
+ Given 2 Epochs, it builds a pair of (segments and 'extended y').
2243
+
2244
+ :param epoch0:
2245
+ Epoch object.
2246
+ :param epoch1:
2247
+ Epoch object.
2248
+ :param builder_extended_y:
2249
+ The object is used for generating 'extended y', visually.
2250
+ :param kwargs:
2251
+
2252
+ Used for customize the default pipeline parameters.
2253
+
2254
+ Getting the default parameters:
2255
+ e.g. "get_pipeline_options"
2256
+ In case this parameter is True, the method will print the pipeline options as kwargs.
2257
+
2258
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
2259
+ In case of setting it, the result of computation at that step is dump as xyz file.
2260
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
2261
+
2262
+ this process is stateless
2263
+
2264
+ :return:
2265
+ tuple [Segments, 'extended y'] | None
2266
+
2267
+ where:
2268
+
2269
+ 'Segments' has the following column structure:
2270
+ X_COLUMN, Y_COLUMN, Z_COLUMN, -> Center of Gravity
2271
+ EPOCH_ID_COLUMN, -> 0/1
2272
+ EIGENVALUE0_COLUMN, EIGENVALUE1_COLUMN, EIGENVALUE2_COLUMN,
2273
+ EIGENVECTOR_0_X_COLUMN, EIGENVECTOR_0_Y_COLUMN, EIGENVECTOR_0_Z_COLUMN,
2274
+ EIGENVECTOR_1_X_COLUMN, EIGENVECTOR_1_Y_COLUMN, EIGENVECTOR_1_Z_COLUMN,
2275
+ EIGENVECTOR_2_X_COLUMN, EIGENVECTOR_2_Y_COLUMN, EIGENVECTOR_2_Z_COLUMN, -> Normal vector
2276
+ LLSV_COLUMN, -> lowest local surface variation
2277
+ SEGMENT_ID_COLUMN,
2278
+ STANDARD_DEVIATION_COLUMN,
2279
+ NR_POINTS_PER_SEG_COLUMN,
2280
+
2281
+ 'extended y' has the following structure: (tuples of index segment from epoch0, index segment from epoch1,
2282
+ label(0/1)) used for learning.
2283
+ """
2284
+
2285
+ if not interactive_available:
2286
+ logger.error("Interactive session not available in this environment.")
2287
+ return
2288
+
2289
+ labeling_pipeline = Pipeline(
2290
+ [
2291
+ ("Transform_PerPointComputation", self._per_point_computation),
2292
+ ("Transform_Segmentation", self._segmentation),
2293
+ ("Transform_Second_Segmentation", self._second_segmentation),
2294
+ ("Transform_ExtractSegments", self._extract_segments),
2295
+ ]
2296
+ )
2297
+
2298
+ # print the default parameters
2299
+ PBM3C2._print_default_parameters(
2300
+ kwargs=kwargs, pipeline_param_dict=labeling_pipeline.get_params()
2301
+ )
2302
+
2303
+ # no computation
2304
+ if epoch0 is None or epoch1 is None:
2305
+ # logger.info("epoch0 and epoch1 are required, no parameter changes applied")
2306
+ return
2307
+
2308
+ # save the default pipeline options
2309
+ default_options = labeling_pipeline.get_params()
2310
+
2311
+ # overwrite the default parameters
2312
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
2313
+ kwargs=kwargs, pipeline=labeling_pipeline
2314
+ )
2315
+ if len(unused_kwargs) > 0:
2316
+ logger.warning(
2317
+ f"The parameters '{unused_kwargs.keys()}' are not part of the pipeline parameters: \n "
2318
+ f"{pp.pformat(labeling_pipeline.get_params())}"
2319
+ )
2320
+
2321
+ # apply the pipeline
2322
+ X0 = np.hstack((epoch0.cloud[:, :], np.zeros((epoch0.cloud.shape[0], 1))))
2323
+ X1 = np.hstack((epoch1.cloud[:, :], np.ones((epoch1.cloud.shape[0], 1))))
2324
+ X = np.vstack((X0, X1))
2325
+ labeling_pipeline.fit(X)
2326
+ segments = labeling_pipeline.transform(X)
2327
+
2328
+ # restore the default pipeline options
2329
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
2330
+ kwargs=default_options,
2331
+ pipeline=labeling_pipeline,
2332
+ message="The pipeline parameters after restoration are: ",
2333
+ )
2334
+ assert (
2335
+ len(unused_kwargs) == 0
2336
+ ), "All default options should be found when default parameter restoration is done"
2337
+
2338
+ return segments, builder_extended_y.generate_extended_y(segments)
2339
+
2340
+ def export_segmented_point_cloud_and_segments(
2341
+ self,
2342
+ epoch0: Epoch = None,
2343
+ epoch1: Epoch = None,
2344
+ x_y_z_id_epoch0_file_name: typing.Union[str, None] = "x_y_z_id_epoch0.xyz",
2345
+ x_y_z_id_epoch1_file_name: typing.Union[str, None] = "x_y_z_id_epoch1.xyz",
2346
+ extracted_segments_file_name: typing.Union[
2347
+ str, None
2348
+ ] = "extracted_segments.seg",
2349
+ concatenate_name="",
2350
+ **kwargs,
2351
+ ) -> typing.Union[
2352
+ typing.Tuple[np.ndarray, typing.Union[np.ndarray, None], np.ndarray], None
2353
+ ]:
2354
+ """
2355
+ For each epoch, it returns the segmentation of the point cloud as a numpy array (n_points, 4)
2356
+ and it also serializes them using the provided file names.
2357
+ where each row has the following structure: x, y, z, segment_id
2358
+
2359
+ It also generates a numpy array of segments of the form:
2360
+ X_COLUMN, Y_COLUMN, Z_COLUMN, -> Center of Gravity
2361
+ EPOCH_ID_COLUMN, -> 0/1
2362
+ EIGENVALUE0_COLUMN, EIGENVALUE1_COLUMN, EIGENVALUE2_COLUMN,
2363
+ EIGENVECTOR_0_X_COLUMN, EIGENVECTOR_0_Y_COLUMN, EIGENVECTOR_0_Z_COLUMN,
2364
+ EIGENVECTOR_1_X_COLUMN, EIGENVECTOR_1_Y_COLUMN, EIGENVECTOR_1_Z_COLUMN,
2365
+ EIGENVECTOR_2_X_COLUMN, EIGENVECTOR_2_Y_COLUMN, EIGENVECTOR_2_Z_COLUMN, -> Normal vector
2366
+ LLSV_COLUMN, -> lowest local surface variation
2367
+ SEGMENT_ID_COLUMN,
2368
+ STANDARD_DEVIATION_COLUMN,
2369
+ NR_POINTS_PER_SEG_COLUMN,
2370
+
2371
+ :param epoch0:
2372
+ Epoch object | None
2373
+ :param epoch1:
2374
+ Epoch object | None
2375
+ :param x_y_z_id_epoch0_file_name:
2376
+ The output file name for epoch0, point cloud segmentation, saved as a numpy array (n_points, 4)
2377
+ (x,y,z, segment_id)
2378
+ | None
2379
+ :param x_y_z_id_epoch1_file_name:
2380
+ The output file name for epoch1, point cloud segmentation, saved as a numpy array (n_points, 4)
2381
+ (x,y,z, segment_id)
2382
+ | None
2383
+ :param extracted_segments_file_name:
2384
+ The output file name for the file containing the segments, saved as a numpy array containing
2385
+ the column structure introduced above.
2386
+ | None
2387
+ :param concatenate_name:
2388
+ String that is utilized to uniquely identify the same transformer between multiple pipelines.
2389
+ :param kwargs:
2390
+
2391
+ Used for customize the default pipeline parameters.
2392
+
2393
+ Getting the default parameters:
2394
+ e.g. "get_pipeline_options"
2395
+ In case this parameter is True, the method will print the pipeline options as kwargs.
2396
+
2397
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
2398
+ In case of setting it, the result of computation at that step is dump as xyz file.
2399
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
2400
+
2401
+ this process is stateless
2402
+
2403
+ :return:
2404
+ tuple [ x_y_z_id_epoch0, x_y_z_id_epoch1 | None, extracted_segments ] | None
2405
+ """
2406
+
2407
+ pipe_segmentation = Pipeline(
2408
+ [
2409
+ (
2410
+ concatenate_name + "_Transform_PerPointComputation",
2411
+ self._per_point_computation,
2412
+ ),
2413
+ (concatenate_name + "_Transform_Segmentation", self._segmentation),
2414
+ (
2415
+ concatenate_name + "_Transform_Second_Segmentation",
2416
+ self._second_segmentation,
2417
+ ),
2418
+ ]
2419
+ )
2420
+
2421
+ pipe_extract_segments = Pipeline(
2422
+ [
2423
+ (
2424
+ concatenate_name + "_Transform_ExtractSegments",
2425
+ self._extract_segments,
2426
+ ),
2427
+ ]
2428
+ )
2429
+
2430
+ # print the default parameters
2431
+ PBM3C2._print_default_parameters(
2432
+ kwargs=kwargs,
2433
+ pipeline_param_dict={
2434
+ **pipe_segmentation.get_params(),
2435
+ **pipe_extract_segments.get_params(),
2436
+ },
2437
+ )
2438
+
2439
+ # no computation
2440
+ if epoch0 is None: # or epoch1 is None:
2441
+ # logger.info("epoch0 is required, no parameter changes applied")
2442
+ return
2443
+
2444
+ # save the default pipeline options
2445
+ default_options = {
2446
+ **pipe_segmentation.get_params(),
2447
+ **pipe_extract_segments.get_params(),
2448
+ }
2449
+ del default_options["memory"]
2450
+ del default_options["steps"]
2451
+ del default_options["verbose"]
2452
+
2453
+ # overwrite the default parameters
2454
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
2455
+ kwargs=kwargs, pipeline=pipe_segmentation
2456
+ )
2457
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
2458
+ kwargs=unused_kwargs, pipeline=pipe_extract_segments
2459
+ )
2460
+ if len(unused_kwargs) > 0:
2461
+ logger.warning(
2462
+ f"The parameters '{unused_kwargs.keys()}' are not part of the pipeline parameters: \n "
2463
+ f"{pp.pformat({**pipe_segmentation.get_params(), **pipe_extract_segments.get_params()})}"
2464
+ )
2465
+
2466
+ # apply the pipeline
2467
+
2468
+ if isinstance(epoch0, Epoch):
2469
+ X0 = np.hstack((epoch0.cloud[:, :], np.zeros((epoch0.cloud.shape[0], 1))))
2470
+ else:
2471
+ X0 = epoch0
2472
+
2473
+ if isinstance(epoch1, Epoch):
2474
+ X1 = np.hstack((epoch1.cloud[:, :], np.ones((epoch1.cloud.shape[0], 1))))
2475
+ X = np.vstack((X0, X1))
2476
+ else:
2477
+ X = X0
2478
+
2479
+ pipe_segmentation.fit(X)
2480
+ # 'out' contains the segmentation of the point cloud.
2481
+ out = pipe_segmentation.transform(X)
2482
+
2483
+ pipe_extract_segments.fit(out)
2484
+ # 'extracted_segments' contains the new set of segments
2485
+ extracted_segments = pipe_extract_segments.transform(out)
2486
+
2487
+ # restore the default pipeline options
2488
+ unused_default_options = PBM3C2._overwrite_pipeline_parameters(
2489
+ kwargs=default_options,
2490
+ pipeline=pipe_segmentation,
2491
+ message="The pipeline parameters after restoration are: ",
2492
+ )
2493
+ unused_default_options = PBM3C2._overwrite_pipeline_parameters(
2494
+ kwargs=unused_default_options,
2495
+ pipeline=pipe_extract_segments,
2496
+ message="The pipeline parameters after restoration are: ",
2497
+ )
2498
+ assert (
2499
+ len(unused_default_options) == 0
2500
+ ), "All default options should be found when default parameter restoration is done"
2501
+
2502
+ columns = self._second_segmentation.columns
2503
+
2504
+ Extract_Columns = [
2505
+ columns.X_COLUMN,
2506
+ columns.Y_COLUMN,
2507
+ columns.Z_COLUMN,
2508
+ columns.SEGMENT_ID_COLUMN,
2509
+ ]
2510
+
2511
+ mask_epoch0 = out[:, columns.EPOCH_ID_COLUMN] == 0
2512
+ mask_epoch1 = out[:, columns.EPOCH_ID_COLUMN] == 1
2513
+
2514
+ out_epoch0 = out[mask_epoch0, :]
2515
+ out_epoch1 = out[mask_epoch1, :]
2516
+
2517
+ x_y_z_id_epoch0 = out_epoch0[:, Extract_Columns] # x,y,z, Seg_ID
2518
+ x_y_z_id_epoch1 = out_epoch1[:, Extract_Columns] # x,y,z, Seg_ID
2519
+
2520
+ if x_y_z_id_epoch0_file_name != None:
2521
+ logger.debug(f"Save 'x_y_z_id_epoch0' in file: {x_y_z_id_epoch0_file_name}")
2522
+ np.savetxt(x_y_z_id_epoch0_file_name, x_y_z_id_epoch0, delimiter=",")
2523
+ else:
2524
+ logger.debug(f"'x_y_z_id_epoch0' is not saved")
2525
+
2526
+ if x_y_z_id_epoch1_file_name != None:
2527
+ logger.debug(f"Save 'x_y_z_id_epoch1' in file: {x_y_z_id_epoch1_file_name}")
2528
+ np.savetxt(x_y_z_id_epoch1_file_name, x_y_z_id_epoch1, delimiter=",")
2529
+ else:
2530
+ logger.debug(f"'x_y_z_id_epoch1' is not saved")
2531
+
2532
+ if extracted_segments_file_name != None:
2533
+ logger.debug(
2534
+ f"Save 'extracted_segments' in file: {extracted_segments_file_name}"
2535
+ )
2536
+ np.savetxt(extracted_segments_file_name, extracted_segments, delimiter=",")
2537
+ else:
2538
+ logger.debug(f"'extracted_segments' is not saved")
2539
+
2540
+ return x_y_z_id_epoch0, x_y_z_id_epoch1, extracted_segments
2541
+
2542
+ def training(
2543
+ self,
2544
+ segments: np.ndarray = None,
2545
+ extended_y: np.ndarray = None,
2546
+ extracted_segments_file_name: str = "extracted_segments.seg",
2547
+ extended_y_file_name: str = "extended_y.csv",
2548
+ ) -> None:
2549
+ """
2550
+ It applies the training algorithm for the input pairs of Segments 'segments'
2551
+ and extended labels 'extended_y'.
2552
+
2553
+ :param segments:
2554
+ 'Segments' numpy array of shape (n_segments, segment_size)
2555
+
2556
+ It has the following column structure:
2557
+ X_COLUMN, Y_COLUMN, Z_COLUMN, -> Center of Gravity
2558
+ EPOCH_ID_COLUMN, -> 0/1
2559
+ EIGENVALUE0_COLUMN, EIGENVALUE1_COLUMN, EIGENVALUE2_COLUMN,
2560
+ EIGENVECTOR_0_X_COLUMN, EIGENVECTOR_0_Y_COLUMN, EIGENVECTOR_0_Z_COLUMN,
2561
+ EIGENVECTOR_1_X_COLUMN, EIGENVECTOR_1_Y_COLUMN, EIGENVECTOR_1_Z_COLUMN,
2562
+ EIGENVECTOR_2_X_COLUMN, EIGENVECTOR_2_Y_COLUMN, EIGENVECTOR_2_Z_COLUMN, -> Normal vector
2563
+ LLSV_COLUMN, -> lowest local surface variation
2564
+ SEGMENT_ID_COLUMN,
2565
+ STANDARD_DEVIATION_COLUMN,
2566
+ NR_POINTS_PER_SEG_COLUMN,
2567
+
2568
+ :param extended_y:
2569
+ numpy array of shape (m_labels, 3)
2570
+ has the following structure: (tuples of index segment from epoch0, index segment from epoch1,
2571
+ label(0/1))
2572
+ :param extracted_segments_file_name:
2573
+ In case 'X' is None segments are loaded using 'extracted_segments_file_name'.
2574
+ :param extended_y_file_name:
2575
+ In case 'extended_y' is None, this file is used as input fallback.
2576
+ """
2577
+
2578
+ if segments is None:
2579
+ # Resolve the given path
2580
+ filename = find_file(extracted_segments_file_name)
2581
+ # Read it
2582
+ try:
2583
+ logger.info(f"Reading segments from file '{filename}'")
2584
+ segments = np.genfromtxt(filename, delimiter=",")
2585
+ except ValueError:
2586
+ raise Py4DGeoError("Malformed file: " + str(filename))
2587
+
2588
+ if extended_y is None:
2589
+ # Resolve the given path
2590
+ filename = find_file(extended_y_file_name)
2591
+ # Read it
2592
+ try:
2593
+ logger.info(
2594
+ f"Reading tuples of (segment epoch0, segment epoch1, label) from file '{filename}'"
2595
+ )
2596
+ extended_y = np.genfromtxt(filename, delimiter=",")
2597
+ except ValueError:
2598
+ raise Py4DGeoError("Malformed file: " + str(filename))
2599
+
2600
+ training_pipeline = Pipeline(
2601
+ [
2602
+ ("Classifier", self._classifier),
2603
+ ]
2604
+ )
2605
+
2606
+ # apply the pipeline
2607
+ training_pipeline.fit(segments, extended_y)
2608
+
2609
+ def predict(
2610
+ self,
2611
+ epoch0: Epoch = None,
2612
+ epoch1: Epoch = None,
2613
+ epoch_additional_dimensions_lookup: typing.Dict[str, str] = None,
2614
+ **kwargs,
2615
+ ) -> typing.Union[np.ndarray, None]:
2616
+ """
2617
+ After extracting the segments from epoch0 and epoch1, it returns a numpy array of corresponding
2618
+ pairs of segments between epoch 0 and epoch 1.
2619
+
2620
+ :param epoch0:
2621
+ Epoch object.
2622
+ :param epoch1:
2623
+ Epoch object.
2624
+ :param epoch_additional_dimensions_lookup:
2625
+ A dictionary that maps between the names of the columns used internally
2626
+ and the names of the columns used by both epoch0 and epoch1.
2627
+
2628
+ No additional column is used.
2629
+ :param kwargs:
2630
+
2631
+ Used for customize the default pipeline parameters.
2632
+
2633
+ Getting the default parameters:
2634
+ e.g. "get_pipeline_options"
2635
+ In case this parameter is True, the method will print the pipeline options as kwargs.
2636
+
2637
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
2638
+ In case of setting it, the result of computation at that step is dump as xyz file.
2639
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
2640
+
2641
+ this process is stateless
2642
+
2643
+ :return:
2644
+ A numpy array ( n_pairs, segment_features_size*2 ) where each row contains a pair of segments.
2645
+ | None
2646
+ """
2647
+
2648
+ pipe_classifier = Pipeline(
2649
+ [
2650
+ ("Classifier", self._classifier),
2651
+ ]
2652
+ )
2653
+
2654
+ # arguments used, as part of the epoch0's pipeline.
2655
+ kwargs_epoch0 = {
2656
+ key: val
2657
+ for key, val in kwargs.items()
2658
+ if key.startswith("epoch0") or key == "get_pipeline_options"
2659
+ }
2660
+
2661
+ # epoch0, segments computation
2662
+ out_for_epoch0 = self.export_segmented_point_cloud_and_segments(
2663
+ epoch0=epoch0,
2664
+ epoch1=None,
2665
+ x_y_z_id_epoch0_file_name=None,
2666
+ x_y_z_id_epoch1_file_name=None,
2667
+ extracted_segments_file_name=None,
2668
+ concatenate_name="epoch0",
2669
+ **kwargs_epoch0,
2670
+ )
2671
+
2672
+ # arguments used, as part of the epoch1's pipeline.
2673
+ kwargs_epoch1 = {
2674
+ key: val
2675
+ for key, val in kwargs.items()
2676
+ if key.startswith("epoch1") or key == "get_pipeline_options"
2677
+ }
2678
+
2679
+ # epoch1, segments computation
2680
+ out_for_epoch1 = self.export_segmented_point_cloud_and_segments(
2681
+ epoch0=epoch1,
2682
+ epoch1=None,
2683
+ x_y_z_id_epoch0_file_name=None,
2684
+ x_y_z_id_epoch1_file_name=None,
2685
+ extracted_segments_file_name=None,
2686
+ concatenate_name="epoch1",
2687
+ **kwargs_epoch1,
2688
+ )
2689
+
2690
+ # arguments used, as part of the classifier 'pipeline'
2691
+ kwargs_classifier = {
2692
+ key: val
2693
+ for key, val in kwargs.items()
2694
+ if not key.startswith("epoch0") and not key.startswith("epoch1")
2695
+ }
2696
+
2697
+ # print the default parameters for 'pipe_classifier'
2698
+ PBM3C2._print_default_parameters(
2699
+ kwargs=kwargs_classifier, pipeline_param_dict=pipe_classifier.get_params()
2700
+ )
2701
+
2702
+ # no computation
2703
+ if epoch0 is None or epoch1 is None:
2704
+ # logger.info("epoch0 and epoch1 are required, no parameter changes applied")
2705
+ return
2706
+
2707
+ # save the default pipe_classifier options
2708
+ default_options = pipe_classifier.get_params()
2709
+
2710
+ # overwrite the default parameters
2711
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
2712
+ kwargs=kwargs_classifier, pipeline=pipe_classifier
2713
+ )
2714
+ if len(unused_kwargs) > 0:
2715
+ logger.error(
2716
+ f"The parameters: '{unused_kwargs.keys()}' are not part of the pipeline parameters: \n "
2717
+ f"{pp.pformat(pipe_classifier.get_params())}"
2718
+ )
2719
+
2720
+ _0, _1, epoch0_segments = out_for_epoch0
2721
+ _0, _1, epoch1_segments = out_for_epoch1
2722
+
2723
+ columns = self._extract_segments.columns
2724
+
2725
+ # compute segment id offset
2726
+ max_seg_id_epoch0 = np.max(epoch0_segments[:, columns.SEGMENT_ID_COLUMN])
2727
+ # apply offset to segment from epoch1
2728
+ epoch1_segments[:, columns.SEGMENT_ID_COLUMN] += max_seg_id_epoch0 + 1
2729
+
2730
+ # enforce epoch ID as '0' for 'epoch0_segments'
2731
+ epoch0_segments[:, columns.EPOCH_ID_COLUMN] = 0
2732
+
2733
+ # change the epoch ID from 0 (the default one used during the previous computation for both epochs)
2734
+ # to 1 for 'epoch1_segments'
2735
+ epoch1_segments[:, columns.EPOCH_ID_COLUMN] = 1
2736
+
2737
+ extracted_segments = np.vstack((epoch0_segments, epoch1_segments))
2738
+
2739
+ out = pipe_classifier.predict(extracted_segments)
2740
+
2741
+ # restore the default pipeline options
2742
+ unused_default_options = PBM3C2._overwrite_pipeline_parameters(
2743
+ kwargs=default_options,
2744
+ pipeline=pipe_classifier,
2745
+ message="The pipeline parameters after restoration are: ",
2746
+ )
2747
+ assert (
2748
+ len(unused_default_options) == 0
2749
+ ), "All default options should be found when default parameter restoration is done"
2750
+
2751
+ return out
2752
+
2753
+ def _compute_distances(
2754
+ self,
2755
+ epoch0_info: typing.Union[Epoch, np.ndarray] = None,
2756
+ epoch1: Epoch = None,
2757
+ alignment_error: float = 1.1,
2758
+ epoch_additional_dimensions_lookup: typing.Dict[str, str] = None,
2759
+ **kwargs,
2760
+ ) -> typing.Union[typing.Tuple[np.ndarray, np.ndarray], None]:
2761
+ """
2762
+ Compute the distance between 2 epochs. It also adds the following properties at the end of the computation:
2763
+ distances, corepoints (corepoints of epoch0), epochs (epoch0, epoch1), uncertainties
2764
+
2765
+ :param epoch0_info:
2766
+ Epoch object.
2767
+ | np.ndarray
2768
+ :param epoch1:
2769
+ Epoch object.
2770
+ :param alignment_error:
2771
+ alignment error reg between point clouds.
2772
+ :param epoch_additional_dimensions_lookup:
2773
+ A dictionary that maps between the names of the columns used internally
2774
+ and the names of the columns used by both epoch0 and epoch1.
2775
+ :param kwargs:
2776
+ Used for customize the default pipeline parameters.
2777
+
2778
+ Getting the default parameters:
2779
+ e.g. "get_pipeline_options"
2780
+ In case this parameter is True, the method will print the pipeline options as kwargs.
2781
+
2782
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
2783
+ In case of setting it, the result of computation at that step is dump as xyz file.
2784
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
2785
+
2786
+ this process is stateless
2787
+
2788
+ :return:
2789
+ tuple [distances, uncertainties]
2790
+ 'distances' is np.array (nr_similar_pairs, 1)
2791
+ 'uncertainties' is np.array (nr_similar_pairs,1) and it has the following structure:
2792
+ dtype=np.dtype(
2793
+ [
2794
+ ("lodetection", "<f8"),
2795
+ ("spread1", "<f8"),
2796
+ ("num_samples1", "<i8"),
2797
+ ("spread2", "<f8"),
2798
+ ("num_samples2", "<i8"),
2799
+ ]
2800
+ )
2801
+ | None
2802
+ """
2803
+
2804
+ logger.info(f"PBM3C2._compute_distances(...)")
2805
+
2806
+ # A numpy array where each row contains a pair of segments.
2807
+ segments_pair = self.predict(
2808
+ epoch0_info,
2809
+ epoch1,
2810
+ epoch_additional_dimensions_lookup=epoch_additional_dimensions_lookup,
2811
+ **kwargs,
2812
+ )
2813
+
2814
+ columns = self._extract_segments.columns
2815
+
2816
+ # no computation
2817
+ if epoch0_info is None or epoch1 is None:
2818
+ # logger.info("epoch0 and epoch1 are required, no parameter changes applied")
2819
+ return
2820
+
2821
+ size_segment = int(segments_pair.shape[1] / 2)
2822
+ nr_pairs = segments_pair.shape[0]
2823
+
2824
+ epoch0_segments = segments_pair[:, :size_segment]
2825
+ epoch1_segments = segments_pair[:, size_segment:]
2826
+
2827
+ # seg_id_epoch0, X_Column0, Y_Column0, Z_Column0, seg_id_epoch1, X_Column1, Y_Column1, Z_Column1, distance, uncertaintie
2828
+ output = np.empty((0, 10), dtype=float)
2829
+
2830
+ for indx in range(nr_pairs):
2831
+ segment_epoch0 = epoch0_segments[indx]
2832
+ segment_epoch1 = epoch1_segments[indx]
2833
+
2834
+ t0_CoG = segment_epoch0[
2835
+ [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
2836
+ ]
2837
+ t1_CoG = segment_epoch1[
2838
+ [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
2839
+ ]
2840
+
2841
+ Normal_Columns = [
2842
+ columns.EIGENVECTOR_2_X_COLUMN,
2843
+ columns.EIGENVECTOR_2_Y_COLUMN,
2844
+ columns.EIGENVECTOR_2_Z_COLUMN,
2845
+ ]
2846
+ normal_vector_t0 = segment_epoch0[Normal_Columns]
2847
+
2848
+ M3C2_dist = normal_vector_t0.dot(t0_CoG - t1_CoG)
2849
+
2850
+ std_dev_normalized_squared_t0 = segment_epoch0[
2851
+ columns.STANDARD_DEVIATION_COLUMN
2852
+ ]
2853
+ std_dev_normalized_squared_t1 = segment_epoch1[
2854
+ columns.STANDARD_DEVIATION_COLUMN
2855
+ ]
2856
+
2857
+ LoDetection = 1.96 * (
2858
+ np.sqrt(std_dev_normalized_squared_t0 + std_dev_normalized_squared_t1)
2859
+ + alignment_error
2860
+ )
2861
+
2862
+ # seg_id_epoch0, X_Column0, Y_Column0, Z_Column0, seg_id_epoch1, X_Column1, Y_Column1, Z_Column1, distance, uncertaintie
2863
+ args = (
2864
+ np.array([segment_epoch0[columns.SEGMENT_ID_COLUMN]]),
2865
+ t0_CoG,
2866
+ np.array([segment_epoch1[columns.SEGMENT_ID_COLUMN]]),
2867
+ t1_CoG,
2868
+ np.array([M3C2_dist]),
2869
+ np.array([LoDetection]),
2870
+ )
2871
+ row = np.concatenate(args)
2872
+ output = np.vstack((output, row))
2873
+
2874
+ # We don't return this anymore. It corresponds to the output structure of the original implementation.
2875
+ # return output
2876
+
2877
+ # distance vector
2878
+ self.distances = output[:, -2]
2879
+
2880
+ # corepoints to epoch0 ('initial' one)
2881
+ self.corepoints = Epoch(output[:, [1, 2, 3]])
2882
+
2883
+ # epochs
2884
+ self.epochs = (epoch0_info, epoch1)
2885
+
2886
+ self.uncertainties = np.empty(
2887
+ (output.shape[0], 1),
2888
+ dtype=np.dtype(
2889
+ [
2890
+ ("lodetection", "<f8"),
2891
+ ("spread1", "<f8"),
2892
+ ("num_samples1", "<i8"),
2893
+ ("spread2", "<f8"),
2894
+ ("num_samples2", "<i8"),
2895
+ ]
2896
+ ),
2897
+ )
2898
+
2899
+ self.uncertainties["lodetection"] = output[:, -1].reshape(-1, 1)
2900
+
2901
+ self.uncertainties["spread1"] = np.sqrt(
2902
+ np.multiply(
2903
+ epoch0_segments[:, columns.STANDARD_DEVIATION_COLUMN],
2904
+ epoch0_segments[:, columns.NR_POINTS_PER_SEG_COLUMN],
2905
+ )
2906
+ ).reshape(-1, 1)
2907
+ self.uncertainties["spread2"] = np.sqrt(
2908
+ np.multiply(
2909
+ epoch1_segments[:, columns.STANDARD_DEVIATION_COLUMN],
2910
+ epoch1_segments[:, columns.NR_POINTS_PER_SEG_COLUMN],
2911
+ )
2912
+ ).reshape(-1, 1)
2913
+
2914
+ self.uncertainties["num_samples1"] = (
2915
+ epoch0_segments[:, columns.NR_POINTS_PER_SEG_COLUMN]
2916
+ .astype(int)
2917
+ .reshape(-1, 1)
2918
+ )
2919
+ self.uncertainties["num_samples2"] = (
2920
+ epoch1_segments[:, columns.NR_POINTS_PER_SEG_COLUMN]
2921
+ .astype(int)
2922
+ .reshape(-1, 1)
2923
+ )
2924
+
2925
+ return (self.distances, self.uncertainties)
2926
+
2927
+ def compute_distances(
2928
+ self,
2929
+ epoch0: typing.Union[Epoch, np.ndarray] = None,
2930
+ epoch1: Epoch = None,
2931
+ alignment_error: float = 1.1,
2932
+ **kwargs,
2933
+ ) -> typing.Union[typing.Tuple[np.ndarray, np.ndarray], None]:
2934
+ """
2935
+ Compute the distance between 2 epochs. It also adds the following properties at the end of the computation:
2936
+ distances, corepoints (corepoints of epoch0), epochs (epoch0, epoch1), uncertainties
2937
+
2938
+ :param epoch0:
2939
+ Epoch object.
2940
+ | np.ndarray
2941
+ :param epoch1:
2942
+ Epoch object.
2943
+ :param alignment_error:
2944
+ alignment error reg between point clouds.
2945
+ :param kwargs:
2946
+ Used for customize the default pipeline parameters.
2947
+
2948
+ Getting the default parameters:
2949
+ e.g. "get_pipeline_options"
2950
+ In case this parameter is True, the method will print the pipeline options as kwargs.
2951
+
2952
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
2953
+ In case of setting it, the result of computation at that step is dump as xyz file.
2954
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
2955
+
2956
+ this process is stateless
2957
+
2958
+ :return:
2959
+ tuple [distances, uncertainties]
2960
+ 'distances' is np.array (nr_similar_pairs, 1)
2961
+ 'uncertainties' is np.array (nr_similar_pairs,1) and it has the following structure:
2962
+ dtype=np.dtype(
2963
+ [
2964
+ ("lodetection", "<f8"),
2965
+ ("spread1", "<f8"),
2966
+ ("num_samples1", "<i8"),
2967
+ ("spread2", "<f8"),
2968
+ ("num_samples2", "<i8"),
2969
+ ]
2970
+ )
2971
+ | None
2972
+ """
2973
+
2974
+ logger.info(f"PBM3C2.compute_distances(...)")
2975
+
2976
+ return self._compute_distances(
2977
+ epoch0_info=epoch0,
2978
+ epoch1=epoch1,
2979
+ alignment_error=alignment_error,
2980
+ epoch_additional_dimensions_lookup=None,
2981
+ **kwargs,
2982
+ )
2983
+
2984
+
2985
+ def build_input_scenario2_with_normals(
2986
+ epoch0, epoch1, columns=SEGMENTED_POINT_CLOUD_COLUMNS
2987
+ ):
2988
+ """
2989
+ Build a segmented point cloud with computed normals for each point.
2990
+
2991
+ :param epoch0:
2992
+ x,y,z point cloud
2993
+ :param epoch1:
2994
+ x,y,z point cloud
2995
+ :param columns:
2996
+
2997
+ :return:
2998
+ tuple [
2999
+ numpy array (n_point_samples, 7) new_epoch0
3000
+ numpy array (m_point_samples, 7) new_epoch1
3001
+ ]
3002
+ both containing: x,y,z, N_x,N_y,N_z, Segment_ID as columns.
3003
+ """
3004
+
3005
+ Normal_Columns = [
3006
+ columns.EIGENVECTOR_2_X_COLUMN,
3007
+ columns.EIGENVECTOR_2_Y_COLUMN,
3008
+ columns.EIGENVECTOR_2_Z_COLUMN,
3009
+ ]
3010
+
3011
+ x_y_z_Columns = [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
3012
+
3013
+ X0 = np.hstack((epoch0.cloud[:, :], np.zeros((epoch0.cloud.shape[0], 1))))
3014
+ X1 = np.hstack((epoch1.cloud[:, :], np.ones((epoch1.cloud.shape[0], 1))))
3015
+
3016
+ X = np.vstack((X0, X1))
3017
+
3018
+ transform_pipeline = Pipeline(
3019
+ [
3020
+ ("Transform_PerPointComputation", PerPointComputation()),
3021
+ ("Transform_Segmentation", Segmentation()),
3022
+ ]
3023
+ )
3024
+
3025
+ transform_pipeline.fit(X)
3026
+ out = transform_pipeline.transform(X)
3027
+
3028
+ mask_epoch0 = out[:, columns.EPOCH_ID_COLUMN] == 0 # epoch0
3029
+ mask_epoch1 = out[:, columns.EPOCH_ID_COLUMN] == 1 # epoch1
3030
+
3031
+ new_epoch0 = out[mask_epoch0, :] # extract epoch0
3032
+ new_epoch1 = out[mask_epoch1, :] # extract epoch1
3033
+
3034
+ new_epoch0 = new_epoch0[
3035
+ :, x_y_z_Columns + Normal_Columns + [columns.SEGMENT_ID_COLUMN]
3036
+ ]
3037
+ new_epoch1 = new_epoch1[
3038
+ :, x_y_z_Columns + Normal_Columns + [columns.SEGMENT_ID_COLUMN]
3039
+ ]
3040
+
3041
+ # x,y,z, N_x,N_y,N_z, Segment_ID
3042
+ return new_epoch0, new_epoch1
3043
+ pass
3044
+
3045
+
3046
+ def build_input_scenario2_without_normals(
3047
+ epoch0, epoch1, columns=SEGMENTED_POINT_CLOUD_COLUMNS
3048
+ ):
3049
+ """
3050
+ Build a segmented point cloud.
3051
+
3052
+ :param epoch0:
3053
+ x,y,z point cloud
3054
+ :param epoch1:
3055
+ x,y,z point cloud
3056
+ :param columns:
3057
+
3058
+ :return:
3059
+ tuple [
3060
+ numpy array (n_point_samples, 4) new_epoch0
3061
+ numpy array (m_point_samples, 4) new_epoch1
3062
+ ]
3063
+ both containing: x,y,z,Segment_ID as columns.
3064
+ """
3065
+
3066
+ x_y_z_Columns = [columns.X_COLUMN, columns.Y_COLUMN, columns.Z_COLUMN]
3067
+
3068
+ Normal_Columns = [
3069
+ columns.EIGENVECTOR_2_X_COLUMN,
3070
+ columns.EIGENVECTOR_2_Y_COLUMN,
3071
+ columns.EIGENVECTOR_2_Z_COLUMN,
3072
+ ]
3073
+
3074
+ X0 = np.hstack((epoch0.cloud[:, :], np.zeros((epoch0.cloud.shape[0], 1))))
3075
+ X1 = np.hstack((epoch1.cloud[:, :], np.ones((epoch1.cloud.shape[0], 1))))
3076
+
3077
+ X = np.vstack((X0, X1))
3078
+
3079
+ transform_pipeline = Pipeline(
3080
+ [
3081
+ ("Transform_PerPointComputation", PerPointComputation()),
3082
+ ("Transform_Segmentation", Segmentation()),
3083
+ ]
3084
+ )
3085
+
3086
+ transform_pipeline.fit(X)
3087
+ out = transform_pipeline.transform(X)
3088
+
3089
+ mask_epoch0 = out[:, columns.EPOCH_ID_COLUMN] == 0 # epoch0
3090
+ mask_epoch1 = out[:, columns.EPOCH_ID_COLUMN] == 1 # epoch1
3091
+
3092
+ new_epoch0 = out[mask_epoch0, :] # extract epoch0
3093
+ new_epoch1 = out[mask_epoch1, :] # extract epoch1
3094
+
3095
+ new_epoch0 = new_epoch0[:, x_y_z_Columns + [columns.SEGMENT_ID_COLUMN]]
3096
+ new_epoch1 = new_epoch1[:, x_y_z_Columns + [columns.SEGMENT_ID_COLUMN]]
3097
+
3098
+ # x,y,z, Segment_ID
3099
+ return new_epoch0, new_epoch1
3100
+ pass
3101
+
3102
+
3103
+ class PBM3C2WithSegments(PBM3C2):
3104
+ def __init__(
3105
+ self,
3106
+ per_point_computation=PerPointComputation(),
3107
+ segmentation=Segmentation(),
3108
+ second_segmentation=Segmentation(),
3109
+ extract_segments=ExtractSegments(),
3110
+ post_segmentation=PostPointCloudSegmentation(compute_normal=True),
3111
+ classifier=ClassifierWrapper(),
3112
+ ):
3113
+ """
3114
+ :param per_point_computation:
3115
+ lowest local surface variation and PCA computation. (computes the normal vector as well)
3116
+ :param segmentation:
3117
+ The object used for the first segmentation.
3118
+ :param second_segmentation:
3119
+ The object used for the second segmentation.
3120
+ :param extract_segments:
3121
+ The object used for building the segments.
3122
+ :param post_segmentation:
3123
+ A transform object used to 'reconstruct' the result that is achieved using the "PB_P3C2 class"
3124
+ pipeline at the end of the point cloud segmentation.
3125
+
3126
+ The 'input' of this adaptor is formed by 2 epochs that contain as 'additional_dimensions'
3127
+ a segment_id column and optionally, precomputed normals as another 3 columns.
3128
+
3129
+ The 'output' of this adaptor is:
3130
+ numpy array (n_point_samples, 19) with the following column structure:
3131
+ [
3132
+ x, y, z ( 3 columns ),
3133
+ EpochID ( 1 column ),
3134
+ Eigenvalues( 3 columns ), -> that correspond to the next 3 Eigenvectors
3135
+ Eigenvectors( 3 columns ) X 3 -> in descending order using vector norm 2,
3136
+ Lowest local surface variation ( 1 column ),
3137
+ Segment_ID ( 1 column ),
3138
+ Standard deviation ( 1 column )
3139
+ ]
3140
+ :param classifier:
3141
+ An instance of ClassifierWrapper class. The default wrapped classifier used is sk-learn RandomForest.
3142
+ """
3143
+
3144
+ super().__init__(
3145
+ per_point_computation=per_point_computation,
3146
+ segmentation=segmentation,
3147
+ second_segmentation=second_segmentation,
3148
+ extract_segments=extract_segments,
3149
+ classifier=classifier,
3150
+ )
3151
+
3152
+ self._post_segmentation = post_segmentation
3153
+
3154
+ def generate_extended_labels_interactively(
3155
+ self,
3156
+ epoch0: Epoch = None,
3157
+ epoch1: Epoch = None,
3158
+ builder_extended_y: BuilderExtended_y_Visually = BuilderExtended_y_Visually(),
3159
+ epoch_additional_dimensions_lookup: typing.Dict[str, str] = dict(
3160
+ segment_id="segment_id", N_x="N_x", N_y="N_y", N_z="N_z"
3161
+ ),
3162
+ **kwargs,
3163
+ ) -> typing.Union[typing.Tuple[np.ndarray, np.ndarray], None]:
3164
+ """
3165
+ Given 2 Epochs, it builds a pair of (segments and 'extended y').
3166
+
3167
+ :param epoch0:
3168
+ Epoch object,
3169
+ contains as 'additional_dimensions' a segment_id column (mandatory)
3170
+ and optionally, precomputed normals as another 3 columns.
3171
+ :param epoch1:
3172
+ Epoch object,
3173
+ contains as 'additional_dimensions' a segment_id column (mandatory)
3174
+ and optionally, precomputed normals as another 3 columns.
3175
+ :param builder_extended_y:
3176
+ The object is used for generating 'extended y', visually.
3177
+ :param epoch_additional_dimensions_lookup:
3178
+ A dictionary that maps between the names of the columns used internally to identify:
3179
+ segment id of the points: "segment_id" -> Mandatory part of the epochs
3180
+ Normal x-axes vector: "N_x" -> Optionally part of the epochs
3181
+ Normal y-axes vector: "N_y" -> Optionally part of the epochs
3182
+ Normal z-axes vector: "N_z" -> Optionally part of the epochs
3183
+ and the names of the columns used by both epoch0 and epoch1.
3184
+ :param kwargs:
3185
+
3186
+ Used for customize the default pipeline parameters.
3187
+
3188
+ Getting the default parameters:
3189
+ e.g. "get_pipeline_options"
3190
+ In case this parameter is True, the method will print the pipeline options as kwargs.
3191
+
3192
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
3193
+ In case of setting it, the result of computation at that step is dump as xyz file.
3194
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
3195
+
3196
+ this process is stateless
3197
+
3198
+ :return:
3199
+ tuple [Segments, 'extended y'] | None
3200
+
3201
+ where:
3202
+
3203
+ 'Segments' has the following column structure:
3204
+ X_COLUMN, Y_COLUMN, Z_COLUMN, -> Center of Gravity
3205
+ EPOCH_ID_COLUMN, -> 0/1
3206
+ EIGENVALUE0_COLUMN, EIGENVALUE1_COLUMN, EIGENVALUE2_COLUMN,
3207
+ EIGENVECTOR_0_X_COLUMN, EIGENVECTOR_0_Y_COLUMN, EIGENVECTOR_0_Z_COLUMN,
3208
+ EIGENVECTOR_1_X_COLUMN, EIGENVECTOR_1_Y_COLUMN, EIGENVECTOR_1_Z_COLUMN,
3209
+ EIGENVECTOR_2_X_COLUMN, EIGENVECTOR_2_Y_COLUMN, EIGENVECTOR_2_Z_COLUMN, -> Normal vector
3210
+ LLSV_COLUMN, -> lowest local surface variation
3211
+ SEGMENT_ID_COLUMN,
3212
+ STANDARD_DEVIATION_COLUMN,
3213
+ NR_POINTS_PER_SEG_COLUMN,
3214
+
3215
+ 'extended y' has the following structure: (tuples of index segment from epoch0, index segment from epoch1,
3216
+ label(0/1)) used for learning.
3217
+ """
3218
+
3219
+ if not interactive_available:
3220
+ logger.error("Interactive session not available in this environment.")
3221
+ return
3222
+
3223
+ transform_pipeline = Pipeline(
3224
+ [
3225
+ ("Transform_Post_Segmentation", self._post_segmentation),
3226
+ ("Transform_ExtractSegments", self._extract_segments),
3227
+ ]
3228
+ )
3229
+
3230
+ # print the default parameters
3231
+ PBM3C2._print_default_parameters(
3232
+ kwargs=kwargs, pipeline_param_dict=transform_pipeline.get_params()
3233
+ )
3234
+
3235
+ # no computation
3236
+ if epoch0 is None or epoch1 is None:
3237
+ # logger.info("epoch0 and epoch1 are required, no parameter changes applied")
3238
+ return
3239
+
3240
+ # save the default pipeline options
3241
+ default_options = transform_pipeline.get_params()
3242
+
3243
+ # overwrite the default parameters
3244
+ PBM3C2._overwrite_pipeline_parameters(
3245
+ kwargs=kwargs, pipeline=transform_pipeline
3246
+ )
3247
+
3248
+ # extract columns
3249
+
3250
+ epoch0_normals = _extract_from_additional_dimensions(
3251
+ epoch=epoch0,
3252
+ column_names=[
3253
+ epoch_additional_dimensions_lookup["N_x"],
3254
+ epoch_additional_dimensions_lookup["N_y"],
3255
+ epoch_additional_dimensions_lookup["N_z"],
3256
+ ],
3257
+ required_number_of_columns=[0, 3],
3258
+ )
3259
+
3260
+ epoch0_segment_id = _extract_from_additional_dimensions(
3261
+ epoch=epoch0,
3262
+ column_names=[
3263
+ epoch_additional_dimensions_lookup["segment_id"],
3264
+ ],
3265
+ required_number_of_columns=[1],
3266
+ )
3267
+
3268
+ epoch0 = np.concatenate(
3269
+ (epoch0.cloud, epoch0_normals, epoch0_segment_id),
3270
+ axis=1,
3271
+ )
3272
+
3273
+ epoch1_normals = _extract_from_additional_dimensions(
3274
+ epoch=epoch1,
3275
+ column_names=[
3276
+ epoch_additional_dimensions_lookup["N_x"],
3277
+ epoch_additional_dimensions_lookup["N_y"],
3278
+ epoch_additional_dimensions_lookup["N_z"],
3279
+ ],
3280
+ required_number_of_columns=[0, 3],
3281
+ )
3282
+
3283
+ epoch1_segment_id = _extract_from_additional_dimensions(
3284
+ epoch=epoch1,
3285
+ column_names=[
3286
+ epoch_additional_dimensions_lookup["segment_id"],
3287
+ ],
3288
+ required_number_of_columns=[1],
3289
+ )
3290
+
3291
+ epoch1 = np.concatenate(
3292
+ (epoch1.cloud, epoch1_normals, epoch1_segment_id),
3293
+ axis=1,
3294
+ )
3295
+
3296
+ X = None
3297
+ for epoch_id, current_epoch in enumerate([epoch0, epoch1]):
3298
+ if current_epoch.shape[1] == 4:
3299
+ # [x, y, z, segment_id] columns
3300
+ assert self._post_segmentation.compute_normal, (
3301
+ "The reconstruction process doesn't have, as input, the Normal vector columns, hence, "
3302
+ "the normal vector computation is mandatory."
3303
+ )
3304
+
3305
+ logger.info(
3306
+ f"Reconstruct post segmentation output using [x, y, z, segment_id] "
3307
+ f"columns from epoch{epoch_id} "
3308
+ )
3309
+
3310
+ if X is not None:
3311
+ X = np.vstack(
3312
+ (
3313
+ X,
3314
+ self._reconstruct_input_without_normals(
3315
+ epoch=current_epoch,
3316
+ epoch_id=epoch_id,
3317
+ columns=self._post_segmentation.columns,
3318
+ ),
3319
+ )
3320
+ )
3321
+ else:
3322
+ X = self._reconstruct_input_without_normals(
3323
+ epoch=current_epoch,
3324
+ epoch_id=epoch_id,
3325
+ columns=self._post_segmentation.columns,
3326
+ )
3327
+ else:
3328
+ # [x, y, z, N_x, N_y, N_z, segment_id] columns
3329
+ logger.info(
3330
+ f"Reconstruct post segmentation output using [x, y, z, N_x, N_y, N_z, segment_id] "
3331
+ f"columns from epoch{epoch_id}"
3332
+ )
3333
+
3334
+ if X is not None:
3335
+ X = np.vstack(
3336
+ (
3337
+ X,
3338
+ self._reconstruct_input_with_normals(
3339
+ epoch=current_epoch,
3340
+ epoch_id=epoch_id,
3341
+ columns=self._post_segmentation.columns,
3342
+ ),
3343
+ )
3344
+ )
3345
+ else:
3346
+ X = self._reconstruct_input_with_normals(
3347
+ epoch=current_epoch,
3348
+ epoch_id=epoch_id,
3349
+ columns=self._post_segmentation.columns,
3350
+ )
3351
+
3352
+ # apply the pipeline
3353
+
3354
+ transform_pipeline.fit(X)
3355
+ segments = transform_pipeline.transform(X)
3356
+
3357
+ # restore the default pipeline options
3358
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
3359
+ kwargs=default_options,
3360
+ pipeline=transform_pipeline,
3361
+ message="The pipeline parameters after restoration are: ",
3362
+ )
3363
+ assert (
3364
+ len(unused_kwargs) == 0
3365
+ ), "All default options should be found when default parameter restoration is done"
3366
+
3367
+ return segments, builder_extended_y.generate_extended_y(segments)
3368
+
3369
+ def reconstruct_post_segmentation_output(
3370
+ self,
3371
+ epoch0: Epoch = None,
3372
+ epoch1: Epoch = None,
3373
+ extracted_segments_file_name: typing.Union[
3374
+ str, None
3375
+ ] = "extracted_segments.seg",
3376
+ epoch_additional_dimensions_lookup: typing.Dict[str, str] = dict(
3377
+ segment_id="segment_id", N_x="N_x", N_y="N_y", N_z="N_z"
3378
+ ),
3379
+ concatenate_name: str = "",
3380
+ **kwargs,
3381
+ ) -> typing.Union[
3382
+ typing.Tuple[np.ndarray, typing.Union[np.ndarray, None], np.ndarray], None
3383
+ ]:
3384
+ """
3385
+ 'reconstruct' the result that is achieved using the "PB_P3C2 class" pipeline, by applying
3386
+ ("Transform LLSV_and_PCA"), ("Transform Segmentation"),
3387
+ ("Transform Second Segmentation") ("Transform ExtractSegments")
3388
+ using, as input, segmented point clouds.
3389
+
3390
+ :param epoch0:
3391
+ Epoch object,
3392
+ contains as 'additional_dimensions' a segment_id column (mandatory)
3393
+ and optionally, precomputed normals as another 3 columns.
3394
+ :param epoch1:
3395
+ Epoch object,
3396
+ contains as 'additional_dimensions' a segment_id column (mandatory)
3397
+ and optionally, precomputed normals as another 3 columns.
3398
+ :param extracted_segments_file_name:
3399
+ out file
3400
+ The file has the following structure:
3401
+ numpy array with shape (n_segments_samples, 20) where the column structure is as following:
3402
+ [
3403
+ X_COLUMN, Y_COLUMN, Z_COLUMN, -> Center of Gravity
3404
+ EPOCH_ID_COLUMN, -> 0/1
3405
+ EIGENVALUE0_COLUMN, EIGENVALUE1_COLUMN, EIGENVALUE2_COLUMN,
3406
+ EIGENVECTOR_0_X_COLUMN, EIGENVECTOR_0_Y_COLUMN, EIGENVECTOR_0_Z_COLUMN,
3407
+ EIGENVECTOR_1_X_COLUMN, EIGENVECTOR_1_Y_COLUMN, EIGENVECTOR_1_Z_COLUMN,
3408
+ EIGENVECTOR_2_X_COLUMN, EIGENVECTOR_2_Y_COLUMN, EIGENVECTOR_2_Z_COLUMN, -> Normal vector
3409
+ LLSV_COLUMN, -> lowest local surface variation
3410
+ SEGMENT_ID_COLUMN,
3411
+ STANDARD_DEVIATION_COLUMN,
3412
+ NR_POINTS_PER_SEG_COLUMN,
3413
+ ]
3414
+ :param epoch_additional_dimensions_lookup:
3415
+ A dictionary that maps between the names of the columns used internally to identify:
3416
+ segment id of the points: "segment_id" -> Mandatory part of the epochs
3417
+ Normal x-axes vector: "N_x" -> Optionally part of the epochs
3418
+ Normal y-axes vector: "N_y" -> Optionally part of the epochs
3419
+ Normal z-axes vector: "N_z" -> Optionally part of the epochs
3420
+ and the names of the columns used by both epoch0 and epoch1.
3421
+ :param concatenate_name:
3422
+ String that is utilized to uniquely identify the same transformer between multiple pipelines.
3423
+ :param kwargs:
3424
+ Used for customize the default pipeline parameters.
3425
+ Getting the default parameters:
3426
+ e.g. "get_pipeline_options"
3427
+ In case this parameter is True, the method will print the pipeline options as kwargs.
3428
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
3429
+ In case of setting it, the result of computation at that step is dump as xyz file.
3430
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
3431
+
3432
+ this process is stateless
3433
+ :return:
3434
+ tuple
3435
+ [
3436
+ numpy array with shape (n, 4|7) corresponding to epoch0 and
3437
+ containing [x,y,z,segment_id] | [x,y,z,N_x,N_y,N_z,segment_id],
3438
+ numpy array with shape (m, 4|7) corresponding to epoch1 and
3439
+ containing [x,y,z,segment_id] | [x,y,z,N_x,N_y,N_z,segment_id] | None,
3440
+ numpy array with shape (p, 20) corresponding to extracted_segments
3441
+ ]
3442
+ | None
3443
+ """
3444
+
3445
+ transform_pipeline = Pipeline(
3446
+ [
3447
+ (
3448
+ concatenate_name + "_Transform_Post Segmentation",
3449
+ self._post_segmentation,
3450
+ ),
3451
+ (
3452
+ concatenate_name + "_Transform_ExtractSegments",
3453
+ self._extract_segments,
3454
+ ),
3455
+ ]
3456
+ )
3457
+
3458
+ # print the default parameters
3459
+ PBM3C2._print_default_parameters(
3460
+ kwargs=kwargs, pipeline_param_dict=transform_pipeline.get_params()
3461
+ )
3462
+
3463
+ # no computation
3464
+ if epoch0 is None: # or epoch1 is None:
3465
+ # logger.info("epoch0 is required, no parameter changes applied")
3466
+ return
3467
+
3468
+ # save the default pipeline options
3469
+ default_options = transform_pipeline.get_params()
3470
+
3471
+ # overwrite the default parameters
3472
+ PBM3C2._overwrite_pipeline_parameters(
3473
+ kwargs=kwargs, pipeline=transform_pipeline
3474
+ )
3475
+
3476
+ # extract columns
3477
+
3478
+ epoch0_normals = _extract_from_additional_dimensions(
3479
+ epoch=epoch0,
3480
+ column_names=[
3481
+ epoch_additional_dimensions_lookup["N_x"],
3482
+ epoch_additional_dimensions_lookup["N_y"],
3483
+ epoch_additional_dimensions_lookup["N_z"],
3484
+ ],
3485
+ required_number_of_columns=[0, 3],
3486
+ )
3487
+
3488
+ epoch0_segment_id = _extract_from_additional_dimensions(
3489
+ epoch=epoch0,
3490
+ column_names=[
3491
+ epoch_additional_dimensions_lookup["segment_id"],
3492
+ ],
3493
+ required_number_of_columns=[1],
3494
+ )
3495
+
3496
+ epoch0 = np.concatenate(
3497
+ (epoch0.cloud, epoch0_normals, epoch0_segment_id),
3498
+ axis=1,
3499
+ )
3500
+
3501
+ if epoch1 != None:
3502
+ epoch1_normals = _extract_from_additional_dimensions(
3503
+ epoch=epoch1,
3504
+ column_names=[
3505
+ epoch_additional_dimensions_lookup["N_x"],
3506
+ epoch_additional_dimensions_lookup["N_y"],
3507
+ epoch_additional_dimensions_lookup["N_z"],
3508
+ ],
3509
+ required_number_of_columns=[0, 3],
3510
+ )
3511
+
3512
+ epoch1_segment_id = _extract_from_additional_dimensions(
3513
+ epoch=epoch1,
3514
+ column_names=[
3515
+ epoch_additional_dimensions_lookup["segment_id"],
3516
+ ],
3517
+ required_number_of_columns=[1],
3518
+ )
3519
+
3520
+ epoch1 = np.concatenate(
3521
+ (epoch1.cloud, epoch1_normals, epoch1_segment_id),
3522
+ axis=1,
3523
+ )
3524
+
3525
+ epochs_list = [epoch0]
3526
+ if isinstance(epoch1, np.ndarray):
3527
+ epochs_list.append(epoch1)
3528
+
3529
+ X = None
3530
+ for epoch_id, current_epoch in enumerate(epochs_list):
3531
+ if current_epoch.shape[1] == 4:
3532
+ # [x, y, z, segment_id] columns
3533
+ assert self._post_segmentation.compute_normal, (
3534
+ "The reconstruction process doesn't have, as input, the Normal vector columns, hence, "
3535
+ "the normal vector computation is mandatory."
3536
+ )
3537
+
3538
+ logger.info(
3539
+ f"Reconstruct post segmentation output using [x, y, z, segment_id] "
3540
+ f"columns from epoch{epoch_id} "
3541
+ )
3542
+
3543
+ if X is not None:
3544
+ X = np.vstack(
3545
+ (
3546
+ X,
3547
+ self._reconstruct_input_without_normals(
3548
+ epoch=current_epoch,
3549
+ epoch_id=epoch_id,
3550
+ columns=self._post_segmentation.columns,
3551
+ ),
3552
+ )
3553
+ )
3554
+ else:
3555
+ X = self._reconstruct_input_without_normals(
3556
+ epoch=current_epoch,
3557
+ epoch_id=epoch_id,
3558
+ columns=self._post_segmentation.columns,
3559
+ )
3560
+ else:
3561
+ # [x, y, z, N_x, N_y, N_z, segment_id] columns
3562
+ logger.info(
3563
+ f"Reconstruct post segmentation output using [x, y, z, N_x, N_y, N_z, segment_id] "
3564
+ f"columns from epoch{epoch_id}"
3565
+ )
3566
+
3567
+ if X is not None:
3568
+ X = np.vstack(
3569
+ (
3570
+ X,
3571
+ self._reconstruct_input_with_normals(
3572
+ epoch=current_epoch,
3573
+ epoch_id=epoch_id,
3574
+ columns=self._post_segmentation.columns,
3575
+ ),
3576
+ )
3577
+ )
3578
+ else:
3579
+ X = self._reconstruct_input_with_normals(
3580
+ epoch=current_epoch,
3581
+ epoch_id=epoch_id,
3582
+ columns=self._post_segmentation.columns,
3583
+ )
3584
+
3585
+ # apply the pipeline
3586
+
3587
+ transform_pipeline.fit(X)
3588
+ extracted_segments = transform_pipeline.transform(X)
3589
+
3590
+ if extracted_segments_file_name is not None:
3591
+ logger.info(f"'Segments' saved in file: {extracted_segments_file_name}")
3592
+ np.savetxt(extracted_segments_file_name, extracted_segments, delimiter=",")
3593
+ else:
3594
+ logger.debug(f"No file name set as output for 'segments'")
3595
+
3596
+ # restore the default pipeline options
3597
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
3598
+ kwargs=default_options,
3599
+ pipeline=transform_pipeline,
3600
+ message="The pipeline parameters after restoration are: ",
3601
+ )
3602
+ assert (
3603
+ len(unused_kwargs) == 0
3604
+ ), "All default options should be found when default parameter restoration is done"
3605
+
3606
+ return epoch0, epoch1, extracted_segments
3607
+
3608
+ def predict(
3609
+ self,
3610
+ epoch0: Epoch = None,
3611
+ epoch1: Epoch = None,
3612
+ epoch_additional_dimensions_lookup: typing.Dict[str, str] = dict(
3613
+ segment_id="segment_id", N_x="N_x", N_y="N_y", N_z="N_z"
3614
+ ),
3615
+ **kwargs,
3616
+ ) -> typing.Union[np.ndarray, None]:
3617
+ """
3618
+ After the reconstruction of the result that is achieved using the "PB_P3C2 class" pipeline, by applying
3619
+ ("Transform LLSV_and_PCA"), ("Transform Segmentation"), ("Transform Second Segmentation"), ("Transform ExtractSegments")
3620
+ applied to the segmented point cloud of epoch0 and epoch1, it returns a numpy array of corresponding
3621
+ pairs of segments between epoch 0 and epoch 1.
3622
+
3623
+ :param epoch0:
3624
+ Epoch object,
3625
+ contains as 'additional_dimensions' a segment_id column ( mandatory )
3626
+ and optionally, precomputed normals as another 3 columns.
3627
+ ( the structure must be consistent with the structure of epoch1 parameter )
3628
+ :param epoch1:
3629
+ Epoch object.
3630
+ contains as 'additional_dimensions' a segment_id column ( mandatory )
3631
+ and optionally, precomputed normals as another 3 columns.
3632
+ ( the structure must be consistent with the structure of epoch0 parameter )
3633
+ :param epoch_additional_dimensions_lookup:
3634
+ A dictionary that maps between the names of the columns used internally to identify:
3635
+ segment id of the points: "segment_id" -> Mandatory part of the epochs
3636
+ Normal x-axes vector: "N_x" -> Optionally part of the epochs
3637
+ Normal y-axes vector: "N_y" -> Optionally part of the epochs
3638
+ Normal z-axes vector: "N_z" -> Optionally part of the epochs
3639
+ and the names of the columns used by both epoch0 and epoch1.
3640
+ :param kwargs:
3641
+ Used for customize the default pipeline parameters.
3642
+
3643
+ Getting the default parameters:
3644
+ e.g. "get_pipeline_options"
3645
+ In case this parameter is True, the method will print the pipeline options as kwargs.
3646
+
3647
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
3648
+ In case of setting it, the result of computation at that step is dump as xyz file.
3649
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
3650
+
3651
+ this process is stateless
3652
+ :return:
3653
+ A numpy array of shape ( n_pairs, segment_size*2 ) where each row contains a pair of segments.
3654
+ """
3655
+
3656
+ predicting_pipeline = Pipeline(
3657
+ [
3658
+ ("Transform_Post_Segmentation", self._post_segmentation),
3659
+ ("Transform_ExtractSegments", self._extract_segments),
3660
+ ("Classifier", self._classifier),
3661
+ ]
3662
+ )
3663
+
3664
+ # print the default parameters
3665
+ PBM3C2._print_default_parameters(
3666
+ kwargs=kwargs, pipeline_param_dict=predicting_pipeline.get_params()
3667
+ )
3668
+
3669
+ # no computation
3670
+ if epoch0 is None or epoch1 is None:
3671
+ # logger.info("epoch0 and epoch1 are required, no parameter changes applied")
3672
+ return
3673
+
3674
+ # save the default pipeline options
3675
+ default_options = predicting_pipeline.get_params()
3676
+
3677
+ # overwrite the default parameters
3678
+ PBM3C2._overwrite_pipeline_parameters(
3679
+ kwargs=kwargs, pipeline=predicting_pipeline
3680
+ )
3681
+
3682
+ # extract columns
3683
+
3684
+ epoch0_normals = _extract_from_additional_dimensions(
3685
+ epoch=epoch0,
3686
+ column_names=[
3687
+ epoch_additional_dimensions_lookup["N_x"],
3688
+ epoch_additional_dimensions_lookup["N_y"],
3689
+ epoch_additional_dimensions_lookup["N_z"],
3690
+ ],
3691
+ required_number_of_columns=[0, 3],
3692
+ )
3693
+
3694
+ epoch0_segment_id = _extract_from_additional_dimensions(
3695
+ epoch=epoch0,
3696
+ column_names=[
3697
+ epoch_additional_dimensions_lookup["segment_id"],
3698
+ ],
3699
+ required_number_of_columns=[1],
3700
+ )
3701
+
3702
+ epoch0 = np.concatenate(
3703
+ (epoch0.cloud, epoch0_normals, epoch0_segment_id),
3704
+ axis=1,
3705
+ )
3706
+
3707
+ epoch1_normals = _extract_from_additional_dimensions(
3708
+ epoch=epoch1,
3709
+ column_names=[
3710
+ epoch_additional_dimensions_lookup["N_x"],
3711
+ epoch_additional_dimensions_lookup["N_y"],
3712
+ epoch_additional_dimensions_lookup["N_z"],
3713
+ ],
3714
+ required_number_of_columns=[0, 3],
3715
+ )
3716
+
3717
+ epoch1_segment_id = _extract_from_additional_dimensions(
3718
+ epoch=epoch1,
3719
+ column_names=[
3720
+ epoch_additional_dimensions_lookup["segment_id"],
3721
+ ],
3722
+ required_number_of_columns=[1],
3723
+ )
3724
+
3725
+ epoch1 = np.concatenate(
3726
+ (epoch1.cloud, epoch1_normals, epoch1_segment_id),
3727
+ axis=1,
3728
+ )
3729
+
3730
+ X = None
3731
+ for epoch_id, current_epoch in enumerate([epoch0, epoch1]):
3732
+ if current_epoch.shape[1] == 4:
3733
+ # [x, y, z, segment_id] columns
3734
+ assert self._post_segmentation.compute_normal, (
3735
+ "The reconstruction process doesn't have, as input, the Normal vector columns, hence, "
3736
+ "the normal vector computation is mandatory."
3737
+ )
3738
+
3739
+ logger.info(
3740
+ f"Reconstruct post segmentation output using [x, y, z, segment_id] "
3741
+ f"columns from epoch{epoch_id} "
3742
+ )
3743
+
3744
+ if X is not None:
3745
+ X = np.vstack(
3746
+ (
3747
+ X,
3748
+ self._reconstruct_input_without_normals(
3749
+ epoch=current_epoch,
3750
+ epoch_id=epoch_id,
3751
+ columns=self._post_segmentation.columns,
3752
+ ),
3753
+ )
3754
+ )
3755
+ else:
3756
+ X = self._reconstruct_input_without_normals(
3757
+ epoch=current_epoch,
3758
+ epoch_id=epoch_id,
3759
+ columns=self._post_segmentation.columns,
3760
+ )
3761
+ else:
3762
+ # [x, y, z, N_x, N_y, N_z, segment_id] columns
3763
+ logger.info(
3764
+ f"Reconstruct post segmentation output using [x, y, z, N_x, N_y, N_z, segment_id] "
3765
+ f"columns from epoch{epoch_id}"
3766
+ )
3767
+
3768
+ if X is not None:
3769
+ X = np.vstack(
3770
+ (
3771
+ X,
3772
+ self._reconstruct_input_with_normals(
3773
+ epoch=current_epoch,
3774
+ epoch_id=epoch_id,
3775
+ columns=self._post_segmentation.columns,
3776
+ ),
3777
+ )
3778
+ )
3779
+ else:
3780
+ X = self._reconstruct_input_with_normals(
3781
+ epoch=current_epoch,
3782
+ epoch_id=epoch_id,
3783
+ columns=self._post_segmentation.columns,
3784
+ )
3785
+
3786
+ # apply the pipeline
3787
+
3788
+ out = predicting_pipeline.predict(X)
3789
+
3790
+ # restore the default pipeline options
3791
+ unused_kwargs = PBM3C2._overwrite_pipeline_parameters(
3792
+ kwargs=default_options,
3793
+ pipeline=predicting_pipeline,
3794
+ message="The pipeline parameters after restoration are: ",
3795
+ )
3796
+ assert (
3797
+ len(unused_kwargs) == 0
3798
+ ), "All default options should be found when default parameter restoration is done"
3799
+
3800
+ return out
3801
+
3802
+ def compute_distances(
3803
+ self,
3804
+ epoch0: Epoch = None,
3805
+ epoch1: Epoch = None,
3806
+ alignment_error: float = 1.1,
3807
+ epoch_additional_dimensions_lookup: typing.Dict[str, str] = dict(
3808
+ segment_id="segment_id", N_x="N_x", N_y="N_y", N_z="N_z"
3809
+ ),
3810
+ **kwargs,
3811
+ ) -> typing.Union[typing.Tuple[np.ndarray, np.ndarray], None]:
3812
+ """
3813
+ Compute the distance between 2 epochs. It also adds the following properties at the end of the computation:
3814
+ distances, corepoints (corepoints of epoch0), epochs (epoch0, epoch1), uncertainties
3815
+
3816
+ :param epoch0:
3817
+ Epoch object,
3818
+ contains as 'additional_dimensions' a segment_id column (mandatory)
3819
+ and optionally, precomputed normals as another 3 columns.
3820
+ :param epoch1:
3821
+ Epoch object,
3822
+ contains as 'additional_dimensions' a segment_id column (mandatory)
3823
+ and optionally, precomputed normals as another 3 columns.
3824
+ :param alignment_error:
3825
+ alignment error reg between point clouds.
3826
+ :param epoch_additional_dimensions_lookup:
3827
+ A dictionary that maps between the names of the columns used internally to identify:
3828
+ segment id of the points: "segment_id" -> Mandatory part of the epochs
3829
+ Normal x-axes vector: "N_x" -> Optionally part of the epochs
3830
+ Normal y-axes vector: "N_y" -> Optionally part of the epochs
3831
+ Normal z-axes vector: "N_z" -> Optionally part of the epochs
3832
+ and the names of the columns used by both epoch0 and epoch1.
3833
+ :param kwargs:
3834
+ Used for customize the default pipeline parameters.
3835
+
3836
+ Getting the default parameters:
3837
+ e.g. "get_pipeline_options"
3838
+ In case this parameter is True, the method will print the pipeline options as kwargs.
3839
+
3840
+ e.g. "output_file_name" (of a specific step in the pipeline) default value is "None".
3841
+ In case of setting it, the result of computation at that step is dump as xyz file.
3842
+ e.g. "distance_3D_threshold" (part of Segmentation Transform)
3843
+
3844
+ this process is stateless
3845
+
3846
+ :return:
3847
+ tuple [distances, uncertainties]
3848
+ 'distances' is np.array (nr_similar_pairs, 1)
3849
+ 'uncertainties' is np array (nr_similar_pairs, 1) and it has the following structure:
3850
+ dtype=np.dtype(
3851
+ [
3852
+ ("lodetection", "<f8"),
3853
+ ("spread1", "<f8"),
3854
+ ("num_samples1", "<i8"),
3855
+ ("spread2", "<f8"),
3856
+ ("num_samples2", "<i8"),
3857
+ ]
3858
+ )
3859
+ | None
3860
+ """
3861
+
3862
+ logger.info(f"PBM3C2WithSegments.compute_distances(...)")
3863
+
3864
+ return super()._compute_distances(
3865
+ epoch0_info=epoch0,
3866
+ epoch1=epoch1,
3867
+ alignment_error=alignment_error,
3868
+ epoch_additional_dimensions_lookup=epoch_additional_dimensions_lookup,
3869
+ **kwargs,
3870
+ )