pyframetree 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.
- pyframetree/__init__.py +15 -0
- pyframetree/backend.py +38 -0
- pyframetree/coordinate_systems.py +346 -0
- pyframetree/rotation.py +85 -0
- pyframetree/transformations.py +125 -0
- pyframetree/utils.py +89 -0
- pyframetree-0.3.0.dist-info/METADATA +72 -0
- pyframetree-0.3.0.dist-info/RECORD +11 -0
- pyframetree-0.3.0.dist-info/WHEEL +5 -0
- pyframetree-0.3.0.dist-info/licenses/LICENSE +21 -0
- pyframetree-0.3.0.dist-info/top_level.txt +1 -0
pyframetree/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .backend import gpu_available
|
|
2
|
+
from .coordinate_systems import CoordinateSystem
|
|
3
|
+
from .rotation import Rotation
|
|
4
|
+
from .transformations import Transformation
|
|
5
|
+
from .utils import X_AX, Y_AX, Z_AX
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'CoordinateSystem',
|
|
9
|
+
'Rotation',
|
|
10
|
+
'Transformation',
|
|
11
|
+
'gpu_available',
|
|
12
|
+
'X_AX',
|
|
13
|
+
'Y_AX',
|
|
14
|
+
'Z_AX',
|
|
15
|
+
]
|
pyframetree/backend.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""NumPy/CuPy dispatch: arrays are processed on the device they already live on."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def array_module(array):
|
|
10
|
+
cupy = sys.modules.get('cupy')
|
|
11
|
+
if cupy is not None and isinstance(array, cupy.ndarray):
|
|
12
|
+
return cupy
|
|
13
|
+
return np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_numpy(array):
|
|
17
|
+
if array_module(array) is np:
|
|
18
|
+
return np.asarray(array)
|
|
19
|
+
return array.get()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def float_points(points):
|
|
23
|
+
"""Points as a float array of shape (..., 3), kept on their current device."""
|
|
24
|
+
xp = array_module(points)
|
|
25
|
+
points = xp.asarray(points)
|
|
26
|
+
if not np.issubdtype(points.dtype, np.floating):
|
|
27
|
+
points = points.astype(float)
|
|
28
|
+
if points.ndim == 0 or points.shape[-1] != 3:
|
|
29
|
+
raise ValueError('points must have shape (..., 3)')
|
|
30
|
+
return points
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gpu_available():
|
|
34
|
+
try:
|
|
35
|
+
cupy = importlib.import_module('cupy')
|
|
36
|
+
return cupy.cuda.runtime.getDeviceCount() > 0
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import quantities as q
|
|
3
|
+
|
|
4
|
+
from .backend import float_points, to_numpy
|
|
5
|
+
from .rotation import Rotation
|
|
6
|
+
from .transformations import Transformation
|
|
7
|
+
from .utils import (
|
|
8
|
+
Y_AX,
|
|
9
|
+
Z_AX,
|
|
10
|
+
axis_angle_matrix,
|
|
11
|
+
conversion_factor,
|
|
12
|
+
finite_scalar,
|
|
13
|
+
homogeneous,
|
|
14
|
+
length_unit,
|
|
15
|
+
magnitude,
|
|
16
|
+
vector3,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
REFERENCES = ('parent', 'world', 'local')
|
|
20
|
+
SPACES = ('local', 'world')
|
|
21
|
+
IDENTITY = np.eye(4)
|
|
22
|
+
IDENTITY.flags.writeable = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _inverse(pose):
|
|
26
|
+
"""Inverse of a 4x4 pose ``[[s R, t], [0, 1]]``, without a general matrix inversion."""
|
|
27
|
+
linear = pose[:3, :3]
|
|
28
|
+
inverse_linear = linear.T / (linear[:, 0] @ linear[:, 0])
|
|
29
|
+
return homogeneous(inverse_linear, -inverse_linear @ pose[:3, 3])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CoordinateSystem:
|
|
33
|
+
"""A coordinate frame: an origin, an orientation and a uniform scale.
|
|
34
|
+
|
|
35
|
+
Frames form a tree. Each frame stores its pose relative to its parent (or
|
|
36
|
+
to the world for a root), so children and attached points follow it.
|
|
37
|
+
Its axes ``u``, ``v`` and ``w`` are always orthogonal and of equal length.
|
|
38
|
+
|
|
39
|
+
Bare numbers are read in ``unit`` (the parent's unit by default, else
|
|
40
|
+
metres); quantities may use any length unit.
|
|
41
|
+
|
|
42
|
+
Motion methods take ``relative_to='parent' | 'world' | 'local'``: their
|
|
43
|
+
arguments are coordinates in that frame. With ``inherit=False``, the
|
|
44
|
+
frame's points and children stay where they are in the world.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self, origin=(0, 0, 0), rotation=None, scale=1.0, *, parent=None, name=None, unit=None
|
|
49
|
+
):
|
|
50
|
+
if unit is None and parent is not None:
|
|
51
|
+
self.unit, self._metres_per_unit = parent.unit, parent._metres_per_unit
|
|
52
|
+
else:
|
|
53
|
+
self.unit = length_unit(q.m if unit is None else unit)
|
|
54
|
+
self._metres_per_unit = conversion_factor(self.unit, q.m)
|
|
55
|
+
self._name = name
|
|
56
|
+
self._parent = None
|
|
57
|
+
self._children = {}
|
|
58
|
+
self._named_children = {}
|
|
59
|
+
self._points = {}
|
|
60
|
+
self._cached_parent_world = self._cached_local = self._cached_world = None
|
|
61
|
+
|
|
62
|
+
if rotation is not None and not isinstance(rotation, Rotation):
|
|
63
|
+
raise TypeError('rotation must be a Rotation')
|
|
64
|
+
scale = self._scale_factor(scale)
|
|
65
|
+
linear = np.eye(3) if rotation is None else rotation.matrix
|
|
66
|
+
self._local = homogeneous(scale * linear, self._metres(origin, 'origin'))
|
|
67
|
+
if parent is not None:
|
|
68
|
+
self.set_parent(parent, keep_world=False)
|
|
69
|
+
|
|
70
|
+
# Tree
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def name(self):
|
|
74
|
+
return self._name
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def parent(self):
|
|
78
|
+
return self._parent
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def children(self):
|
|
82
|
+
return tuple(self._children)
|
|
83
|
+
|
|
84
|
+
def child(self, name):
|
|
85
|
+
return self._named_children[name]
|
|
86
|
+
|
|
87
|
+
def set_parent(self, parent, *, keep_world=True):
|
|
88
|
+
"""Attach this frame to ``parent``, or detach it with ``None``.
|
|
89
|
+
|
|
90
|
+
By default the frame keeps its world pose. With ``keep_world=False``
|
|
91
|
+
its current pose is reinterpreted relative to the new parent.
|
|
92
|
+
"""
|
|
93
|
+
if parent is not None:
|
|
94
|
+
if not isinstance(parent, CoordinateSystem):
|
|
95
|
+
raise TypeError('parent must be a CoordinateSystem or None')
|
|
96
|
+
ancestor = parent
|
|
97
|
+
while ancestor is not None:
|
|
98
|
+
if ancestor is self:
|
|
99
|
+
raise ValueError('a frame cannot be its own ancestor')
|
|
100
|
+
ancestor = ancestor._parent
|
|
101
|
+
sibling = parent._named_children.get(self._name)
|
|
102
|
+
if sibling is not None and sibling is not self:
|
|
103
|
+
raise ValueError(f'{parent!r} already has a child named {self._name!r}')
|
|
104
|
+
|
|
105
|
+
if keep_world:
|
|
106
|
+
world = self._world()
|
|
107
|
+
self._local = world.copy() if parent is None else _inverse(parent._world()) @ world
|
|
108
|
+
if self._parent is not None:
|
|
109
|
+
del self._parent._children[self]
|
|
110
|
+
self._parent._named_children.pop(self._name, None)
|
|
111
|
+
if parent is not None:
|
|
112
|
+
parent._children[self] = None
|
|
113
|
+
if self._name is not None:
|
|
114
|
+
parent._named_children[self._name] = self
|
|
115
|
+
self._parent = parent
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
# Pose
|
|
119
|
+
|
|
120
|
+
def _world(self):
|
|
121
|
+
"""World pose as a read-only 4x4 matrix in metres.
|
|
122
|
+
|
|
123
|
+
Poses are replaced, never modified in place, so a cached world pose is
|
|
124
|
+
valid while its inputs (parent world pose, local pose) are the same objects.
|
|
125
|
+
"""
|
|
126
|
+
chain, frame = [], self
|
|
127
|
+
while frame is not None:
|
|
128
|
+
chain.append(frame)
|
|
129
|
+
frame = frame._parent
|
|
130
|
+
world = IDENTITY
|
|
131
|
+
for frame in reversed(chain):
|
|
132
|
+
if frame._cached_parent_world is not world or frame._cached_local is not frame._local:
|
|
133
|
+
frame._cached_parent_world, frame._cached_local = world, frame._local
|
|
134
|
+
frame._cached_world = world @ frame._local
|
|
135
|
+
frame._cached_world.flags.writeable = False
|
|
136
|
+
world = frame._cached_world
|
|
137
|
+
return world
|
|
138
|
+
|
|
139
|
+
def _parent_world(self):
|
|
140
|
+
return IDENTITY if self._parent is None else self._parent._world()
|
|
141
|
+
|
|
142
|
+
def _in_unit(self, pose):
|
|
143
|
+
if self._metres_per_unit == 1:
|
|
144
|
+
return Transformation._trusted(pose.copy(), self.unit)
|
|
145
|
+
matrix = pose.copy()
|
|
146
|
+
matrix[:3, 3] /= self._metres_per_unit
|
|
147
|
+
return Transformation._trusted(matrix, self.unit)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def local_transform(self):
|
|
151
|
+
"""Snapshot of the map from this frame's coordinates to its parent's, in ``unit``."""
|
|
152
|
+
return self._in_unit(self._local)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def world_transform(self):
|
|
156
|
+
"""Snapshot of the map from this frame's coordinates to world coordinates, in ``unit``."""
|
|
157
|
+
return self._in_unit(self._world())
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def origin(self):
|
|
161
|
+
return self._world()[:3, 3] / self._metres_per_unit * self.unit
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def scale_factor(self):
|
|
165
|
+
return float(np.cbrt(np.linalg.det(self._world()[:3, :3])))
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def rotation(self):
|
|
169
|
+
return Rotation._trusted(self._world()[:3, :3] / self.scale_factor)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def u(self):
|
|
173
|
+
return self._world()[:3, 0].copy()
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def v(self):
|
|
177
|
+
return self._world()[:3, 1].copy()
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def w(self):
|
|
181
|
+
return self._world()[:3, 2].copy()
|
|
182
|
+
|
|
183
|
+
# Coordinates
|
|
184
|
+
|
|
185
|
+
def to_world(self, points):
|
|
186
|
+
"""Convert points from this frame's coordinates to world coordinates."""
|
|
187
|
+
return self.world_transform.apply(points)
|
|
188
|
+
|
|
189
|
+
def to_local(self, points):
|
|
190
|
+
"""Convert points from world coordinates to this frame's coordinates."""
|
|
191
|
+
return self._in_unit(_inverse(self._world())).apply(points)
|
|
192
|
+
|
|
193
|
+
# Motion
|
|
194
|
+
|
|
195
|
+
def translate(self, offset, *, relative_to='parent', inherit=True):
|
|
196
|
+
motion = homogeneous(np.eye(3), self._metres(offset, 'offset'))
|
|
197
|
+
return self._move(motion, relative_to, inherit)
|
|
198
|
+
|
|
199
|
+
def rotate(self, axis, angle, *, pivot=None, relative_to='parent', inherit=True):
|
|
200
|
+
"""Rotate about ``axis`` through ``pivot`` (default: this frame's origin)."""
|
|
201
|
+
rotation = axis_angle_matrix(axis, angle)
|
|
202
|
+
if pivot is None:
|
|
203
|
+
pivot = self._pose_in(relative_to)[:3, 3]
|
|
204
|
+
else:
|
|
205
|
+
pivot = self._metres(pivot, 'pivot')
|
|
206
|
+
return self._move(homogeneous(rotation, pivot - rotation @ pivot), relative_to, inherit)
|
|
207
|
+
|
|
208
|
+
def scale(self, factor, *, inherit=True):
|
|
209
|
+
"""Multiply the axis lengths by ``factor``, about this frame's origin."""
|
|
210
|
+
motion = homogeneous(self._scale_factor(factor) * np.eye(3))
|
|
211
|
+
return self._move(motion, 'local', inherit)
|
|
212
|
+
|
|
213
|
+
def flip(self, *, inherit=True):
|
|
214
|
+
"""Reverse all three axes. This changes the frame's handedness."""
|
|
215
|
+
return self.scale(-1, inherit=inherit)
|
|
216
|
+
|
|
217
|
+
def set_position(self, position, *, relative_to='parent', inherit=True):
|
|
218
|
+
target = self._pose_in(relative_to).copy()
|
|
219
|
+
target[:3, 3] = self._metres(position, 'position')
|
|
220
|
+
return self._place(target, relative_to, inherit)
|
|
221
|
+
|
|
222
|
+
def set_spherical(self, r, theta, phi, *, relative_to='parent', inherit=True):
|
|
223
|
+
"""Place the origin at (r, theta, phi), with ``w`` pointing radially outward.
|
|
224
|
+
|
|
225
|
+
``theta`` is the polar angle from z and ``phi`` the azimuth from x.
|
|
226
|
+
``u`` points towards increasing theta and ``v`` towards increasing phi.
|
|
227
|
+
"""
|
|
228
|
+
radius = finite_scalar(r, 'r', self.unit) * self._metres_per_unit
|
|
229
|
+
if radius < 0:
|
|
230
|
+
raise ValueError('r must be nonnegative')
|
|
231
|
+
rotation = axis_angle_matrix(Z_AX, phi) @ axis_angle_matrix(Y_AX, theta)
|
|
232
|
+
scale = np.cbrt(np.linalg.det(self._pose_in(relative_to)[:3, :3]))
|
|
233
|
+
target = homogeneous(scale * rotation, radius * rotation[:, 2])
|
|
234
|
+
return self._place(target, relative_to, inherit)
|
|
235
|
+
|
|
236
|
+
def _pose_in(self, relative_to):
|
|
237
|
+
"""This frame's pose, in the coordinates of the ``relative_to`` frame."""
|
|
238
|
+
if relative_to == 'parent':
|
|
239
|
+
return self._local
|
|
240
|
+
if relative_to == 'world':
|
|
241
|
+
return self._world()
|
|
242
|
+
if relative_to == 'local':
|
|
243
|
+
return IDENTITY
|
|
244
|
+
raise ValueError(f'relative_to must be one of {REFERENCES}')
|
|
245
|
+
|
|
246
|
+
def _place(self, target, relative_to, inherit):
|
|
247
|
+
"""Give this frame the pose ``target``, expressed in ``relative_to`` coordinates."""
|
|
248
|
+
if relative_to == 'world':
|
|
249
|
+
target = _inverse(self._parent_world()) @ target
|
|
250
|
+
elif relative_to == 'local':
|
|
251
|
+
target = self._local @ target
|
|
252
|
+
return self._set_local(target, inherit)
|
|
253
|
+
|
|
254
|
+
def _move(self, motion, relative_to, inherit):
|
|
255
|
+
"""Apply ``motion``, expressed in ``relative_to`` coordinates."""
|
|
256
|
+
return self._place(motion @ self._pose_in(relative_to), relative_to, inherit)
|
|
257
|
+
|
|
258
|
+
def _set_local(self, local, inherit):
|
|
259
|
+
if not inherit:
|
|
260
|
+
correction = _inverse(local) @ self._local
|
|
261
|
+
for child in self._children:
|
|
262
|
+
child._local = correction @ child._local
|
|
263
|
+
transform = self._in_unit(correction)
|
|
264
|
+
self._points = {label: transform.apply(value) for label, value in self._points.items()}
|
|
265
|
+
self._local = local
|
|
266
|
+
return self
|
|
267
|
+
|
|
268
|
+
def _metres(self, value, name):
|
|
269
|
+
return vector3(value, name, self.unit) * self._metres_per_unit
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _scale_factor(value):
|
|
273
|
+
value = finite_scalar(value, 'scale')
|
|
274
|
+
if value == 0:
|
|
275
|
+
raise ValueError('scale must be nonzero')
|
|
276
|
+
return value
|
|
277
|
+
|
|
278
|
+
# Points
|
|
279
|
+
|
|
280
|
+
def add_points(self, points, label, *, space='local'):
|
|
281
|
+
"""Attach a copy of ``points`` (shape ``(..., 3)``) under ``label``.
|
|
282
|
+
|
|
283
|
+
Points are stored in this frame's coordinates and unit, keeping their
|
|
284
|
+
dtype and device, and move with the frame.
|
|
285
|
+
"""
|
|
286
|
+
if space not in SPACES:
|
|
287
|
+
raise ValueError(f'space must be one of {SPACES}')
|
|
288
|
+
if label in self._points:
|
|
289
|
+
raise ValueError(f'point label already exists: {label!r}')
|
|
290
|
+
if isinstance(points, (q.Quantity, list, tuple)):
|
|
291
|
+
points = magnitude(points, self.unit)
|
|
292
|
+
points = float_points(points)
|
|
293
|
+
self._points[label] = self.to_local(points) if space == 'world' else points.copy()
|
|
294
|
+
|
|
295
|
+
def remove_points(self, label):
|
|
296
|
+
"""Detach the points stored under ``label`` and return them, in local coordinates."""
|
|
297
|
+
return self._points.pop(label)
|
|
298
|
+
|
|
299
|
+
def get_points(self, label, *, space='local'):
|
|
300
|
+
"""A copy of the points stored under ``label``, in this frame's unit."""
|
|
301
|
+
if space not in SPACES:
|
|
302
|
+
raise ValueError(f'space must be one of {SPACES}')
|
|
303
|
+
points = self._points[label]
|
|
304
|
+
return self.to_world(points) if space == 'world' else points.copy()
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def point_labels(self):
|
|
308
|
+
return tuple(self._points)
|
|
309
|
+
|
|
310
|
+
# Display
|
|
311
|
+
|
|
312
|
+
def visualize(self, plotter=None, cmap='viridis', children=True, show_points=True):
|
|
313
|
+
"""Draw the frame (and its descendants) in world coordinates with PyVista."""
|
|
314
|
+
try:
|
|
315
|
+
import pyvista as pv
|
|
316
|
+
from matplotlib import colormaps
|
|
317
|
+
except ImportError as exc:
|
|
318
|
+
raise ImportError("visualize requires pip install 'pyframetree[visualization]'") from exc
|
|
319
|
+
|
|
320
|
+
plotter = pv.Plotter() if plotter is None else plotter
|
|
321
|
+
axis_colors = colormaps[cmap](np.linspace(0, 1, 3))
|
|
322
|
+
frames = [self]
|
|
323
|
+
while frames:
|
|
324
|
+
frame = frames.pop()
|
|
325
|
+
origin = frame.origin.rescale(self.unit).magnitude
|
|
326
|
+
for axis, color in zip((frame.u, frame.v, frame.w), axis_colors):
|
|
327
|
+
plotter.add_arrows(origin, axis, color=color)
|
|
328
|
+
plotter.add_points(origin, color='black')
|
|
329
|
+
if show_points:
|
|
330
|
+
for index, label in enumerate(frame._points):
|
|
331
|
+
points = to_numpy(frame.get_points(label, space='world'))
|
|
332
|
+
points = points * conversion_factor(frame.unit, self.unit)
|
|
333
|
+
if points.size:
|
|
334
|
+
plotter.add_points(
|
|
335
|
+
points.reshape(-1, 3),
|
|
336
|
+
color=colormaps['tab10'](index % 10),
|
|
337
|
+
point_size=10,
|
|
338
|
+
render_points_as_spheres=True,
|
|
339
|
+
)
|
|
340
|
+
if children:
|
|
341
|
+
frames.extend(reversed(frame.children))
|
|
342
|
+
return plotter
|
|
343
|
+
|
|
344
|
+
def __repr__(self):
|
|
345
|
+
name = '' if self._name is None else f'{self._name!r}, '
|
|
346
|
+
return f'CoordinateSystem({name}origin={self.origin}, scale={self.scale_factor:g})'
|
pyframetree/rotation.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import quantities as q
|
|
3
|
+
|
|
4
|
+
from .utils import axis_angle_matrix
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Rotation:
|
|
8
|
+
"""An immutable 3D rotation, stored as an orthonormal 3x3 matrix.
|
|
9
|
+
|
|
10
|
+
``a @ b`` is the rotation that applies ``b`` first, then ``a``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, matrix=None):
|
|
14
|
+
matrix = np.eye(3) if matrix is None else np.array(matrix, dtype=float)
|
|
15
|
+
if matrix.shape != (3, 3) or not np.allclose(matrix.T @ matrix, np.eye(3), atol=1e-9):
|
|
16
|
+
raise ValueError('rotation matrix must be an orthonormal 3x3 matrix')
|
|
17
|
+
if np.linalg.det(matrix) < 0:
|
|
18
|
+
raise ValueError('rotation matrix must have determinant +1')
|
|
19
|
+
self._matrix = matrix
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def _trusted(cls, matrix):
|
|
23
|
+
rotation = cls.__new__(cls)
|
|
24
|
+
rotation._matrix = matrix
|
|
25
|
+
return rotation
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_axis_angle(cls, axis, angle):
|
|
29
|
+
"""Right-handed rotation about ``axis``; bare angles are radians."""
|
|
30
|
+
return cls._trusted(axis_angle_matrix(axis, angle))
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_quaternion(cls, quaternion):
|
|
34
|
+
"""From a quaternion (w, x, y, z); it does not need to be normalized."""
|
|
35
|
+
quaternion = np.asarray(quaternion, dtype=float)
|
|
36
|
+
norm = np.linalg.norm(quaternion)
|
|
37
|
+
if quaternion.shape != (4,) or not np.isfinite(norm) or norm == 0:
|
|
38
|
+
raise ValueError('quaternion must be four finite numbers, not all zero')
|
|
39
|
+
w, x, y, z = quaternion / norm
|
|
40
|
+
return cls._trusted(
|
|
41
|
+
np.array(
|
|
42
|
+
[
|
|
43
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
|
|
44
|
+
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
|
|
45
|
+
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def matrix(self):
|
|
52
|
+
return self._matrix.copy()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def quaternion(self):
|
|
56
|
+
"""The unit quaternion (w, x, y, z), with w >= 0."""
|
|
57
|
+
# Bar-Itzhack: the quaternion is the dominant eigenvector of this matrix.
|
|
58
|
+
(xx, xy, xz), (yx, yy, yz), (zx, zy, zz) = self._matrix
|
|
59
|
+
k = np.array(
|
|
60
|
+
[
|
|
61
|
+
[xx + yy + zz, zy - yz, xz - zx, yx - xy],
|
|
62
|
+
[zy - yz, xx - yy - zz, xy + yx, xz + zx],
|
|
63
|
+
[xz - zx, xy + yx, yy - xx - zz, yz + zy],
|
|
64
|
+
[yx - xy, xz + zx, yz + zy, zz - xx - yy],
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
quaternion = np.linalg.eigh(k)[1][:, -1]
|
|
68
|
+
return quaternion if quaternion[0] >= 0 else -quaternion
|
|
69
|
+
|
|
70
|
+
def inverse(self):
|
|
71
|
+
return Rotation._trusted(self._matrix.T)
|
|
72
|
+
|
|
73
|
+
def apply(self, vectors):
|
|
74
|
+
"""Rotate one vector ``(3,)`` or a batch ``(..., 3)``; quantities keep their units."""
|
|
75
|
+
if isinstance(vectors, q.Quantity):
|
|
76
|
+
return self.apply(vectors.magnitude) * vectors.units
|
|
77
|
+
return np.asarray(vectors, dtype=float) @ self._matrix.T
|
|
78
|
+
|
|
79
|
+
def __matmul__(self, other):
|
|
80
|
+
if not isinstance(other, Rotation):
|
|
81
|
+
return NotImplemented
|
|
82
|
+
return Rotation._trusted(self._matrix @ other._matrix)
|
|
83
|
+
|
|
84
|
+
def __repr__(self):
|
|
85
|
+
return f'Rotation(quaternion={self.quaternion.round(12).tolist()})'
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import quantities as q
|
|
3
|
+
|
|
4
|
+
from .backend import array_module, float_points, to_numpy
|
|
5
|
+
from .utils import (
|
|
6
|
+
angle_radians,
|
|
7
|
+
axis_angle_matrix,
|
|
8
|
+
conversion_factor,
|
|
9
|
+
homogeneous,
|
|
10
|
+
length_unit,
|
|
11
|
+
magnitude,
|
|
12
|
+
unit_vector,
|
|
13
|
+
vector3,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
SHEAR_PLANES = ('xy', 'xz', 'yx', 'yz', 'zx', 'zy')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Transformation:
|
|
20
|
+
"""An immutable affine map of 3D points, stored as a 4x4 matrix.
|
|
21
|
+
|
|
22
|
+
Distances in the matrix, and bare numbers given to it, are in ``unit``.
|
|
23
|
+
``a @ b`` applies ``b`` first, then ``a``. Points are processed on the
|
|
24
|
+
device (NumPy or CuPy) they already live on.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, matrix=None, *, unit=q.m):
|
|
28
|
+
self.unit = length_unit(unit)
|
|
29
|
+
matrix = np.eye(4) if matrix is None else np.array(to_numpy(matrix), dtype=float)
|
|
30
|
+
if matrix.shape != (4, 4) or not np.all(np.isfinite(matrix)):
|
|
31
|
+
raise ValueError('matrix must be a finite 4x4 array')
|
|
32
|
+
if not np.array_equal(matrix[3], [0, 0, 0, 1]):
|
|
33
|
+
raise ValueError('matrix must be affine: its last row must be (0, 0, 0, 1)')
|
|
34
|
+
self._matrix = matrix
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _trusted(cls, matrix, unit):
|
|
38
|
+
transform = cls.__new__(cls)
|
|
39
|
+
transform._matrix, transform.unit = matrix, unit
|
|
40
|
+
return transform
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def translation(cls, offset, *, unit=q.m):
|
|
44
|
+
unit = length_unit(unit)
|
|
45
|
+
return cls._trusted(homogeneous(np.eye(3), vector3(offset, 'offset', unit)), unit)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def rotation(cls, axis, angle):
|
|
49
|
+
"""Right-handed rotation about ``axis`` through the origin; bare angles are radians."""
|
|
50
|
+
return cls._trusted(homogeneous(axis_angle_matrix(axis, angle)), q.m)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def scaling(cls, factors):
|
|
54
|
+
"""Scale by one factor, or by three factors along x, y and z."""
|
|
55
|
+
factors = np.broadcast_to(magnitude(factors, q.dimensionless), 3)
|
|
56
|
+
return cls._trusted(homogeneous(np.diag(vector3(factors, 'factors'))), q.m)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def shear(cls, plane, angle):
|
|
60
|
+
"""Shear in ``plane``: 'xy' adds tan(angle) * y to x, and so on."""
|
|
61
|
+
if plane not in SHEAR_PLANES:
|
|
62
|
+
raise ValueError(f'plane must be one of {SHEAR_PLANES}')
|
|
63
|
+
radians = angle_radians(angle)
|
|
64
|
+
if np.isclose(np.cos(radians), 0):
|
|
65
|
+
raise ValueError('shear angle must not be ±90°')
|
|
66
|
+
linear = np.eye(3)
|
|
67
|
+
linear['xyz'.index(plane[0]), 'xyz'.index(plane[1])] = np.tan(radians)
|
|
68
|
+
return cls._trusted(homogeneous(linear), q.m)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def look_at(cls, eye, target, up=(0, 1, 0), *, unit=q.m):
|
|
72
|
+
"""World-to-camera map: ``eye`` goes to the origin, ``target`` onto the -z axis."""
|
|
73
|
+
unit = length_unit(unit)
|
|
74
|
+
eye = vector3(eye, 'eye', unit)
|
|
75
|
+
backward = unit_vector(eye - vector3(target, 'target', unit), 'eye - target')
|
|
76
|
+
right = unit_vector(np.cross(vector3(up, 'up'), backward), 'up × (eye - target)')
|
|
77
|
+
rows = np.array([right, np.cross(backward, right), backward])
|
|
78
|
+
return cls._trusted(homogeneous(rows, -rows @ eye), unit)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def matrix(self):
|
|
82
|
+
return self._matrix.copy()
|
|
83
|
+
|
|
84
|
+
def as_unit(self, unit):
|
|
85
|
+
"""The same physical map, with distances expressed in another unit."""
|
|
86
|
+
unit = length_unit(unit)
|
|
87
|
+
factor = conversion_factor(self.unit, unit)
|
|
88
|
+
if factor == 1:
|
|
89
|
+
return self
|
|
90
|
+
matrix = self._matrix.copy()
|
|
91
|
+
matrix[:3, 3] *= factor
|
|
92
|
+
return Transformation._trusted(matrix, unit)
|
|
93
|
+
|
|
94
|
+
def inverse(self):
|
|
95
|
+
linear = np.linalg.inv(self._matrix[:3, :3])
|
|
96
|
+
return Transformation._trusted(
|
|
97
|
+
homogeneous(linear, -linear @ self._matrix[:3, 3]), self.unit
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __matmul__(self, other):
|
|
101
|
+
if not isinstance(other, Transformation):
|
|
102
|
+
return NotImplemented
|
|
103
|
+
return Transformation._trusted(self._matrix @ other.as_unit(self.unit)._matrix, self.unit)
|
|
104
|
+
|
|
105
|
+
def apply(self, points, *, vectors=False):
|
|
106
|
+
"""Transform one point ``(3,)`` or a batch ``(..., 3)``.
|
|
107
|
+
|
|
108
|
+
``vectors=True`` skips the translation, for directions or velocities.
|
|
109
|
+
Quantities come back in their own units.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(points, q.Quantity):
|
|
112
|
+
transform = self if vectors else self.as_unit(points.units)
|
|
113
|
+
return transform.apply(points.magnitude, vectors=vectors) * points.units
|
|
114
|
+
if isinstance(points, (list, tuple)):
|
|
115
|
+
points = magnitude(points, q.dimensionless if vectors else self.unit)
|
|
116
|
+
|
|
117
|
+
points = float_points(points)
|
|
118
|
+
xp = array_module(points)
|
|
119
|
+
result = points @ xp.asarray(self._matrix[:3, :3].T, dtype=points.dtype)
|
|
120
|
+
if not vectors:
|
|
121
|
+
result += xp.asarray(self._matrix[:3, 3], dtype=points.dtype)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
def __repr__(self):
|
|
125
|
+
return f'Transformation(unit={self.unit.dimensionality}, matrix=\n{self._matrix})'
|
pyframetree/utils.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Unit handling and input validation.
|
|
2
|
+
|
|
3
|
+
``quantities`` conversions are slow (~100 µs), so conversion factors are cached.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import quantities as q
|
|
8
|
+
|
|
9
|
+
X_AX = np.array([1.0, 0.0, 0.0])
|
|
10
|
+
Y_AX = np.array([0.0, 1.0, 0.0])
|
|
11
|
+
Z_AX = np.array([0.0, 0.0, 1.0])
|
|
12
|
+
|
|
13
|
+
_FACTORS = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def conversion_factor(units, target):
|
|
17
|
+
"""Multiply a magnitude in ``units`` by this to express it in ``target``."""
|
|
18
|
+
units, target = units.dimensionality, target.dimensionality
|
|
19
|
+
key = (frozenset(units.items()), frozenset(target.items()))
|
|
20
|
+
factor = _FACTORS.get(key)
|
|
21
|
+
if factor is None:
|
|
22
|
+
factor = _FACTORS[key] = float(q.Quantity(1.0, units).rescale(target).magnitude)
|
|
23
|
+
return factor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def length_unit(unit):
|
|
27
|
+
if not isinstance(unit, q.Quantity):
|
|
28
|
+
unit = q.Quantity(1.0, unit).units
|
|
29
|
+
conversion_factor(unit, q.m) # Raises for anything that is not a length.
|
|
30
|
+
return unit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def magnitude(value, unit):
|
|
34
|
+
"""Strip units: quantities are converted to ``unit``, bare numbers are taken as they are."""
|
|
35
|
+
if isinstance(value, q.Quantity):
|
|
36
|
+
return value.magnitude * conversion_factor(value, unit)
|
|
37
|
+
if isinstance(value, (list, tuple)) and any(
|
|
38
|
+
isinstance(v, (q.Quantity, list, tuple)) for v in value
|
|
39
|
+
):
|
|
40
|
+
return np.array([magnitude(v, unit) for v in value], dtype=float)
|
|
41
|
+
return np.asarray(value, dtype=float)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def vector3(value, name='vector', unit=q.dimensionless):
|
|
45
|
+
value = magnitude(value, unit)
|
|
46
|
+
if value.shape != (3,) or not np.all(np.isfinite(value)):
|
|
47
|
+
raise ValueError(f'{name} must be three finite numbers')
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def unit_vector(value, name='axis'):
|
|
52
|
+
value = vector3(value, name)
|
|
53
|
+
length = np.linalg.norm(value)
|
|
54
|
+
if length == 0:
|
|
55
|
+
raise ValueError(f'{name} must be nonzero')
|
|
56
|
+
return value / length
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def finite_scalar(value, name, unit=q.dimensionless):
|
|
60
|
+
value = magnitude(value, unit)
|
|
61
|
+
if value.shape != () or not np.isfinite(value):
|
|
62
|
+
raise ValueError(f'{name} must be a finite number')
|
|
63
|
+
return float(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def angle_radians(angle):
|
|
67
|
+
return finite_scalar(angle, 'angle (bare numbers are radians)', q.rad)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def homogeneous(linear, translation=(0.0, 0.0, 0.0)):
|
|
71
|
+
matrix = np.eye(4)
|
|
72
|
+
matrix[:3, :3] = linear
|
|
73
|
+
matrix[:3, 3] = translation
|
|
74
|
+
return matrix
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def axis_angle_matrix(axis, angle):
|
|
78
|
+
"""Right-handed rotation matrix about ``axis`` (Rodrigues' formula)."""
|
|
79
|
+
x, y, z = unit_vector(axis)
|
|
80
|
+
radians = angle_radians(angle)
|
|
81
|
+
c, s = np.cos(radians), np.sin(radians)
|
|
82
|
+
t = 1 - c
|
|
83
|
+
return np.array(
|
|
84
|
+
[
|
|
85
|
+
[c + x * x * t, x * y * t - z * s, x * z * t + y * s],
|
|
86
|
+
[y * x * t + z * s, c + y * y * t, y * z * t - x * s],
|
|
87
|
+
[z * x * t - y * s, z * y * t + x * s, c + z * z * t],
|
|
88
|
+
]
|
|
89
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyframetree
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Nested 3D coordinate frames with physical units and vectorized transformations
|
|
5
|
+
Author-email: Gabriel Lefloch <gabriel.lefloch@kit.edu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/gabs1234/QuaternionFrame
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.24
|
|
12
|
+
Requires-Dist: quantities>=0.16
|
|
13
|
+
Provides-Extra: visualization
|
|
14
|
+
Requires-Dist: pyvista>=0.43; extra == "visualization"
|
|
15
|
+
Requires-Dist: matplotlib>=3.6; extra == "visualization"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# PyFrameTree
|
|
19
|
+
|
|
20
|
+
Nested 3D coordinate frames and point-cloud transformations, with physical units.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
From the repository:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv sync --extra visualization
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv run --extra visualization python examples/simple_transform.py --show
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pyframetree import CoordinateSystem
|
|
38
|
+
import quantities as pq
|
|
39
|
+
import numpy as np
|
|
40
|
+
import pyvista as pv
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
plotter = pv.Plotter()
|
|
45
|
+
|
|
46
|
+
# Create a random point cloud
|
|
47
|
+
N = 100
|
|
48
|
+
points = np.random.rand(N, 3)
|
|
49
|
+
|
|
50
|
+
# Create a reference coordinate system
|
|
51
|
+
Reference = CoordinateSystem()
|
|
52
|
+
|
|
53
|
+
# Create a main coordinate system
|
|
54
|
+
Main = CoordinateSystem()
|
|
55
|
+
|
|
56
|
+
# Create a child coordinate system
|
|
57
|
+
Child = CoordinateSystem(parent=Main, name='Child')
|
|
58
|
+
Child.add_points(points, 'points')
|
|
59
|
+
Child.translate([2, 2, 0]*pq.m)
|
|
60
|
+
Child.rotate([0, 1, 1], 30*pq.deg)
|
|
61
|
+
|
|
62
|
+
Main.scale(3, inherit=False) # Scale the main coordinate system, but not its children
|
|
63
|
+
Main.rotate([1, 0, 0], 30*pq.deg, inherit=True)
|
|
64
|
+
|
|
65
|
+
Reference.visualize(plotter=plotter, children=False, cmap='gray')
|
|
66
|
+
Main.visualize(plotter=plotter, children=False, cmap='plasma')
|
|
67
|
+
Child.visualize(plotter=plotter, children=False, cmap='viridis')
|
|
68
|
+
|
|
69
|
+
plotter.save_graphic('assets/frames.svg')
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+

|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyframetree/__init__.py,sha256=Q08bYBPG2IVm-H0fuuJKdIbMEsJuFNL7lre1svUdHAU,329
|
|
2
|
+
pyframetree/backend.py,sha256=-mUpDekhlam1wmAdefcheKey18o-IjLATa0C0Y6-CJY,971
|
|
3
|
+
pyframetree/coordinate_systems.py,sha256=4bV_XeKJ0HLKTnt4upMj8k85JrXSNc82g1rRPkv3x0E,13489
|
|
4
|
+
pyframetree/rotation.py,sha256=nIYQWDsNp5wFNdvqQYsBHqsB04GwqPNWo-aKRbWntS0,3218
|
|
5
|
+
pyframetree/transformations.py,sha256=xmWaoQFEDKMqyBY4ApCQ6zD-SEIio0hukRjKgDkYrr0,4907
|
|
6
|
+
pyframetree/utils.py,sha256=H05OnsFI39MATGxJ9EVa1OSEzSOzAKZiudSKAcIRJF4,2779
|
|
7
|
+
pyframetree-0.3.0.dist-info/licenses/LICENSE,sha256=GTVQl3vH6ht70wJXKC0yMT8CmXKHxv_YyO_utAgm7EA,1065
|
|
8
|
+
pyframetree-0.3.0.dist-info/METADATA,sha256=E82MRXWYUXmBqRjSihAOqld2JU3SE0YqfPiToVJo7Ao,1829
|
|
9
|
+
pyframetree-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
pyframetree-0.3.0.dist-info/top_level.txt,sha256=tTpwoeaLaeADigd40EzdW5zN9vg-dmwVinahYG1uGVw,12
|
|
11
|
+
pyframetree-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyframetree
|