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.
@@ -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})'
@@ -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
+ ![example](assets/frames.svg)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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