py4dgeo 0.4.0__cp39-cp39-win_amd64.whl → 0.6.0__cp39-cp39-win_amd64.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.
Binary file
py4dgeo/__init__.py CHANGED
@@ -1,7 +1,15 @@
1
1
  from py4dgeo.logger import set_py4dgeo_logfile
2
2
  from py4dgeo.cloudcompare import CloudCompareM3C2
3
- from py4dgeo.epoch import Epoch, read_from_las, read_from_xyz, save_epoch, load_epoch
4
- from py4dgeo.m3c2 import M3C2
3
+ from py4dgeo.epoch import (
4
+ Epoch,
5
+ read_from_las,
6
+ read_from_xyz,
7
+ save_epoch,
8
+ load_epoch,
9
+ )
10
+ from py4dgeo.m3c2 import M3C2, write_m3c2_results_to_las
11
+ from py4dgeo.m3c2ep import M3C2EP
12
+ from py4dgeo.registration import iterative_closest_point
5
13
  from py4dgeo.segmentation import (
6
14
  RegionGrowingAlgorithm,
7
15
  SpatiotemporalAnalysis,
@@ -9,9 +17,12 @@ from py4dgeo.segmentation import (
9
17
  temporal_averaging,
10
18
  )
11
19
  from py4dgeo.util import (
12
- ensure_test_data_availability,
20
+ __version__,
21
+ find_file,
13
22
  MemoryPolicy,
14
23
  set_memory_policy,
15
24
  get_num_threads,
16
25
  set_num_threads,
17
26
  )
27
+
28
+ from py4dgeo.pbm3c2 import *
py4dgeo/epoch.py CHANGED
@@ -1,3 +1,5 @@
1
+ from py4dgeo.logger import logger_context
2
+ from py4dgeo.registration import Transformation
1
3
  from py4dgeo.util import (
2
4
  Py4DGeoError,
3
5
  append_file_extension,
@@ -6,6 +8,7 @@ from py4dgeo.util import (
6
8
  make_contiguous,
7
9
  is_iterable,
8
10
  )
11
+ from numpy.lib.recfunctions import append_fields
9
12
 
10
13
  import dateparser
11
14
  import datetime
@@ -15,28 +18,56 @@ import logging
15
18
  import numpy as np
16
19
  import os
17
20
  import tempfile
21
+ import typing
18
22
  import zipfile
19
23
 
20
- import py4dgeo._py4dgeo as _py4dgeo
21
-
24
+ import _py4dgeo
22
25
 
23
26
  logger = logging.getLogger("py4dgeo")
24
27
 
25
-
26
28
  # This integer controls the versioning of the epoch file format. Whenever the
27
29
  # format is changed, this version should be increased, so that py4dgeo can warn
28
30
  # about incompatibilities of py4dgeo with loaded data. This version is intentionally
29
31
  # different from py4dgeo's version, because not all releases of py4dgeo necessarily
30
32
  # change the epoch file format and we want to be as compatible as possible.
31
- PY4DGEO_EPOCH_FILE_FORMAT_VERSION = 2
33
+ PY4DGEO_EPOCH_FILE_FORMAT_VERSION = 4
34
+
35
+
36
+ class NumpyArrayEncoder(json.JSONEncoder):
37
+ def default(self, obj):
38
+ if isinstance(obj, np.ndarray):
39
+ return obj.tolist()
40
+ return json.JSONEncoder.default(self, obj)
32
41
 
33
42
 
34
43
  class Epoch(_py4dgeo.Epoch):
35
- def __init__(self, cloud: np.ndarray, timestamp=None):
44
+ def __init__(
45
+ self,
46
+ cloud: np.ndarray,
47
+ normals: np.ndarray = None,
48
+ additional_dimensions: np.ndarray = None,
49
+ timestamp=None,
50
+ scanpos_info: dict = None,
51
+ ):
36
52
  """
37
53
 
38
54
  :param cloud:
39
55
  The point cloud array of shape (n, 3).
56
+
57
+ :param normals:
58
+ The point cloud normals of shape (n, 3) where n is the
59
+ same as the number of points in the point cloud.
60
+
61
+ :param additional_dimensions:
62
+ A numpy array of additional, per-point data in the point cloud. The
63
+ numpy data type is expected to be a structured dtype, so that the data
64
+ columns are accessible by their name.
65
+
66
+ :param timestamp:
67
+ The point cloud timestamp, default is None.
68
+
69
+ :param scanpos_info:
70
+ The point scan positions information, default is None..
40
71
  """
41
72
  # Check the given array shapes
42
73
  if len(cloud.shape) != 2 or cloud.shape[1] != 3:
@@ -46,12 +77,66 @@ class Epoch(_py4dgeo.Epoch):
46
77
  cloud = as_double_precision(cloud)
47
78
  cloud = make_contiguous(cloud)
48
79
 
80
+ # Set identity transformation
81
+ self._transformations = []
82
+
83
+ # Make sure that given normals are DP and contiguous as well
84
+ if normals is not None:
85
+ normals = make_contiguous(as_double_precision(normals))
86
+ self._normals = normals
87
+
49
88
  # Set metadata properties
50
89
  self.timestamp = timestamp
90
+ self.scanpos_info = scanpos_info
91
+
92
+ # Set the additional information (e.g. segment ids, normals, etc)
93
+ self.additional_dimensions = additional_dimensions
51
94
 
52
95
  # Call base class constructor
53
96
  super().__init__(cloud)
54
97
 
98
+ @property
99
+ def normals(self):
100
+ # Maybe calculate normals
101
+ if self._normals is None:
102
+ raise Py4DGeoError(
103
+ "Normals for this Epoch have not been calculated! Please use Epoch.calculate_normals or load externally calculated normals."
104
+ )
105
+
106
+ return self._normals
107
+
108
+ def calculate_normals(
109
+ self, radius=1.0, orientation_vector: np.ndarray = np.array([0, 0, 1])
110
+ ):
111
+ """Calculate point cloud normals
112
+
113
+ :param radius:
114
+ The radius used to determine the neighborhood of a point.
115
+
116
+ :param orientation_vector:
117
+ A vector to determine orientation of the normals. It should point "up".
118
+ """
119
+
120
+ # Ensure that the KDTree is built
121
+ if self.kdtree.leaf_parameter() == 0:
122
+ self.build_kdtree()
123
+
124
+ # Allocate memory for the normals
125
+ self._normals = np.empty(self.cloud.shape, dtype=np.float64)
126
+
127
+ # Reuse the multiscale code with a single radius in order to
128
+ # avoid code duplication.
129
+ with logger_context("Calculating point cloud normals:"):
130
+ _py4dgeo.compute_multiscale_directions(
131
+ self,
132
+ self.cloud,
133
+ [radius],
134
+ orientation_vector,
135
+ self._normals,
136
+ )
137
+
138
+ return self.normals
139
+
55
140
  @property
56
141
  def timestamp(self):
57
142
  return self._timestamp
@@ -60,6 +145,46 @@ class Epoch(_py4dgeo.Epoch):
60
145
  def timestamp(self, timestamp):
61
146
  self._timestamp = normalize_timestamp(timestamp)
62
147
 
148
+ @property
149
+ def scanpos_info(self):
150
+ return self._scanpos_info
151
+
152
+ @scanpos_info.setter
153
+ def scanpos_info(self, scanpos_info):
154
+ if isinstance(scanpos_info, list):
155
+ self._scanpos_info = scanpos_info
156
+ elif isinstance(scanpos_info, dict):
157
+ self._scanpos_info = scan_positions_info_from_dict(scanpos_info)
158
+ else:
159
+ self._scanpos_info = None
160
+
161
+ @property
162
+ def scanpos_id(self):
163
+ return (
164
+ self.additional_dimensions["scanpos_id"]
165
+ .reshape(self.cloud.shape[0])
166
+ .astype(np.int32)
167
+ )
168
+
169
+ @scanpos_id.setter
170
+ def scanpos_id(self, scanpos_id):
171
+ if self.additional_dimensions is None:
172
+ additional_columns = np.empty(
173
+ shape=(self.cloud.shape[0], 1),
174
+ dtype=np.dtype([("scanpos_id", "<i4")]),
175
+ )
176
+ additional_columns["scanpos_id"] = np.array(
177
+ scanpos_id, dtype=np.int32
178
+ ).reshape(-1, 1)
179
+ self.additional_dimensions = additional_columns
180
+ else:
181
+ scanpos_id = np.array(scanpos_id, dtype=np.int32)
182
+ new_additional_dimensions = append_fields(
183
+ self.additional_dimensions, "scanpos_id", scanpos_id, usemask=False
184
+ )
185
+
186
+ self.additional_dimensions = new_additional_dimensions
187
+
63
188
  @property
64
189
  def metadata(self):
65
190
  """Provide the metadata of this epoch as a Python dictionary
@@ -72,6 +197,7 @@ class Epoch(_py4dgeo.Epoch):
72
197
 
73
198
  return {
74
199
  "timestamp": None if self.timestamp is None else str(self.timestamp),
200
+ "scanpos_info": None if self.scanpos_info is None else self.scanpos_info,
75
201
  }
76
202
 
77
203
  def build_kdtree(self, leaf_size=10, force_rebuild=False):
@@ -91,6 +217,89 @@ class Epoch(_py4dgeo.Epoch):
91
217
  logger.info(f"Building KDTree structure with leaf parameter {leaf_size}")
92
218
  self.kdtree.build_tree(leaf_size)
93
219
 
220
+ def transform(
221
+ self,
222
+ transformation: typing.Optional[Transformation] = None,
223
+ affine_transformation: typing.Optional[np.ndarray] = None,
224
+ rotation: typing.Optional[np.ndarray] = None,
225
+ translation: typing.Optional[np.ndarray] = None,
226
+ reduction_point: typing.Optional[np.ndarray] = None,
227
+ ):
228
+ """Transform the epoch with an affine transformation
229
+
230
+ :param transformation:
231
+ A Transformation object that describes the transformation to apply.
232
+ If this argument is given, the other arguments are ignored.
233
+ This parameter is typically used if the transformation was calculated
234
+ by py4dgeo itself.
235
+ :type transformation: Transformation
236
+ :param affine_transformation:
237
+ A 4x4 or 3x4 matrix representing the affine transformation. Given
238
+ as a numpy array. If this argument is given, the rotation and
239
+ translation arguments are ignored.
240
+ :type transformation: np.ndarray
241
+ :param rotation:
242
+ A 3x3 matrix specifying the rotation to apply
243
+ :type rotation: np.ndarray
244
+ :param translation:
245
+ A vector specifying the translation to apply
246
+ :type translation: np.ndarray
247
+ :param reduction_point:
248
+ A translation vector to apply before applying rotation and scaling.
249
+ This is used to increase the numerical accuracy of transformation.
250
+ If a transformation is given, this argument is ignored.
251
+ :type reduction_point: np.ndarray
252
+ """
253
+
254
+ # Extract the affine transformation and reduction point from the given transformation
255
+ if transformation is not None:
256
+ assert isinstance(transformation, Transformation)
257
+ affine_transformation = transformation.affine_transformation
258
+ reduction_point = transformation.reduction_point
259
+
260
+ # Build the transformation if it is not explicitly given
261
+ if affine_transformation is None:
262
+ trafo = np.identity(4, dtype=np.float64)
263
+ trafo[:3, :3] = rotation
264
+ trafo[:3, 3] = translation
265
+ else:
266
+ # If it was given, make a copy and potentially resize it
267
+ trafo = affine_transformation.copy()
268
+ if trafo.shape[0] == 3:
269
+ trafo.resize((4, 4), refcheck=False)
270
+ trafo[3, 3] = 1
271
+
272
+ if reduction_point is None:
273
+ reduction_point = np.array([0, 0, 0], dtype=np.float64)
274
+
275
+ # Ensure contiguous DP memory
276
+ trafo = as_double_precision(make_contiguous(trafo))
277
+
278
+ # Invalidate the KDTree
279
+ self.kdtree.invalidate()
280
+
281
+ # Apply the actual transformation as efficient C++
282
+ _py4dgeo.transform_pointcloud_inplace(self.cloud, trafo, reduction_point)
283
+
284
+ # Store the transformation
285
+ self._transformations.append(
286
+ Transformation(affine_transformation=trafo, reduction_point=reduction_point)
287
+ )
288
+
289
+ @property
290
+ def transformation(self):
291
+ """Access the affine transformations that were applied to this epoch
292
+
293
+ In order to set this property please use the transform method instead,
294
+ which will make sure to also apply the transformation.
295
+
296
+ :returns:
297
+ Returns a list of applied transformations. These are given
298
+ as a tuple of a 4x4 matrix defining the affine transformation
299
+ and the reduction point used when applying it.
300
+ """
301
+ return self._transformations
302
+
94
303
  def save(self, filename):
95
304
  """Save this epoch to a file
96
305
 
@@ -109,7 +318,6 @@ class Epoch(_py4dgeo.Epoch):
109
318
  with zipfile.ZipFile(
110
319
  filename, mode="w", compression=zipfile.ZIP_BZIP2
111
320
  ) as zf:
112
-
113
321
  # Write the epoch file format version number
114
322
  zf.writestr("EPOCH_FILE_FORMAT", str(PY4DGEO_EPOCH_FILE_FORMAT_VERSION))
115
323
 
@@ -119,14 +327,55 @@ class Epoch(_py4dgeo.Epoch):
119
327
  json.dump(self.metadata, f)
120
328
  zf.write(metadatafile, arcname="metadata.json")
121
329
 
330
+ # Write the transformation into a file
331
+ trafofile = os.path.join(tmp_dir, "trafo.json")
332
+ with open(trafofile, "w") as f:
333
+ json.dump(
334
+ [t.__dict__ for t in self._transformations],
335
+ f,
336
+ cls=NumpyArrayEncoder,
337
+ )
338
+ zf.write(trafofile, arcname="trafo.json")
339
+
122
340
  # Write the actual point cloud array using laspy - LAZ compression
123
341
  # is far better than any compression numpy + zipfile can do.
124
342
  cloudfile = os.path.join(tmp_dir, "cloud.laz")
125
- header = laspy.LasHeader(version="1.4", point_format=6)
126
- lasfile = laspy.LasData(header)
343
+ hdr = laspy.LasHeader(version="1.4", point_format=6)
344
+ hdr.x_scale = 0.00025
345
+ hdr.y_scale = 0.00025
346
+ hdr.z_scale = 0.00025
347
+ mean_extent = np.mean(self.cloud, axis=0)
348
+ hdr.x_offset = int(mean_extent[0])
349
+ hdr.y_offset = int(mean_extent[1])
350
+ hdr.z_offset = int(mean_extent[2])
351
+ lasfile = laspy.LasData(hdr)
127
352
  lasfile.x = self.cloud[:, 0]
128
353
  lasfile.y = self.cloud[:, 1]
129
354
  lasfile.z = self.cloud[:, 2]
355
+
356
+ # define dimensions for normals below:
357
+ if self._normals is not None:
358
+ lasfile.add_extra_dim(
359
+ laspy.ExtraBytesParams(
360
+ name="NormalX", type="f8", description="X axis of normals"
361
+ )
362
+ )
363
+ lasfile.add_extra_dim(
364
+ laspy.ExtraBytesParams(
365
+ name="NormalY", type="f8", description="Y axis of normals"
366
+ )
367
+ )
368
+ lasfile.add_extra_dim(
369
+ laspy.ExtraBytesParams(
370
+ name="NormalZ", type="f8", description="Z axis of normals"
371
+ )
372
+ )
373
+ lasfile.NormalX = self.normals[:, 0]
374
+ lasfile.NormalY = self.normals[:, 1]
375
+ lasfile.NormalZ = self.normals[:, 2]
376
+ else:
377
+ logger.info("Saving a file without normals.")
378
+
130
379
  lasfile.write(cloudfile)
131
380
  zf.write(cloudfile, arcname="cloud.laz")
132
381
 
@@ -152,11 +401,12 @@ class Epoch(_py4dgeo.Epoch):
152
401
  with tempfile.TemporaryDirectory() as tmp_dir:
153
402
  # Open the ZIP archive
154
403
  with zipfile.ZipFile(filename, mode="r") as zf:
155
-
156
404
  # Read the epoch file version number and compare to current
157
405
  version = int(zf.read("EPOCH_FILE_FORMAT").decode())
158
- if version != PY4DGEO_EPOCH_FILE_FORMAT_VERSION:
159
- raise Py4DGeoError("Epoch file format is out of date!")
406
+ if version > PY4DGEO_EPOCH_FILE_FORMAT_VERSION:
407
+ raise Py4DGeoError(
408
+ "Epoch file format not known - please update py4dgeo!"
409
+ )
160
410
 
161
411
  # Read the metadata JSON file
162
412
  metadatafile = zf.extract("metadata.json", path=tmp_dir)
@@ -167,14 +417,26 @@ class Epoch(_py4dgeo.Epoch):
167
417
  cloudfile = zf.extract("cloud.laz", path=tmp_dir)
168
418
  lasfile = laspy.read(cloudfile)
169
419
  cloud = np.vstack((lasfile.x, lasfile.y, lasfile.z)).transpose()
170
-
420
+ try:
421
+ normals = np.vstack(
422
+ (lasfile.NormalX, lasfile.NormalY, lasfile.NormalZ)
423
+ ).transpose()
424
+ except AttributeError:
425
+ normals = None
171
426
  # Construct the epoch object
172
- epoch = Epoch(cloud, **metadata)
427
+ epoch = Epoch(cloud, normals=normals, **metadata)
173
428
 
174
429
  # Restore the KDTree object
175
430
  kdtreefile = zf.extract("kdtree", path=tmp_dir)
176
431
  epoch.kdtree.load_index(kdtreefile)
177
432
 
433
+ # Read the transformation if it exists
434
+ if version >= 3:
435
+ trafofile = zf.extract("trafo.json", path=tmp_dir)
436
+ with open(trafofile, "r") as f:
437
+ trafo = json.load(f)
438
+ epoch._transformations = [Transformation(**t) for t in trafo]
439
+
178
440
  return epoch
179
441
 
180
442
  def __getstate__(self):
@@ -242,37 +504,95 @@ def _as_tuple(x):
242
504
  return (x,)
243
505
 
244
506
 
245
- def read_from_xyz(*filenames, other_epoch=None, **parse_opts):
507
+ def read_from_xyz(
508
+ *filenames,
509
+ xyz_columns=[0, 1, 2],
510
+ normal_columns=[],
511
+ additional_dimensions={},
512
+ **parse_opts,
513
+ ):
246
514
  """Create an epoch from an xyz file
247
515
 
248
516
  :param filename:
249
517
  The filename to read from. Each line in the input file is expected
250
518
  to contain three space separated numbers.
251
519
  :type filename: str
252
- :param other_epoch:
253
- An existing epoch that we want to be compatible with.
254
- :type other_epoch: py4dgeo.Epoch
520
+ :param xyz_columns:
521
+ The column indices of X, Y and Z coordinates. Defaults to [0, 1, 2].
522
+ :type xyz_columns: list
523
+ :param normal_columns:
524
+ The column indices of the normal vector components. Leave empty, if
525
+ your data file does not contain normals, otherwise exactly three indices
526
+ for the x, y and z components need to be given.
527
+ :type normal_columns: list
255
528
  :param parse_opts:
256
529
  Additional options forwarded to numpy.genfromtxt. This can be used
257
530
  to e.g. change the delimiter character, remove header_lines or manually
258
531
  specify which columns of the input contain the XYZ coordinates.
532
+ :param additional_dimensions:
533
+ A dictionary, mapping column indices to names of additional data dimensions.
534
+ They will be read from the file and are accessible under their names from the
535
+ created Epoch objects.
536
+ Additional column indexes start with 3.
259
537
  :type parse_opts: dict
260
538
  """
261
539
 
262
540
  # Resolve the given path
263
541
  filename = find_file(filenames[0])
264
542
 
265
- # Read the first cloud
543
+ # Ensure that usecols is not passed by the user, we need to use this
544
+ if "usecols" in parse_opts:
545
+ raise Py4DGeoError(
546
+ "read_from_xyz cannot be customized by using usecols, please use xyz_columns, normal_columns or additional_dimensions instead!"
547
+ )
548
+
549
+ # Read the point cloud
550
+ logger.info(f"Reading point cloud from file '{filename}'")
551
+
266
552
  try:
267
- logger.info(f"Reading point cloud from file '{filename}'")
268
- cloud = np.genfromtxt(filename, dtype=np.float64, **parse_opts)
553
+ cloud = np.genfromtxt(
554
+ filename, dtype=np.float64, usecols=xyz_columns, **parse_opts
555
+ )
269
556
  except ValueError:
270
- raise Py4DGeoError(
271
- "Malformed XYZ file - all rows are expected to have exactly three columns"
557
+ raise Py4DGeoError("Malformed XYZ file")
558
+
559
+ # Potentially read normals
560
+ normals = None
561
+ if normal_columns:
562
+ if len(normal_columns) != 3:
563
+ raise Py4DGeoError("normal_columns need to be a list of three integers!")
564
+
565
+ try:
566
+ normals = np.genfromtxt(
567
+ filename, dtype=np.float64, usecols=normal_columns, **parse_opts
568
+ )
569
+ except ValueError:
570
+ raise Py4DGeoError("Malformed XYZ file")
571
+
572
+ # Potentially read additional_dimensions passed by the user
573
+ additional_columns = np.empty(
574
+ shape=(cloud.shape[0], 1),
575
+ dtype=np.dtype([(name, "<f8") for name in additional_dimensions.values()]),
576
+ )
577
+
578
+ add_cols = list(sorted(additional_dimensions.keys()))
579
+ try:
580
+ parsed_additionals = np.genfromtxt(
581
+ filename, dtype=np.float64, usecols=add_cols, **parse_opts
272
582
  )
583
+ # Ensure that the parsed array is two-dimensional, even if only
584
+ # one additional dimension was given (avoids an edge case)
585
+ parsed_additionals = parsed_additionals.reshape(-1, 1)
586
+ except ValueError:
587
+ raise Py4DGeoError("Malformed XYZ file")
273
588
 
274
- # Construct the new Epoch object
275
- new_epoch = Epoch(cloud=cloud)
589
+ for i, col in enumerate(add_cols):
590
+ additional_columns[additional_dimensions[col]] = parsed_additionals[
591
+ :, i
592
+ ].reshape(-1, 1)
593
+
594
+ # Finalize the construction of the new epoch
595
+ new_epoch = Epoch(cloud, normals=normals, additional_dimensions=additional_columns)
276
596
 
277
597
  if len(filenames) == 1:
278
598
  # End recursion and return non-tuple to make the case that the user
@@ -281,20 +601,31 @@ def read_from_xyz(*filenames, other_epoch=None, **parse_opts):
281
601
  else:
282
602
  # Go into recursion
283
603
  return (new_epoch,) + _as_tuple(
284
- read_from_xyz(*filenames[1:], other_epoch=new_epoch, **parse_opts)
604
+ read_from_xyz(
605
+ *filenames[1:],
606
+ xyz_columns=xyz_columns,
607
+ normal_columns=normal_columns,
608
+ additional_dimensions=additional_dimensions,
609
+ **parse_opts,
610
+ )
285
611
  )
286
612
 
287
613
 
288
- def read_from_las(*filenames, other_epoch=None):
614
+ def read_from_las(*filenames, normal_columns=[], additional_dimensions={}):
289
615
  """Create an epoch from a LAS/LAZ file
290
616
 
291
617
  :param filename:
292
618
  The filename to read from. It is expected to be in LAS/LAZ format
293
619
  and will be processed using laspy.
294
620
  :type filename: str
295
- :param other_epoch:
296
- An existing epoch that we want to be compatible with.
297
- :type other_epoch: py4dgeo.Epoch
621
+ :param normal_columns:
622
+ The column names of the normal vector components, e.g. "NormalX", "nx", "normal_x" etc., keep in mind that there
623
+ must be exactly 3 columns. Leave empty, if your data file does not contain normals.
624
+ :type normal_columns: list
625
+ :param additional_dimensions:
626
+ A dictionary, mapping names of additional data dimensions in the input
627
+ dataset to additional data dimensions in our epoch data structure.
628
+ :type additional_dimensions: dict
298
629
  """
299
630
 
300
631
  # Resolve the given path
@@ -304,16 +635,44 @@ def read_from_las(*filenames, other_epoch=None):
304
635
  logger.info(f"Reading point cloud from file '{filename}'")
305
636
  lasfile = laspy.read(filename)
306
637
 
638
+ cloud = np.vstack(
639
+ (
640
+ lasfile.x,
641
+ lasfile.y,
642
+ lasfile.z,
643
+ )
644
+ ).transpose()
645
+
646
+ normals = None
647
+ if normal_columns:
648
+ if len(normal_columns) != 3:
649
+ raise Py4DGeoError("normal_columns need to be a list of three strings!")
650
+
651
+ normals = np.vstack(
652
+ [
653
+ lasfile.points[normal_columns[0]],
654
+ lasfile.points[normal_columns[1]],
655
+ lasfile.points[normal_columns[2]],
656
+ ]
657
+ ).transpose()
658
+
659
+ # set scan positions
660
+ # build additional_dimensions dtype structure
661
+ additional_columns = np.empty(
662
+ shape=(cloud.shape[0], 1),
663
+ dtype=np.dtype([(name, "<f8") for name in additional_dimensions.values()]),
664
+ )
665
+ for column_id, column_name in additional_dimensions.items():
666
+ additional_columns[column_name] = np.array(
667
+ lasfile.points[column_id], dtype=np.int32
668
+ ).reshape(-1, 1)
669
+
307
670
  # Construct Epoch and go into recursion
308
671
  new_epoch = Epoch(
309
- np.vstack(
310
- (
311
- lasfile.x,
312
- lasfile.y,
313
- lasfile.z,
314
- )
315
- ).transpose(),
672
+ cloud,
673
+ normals=normals,
316
674
  timestamp=lasfile.header.creation_date,
675
+ additional_dimensions=additional_columns,
317
676
  )
318
677
 
319
678
  if len(filenames) == 1:
@@ -323,7 +682,11 @@ def read_from_las(*filenames, other_epoch=None):
323
682
  else:
324
683
  # Go into recursion
325
684
  return (new_epoch,) + _as_tuple(
326
- read_from_las(*filenames[1:], other_epoch=new_epoch)
685
+ read_from_las(
686
+ *filenames[1:],
687
+ normal_columns=normal_columns,
688
+ additional_dimensions=additional_dimensions,
689
+ )
327
690
  )
328
691
 
329
692
 
@@ -356,3 +719,27 @@ def normalize_timestamp(timestamp):
356
719
  return parsed
357
720
 
358
721
  raise Py4DGeoError(f"The timestamp '{timestamp}' was not understood by py4dgeo.")
722
+
723
+
724
+ def scan_positions_info_from_dict(info_dict: dict):
725
+ if info_dict is None:
726
+ return None
727
+ if not isinstance(info_dict, dict):
728
+ raise Py4DGeoError(f"The input scan position information should be dictionary.")
729
+ return None
730
+ # Compatible with both integer key and string key as index of the scan positions in json file
731
+ # load scan positions from dictionary, standardize loading via json format dumps to string key
732
+ scanpos_dict_load = json.loads(json.dumps(info_dict))
733
+ sps_list = []
734
+ for i in range(1, 1 + len(scanpos_dict_load)):
735
+ sps_list.append(scanpos_dict_load[str(i)])
736
+
737
+ for sp in sps_list:
738
+ sp_check = True
739
+ sp_check = False if len(sp["origin"]) != 3 else sp_check
740
+ sp_check = False if not isinstance(sp["sigma_range"], float) else sp_check
741
+ sp_check = False if not isinstance(sp["sigma_scan"], float) else sp_check
742
+ sp_check = False if not isinstance(sp["sigma_yaw"], float) else sp_check
743
+ if not sp_check:
744
+ raise Py4DGeoError("Scan positions load failed, please check format. ")
745
+ return sps_list
py4dgeo/fallback.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from py4dgeo.m3c2 import M3C2
4
4
 
5
5
  import numpy as np
6
- import py4dgeo._py4dgeo as _py4dgeo
6
+ import _py4dgeo
7
7
 
8
8
 
9
9
  def radius_workingset_finder(params: _py4dgeo.WorkingSetFinderParameters) -> np.ndarray: