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.
- _py4dgeo.cp39-win_amd64.pyd +0 -0
- py4dgeo/__init__.py +14 -3
- py4dgeo/epoch.py +424 -37
- py4dgeo/fallback.py +1 -1
- py4dgeo/m3c2.py +46 -1
- py4dgeo/m3c2ep.py +853 -0
- py4dgeo/pbm3c2.py +3865 -0
- py4dgeo/py4dgeo_python.cpp +401 -0
- py4dgeo/registration.py +104 -0
- py4dgeo/segmentation.py +141 -72
- py4dgeo/util.py +31 -35
- py4dgeo-0.6.0.dist-info/METADATA +203 -0
- py4dgeo-0.6.0.dist-info/RECORD +20 -0
- {py4dgeo-0.4.0.dist-info → py4dgeo-0.6.0.dist-info}/WHEEL +1 -1
- {py4dgeo-0.4.0.dist-info → py4dgeo-0.6.0.dist-info}/entry_points.txt +1 -0
- py4dgeo/_py4dgeo.cp39-win_amd64.pyd +0 -0
- py4dgeo-0.4.0.dist-info/METADATA +0 -21
- py4dgeo-0.4.0.dist-info/RECORD +0 -17
- py4dgeo-0.4.0.dist-info/top_level.txt +0 -1
- /py4dgeo/{zipfile.py → UpdateableZipFile.py} +0 -0
- {py4dgeo-0.4.0.dist-info → py4dgeo-0.6.0.dist-info/licenses}/COPYING.md +0 -0
- {py4dgeo-0.4.0.dist-info → py4dgeo-0.6.0.dist-info/licenses}/LICENSE.md +0 -0
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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__(
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
159
|
-
raise Py4DGeoError(
|
|
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(
|
|
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
|
|
253
|
-
|
|
254
|
-
:type
|
|
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
|
-
#
|
|
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
|
-
|
|
268
|
-
|
|
553
|
+
cloud = np.genfromtxt(
|
|
554
|
+
filename, dtype=np.float64, usecols=xyz_columns, **parse_opts
|
|
555
|
+
)
|
|
269
556
|
except ValueError:
|
|
270
|
-
raise Py4DGeoError(
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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(
|
|
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,
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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(
|
|
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
|