hazy-frames 0.1.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.
hazy/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Coordinate system module for frame-aware geometric transformations.
2
+
3
+ This module provides hierarchical reference frames and geometric primitives (Point, Vector)
4
+ that carry frame information and support coordinate transformations.
5
+
6
+ Key classes:
7
+ - Frame: Hierarchical reference frame with transformation tracking
8
+ - Point: Geometric point with frame awareness (w=1)
9
+ - Vector: Geometric vector with frame awareness (w=0)
10
+
11
+ Example:
12
+ >>> from hazy import Frame, Point, Vector
13
+ >>>
14
+ >>> # Create frames
15
+ >>> parent = Frame(name="parent")
16
+ >>> child = Frame(parent=parent, name="child")
17
+ >>> child.translate(x=5).rotate_euler(z=90, degrees=True)
18
+ >>>
19
+ >>> # Create points in child frame
20
+ >>> p_local = Point(1, 0, 0, frame=child)
21
+ >>> p_parent = p_local.to_frame(parent)
22
+ >>> print(p_parent) # Point at (5, 1, 0) in parent frame
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from hazy.frame import Frame
27
+ from hazy.primitives import Point, Vector
28
+
29
+ __all__ = ["Frame", "Point", "Vector"]
hazy/constants.py ADDED
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from scipy.spatial.transform import Rotation
5
+
6
+ VSMALL: float = 1e-8
7
+ VVSMALL: float = 1e-12
8
+
9
+ IDENTITY_SCALE = np.ones(3, dtype=float)
10
+ IDENTITY_TRANSLATION = np.zeros(3, dtype=float)
11
+ IDENTITY_ROTATION = Rotation.identity()
hazy/frame.py ADDED
@@ -0,0 +1,494 @@
1
+ """Hierarchical reference frames with transformation tracking and caching.
2
+
3
+ This module provides a Frame class for managing coordinate system transformations
4
+ using homogeneous transformation matrices. Frames can be hierarchically organized
5
+ with parent-child relationships, and transformations are cached for performance.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ from functools import reduce, wraps
12
+ from operator import add, mul
13
+ from typing import TYPE_CHECKING, Literal, Self, overload
14
+
15
+ import numpy as np
16
+ from scipy.spatial.transform import Rotation
17
+
18
+ from hazy.constants import IDENTITY_ROTATION, IDENTITY_SCALE, IDENTITY_TRANSLATION
19
+ from hazy.primitives import Point, Vector
20
+
21
+ if TYPE_CHECKING:
22
+ from numpy.typing import ArrayLike, NDArray
23
+
24
+ # from laser_cross_calibration.coordinate_system.primitives import Point, Vector
25
+
26
+
27
+ def invalidate_transform_cache(method):
28
+ """Decorator to invalidate cached transforms when frame is modified.
29
+
30
+ Args:
31
+ method: Method that modifies the frame
32
+
33
+ Returns:
34
+ Wrapped method that clears caches before execution
35
+
36
+ Raises:
37
+ RuntimeError: If frame is frozen
38
+ """
39
+
40
+ @wraps(method)
41
+ def wrapper(self: Frame, *args, **kwargs):
42
+ if self._is_frozen:
43
+ raise RuntimeError("Can not modify frozen frame.")
44
+ self._cached_transform = None
45
+ self._cached_transform_global = None
46
+ return method(self, *args, **kwargs)
47
+
48
+ return wrapper
49
+
50
+
51
+ class Frame:
52
+ """Hierarchical reference frame with transformation tracking.
53
+
54
+ Frames support accumulation of transformations (translation, rotation, scaling)
55
+ and provide cached transformation matrices for efficient repeated calculations.
56
+
57
+ Transformations are applied in S→R→T order (Scale, Rotation, Translation)
58
+ when converting from local to parent coordinates.
59
+
60
+ Attributes:
61
+ parent: Parent frame in hierarchy (None for root frames)
62
+ name: Human-readable frame identifier
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ parent: Frame | None = None,
68
+ name: str | None = None,
69
+ ):
70
+ """Initialize a new reference frame.
71
+
72
+ Args:
73
+ parent: Parent frame in hierarchy (None for root frames)
74
+ name: Frame identifier (auto-generated if not provided)
75
+ """
76
+ self._parent: Frame | None = parent
77
+ self._name = name or f"Frame-{id(self)}"
78
+
79
+ self._rotations: list[Rotation] = [IDENTITY_ROTATION]
80
+ self._translations: list[NDArray[np.floating]] = [IDENTITY_TRANSLATION]
81
+ self._scalings: list[NDArray[np.floating]] = [IDENTITY_SCALE]
82
+
83
+ self._cached_transform: NDArray[np.floating] | None = None
84
+ self._cached_transform_global: NDArray[np.floating] | None = None
85
+
86
+ self._is_frozen = False
87
+
88
+ def __deepcopy__(self, memo):
89
+ """Create a deep copy of the frame and its hierarchy."""
90
+ cls = self.__class__
91
+ result = cls.__new__(cls)
92
+ memo[id(self)] = result
93
+
94
+ for k, v in self.__dict__.items():
95
+ setattr(result, k, copy.deepcopy(v, memo))
96
+
97
+ return result
98
+
99
+ @property
100
+ def parent(self) -> Frame | None:
101
+ return self._parent
102
+
103
+ @property
104
+ def name(self) -> str:
105
+ return self._name
106
+
107
+ @property
108
+ def root(self) -> Frame:
109
+ """Get the root frame of this hierarchy.
110
+
111
+ Returns:
112
+ Root frame (self if no parent, otherwise traverses up to root)
113
+ """
114
+ current = self
115
+ while current.parent is not None:
116
+ current = current.parent
117
+ return current
118
+
119
+ @property
120
+ def combined_rotation(self) -> Rotation:
121
+ """Combined rotation from all accumulated rotations."""
122
+ return reduce(mul, self._rotations)
123
+
124
+ @property
125
+ def combined_scale(self) -> NDArray[np.floating]:
126
+ """Combined scaling matrix from all accumulated scalings."""
127
+ return np.diag(np.append(reduce(mul, self._scalings), 1))
128
+
129
+ @property
130
+ def combined_translation(self) -> NDArray[np.floating]:
131
+ """Combined translation vector from all accumulated translations."""
132
+ return reduce(add, self._translations)
133
+
134
+ @property
135
+ def transform_to_parent(self) -> NDArray[np.floating]:
136
+ """4x4 homogeneous transformation matrix from local to parent frame.
137
+
138
+ Transformations are applied in S→R→T order (Scale, Rotation, Translation).
139
+ Results are cached for performance.
140
+
141
+ Returns:
142
+ 4x4 transformation matrix (copy to prevent modification)
143
+ """
144
+ if self._cached_transform is not None:
145
+ return self._cached_transform.copy()
146
+
147
+ transform = np.eye(4)
148
+
149
+ transform = self.combined_scale @ transform
150
+ transform[:3, :3] = self.combined_rotation.as_matrix() @ transform[:3, :3]
151
+ transform[:3, 3] += self.combined_translation
152
+
153
+ self._cached_transform = transform.copy()
154
+
155
+ return transform.copy()
156
+
157
+ @property
158
+ def transform_from_parent(self) -> NDArray[np.floating]:
159
+ """4x4 homogeneous transformation matrix from parent to local frame.
160
+
161
+ Returns:
162
+ Inverse of transform_to_parent
163
+ """
164
+ return np.linalg.inv(self.transform_to_parent)
165
+
166
+ def freeze(self) -> Self:
167
+ """Freeze frame to prevent further modifications.
168
+
169
+ Returns:
170
+ Self for method chaining
171
+ """
172
+ self._is_frozen = True
173
+ return self
174
+
175
+ def unfreeze(self) -> Self:
176
+ """Unfreeze frame to allow modifications.
177
+
178
+ Returns:
179
+ Self for method chaining
180
+ """
181
+ self._is_frozen = False
182
+ return self
183
+
184
+ @invalidate_transform_cache
185
+ def rotate_euler(
186
+ self,
187
+ *,
188
+ x: float = 0.0,
189
+ y: float = 0.0,
190
+ z: float = 0.0,
191
+ seq: Literal["xyz", "xzy", "yzx", "yxz", "zxy", "zyx"] = "xyz",
192
+ degrees: bool = False,
193
+ ) -> Self:
194
+ """Add Euler angle rotation to frame.
195
+
196
+ Args:
197
+ x: Rotation around x-axis
198
+ y: Rotation around y-axis
199
+ z: Rotation around z-axis
200
+ seq: Rotation sequence (default: xyz)
201
+ degrees: If True, angles are in degrees, otherwise radians
202
+
203
+ Returns:
204
+ Self for method chaining
205
+ """
206
+ R = Rotation.from_euler(seq=seq, angles=(x, y, z), degrees=degrees)
207
+ self._rotations.append(R)
208
+ return self
209
+
210
+ @invalidate_transform_cache
211
+ def rotate(self, rotation) -> Self:
212
+ """Add rotation matrix to frame.
213
+
214
+ Args:
215
+ rotation: 3x3 rotation matrix
216
+
217
+ Returns:
218
+ Self for method chaining
219
+ """
220
+ R = Rotation.from_matrix(rotation)
221
+ self._rotations.append(R)
222
+ return self
223
+
224
+ @invalidate_transform_cache
225
+ def translate(self, *, x=0.0, y=0.0, z=0.0) -> Self:
226
+ """Add translation to frame.
227
+
228
+ Args:
229
+ x: Translation along x-axis
230
+ y: Translation along y-axis
231
+ z: Translation along z-axis
232
+
233
+ Returns:
234
+ Self for method chaining
235
+ """
236
+ translation = np.array([x, y, z], dtype=float)
237
+ self._translations.append(translation)
238
+ return self
239
+
240
+ @invalidate_transform_cache
241
+ def scale(self, scale: float | tuple[float, float, float] | ArrayLike) -> Self:
242
+ """Add scaling to frame.
243
+
244
+ Args:
245
+ scale: Uniform scale factor or (sx, sy, sz) tuple
246
+
247
+ Returns:
248
+ Self for method chaining
249
+
250
+ Raises:
251
+ ValueError: If tuple doesn't have exactly 3 elements
252
+ """
253
+ if isinstance(scale, float | int):
254
+ scaling = np.ones(3, dtype=float) * scale
255
+ else:
256
+ scaling = np.asarray(scale, dtype=float).flatten()
257
+ if scaling.shape != (3,):
258
+ raise ValueError()
259
+
260
+ self._scalings.append(scaling)
261
+ return self
262
+
263
+ @property
264
+ def transform_to_global(self) -> NDArray[np.floating]:
265
+ """4x4 transformation matrix from this frame to global frame.
266
+
267
+ Recursively composes transformations through parent hierarchy.
268
+ Results are cached for performance.
269
+
270
+ Returns:
271
+ 4x4 transformation matrix
272
+ """
273
+ if self._cached_transform_global is not None:
274
+ return self._cached_transform_global.copy()
275
+
276
+ if self.parent is None:
277
+ self._cached_transform_global = np.eye(4, dtype=float)
278
+ else:
279
+ self._cached_transform_global = (
280
+ self.parent.transform_to_global @ self.transform_to_parent
281
+ )
282
+ return self._cached_transform_global
283
+
284
+ @property
285
+ def transform_from_global(self) -> NDArray[np.floating]:
286
+ """4x4 transformation matrix from global frame to this frame.
287
+
288
+ Returns:
289
+ Inverse of transform_to_global
290
+ """
291
+ return np.linalg.inv(self.transform_to_global)
292
+
293
+ def transform_to(self, target: Frame) -> NDArray[np.floating]:
294
+ """Compute transformation matrix from this frame to target frame.
295
+
296
+ Args:
297
+ target: Target reference frame
298
+
299
+ Returns:
300
+ 4x4 transformation matrix from self to target
301
+
302
+ Raises:
303
+ RuntimeError: If frames belong to different hierarchies (different roots)
304
+ """
305
+ if self == target:
306
+ return np.eye(4)
307
+
308
+ if self.root is not target.root:
309
+ raise RuntimeError(
310
+ f"Cannot transform between frames from different hierarchies. "
311
+ f"Frame '{self.name}' has root '{self.root.name}', "
312
+ f"but frame '{target.name}' has root '{target.root.name}'."
313
+ )
314
+
315
+ return target.transform_from_global @ self.transform_to_global
316
+
317
+ @property
318
+ def x_axis(self) -> Vector:
319
+ return Vector(x=1.0, y=0.0, z=0.0, frame=self)
320
+
321
+ @property
322
+ def x_axis_global(self) -> Vector:
323
+ return self.x_axis.to_frame(target_frame=self.root)
324
+
325
+ @property
326
+ def y_axis(self) -> Vector:
327
+ return Vector(x=0.0, y=1.0, z=0.0, frame=self)
328
+
329
+ @property
330
+ def y_axis_global(self) -> Vector:
331
+ return self.y_axis.to_frame(target_frame=self.root)
332
+
333
+ @property
334
+ def z_axis(self) -> Vector:
335
+ return Vector(x=0.0, y=0.0, z=1.0, frame=self)
336
+
337
+ @property
338
+ def z_axis_global(self) -> Vector:
339
+ return self.z_axis.to_frame(target_frame=self.root)
340
+
341
+ @property
342
+ def origin(self) -> Point:
343
+ return Point(x=0.0, y=0.0, z=0.0, frame=self)
344
+
345
+ @property
346
+ def origin_global(self):
347
+ return self.origin.to_frame(target_frame=self.root)
348
+
349
+ @overload
350
+ def vector(self, x: float, y: float, z: float) -> Vector: ...
351
+
352
+ @overload
353
+ def vector(self, x: ArrayLike) -> Vector: ...
354
+
355
+ def vector(
356
+ self, x: float | ArrayLike, y: float | None = None, z: float | None = None
357
+ ) -> Vector:
358
+ """Create vector in this frame.
359
+
360
+ Args:
361
+ x: X-coordinate or array-like [x, y, z]
362
+ y: Y-coordinate (required if x is scalar)
363
+ z: Z-coordinate (required if x is scalar)
364
+
365
+ Returns:
366
+ Vector in this frame
367
+
368
+ Examples:
369
+ >>> frame.vector(1.0, 2.0, 3.0)
370
+ >>> frame.vector([1, 2, 3])
371
+ >>> frame.vector(np.array([1, 2, 3]))
372
+ """
373
+ if y is None and z is None:
374
+ return Vector.from_array(x, frame=self)
375
+ elif y is not None and z is not None:
376
+ return Vector(x=x, y=y, z=z, frame=self)
377
+ else:
378
+ raise ValueError("Provide either (x, y, z) or single array-like")
379
+
380
+ @overload
381
+ def point(self, x: float, y: float, z: float) -> Point: ...
382
+
383
+ @overload
384
+ def point(self, x: ArrayLike) -> Point: ...
385
+
386
+ def point(
387
+ self, x: float | ArrayLike, y: float | None = None, z: float | None = None
388
+ ) -> Point:
389
+ """Create point in this frame.
390
+
391
+ Args:
392
+ x: X-coordinate or array-like [x, y, z]
393
+ y: Y-coordinate (required if x is scalar)
394
+ z: Z-coordinate (required if x is scalar)
395
+
396
+ Returns:
397
+ Point in this frame
398
+
399
+ Examples:
400
+ >>> frame.point(1.0, 2.0, 3.0)
401
+ >>> frame.point([1, 2, 3])
402
+ >>> frame.point(np.array([1, 2, 3]))
403
+ """
404
+ if y is None and z is None:
405
+ return Point.from_array(x, frame=self)
406
+ elif y is not None and z is not None:
407
+ return Point(x=x, y=y, z=z, frame=self)
408
+ else:
409
+ raise ValueError("Provide either (x, y, z) or single array-like")
410
+
411
+ def batch_transform_points_global(
412
+ self, points: NDArray[np.floating]
413
+ ) -> NDArray[np.floating]:
414
+ """Batch transform an array of points from this coordinate system to global.
415
+ Homogenous coordinate (w=1) will be added automatically.
416
+
417
+ Args
418
+ points: array of 3d points, will be reshaped to (N, 3)
419
+
420
+ Returns:
421
+ points transformed to global space
422
+ """
423
+ points = np.asarray(points)
424
+ original_shape = points.shape
425
+ points_homogenous = np.hstack(
426
+ [points.reshape(-1, 3), np.ones((points.size // 3, 1))]
427
+ )
428
+ transformed = points_homogenous @ self.transform_to_global.T
429
+ return transformed[:, :3].reshape(original_shape)
430
+
431
+ def batch_transform_vectors_global(
432
+ self, vectors: NDArray[np.floating]
433
+ ) -> NDArray[np.floating]:
434
+ """Batch transform an array of vectors from this coordinate system to global.
435
+ Homogenous coordinate (w=0) will be added automatically.
436
+
437
+ Args
438
+ vectors: array of 3d vectors, will be reshaped to (N, 3)
439
+
440
+ Returns:
441
+ vectors transformed to global space
442
+ """
443
+ vectors = np.asarray(vectors)
444
+ original_shape = vectors.shape
445
+ vectors_homogenous = np.hstack(
446
+ [vectors.reshape(-1, 3), np.ones((vectors.size // 3, 1))]
447
+ )
448
+ transformed = vectors_homogenous @ self.transform_to_global.T
449
+ return transformed[:, :3].reshape(original_shape)
450
+
451
+ def __repr__(self) -> str:
452
+ parent_name = self.parent.name if self.parent else "None"
453
+ # Subtract 1 because we always have identity elements
454
+ n_rot = len(self._rotations) - 1
455
+ n_trans = len(self._translations) - 1
456
+ n_scale = len(self._scalings) - 1
457
+ transforms = f"{n_rot}R+{n_trans}T+{n_scale}S"
458
+ frozen = " [FROZEN]" if self._is_frozen else ""
459
+ return (
460
+ f"Frame('{self.name}', "
461
+ f"parent='{parent_name}', "
462
+ f"transforms={transforms}{frozen})"
463
+ )
464
+
465
+ @classmethod
466
+ def make_root(cls, name: str | None = None) -> Frame:
467
+ """Create a root frame (frame without parent).
468
+
469
+ Args:
470
+ name: Optional name for the root frame
471
+
472
+ Returns:
473
+ New root frame
474
+
475
+ Example:
476
+ >>> root = Frame.make_root(name="world")
477
+ >>> robot = root.make_child(name="robot")
478
+ """
479
+ return cls(parent=None, name=name)
480
+
481
+ def make_child(self, name: str | None = None) -> Frame:
482
+ """Creates a frame with this frame as its parent.
483
+
484
+ Args:
485
+ name: Optional name for the child frame
486
+
487
+ Returns:
488
+ New child frame
489
+
490
+ Example:
491
+ >>> root = Frame.make_root(name="world")
492
+ >>> child = root.make_child(name="child")
493
+ """
494
+ return Frame(parent=self, name=name)