primlib 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primlib/__init__.py +6 -0
- primlib/_version.py +24 -0
- primlib/line_set.py +435 -0
- primlib/point_cloud.py +711 -0
- primlib/triangle_mesh.py +1910 -0
- primlib/utils.py +53 -0
- primlib/vertices.py +604 -0
- primlib/visualizer.py +179 -0
- primlib-0.3.0.dist-info/METADATA +124 -0
- primlib-0.3.0.dist-info/RECORD +13 -0
- primlib-0.3.0.dist-info/WHEEL +5 -0
- primlib-0.3.0.dist-info/licenses/LICENSE +22 -0
- primlib-0.3.0.dist-info/top_level.txt +1 -0
primlib/utils.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
import numpy as np
|
|
3
|
+
from numpy.typing import ArrayLike
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def scalar_to_color(
|
|
7
|
+
scalars: ArrayLike,
|
|
8
|
+
cmap: str = "viridis",
|
|
9
|
+
min_max: tuple[float, float] | None = None,
|
|
10
|
+
nan_color: tuple[float, float, float] = (1, 0, 0),
|
|
11
|
+
) -> np.ndarray:
|
|
12
|
+
scalars = np.asarray(scalars)
|
|
13
|
+
mask_valid = ~np.isnan(scalars)
|
|
14
|
+
if min_max is None:
|
|
15
|
+
min_max = (np.min(scalars[mask_valid]), np.max(scalars[mask_valid]))
|
|
16
|
+
scalars_norm = np.clip(
|
|
17
|
+
(scalars[mask_valid] - min_max[0]) / (min_max[1] - min_max[0]), 0, 1
|
|
18
|
+
)
|
|
19
|
+
cm = plt.get_cmap(cmap)
|
|
20
|
+
colors = np.empty((scalars.size, 3))
|
|
21
|
+
colors[mask_valid, :] = cm(scalars_norm)[:, 0:3]
|
|
22
|
+
colors[~mask_valid, :] = nan_color
|
|
23
|
+
return colors
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scalar_to_bicolor(
|
|
27
|
+
scalars: ArrayLike,
|
|
28
|
+
cmaps: tuple[str, str] = ("Blues", "Reds"),
|
|
29
|
+
vmax: float | None = None,
|
|
30
|
+
nan_color: tuple[float, float, float] = (0, 1, 0),
|
|
31
|
+
) -> np.ndarray:
|
|
32
|
+
scalars = np.asarray(scalars)
|
|
33
|
+
mask_valid = ~np.isnan(scalars)
|
|
34
|
+
mask_pos = scalars >= 0
|
|
35
|
+
mask_neg = ~mask_pos
|
|
36
|
+
neg = scalars[mask_valid & mask_neg]
|
|
37
|
+
pos = scalars[mask_valid & mask_pos]
|
|
38
|
+
if vmax is None:
|
|
39
|
+
if neg.size == 0:
|
|
40
|
+
vmax = np.max(pos)
|
|
41
|
+
elif pos.size == 0:
|
|
42
|
+
vmax = np.max(-neg)
|
|
43
|
+
else:
|
|
44
|
+
vmax = np.max((np.max(pos), np.max(-neg)))
|
|
45
|
+
scalars_norm_neg = np.clip(-neg / vmax, 0, 1)
|
|
46
|
+
scalars_norm_pos = np.clip(pos / vmax, 0, 1)
|
|
47
|
+
cm_neg = plt.get_cmap(cmaps[0])
|
|
48
|
+
cm_pos = plt.get_cmap(cmaps[1])
|
|
49
|
+
colors = np.empty((scalars.size, 3))
|
|
50
|
+
colors[mask_valid & mask_neg, :] = cm_neg(scalars_norm_neg)[:, 0:3]
|
|
51
|
+
colors[mask_valid & mask_pos, :] = cm_pos(scalars_norm_pos)[:, 0:3]
|
|
52
|
+
colors[~mask_valid, :] = nan_color
|
|
53
|
+
return colors
|
primlib/vertices.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import ArrayLike
|
|
8
|
+
from plyfile import PlyData, PlyElement
|
|
9
|
+
from scipy.spatial import KDTree
|
|
10
|
+
from trafolib.trafo3d import Trafo3d
|
|
11
|
+
|
|
12
|
+
from primlib.utils import scalar_to_bicolor, scalar_to_color
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Vertices:
|
|
16
|
+
"""
|
|
17
|
+
A class representing a set of vertices, normals and colors
|
|
18
|
+
|
|
19
|
+
Can be used as a base class for point clouds, line sets or triangle meshes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ("_colors", "_normals", "_vertices")
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
vertices: ArrayLike | None = None,
|
|
27
|
+
normals: ArrayLike | None = None,
|
|
28
|
+
colors: ArrayLike | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
# Define types and initialize
|
|
31
|
+
self._vertices: np.ndarray = np.zeros((0, 3))
|
|
32
|
+
self._normals: np.ndarray = np.zeros((0, 3))
|
|
33
|
+
self._colors: np.ndarray = np.zeros((0, 3))
|
|
34
|
+
# Initialize using parameters and setters
|
|
35
|
+
self.vertices = vertices
|
|
36
|
+
self.normals = normals
|
|
37
|
+
self.colors = colors
|
|
38
|
+
|
|
39
|
+
def __array__(
|
|
40
|
+
self,
|
|
41
|
+
dtype: Any = None,
|
|
42
|
+
copy: bool | None = None,
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
"""Convert vertices to a NumPy array.
|
|
45
|
+
|
|
46
|
+
:param dtype: Desired data type of the resulting array.
|
|
47
|
+
If None, the data type is inferred from the vertices.
|
|
48
|
+
:param copy: Whether to return a copy of the array.
|
|
49
|
+
If None, the copy behaviour is determined by NumPy.
|
|
50
|
+
:returns: NumPy array containing the vertices.
|
|
51
|
+
"""
|
|
52
|
+
if copy is True:
|
|
53
|
+
return np.array(self._vertices, dtype=dtype, copy=True)
|
|
54
|
+
return np.asarray(self._vertices, dtype=dtype)
|
|
55
|
+
|
|
56
|
+
def __getitem__(self, item: Any) -> np.ndarray:
|
|
57
|
+
"""Get vertices by index or slice.
|
|
58
|
+
|
|
59
|
+
Supports integer indexing, slicing, and advanced NumPy indexing.
|
|
60
|
+
|
|
61
|
+
:param item: Index, slice, or array of indices used to select vertices.
|
|
62
|
+
:returns: Selected vertex or vertices as a NumPy array.
|
|
63
|
+
"""
|
|
64
|
+
return self._vertices[item]
|
|
65
|
+
|
|
66
|
+
def __eq__(self, other: object) -> bool:
|
|
67
|
+
"""Check two vertices sets for equality.
|
|
68
|
+
|
|
69
|
+
:param other: Other object
|
|
70
|
+
:returns: True if vertices sets are equal
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(other, Vertices):
|
|
73
|
+
return NotImplemented
|
|
74
|
+
return (
|
|
75
|
+
# Vertices
|
|
76
|
+
self._vertices.shape == other._vertices.shape
|
|
77
|
+
and np.allclose(self._vertices, other._vertices)
|
|
78
|
+
# Normals
|
|
79
|
+
and self._normals.shape == other._normals.shape
|
|
80
|
+
and np.allclose(self._normals, other._normals)
|
|
81
|
+
# Colors
|
|
82
|
+
and self._colors.shape == other._colors.shape
|
|
83
|
+
and np.allclose(
|
|
84
|
+
self._colors,
|
|
85
|
+
other._colors,
|
|
86
|
+
atol=1.0 / 255.0,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def __ne__(self, other: object) -> bool:
|
|
91
|
+
"""Check two vertices sets for inequality
|
|
92
|
+
|
|
93
|
+
:param other: Other object
|
|
94
|
+
:returns: True, if vertices sets are not equal
|
|
95
|
+
"""
|
|
96
|
+
return not self.__eq__(other)
|
|
97
|
+
|
|
98
|
+
def __iadd__(self, other: "Vertices") -> Self:
|
|
99
|
+
"""Concatenate another vertices set in-place
|
|
100
|
+
|
|
101
|
+
:param other: Other vertices set
|
|
102
|
+
:returns: This vertices set (self)
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(other, Vertices):
|
|
105
|
+
return NotImplemented
|
|
106
|
+
if self.has_normals != other.has_normals or self.has_colors != other.has_colors:
|
|
107
|
+
raise ValueError("Both vertices sets must have the same properties")
|
|
108
|
+
self._vertices = np.vstack((self._vertices, other._vertices))
|
|
109
|
+
if self.has_normals:
|
|
110
|
+
self._normals = np.vstack((self._normals, other._normals))
|
|
111
|
+
if self.has_colors:
|
|
112
|
+
self._colors = np.vstack((self._colors, other._colors))
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __add__(self, other: "Vertices") -> Self:
|
|
116
|
+
"""Concatenate two vertices sets
|
|
117
|
+
|
|
118
|
+
:param other: Other vertices set
|
|
119
|
+
:returns: New vertices set
|
|
120
|
+
"""
|
|
121
|
+
if not isinstance(other, Vertices):
|
|
122
|
+
return NotImplemented
|
|
123
|
+
result = self.copy()
|
|
124
|
+
result += other
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
def copy(self) -> Self:
|
|
128
|
+
"""Get copy of vertices
|
|
129
|
+
|
|
130
|
+
:returns: Copy
|
|
131
|
+
"""
|
|
132
|
+
return copy.deepcopy(self)
|
|
133
|
+
|
|
134
|
+
def __copy__(self) -> Self:
|
|
135
|
+
"""Return a shallow copy of the object
|
|
136
|
+
|
|
137
|
+
Required by the `copy` module to implement custom shallow copy behavior.
|
|
138
|
+
|
|
139
|
+
:returns: A new instance containing references to the original
|
|
140
|
+
instance's attributes.
|
|
141
|
+
"""
|
|
142
|
+
cls = type(self)
|
|
143
|
+
result = cls.__new__(cls)
|
|
144
|
+
for base in cls.__mro__:
|
|
145
|
+
slots = getattr(base, "__slots__", ())
|
|
146
|
+
if isinstance(slots, str):
|
|
147
|
+
slots = (slots,)
|
|
148
|
+
for name in slots:
|
|
149
|
+
if name in ("__dict__", "__weakref__"):
|
|
150
|
+
continue
|
|
151
|
+
if hasattr(self, name):
|
|
152
|
+
setattr(result, name, copy.copy(getattr(self, name)))
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
def __deepcopy__(self, memo) -> "Vertices":
|
|
156
|
+
"""Return a deep copy of the object
|
|
157
|
+
|
|
158
|
+
Required by the `copy` module to implement custom deep copy behavior.
|
|
159
|
+
|
|
160
|
+
:param memo: A dictionary tracking already copied objects to prevent
|
|
161
|
+
infinite loops from recursive references
|
|
162
|
+
:returns: A new, completely independent copy of the instance.
|
|
163
|
+
"""
|
|
164
|
+
cls = type(self)
|
|
165
|
+
result = cls.__new__(cls)
|
|
166
|
+
memo[id(self)] = result
|
|
167
|
+
for base in cls.__mro__:
|
|
168
|
+
slots = getattr(base, "__slots__", ())
|
|
169
|
+
if isinstance(slots, str):
|
|
170
|
+
slots = (slots,)
|
|
171
|
+
for name in slots:
|
|
172
|
+
if name in ("__dict__", "__weakref__"):
|
|
173
|
+
continue
|
|
174
|
+
if hasattr(self, name):
|
|
175
|
+
setattr(result, name, copy.deepcopy(getattr(self, name), memo))
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def vertices(self) -> np.ndarray:
|
|
180
|
+
"""Get vertices
|
|
181
|
+
|
|
182
|
+
Vertices are points in 3d.
|
|
183
|
+
|
|
184
|
+
:returns: Vertices, shape (n, 3), type float
|
|
185
|
+
"""
|
|
186
|
+
return self._vertices
|
|
187
|
+
|
|
188
|
+
@vertices.setter
|
|
189
|
+
def vertices(self, value: ArrayLike | None) -> None:
|
|
190
|
+
"""Set vertices
|
|
191
|
+
|
|
192
|
+
Vertices are points in 3d.
|
|
193
|
+
|
|
194
|
+
If there are normals or colors defined in the vertices set,
|
|
195
|
+
then the number of vertices has to match their number.
|
|
196
|
+
|
|
197
|
+
:param value: Vertices, shape (n, 3), type float
|
|
198
|
+
"""
|
|
199
|
+
if value is None:
|
|
200
|
+
value = np.zeros((0, 3))
|
|
201
|
+
else:
|
|
202
|
+
value = np.asarray(value, dtype=float)
|
|
203
|
+
if value.ndim != 2 or value.shape[1] != 3:
|
|
204
|
+
raise Exception("Provide vertices of shape (n, 3)")
|
|
205
|
+
if self.has_normals and value.shape[0] != self._normals.shape[0]:
|
|
206
|
+
raise Exception(
|
|
207
|
+
"vertices set already has normals, provide proper amount of vertices"
|
|
208
|
+
)
|
|
209
|
+
if self.has_colors and value.shape[0] != self._colors.shape[0]:
|
|
210
|
+
raise Exception(
|
|
211
|
+
"vertices set already has colors, provide proper amount of vertices"
|
|
212
|
+
)
|
|
213
|
+
self._vertices = value
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def normals(self) -> np.ndarray:
|
|
217
|
+
"""Get normals
|
|
218
|
+
|
|
219
|
+
Normals are 3d vectors that are perpendicular to neighborhood of the
|
|
220
|
+
vertex. For a point cloud that can mean perpendicular to a plane
|
|
221
|
+
sampled by the vertex and its neighbors. For a triangle mesh the
|
|
222
|
+
vertex normal can be the mean of the face normals of all faces
|
|
223
|
+
adjacent to the vector. Normals are normalized, i.e. they have a
|
|
224
|
+
length of 1.0.
|
|
225
|
+
|
|
226
|
+
:returns: Normals, shape (n, 3)
|
|
227
|
+
"""
|
|
228
|
+
return self._normals
|
|
229
|
+
|
|
230
|
+
@normals.setter
|
|
231
|
+
def normals(self, value: ArrayLike | None) -> None:
|
|
232
|
+
"""Set normals
|
|
233
|
+
|
|
234
|
+
If there are vertices defined in the vertices set, then the number
|
|
235
|
+
of normals has to match the number of vertices.
|
|
236
|
+
|
|
237
|
+
Normals should have a length > 0, otherwise an exception is
|
|
238
|
+
thrown. Normals should have unit length. If not, normals will
|
|
239
|
+
be normalized and a warning is given.
|
|
240
|
+
|
|
241
|
+
:param value: Normals, shape (n, 3)
|
|
242
|
+
"""
|
|
243
|
+
if value is None:
|
|
244
|
+
value = np.zeros((0, 3))
|
|
245
|
+
else:
|
|
246
|
+
value = np.asarray(value, dtype=float)
|
|
247
|
+
if value.ndim != 2 or value.shape[1] != 3:
|
|
248
|
+
raise Exception("Provide normals of shape (n, 3)")
|
|
249
|
+
if value.shape[0] > 0 and value.shape[0] != self._vertices.shape[0]:
|
|
250
|
+
raise Exception("Provide as many normals as vertices")
|
|
251
|
+
lengths = np.sqrt(value[:, 0] ** 2 + value[:, 1] ** 2 + value[:, 2] ** 2)
|
|
252
|
+
if np.any(np.isclose(lengths, 0.0)):
|
|
253
|
+
raise Exception("Some normals have zero lengths")
|
|
254
|
+
if not np.allclose(lengths, 1.0):
|
|
255
|
+
logging.warning("Some normals are not normalized. Normalizing...")
|
|
256
|
+
value = value / lengths[:, None]
|
|
257
|
+
self._normals = value
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def has_normals(self) -> bool:
|
|
261
|
+
"""Check if vertices set has normals
|
|
262
|
+
|
|
263
|
+
:returns: True, if vertices set has normals
|
|
264
|
+
"""
|
|
265
|
+
return self._normals.shape[0] > 0
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def colors(self) -> np.ndarray:
|
|
269
|
+
"""Get colors
|
|
270
|
+
|
|
271
|
+
Color are 3-tuples of RGB values in range [0..1] of type float.
|
|
272
|
+
|
|
273
|
+
:returns: Colors
|
|
274
|
+
"""
|
|
275
|
+
return self._colors
|
|
276
|
+
|
|
277
|
+
@colors.setter
|
|
278
|
+
def colors(self, value: ArrayLike | None) -> None:
|
|
279
|
+
"""Set colors
|
|
280
|
+
|
|
281
|
+
If there are vertices defined in the vertices set, then the number
|
|
282
|
+
of colors has to match the number of the vertices.
|
|
283
|
+
|
|
284
|
+
Colors are RGB values in range [0..1]. If values outside that
|
|
285
|
+
range are provided, values will be clipped and a warning is given.
|
|
286
|
+
|
|
287
|
+
:param value: Colors, shape (n, 3)
|
|
288
|
+
"""
|
|
289
|
+
if value is None:
|
|
290
|
+
colors = np.zeros((0, 3))
|
|
291
|
+
else:
|
|
292
|
+
colors = np.asarray(value, dtype=float)
|
|
293
|
+
if colors.ndim != 2 or colors.shape[1] != 3:
|
|
294
|
+
raise ValueError("Provide colors of shape (n, 3)")
|
|
295
|
+
if colors.shape[0] > 0 and colors.shape[0] != self._vertices.shape[0]:
|
|
296
|
+
raise ValueError("Provide as many colors as vertices")
|
|
297
|
+
if not np.all(np.logical_and(colors >= 0.0, colors <= 1.0)):
|
|
298
|
+
logging.warning("Some colors are out of bounds. Clipping to [0,1] ...")
|
|
299
|
+
colors = np.clip(colors, 0.0, 1.0)
|
|
300
|
+
self._colors = colors
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def has_colors(self) -> bool:
|
|
304
|
+
"""Check if vertices set has colors
|
|
305
|
+
|
|
306
|
+
:returns: True, if vertices set has colors
|
|
307
|
+
"""
|
|
308
|
+
return self._colors.shape[0] > 0
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _read_ply_data(path: str | os.PathLike) -> PlyData:
|
|
312
|
+
"""Helper method to read PlyData from file
|
|
313
|
+
|
|
314
|
+
:param path: Path to file to read from
|
|
315
|
+
:returns: PlyData
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
return PlyData.read(path)
|
|
319
|
+
except Exception as ex:
|
|
320
|
+
raise Exception(str(type(ex))) from ex
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _write_ply_data(path: str | os.PathLike, elements: list[PlyElement]):
|
|
324
|
+
"""Helper method to write PlyData to file
|
|
325
|
+
|
|
326
|
+
:param path: Path to file to write to
|
|
327
|
+
:param elements: PlyData
|
|
328
|
+
"""
|
|
329
|
+
plydata = PlyData(elements, text=False)
|
|
330
|
+
try:
|
|
331
|
+
plydata.write(path)
|
|
332
|
+
except Exception as ex:
|
|
333
|
+
raise Exception(str(type(ex))) from ex
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _load_vertex_data(
|
|
337
|
+
plydata: PlyData,
|
|
338
|
+
) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray | None]:
|
|
339
|
+
"""Helper method to read vertex data
|
|
340
|
+
|
|
341
|
+
:param plydata: PlyData
|
|
342
|
+
:returns: Vertices, normals and colors
|
|
343
|
+
"""
|
|
344
|
+
vertex_data = plydata["vertex"]
|
|
345
|
+
properties = {p.name: p.val_dtype for p in vertex_data.properties}
|
|
346
|
+
# Vertices
|
|
347
|
+
vertices = (
|
|
348
|
+
np.vstack([vertex_data["x"], vertex_data["y"], vertex_data["z"]])
|
|
349
|
+
.astype(float)
|
|
350
|
+
.T
|
|
351
|
+
)
|
|
352
|
+
# Normals (if existing)
|
|
353
|
+
normals = None
|
|
354
|
+
normal_keys = ["nx", "ny", "nz"]
|
|
355
|
+
if all(k in properties for k in normal_keys):
|
|
356
|
+
normals = (
|
|
357
|
+
np.vstack([vertex_data["nx"], vertex_data["ny"], vertex_data["nz"]])
|
|
358
|
+
.astype(float)
|
|
359
|
+
.T
|
|
360
|
+
)
|
|
361
|
+
# Colors (if existing)
|
|
362
|
+
colors = None
|
|
363
|
+
color_keys_rgb = ["red", "green", "blue"]
|
|
364
|
+
if all(k in properties for k in color_keys_rgb):
|
|
365
|
+
if properties["red"] == "u1":
|
|
366
|
+
colors = np.vstack(
|
|
367
|
+
[vertex_data["red"], vertex_data["green"], vertex_data["blue"]]
|
|
368
|
+
).T
|
|
369
|
+
colors = colors.astype(float) / 255.0
|
|
370
|
+
elif properties["red"] == "f4":
|
|
371
|
+
colors = np.vstack(
|
|
372
|
+
[vertex_data["red"], vertex_data["green"], vertex_data["blue"]]
|
|
373
|
+
).T
|
|
374
|
+
else:
|
|
375
|
+
raise Exception(f"Unknown color format: {properties['red']}")
|
|
376
|
+
return vertices, normals, colors
|
|
377
|
+
|
|
378
|
+
def _save_vertex_data(self) -> PlyElement:
|
|
379
|
+
"""Helper method to save vertex data"""
|
|
380
|
+
# Collect dtypes
|
|
381
|
+
dtypes = [("x", "f8"), ("y", "f8"), ("z", "f8")]
|
|
382
|
+
if self.has_normals:
|
|
383
|
+
dtypes.extend([("nx", "f8"), ("ny", "f8"), ("nz", "f8")])
|
|
384
|
+
if self.has_colors:
|
|
385
|
+
dtypes.extend([("red", "u1"), ("green", "u1"), ("blue", "u1")])
|
|
386
|
+
# Collect data
|
|
387
|
+
element_data = np.empty(self._vertices.shape[0], dtype=dtypes)
|
|
388
|
+
element_data["x"] = self._vertices[:, 0]
|
|
389
|
+
element_data["y"] = self._vertices[:, 1]
|
|
390
|
+
element_data["z"] = self._vertices[:, 2]
|
|
391
|
+
if self.has_normals:
|
|
392
|
+
element_data["nx"] = self._normals[:, 0]
|
|
393
|
+
element_data["ny"] = self._normals[:, 1]
|
|
394
|
+
element_data["nz"] = self._normals[:, 2]
|
|
395
|
+
if self.has_colors:
|
|
396
|
+
element_data["red"] = np.round(self._colors[:, 0] * 255.0).astype(np.uint8)
|
|
397
|
+
element_data["green"] = np.round(self._colors[:, 1] * 255.0).astype(
|
|
398
|
+
np.uint8
|
|
399
|
+
)
|
|
400
|
+
element_data["blue"] = np.round(self._colors[:, 2] * 255.0).astype(np.uint8)
|
|
401
|
+
return PlyElement.describe(element_data, "vertex")
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
def from_ply(cls, path: str | os.PathLike) -> "Vertices":
|
|
405
|
+
"""Create vertices set from PLY file
|
|
406
|
+
|
|
407
|
+
:param path: Path to PLY file to read
|
|
408
|
+
:returns: Resulting vertices set
|
|
409
|
+
"""
|
|
410
|
+
plydata = Vertices._read_ply_data(path)
|
|
411
|
+
vertices, normals, colors = Vertices._load_vertex_data(plydata)
|
|
412
|
+
return cls(vertices, normals, colors)
|
|
413
|
+
|
|
414
|
+
def save_ply(self, path: str | os.PathLike) -> None:
|
|
415
|
+
"""Save vertices set to PLY file
|
|
416
|
+
|
|
417
|
+
:param path: Path to PLY file to write
|
|
418
|
+
"""
|
|
419
|
+
vertex_element = self._save_vertex_data()
|
|
420
|
+
Vertices._write_ply_data(path, [vertex_element])
|
|
421
|
+
|
|
422
|
+
def min(self) -> np.ndarray:
|
|
423
|
+
"""Get minimum of all coordinates
|
|
424
|
+
|
|
425
|
+
:raises Exception: Raised if vertices set empty
|
|
426
|
+
:returns: Minimum of all coordinates, shape (3,)
|
|
427
|
+
"""
|
|
428
|
+
if self._vertices.shape[0] == 0:
|
|
429
|
+
raise Exception("Cannot compute min for empty vertices set")
|
|
430
|
+
return np.min(self._vertices, axis=0)
|
|
431
|
+
|
|
432
|
+
def max(self) -> np.ndarray:
|
|
433
|
+
"""Get maximum of all coordinates
|
|
434
|
+
|
|
435
|
+
:raises Exception: Raised if vertices set empty
|
|
436
|
+
:returns: Maximum of all coordinates, shape (3,)
|
|
437
|
+
"""
|
|
438
|
+
if self._vertices.shape[0] == 0:
|
|
439
|
+
raise Exception("Cannot compute max for empty vertices set")
|
|
440
|
+
return np.max(self._vertices, axis=0)
|
|
441
|
+
|
|
442
|
+
def bounds(self) -> np.ndarray:
|
|
443
|
+
"""Get bounds (min and max) of vertices set
|
|
444
|
+
|
|
445
|
+
:raises Exception: Raised if vertices set empty
|
|
446
|
+
:returns: Min and max values for all coordinates, shape (2, 3)
|
|
447
|
+
"""
|
|
448
|
+
if self._vertices.shape[0] == 0:
|
|
449
|
+
raise Exception("Cannot compute bounds for empty vertices set")
|
|
450
|
+
return np.vstack(
|
|
451
|
+
(np.min(self._vertices, axis=0), np.max(self._vertices, axis=0))
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def range(self) -> np.ndarray:
|
|
455
|
+
"""Get ranges for all coordinates
|
|
456
|
+
|
|
457
|
+
:raises Exception: Raised if vertices set empty
|
|
458
|
+
:returns: Ranges (max-min) for all coordinates
|
|
459
|
+
"""
|
|
460
|
+
if self._vertices.shape[0] == 0:
|
|
461
|
+
raise Exception("Cannot compute range for empty vertices set")
|
|
462
|
+
return self.max() - self.min()
|
|
463
|
+
|
|
464
|
+
def mean(self) -> np.ndarray:
|
|
465
|
+
"""Get mean for all coordinates
|
|
466
|
+
:raises Exception: Raised if vertices set empty
|
|
467
|
+
:returns: Mean for all coordinates
|
|
468
|
+
"""
|
|
469
|
+
if self._vertices.shape[0] == 0:
|
|
470
|
+
raise Exception("Cannot compute mean for empty vertices set")
|
|
471
|
+
return np.mean(self._vertices, axis=0)
|
|
472
|
+
|
|
473
|
+
def center(self) -> np.ndarray:
|
|
474
|
+
"""Get center of gravity
|
|
475
|
+
|
|
476
|
+
Same as self.mean.
|
|
477
|
+
|
|
478
|
+
:raises Exception: Raised if vertices set empty
|
|
479
|
+
:returns: Center of gravity
|
|
480
|
+
"""
|
|
481
|
+
return self.mean()
|
|
482
|
+
|
|
483
|
+
def demean(self) -> None:
|
|
484
|
+
"""Translate vertices set that center of gravity is at (0, 0, 0)."""
|
|
485
|
+
if self._vertices.shape[0] == 0:
|
|
486
|
+
return
|
|
487
|
+
self._vertices -= np.mean(self._vertices, axis=0)
|
|
488
|
+
|
|
489
|
+
def scale(self, factor: float | ArrayLike, center: ArrayLike | None = None) -> None:
|
|
490
|
+
"""Scale vertices
|
|
491
|
+
|
|
492
|
+
:param factor: Scaling factor, either scalar or shape (3,)
|
|
493
|
+
:param center: Center, shape (3,)
|
|
494
|
+
"""
|
|
495
|
+
if center is None:
|
|
496
|
+
self._vertices = np.asarray(factor) * self._vertices
|
|
497
|
+
else:
|
|
498
|
+
center = np.asarray(center)
|
|
499
|
+
if not (center.ndim == 1 and center.size == 3):
|
|
500
|
+
raise Exception("Provide center of shape (3, )")
|
|
501
|
+
self._vertices = (np.asarray(factor) * (self._vertices - center)) + center
|
|
502
|
+
|
|
503
|
+
def translate(self, offset: ArrayLike) -> None:
|
|
504
|
+
"""Translate vertices
|
|
505
|
+
|
|
506
|
+
:param offset: Translation offset, shape (3,)
|
|
507
|
+
"""
|
|
508
|
+
offset = np.asarray(offset)
|
|
509
|
+
if not (offset.ndim == 1 and offset.size == 3):
|
|
510
|
+
raise Exception("Provide offset of shape (3, )")
|
|
511
|
+
self._vertices += offset
|
|
512
|
+
|
|
513
|
+
def transform(self, trafo: Trafo3d) -> None:
|
|
514
|
+
"""Transform vertices
|
|
515
|
+
|
|
516
|
+
:param trafo: 6d transformation
|
|
517
|
+
"""
|
|
518
|
+
self._vertices = trafo * self._vertices
|
|
519
|
+
if self.has_normals:
|
|
520
|
+
self._normals = self._normals @ trafo.rotation_matrix.T
|
|
521
|
+
|
|
522
|
+
def kdtree(self) -> KDTree:
|
|
523
|
+
"""Get KDTree of vertices
|
|
524
|
+
|
|
525
|
+
Use for fast next-neighbor queries.
|
|
526
|
+
|
|
527
|
+
:returns: KDTree of type scipy.spatial.KDTree
|
|
528
|
+
"""
|
|
529
|
+
return KDTree(self._vertices)
|
|
530
|
+
|
|
531
|
+
def filter_region_of_interest(
|
|
532
|
+
self,
|
|
533
|
+
bounds: ArrayLike,
|
|
534
|
+
trafo: Trafo3d | None = None,
|
|
535
|
+
) -> np.ndarray:
|
|
536
|
+
"""Filter vertices set
|
|
537
|
+
|
|
538
|
+
:param bounds: Min and max values for all coordinates, shape (2, 3)
|
|
539
|
+
:param trafo: 6d Pose of the bounding box
|
|
540
|
+
:returns: Inlier mask
|
|
541
|
+
"""
|
|
542
|
+
bounds = np.array(bounds)
|
|
543
|
+
if bounds.shape != (2, 3):
|
|
544
|
+
raise Exception(
|
|
545
|
+
"Provide bounds as ((xmin, ymin, zmin), "
|
|
546
|
+
"(xmax, ymax, zmax)), shape (2, 3)"
|
|
547
|
+
)
|
|
548
|
+
P = self._vertices
|
|
549
|
+
if trafo is not None:
|
|
550
|
+
P = trafo.inverse * P
|
|
551
|
+
return np.all(P >= bounds[0], axis=1) & np.all(P <= bounds[1], axis=1)
|
|
552
|
+
|
|
553
|
+
def paint_uniform_color(self, color: ArrayLike) -> None:
|
|
554
|
+
"""Colorize vertices set with uniform color
|
|
555
|
+
|
|
556
|
+
Sets colors to a single user defined color.
|
|
557
|
+
|
|
558
|
+
:param color: Color, RGB values in range [0..1], shape (3,), type float
|
|
559
|
+
"""
|
|
560
|
+
color = np.asarray(color)
|
|
561
|
+
if not (color.ndim == 1 and color.size == 3):
|
|
562
|
+
raise Exception("Provide color of shape (3, )")
|
|
563
|
+
if not np.all(np.logical_and(color >= 0.0, color <= 1.0)):
|
|
564
|
+
logging.warning("Color are out of bounds. Clipping to [0,1] ...")
|
|
565
|
+
color = np.clip(color, 0.0, 1.0)
|
|
566
|
+
self._colors = np.tile(color, (self._vertices.shape[0], 1))
|
|
567
|
+
|
|
568
|
+
def colorize_vertices_by_scalar(
|
|
569
|
+
self,
|
|
570
|
+
scalars: ArrayLike,
|
|
571
|
+
cmap: str = "viridis",
|
|
572
|
+
min_max: tuple[float, float] | None = None,
|
|
573
|
+
nan_color: tuple[float, float, float] = (1, 0, 0),
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Colorizes vertices by scalar
|
|
576
|
+
|
|
577
|
+
:param scalars: Scalar values, length has to match number of vertices
|
|
578
|
+
:param cmap: Matplotlib color map string
|
|
579
|
+
:param min_max: Minimum and maximum
|
|
580
|
+
:param nan_color: Color for NaN values, RGB values [0..1]
|
|
581
|
+
"""
|
|
582
|
+
scalars = np.asarray(scalars)
|
|
583
|
+
if scalars.ndim != 1 or scalars.size != self._vertices.shape[0]:
|
|
584
|
+
raise Exception("Provide corrent number of values to colorize vertices")
|
|
585
|
+
self._colors = scalar_to_color(scalars, cmap, min_max, nan_color)
|
|
586
|
+
|
|
587
|
+
def colorize_vertices_by_scalar_bicolor(
|
|
588
|
+
self,
|
|
589
|
+
scalars: ArrayLike,
|
|
590
|
+
cmaps: tuple[str, str] = ("Blues", "Reds"),
|
|
591
|
+
vmax: float | None = None,
|
|
592
|
+
nan_color: tuple[float, float, float] = (0, 1, 0),
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Colorizes mesh vertices by scalar, pos/neg in separate colors
|
|
595
|
+
|
|
596
|
+
:param scalars: Scalar values, length has to match number of vertices
|
|
597
|
+
:param cmaps: Matplotlib color map strings for pos/neg
|
|
598
|
+
:param vmax: Maximum (pos/neg) value
|
|
599
|
+
:param nan_color: Color for NaN values, RGB values [0..1]
|
|
600
|
+
"""
|
|
601
|
+
scalars = np.asarray(scalars)
|
|
602
|
+
if scalars.ndim != 1 or scalars.size != self._vertices.shape[0]:
|
|
603
|
+
raise Exception("Provide corrent number of values to colorize vertices")
|
|
604
|
+
self._colors = scalar_to_bicolor(scalars, cmaps, vmax, nan_color)
|