tgraph
transform-graph — Spatial Transformations for Robotics and Computer Vision
transform-graph is the foundational mathematical layer for Spatial AI and Robotics in Python.
It provides strict-typed handling of SE(3) rigid body transformations, camera and
orthographic projections, and a frame graph for automatic path composition.
Target Environment: Python 3.10+, NumPy 2.0+.
Key Classes
Transforms (3D → 3D)
| Class | Description |
|---|---|
Transform |
Full SE(3) rigid body transform (translation + quaternion rotation) |
Translation |
Pure translation (identity rotation) |
Rotation |
Pure rotation (zero translation) |
Identity |
Neutral element — composes with anything and returns the other operand |
MatrixTransform |
Generic 4×4 homogeneous matrix (fallback for mixed compositions) |
Convenience constructors on Transform and Rotation::
Transform.from_rotation_matrix(R, t=None, validate=True)
Transform.from_quaternion(q, t=None, convention="wxyz")
Transform.from_axis_angle(axis, angle, t=None)
Quaternion Interop (tgraph.quaternion)
Conversion between numpy-quaternion (wxyz), scipy Rotation, and raw arrays::
from tgraph.quaternion import to_xyzw, from_xyzw, to_scipy, from_scipy, normalize
Projections (3D → 2D)
| Class | Description |
|---|---|
CameraProjection |
Pinhole camera model with intrinsics K and optional distortion D |
OrthographicProjection |
Orthographic (BEV / front / side) projection at fixed resolution |
CompositeProjection |
Result of Projection × Transform — projects from any 3D frame |
Inverse Projections (2D → 3D)
| Class | Description |
|---|---|
InverseCameraProjection |
Unprojects pixels to 3D rays (use .unproject(pixels, depths)) |
InverseOrthographicProjection |
Lifts pixels back to 3D on the projection plane |
InverseCompositeProjection |
Result of Transform × InverseProjection |
Graph & Pose
| Class | Description |
|---|---|
TransformGraph |
Frame graph with BFS pathfinding and automatic composition |
Pose |
User-friendly wrapper for position + orientation in a named frame |
Composition Algebra
The * operator composes transforms. The dimensional flow determines valid operations:
| Composition | Flow | Result | Use Case |
|---|---|---|---|
Transform × Transform |
3D→3D→3D | Transform |
Chain rigid body transforms |
Projection × Transform |
3D→3D→2D | CompositeProjection |
Project from any frame |
Transform × InvProjection |
2D→3D→3D | InverseCompositeProjection |
Unproject + reposition |
Projection × InverseProjection |
2D→3D→2D | MatrixTransform |
Inter-image mapping |
Invalid compositions (raise TypeError):
InverseProjection × Transform— dimensional mismatch (2D→3D then 3D→3D)Projection × Projection— cannot compose two projections
Quick Start
import tgraph.transform as tf
import numpy as np
# Build a frame graph
graph = tf.TransformGraph()
graph.add_transform('world', 'robot', tf.Translation(x=1.0, y=2.0))
graph.add_transform('robot', 'camera', tf.Transform(
translation=[0.1, 0, 0.5],
rotation=tf.Rotation.from_roll_pitch_yaw(pitch=-0.1).rotation,
))
# Add a camera projection edge
K = np.array([[500, 0, 320], [0, 500, 240], [0, 0, 1]], dtype=np.float64)
graph.add_transform('camera', 'image', tf.CameraProjection(K=K))
# Query any transform — automatic path composition
world_to_image = graph.get_transform('world', 'image')
# Project 3D world points to pixels
points_world = np.array([[2.0, 3.0, 1.0]])
pixels = tf.transform_points(points_world, graph, 'world', 'image')
Apache 2.0 — Vistralis Labs
1# Copyright (c) 2026 Vistralis Labs. All rights reserved. 2# SPDX-License-Identifier: Apache-2.0 3 4""" 5# transform-graph — Spatial Transformations for Robotics and Computer Vision 6 7`transform-graph` is the foundational mathematical layer for Spatial AI and Robotics in Python. 8It provides strict-typed handling of SE(3) rigid body transformations, camera and 9orthographic projections, and a frame graph for automatic path composition. 10 11**Target Environment:** Python 3.10+, NumPy 2.0+. 12 13--- 14 15## Key Classes 16 17### Transforms (3D → 3D) 18 19| Class | Description | 20|-------|-------------| 21| `Transform` | Full SE(3) rigid body transform (translation + quaternion rotation) | 22| `Translation` | Pure translation (identity rotation) | 23| `Rotation` | Pure rotation (zero translation) | 24| `Identity` | Neutral element — composes with anything and returns the other operand | 25| `MatrixTransform` | Generic 4×4 homogeneous matrix (fallback for mixed compositions) | 26 27**Convenience constructors** on `Transform` and `Rotation`:: 28 29 Transform.from_rotation_matrix(R, t=None, validate=True) 30 Transform.from_quaternion(q, t=None, convention="wxyz") 31 Transform.from_axis_angle(axis, angle, t=None) 32 33### Quaternion Interop (`tgraph.quaternion`) 34 35Conversion between numpy-quaternion (wxyz), scipy Rotation, and raw arrays:: 36 37 from tgraph.quaternion import to_xyzw, from_xyzw, to_scipy, from_scipy, normalize 38 39### Projections (3D → 2D) 40 41| Class | Description | 42|-------|-------------| 43| `CameraProjection` | Pinhole camera model with intrinsics K and optional distortion D | 44| `OrthographicProjection` | Orthographic (BEV / front / side) projection at fixed resolution | 45| `CompositeProjection` | Result of `Projection × Transform` — projects from any 3D frame | 46 47### Inverse Projections (2D → 3D) 48 49| Class | Description | 50|-------|-------------| 51| `InverseCameraProjection` | Unprojects pixels to 3D rays (use `.unproject(pixels, depths)`) | 52| `InverseOrthographicProjection` | Lifts pixels back to 3D on the projection plane | 53| `InverseCompositeProjection` | Result of `Transform × InverseProjection` | 54 55### Graph & Pose 56 57| Class | Description | 58|-------|-------------| 59| `TransformGraph` | Frame graph with BFS pathfinding and automatic composition | 60| `Pose` | User-friendly wrapper for position + orientation in a named frame | 61 62--- 63 64## Composition Algebra 65 66The `*` operator composes transforms. The dimensional flow determines valid operations: 67 68| Composition | Flow | Result | Use Case | 69|-------------|------|--------|----------| 70| `Transform × Transform` | 3D→3D→3D | `Transform` | Chain rigid body transforms | 71| `Projection × Transform` | 3D→3D→2D | `CompositeProjection` | Project from any frame | 72| `Transform × InvProjection` | 2D→3D→3D | `InverseCompositeProjection` | Unproject + reposition | 73| `Projection × InverseProjection` | 2D→3D→2D | `MatrixTransform` | Inter-image mapping | 74 75**Invalid compositions** (raise `TypeError`): 76- `InverseProjection × Transform` — dimensional mismatch (2D→3D then 3D→3D) 77- `Projection × Projection` — cannot compose two projections 78 79--- 80 81## Quick Start 82 83```python 84import tgraph.transform as tf 85import numpy as np 86 87# Build a frame graph 88graph = tf.TransformGraph() 89graph.add_transform('world', 'robot', tf.Translation(x=1.0, y=2.0)) 90graph.add_transform('robot', 'camera', tf.Transform( 91 translation=[0.1, 0, 0.5], 92 rotation=tf.Rotation.from_roll_pitch_yaw(pitch=-0.1).rotation, 93)) 94 95# Add a camera projection edge 96K = np.array([[500, 0, 320], [0, 500, 240], [0, 0, 1]], dtype=np.float64) 97graph.add_transform('camera', 'image', tf.CameraProjection(K=K)) 98 99# Query any transform — automatic path composition 100world_to_image = graph.get_transform('world', 'image') 101 102# Project 3D world points to pixels 103points_world = np.array([[2.0, 3.0, 1.0]]) 104pixels = tf.transform_points(points_world, graph, 'world', 'image') 105``` 106 107--- 108 109Apache 2.0 — Vistralis Labs 110""" 111 112from .transform import ( 113 BaseTransform, 114 CameraProjection, 115 CompositeProjection, 116 Identity, 117 InverseCameraProjection, 118 InverseCompositeProjection, 119 InverseOrthographicProjection, 120 InverseProjection, 121 MatrixTransform, 122 OrthographicProjection, 123 Pose, 124 Projection, 125 ProjectionModel, 126 Rotation, 127 Transform, 128 TransformGraph, 129 Translation, 130 as_roll_pitch_yaw, 131 deserialize_transform, 132 from_roll_pitch_yaw, 133 project_points, 134 register_transform, 135 serialize_transform, 136 transform_points, 137) 138 139__version__ = "0.1.2" 140 141__all__ = [ 142 "BaseTransform", 143 "Transform", 144 "Translation", 145 "Rotation", 146 "Identity", 147 "MatrixTransform", 148 "Projection", 149 "InverseProjection", 150 "CameraProjection", 151 "InverseCameraProjection", 152 "OrthographicProjection", 153 "InverseOrthographicProjection", 154 "CompositeProjection", 155 "InverseCompositeProjection", 156 "ProjectionModel", 157 "TransformGraph", 158 "register_transform", 159 "serialize_transform", 160 "deserialize_transform", 161 "from_roll_pitch_yaw", 162 "as_roll_pitch_yaw", 163 "Pose", 164 "transform_points", 165 "project_points", 166]
351class BaseTransform(ABC): 352 """ 353 Abstract interface for all spatial transformations. 354 """ 355 356 def __init__(self, dtype: np.dtype = np.float64): 357 self.dtype = dtype 358 359 @abstractmethod 360 def as_matrix() -> np.ndarray: 361 """ 362 Returns the 4x4 homogeneous representation of the transform. 363 364 Returns: 365 np.ndarray: 4x4 matrix of the transform's dtype. 366 """ 367 pass 368 369 @abstractmethod 370 def inverse() -> BaseTransform: 371 """ 372 Returns the mathematical inverse of the transformation. 373 374 Returns: 375 BaseTransform: The inverse transformation. 376 """ 377 pass 378 379 def _apply(self, vector: np.ndarray | list | tuple) -> np.ndarray: 380 """ 381 Apply the transform to 3D vectors (Nx3 or Nx4). 382 383 Args: 384 vector: Nx3 or Nx4 array of vectors. 385 - If Nx3: Treated as 3D points/vectors (w=1 implied if Transform, 386 checking subclass logic). 387 Standard BaseTransform behavior: 388 Homogenize (w=1) -> Multiply -> Dehomogenize (w division). 389 - If Nx4: Treated as homogeneous vectors. 390 Multiply -> Return Nx4. 391 392 Returns: 393 np.ndarray: Transformed vectors (Nx3 or Nx4). 394 """ 395 vector = np.atleast_2d(vector) 396 397 if vector.shape[1] == 3: 398 # Homogenize (w=1) 399 hom_vector = np.hstack([vector, np.ones((vector.shape[0], 1), dtype=self.dtype)]) 400 # Apply 401 transformed_hom = (self.as_matrix() @ hom_vector.T).T 402 # Dehomogenize (return 3D) 403 return transformed_hom[:, :3] 404 405 elif vector.shape[1] == 4: 406 # Generic 4x4 407 return (self.as_matrix() @ vector.T).T 408 else: 409 raise ValueError(f"Input vector must be Nx3 or Nx4, got {vector.shape}") 410 411 @abstractmethod 412 def __mul__(self, other: BaseTransform) -> BaseTransform: 413 """ 414 Composes this transform with another. 415 Composition follows standard matrix multiplication order: (T1 * T2) * p = T1 * (T2 * p). 416 417 Args: 418 other: The transform to apply second. 419 420 Returns: 421 BaseTransform: The composed transformation. 422 """ 423 pass 424 425 @abstractmethod 426 def to_dict(self) -> dict[str, Any]: 427 """ 428 Serialize the transform to a JSON-compatible dictionary. 429 430 The dictionary MUST include a "type" key with the class name 431 to enable proper deserialization. 432 433 Returns: 434 Dict[str, Any]: JSON-compatible dictionary representation. 435 """ 436 pass 437 438 @classmethod 439 @abstractmethod 440 def from_dict(cls, data: dict[str, Any]) -> BaseTransform: 441 """ 442 Deserialize a transform from a dictionary. 443 444 Args: 445 data: Dictionary previously created by to_dict(). 446 447 Returns: 448 BaseTransform: The deserialized transform instance. 449 """ 450 pass 451 452 def __repr__(self) -> str: 453 return f"<{self.__class__.__name__}>"
Abstract interface for all spatial transformations.
359 @abstractmethod 360 def as_matrix() -> np.ndarray: 361 """ 362 Returns the 4x4 homogeneous representation of the transform. 363 364 Returns: 365 np.ndarray: 4x4 matrix of the transform's dtype. 366 """ 367 pass
Returns the 4x4 homogeneous representation of the transform.
Returns: np.ndarray: 4x4 matrix of the transform's dtype.
369 @abstractmethod 370 def inverse() -> BaseTransform: 371 """ 372 Returns the mathematical inverse of the transformation. 373 374 Returns: 375 BaseTransform: The inverse transformation. 376 """ 377 pass
Returns the mathematical inverse of the transformation.
Returns: BaseTransform: The inverse transformation.
425 @abstractmethod 426 def to_dict(self) -> dict[str, Any]: 427 """ 428 Serialize the transform to a JSON-compatible dictionary. 429 430 The dictionary MUST include a "type" key with the class name 431 to enable proper deserialization. 432 433 Returns: 434 Dict[str, Any]: JSON-compatible dictionary representation. 435 """ 436 pass
Serialize the transform to a JSON-compatible dictionary.
The dictionary MUST include a "type" key with the class name to enable proper deserialization.
Returns: Dict[str, Any]: JSON-compatible dictionary representation.
438 @classmethod 439 @abstractmethod 440 def from_dict(cls, data: dict[str, Any]) -> BaseTransform: 441 """ 442 Deserialize a transform from a dictionary. 443 444 Args: 445 data: Dictionary previously created by to_dict(). 446 447 Returns: 448 BaseTransform: The deserialized transform instance. 449 """ 450 pass
Deserialize a transform from a dictionary.
Args: data: Dictionary previously created by to_dict().
Returns: BaseTransform: The deserialized transform instance.
456@register_transform 457class Transform(BaseTransform): 458 """ 459 Standard SE(3) rigid body transformation. 460 Consists of a translation (3x1) and a rotation (quaternion). 461 """ 462 463 def __init__( 464 self, 465 translation: np.ndarray | list | tuple | None = None, 466 rotation: quaternion.quaternion | np.ndarray | list | tuple | Transform | None = None, 467 dtype: np.dtype = np.float64, 468 ): 469 super().__init__(dtype=dtype) 470 471 self.translation = ensure_translation(translation, self.dtype) 472 473 # Handle rotation: can be quaternion, array, or Transform/Rotation object 474 if isinstance(rotation, BaseTransform): 475 # It's a Transform or Rotation object - extract the quaternion 476 rotation = rotation.rotation 477 478 self.rotation = ensure_rotation(rotation, self.dtype) 479 480 @classmethod 481 def from_matrix(cls, matrix: np.ndarray, dtype: np.dtype | None = None) -> Transform: 482 """ 483 Creates a Transform from a 4x4 matrix. 484 """ 485 if matrix.shape != (4, 4): 486 raise ValueError(f"Matrix must be 4x4, got {matrix.shape}") 487 488 target_dtype = dtype if dtype is not None else matrix.dtype 489 translation = matrix[:3, 3] 490 rot_mat = matrix[:3, :3] 491 rot_quat = quaternion.from_rotation_matrix(rot_mat) 492 return cls(translation=translation, rotation=rot_quat, dtype=target_dtype) 493 494 @classmethod 495 def from_rotation_matrix( 496 cls, 497 rotation_matrix: np.ndarray, 498 t: np.ndarray | list | tuple | None = None, 499 *, 500 validate: bool = True, 501 dtype: np.dtype = np.float64, 502 ) -> Transform: 503 """Create a Transform from a 3x3 rotation matrix. 504 505 Args: 506 rotation_matrix: A 3x3 rotation matrix (must be SO(3) when 507 ``validate=True``). 508 t: Optional 3-element translation vector. 509 validate: If True (default), verify that ``rotation_matrix`` is a 510 valid SO(3) element (orthogonal with determinant +1). Set to 511 False to skip the check on trusted data. Follows the same 512 pattern as ``scipy.spatial.transform.Rotation.from_matrix`` 513 with ``assume_valid``. 514 dtype: NumPy dtype for the resulting transform. 515 516 Returns: 517 Transform: The constructed SE(3) transform. 518 519 Raises: 520 ValueError: If the matrix is not 3x3 or fails SO(3) validation. 521 522 Example: 523 >>> R = np.eye(3) 524 >>> tf.Transform.from_rotation_matrix(R, t=[1, 2, 3]) 525 """ 526 R = np.asarray(rotation_matrix, dtype=np.float64) 527 if R.shape != (3, 3): 528 raise ValueError(f"Rotation matrix must be 3x3, got {R.shape}") 529 530 if validate: 531 # Check orthogonality: R^T R ≈ I 532 orthogonality_error = np.max(np.abs(R.T @ R - np.eye(3))) 533 if orthogonality_error > 1e-6: 534 raise ValueError( 535 f"Rotation matrix is not orthogonal (max error {orthogonality_error:.2e}). " 536 "Expected R^T R = I. Pass validate=False to skip this check." 537 ) 538 # Check determinant ≈ +1 (proper rotation, not reflection) 539 det = np.linalg.det(R) 540 if abs(det - 1.0) > 1e-6: 541 raise ValueError( 542 f"Rotation matrix determinant is {det:.6f}, expected +1.0 (SO(3)). " 543 "A determinant of -1 indicates a reflection, not a rotation." 544 ) 545 546 rot_quat = quaternion.from_rotation_matrix(R) 547 return cls(translation=t, rotation=rot_quat, dtype=dtype) 548 549 @classmethod 550 def from_quaternion( 551 cls, 552 q: quaternion.quaternion | np.ndarray | list | tuple, 553 t: np.ndarray | list | tuple | None = None, 554 *, 555 convention: str = "wxyz", 556 dtype: np.dtype = np.float64, 557 ) -> Transform: 558 """Create a Transform from a quaternion. 559 560 The quaternion is auto-normalized to unit length. 561 562 Args: 563 q: Quaternion — either a ``quaternion.quaternion`` object or a 564 4-element array. The element order is determined by 565 ``convention``. 566 t: Optional 3-element translation vector. 567 convention: Element ordering of array input: 568 ``"wxyz"`` (default, numpy-quaternion / Hamilton) or 569 ``"xyzw"`` (scipy / ROS). Ignored when ``q`` is a 570 ``quaternion.quaternion`` object. 571 dtype: NumPy dtype for the resulting transform. 572 573 Returns: 574 Transform: The constructed SE(3) transform. 575 576 Raises: 577 ValueError: If the quaternion is zero or the convention is unknown. 578 579 Example: 580 >>> tf.Transform.from_quaternion([1, 0, 0, 0]) # wxyz identity 581 >>> tf.Transform.from_quaternion([0, 0, 0, 1], convention="xyzw") 582 """ 583 if isinstance(q, quaternion.quaternion): 584 quat = q 585 else: 586 arr = np.asarray(q, dtype=np.float64).ravel() 587 if arr.size != 4: 588 raise ValueError(f"Quaternion must have 4 elements, got {arr.size}") 589 if convention == "wxyz": 590 quat = quaternion.quaternion(*arr) 591 elif convention == "xyzw": 592 x, y, z, w = arr 593 quat = quaternion.quaternion(w, x, y, z) 594 else: 595 raise ValueError( 596 f"Unknown quaternion convention '{convention}'. " 597 "Use 'wxyz' (numpy-quaternion) or 'xyzw' (scipy/ROS)." 598 ) 599 600 # Normalize — reject zero 601 norm = np.sqrt(quat.w**2 + quat.x**2 + quat.y**2 + quat.z**2) 602 if norm < 1e-15: 603 raise ValueError("Zero-norm quaternion cannot represent a rotation.") 604 quat = quat / norm 605 606 return cls(translation=t, rotation=quat, dtype=dtype) 607 608 @classmethod 609 def from_axis_angle( 610 cls, 611 axis: np.ndarray | list | tuple, 612 angle: float, 613 t: np.ndarray | list | tuple | None = None, 614 *, 615 dtype: np.dtype = np.float64, 616 ) -> Transform: 617 """Create a Transform from an axis-angle representation. 618 619 The axis is auto-normalized to unit length. 620 621 Args: 622 axis: 3-element rotation axis (will be normalized). 623 angle: Rotation angle in radians. 624 t: Optional 3-element translation vector. 625 dtype: NumPy dtype for the resulting transform. 626 627 Returns: 628 Transform: The constructed SE(3) transform. 629 630 Raises: 631 ValueError: If the axis has zero length. 632 633 Example: 634 >>> tf.Transform.from_axis_angle([0, 0, 1], np.pi / 2) 635 """ 636 axis_array = np.asarray(axis, dtype=np.float64).ravel() 637 if axis_array.size != 3: 638 raise ValueError(f"Axis must have 3 elements, got {axis_array.size}") 639 640 axis_norm = np.linalg.norm(axis_array) 641 if axis_norm < 1e-15: 642 raise ValueError("Zero-length axis vector cannot define a rotation direction.") 643 644 axis_unit = axis_array / axis_norm 645 rotation_vector = axis_unit * float(angle) 646 rot_quat = quaternion.from_rotation_vector(rotation_vector) 647 648 return cls(translation=t, rotation=rot_quat, dtype=dtype) 649 650 def as_matrix(self) -> np.ndarray: 651 """Return the 4x4 homogeneous transformation matrix [R|t; 0 1].""" 652 matrix = np.eye(4, dtype=self.dtype) 653 matrix[:3, :3] = quaternion.as_rotation_matrix(self.rotation).astype(self.dtype) 654 matrix[:3, 3] = self.translation.ravel() 655 return matrix 656 657 def inverse(self) -> Transform: 658 """Return the inverse SE(3) transform: T^-1 = [-R^T t; R^T].""" 659 inv_rotation = self.rotation.conjugate() 660 # Rotate expects (..., 3) vectors. flatten() ensures we get (3,) if translation is (3, 1) 661 inv_translation = -quaternion.rotate_vectors(inv_rotation, self.translation.flatten()) 662 return Transform(translation=inv_translation, rotation=inv_rotation, dtype=self.dtype) 663 664 def __mul__(self, other: BaseTransform) -> BaseTransform: 665 if isinstance(other, Transform): 666 # T1 * T2 = [R1*R2, R1*t2 + t1] 667 new_rotation = self.rotation * other.rotation 668 new_translation = ( 669 quaternion.rotate_vectors(self.rotation, other.translation.flatten()) 670 + self.translation.flatten() 671 ) 672 return Transform(translation=new_translation, rotation=new_rotation, dtype=self.dtype) 673 674 if isinstance(other, (Projection, CameraProjection)): 675 # Transform * Projection -> Invalid 676 # 3D->3D * 3D->2D = dimensional mismatch if interpreted strictly as flow? 677 # Guidelines say: Transform * CameraProjection = Forbidden. 678 raise TypeError( 679 "Composition 'Transform * CameraProjection' is invalid. " 680 "Transforms (Spatial) cannot pre-multiply Projections (Sensor). " 681 "Did you mean 'CameraProjection * Transform'?" 682 ) 683 684 if isinstance(other, InverseProjection): 685 # Transform * InverseProjection -> InverseCompositeProjection 686 # T * P_inv 687 # Logic: P_inv unprojects to frame A. T transforms A to B. Result unprojects to B. 688 689 # If other is already InverseCompositeProjection: T * (T_old * K_inv) 690 # The __rmul__ of InverseCompositeProjection should handle 691 # this if we return NotImplemented? 692 # But BaseTransform doesn't implement __rmul__ universally. 693 # Let's handle it explicitly or rely on commutation if possible. 694 # Transform doesn't know about InverseCompositeProjection class structure necessarily? 695 # But we just added it to this module. 696 697 if isinstance(other, InverseCompositeProjection): 698 # T * ICA(T_old, K_inv) = ICA(T * T_old, K_inv) 699 new_transform = self * other.transform 700 if isinstance(new_transform, Transform): 701 return InverseCompositeProjection( 702 new_transform, other.projection, dtype=self.dtype 703 ) 704 705 # Generic InverseProjection (e.g. InverseCameraProjection or raw InverseProjection) 706 # Treat 'self' as the extrinsics T. 707 # Result = T * P_inv. 708 # We create InverseCompositeProjection(T, P_inv). 709 return InverseCompositeProjection(self, other, dtype=self.dtype) 710 711 # Fallback to matrix multiplication 712 return MatrixTransform(self.as_matrix() @ other.as_matrix(), dtype=self.dtype) 713 714 def __repr__(self) -> str: 715 return f"Transform(translation={self.translation.flatten()!r}, rotation={self.rotation!r})" 716 717 def to_dict(self) -> dict[str, Any]: 718 """Serialize transform to a JSON-compatible dictionary.""" 719 t = self.translation.flatten() 720 q = self.rotation 721 return { 722 "type": "Transform", 723 "translation": [float(t[0]), float(t[1]), float(t[2])], 724 "rotation": [float(q.w), float(q.x), float(q.y), float(q.z)], 725 "dtype": np.dtype(self.dtype).name, 726 } 727 728 @classmethod 729 def from_dict(cls, data: dict[str, Any]) -> Transform: 730 """Deserialize transform from a dictionary.""" 731 dtype = np.dtype(data.get("dtype", "float64")) 732 return cls( 733 translation=data["translation"], 734 rotation=data["rotation"], 735 dtype=dtype, 736 )
Standard SE(3) rigid body transformation. Consists of a translation (3x1) and a rotation (quaternion).
463 def __init__( 464 self, 465 translation: np.ndarray | list | tuple | None = None, 466 rotation: quaternion.quaternion | np.ndarray | list | tuple | Transform | None = None, 467 dtype: np.dtype = np.float64, 468 ): 469 super().__init__(dtype=dtype) 470 471 self.translation = ensure_translation(translation, self.dtype) 472 473 # Handle rotation: can be quaternion, array, or Transform/Rotation object 474 if isinstance(rotation, BaseTransform): 475 # It's a Transform or Rotation object - extract the quaternion 476 rotation = rotation.rotation 477 478 self.rotation = ensure_rotation(rotation, self.dtype)
480 @classmethod 481 def from_matrix(cls, matrix: np.ndarray, dtype: np.dtype | None = None) -> Transform: 482 """ 483 Creates a Transform from a 4x4 matrix. 484 """ 485 if matrix.shape != (4, 4): 486 raise ValueError(f"Matrix must be 4x4, got {matrix.shape}") 487 488 target_dtype = dtype if dtype is not None else matrix.dtype 489 translation = matrix[:3, 3] 490 rot_mat = matrix[:3, :3] 491 rot_quat = quaternion.from_rotation_matrix(rot_mat) 492 return cls(translation=translation, rotation=rot_quat, dtype=target_dtype)
Creates a Transform from a 4x4 matrix.
494 @classmethod 495 def from_rotation_matrix( 496 cls, 497 rotation_matrix: np.ndarray, 498 t: np.ndarray | list | tuple | None = None, 499 *, 500 validate: bool = True, 501 dtype: np.dtype = np.float64, 502 ) -> Transform: 503 """Create a Transform from a 3x3 rotation matrix. 504 505 Args: 506 rotation_matrix: A 3x3 rotation matrix (must be SO(3) when 507 ``validate=True``). 508 t: Optional 3-element translation vector. 509 validate: If True (default), verify that ``rotation_matrix`` is a 510 valid SO(3) element (orthogonal with determinant +1). Set to 511 False to skip the check on trusted data. Follows the same 512 pattern as ``scipy.spatial.transform.Rotation.from_matrix`` 513 with ``assume_valid``. 514 dtype: NumPy dtype for the resulting transform. 515 516 Returns: 517 Transform: The constructed SE(3) transform. 518 519 Raises: 520 ValueError: If the matrix is not 3x3 or fails SO(3) validation. 521 522 Example: 523 >>> R = np.eye(3) 524 >>> tf.Transform.from_rotation_matrix(R, t=[1, 2, 3]) 525 """ 526 R = np.asarray(rotation_matrix, dtype=np.float64) 527 if R.shape != (3, 3): 528 raise ValueError(f"Rotation matrix must be 3x3, got {R.shape}") 529 530 if validate: 531 # Check orthogonality: R^T R ≈ I 532 orthogonality_error = np.max(np.abs(R.T @ R - np.eye(3))) 533 if orthogonality_error > 1e-6: 534 raise ValueError( 535 f"Rotation matrix is not orthogonal (max error {orthogonality_error:.2e}). " 536 "Expected R^T R = I. Pass validate=False to skip this check." 537 ) 538 # Check determinant ≈ +1 (proper rotation, not reflection) 539 det = np.linalg.det(R) 540 if abs(det - 1.0) > 1e-6: 541 raise ValueError( 542 f"Rotation matrix determinant is {det:.6f}, expected +1.0 (SO(3)). " 543 "A determinant of -1 indicates a reflection, not a rotation." 544 ) 545 546 rot_quat = quaternion.from_rotation_matrix(R) 547 return cls(translation=t, rotation=rot_quat, dtype=dtype)
Create a Transform from a 3x3 rotation matrix.
Args:
rotation_matrix: A 3x3 rotation matrix (must be SO(3) when
validate=True).
t: Optional 3-element translation vector.
validate: If True (default), verify that rotation_matrix is a
valid SO(3) element (orthogonal with determinant +1). Set to
False to skip the check on trusted data. Follows the same
pattern as scipy.spatial.transform.Rotation.from_matrix
with assume_valid.
dtype: NumPy dtype for the resulting transform.
Returns: Transform: The constructed SE(3) transform.
Raises: ValueError: If the matrix is not 3x3 or fails SO(3) validation.
Example:
R = np.eye(3) tf.Transform.from_rotation_matrix(R, t=[1, 2, 3])
549 @classmethod 550 def from_quaternion( 551 cls, 552 q: quaternion.quaternion | np.ndarray | list | tuple, 553 t: np.ndarray | list | tuple | None = None, 554 *, 555 convention: str = "wxyz", 556 dtype: np.dtype = np.float64, 557 ) -> Transform: 558 """Create a Transform from a quaternion. 559 560 The quaternion is auto-normalized to unit length. 561 562 Args: 563 q: Quaternion — either a ``quaternion.quaternion`` object or a 564 4-element array. The element order is determined by 565 ``convention``. 566 t: Optional 3-element translation vector. 567 convention: Element ordering of array input: 568 ``"wxyz"`` (default, numpy-quaternion / Hamilton) or 569 ``"xyzw"`` (scipy / ROS). Ignored when ``q`` is a 570 ``quaternion.quaternion`` object. 571 dtype: NumPy dtype for the resulting transform. 572 573 Returns: 574 Transform: The constructed SE(3) transform. 575 576 Raises: 577 ValueError: If the quaternion is zero or the convention is unknown. 578 579 Example: 580 >>> tf.Transform.from_quaternion([1, 0, 0, 0]) # wxyz identity 581 >>> tf.Transform.from_quaternion([0, 0, 0, 1], convention="xyzw") 582 """ 583 if isinstance(q, quaternion.quaternion): 584 quat = q 585 else: 586 arr = np.asarray(q, dtype=np.float64).ravel() 587 if arr.size != 4: 588 raise ValueError(f"Quaternion must have 4 elements, got {arr.size}") 589 if convention == "wxyz": 590 quat = quaternion.quaternion(*arr) 591 elif convention == "xyzw": 592 x, y, z, w = arr 593 quat = quaternion.quaternion(w, x, y, z) 594 else: 595 raise ValueError( 596 f"Unknown quaternion convention '{convention}'. " 597 "Use 'wxyz' (numpy-quaternion) or 'xyzw' (scipy/ROS)." 598 ) 599 600 # Normalize — reject zero 601 norm = np.sqrt(quat.w**2 + quat.x**2 + quat.y**2 + quat.z**2) 602 if norm < 1e-15: 603 raise ValueError("Zero-norm quaternion cannot represent a rotation.") 604 quat = quat / norm 605 606 return cls(translation=t, rotation=quat, dtype=dtype)
Create a Transform from a quaternion.
The quaternion is auto-normalized to unit length.
Args:
q: Quaternion — either a quaternion.quaternion object or a
4-element array. The element order is determined by
convention.
t: Optional 3-element translation vector.
convention: Element ordering of array input:
"wxyz" (default, numpy-quaternion / Hamilton) or
"xyzw" (scipy / ROS). Ignored when q is a
quaternion.quaternion object.
dtype: NumPy dtype for the resulting transform.
Returns: Transform: The constructed SE(3) transform.
Raises: ValueError: If the quaternion is zero or the convention is unknown.
Example:
tf.Transform.from_quaternion([1, 0, 0, 0]) # wxyz identity tf.Transform.from_quaternion([0, 0, 0, 1], convention="xyzw")
608 @classmethod 609 def from_axis_angle( 610 cls, 611 axis: np.ndarray | list | tuple, 612 angle: float, 613 t: np.ndarray | list | tuple | None = None, 614 *, 615 dtype: np.dtype = np.float64, 616 ) -> Transform: 617 """Create a Transform from an axis-angle representation. 618 619 The axis is auto-normalized to unit length. 620 621 Args: 622 axis: 3-element rotation axis (will be normalized). 623 angle: Rotation angle in radians. 624 t: Optional 3-element translation vector. 625 dtype: NumPy dtype for the resulting transform. 626 627 Returns: 628 Transform: The constructed SE(3) transform. 629 630 Raises: 631 ValueError: If the axis has zero length. 632 633 Example: 634 >>> tf.Transform.from_axis_angle([0, 0, 1], np.pi / 2) 635 """ 636 axis_array = np.asarray(axis, dtype=np.float64).ravel() 637 if axis_array.size != 3: 638 raise ValueError(f"Axis must have 3 elements, got {axis_array.size}") 639 640 axis_norm = np.linalg.norm(axis_array) 641 if axis_norm < 1e-15: 642 raise ValueError("Zero-length axis vector cannot define a rotation direction.") 643 644 axis_unit = axis_array / axis_norm 645 rotation_vector = axis_unit * float(angle) 646 rot_quat = quaternion.from_rotation_vector(rotation_vector) 647 648 return cls(translation=t, rotation=rot_quat, dtype=dtype)
Create a Transform from an axis-angle representation.
The axis is auto-normalized to unit length.
Args: axis: 3-element rotation axis (will be normalized). angle: Rotation angle in radians. t: Optional 3-element translation vector. dtype: NumPy dtype for the resulting transform.
Returns: Transform: The constructed SE(3) transform.
Raises: ValueError: If the axis has zero length.
Example:
tf.Transform.from_axis_angle([0, 0, 1], np.pi / 2)
650 def as_matrix(self) -> np.ndarray: 651 """Return the 4x4 homogeneous transformation matrix [R|t; 0 1].""" 652 matrix = np.eye(4, dtype=self.dtype) 653 matrix[:3, :3] = quaternion.as_rotation_matrix(self.rotation).astype(self.dtype) 654 matrix[:3, 3] = self.translation.ravel() 655 return matrix
Return the 4x4 homogeneous transformation matrix [R|t; 0 1].
657 def inverse(self) -> Transform: 658 """Return the inverse SE(3) transform: T^-1 = [-R^T t; R^T].""" 659 inv_rotation = self.rotation.conjugate() 660 # Rotate expects (..., 3) vectors. flatten() ensures we get (3,) if translation is (3, 1) 661 inv_translation = -quaternion.rotate_vectors(inv_rotation, self.translation.flatten()) 662 return Transform(translation=inv_translation, rotation=inv_rotation, dtype=self.dtype)
Return the inverse SE(3) transform: T^-1 = [-R^T t; R^T].
717 def to_dict(self) -> dict[str, Any]: 718 """Serialize transform to a JSON-compatible dictionary.""" 719 t = self.translation.flatten() 720 q = self.rotation 721 return { 722 "type": "Transform", 723 "translation": [float(t[0]), float(t[1]), float(t[2])], 724 "rotation": [float(q.w), float(q.x), float(q.y), float(q.z)], 725 "dtype": np.dtype(self.dtype).name, 726 }
Serialize transform to a JSON-compatible dictionary.
728 @classmethod 729 def from_dict(cls, data: dict[str, Any]) -> Transform: 730 """Deserialize transform from a dictionary.""" 731 dtype = np.dtype(data.get("dtype", "float64")) 732 return cls( 733 translation=data["translation"], 734 rotation=data["rotation"], 735 dtype=dtype, 736 )
Deserialize transform from a dictionary.
739class Translation(Transform): 740 """A Transform with only translation (Identity rotation).""" 741 742 def __init__( 743 self, 744 x: float = 0.0, 745 y: float = 0.0, 746 z: float = 0.0, 747 translation: np.ndarray | list | tuple | None = None, 748 ): 749 if translation is not None: 750 super().__init__(translation=translation) 751 else: 752 super().__init__(translation=[x, y, z])
A Transform with only translation (Identity rotation).
755class Rotation(Transform): 756 """ 757 A Transform with only rotation (Zero translation). 758 759 Supports multiple construction patterns: 760 - Quaternion components: Rotation(w=1, x=0, y=0, z=0) 761 - Quaternion object: Rotation(rotation=q) 762 - Euler angles: Rotation.from_roll_pitch_yaw(roll=0, pitch=0, yaw=0) 763 ... 764 """ 765 766 def __init__( 767 self, 768 w: float = 1.0, 769 x: float = 0.0, 770 y: float = 0.0, 771 z: float = 0.0, 772 rotation: quaternion.quaternion | np.ndarray | list | tuple | None = None, 773 ): 774 if rotation is not None: 775 super().__init__(rotation=rotation) 776 else: 777 super().__init__(rotation=[w, x, y, z]) 778 779 @classmethod 780 def from_roll_pitch_yaw( 781 cls, 782 roll: float = 0.0, 783 pitch: float = 0.0, 784 yaw: float = 0.0, 785 ) -> Rotation: 786 """ 787 Create a Rotation from roll-pitch-yaw angles. 788 789 Uses the aerospace/robotics intrinsic **ZYX** (Tait-Bryan) convention: 790 yaw (Z) → pitch (Y) → roll (X). 791 792 Args: 793 roll: Rotation about X-axis in radians. 794 pitch: Rotation about Y-axis in radians. 795 yaw: Rotation about Z-axis in radians. 796 797 Returns: 798 Rotation: A rotation-only transform. 799 800 Example: 801 >>> attitude = tf.Rotation.from_roll_pitch_yaw(pitch=np.radians(10)) 802 >>> heading = tf.Rotation.from_roll_pitch_yaw(yaw=np.pi/4) 803 """ 804 q = from_roll_pitch_yaw(roll=roll, pitch=pitch, yaw=yaw) 805 return cls(rotation=q) 806 807 def as_roll_pitch_yaw(self) -> tuple[float, float, float]: 808 """ 809 Extract roll, pitch, yaw from the rotation. 810 811 Uses the aerospace/robotics intrinsic **ZYX** (Tait-Bryan) convention. 812 813 Returns: 814 Tuple[float, float, float]: ``(roll, pitch, yaw)`` in radians. 815 816 Warning: 817 Euler angles have a singularity (gimbal lock) when pitch = ±90°. 818 819 Example: 820 >>> rotation = tf.Rotation.from_roll_pitch_yaw(roll=0.1, pitch=0.2, yaw=0.3) 821 >>> roll, pitch, yaw = rotation.as_roll_pitch_yaw() 822 """ 823 return as_roll_pitch_yaw(self.rotation) 824 825 @classmethod 826 def from_rotation_matrix( 827 cls, 828 rotation_matrix: np.ndarray, 829 *, 830 validate: bool = True, 831 ) -> Rotation: 832 """Create a Rotation from a 3x3 rotation matrix. 833 834 See :meth:`Transform.from_rotation_matrix` for full documentation. 835 836 Args: 837 rotation_matrix: A 3x3 rotation matrix. 838 validate: If True, verify SO(3) membership. 839 840 Returns: 841 Rotation: A rotation-only transform. 842 """ 843 transform = Transform.from_rotation_matrix(rotation_matrix, validate=validate) 844 return cls(rotation=transform.rotation) 845 846 @classmethod 847 def from_quaternion( 848 cls, 849 q: quaternion.quaternion | np.ndarray | list | tuple, 850 *, 851 convention: str = "wxyz", 852 ) -> Rotation: 853 """Create a Rotation from a quaternion. 854 855 See :meth:`Transform.from_quaternion` for full documentation. 856 857 Args: 858 q: Quaternion (object or 4-element array). 859 convention: ``"wxyz"`` or ``"xyzw"``. 860 861 Returns: 862 Rotation: A rotation-only transform. 863 """ 864 transform = Transform.from_quaternion(q, convention=convention) 865 return cls(rotation=transform.rotation) 866 867 @classmethod 868 def from_axis_angle( 869 cls, 870 axis: np.ndarray | list | tuple, 871 angle: float, 872 ) -> Rotation: 873 """Create a Rotation from an axis-angle representation. 874 875 See :meth:`Transform.from_axis_angle` for full documentation. 876 877 Args: 878 axis: 3-element rotation axis (auto-normalized). 879 angle: Rotation angle in radians. 880 881 Returns: 882 Rotation: A rotation-only transform. 883 """ 884 transform = Transform.from_axis_angle(axis, angle) 885 return cls(rotation=transform.rotation)
A Transform with only rotation (Zero translation).
Supports multiple construction patterns:
- Quaternion components: Rotation(w=1, x=0, y=0, z=0)
- Quaternion object: Rotation(rotation=q)
- Euler angles: Rotation.from_roll_pitch_yaw(roll=0, pitch=0, yaw=0) ...
766 def __init__( 767 self, 768 w: float = 1.0, 769 x: float = 0.0, 770 y: float = 0.0, 771 z: float = 0.0, 772 rotation: quaternion.quaternion | np.ndarray | list | tuple | None = None, 773 ): 774 if rotation is not None: 775 super().__init__(rotation=rotation) 776 else: 777 super().__init__(rotation=[w, x, y, z])
779 @classmethod 780 def from_roll_pitch_yaw( 781 cls, 782 roll: float = 0.0, 783 pitch: float = 0.0, 784 yaw: float = 0.0, 785 ) -> Rotation: 786 """ 787 Create a Rotation from roll-pitch-yaw angles. 788 789 Uses the aerospace/robotics intrinsic **ZYX** (Tait-Bryan) convention: 790 yaw (Z) → pitch (Y) → roll (X). 791 792 Args: 793 roll: Rotation about X-axis in radians. 794 pitch: Rotation about Y-axis in radians. 795 yaw: Rotation about Z-axis in radians. 796 797 Returns: 798 Rotation: A rotation-only transform. 799 800 Example: 801 >>> attitude = tf.Rotation.from_roll_pitch_yaw(pitch=np.radians(10)) 802 >>> heading = tf.Rotation.from_roll_pitch_yaw(yaw=np.pi/4) 803 """ 804 q = from_roll_pitch_yaw(roll=roll, pitch=pitch, yaw=yaw) 805 return cls(rotation=q)
Create a Rotation from roll-pitch-yaw angles.
Uses the aerospace/robotics intrinsic ZYX (Tait-Bryan) convention: yaw (Z) → pitch (Y) → roll (X).
Args: roll: Rotation about X-axis in radians. pitch: Rotation about Y-axis in radians. yaw: Rotation about Z-axis in radians.
Returns: Rotation: A rotation-only transform.
Example:
attitude = tf.Rotation.from_roll_pitch_yaw(pitch=np.radians(10)) heading = tf.Rotation.from_roll_pitch_yaw(yaw=np.pi/4)
807 def as_roll_pitch_yaw(self) -> tuple[float, float, float]: 808 """ 809 Extract roll, pitch, yaw from the rotation. 810 811 Uses the aerospace/robotics intrinsic **ZYX** (Tait-Bryan) convention. 812 813 Returns: 814 Tuple[float, float, float]: ``(roll, pitch, yaw)`` in radians. 815 816 Warning: 817 Euler angles have a singularity (gimbal lock) when pitch = ±90°. 818 819 Example: 820 >>> rotation = tf.Rotation.from_roll_pitch_yaw(roll=0.1, pitch=0.2, yaw=0.3) 821 >>> roll, pitch, yaw = rotation.as_roll_pitch_yaw() 822 """ 823 return as_roll_pitch_yaw(self.rotation)
Extract roll, pitch, yaw from the rotation.
Uses the aerospace/robotics intrinsic ZYX (Tait-Bryan) convention.
Returns:
Tuple[float, float, float]: (roll, pitch, yaw) in radians.
Warning: Euler angles have a singularity (gimbal lock) when pitch = ±90°.
Example:
rotation = tf.Rotation.from_roll_pitch_yaw(roll=0.1, pitch=0.2, yaw=0.3) roll, pitch, yaw = rotation.as_roll_pitch_yaw()
825 @classmethod 826 def from_rotation_matrix( 827 cls, 828 rotation_matrix: np.ndarray, 829 *, 830 validate: bool = True, 831 ) -> Rotation: 832 """Create a Rotation from a 3x3 rotation matrix. 833 834 See :meth:`Transform.from_rotation_matrix` for full documentation. 835 836 Args: 837 rotation_matrix: A 3x3 rotation matrix. 838 validate: If True, verify SO(3) membership. 839 840 Returns: 841 Rotation: A rotation-only transform. 842 """ 843 transform = Transform.from_rotation_matrix(rotation_matrix, validate=validate) 844 return cls(rotation=transform.rotation)
Create a Rotation from a 3x3 rotation matrix.
See Transform.from_rotation_matrix() for full documentation.
Args: rotation_matrix: A 3x3 rotation matrix. validate: If True, verify SO(3) membership.
Returns: Rotation: A rotation-only transform.
846 @classmethod 847 def from_quaternion( 848 cls, 849 q: quaternion.quaternion | np.ndarray | list | tuple, 850 *, 851 convention: str = "wxyz", 852 ) -> Rotation: 853 """Create a Rotation from a quaternion. 854 855 See :meth:`Transform.from_quaternion` for full documentation. 856 857 Args: 858 q: Quaternion (object or 4-element array). 859 convention: ``"wxyz"`` or ``"xyzw"``. 860 861 Returns: 862 Rotation: A rotation-only transform. 863 """ 864 transform = Transform.from_quaternion(q, convention=convention) 865 return cls(rotation=transform.rotation)
Create a Rotation from a quaternion.
See Transform.from_quaternion() for full documentation.
Args:
q: Quaternion (object or 4-element array).
convention: "wxyz" or "xyzw".
Returns: Rotation: A rotation-only transform.
867 @classmethod 868 def from_axis_angle( 869 cls, 870 axis: np.ndarray | list | tuple, 871 angle: float, 872 ) -> Rotation: 873 """Create a Rotation from an axis-angle representation. 874 875 See :meth:`Transform.from_axis_angle` for full documentation. 876 877 Args: 878 axis: 3-element rotation axis (auto-normalized). 879 angle: Rotation angle in radians. 880 881 Returns: 882 Rotation: A rotation-only transform. 883 """ 884 transform = Transform.from_axis_angle(axis, angle) 885 return cls(rotation=transform.rotation)
Create a Rotation from an axis-angle representation.
See Transform.from_axis_angle() for full documentation.
Args: axis: 3-element rotation axis (auto-normalized). angle: Rotation angle in radians.
Returns: Rotation: A rotation-only transform.
888class Identity(Transform): 889 """The identity transform (0 translation, identity rotation).""" 890 891 def __init__(self): 892 super().__init__() 893 894 def __mul__(self, other: BaseTransform) -> BaseTransform: 895 """Identity is the neutral element: I * X = X.""" 896 return other
The identity transform (0 translation, identity rotation).
899@register_transform 900class MatrixTransform(BaseTransform): 901 """ 902 A generic transform held as a raw 4x4 matrix. 903 Used when SE(3) structure is lost or not applicable. 904 """ 905 906 def __init__(self, matrix: np.ndarray, dtype: np.dtype = np.float64): 907 super().__init__(dtype=dtype) 908 if matrix.shape != (4, 4): 909 raise ValueError(f"Matrix must be 4x4, got {matrix.shape}") 910 self.matrix = matrix.astype(self.dtype) 911 912 def as_matrix(self) -> np.ndarray: 913 """Return the stored 4x4 matrix.""" 914 return self.matrix 915 916 def inverse(self) -> MatrixTransform: 917 """Return the inverse via np.linalg.inv. 918 919 .. warning:: 920 Uses raw ``np.linalg.inv`` for convenience. This method is intended 921 for quick inspection and non-critical paths. For near-singular or 922 ill-conditioned matrices, prefer decomposing back into structured 923 types (``Transform``, ``Projection``) that have numerically stable 924 inverses. 925 """ 926 return MatrixTransform(np.linalg.inv(self.matrix), dtype=self.dtype) 927 928 def __mul__(self, other: BaseTransform) -> MatrixTransform: 929 return MatrixTransform(self.matrix @ other.as_matrix(), dtype=self.dtype) 930 931 def to_dict(self) -> dict[str, Any]: 932 """Serialize transform to a JSON-compatible dictionary.""" 933 return { 934 "type": "MatrixTransform", 935 "matrix": self.matrix.tolist(), 936 "dtype": np.dtype(self.dtype).name, 937 } 938 939 @classmethod 940 def from_dict(cls, data: dict[str, Any]) -> MatrixTransform: 941 """Deserialize transform from a dictionary.""" 942 dtype = np.dtype(data.get("dtype", "float64")) 943 return cls(matrix=np.array(data["matrix"]), dtype=dtype) 944 945 def __repr__(self) -> str: 946 # Format matrix with numpy's array_repr for better readability 947 matrix_str = np.array_repr(self.matrix, precision=4, suppress_small=True) 948 return f"MatrixTransform(matrix={matrix_str})"
A generic transform held as a raw 4x4 matrix. Used when SE(3) structure is lost or not applicable.
912 def as_matrix(self) -> np.ndarray: 913 """Return the stored 4x4 matrix.""" 914 return self.matrix
Return the stored 4x4 matrix.
916 def inverse(self) -> MatrixTransform: 917 """Return the inverse via np.linalg.inv. 918 919 .. warning:: 920 Uses raw ``np.linalg.inv`` for convenience. This method is intended 921 for quick inspection and non-critical paths. For near-singular or 922 ill-conditioned matrices, prefer decomposing back into structured 923 types (``Transform``, ``Projection``) that have numerically stable 924 inverses. 925 """ 926 return MatrixTransform(np.linalg.inv(self.matrix), dtype=self.dtype)
Return the inverse via np.linalg.inv.
Uses raw np.linalg.inv for convenience. This method is intended
for quick inspection and non-critical paths. For near-singular or
ill-conditioned matrices, prefer decomposing back into structured
types (Transform, Projection) that have numerically stable
inverses.
931 def to_dict(self) -> dict[str, Any]: 932 """Serialize transform to a JSON-compatible dictionary.""" 933 return { 934 "type": "MatrixTransform", 935 "matrix": self.matrix.tolist(), 936 "dtype": np.dtype(self.dtype).name, 937 }
Serialize transform to a JSON-compatible dictionary.
975@register_transform 976class Projection(BaseTransform): 977 """ 978 A 3D to 2D projection transformation. 979 980 Stores a projection matrix P that maps 3D homogeneous points to 2D. 981 Internally stored as 4x4 matrix with bottom row [0, 0, 0, 1] for compatibility. 982 983 The project_points() method projects 3D points to 2D pixel coordinates. 984 985 Note: Projections are generally non-invertible. The inverse() method returns 986 an InverseProjection which represents the conceptual inverse but requires 987 additional depth information to actually unproject points. 988 """ 989 990 def __init__( 991 self, 992 matrix: np.ndarray | list, 993 dtype: np.dtype = np.float64, 994 ): 995 """ 996 Create a Projection from a 3x4 or 4x4 matrix. 997 998 Args: 999 matrix: 3x4 or 4x4 projection matrix. 1000 dtype: Data type for the matrix. 1001 """ 1002 super().__init__(dtype=dtype) 1003 self.matrix = _ensure_4x4_projection(np.asarray(matrix), self.dtype) 1004 1005 def as_matrix(self) -> np.ndarray: 1006 """Returns the 4x4 projection matrix.""" 1007 return self.matrix 1008 1009 def as_matrix_3x4(self) -> np.ndarray: 1010 """Returns the 3x4 projection matrix (top 3 rows).""" 1011 return self.matrix[:3, :] 1012 1013 def inverse(self) -> InverseProjection: 1014 """ 1015 Returns an InverseProjection representing P^-1. 1016 1017 Note: The inverse projection requires depth information to actually 1018 unproject 2D points to 3D. 1019 """ 1020 return InverseProjection(self.matrix, dtype=self.dtype) 1021 1022 def __mul__(self, other: BaseTransform) -> BaseTransform: 1023 """Compose projection with another transform.""" 1024 result_matrix = self.matrix @ other.as_matrix() 1025 1026 # Compose with Rigid Transform -> CompositeProjection 1027 if isinstance(other, Transform): 1028 return CompositeProjection(self, other, dtype=self.dtype) 1029 1030 if isinstance(other, (Projection, CompositeProjection)): 1031 raise TypeError( 1032 f"Composition '{type(self).__name__} * " 1033 f"{type(other).__name__}' is invalid " 1034 "(dimensional mismatch). " 1035 "Both transformations map to 2D; you cannot compose them in this order." 1036 ) 1037 1038 # Fallback for all other types (MatrixTransform, InverseProjection) 1039 # These compositions (e.g. P * P_inv) stay within 2D->2D or 1040 # 3D->3D bounds but are not specialized. 1041 return MatrixTransform(result_matrix, dtype=self.dtype) 1042 1043 def _apply(self, vector: np.ndarray | list | tuple) -> np.ndarray: 1044 """ 1045 Project 3D vectors to 2D pixel coordinates. 1046 1047 Args: 1048 vector: Nx3 (points) or Nx4 (homogeneous) array. 1049 1050 Returns: 1051 np.ndarray: Nx2 pixel coordinates. 1052 """ 1053 vector = np.atleast_2d(vector) 1054 1055 if vector.shape[1] == 3: 1056 # Homogenize (w=1 implicit for points) 1057 hom_vec = np.hstack([vector, np.ones((vector.shape[0], 1), dtype=self.dtype)]) 1058 elif vector.shape[1] == 4: 1059 hom_vec = vector 1060 else: 1061 raise ValueError(f"Input must be Nx3 or Nx4, got {vector.shape}") 1062 1063 # Project: (3x4 or 4x4) @ 4x1 -> 4x1 (if 4x4) or 3x1 (if 3x4?) 1064 # Base class stores 4x4 with bottom [0,0,0,1]. 1065 # Result will be [u*w, v*w, w, 1]. 1066 projected = (self.matrix @ hom_vec.T).T 1067 1068 # We need [x, y, w] part. 1069 # projected is Nx4. 1070 1071 # Perspective division 1072 w = projected[:, 2:3] 1073 w = np.where(np.abs(w) < 1e-10, 1e-10, w) 1074 pixels = projected[:, :2] / w 1075 1076 return pixels 1077 1078 def project_points(self, points: np.ndarray | list | tuple) -> np.ndarray: 1079 """ 1080 Project 3D points (Nx3 or Nx4) to 2D pixel coordinates. 1081 alias for _apply(points). 1082 1083 Args: 1084 points: Nx3 or Nx4 array of points. 1085 1086 Returns: 1087 np.ndarray: Nx2 pixel coordinates. 1088 """ 1089 return self._apply(points) 1090 1091 def to_dict(self) -> dict[str, Any]: 1092 """Serialize projection to a JSON-compatible dictionary.""" 1093 return { 1094 "type": "Projection", 1095 "matrix": self.matrix.tolist(), 1096 "dtype": np.dtype(self.dtype).name, 1097 } 1098 1099 @classmethod 1100 def from_dict(cls, data: dict[str, Any]) -> Projection: 1101 """Deserialize projection from a dictionary.""" 1102 dtype = np.dtype(data.get("dtype", "float64")) 1103 return cls(matrix=np.array(data["matrix"]), dtype=dtype) 1104 1105 def __repr__(self) -> str: 1106 return f"Projection(matrix_shape={self.matrix.shape})"
A 3D to 2D projection transformation.
Stores a projection matrix P that maps 3D homogeneous points to 2D. Internally stored as 4x4 matrix with bottom row [0, 0, 0, 1] for compatibility.
The project_points() method projects 3D points to 2D pixel coordinates.
Note: Projections are generally non-invertible. The inverse() method returns an InverseProjection which represents the conceptual inverse but requires additional depth information to actually unproject points.
990 def __init__( 991 self, 992 matrix: np.ndarray | list, 993 dtype: np.dtype = np.float64, 994 ): 995 """ 996 Create a Projection from a 3x4 or 4x4 matrix. 997 998 Args: 999 matrix: 3x4 or 4x4 projection matrix. 1000 dtype: Data type for the matrix. 1001 """ 1002 super().__init__(dtype=dtype) 1003 self.matrix = _ensure_4x4_projection(np.asarray(matrix), self.dtype)
Create a Projection from a 3x4 or 4x4 matrix.
Args: matrix: 3x4 or 4x4 projection matrix. dtype: Data type for the matrix.
1005 def as_matrix(self) -> np.ndarray: 1006 """Returns the 4x4 projection matrix.""" 1007 return self.matrix
Returns the 4x4 projection matrix.
1009 def as_matrix_3x4(self) -> np.ndarray: 1010 """Returns the 3x4 projection matrix (top 3 rows).""" 1011 return self.matrix[:3, :]
Returns the 3x4 projection matrix (top 3 rows).
1013 def inverse(self) -> InverseProjection: 1014 """ 1015 Returns an InverseProjection representing P^-1. 1016 1017 Note: The inverse projection requires depth information to actually 1018 unproject 2D points to 3D. 1019 """ 1020 return InverseProjection(self.matrix, dtype=self.dtype)
Returns an InverseProjection representing P^-1.
Note: The inverse projection requires depth information to actually unproject 2D points to 3D.
1078 def project_points(self, points: np.ndarray | list | tuple) -> np.ndarray: 1079 """ 1080 Project 3D points (Nx3 or Nx4) to 2D pixel coordinates. 1081 alias for _apply(points). 1082 1083 Args: 1084 points: Nx3 or Nx4 array of points. 1085 1086 Returns: 1087 np.ndarray: Nx2 pixel coordinates. 1088 """ 1089 return self._apply(points)
Project 3D points (Nx3 or Nx4) to 2D pixel coordinates. alias for _apply(points).
Args: points: Nx3 or Nx4 array of points.
Returns: np.ndarray: Nx2 pixel coordinates.
1091 def to_dict(self) -> dict[str, Any]: 1092 """Serialize projection to a JSON-compatible dictionary.""" 1093 return { 1094 "type": "Projection", 1095 "matrix": self.matrix.tolist(), 1096 "dtype": np.dtype(self.dtype).name, 1097 }
Serialize projection to a JSON-compatible dictionary.
1109@register_transform 1110class InverseProjection(BaseTransform): 1111 """ 1112 Represents the conceptual inverse of a Projection (P^-1). 1113 1114 This class tracks that an inverse operation was requested, but actual 1115 unprojection requires depth information. Use unproject() with depth values 1116 to convert 2D pixels back to 3D points. 1117 1118 Useful for: 1119 - Tracking transform logic in a graph 1120 - Composing with other transforms 1121 - Unprojecting when depth is available 1122 """ 1123 1124 def __init__( 1125 self, 1126 original_matrix: np.ndarray | list, 1127 dtype: np.dtype = np.float64, 1128 ): 1129 """ 1130 Create an InverseProjection from the original projection matrix. 1131 1132 Args: 1133 original_matrix: The original 3x4 or 4x4 projection matrix. 1134 dtype: Data type for the matrix. 1135 """ 1136 super().__init__(dtype=dtype) 1137 self._original_matrix = _ensure_4x4_projection(np.asarray(original_matrix), self.dtype) 1138 1139 @property 1140 def original_matrix(self) -> np.ndarray: 1141 """The original projection matrix that was inverted.""" 1142 return self._original_matrix 1143 1144 def as_matrix(self) -> np.ndarray: 1145 """ 1146 Returns a pseudo-inverse matrix for composition purposes. 1147 1148 Warning: This is the Moore-Penrose pseudo-inverse and may not 1149 produce geometrically meaningful results for all operations. 1150 """ 1151 return np.linalg.pinv(self._original_matrix) 1152 1153 def inverse(self) -> Projection: 1154 """Returns the original Projection.""" 1155 return Projection(self._original_matrix, dtype=self.dtype) 1156 1157 def __mul__(self, other: BaseTransform) -> BaseTransform: 1158 """Compose with another transform using pseudo-inverse.""" 1159 if isinstance(other, Identity): 1160 return self 1161 if isinstance(other, Transform): 1162 raise TypeError( 1163 f"Composition '{type(self).__name__} * Transform' " 1164 "is invalid (dimensional mismatch). " 1165 "InverseProjections (2D->3D) cannot post-multiply Transforms (3D->3D). " 1166 "Did you mean 'Transform * InverseProjection'?" 1167 ) 1168 if isinstance(other, (InverseProjection, InverseCompositeProjection)): 1169 raise TypeError( 1170 f"Composition '{type(self).__name__} * " 1171 f"{type(other).__name__}' is invalid " 1172 "(dimensional mismatch)." 1173 ) 1174 1175 return MatrixTransform(self.as_matrix() @ other.as_matrix(), dtype=self.dtype) 1176 1177 def _apply(self, vector: np.ndarray | list | tuple) -> np.ndarray: 1178 """ 1179 Unproject 2D/3D vectors using pseudo-inverse. 1180 1181 Args: 1182 vector: Nx2 (pixels), Nx3 (homogenous pixels), or Nx4. 1183 1184 Returns: 1185 np.ndarray: Transformed vectors (Nx3 or Nx4). 1186 """ 1187 vector = np.atleast_2d(vector) 1188 cols = vector.shape[1] 1189 1190 input_vec = None 1191 if cols == 2: 1192 # Nx2 pixels -> Nx3 homogeneous [u, v, 1] 1193 input_vec = np.hstack([vector, np.ones((vector.shape[0], 1), dtype=self.dtype)]) 1194 elif cols == 3: 1195 input_vec = vector 1196 elif cols == 4: 1197 input_vec = vector 1198 else: 1199 raise ValueError(f"Input must be Nx2, Nx3 or Nx4, got {vector.shape}") 1200 1201 # If input is 3D, and P_inv is 4x4, we need 4D input? 1202 # P_inv is 4x4 (from BaseTransform.as_matrix which pinv's 4x4 P). 1203 # We need to pad to 4D if it's 3D. 1204 if input_vec.shape[1] == 3: 1205 # Pad with 0? or 1? 1206 # Let's assume w=1 for "point-like" unprojection (ray). 1207 input_vec = np.hstack([input_vec, np.ones((input_vec.shape[0], 1), dtype=self.dtype)]) 1208 1209 result = (self.as_matrix() @ input_vec.T).T 1210 1211 # User wants "homogenize when needed and dehomogenize". 1212 if cols < 4: 1213 return result[:, :3] 1214 return result 1215 1216 def unproject(self, pixels: np.ndarray, depths: np.ndarray) -> np.ndarray: 1217 """ 1218 Unproject 2D pixels to 3D points using depth values. 1219 1220 Args: 1221 pixels: Nx2 array of 2D pixel coordinates. 1222 depths: N array of depth values (Z coordinate in camera frame). 1223 1224 Returns: 1225 np.ndarray: Nx3 array of 3D points. 1226 1227 Note: This assumes a standard pinhole camera model where the 1228 projection matrix can be decomposed into K[R|t] form. 1229 """ 1230 pixels = np.atleast_2d(pixels) 1231 depths = np.atleast_1d(depths).flatten() 1232 1233 if pixels.shape[1] != 2: 1234 raise ValueError(f"Pixels must be Nx2, got {pixels.shape}") 1235 if len(depths) != len(pixels): 1236 raise ValueError(f"Depths length {len(depths)} must match pixels length {len(pixels)}") 1237 1238 # Extract K matrix (intrinsics) from projection matrix P = K[R|t] 1239 # For simple unprojection, we assume P[:3,:3] contains K*R 1240 # and use the pseudo-inverse approach 1241 projection_3x3 = self._original_matrix[:3, :3] 1242 projection_t = self._original_matrix[:3, 3] 1243 1244 # Homogeneous pixel coordinates scaled by depth 1245 hom_pixels = np.column_stack([pixels[:, 0] * depths, pixels[:, 1] * depths, depths]) 1246 1247 # Solve for 3D points: P[:3,:3] * X = hom_pixels - P[:3,3] 1248 # Using solve instead of inv for numerical stability and performance. 1249 points_3d = np.linalg.solve(projection_3x3, (hom_pixels - projection_t).T).T 1250 1251 return points_3d 1252 1253 def to_dict(self) -> dict[str, Any]: 1254 """Serialize inverse projection to a JSON-compatible dictionary.""" 1255 return { 1256 "type": "InverseProjection", 1257 "original_matrix": self._original_matrix.tolist(), 1258 "dtype": np.dtype(self.dtype).name, 1259 } 1260 1261 @classmethod 1262 def from_dict(cls, data: dict[str, Any]) -> InverseProjection: 1263 """Deserialize inverse projection from a dictionary.""" 1264 dtype = np.dtype(data.get("dtype", "float64")) 1265 return cls(original_matrix=np.array(data["original_matrix"]), dtype=dtype) 1266 1267 def __repr__(self) -> str: 1268 return f"InverseProjection(original_matrix_shape={self._original_matrix.shape})"
Represents the conceptual inverse of a Projection (P^-1).
This class tracks that an inverse operation was requested, but actual unprojection requires depth information. Use unproject() with depth values to convert 2D pixels back to 3D points.
Useful for:
- Tracking transform logic in a graph
- Composing with other transforms
- Unprojecting when depth is available
1124 def __init__( 1125 self, 1126 original_matrix: np.ndarray | list, 1127 dtype: np.dtype = np.float64, 1128 ): 1129 """ 1130 Create an InverseProjection from the original projection matrix. 1131 1132 Args: 1133 original_matrix: The original 3x4 or 4x4 projection matrix. 1134 dtype: Data type for the matrix. 1135 """ 1136 super().__init__(dtype=dtype) 1137 self._original_matrix = _ensure_4x4_projection(np.asarray(original_matrix), self.dtype)
Create an InverseProjection from the original projection matrix.
Args: original_matrix: The original 3x4 or 4x4 projection matrix. dtype: Data type for the matrix.
1139 @property 1140 def original_matrix(self) -> np.ndarray: 1141 """The original projection matrix that was inverted.""" 1142 return self._original_matrix
The original projection matrix that was inverted.
1144 def as_matrix(self) -> np.ndarray: 1145 """ 1146 Returns a pseudo-inverse matrix for composition purposes. 1147 1148 Warning: This is the Moore-Penrose pseudo-inverse and may not 1149 produce geometrically meaningful results for all operations. 1150 """ 1151 return np.linalg.pinv(self._original_matrix)
Returns a pseudo-inverse matrix for composition purposes.
Warning: This is the Moore-Penrose pseudo-inverse and may not produce geometrically meaningful results for all operations.
1153 def inverse(self) -> Projection: 1154 """Returns the original Projection.""" 1155 return Projection(self._original_matrix, dtype=self.dtype)
Returns the original Projection.
1216 def unproject(self, pixels: np.ndarray, depths: np.ndarray) -> np.ndarray: 1217 """ 1218 Unproject 2D pixels to 3D points using depth values. 1219 1220 Args: 1221 pixels: Nx2 array of 2D pixel coordinates. 1222 depths: N array of depth values (Z coordinate in camera frame). 1223 1224 Returns: 1225 np.ndarray: Nx3 array of 3D points. 1226 1227 Note: This assumes a standard pinhole camera model where the 1228 projection matrix can be decomposed into K[R|t] form. 1229 """ 1230 pixels = np.atleast_2d(pixels) 1231 depths = np.atleast_1d(depths).flatten() 1232 1233 if pixels.shape[1] != 2: 1234 raise ValueError(f"Pixels must be Nx2, got {pixels.shape}") 1235 if len(depths) != len(pixels): 1236 raise ValueError(f"Depths length {len(depths)} must match pixels length {len(pixels)}") 1237 1238 # Extract K matrix (intrinsics) from projection matrix P = K[R|t] 1239 # For simple unprojection, we assume P[:3,:3] contains K*R 1240 # and use the pseudo-inverse approach 1241 projection_3x3 = self._original_matrix[:3, :3] 1242 projection_t = self._original_matrix[:3, 3] 1243 1244 # Homogeneous pixel coordinates scaled by depth 1245 hom_pixels = np.column_stack([pixels[:, 0] * depths, pixels[:, 1] * depths, depths]) 1246 1247 # Solve for 3D points: P[:3,:3] * X = hom_pixels - P[:3,3] 1248 # Using solve instead of inv for numerical stability and performance. 1249 points_3d = np.linalg.solve(projection_3x3, (hom_pixels - projection_t).T).T 1250 1251 return points_3d
Unproject 2D pixels to 3D points using depth values.
Args: pixels: Nx2 array of 2D pixel coordinates. depths: N array of depth values (Z coordinate in camera frame).
Returns: np.ndarray: Nx3 array of 3D points.
Note: This assumes a standard pinhole camera model where the projection matrix can be decomposed into K[R|t] form.
1253 def to_dict(self) -> dict[str, Any]: 1254 """Serialize inverse projection to a JSON-compatible dictionary.""" 1255 return { 1256 "type": "InverseProjection", 1257 "original_matrix": self._original_matrix.tolist(), 1258 "dtype": np.dtype(self.dtype).name, 1259 }
Serialize inverse projection to a JSON-compatible dictionary.
1261 @classmethod 1262 def from_dict(cls, data: dict[str, Any]) -> InverseProjection: 1263 """Deserialize inverse projection from a dictionary.""" 1264 dtype = np.dtype(data.get("dtype", "float64")) 1265 return cls(original_matrix=np.array(data["original_matrix"]), dtype=dtype)
Deserialize inverse projection from a dictionary.
1271@register_transform 1272class CameraProjection(Projection): 1273 """ 1274 A camera projection with strict Intrinsic-only parameters. 1275 1276 Adheres to the architectural guideline that CameraProjection represents 1277 the internal geometry of the optical sensor (K, D) ONLY. 1278 1279 Spatial pose (Extrinsics) must be managed via separate Transform objects. 1280 1281 Structure: 1282 - K: 3x3 intrinsic matrix (focal length, principal point) 1283 - D: Distortion coefficients (OpenCV convention) 1284 1285 Can be constructed from: 1286 - Explicit K parameters (with optional distortion) 1287 - Flexible aliases: K/intrinsic_matrix, D/dist_coeffs 1288 """ 1289 1290 def __init__( 1291 self, 1292 intrinsic_matrix: np.ndarray | list | None = None, 1293 dist_coeffs: list | np.ndarray | None = None, 1294 projection_model: ProjectionModel | str | None = None, 1295 image_size: tuple[int, int] | None = None, 1296 dtype: np.dtype = np.float64, 1297 # Flexible aliases (OpenCV-style) 1298 K: np.ndarray | list | None = None, 1299 D: list | np.ndarray | None = None, 1300 ): 1301 """ 1302 Create a CameraProjection (Intrinsic-only). 1303 1304 Args: 1305 intrinsic_matrix: 3x3 camera intrinsic matrix K. 1306 dist_coeffs: Distortion coefficients (OpenCV ordering: k1,k2,p1,p2,k3,...). 1307 projection_model: Camera model (PINHOLE, BROWN_CONRADY, KANNALA_BRANDT, etc.). 1308 image_size: Image dimensions (width, height) in pixels. 1309 dtype: Data type for matrices. 1310 K: Alias for intrinsic_matrix. 1311 D: Alias for dist_coeffs. 1312 """ 1313 self._dtype = dtype 1314 1315 # Handle aliases 1316 if K is not None: 1317 intrinsic_matrix = K 1318 if D is not None: 1319 dist_coeffs = D 1320 1321 # Store image size 1322 self._image_size = image_size 1323 1324 # Handle distortion coefficients 1325 if dist_coeffs is None: 1326 self._dist_coeffs = np.array([], dtype=dtype) 1327 else: 1328 self._dist_coeffs = np.asarray(dist_coeffs, dtype=dtype).flatten() 1329 1330 # Handle projection model 1331 if projection_model is None: 1332 if len(self._dist_coeffs) > 0: 1333 self._projection_model = ProjectionModel.BrownConrady 1334 else: 1335 self._projection_model = ProjectionModel.Pinhole 1336 elif isinstance(projection_model, str): 1337 self._projection_model = ProjectionModel.from_string(projection_model) 1338 else: 1339 self._projection_model = projection_model 1340 1341 if intrinsic_matrix is None: 1342 raise ValueError("Must provide 'intrinsic_matrix' or alias 'K'") 1343 1344 self._intrinsic_matrix = np.asarray(intrinsic_matrix, dtype=dtype) 1345 if self._intrinsic_matrix.shape != (3, 3): 1346 raise ValueError(f"Intrinsic matrix must be 3x3, got {self._intrinsic_matrix.shape}") 1347 1348 # The internal matrix is just K with a [0,0,0,1] row to make it 4x4 1349 # (Scale/Intrinsic transform) 1350 # We assume points are in Camera Frame already. 1351 matrix_4x4 = np.eye(4, dtype=dtype) 1352 matrix_4x4[:3, :3] = self._intrinsic_matrix 1353 1354 super().__init__(matrix=matrix_4x4, dtype=dtype) 1355 1356 @classmethod 1357 def from_intrinsics_and_transform(cls, *args, **kwargs): 1358 """Disabled — CameraProjection is intrinsic-only. Use separate Transform objects.""" 1359 raise NotImplementedError( 1360 "CameraProjection is now Intrinsic-only. Use separate Transform objects for Extrinsics." 1361 ) 1362 1363 @property 1364 def intrinsic_matrix(self) -> np.ndarray: 1365 """The 3x3 camera intrinsic matrix K.""" 1366 return self._intrinsic_matrix 1367 1368 @property 1369 def fx(self) -> float: 1370 """Focal length x.""" 1371 return float(self._intrinsic_matrix[0, 0]) 1372 1373 @property 1374 def fy(self) -> float: 1375 """Focal length y.""" 1376 return float(self._intrinsic_matrix[1, 1]) 1377 1378 @property 1379 def cx(self) -> float: 1380 """Principal point x.""" 1381 return float(self._intrinsic_matrix[0, 2]) 1382 1383 @property 1384 def cy(self) -> float: 1385 """Principal point y.""" 1386 return float(self._intrinsic_matrix[1, 2]) 1387 1388 @property 1389 def focal_length(self) -> tuple[float, float]: 1390 """Focal lengths (fx, fy) from the intrinsic matrix.""" 1391 return (self.fx, self.fy) 1392 1393 @property 1394 def principal_point(self) -> tuple[float, float]: 1395 """Principal point (cx, cy) from the intrinsic matrix.""" 1396 return (self.cx, self.cy) 1397 1398 @property 1399 def dist_coeffs(self) -> np.ndarray: 1400 """Distortion coefficients (OpenCV ordering: k1, k2, p1, p2, k3, ...).""" 1401 return self._dist_coeffs 1402 1403 @property 1404 def distortion_coefficients(self) -> np.ndarray: 1405 """Alias for dist_coeffs.""" 1406 return self._dist_coeffs 1407 1408 @property 1409 def projection_model(self) -> ProjectionModel: 1410 """The camera projection model (PINHOLE, BROWN_CONRADY, KANNALA_BRANDT, etc.).""" 1411 return self._projection_model 1412 1413 @property 1414 def image_size(self) -> tuple[int, int] | None: 1415 """Image dimensions (width, height) in pixels, if specified.""" 1416 return self._image_size 1417 1418 # OpenCV-style shortcuts 1419 @property 1420 def K(self) -> np.ndarray: 1421 """Alias for intrinsic_matrix (OpenCV-style).""" 1422 return self._intrinsic_matrix 1423 1424 @property 1425 def D(self) -> np.ndarray: 1426 """Alias for dist_coeffs (OpenCV-style).""" 1427 return self._dist_coeffs 1428 1429 def _apply(self, vector: np.ndarray | list | tuple) -> np.ndarray: 1430 """ 1431 Project 3D points to 2D pixel coordinates using the full projection model. 1432 1433 Dispatches to the correct projection function based on ``projection_model``. 1434 Each model implements the complete 3D → 2D pipeline including distortion. 1435 1436 Args: 1437 vector: Nx3 (or Nx4 homogeneous) points in camera frame. 1438 1439 Returns: 1440 np.ndarray: Nx2 pixel coordinates. 1441 """ 1442 pts = np.atleast_2d(vector) 1443 if pts.shape[1] == 4: 1444 pts = pts[:, :3] / pts[:, 3:4] 1445 1446 model = self._projection_model 1447 if model == ProjectionModel.Pinhole: 1448 return self._project_pinhole(pts) 1449 elif model == ProjectionModel.BrownConrady: 1450 return self._project_brown_conrady(pts) 1451 elif model == ProjectionModel.KannalaBrandt: 1452 return self._project_kannala_brandt(pts) 1453 elif model == ProjectionModel.Rational: 1454 return self._project_rational(pts) 1455 elif model == ProjectionModel.Division: 1456 return self._project_division(pts) 1457 elif model == ProjectionModel.MeiUnified: 1458 return self._project_mei_unified(pts) 1459 elif model == ProjectionModel.Fisheye62: 1460 return self._project_fisheye62(pts) 1461 else: 1462 raise NotImplementedError(f"Projection not implemented for {model}") 1463 1464 # ------------------------------------------------------------------ 1465 # Per-model projection implementations 1466 # 1467 # All methods use named numerical constants instead of magic numbers. 1468 # Polynomial evaluation uses Horner form: p(θ²) = ((k4·θ² + k3)·θ² + k2)·θ² + k1 1469 # ------------------------------------------------------------------ 1470 1471 def _get_padded_dist_coeffs(self, count: int) -> np.ndarray: 1472 """Return distortion coefficients padded with zeros to the requested length. 1473 1474 Args: 1475 count: Number of coefficients to return. 1476 1477 Returns: 1478 Array of length ``count`` with available coefficients filled in and 1479 the remainder set to zero. 1480 """ 1481 coeffs = np.zeros(count, dtype=self.dtype) 1482 num_present = min(len(self._dist_coeffs), count) 1483 if num_present > 0: 1484 coeffs[:num_present] = self._dist_coeffs[:num_present] 1485 return coeffs 1486 1487 def _project_pinhole(self, pts: np.ndarray) -> np.ndarray: 1488 """Ideal pinhole: normalize by z, apply K. No distortion.""" 1489 z = np.where(np.abs(pts[:, 2]) < _DEPTH_EPS, _DEPTH_EPS, pts[:, 2]) 1490 x = pts[:, 0] / z 1491 y = pts[:, 1] / z 1492 u = self.fx * x + self.cx 1493 v = self.fy * y + self.cy 1494 return np.column_stack([u, v]) 1495 1496 def _project_brown_conrady(self, pts: np.ndarray) -> np.ndarray: 1497 """Pinhole + Brown-Conrady radial/tangential distortion (OpenCV default). 1498 1499 D = (k1, k2, p1, p2 [, k3]) 1500 """ 1501 z = np.where(np.abs(pts[:, 2]) < _DEPTH_EPS, _DEPTH_EPS, pts[:, 2]) 1502 x = pts[:, 0] / z 1503 y = pts[:, 1] / z 1504 1505 d = self._dist_coeffs 1506 if len(d) >= 4: 1507 r2 = x * x + y * y 1508 k1, k2, p1, p2 = d[0], d[1], d[2], d[3] 1509 k3 = d[4] if len(d) > 4 else 0.0 1510 1511 # Horner form: 1 + r²·(k1 + r²·(k2 + r²·k3)) 1512 radial = 1.0 + r2 * (k1 + r2 * (k2 + r2 * k3)) 1513 x_tan = 2 * p1 * x * y + p2 * (r2 + 2 * x * x) 1514 y_tan = p1 * (r2 + 2 * y * y) + 2 * p2 * x * y 1515 x = x * radial + x_tan 1516 y = y * radial + y_tan 1517 1518 u = self.fx * x + self.cx 1519 v = self.fy * y + self.cy 1520 return np.column_stack([u, v]) 1521 1522 def _project_kannala_brandt(self, pts: np.ndarray) -> np.ndarray: 1523 """Kannala-Brandt equidistant fisheye model (OpenCV cv2.fisheye). 1524 1525 D = (k1, k2, k3, k4) 1526 1527 Projects via θ_d = θ·(1 + k1·θ² + k2·θ⁴ + k3·θ⁶ + k4·θ⁸) 1528 where θ = atan2(r, z), and the distorted point is scaled by θ_d/r. 1529 """ 1530 x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] 1531 r = np.sqrt(x * x + y * y) 1532 theta = np.arctan2(r, z) 1533 1534 k1, k2, k3, k4 = self._get_padded_dist_coeffs(4) 1535 1536 theta2 = theta * theta 1537 # Horner form: θ·(1 + θ²·(k1 + θ²·(k2 + θ²·(k3 + θ²·k4)))) 1538 theta_d = theta * (1 + theta2 * (k1 + theta2 * (k2 + theta2 * (k3 + theta2 * k4)))) 1539 1540 # Scale factor: θ_d / r (safe division for on-axis points where r → 0) 1541 safe_r = np.where(r < _RADIAL_EPS, 1.0, r) 1542 scale = np.where(r < _RADIAL_EPS, 1.0, theta_d / safe_r) 1543 x_d = x * scale 1544 y_d = y * scale 1545 1546 u = self.fx * x_d + self.cx 1547 v = self.fy * y_d + self.cy 1548 return np.column_stack([u, v]) 1549 1550 def _project_rational(self, pts: np.ndarray) -> np.ndarray: 1551 """Rational polynomial model (OpenCV CALIB_RATIONAL_MODEL). 1552 1553 D = (k1, k2, p1, p2, k3, k4, k5, k6) 1554 1555 Radial: (1 + k1·r² + k2·r⁴ + k3·r⁶) / (1 + k4·r² + k5·r⁴ + k6·r⁶) 1556 Plus tangential distortion (p1, p2). 1557 """ 1558 z = np.where(np.abs(pts[:, 2]) < _DEPTH_EPS, _DEPTH_EPS, pts[:, 2]) 1559 x = pts[:, 0] / z 1560 y = pts[:, 1] / z 1561 1562 k1, k2, p1, p2, k3, k4, k5, k6 = self._get_padded_dist_coeffs(8) 1563 1564 r2 = x * x + y * y 1565 1566 # Horner form for numerator and denominator 1567 numerator = 1.0 + r2 * (k1 + r2 * (k2 + r2 * k3)) 1568 denominator = 1.0 + r2 * (k4 + r2 * (k5 + r2 * k6)) 1569 safe_denom = np.where(np.abs(denominator) < _DENOM_EPS, _DENOM_EPS, denominator) 1570 radial = numerator / safe_denom 1571 1572 x_tan = 2 * p1 * x * y + p2 * (r2 + 2 * x * x) 1573 y_tan = p1 * (r2 + 2 * y * y) + 2 * p2 * x * y 1574 x = x * radial + x_tan 1575 y = y * radial + y_tan 1576 1577 u = self.fx * x + self.cx 1578 v = self.fy * y + self.cy 1579 return np.column_stack([u, v]) 1580 1581 def _project_division(self, pts: np.ndarray) -> np.ndarray: 1582 """Division undistortion model. 1583 1584 D = (k1,) 1585 1586 Distorted: x_d = x / (1 + k1·r²). Simple single-parameter wide-angle model. 1587 """ 1588 z = np.where(np.abs(pts[:, 2]) < _DEPTH_EPS, _DEPTH_EPS, pts[:, 2]) 1589 x = pts[:, 0] / z 1590 y = pts[:, 1] / z 1591 1592 (k1,) = self._get_padded_dist_coeffs(1) 1593 1594 r2 = x * x + y * y 1595 denom = 1.0 + k1 * r2 1596 safe_denom = np.where(np.abs(denom) < _DENOM_EPS, _DENOM_EPS, denom) 1597 x = x / safe_denom 1598 y = y / safe_denom 1599 1600 u = self.fx * x + self.cx 1601 v = self.fy * y + self.cy 1602 return np.column_stack([u, v]) 1603 1604 def _project_mei_unified(self, pts: np.ndarray) -> np.ndarray: 1605 """Mei Unified omnidirectional camera model. 1606 1607 D = (xi [, k1, k2]) 1608 1609 Projects onto unit sphere, then divides by (z + xi) to model the 1610 mirror/sphere, applies radial distortion, then scales by focal 1611 length and principal point. 1612 """ 1613 norm = np.sqrt(pts[:, 0] ** 2 + pts[:, 1] ** 2 + pts[:, 2] ** 2) 1614 safe_norm = np.where(norm < _NORM_EPS, _NORM_EPS, norm) 1615 x = pts[:, 0] / safe_norm 1616 y = pts[:, 1] / safe_norm 1617 z = pts[:, 2] / safe_norm 1618 1619 xi, k1, k2 = self._get_padded_dist_coeffs(3) 1620 1621 denom = z + xi 1622 safe_denom = np.where(np.abs(denom) < _DENOM_EPS, _DENOM_EPS, denom) 1623 x = x / safe_denom 1624 y = y / safe_denom 1625 1626 # Radial distortion (unconditional — branch-free) 1627 r2 = x * x + y * y 1628 radial = 1.0 + r2 * (k1 + r2 * k2) # Horner form 1629 x = x * radial 1630 y = y * radial 1631 1632 u = self.fx * x + self.cx 1633 v = self.fy * y + self.cy 1634 return np.column_stack([u, v]) 1635 1636 def _project_fisheye62(self, pts: np.ndarray) -> np.ndarray: 1637 """Project Aria Fisheye62 model. 1638 1639 D = (k0, k1, k2, k3, p0, p1) 1640 1641 Equidistant-style with 4 radial + 2 tangential coefficients. 1642 """ 1643 x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] 1644 r = np.sqrt(x * x + y * y) 1645 theta = np.arctan2(r, z) 1646 1647 k0, k1, k2, k3, p0, p1 = self._get_padded_dist_coeffs(6) 1648 1649 theta2 = theta * theta 1650 # Horner form: θ·(1 + θ²·(k0 + θ²·(k1 + θ²·(k2 + θ²·k3)))) 1651 theta_d = theta * (1 + theta2 * (k0 + theta2 * (k1 + theta2 * (k2 + theta2 * k3)))) 1652 1653 safe_r = np.where(r < _RADIAL_EPS, 1.0, r) 1654 scale = np.where(r < _RADIAL_EPS, 1.0, theta_d / safe_r) 1655 x_d = x * scale 1656 y_d = y * scale 1657 1658 # Tangential 1659 r2_d = x_d * x_d + y_d * y_d 1660 x_d = x_d + 2 * p0 * x_d * y_d + p1 * (r2_d + 2 * x_d * x_d) 1661 y_d = y_d + p0 * (r2_d + 2 * y_d * y_d) + 2 * p1 * x_d * y_d 1662 1663 u = self.fx * x_d + self.cx 1664 v = self.fy * y_d + self.cy 1665 return np.column_stack([u, v]) 1666 1667 def to_dict(self) -> dict[str, Any]: 1668 """Serialize camera projection to a JSON-compatible dictionary.""" 1669 result = { 1670 "type": "CameraProjection", 1671 "intrinsic_matrix": self._intrinsic_matrix.tolist(), 1672 "dist_coeffs": self._dist_coeffs.tolist(), 1673 "projection_model": self._projection_model.value, 1674 "dtype": np.dtype(self.dtype).name, 1675 } 1676 if self._image_size is not None: 1677 result["image_size"] = list(self._image_size) 1678 return result 1679 1680 @classmethod 1681 def from_dict(cls, data: dict[str, Any]) -> CameraProjection: 1682 """Deserialize camera projection from a dictionary.""" 1683 dtype = np.dtype(data.get("dtype", "float64")) 1684 dist_coeffs = data.get("dist_coeffs", []) 1685 projection_model_str = data.get("projection_model", "Pinhole") 1686 image_size = data.get("image_size", None) 1687 if image_size is not None: 1688 image_size = tuple(image_size) 1689 1690 return cls( 1691 intrinsic_matrix=np.array(data["intrinsic_matrix"]), 1692 dist_coeffs=dist_coeffs, 1693 projection_model=projection_model_str, 1694 image_size=image_size, 1695 dtype=dtype, 1696 ) 1697 1698 def __repr__(self) -> str: 1699 fx, fy = self.focal_length 1700 cx, cy = self.principal_point 1701 return f"CameraProjection(fx={fx:.1f}, fy={fy:.1f}, cx={cx:.1f}, cy={cy:.1f})" 1702 1703 def inverse(self) -> InverseCameraProjection: 1704 """ 1705 Returns an InverseCameraProjection for unprojection. 1706 1707 Preserves the camera parameters (intrinsics) in the inverse object. 1708 """ 1709 return InverseCameraProjection(self)
A camera projection with strict Intrinsic-only parameters.
Adheres to the architectural guideline that CameraProjection represents the internal geometry of the optical sensor (K, D) ONLY.
Spatial pose (Extrinsics) must be managed via separate Transform objects.
Structure: - K: 3x3 intrinsic matrix (focal length, principal point) - D: Distortion coefficients (OpenCV convention)
Can be constructed from: - Explicit K parameters (with optional distortion) - Flexible aliases: K/intrinsic_matrix, D/dist_coeffs
1290 def __init__( 1291 self, 1292 intrinsic_matrix: np.ndarray | list | None = None, 1293 dist_coeffs: list | np.ndarray | None = None, 1294 projection_model: ProjectionModel | str | None = None, 1295 image_size: tuple[int, int] | None = None, 1296 dtype: np.dtype = np.float64, 1297 # Flexible aliases (OpenCV-style) 1298 K: np.ndarray | list | None = None, 1299 D: list | np.ndarray | None = None, 1300 ): 1301 """ 1302 Create a CameraProjection (Intrinsic-only). 1303 1304 Args: 1305 intrinsic_matrix: 3x3 camera intrinsic matrix K. 1306 dist_coeffs: Distortion coefficients (OpenCV ordering: k1,k2,p1,p2,k3,...). 1307 projection_model: Camera model (PINHOLE, BROWN_CONRADY, KANNALA_BRANDT, etc.). 1308 image_size: Image dimensions (width, height) in pixels. 1309 dtype: Data type for matrices. 1310 K: Alias for intrinsic_matrix. 1311 D: Alias for dist_coeffs. 1312 """ 1313 self._dtype = dtype 1314 1315 # Handle aliases 1316 if K is not None: 1317 intrinsic_matrix = K 1318 if D is not None: 1319 dist_coeffs = D 1320 1321 # Store image size 1322 self._image_size = image_size 1323 1324 # Handle distortion coefficients 1325 if dist_coeffs is None: 1326 self._dist_coeffs = np.array([], dtype=dtype) 1327 else: 1328 self._dist_coeffs = np.asarray(dist_coeffs, dtype=dtype).flatten() 1329 1330 # Handle projection model 1331 if projection_model is None: 1332 if len(self._dist_coeffs) > 0: 1333 self._projection_model = ProjectionModel.BrownConrady 1334 else: 1335 self._projection_model = ProjectionModel.Pinhole 1336 elif isinstance(projection_model, str): 1337 self._projection_model = ProjectionModel.from_string(projection_model) 1338 else: 1339 self._projection_model = projection_model 1340 1341 if intrinsic_matrix is None: 1342 raise ValueError("Must provide 'intrinsic_matrix' or alias 'K'") 1343 1344 self._intrinsic_matrix = np.asarray(intrinsic_matrix, dtype=dtype) 1345 if self._intrinsic_matrix.shape != (3, 3): 1346 raise ValueError(f"Intrinsic matrix must be 3x3, got {self._intrinsic_matrix.shape}") 1347 1348 # The internal matrix is just K with a [0,0,0,1] row to make it 4x4 1349 # (Scale/Intrinsic transform) 1350 # We assume points are in Camera Frame already. 1351 matrix_4x4 = np.eye(4, dtype=dtype) 1352 matrix_4x4[:3, :3] = self._intrinsic_matrix 1353 1354 super().__init__(matrix=matrix_4x4, dtype=dtype)
Create a CameraProjection (Intrinsic-only).
Args: intrinsic_matrix: 3x3 camera intrinsic matrix K. dist_coeffs: Distortion coefficients (OpenCV ordering: k1,k2,p1,p2,k3,...). projection_model: Camera model (PINHOLE, BROWN_CONRADY, KANNALA_BRANDT, etc.). image_size: Image dimensions (width, height) in pixels. dtype: Data type for matrices. K: Alias for intrinsic_matrix. D: Alias for dist_coeffs.
1356 @classmethod 1357 def from_intrinsics_and_transform(cls, *args, **kwargs): 1358 """Disabled — CameraProjection is intrinsic-only. Use separate Transform objects.""" 1359 raise NotImplementedError( 1360 "CameraProjection is now Intrinsic-only. Use separate Transform objects for Extrinsics." 1361 )
Disabled — CameraProjection is intrinsic-only. Use separate Transform objects.
1363 @property 1364 def intrinsic_matrix(self) -> np.ndarray: 1365 """The 3x3 camera intrinsic matrix K.""" 1366 return self._intrinsic_matrix
The 3x3 camera intrinsic matrix K.
1368 @property 1369 def fx(self) -> float: 1370 """Focal length x.""" 1371 return float(self._intrinsic_matrix[0, 0])
Focal length x.
1373 @property 1374 def fy(self) -> float: 1375 """Focal length y.""" 1376 return float(self._intrinsic_matrix[1, 1])
Focal length y.
1378 @property 1379 def cx(self) -> float: 1380 """Principal point x.""" 1381 return float(self._intrinsic_matrix[0, 2])
Principal point x.
1383 @property 1384 def cy(self) -> float: 1385 """Principal point y.""" 1386 return float(self._intrinsic_matrix[1, 2])
Principal point y.
1388 @property 1389 def focal_length(self) -> tuple[float, float]: 1390 """Focal lengths (fx, fy) from the intrinsic matrix.""" 1391 return (self.fx, self.fy)
Focal lengths (fx, fy) from the intrinsic matrix.
1393 @property 1394 def principal_point(self) -> tuple[float, float]: 1395 """Principal point (cx, cy) from the intrinsic matrix.""" 1396 return (self.cx, self.cy)
Principal point (cx, cy) from the intrinsic matrix.
1398 @property 1399 def dist_coeffs(self) -> np.ndarray: 1400 """Distortion coefficients (OpenCV ordering: k1, k2, p1, p2, k3, ...).""" 1401 return self._dist_coeffs
Distortion coefficients (OpenCV ordering: k1, k2, p1, p2, k3, ...).
1403 @property 1404 def distortion_coefficients(self) -> np.ndarray: 1405 """Alias for dist_coeffs.""" 1406 return self._dist_coeffs
Alias for dist_coeffs.
1408 @property 1409 def projection_model(self) -> ProjectionModel: 1410 """The camera projection model (PINHOLE, BROWN_CONRADY, KANNALA_BRANDT, etc.).""" 1411 return self._projection_model
The camera projection model (PINHOLE, BROWN_CONRADY, KANNALA_BRANDT, etc.).
1413 @property 1414 def image_size(self) -> tuple[int, int] | None: 1415 """Image dimensions (width, height) in pixels, if specified.""" 1416 return self._image_size
Image dimensions (width, height) in pixels, if specified.
1419 @property 1420 def K(self) -> np.ndarray: 1421 """Alias for intrinsic_matrix (OpenCV-style).""" 1422 return self._intrinsic_matrix
Alias for intrinsic_matrix (OpenCV-style).
1424 @property 1425 def D(self) -> np.ndarray: 1426 """Alias for dist_coeffs (OpenCV-style).""" 1427 return self._dist_coeffs
Alias for dist_coeffs (OpenCV-style).
1667 def to_dict(self) -> dict[str, Any]: 1668 """Serialize camera projection to a JSON-compatible dictionary.""" 1669 result = { 1670 "type": "CameraProjection", 1671 "intrinsic_matrix": self._intrinsic_matrix.tolist(), 1672 "dist_coeffs": self._dist_coeffs.tolist(), 1673 "projection_model": self._projection_model.value, 1674 "dtype": np.dtype(self.dtype).name, 1675 } 1676 if self._image_size is not None: 1677 result["image_size"] = list(self._image_size) 1678 return result
Serialize camera projection to a JSON-compatible dictionary.
1680 @classmethod 1681 def from_dict(cls, data: dict[str, Any]) -> CameraProjection: 1682 """Deserialize camera projection from a dictionary.""" 1683 dtype = np.dtype(data.get("dtype", "float64")) 1684 dist_coeffs = data.get("dist_coeffs", []) 1685 projection_model_str = data.get("projection_model", "Pinhole") 1686 image_size = data.get("image_size", None) 1687 if image_size is not None: 1688 image_size = tuple(image_size) 1689 1690 return cls( 1691 intrinsic_matrix=np.array(data["intrinsic_matrix"]), 1692 dist_coeffs=dist_coeffs, 1693 projection_model=projection_model_str, 1694 image_size=image_size, 1695 dtype=dtype, 1696 )
Deserialize camera projection from a dictionary.
1703 def inverse(self) -> InverseCameraProjection: 1704 """ 1705 Returns an InverseCameraProjection for unprojection. 1706 1707 Preserves the camera parameters (intrinsics) in the inverse object. 1708 """ 1709 return InverseCameraProjection(self)
Returns an InverseCameraProjection for unprojection.
Preserves the camera parameters (intrinsics) in the inverse object.
1712@register_transform 1713class InverseCameraProjection(InverseProjection): 1714 """ 1715 The inverse of a CameraProjection. 1716 1717 Preserves the internal CameraProjection instance to maintain access to 1718 intrinsics (K) and distortion coefficients. 1719 """ 1720 1721 def __init__(self, camera_projection: CameraProjection): 1722 """ 1723 Create an InverseCameraProjection. 1724 1725 Args: 1726 camera_projection: The original CameraProjection instance. 1727 """ 1728 self._camera_projection = camera_projection 1729 # Initialize parent with the matrix (will be pseudo-inverted by parent's as_matrix) 1730 super().__init__(original_matrix=camera_projection.matrix, dtype=camera_projection.dtype) 1731 1732 @property 1733 def camera_projection(self) -> CameraProjection: 1734 """The original CameraProjection instance.""" 1735 return self._camera_projection 1736 1737 # Shortcuts exposing the camera parameters 1738 @property 1739 def fx(self) -> float: 1740 """Focal length in x from the original CameraProjection.""" 1741 return self._camera_projection.fx 1742 1743 @property 1744 def fy(self) -> float: 1745 """Focal length in y from the original CameraProjection.""" 1746 return self._camera_projection.fy 1747 1748 @property 1749 def cx(self) -> float: 1750 """Principal point x from the original CameraProjection.""" 1751 return self._camera_projection.cx 1752 1753 @property 1754 def cy(self) -> float: 1755 """Principal point y from the original CameraProjection.""" 1756 return self._camera_projection.cy 1757 1758 @property 1759 def intrinsic_matrix(self) -> np.ndarray: 1760 """The 3x3 intrinsic matrix K from the original CameraProjection.""" 1761 return self._camera_projection.intrinsic_matrix 1762 1763 def inverse(self) -> CameraProjection: 1764 """Returns the original CameraProjection.""" 1765 return self._camera_projection 1766 1767 def to_dict(self) -> dict[str, Any]: 1768 """Serialize to dictionary using the contained CameraProjection.""" 1769 return { 1770 "type": "InverseCameraProjection", 1771 "camera_projection": self._camera_projection.to_dict(), 1772 } 1773 1774 @classmethod 1775 def from_dict(cls, data: dict[str, Any]) -> InverseCameraProjection: 1776 """Deserialize from dictionary.""" 1777 cam_data = data["camera_projection"] 1778 if cam_data.get("type") != "CameraProjection": 1779 cam_data["type"] = "CameraProjection" 1780 1781 camera_proj = CameraProjection.from_dict(cam_data) 1782 return cls(camera_proj) 1783 1784 def __repr__(self) -> str: 1785 return f"InverseCameraProjection({self._camera_projection})"
The inverse of a CameraProjection.
Preserves the internal CameraProjection instance to maintain access to intrinsics (K) and distortion coefficients.
1721 def __init__(self, camera_projection: CameraProjection): 1722 """ 1723 Create an InverseCameraProjection. 1724 1725 Args: 1726 camera_projection: The original CameraProjection instance. 1727 """ 1728 self._camera_projection = camera_projection 1729 # Initialize parent with the matrix (will be pseudo-inverted by parent's as_matrix) 1730 super().__init__(original_matrix=camera_projection.matrix, dtype=camera_projection.dtype)
Create an InverseCameraProjection.
Args: camera_projection: The original CameraProjection instance.
1732 @property 1733 def camera_projection(self) -> CameraProjection: 1734 """The original CameraProjection instance.""" 1735 return self._camera_projection
The original CameraProjection instance.
1738 @property 1739 def fx(self) -> float: 1740 """Focal length in x from the original CameraProjection.""" 1741 return self._camera_projection.fx
Focal length in x from the original CameraProjection.
1743 @property 1744 def fy(self) -> float: 1745 """Focal length in y from the original CameraProjection.""" 1746 return self._camera_projection.fy
Focal length in y from the original CameraProjection.
1748 @property 1749 def cx(self) -> float: 1750 """Principal point x from the original CameraProjection.""" 1751 return self._camera_projection.cx
Principal point x from the original CameraProjection.
1753 @property 1754 def cy(self) -> float: 1755 """Principal point y from the original CameraProjection.""" 1756 return self._camera_projection.cy
Principal point y from the original CameraProjection.
1758 @property 1759 def intrinsic_matrix(self) -> np.ndarray: 1760 """The 3x3 intrinsic matrix K from the original CameraProjection.""" 1761 return self._camera_projection.intrinsic_matrix
The 3x3 intrinsic matrix K from the original CameraProjection.
1763 def inverse(self) -> CameraProjection: 1764 """Returns the original CameraProjection.""" 1765 return self._camera_projection
Returns the original CameraProjection.
1767 def to_dict(self) -> dict[str, Any]: 1768 """Serialize to dictionary using the contained CameraProjection.""" 1769 return { 1770 "type": "InverseCameraProjection", 1771 "camera_projection": self._camera_projection.to_dict(), 1772 }
Serialize to dictionary using the contained CameraProjection.
1774 @classmethod 1775 def from_dict(cls, data: dict[str, Any]) -> InverseCameraProjection: 1776 """Deserialize from dictionary.""" 1777 cam_data = data["camera_projection"] 1778 if cam_data.get("type") != "CameraProjection": 1779 cam_data["type"] = "CameraProjection" 1780 1781 camera_proj = CameraProjection.from_dict(cam_data) 1782 return cls(camera_proj)
Deserialize from dictionary.
1800@register_transform 1801class OrthographicProjection(Projection): 1802 """ 1803 Orthographic (parallel) projection — maps 3D to 2D without perspective. 1804 1805 Unlike perspective ``CameraProjection``, this applies a pure affine 1806 mapping: the output pixel coordinates are a linear function of the input 1807 3D coordinates, with no division by depth. 1808 1809 Axis conventions (default ``"top"`` / BEV): 1810 * **+x** (forward) → image **top** (smaller row) 1811 * **+y** (left) → image **left** (smaller col) 1812 1813 Supported axis presets: 1814 * ``"top"`` — Bird's-eye view (drops Z) 1815 * ``"front"`` — Front view (drops X) 1816 * ``"side"`` — Side view (drops Y) 1817 1818 Usage:: 1819 1820 ortho = OrthographicProjection("top", (-50, 50), (-50, 50), 0.1) 1821 graph.add_transform("ego", "bev", ortho) 1822 pixels = transform_points(pts, graph, "lidar", "bev") 1823 1824 Args: 1825 axis: Projection axis preset (``"top"``, ``"front"``, ``"side"``). 1826 u_range: World-coordinate extent along the column axis (metres). 1827 v_range: World-coordinate extent along the row axis (metres). 1828 resolution: Metres per pixel. 1829 dtype: Numeric data type. 1830 """ 1831 1832 def __init__( 1833 self, 1834 axis: str = "top", 1835 u_range: tuple[float, float] = (-50.0, 50.0), 1836 v_range: tuple[float, float] = (-50.0, 50.0), 1837 resolution: float = 0.1, 1838 dtype: np.dtype = np.float64, 1839 ): 1840 if axis not in _ORTHO_AXIS_PRESETS: 1841 raise ValueError(f"Unknown axis '{axis}', must be one of {list(_ORTHO_AXIS_PRESETS)}") 1842 1843 self._axis = axis 1844 self._u_range = tuple(u_range) 1845 self._v_range = tuple(v_range) 1846 self._resolution = float(resolution) 1847 1848 u_idx, v_idx, flip_u, flip_v = _ORTHO_AXIS_PRESETS[axis] 1849 self._u_idx = u_idx 1850 self._v_idx = v_idx 1851 1852 # Build the 3x4 affine projection matrix. 1853 # col = (u_max - world[u_idx]) / res if flip_u else (world[u_idx] - u_min) / res 1854 # row = (v_max - world[v_idx]) / res if flip_v else (world[v_idx] - v_min) / res 1855 inv_res = 1.0 / resolution 1856 mat = np.zeros((3, 4), dtype=dtype) 1857 1858 if flip_u: 1859 mat[0, u_idx] = -inv_res 1860 mat[0, 3] = u_range[1] * inv_res 1861 else: 1862 mat[0, u_idx] = inv_res 1863 mat[0, 3] = -u_range[0] * inv_res 1864 1865 if flip_v: 1866 mat[1, v_idx] = -inv_res 1867 mat[1, 3] = v_range[1] * inv_res 1868 else: 1869 mat[1, v_idx] = inv_res 1870 mat[1, 3] = -v_range[0] * inv_res 1871 1872 # Third row: constant 1 (homogeneous w) 1873 mat[2, 3] = 1.0 1874 1875 super().__init__(matrix=mat, dtype=dtype) 1876 1877 # ------------------------------------------------------------------ # 1878 # Properties # 1879 # ------------------------------------------------------------------ # 1880 1881 @property 1882 def axis(self) -> str: 1883 """Projection axis preset.""" 1884 return self._axis 1885 1886 @property 1887 def u_range(self) -> tuple[float, float]: 1888 """World-coordinate extent along the column axis (metres).""" 1889 return self._u_range 1890 1891 @property 1892 def v_range(self) -> tuple[float, float]: 1893 """World-coordinate extent along the row axis (metres).""" 1894 return self._v_range 1895 1896 @property 1897 def resolution(self) -> float: 1898 """Metres per pixel.""" 1899 return self._resolution 1900 1901 @property 1902 def grid_shape(self) -> tuple[int, int]: 1903 """Output image dimensions ``(H, W)`` in pixels.""" 1904 W = int((self._u_range[1] - self._u_range[0]) / self._resolution) 1905 H = int((self._v_range[1] - self._v_range[0]) / self._resolution) 1906 return H, W 1907 1908 @property 1909 def origin_pixel(self) -> tuple[int, int]: 1910 """Pixel coordinates ``(col, row)`` of the world origin ``(0, 0, 0)``.""" 1911 px = self._apply(np.array([[0.0, 0.0, 0.0]]))[0] 1912 return int(px[0]), int(px[1]) 1913 1914 # ------------------------------------------------------------------ # 1915 # Core projection (override — NO perspective division) # 1916 # ------------------------------------------------------------------ # 1917 1918 def _apply(self, vector: np.ndarray | list | tuple) -> np.ndarray: 1919 """ 1920 Project 3D points to 2D pixel coordinates (affine, no perspective). 1921 1922 Args: 1923 vector: ``(N, 3)`` or ``(N, 4)`` array of 3D points. 1924 1925 Returns: 1926 ``(N, 2)`` array of ``[col, row]`` pixel coordinates. 1927 """ 1928 vector = np.atleast_2d(np.asarray(vector, dtype=self.dtype)) 1929 1930 if vector.shape[1] == 3: 1931 hom = np.hstack([vector, np.ones((vector.shape[0], 1), dtype=self.dtype)]) 1932 elif vector.shape[1] == 4: 1933 hom = vector 1934 else: 1935 raise ValueError(f"Input must be Nx3 or Nx4, got {vector.shape}") 1936 1937 # Affine projection: result[:, :2] are pixel coords, result[:, 2] == 1 1938 projected = (self.matrix @ hom.T).T 1939 return projected[:, :2] 1940 1941 def project_points(self, points: np.ndarray | list | tuple) -> np.ndarray: 1942 """Alias for :meth:`_apply`.""" 1943 return self._apply(points) 1944 1945 # ------------------------------------------------------------------ # 1946 # Inverse # 1947 # ------------------------------------------------------------------ # 1948 1949 def inverse(self) -> InverseOrthographicProjection: 1950 """ 1951 Return the inverse projection. 1952 1953 The inverse lifts 2D pixel coordinates back to 3D, placing them on the 1954 projection plane (the collapsed axis coordinate is set to 0). 1955 """ 1956 return InverseOrthographicProjection(self) 1957 1958 # ------------------------------------------------------------------ # 1959 # Serialization # 1960 # ------------------------------------------------------------------ # 1961 1962 def to_dict(self) -> dict[str, Any]: 1963 """Serialize to a JSON-compatible dictionary.""" 1964 return { 1965 "type": "OrthographicProjection", 1966 "axis": self._axis, 1967 "u_range": list(self._u_range), 1968 "v_range": list(self._v_range), 1969 "resolution": self._resolution, 1970 "dtype": np.dtype(self.dtype).name, 1971 } 1972 1973 @classmethod 1974 def from_dict(cls, data: dict[str, Any]) -> OrthographicProjection: 1975 """Deserialize from a dictionary.""" 1976 return cls( 1977 axis=data["axis"], 1978 u_range=tuple(data["u_range"]), 1979 v_range=tuple(data["v_range"]), 1980 resolution=data["resolution"], 1981 dtype=np.dtype(data.get("dtype", "float64")), 1982 ) 1983 1984 def __repr__(self) -> str: 1985 H, W = self.grid_shape 1986 return ( 1987 f"OrthographicProjection(axis={self._axis!r}, " 1988 f"u_range={self._u_range}, v_range={self._v_range}, " 1989 f"res={self._resolution}, grid={W}x{H})" 1990 )
Orthographic (parallel) projection — maps 3D to 2D without perspective.
Unlike perspective CameraProjection, this applies a pure affine
mapping: the output pixel coordinates are a linear function of the input
3D coordinates, with no division by depth.
Axis conventions (default "top" / BEV):
* +x (forward) → image top (smaller row)
* +y (left) → image left (smaller col)
Supported axis presets:
* "top" — Bird's-eye view (drops Z)
* "front" — Front view (drops X)
* "side" — Side view (drops Y)
Usage::
ortho = OrthographicProjection("top", (-50, 50), (-50, 50), 0.1)
graph.add_transform("ego", "bev", ortho)
pixels = transform_points(pts, graph, "lidar", "bev")
Args:
axis: Projection axis preset ("top", "front", "side").
u_range: World-coordinate extent along the column axis (metres).
v_range: World-coordinate extent along the row axis (metres).
resolution: Metres per pixel.
dtype: Numeric data type.
1832 def __init__( 1833 self, 1834 axis: str = "top", 1835 u_range: tuple[float, float] = (-50.0, 50.0), 1836 v_range: tuple[float, float] = (-50.0, 50.0), 1837 resolution: float = 0.1, 1838 dtype: np.dtype = np.float64, 1839 ): 1840 if axis not in _ORTHO_AXIS_PRESETS: 1841 raise ValueError(f"Unknown axis '{axis}', must be one of {list(_ORTHO_AXIS_PRESETS)}") 1842 1843 self._axis = axis 1844 self._u_range = tuple(u_range) 1845 self._v_range = tuple(v_range) 1846 self._resolution = float(resolution) 1847 1848 u_idx, v_idx, flip_u, flip_v = _ORTHO_AXIS_PRESETS[axis] 1849 self._u_idx = u_idx 1850 self._v_idx = v_idx 1851 1852 # Build the 3x4 affine projection matrix. 1853 # col = (u_max - world[u_idx]) / res if flip_u else (world[u_idx] - u_min) / res 1854 # row = (v_max - world[v_idx]) / res if flip_v else (world[v_idx] - v_min) / res 1855 inv_res = 1.0 / resolution 1856 mat = np.zeros((3, 4), dtype=dtype) 1857 1858 if flip_u: 1859 mat[0, u_idx] = -inv_res 1860 mat[0, 3] = u_range[1] * inv_res 1861 else: 1862 mat[0, u_idx] = inv_res 1863 mat[0, 3] = -u_range[0] * inv_res 1864 1865 if flip_v: 1866 mat[1, v_idx] = -inv_res 1867 mat[1, 3] = v_range[1] * inv_res 1868 else: 1869 mat[1, v_idx] = inv_res 1870 mat[1, 3] = -v_range[0] * inv_res 1871 1872 # Third row: constant 1 (homogeneous w) 1873 mat[2, 3] = 1.0 1874 1875 super().__init__(matrix=mat, dtype=dtype)
Create a Projection from a 3x4 or 4x4 matrix.
Args: matrix: 3x4 or 4x4 projection matrix. dtype: Data type for the matrix.
1881 @property 1882 def axis(self) -> str: 1883 """Projection axis preset.""" 1884 return self._axis
Projection axis preset.
1886 @property 1887 def u_range(self) -> tuple[float, float]: 1888 """World-coordinate extent along the column axis (metres).""" 1889 return self._u_range
World-coordinate extent along the column axis (metres).
1891 @property 1892 def v_range(self) -> tuple[float, float]: 1893 """World-coordinate extent along the row axis (metres).""" 1894 return self._v_range
World-coordinate extent along the row axis (metres).
1896 @property 1897 def resolution(self) -> float: 1898 """Metres per pixel.""" 1899 return self._resolution
Metres per pixel.
1901 @property 1902 def grid_shape(self) -> tuple[int, int]: 1903 """Output image dimensions ``(H, W)`` in pixels.""" 1904 W = int((self._u_range[1] - self._u_range[0]) / self._resolution) 1905 H = int((self._v_range[1] - self._v_range[0]) / self._resolution) 1906 return H, W
Output image dimensions (H, W) in pixels.
1908 @property 1909 def origin_pixel(self) -> tuple[int, int]: 1910 """Pixel coordinates ``(col, row)`` of the world origin ``(0, 0, 0)``.""" 1911 px = self._apply(np.array([[0.0, 0.0, 0.0]]))[0] 1912 return int(px[0]), int(px[1])
Pixel coordinates (col, row) of the world origin (0, 0, 0).
1941 def project_points(self, points: np.ndarray | list | tuple) -> np.ndarray: 1942 """Alias for :meth:`_apply`.""" 1943 return self._apply(points)
Alias for _apply().
1949 def inverse(self) -> InverseOrthographicProjection: 1950 """ 1951 Return the inverse projection. 1952 1953 The inverse lifts 2D pixel coordinates back to 3D, placing them on the 1954 projection plane (the collapsed axis coordinate is set to 0). 1955 """ 1956 return InverseOrthographicProjection(self)
Return the inverse projection.
The inverse lifts 2D pixel coordinates back to 3D, placing them on the projection plane (the collapsed axis coordinate is set to 0).
1962 def to_dict(self) -> dict[str, Any]: 1963 """Serialize to a JSON-compatible dictionary.""" 1964 return { 1965 "type": "OrthographicProjection", 1966 "axis": self._axis, 1967 "u_range": list(self._u_range), 1968 "v_range": list(self._v_range), 1969 "resolution": self._resolution, 1970 "dtype": np.dtype(self.dtype).name, 1971 }
Serialize to a JSON-compatible dictionary.
1973 @classmethod 1974 def from_dict(cls, data: dict[str, Any]) -> OrthographicProjection: 1975 """Deserialize from a dictionary.""" 1976 return cls( 1977 axis=data["axis"], 1978 u_range=tuple(data["u_range"]), 1979 v_range=tuple(data["v_range"]), 1980 resolution=data["resolution"], 1981 dtype=np.dtype(data.get("dtype", "float64")), 1982 )
Deserialize from a dictionary.
1993@register_transform 1994class InverseOrthographicProjection(InverseProjection): 1995 """ 1996 Inverse of an :class:`OrthographicProjection`. 1997 1998 Lifts 2D pixel coordinates back to 3D by inverting the affine mapping. 1999 The collapsed axis coordinate is set to 0 (i.e. the point is placed on 2000 the projection plane). 2001 """ 2002 2003 def __init__(self, ortho: OrthographicProjection): 2004 self._ortho = ortho 2005 super().__init__(original_matrix=ortho.matrix, dtype=ortho.dtype) 2006 2007 @property 2008 def orthographic_projection(self) -> OrthographicProjection: 2009 """The original OrthographicProjection.""" 2010 return self._ortho 2011 2012 def _apply(self, vector: np.ndarray | list | tuple) -> np.ndarray: 2013 """ 2014 Unproject 2D pixel coordinates to 3D (on the projection plane). 2015 2016 Args: 2017 vector: ``(N, 2)`` pixel coords ``[col, row]``. 2018 2019 Returns: 2020 ``(N, 3)`` array of 3D points (collapsed axis = 0). 2021 """ 2022 vector = np.atleast_2d(np.asarray(vector, dtype=self.dtype)) 2023 if vector.shape[1] != 2: 2024 raise ValueError(f"Expected Nx2 pixel coordinates, got {vector.shape}") 2025 2026 cols = vector[:, 0] 2027 rows = vector[:, 1] 2028 2029 u_idx, v_idx, flip_u, flip_v = _ORTHO_AXIS_PRESETS[self._ortho.axis] 2030 res = self._ortho.resolution 2031 u_range = self._ortho.u_range 2032 v_range = self._ortho.v_range 2033 2034 # Invert the affine mapping 2035 if flip_u: 2036 world_u = u_range[1] - cols * res 2037 else: 2038 world_u = u_range[0] + cols * res 2039 2040 if flip_v: 2041 world_v = v_range[1] - rows * res 2042 else: 2043 world_v = v_range[0] + rows * res 2044 2045 # Build 3D points (collapsed axis = 0) 2046 N = len(cols) 2047 points = np.zeros((N, 3), dtype=self.dtype) 2048 points[:, u_idx] = world_u 2049 points[:, v_idx] = world_v 2050 return points 2051 2052 def inverse(self) -> OrthographicProjection: 2053 """Return the original OrthographicProjection.""" 2054 return self._ortho 2055 2056 def to_dict(self) -> dict[str, Any]: 2057 """Serialize to a JSON-compatible dictionary.""" 2058 return { 2059 "type": "InverseOrthographicProjection", 2060 "orthographic_projection": self._ortho.to_dict(), 2061 } 2062 2063 @classmethod 2064 def from_dict(cls, data: dict[str, Any]) -> InverseOrthographicProjection: 2065 """Deserialize from a dictionary produced by to_dict.""" 2066 ortho = OrthographicProjection.from_dict(data["orthographic_projection"]) 2067 return cls(ortho) 2068 2069 def __repr__(self) -> str: 2070 return f"InverseOrthographicProjection({self._ortho})"
Inverse of an OrthographicProjection.
Lifts 2D pixel coordinates back to 3D by inverting the affine mapping. The collapsed axis coordinate is set to 0 (i.e. the point is placed on the projection plane).
2003 def __init__(self, ortho: OrthographicProjection): 2004 self._ortho = ortho 2005 super().__init__(original_matrix=ortho.matrix, dtype=ortho.dtype)
Create an InverseProjection from the original projection matrix.
Args: original_matrix: The original 3x4 or 4x4 projection matrix. dtype: Data type for the matrix.
2007 @property 2008 def orthographic_projection(self) -> OrthographicProjection: 2009 """The original OrthographicProjection.""" 2010 return self._ortho
The original OrthographicProjection.
2052 def inverse(self) -> OrthographicProjection: 2053 """Return the original OrthographicProjection.""" 2054 return self._ortho
Return the original OrthographicProjection.
2056 def to_dict(self) -> dict[str, Any]: 2057 """Serialize to a JSON-compatible dictionary.""" 2058 return { 2059 "type": "InverseOrthographicProjection", 2060 "orthographic_projection": self._ortho.to_dict(), 2061 }
Serialize to a JSON-compatible dictionary.
2063 @classmethod 2064 def from_dict(cls, data: dict[str, Any]) -> InverseOrthographicProjection: 2065 """Deserialize from a dictionary produced by to_dict.""" 2066 ortho = OrthographicProjection.from_dict(data["orthographic_projection"]) 2067 return cls(ortho)
Deserialize from a dictionary produced by to_dict.
2073@register_transform 2074class CompositeProjection(Projection): 2075 """ 2076 Represents a composition of a Projection (Intrinsics) and a Transform (Extrinsics). 2077 2078 Equivalent to: Projection * Transform 2079 P_composite = K * T 2080 2081 Projects from the source frame of T directly to 2D. 2082 2083 Structure: 2084 - projection: The Projection component (applied last/leftmost) 2085 - transform: The Transform component (applied first/rightmost) 2086 """ 2087 2088 def __init__(self, projection: Projection, transform: Transform, dtype: np.dtype = np.float64): 2089 self._projection = projection 2090 self._transform = transform 2091 2092 # Calculate matrix for BaseTransform compatibility 2093 # Matrix = K * T 2094 matrix = projection.as_matrix() @ transform.as_matrix() 2095 super().__init__(matrix=matrix, dtype=dtype) 2096 2097 @property 2098 def projection(self) -> Projection: 2099 """The intrinsic Projection component (applied last in the chain).""" 2100 return self._projection 2101 2102 @property 2103 def transform(self) -> Transform: 2104 """The extrinsic Transform component (applied first in the chain).""" 2105 return self._transform 2106 2107 def inverse(self) -> InverseCompositeProjection: 2108 """Return the inverse as an InverseCompositeProjection (T_inv * K_inv).""" 2109 return InverseCompositeProjection(self._transform.inverse(), self._projection.inverse()) 2110 2111 def __mul__(self, other: BaseTransform) -> BaseTransform: 2112 if isinstance(other, Transform): 2113 # Composite * Transform = (K * T_old) * T_new = K * (T_old * T_new) 2114 # Update transform component 2115 new_transform = self._transform * other 2116 # Result must be a Transform (T_old * T_new is Transform * Transform -> Transform) 2117 if not isinstance(new_transform, Transform): 2118 # Fallback if transform degrades (unlikely) 2119 return super().__mul__(other) 2120 2121 return CompositeProjection(self._projection, new_transform, dtype=self.dtype) 2122 2123 # Inherit strict checks from Projection 2124 return super().__mul__(other) 2125 2126 def to_dict(self) -> dict[str, Any]: 2127 """Serialize CompositeProjection (projection + transform) to dictionary.""" 2128 return { 2129 "type": "CompositeProjection", 2130 "projection": self._projection.to_dict(), 2131 "transform": self._transform.to_dict(), 2132 "dtype": np.dtype(self.dtype).name, 2133 } 2134 2135 @classmethod 2136 def from_dict(cls, data: dict[str, Any]) -> CompositeProjection: 2137 """Deserialize CompositeProjection from dictionary.""" 2138 dtype = np.dtype(data.get("dtype", "float64")) 2139 projection = deserialize_transform(data["projection"]) 2140 transform = deserialize_transform(data["transform"]) 2141 if not isinstance(projection, Projection): 2142 raise ValueError("CompositeProjection projection must be a Projection") 2143 if not isinstance(transform, Transform): 2144 raise ValueError("CompositeProjection transform must be a Transform") 2145 return cls(projection, transform, dtype=dtype) 2146 2147 def __repr__(self) -> str: 2148 return f"CompositeProjection(projection={self._projection}, transform={self._transform})"
Represents a composition of a Projection (Intrinsics) and a Transform (Extrinsics).
Equivalent to: Projection * Transform P_composite = K * T
Projects from the source frame of T directly to 2D.
Structure:
- projection: The Projection component (applied last/leftmost)
- transform: The Transform component (applied first/rightmost)
2088 def __init__(self, projection: Projection, transform: Transform, dtype: np.dtype = np.float64): 2089 self._projection = projection 2090 self._transform = transform 2091 2092 # Calculate matrix for BaseTransform compatibility 2093 # Matrix = K * T 2094 matrix = projection.as_matrix() @ transform.as_matrix() 2095 super().__init__(matrix=matrix, dtype=dtype)
Create a Projection from a 3x4 or 4x4 matrix.
Args: matrix: 3x4 or 4x4 projection matrix. dtype: Data type for the matrix.
2097 @property 2098 def projection(self) -> Projection: 2099 """The intrinsic Projection component (applied last in the chain).""" 2100 return self._projection
The intrinsic Projection component (applied last in the chain).
2102 @property 2103 def transform(self) -> Transform: 2104 """The extrinsic Transform component (applied first in the chain).""" 2105 return self._transform
The extrinsic Transform component (applied first in the chain).
2107 def inverse(self) -> InverseCompositeProjection: 2108 """Return the inverse as an InverseCompositeProjection (T_inv * K_inv).""" 2109 return InverseCompositeProjection(self._transform.inverse(), self._projection.inverse())
Return the inverse as an InverseCompositeProjection (T_inv * K_inv).
2126 def to_dict(self) -> dict[str, Any]: 2127 """Serialize CompositeProjection (projection + transform) to dictionary.""" 2128 return { 2129 "type": "CompositeProjection", 2130 "projection": self._projection.to_dict(), 2131 "transform": self._transform.to_dict(), 2132 "dtype": np.dtype(self.dtype).name, 2133 }
Serialize CompositeProjection (projection + transform) to dictionary.
2135 @classmethod 2136 def from_dict(cls, data: dict[str, Any]) -> CompositeProjection: 2137 """Deserialize CompositeProjection from dictionary.""" 2138 dtype = np.dtype(data.get("dtype", "float64")) 2139 projection = deserialize_transform(data["projection"]) 2140 transform = deserialize_transform(data["transform"]) 2141 if not isinstance(projection, Projection): 2142 raise ValueError("CompositeProjection projection must be a Projection") 2143 if not isinstance(transform, Transform): 2144 raise ValueError("CompositeProjection transform must be a Transform") 2145 return cls(projection, transform, dtype=dtype)
Deserialize CompositeProjection from dictionary.
2151@register_transform 2152class InverseCompositeProjection(InverseProjection): 2153 """ 2154 Represents the inverse of a CompositeProjection. 2155 2156 Equivalent to: Transform * InverseProjection 2157 P_inv_composite = T * K_inv 2158 2159 Unprojects from 2D to the source frame of T. 2160 2161 Structure: 2162 - transform: The Transform component (applied last/leftmost) 2163 - projection: The InverseProjection component (applied first/rightmost) 2164 """ 2165 2166 def __init__( 2167 self, transform: Transform, projection: InverseProjection, dtype: np.dtype = np.float64 2168 ): 2169 self._transform = transform 2170 self._projection = projection 2171 2172 # Calculate matrix for BaseTransform compatibility 2173 # Matrix = T * K_inv 2174 transform.as_matrix() @ projection.as_matrix() 2175 # Pass a dummy matrix to super, we override everything anyway. 2176 # But for correctness, passing what we think is the "original" is hard. 2177 # Let's assume standard behavior. 2178 # super needs "original_matrix" that is the PROJECTION matrix. 2179 # P_comp = K * T_inv. 2180 # So original = (K * T_inv).matrix 2181 2182 # NOTE: We can't easily construct the unified projection 2183 # matrix just from pieces without logic, 2184 # but let's try. 2185 try: 2186 # K * T_inv 2187 orig_proj_mat = projection.inverse().as_matrix() @ transform.inverse().as_matrix() 2188 super().__init__(original_matrix=orig_proj_mat, dtype=dtype) 2189 except Exception: 2190 # Fallback 2191 super().__init__(original_matrix=np.eye(4), dtype=dtype) 2192 2193 @property 2194 def transform(self) -> Transform: 2195 """The extrinsic Transform component (applied first in the chain).""" 2196 return self._transform 2197 2198 @property 2199 def projection(self) -> InverseProjection: 2200 """The inverse Projection component (applied last in the chain).""" 2201 return self._projection 2202 2203 def as_matrix(self) -> np.ndarray: 2204 """Return the combined T * K_inv matrix.""" 2205 return self._transform.as_matrix() @ self._projection.as_matrix() 2206 2207 def inverse(self) -> CompositeProjection: 2208 """Return the inverse as a CompositeProjection (K * T_inv).""" 2209 return CompositeProjection( 2210 self._projection.inverse(), self._transform.inverse(), dtype=self.dtype 2211 ) 2212 2213 def __mul__(self, other: BaseTransform) -> BaseTransform: 2214 # Inherit strict checks from InverseProjection 2215 return super().__mul__(other) 2216 2217 def __rmul__(self, other: BaseTransform) -> BaseTransform: 2218 # Handle Transform * InverseCompositeProjection 2219 # T_new * (T_old * K_inv) = (T_new * T_old) * K_inv 2220 if isinstance(other, Transform): 2221 new_transform = other * self._transform 2222 if isinstance(new_transform, Transform): 2223 return InverseCompositeProjection(new_transform, self._projection, dtype=self.dtype) 2224 2225 return NotImplemented 2226 2227 def to_dict(self) -> dict[str, Any]: 2228 """Serialize InverseCompositeProjection to dictionary.""" 2229 return { 2230 "type": "InverseCompositeProjection", 2231 "transform": self._transform.to_dict(), 2232 "projection": self._projection.to_dict(), 2233 "dtype": np.dtype(self.dtype).name, 2234 } 2235 2236 @classmethod 2237 def from_dict(cls, data: dict[str, Any]) -> InverseCompositeProjection: 2238 """Deserialize InverseCompositeProjection from dictionary.""" 2239 dtype = np.dtype(data.get("dtype", "float64")) 2240 transform = deserialize_transform(data["transform"]) 2241 projection = deserialize_transform(data["projection"]) 2242 if not isinstance(transform, Transform): 2243 raise ValueError("InverseCompositeProjection transform must be a Transform") 2244 if not isinstance(projection, InverseProjection): 2245 raise ValueError("InverseCompositeProjection projection must be an InverseProjection") 2246 return cls(transform, projection, dtype=dtype) 2247 2248 def __repr__(self) -> str: 2249 return ( 2250 f"InverseCompositeProjection(" 2251 f"transform={self._transform}, " 2252 f"projection={self._projection})" 2253 )
Represents the inverse of a CompositeProjection.
Equivalent to: Transform * InverseProjection P_inv_composite = T * K_inv
Unprojects from 2D to the source frame of T.
Structure:
- transform: The Transform component (applied last/leftmost)
- projection: The InverseProjection component (applied first/rightmost)
2166 def __init__( 2167 self, transform: Transform, projection: InverseProjection, dtype: np.dtype = np.float64 2168 ): 2169 self._transform = transform 2170 self._projection = projection 2171 2172 # Calculate matrix for BaseTransform compatibility 2173 # Matrix = T * K_inv 2174 transform.as_matrix() @ projection.as_matrix() 2175 # Pass a dummy matrix to super, we override everything anyway. 2176 # But for correctness, passing what we think is the "original" is hard. 2177 # Let's assume standard behavior. 2178 # super needs "original_matrix" that is the PROJECTION matrix. 2179 # P_comp = K * T_inv. 2180 # So original = (K * T_inv).matrix 2181 2182 # NOTE: We can't easily construct the unified projection 2183 # matrix just from pieces without logic, 2184 # but let's try. 2185 try: 2186 # K * T_inv 2187 orig_proj_mat = projection.inverse().as_matrix() @ transform.inverse().as_matrix() 2188 super().__init__(original_matrix=orig_proj_mat, dtype=dtype) 2189 except Exception: 2190 # Fallback 2191 super().__init__(original_matrix=np.eye(4), dtype=dtype)
Create an InverseProjection from the original projection matrix.
Args: original_matrix: The original 3x4 or 4x4 projection matrix. dtype: Data type for the matrix.
2193 @property 2194 def transform(self) -> Transform: 2195 """The extrinsic Transform component (applied first in the chain).""" 2196 return self._transform
The extrinsic Transform component (applied first in the chain).
2198 @property 2199 def projection(self) -> InverseProjection: 2200 """The inverse Projection component (applied last in the chain).""" 2201 return self._projection
The inverse Projection component (applied last in the chain).
2203 def as_matrix(self) -> np.ndarray: 2204 """Return the combined T * K_inv matrix.""" 2205 return self._transform.as_matrix() @ self._projection.as_matrix()
Return the combined T * K_inv matrix.
2207 def inverse(self) -> CompositeProjection: 2208 """Return the inverse as a CompositeProjection (K * T_inv).""" 2209 return CompositeProjection( 2210 self._projection.inverse(), self._transform.inverse(), dtype=self.dtype 2211 )
Return the inverse as a CompositeProjection (K * T_inv).
2227 def to_dict(self) -> dict[str, Any]: 2228 """Serialize InverseCompositeProjection to dictionary.""" 2229 return { 2230 "type": "InverseCompositeProjection", 2231 "transform": self._transform.to_dict(), 2232 "projection": self._projection.to_dict(), 2233 "dtype": np.dtype(self.dtype).name, 2234 }
Serialize InverseCompositeProjection to dictionary.
2236 @classmethod 2237 def from_dict(cls, data: dict[str, Any]) -> InverseCompositeProjection: 2238 """Deserialize InverseCompositeProjection from dictionary.""" 2239 dtype = np.dtype(data.get("dtype", "float64")) 2240 transform = deserialize_transform(data["transform"]) 2241 projection = deserialize_transform(data["projection"]) 2242 if not isinstance(transform, Transform): 2243 raise ValueError("InverseCompositeProjection transform must be a Transform") 2244 if not isinstance(projection, InverseProjection): 2245 raise ValueError("InverseCompositeProjection projection must be an InverseProjection") 2246 return cls(transform, projection, dtype=dtype)
Deserialize InverseCompositeProjection from dictionary.
126class ProjectionModel(Enum): 127 """ 128 Supported camera projection models. 129 130 Each member represents a complete 3D → 2D projection function, covering 131 both the ideal projection geometry and its associated distortion model. 132 133 Models: 134 135 - Pinhole: Ideal perspective projection, no distortion. 136 Parameters: fx, fy, cx, cy. 137 - BrownConrady: Pinhole + radial/tangential distortion. 138 D = (k1, k2, p1, p2, k3). OpenCV default, ROS ``plumb_bob``. 139 - KannalaBrandt: Fisheye / equidistant. 140 D = (k1, k2, k3, k4). ``cv2.fisheye``, ROS ``kannala_brandt``. 141 - Division: Simple wide-angle with single division coefficient. 142 D = (k1,). 143 - Rational: Full rational polynomial. 144 D = (k1, k2, p1, p2, k3, k4, k5, k6). ROS ``rational_polynomial``. 145 - Fisheye62: Project Aria fisheye model. 146 D = (k0, k1, k2, k3, p0, p1). 147 - MeiUnified: Unified omnidirectional camera model (Mei 2007). 148 D = (xi, k1, k2). Used by KITTI-360 fisheye cameras. 149 """ 150 151 Pinhole = "Pinhole" 152 BrownConrady = "BrownConrady" 153 KannalaBrandt = "KannalaBrandt" 154 Division = "Division" 155 Rational = "Rational" 156 Fisheye62 = "Fisheye62" 157 MeiUnified = "MeiUnified" 158 159 @classmethod 160 def from_string(cls, model_str: str) -> ProjectionModel: 161 """Convert a string to a ProjectionModel enum value. 162 163 Accepts ROS ``distortion_model`` names and legacy tgraph names. 164 """ 165 _aliases = { 166 # ROS camera_info distortion_model names 167 "plumb_bob": cls.BrownConrady, 168 "rational_polynomial": cls.Rational, 169 "kannala_brandt": cls.KannalaBrandt, 170 "fisheye62": cls.Fisheye62, 171 # Legacy tgraph names 172 "Fisheye": cls.KannalaBrandt, 173 "Omnidirectional": cls.Division, 174 "Pinhole+Polynomial": cls.BrownConrady, 175 } 176 if model_str in _aliases: 177 return _aliases[model_str] 178 # Exact value match 179 for model in cls: 180 if model.value == model_str: 181 return model 182 # Case-insensitive name match 183 lower = model_str.lower().replace("_", "").replace("-", "").replace("+", "") 184 for model in cls: 185 if model.name.lower() == lower: 186 return model 187 raise ValueError(f"Unknown projection model: {model_str}. Valid: {[m.value for m in cls]}")
Supported camera projection models.
Each member represents a complete 3D → 2D projection function, covering both the ideal projection geometry and its associated distortion model.
Models:
- Pinhole: Ideal perspective projection, no distortion. Parameters: fx, fy, cx, cy.
- BrownConrady: Pinhole + radial/tangential distortion.
D = (k1, k2, p1, p2, k3). OpenCV default, ROS
plumb_bob. - KannalaBrandt: Fisheye / equidistant.
D = (k1, k2, k3, k4).
cv2.fisheye, ROSkannala_brandt. - Division: Simple wide-angle with single division coefficient. D = (k1,).
- Rational: Full rational polynomial.
D = (k1, k2, p1, p2, k3, k4, k5, k6). ROS
rational_polynomial. - Fisheye62: Project Aria fisheye model. D = (k0, k1, k2, k3, p0, p1).
- MeiUnified: Unified omnidirectional camera model (Mei 2007). D = (xi, k1, k2). Used by KITTI-360 fisheye cameras.
159 @classmethod 160 def from_string(cls, model_str: str) -> ProjectionModel: 161 """Convert a string to a ProjectionModel enum value. 162 163 Accepts ROS ``distortion_model`` names and legacy tgraph names. 164 """ 165 _aliases = { 166 # ROS camera_info distortion_model names 167 "plumb_bob": cls.BrownConrady, 168 "rational_polynomial": cls.Rational, 169 "kannala_brandt": cls.KannalaBrandt, 170 "fisheye62": cls.Fisheye62, 171 # Legacy tgraph names 172 "Fisheye": cls.KannalaBrandt, 173 "Omnidirectional": cls.Division, 174 "Pinhole+Polynomial": cls.BrownConrady, 175 } 176 if model_str in _aliases: 177 return _aliases[model_str] 178 # Exact value match 179 for model in cls: 180 if model.value == model_str: 181 return model 182 # Case-insensitive name match 183 lower = model_str.lower().replace("_", "").replace("-", "").replace("+", "") 184 for model in cls: 185 if model.name.lower() == lower: 186 return model 187 raise ValueError(f"Unknown projection model: {model_str}. Valid: {[m.value for m in cls]}")
Convert a string to a ProjectionModel enum value.
Accepts ROS distortion_model names and legacy tgraph names.
2598class TransformGraph: 2599 """ 2600 Manages a graph of coordinate frames connected by spatial transformations. 2601 2602 Uses an undirected NetworkX graph with directional metadata on edges. 2603 Each edge stores: 2604 - transform: The BaseTransform object 2605 - parent: The source frame (defines the "natural" direction) 2606 - is_cache: Whether this is a cached shortcut (True) or added edge (False) 2607 - weight: 1.0 for added edges, 0.1 for cached shortcuts 2608 2609 Features: 2610 - Automatic path finding and transform composition 2611 - Lazy inversion when traversing against natural direction 2612 - Shortcut caching for O(1) repeated queries 2613 - Dependency-aware cache invalidation 2614 - JSON-compatible serialization 2615 """ 2616 2617 # Edge weights for path finding 2618 ADDED_EDGE_WEIGHT = 1.0 2619 CACHED_EDGE_WEIGHT = 1.0 2620 2621 def __init__(self): 2622 self._graph = nx.Graph() 2623 # Maps (source, target) added edges to list of cache edges that depend on them 2624 self._dependency_map: dict[tuple[str, str], list[tuple[str, str]]] = {} 2625 2626 @property 2627 def graph(self) -> nx.Graph: 2628 """Returns the internal NetworkX graph (read-only).""" 2629 return self._graph 2630 2631 @property 2632 def frames(self) -> list[str]: 2633 """Returns list of all frame IDs in the graph.""" 2634 return list(self._graph.nodes()) 2635 2636 @property 2637 def edges(self) -> list[tuple[str, str]]: 2638 """Returns list of all added edges as (reference_frame, target_frame) tuples.""" 2639 result = [] 2640 for u, v, data in self._graph.edges(data=True): 2641 if not data.get("is_cache", False): 2642 reference_frame = data["reference_frame"] 2643 target_frame = v if reference_frame == u else u 2644 result.append((reference_frame, target_frame)) 2645 return result 2646 2647 def has_frame(self, frame_id: str) -> bool: 2648 """Check if a frame exists in the graph.""" 2649 return frame_id in self._graph 2650 2651 def has_transform(self, source_frame: str, target_frame: str) -> bool: 2652 """Check if a direct transform (edge) exists between two frames.""" 2653 return self._graph.has_edge(source_frame, target_frame) 2654 2655 def add_transform( 2656 self, 2657 source_frame: str, 2658 target_frame: str, 2659 transform: BaseTransform, 2660 ) -> None: 2661 """ 2662 Add a transform between two frames. 2663 2664 API: add_transform(source, target, transform) 2665 - SOURCE: The domain frame (where vectors start). 2666 - TARGET: The codomain/reference frame (where vectors end). 2667 - TRANSFORM: Source→Target operator. 2668 2669 The transform maps Source coordinates to Target coordinates: 2670 P_target = transform * P_source 2671 2672 Args: 2673 source_frame: The source/domain frame ID. 2674 target_frame: The target/reference frame ID. 2675 transform: The transform from source to target. 2676 2677 Raises: 2678 ValueError: If an edge already exists between these frames. 2679 """ 2680 if self._graph.has_edge(source_frame, target_frame): 2681 raise ValueError( 2682 f"Transform between '{source_frame}' and '{target_frame}' already exists. " 2683 "Use update_transform() to modify it." 2684 ) 2685 2686 # Store edge between nodes. 2687 # We store "reference_frame" to indicate which node is the Target (Codomain) 2688 # of the transform. 2689 # If reference_frame = target_frame, then the transform is source -> target. 2690 self._graph.add_edge( 2691 target_frame, 2692 source_frame, 2693 transform=transform, 2694 reference_frame=target_frame, 2695 is_cache=False, 2696 weight=self.ADDED_EDGE_WEIGHT, 2697 ) 2698 2699 def update_transform( 2700 self, 2701 source_frame: str, 2702 target_frame: str, 2703 transform: BaseTransform, 2704 ) -> None: 2705 """ 2706 Update an existing transform between two frames. 2707 2708 Automatically invalidates any cached shortcuts that depend on this edge. 2709 2710 Args: 2711 source_frame: The source frame ID. 2712 target_frame: The target frame ID. 2713 transform: The new transform from source to target. 2714 2715 Raises: 2716 ValueError: If no edge exists between these frames. 2717 """ 2718 if not self._graph.has_edge(target_frame, source_frame): 2719 raise ValueError( 2720 f"No transform between '{source_frame}' and '{target_frame}'. " 2721 "Use add_transform() to create it." 2722 ) 2723 2724 # Invalidate dependent caches 2725 self._invalidate_caches_for_edge(target_frame, source_frame) 2726 2727 # Update the transform 2728 self._graph[target_frame][source_frame]["transform"] = transform 2729 self._graph[target_frame][source_frame]["reference_frame"] = target_frame 2730 2731 def remove_transform(self, frame_a: str, frame_b: str) -> None: 2732 """ 2733 Remove a transform (edge) between two frames. 2734 2735 Args: 2736 frame_a: First frame ID. 2737 frame_b: Second frame ID. 2738 2739 Raises: 2740 ValueError: If no edge exists between these frames. 2741 """ 2742 if not self._graph.has_edge(frame_a, frame_b): 2743 raise ValueError(f"No transform between '{frame_a}' and '{frame_b}'.") 2744 2745 # Invalidate dependent caches 2746 self._invalidate_caches_for_edge(frame_a, frame_b) 2747 2748 # Remove the edge 2749 self._graph.remove_edge(frame_a, frame_b) 2750 2751 # Clean up isolated nodes 2752 for frame in [frame_a, frame_b]: 2753 if self._graph.degree(frame) == 0: 2754 self._graph.remove_node(frame) 2755 2756 def get_transform(self, source_frame: str, target_frame: str) -> BaseTransform: 2757 """ 2758 Get the transform from source_frame to target_frame. 2759 2760 Automatically finds the shortest path and composes transforms. 2761 Results are cached as shortcut edges for O(1) subsequent lookups. 2762 2763 Args: 2764 source_frame: The source frame ID. 2765 target_frame: The target frame ID. 2766 2767 Returns: 2768 BaseTransform: The composed transform T_source_to_target. 2769 2770 Raises: 2771 ValueError: If either frame doesn't exist or no path exists. 2772 """ 2773 if source_frame == target_frame: 2774 return Identity() 2775 2776 if source_frame not in self._graph: 2777 raise ValueError(f"Frame '{source_frame}' not found in graph.") 2778 if target_frame not in self._graph: 2779 raise ValueError(f"Frame '{target_frame}' not found in graph.") 2780 2781 # Check for direct edge (including cached shortcuts) 2782 if self._graph.has_edge(source_frame, target_frame): 2783 edge_data = self._graph[source_frame][target_frame] 2784 transform = edge_data["transform"] 2785 reference_frame = edge_data["reference_frame"] 2786 2787 if reference_frame == source_frame: 2788 # source is reference_frame (Target). Going to target_frame (Source). 2789 # Direction: Target -> Source. 2790 # Use inverse. 2791 return transform.inverse() 2792 return transform # source is Source. Going to reference_frame (Target). Use direct. 2793 2794 # Find shortest path 2795 try: 2796 path = nx.shortest_path(self._graph, source_frame, target_frame, weight="weight") 2797 except nx.NetworkXNoPath: 2798 raise ValueError(f"No path from '{source_frame}' to '{target_frame}'.") 2799 2800 # Compose transforms along path 2801 composed_transform = Identity() 2802 added_edges = [] 2803 2804 for i in range(len(path) - 1): 2805 current_frame = path[i] 2806 next_frame = path[i + 1] 2807 2808 edge_data = self._graph[current_frame][next_frame] 2809 transform = edge_data["transform"] 2810 reference_frame = edge_data["reference_frame"] 2811 2812 if not edge_data.get("is_cache", False): 2813 # Normalize edge key (always smaller, larger) 2814 # Use str(frame) for consistent sorting across mixed types (int vs str vs UUID) 2815 sorted_frames = sorted([current_frame, next_frame], key=str) 2816 edge_key = tuple(sorted_frames) 2817 added_edges.append(edge_key) 2818 2819 # Traversal Logic: 2820 # If current is Ref (Target): Going to Source. Use Inverse. 2821 # If current is Source: Going to Ref (Target). Use Direct. 2822 if reference_frame == current_frame: 2823 step_transform = transform.inverse() 2824 else: 2825 step_transform = transform 2826 2827 # Compose in reverse order: new_step * accumulated 2828 # This ensures: (T3 * T2 * T1) transforms correctly 2829 composed_transform = step_transform * composed_transform 2830 2831 # Cache the result as a shortcut edge 2832 self._add_cache_edge(source_frame, target_frame, composed_transform, added_edges) 2833 2834 return composed_transform 2835 2836 def _add_cache_edge( 2837 self, 2838 source_frame: str, 2839 target_frame: str, 2840 transform: BaseTransform, 2841 added_edges: list[tuple[str, str]], 2842 ) -> None: 2843 """ 2844 Add a cached shortcut edge and register dependencies. 2845 2846 The transform is source→target, edge goes from target→source. 2847 """ 2848 self._graph.add_edge( 2849 target_frame, # Edge from ref (target) 2850 source_frame, # to source 2851 transform=transform, # source→target transform 2852 reference_frame=target_frame, # target is the reference_frame 2853 is_cache=True, 2854 weight=self.CACHED_EDGE_WEIGHT, 2855 ) 2856 2857 # Register cache dependency for all constituent edges 2858 # Use str() key for consistent sorting 2859 sorted_frames = sorted([source_frame, target_frame], key=str) 2860 cache_edge = tuple(sorted_frames) 2861 for added_edge in added_edges: 2862 if added_edge not in self._dependency_map: 2863 self._dependency_map[added_edge] = [] 2864 self._dependency_map[added_edge].append(cache_edge) 2865 2866 def _invalidate_caches_for_edge(self, frame_a: str, frame_b: str) -> None: 2867 """ 2868 Remove all cached edges that depend on the edge (frame_a, frame_b). 2869 """ 2870 sorted_frames = sorted([frame_a, frame_b], key=str) 2871 edge_key = tuple(sorted_frames) 2872 if edge_key in self._dependency_map: 2873 for cache_u, cache_v in self._dependency_map[edge_key]: 2874 if self._graph.has_edge(cache_u, cache_v) and self._graph[cache_u][cache_v].get( 2875 "is_cache", False 2876 ): 2877 self._graph.remove_edge(cache_u, cache_v) 2878 # Clear dependencies for this edge 2879 del self._dependency_map[edge_key] 2880 2881 def is_projection_frame(self, frame_id: str) -> bool: 2882 """ 2883 Check if a frame is a 2D projection frame (e.g., an Image frame). 2884 2885 Rule: A frame is a projection frame if ALL edges connected to it treat it 2886 as a projection space. 2887 - If transform maps INTO frame (frame is Target), transform must be a Projection. 2888 - If transform maps OUT OF frame (frame is Source), transform must be an InverseProjection. 2889 """ 2890 if frame_id not in self._graph: 2891 return False 2892 2893 neighbors = list(self._graph.neighbors(frame_id)) 2894 if not neighbors: 2895 return False 2896 2897 for neighbor in neighbors: 2898 edge_data = self._graph[frame_id][neighbor] 2899 transform = edge_data["transform"] 2900 reference_frame = edge_data["reference_frame"] 2901 2902 # Use 'reference_frame' to determine direction. 2903 # reference_frame is the TARGET of the transform. 2904 2905 if reference_frame == frame_id: 2906 # Transform is Neighbor -> Frame (Source -> Target) 2907 # For Frame to be 2D, this must be a Projection (3D -> 2D) 2908 if not isinstance(transform, Projection): 2909 return False 2910 else: 2911 # Transform is Frame -> Neighbor (Source -> Target) 2912 # For Frame to be 2D, this must be an InverseProjection (2D -> 3D) 2913 if not isinstance(transform, InverseProjection): 2914 return False 2915 2916 return True 2917 2918 def _get_camera_intrinsics_and_pose(self, image_frame: str) -> tuple[np.ndarray, str]: 2919 """ 2920 Helper: Find the connected 3D camera frame and K matrix for an image frame. 2921 2922 Returns: 2923 (K, camera_frame_id) 2924 """ 2925 if image_frame not in self._graph: 2926 raise ValueError(f"Frame '{image_frame}' not found.") 2927 2928 for neighbor in self._graph.neighbors(image_frame): 2929 edge_data = self._graph[image_frame][neighbor] 2930 transform = edge_data["transform"] 2931 reference_frame = edge_data["reference_frame"] 2932 2933 K = None 2934 cam_frame = None 2935 2936 # Logic: If frame is projection frame, the neighbor must be the camera 2937 if isinstance(transform, CameraProjection): 2938 if reference_frame == image_frame: # Proj maps Neighbor->Image 2939 K = transform.intrinsic_matrix 2940 cam_frame = neighbor 2941 2942 elif isinstance(transform, InverseCameraProjection): 2943 if reference_frame != image_frame: # InvProj maps Image->Neighbor 2944 K = transform.intrinsic_matrix 2945 cam_frame = neighbor 2946 2947 if K is not None: 2948 return K, cam_frame 2949 2950 raise ValueError( 2951 f"Frame '{image_frame}' is not a valid projection frame connected to a camera." 2952 ) 2953 2954 def get_essential_matrix(self, image_frame_1: str, image_frame_2: str) -> np.ndarray: 2955 """ 2956 Compute the Essential Matrix E between two image frames. 2957 2958 E = [t]_x R 2959 where R, t describe method to transform points from Camera 1 to Camera 2. 2960 X2 = R X1 + t. 2961 """ 2962 _, c1 = self._get_camera_intrinsics_and_pose(image_frame_1) 2963 _, c2 = self._get_camera_intrinsics_and_pose(image_frame_2) 2964 2965 # Get transform from C1 to C2 2966 # T_c1_to_c2: converts X_c1 to X_c2. 2967 T_12 = self.get_transform(c1, c2) 2968 if not isinstance(T_12, Transform): 2969 raise ValueError(f"Transform between '{c1}' and '{c2}' is not a spatial Transform.") 2970 2971 R = quaternion.as_rotation_matrix(T_12.rotation) 2972 t = T_12.translation.flatten() 2973 2974 # Skew symmetric matrix of t 2975 t_x = skew(t) 2976 2977 return t_x @ R 2978 2979 def get_fundamental_matrix(self, image_frame_1: str, image_frame_2: str) -> np.ndarray: 2980 """ 2981 Compute the Fundamental Matrix F between two image frames. 2982 2983 F = K2^-T E K1^-1 2984 x2^T F x1 = 0 2985 """ 2986 K1, _ = self._get_camera_intrinsics_and_pose(image_frame_1) 2987 K2, _ = self._get_camera_intrinsics_and_pose(image_frame_2) 2988 2989 E = self.get_essential_matrix(image_frame_1, image_frame_2) 2990 2991 K1_inv = np.linalg.inv(K1) 2992 K2_inv = np.linalg.inv(K2) 2993 2994 return K2_inv.T @ E @ K1_inv 2995 2996 def get_homography( 2997 self, 2998 image_frame_1: str, 2999 image_frame_2: str, 3000 plane_normal: np.ndarray, 3001 plane_distance: float, 3002 ) -> np.ndarray: 3003 """ 3004 Compute Homography H mapping pixels from image 1 to image 2 induced by a plane. 3005 3006 x2 ~ H x1 3007 3008 Plane equation in Camera 1 frame: n^T X = d 3009 H = K2 (R + t n^T / d) K1^-1 3010 3011 Args: 3012 image_frame_1: Source image frame. 3013 image_frame_2: Target image frame. 3014 plane_normal: Normal vector of the plane in Camera 1 frame (3,). 3015 plane_distance: Distance to the plane in Camera 1 frame (scalar). 3016 """ 3017 K1, c1 = self._get_camera_intrinsics_and_pose(image_frame_1) 3018 K2, c2 = self._get_camera_intrinsics_and_pose(image_frame_2) 3019 3020 # T_12: C1 -> C2 3021 T_12 = self.get_transform(c1, c2) 3022 R = quaternion.as_rotation_matrix(T_12.rotation) 3023 t = T_12.translation.flatten().reshape(3, 1) 3024 3025 n = np.asarray(plane_normal).reshape(3, 1) 3026 d = float(plane_distance) 3027 3028 H_euclidean = R + (t @ n.T) / d 3029 3030 return K2 @ H_euclidean @ np.linalg.inv(K1) 3031 3032 @staticmethod 3033 def estimate_skew(intrinsic_matrix: np.ndarray) -> float: 3034 """ 3035 Estimate the skew parameter from an intrinsic matrix K. 3036 3037 K = [[fx, s, cx], [0, fy, cy], [0, 0, 1]] 3038 Returns s. 3039 """ 3040 return float(intrinsic_matrix[0, 1]) 3041 3042 def clear_cache(self) -> None: 3043 """ 3044 Clear all cached shortcut transforms. 3045 3046 Removes edges marked with is_cache=True. 3047 """ 3048 edges_to_remove = [ 3049 (u, v) for u, v, data in self._graph.edges(data=True) if data.get("is_cache", False) 3050 ] 3051 self._graph.remove_edges_from(edges_to_remove) 3052 self._dependency_map.clear() 3053 3054 def get_connected_components(self) -> list[set[str]]: 3055 """ 3056 Get all connected components in the graph. 3057 3058 Returns: 3059 List of sets, where each set contains frame IDs of a connected component. 3060 """ 3061 return list(nx.connected_components(self._graph)) 3062 3063 def get_connected_nodes(self, frame_id: str) -> set[str]: 3064 """ 3065 Get the set of all nodes connected to the given frame (its connected component). 3066 3067 Args: 3068 frame_id: The frame to start searching from. 3069 3070 Returns: 3071 Set of connected frame IDs. 3072 3073 Raises: 3074 ValueError: If frame_id is not in the graph. 3075 """ 3076 if frame_id not in self._graph: 3077 raise ValueError(f"Frame '{frame_id}' is not in the graph.") 3078 return nx.node_connected_component(self._graph, frame_id) 3079 3080 def to_dict(self) -> dict[str, Any]: 3081 """ 3082 Serialize the entire graph to a dictionary. 3083 3084 Returns: 3085 Dict containing 'frames' and 'edges' (only explicit, non-cached edges). 3086 3087 Frame IDs that are not JSON-native (tuples, datetime, UUID) are encoded 3088 as tagged dicts with ``__type__`` and ``value`` keys so they survive 3089 ``json.dumps``/``json.loads`` roundtrip without information loss. 3090 """ 3091 frames = [self._encode_frame_id(f) for f in self.frames] 3092 edges = [] 3093 for u, v, data in self._graph.edges(data=True): 3094 if not data.get("is_cache", False): 3095 transform = data["transform"] 3096 reference = data.get("reference_frame") 3097 source = v if reference == u else u 3098 edges.append( 3099 { 3100 "source": self._encode_frame_id(source), 3101 "target": self._encode_frame_id(reference), 3102 "transform": transform.to_dict(), 3103 } 3104 ) 3105 3106 return {"frames": frames, "edges": edges} 3107 3108 # ----------------------------------------------------------------------- 3109 # Frame ID Serialization Helpers 3110 # 3111 # Non-JSON-native Python types used as frame IDs are encoded as tagged 3112 # dicts so the graph can be losslessly transmitted as JSON (e.g., via 3113 # HTTP requests). 3114 # 3115 # Supported types: 3116 # tuple → {"type": "tuple", "value": [encoded elements...]} 3117 # datetime → {"type": "datetime", "value": "2026-01-01T12:00:00+00:00"} 3118 # datetime64 → {"type": "datetime64", "value": "2026-01-01T12:00:00.000000000", "unit": "ns"} 3119 # UUID → {"type": "uuid", "value": "550e8400-..."} 3120 # 3121 # Primitives (str, int, float, bool, None) pass through unchanged. 3122 # ----------------------------------------------------------------------- 3123 3124 @staticmethod 3125 def _encode_frame_id(frame_id: Any) -> Any: 3126 """Encode a frame ID into a JSON-safe representation. 3127 3128 Non-JSON-native types are wrapped in ``{"type": ..., "value": ...}`` 3129 tagged dicts. The encoding is recursive for compound types (tuples). 3130 """ 3131 import datetime as dt 3132 3133 if isinstance(frame_id, tuple): 3134 return { 3135 "type": "tuple", 3136 "value": [TransformGraph._encode_frame_id(item) for item in frame_id], 3137 } 3138 if isinstance(frame_id, np.datetime64): 3139 unit = np.datetime_data(frame_id)[0] 3140 return {"type": "datetime64", "value": str(frame_id), "unit": unit} 3141 if isinstance(frame_id, dt.datetime): 3142 return {"type": "datetime", "value": frame_id.isoformat()} 3143 if isinstance(frame_id, UUID): 3144 return {"type": "uuid", "value": str(frame_id)} 3145 # str, int, float, bool, None — JSON-native 3146 return frame_id 3147 3148 @staticmethod 3149 def _decode_frame_id(frame_id: Any) -> Any: 3150 """Decode a JSON-deserialized frame ID back to its original Python type. 3151 3152 Recognizes tagged dicts produced by ``_encode_frame_id`` and converts 3153 them back. Plain lists (from untagged JSON arrays) are converted to 3154 tuples for backward compatibility. 3155 """ 3156 import datetime as dt 3157 3158 if isinstance(frame_id, dict) and "type" in frame_id: 3159 type_tag = frame_id["type"] 3160 value = frame_id["value"] 3161 if type_tag == "tuple": 3162 return tuple(TransformGraph._decode_frame_id(item) for item in value) 3163 if type_tag == "datetime": 3164 return dt.datetime.fromisoformat(value) 3165 if type_tag == "datetime64": 3166 unit = frame_id.get("unit", "ns") 3167 return np.datetime64(value, unit) 3168 if type_tag == "uuid": 3169 return UUID(value) 3170 raise ValueError(f"Unknown frame ID type tag: {type_tag!r}") 3171 # Backward compatibility: plain JSON arrays → tuples 3172 if isinstance(frame_id, list): 3173 return tuple(TransformGraph._decode_frame_id(item) for item in frame_id) 3174 return frame_id 3175 3176 @classmethod 3177 def from_dict(cls, data: dict[str, Any]) -> TransformGraph: 3178 """ 3179 Deserialize a graph from a dictionary. 3180 3181 Args: 3182 data: Dictionary produced by to_dict(). 3183 3184 Returns: 3185 New TransformGraph instance. 3186 """ 3187 graph = cls() 3188 for edge_data in data.get("edges", []): 3189 src = cls._decode_frame_id(edge_data["source"]) 3190 tgt = cls._decode_frame_id(edge_data["target"]) 3191 transform = deserialize_transform(edge_data["transform"]) 3192 graph.add_transform(src, tgt, transform) 3193 3194 return graph 3195 3196 def __repr__(self) -> str: 3197 """ 3198 String representation of the graph. 3199 Shows basic stats and the largest connected component. 3200 """ 3201 nodes = self.frames 3202 if not nodes: 3203 return "TransformGraph(Empty)" 3204 3205 edges = self.edges 3206 components = self.get_connected_components() 3207 3208 # Sort by size, descending 3209 components.sort(key=len, reverse=True) 3210 largest = components[0] 3211 largest_nodes = sorted(list(largest)) 3212 3213 return ( 3214 f"TransformGraph(\n" 3215 f" nodes={len(nodes)}, edges={len(edges)}, components={len(components)},\n" 3216 f" largest_component={largest_nodes}\n" 3217 f")" 3218 )
Manages a graph of coordinate frames connected by spatial transformations.
Uses an undirected NetworkX graph with directional metadata on edges. Each edge stores: - transform: The BaseTransform object - parent: The source frame (defines the "natural" direction) - is_cache: Whether this is a cached shortcut (True) or added edge (False) - weight: 1.0 for added edges, 0.1 for cached shortcuts
Features: - Automatic path finding and transform composition - Lazy inversion when traversing against natural direction - Shortcut caching for O(1) repeated queries - Dependency-aware cache invalidation - JSON-compatible serialization
2626 @property 2627 def graph(self) -> nx.Graph: 2628 """Returns the internal NetworkX graph (read-only).""" 2629 return self._graph
Returns the internal NetworkX graph (read-only).
2631 @property 2632 def frames(self) -> list[str]: 2633 """Returns list of all frame IDs in the graph.""" 2634 return list(self._graph.nodes())
Returns list of all frame IDs in the graph.
2636 @property 2637 def edges(self) -> list[tuple[str, str]]: 2638 """Returns list of all added edges as (reference_frame, target_frame) tuples.""" 2639 result = [] 2640 for u, v, data in self._graph.edges(data=True): 2641 if not data.get("is_cache", False): 2642 reference_frame = data["reference_frame"] 2643 target_frame = v if reference_frame == u else u 2644 result.append((reference_frame, target_frame)) 2645 return result
Returns list of all added edges as (reference_frame, target_frame) tuples.
2647 def has_frame(self, frame_id: str) -> bool: 2648 """Check if a frame exists in the graph.""" 2649 return frame_id in self._graph
Check if a frame exists in the graph.
2651 def has_transform(self, source_frame: str, target_frame: str) -> bool: 2652 """Check if a direct transform (edge) exists between two frames.""" 2653 return self._graph.has_edge(source_frame, target_frame)
Check if a direct transform (edge) exists between two frames.
2655 def add_transform( 2656 self, 2657 source_frame: str, 2658 target_frame: str, 2659 transform: BaseTransform, 2660 ) -> None: 2661 """ 2662 Add a transform between two frames. 2663 2664 API: add_transform(source, target, transform) 2665 - SOURCE: The domain frame (where vectors start). 2666 - TARGET: The codomain/reference frame (where vectors end). 2667 - TRANSFORM: Source→Target operator. 2668 2669 The transform maps Source coordinates to Target coordinates: 2670 P_target = transform * P_source 2671 2672 Args: 2673 source_frame: The source/domain frame ID. 2674 target_frame: The target/reference frame ID. 2675 transform: The transform from source to target. 2676 2677 Raises: 2678 ValueError: If an edge already exists between these frames. 2679 """ 2680 if self._graph.has_edge(source_frame, target_frame): 2681 raise ValueError( 2682 f"Transform between '{source_frame}' and '{target_frame}' already exists. " 2683 "Use update_transform() to modify it." 2684 ) 2685 2686 # Store edge between nodes. 2687 # We store "reference_frame" to indicate which node is the Target (Codomain) 2688 # of the transform. 2689 # If reference_frame = target_frame, then the transform is source -> target. 2690 self._graph.add_edge( 2691 target_frame, 2692 source_frame, 2693 transform=transform, 2694 reference_frame=target_frame, 2695 is_cache=False, 2696 weight=self.ADDED_EDGE_WEIGHT, 2697 )
Add a transform between two frames.
API: add_transform(source, target, transform)
- SOURCE: The domain frame (where vectors start).
- TARGET: The codomain/reference frame (where vectors end).
- TRANSFORM: Source→Target operator.
The transform maps Source coordinates to Target coordinates: P_target = transform * P_source
Args: source_frame: The source/domain frame ID. target_frame: The target/reference frame ID. transform: The transform from source to target.
Raises: ValueError: If an edge already exists between these frames.
2699 def update_transform( 2700 self, 2701 source_frame: str, 2702 target_frame: str, 2703 transform: BaseTransform, 2704 ) -> None: 2705 """ 2706 Update an existing transform between two frames. 2707 2708 Automatically invalidates any cached shortcuts that depend on this edge. 2709 2710 Args: 2711 source_frame: The source frame ID. 2712 target_frame: The target frame ID. 2713 transform: The new transform from source to target. 2714 2715 Raises: 2716 ValueError: If no edge exists between these frames. 2717 """ 2718 if not self._graph.has_edge(target_frame, source_frame): 2719 raise ValueError( 2720 f"No transform between '{source_frame}' and '{target_frame}'. " 2721 "Use add_transform() to create it." 2722 ) 2723 2724 # Invalidate dependent caches 2725 self._invalidate_caches_for_edge(target_frame, source_frame) 2726 2727 # Update the transform 2728 self._graph[target_frame][source_frame]["transform"] = transform 2729 self._graph[target_frame][source_frame]["reference_frame"] = target_frame
Update an existing transform between two frames.
Automatically invalidates any cached shortcuts that depend on this edge.
Args: source_frame: The source frame ID. target_frame: The target frame ID. transform: The new transform from source to target.
Raises: ValueError: If no edge exists between these frames.
2731 def remove_transform(self, frame_a: str, frame_b: str) -> None: 2732 """ 2733 Remove a transform (edge) between two frames. 2734 2735 Args: 2736 frame_a: First frame ID. 2737 frame_b: Second frame ID. 2738 2739 Raises: 2740 ValueError: If no edge exists between these frames. 2741 """ 2742 if not self._graph.has_edge(frame_a, frame_b): 2743 raise ValueError(f"No transform between '{frame_a}' and '{frame_b}'.") 2744 2745 # Invalidate dependent caches 2746 self._invalidate_caches_for_edge(frame_a, frame_b) 2747 2748 # Remove the edge 2749 self._graph.remove_edge(frame_a, frame_b) 2750 2751 # Clean up isolated nodes 2752 for frame in [frame_a, frame_b]: 2753 if self._graph.degree(frame) == 0: 2754 self._graph.remove_node(frame)
Remove a transform (edge) between two frames.
Args: frame_a: First frame ID. frame_b: Second frame ID.
Raises: ValueError: If no edge exists between these frames.
2756 def get_transform(self, source_frame: str, target_frame: str) -> BaseTransform: 2757 """ 2758 Get the transform from source_frame to target_frame. 2759 2760 Automatically finds the shortest path and composes transforms. 2761 Results are cached as shortcut edges for O(1) subsequent lookups. 2762 2763 Args: 2764 source_frame: The source frame ID. 2765 target_frame: The target frame ID. 2766 2767 Returns: 2768 BaseTransform: The composed transform T_source_to_target. 2769 2770 Raises: 2771 ValueError: If either frame doesn't exist or no path exists. 2772 """ 2773 if source_frame == target_frame: 2774 return Identity() 2775 2776 if source_frame not in self._graph: 2777 raise ValueError(f"Frame '{source_frame}' not found in graph.") 2778 if target_frame not in self._graph: 2779 raise ValueError(f"Frame '{target_frame}' not found in graph.") 2780 2781 # Check for direct edge (including cached shortcuts) 2782 if self._graph.has_edge(source_frame, target_frame): 2783 edge_data = self._graph[source_frame][target_frame] 2784 transform = edge_data["transform"] 2785 reference_frame = edge_data["reference_frame"] 2786 2787 if reference_frame == source_frame: 2788 # source is reference_frame (Target). Going to target_frame (Source). 2789 # Direction: Target -> Source. 2790 # Use inverse. 2791 return transform.inverse() 2792 return transform # source is Source. Going to reference_frame (Target). Use direct. 2793 2794 # Find shortest path 2795 try: 2796 path = nx.shortest_path(self._graph, source_frame, target_frame, weight="weight") 2797 except nx.NetworkXNoPath: 2798 raise ValueError(f"No path from '{source_frame}' to '{target_frame}'.") 2799 2800 # Compose transforms along path 2801 composed_transform = Identity() 2802 added_edges = [] 2803 2804 for i in range(len(path) - 1): 2805 current_frame = path[i] 2806 next_frame = path[i + 1] 2807 2808 edge_data = self._graph[current_frame][next_frame] 2809 transform = edge_data["transform"] 2810 reference_frame = edge_data["reference_frame"] 2811 2812 if not edge_data.get("is_cache", False): 2813 # Normalize edge key (always smaller, larger) 2814 # Use str(frame) for consistent sorting across mixed types (int vs str vs UUID) 2815 sorted_frames = sorted([current_frame, next_frame], key=str) 2816 edge_key = tuple(sorted_frames) 2817 added_edges.append(edge_key) 2818 2819 # Traversal Logic: 2820 # If current is Ref (Target): Going to Source. Use Inverse. 2821 # If current is Source: Going to Ref (Target). Use Direct. 2822 if reference_frame == current_frame: 2823 step_transform = transform.inverse() 2824 else: 2825 step_transform = transform 2826 2827 # Compose in reverse order: new_step * accumulated 2828 # This ensures: (T3 * T2 * T1) transforms correctly 2829 composed_transform = step_transform * composed_transform 2830 2831 # Cache the result as a shortcut edge 2832 self._add_cache_edge(source_frame, target_frame, composed_transform, added_edges) 2833 2834 return composed_transform
Get the transform from source_frame to target_frame.
Automatically finds the shortest path and composes transforms. Results are cached as shortcut edges for O(1) subsequent lookups.
Args: source_frame: The source frame ID. target_frame: The target frame ID.
Returns: BaseTransform: The composed transform T_source_to_target.
Raises: ValueError: If either frame doesn't exist or no path exists.
2881 def is_projection_frame(self, frame_id: str) -> bool: 2882 """ 2883 Check if a frame is a 2D projection frame (e.g., an Image frame). 2884 2885 Rule: A frame is a projection frame if ALL edges connected to it treat it 2886 as a projection space. 2887 - If transform maps INTO frame (frame is Target), transform must be a Projection. 2888 - If transform maps OUT OF frame (frame is Source), transform must be an InverseProjection. 2889 """ 2890 if frame_id not in self._graph: 2891 return False 2892 2893 neighbors = list(self._graph.neighbors(frame_id)) 2894 if not neighbors: 2895 return False 2896 2897 for neighbor in neighbors: 2898 edge_data = self._graph[frame_id][neighbor] 2899 transform = edge_data["transform"] 2900 reference_frame = edge_data["reference_frame"] 2901 2902 # Use 'reference_frame' to determine direction. 2903 # reference_frame is the TARGET of the transform. 2904 2905 if reference_frame == frame_id: 2906 # Transform is Neighbor -> Frame (Source -> Target) 2907 # For Frame to be 2D, this must be a Projection (3D -> 2D) 2908 if not isinstance(transform, Projection): 2909 return False 2910 else: 2911 # Transform is Frame -> Neighbor (Source -> Target) 2912 # For Frame to be 2D, this must be an InverseProjection (2D -> 3D) 2913 if not isinstance(transform, InverseProjection): 2914 return False 2915 2916 return True
Check if a frame is a 2D projection frame (e.g., an Image frame).
Rule: A frame is a projection frame if ALL edges connected to it treat it as a projection space.
- If transform maps INTO frame (frame is Target), transform must be a Projection.
- If transform maps OUT OF frame (frame is Source), transform must be an InverseProjection.
2954 def get_essential_matrix(self, image_frame_1: str, image_frame_2: str) -> np.ndarray: 2955 """ 2956 Compute the Essential Matrix E between two image frames. 2957 2958 E = [t]_x R 2959 where R, t describe method to transform points from Camera 1 to Camera 2. 2960 X2 = R X1 + t. 2961 """ 2962 _, c1 = self._get_camera_intrinsics_and_pose(image_frame_1) 2963 _, c2 = self._get_camera_intrinsics_and_pose(image_frame_2) 2964 2965 # Get transform from C1 to C2 2966 # T_c1_to_c2: converts X_c1 to X_c2. 2967 T_12 = self.get_transform(c1, c2) 2968 if not isinstance(T_12, Transform): 2969 raise ValueError(f"Transform between '{c1}' and '{c2}' is not a spatial Transform.") 2970 2971 R = quaternion.as_rotation_matrix(T_12.rotation) 2972 t = T_12.translation.flatten() 2973 2974 # Skew symmetric matrix of t 2975 t_x = skew(t) 2976 2977 return t_x @ R
Compute the Essential Matrix E between two image frames.
E = [t]_x R where R, t describe method to transform points from Camera 1 to Camera 2. X2 = R X1 + t.
2979 def get_fundamental_matrix(self, image_frame_1: str, image_frame_2: str) -> np.ndarray: 2980 """ 2981 Compute the Fundamental Matrix F between two image frames. 2982 2983 F = K2^-T E K1^-1 2984 x2^T F x1 = 0 2985 """ 2986 K1, _ = self._get_camera_intrinsics_and_pose(image_frame_1) 2987 K2, _ = self._get_camera_intrinsics_and_pose(image_frame_2) 2988 2989 E = self.get_essential_matrix(image_frame_1, image_frame_2) 2990 2991 K1_inv = np.linalg.inv(K1) 2992 K2_inv = np.linalg.inv(K2) 2993 2994 return K2_inv.T @ E @ K1_inv
Compute the Fundamental Matrix F between two image frames.
F = K2^-T E K1^-1 x2^T F x1 = 0
2996 def get_homography( 2997 self, 2998 image_frame_1: str, 2999 image_frame_2: str, 3000 plane_normal: np.ndarray, 3001 plane_distance: float, 3002 ) -> np.ndarray: 3003 """ 3004 Compute Homography H mapping pixels from image 1 to image 2 induced by a plane. 3005 3006 x2 ~ H x1 3007 3008 Plane equation in Camera 1 frame: n^T X = d 3009 H = K2 (R + t n^T / d) K1^-1 3010 3011 Args: 3012 image_frame_1: Source image frame. 3013 image_frame_2: Target image frame. 3014 plane_normal: Normal vector of the plane in Camera 1 frame (3,). 3015 plane_distance: Distance to the plane in Camera 1 frame (scalar). 3016 """ 3017 K1, c1 = self._get_camera_intrinsics_and_pose(image_frame_1) 3018 K2, c2 = self._get_camera_intrinsics_and_pose(image_frame_2) 3019 3020 # T_12: C1 -> C2 3021 T_12 = self.get_transform(c1, c2) 3022 R = quaternion.as_rotation_matrix(T_12.rotation) 3023 t = T_12.translation.flatten().reshape(3, 1) 3024 3025 n = np.asarray(plane_normal).reshape(3, 1) 3026 d = float(plane_distance) 3027 3028 H_euclidean = R + (t @ n.T) / d 3029 3030 return K2 @ H_euclidean @ np.linalg.inv(K1)
Compute Homography H mapping pixels from image 1 to image 2 induced by a plane.
x2 ~ H x1
Plane equation in Camera 1 frame: n^T X = d H = K2 (R + t n^T / d) K1^-1
Args: image_frame_1: Source image frame. image_frame_2: Target image frame. plane_normal: Normal vector of the plane in Camera 1 frame (3,). plane_distance: Distance to the plane in Camera 1 frame (scalar).
3032 @staticmethod 3033 def estimate_skew(intrinsic_matrix: np.ndarray) -> float: 3034 """ 3035 Estimate the skew parameter from an intrinsic matrix K. 3036 3037 K = [[fx, s, cx], [0, fy, cy], [0, 0, 1]] 3038 Returns s. 3039 """ 3040 return float(intrinsic_matrix[0, 1])
Estimate the skew parameter from an intrinsic matrix K.
K = [[fx, s, cx], [0, fy, cy], [0, 0, 1]] Returns s.
3042 def clear_cache(self) -> None: 3043 """ 3044 Clear all cached shortcut transforms. 3045 3046 Removes edges marked with is_cache=True. 3047 """ 3048 edges_to_remove = [ 3049 (u, v) for u, v, data in self._graph.edges(data=True) if data.get("is_cache", False) 3050 ] 3051 self._graph.remove_edges_from(edges_to_remove) 3052 self._dependency_map.clear()
Clear all cached shortcut transforms.
Removes edges marked with is_cache=True.
3054 def get_connected_components(self) -> list[set[str]]: 3055 """ 3056 Get all connected components in the graph. 3057 3058 Returns: 3059 List of sets, where each set contains frame IDs of a connected component. 3060 """ 3061 return list(nx.connected_components(self._graph))
Get all connected components in the graph.
Returns: List of sets, where each set contains frame IDs of a connected component.
3063 def get_connected_nodes(self, frame_id: str) -> set[str]: 3064 """ 3065 Get the set of all nodes connected to the given frame (its connected component). 3066 3067 Args: 3068 frame_id: The frame to start searching from. 3069 3070 Returns: 3071 Set of connected frame IDs. 3072 3073 Raises: 3074 ValueError: If frame_id is not in the graph. 3075 """ 3076 if frame_id not in self._graph: 3077 raise ValueError(f"Frame '{frame_id}' is not in the graph.") 3078 return nx.node_connected_component(self._graph, frame_id)
Get the set of all nodes connected to the given frame (its connected component).
Args: frame_id: The frame to start searching from.
Returns: Set of connected frame IDs.
Raises: ValueError: If frame_id is not in the graph.
3080 def to_dict(self) -> dict[str, Any]: 3081 """ 3082 Serialize the entire graph to a dictionary. 3083 3084 Returns: 3085 Dict containing 'frames' and 'edges' (only explicit, non-cached edges). 3086 3087 Frame IDs that are not JSON-native (tuples, datetime, UUID) are encoded 3088 as tagged dicts with ``__type__`` and ``value`` keys so they survive 3089 ``json.dumps``/``json.loads`` roundtrip without information loss. 3090 """ 3091 frames = [self._encode_frame_id(f) for f in self.frames] 3092 edges = [] 3093 for u, v, data in self._graph.edges(data=True): 3094 if not data.get("is_cache", False): 3095 transform = data["transform"] 3096 reference = data.get("reference_frame") 3097 source = v if reference == u else u 3098 edges.append( 3099 { 3100 "source": self._encode_frame_id(source), 3101 "target": self._encode_frame_id(reference), 3102 "transform": transform.to_dict(), 3103 } 3104 ) 3105 3106 return {"frames": frames, "edges": edges}
Serialize the entire graph to a dictionary.
Returns: Dict containing 'frames' and 'edges' (only explicit, non-cached edges).
Frame IDs that are not JSON-native (tuples, datetime, UUID) are encoded
as tagged dicts with __type__ and value keys so they survive
json.dumps/json.loads roundtrip without information loss.
3176 @classmethod 3177 def from_dict(cls, data: dict[str, Any]) -> TransformGraph: 3178 """ 3179 Deserialize a graph from a dictionary. 3180 3181 Args: 3182 data: Dictionary produced by to_dict(). 3183 3184 Returns: 3185 New TransformGraph instance. 3186 """ 3187 graph = cls() 3188 for edge_data in data.get("edges", []): 3189 src = cls._decode_frame_id(edge_data["source"]) 3190 tgt = cls._decode_frame_id(edge_data["target"]) 3191 transform = deserialize_transform(edge_data["transform"]) 3192 graph.add_transform(src, tgt, transform) 3193 3194 return graph
Deserialize a graph from a dictionary.
Args: data: Dictionary produced by to_dict().
Returns: New TransformGraph instance.
24def register_transform(cls: type[BaseTransform]) -> type[BaseTransform]: 25 """ 26 Decorator to register a transform class for serialization. 27 28 Usage: 29 @register_transform 30 class MyTransform(BaseTransform): 31 ... 32 """ 33 _TRANSFORM_REGISTRY[cls.__name__] = cls 34 return cls
Decorator to register a transform class for serialization.
Usage: @register_transform class MyTransform(BaseTransform): ...
37def serialize_transform(transform: BaseTransform) -> dict[str, Any]: 38 """ 39 Serialize any transform to a JSON-compatible dictionary. 40 41 Args: 42 transform: Any BaseTransform subclass instance. 43 44 Returns: 45 Dict containing the serialized transform with a "type" key. 46 """ 47 return transform.to_dict()
Serialize any transform to a JSON-compatible dictionary.
Args: transform: Any BaseTransform subclass instance.
Returns: Dict containing the serialized transform with a "type" key.
190def deserialize_transform(data: dict[str, Any]) -> BaseTransform: 191 """ 192 Deserialize a transform from a dictionary. 193 194 Automatically determines the correct class from the "type" field 195 and calls its from_dict() method. 196 197 Args: 198 data: Dictionary previously created by serialize_transform() or to_dict(). 199 200 Returns: 201 BaseTransform: The deserialized transform instance. 202 203 Raises: 204 ValueError: If the transform type is not registered. 205 """ 206 transform_type = data.get("type") 207 if not transform_type: 208 raise ValueError("Missing 'type' field in transform data") 209 210 if transform_type not in _TRANSFORM_REGISTRY: 211 raise ValueError( 212 f"Unknown transform type: '{transform_type}'. " 213 f"Registered types: {list(_TRANSFORM_REGISTRY.keys())}" 214 ) 215 216 cls = _TRANSFORM_REGISTRY[transform_type] 217 return cls.from_dict(data)
Deserialize a transform from a dictionary.
Automatically determines the correct class from the "type" field and calls its from_dict() method.
Args: data: Dictionary previously created by serialize_transform() or to_dict().
Returns: BaseTransform: The deserialized transform instance.
Raises: ValueError: If the transform type is not registered.
50def from_roll_pitch_yaw( 51 roll: float = 0.0, 52 pitch: float = 0.0, 53 yaw: float = 0.0, 54) -> quaternion.quaternion: 55 """ 56 Create a quaternion from roll-pitch-yaw angles. 57 58 Uses the aerospace/robotics intrinsic **ZYX** (Tait-Bryan) convention: 59 yaw (Z) → pitch (Y) → roll (X). 60 61 For other conventions (ZYZ, XYZ, etc.), use scipy directly:: 62 63 from scipy.spatial.transform import Rotation as R 64 q_scipy = R.from_euler('ZYZ', [alpha, beta, gamma]) 65 66 Args: 67 roll: Rotation about X-axis in radians. 68 pitch: Rotation about Y-axis in radians. 69 yaw: Rotation about Z-axis in radians. 70 71 Returns: 72 quaternion.quaternion: The resulting quaternion. 73 74 Warning: 75 This function uses ``scipy.spatial.transform.Rotation`` with true 76 ZYX intrinsic ordering. It is **not** compatible with 77 ``quaternion.from_euler_angles(alpha, beta, gamma)``, which uses 78 ZYZ convention. 79 """ 80 scipy_rot = ScipyRotation.from_euler("ZYX", [yaw, pitch, roll]) 81 # scipy uses [x, y, z, w], numpy-quaternion uses [w, x, y, z] 82 x, y, z, w = scipy_rot.as_quat() 83 return quaternion.quaternion(w, x, y, z)
Create a quaternion from roll-pitch-yaw angles.
Uses the aerospace/robotics intrinsic ZYX (Tait-Bryan) convention: yaw (Z) → pitch (Y) → roll (X).
For other conventions (ZYZ, XYZ, etc.), use scipy directly::
from scipy.spatial.transform import Rotation as R
q_scipy = R.from_euler('ZYZ', [alpha, beta, gamma])
Args: roll: Rotation about X-axis in radians. pitch: Rotation about Y-axis in radians. yaw: Rotation about Z-axis in radians.
Returns: quaternion.quaternion: The resulting quaternion.
Warning:
This function uses scipy.spatial.transform.Rotation with true
ZYX intrinsic ordering. It is not compatible with
quaternion.from_euler_angles(alpha, beta, gamma), which uses
ZYZ convention.
86def as_roll_pitch_yaw( 87 q: quaternion.quaternion, 88) -> tuple[float, float, float]: 89 """ 90 Extract roll, pitch, yaw from a quaternion. 91 92 Uses the aerospace/robotics intrinsic **ZYX** (Tait-Bryan) convention. 93 94 For other conventions (ZYZ, XYZ, etc.), use scipy directly:: 95 96 from scipy.spatial.transform import Rotation as R 97 angles = R.from_quat([q.x, q.y, q.z, q.w]).as_euler('ZYZ') 98 99 Args: 100 q: The input quaternion. 101 102 Returns: 103 Tuple[float, float, float]: ``(roll, pitch, yaw)`` in radians. 104 """ 105 scipy_rot = ScipyRotation.from_quat([q.x, q.y, q.z, q.w]) 106 yaw, pitch, roll = scipy_rot.as_euler("ZYX") 107 return (roll, pitch, yaw)
Extract roll, pitch, yaw from a quaternion.
Uses the aerospace/robotics intrinsic ZYX (Tait-Bryan) convention.
For other conventions (ZYZ, XYZ, etc.), use scipy directly::
from scipy.spatial.transform import Rotation as R
angles = R.from_quat([q.x, q.y, q.z, q.w]).as_euler('ZYZ')
Args: q: The input quaternion.
Returns:
Tuple[float, float, float]: (roll, pitch, yaw) in radians.
2256class Pose: 2257 """ 2258 A user-friendly wrapper around Transform for pose representation. 2259 2260 Represents the pose of 'child_frame_id' relative to 'frame_id'. 2261 """ 2262 2263 def __init__( 2264 self, 2265 position: np.ndarray | list | tuple | None = None, 2266 orientation: quaternion.quaternion | np.ndarray | list | tuple | None = None, 2267 frame_id: str | int | UUID | None = None, 2268 child_frame_id: str | int | UUID | None = None, 2269 ): 2270 # Use ensure_rotation logic but for rvec too 2271 quat = quaternion.one 2272 if orientation is not None: 2273 if isinstance(orientation, quaternion.quaternion): 2274 quat = orientation.normalized() 2275 elif isinstance(orientation, (list, tuple, np.ndarray)): 2276 arr = np.array(orientation, dtype=np.float64).flatten() 2277 if arr.size == 4: 2278 quat = quaternion.quaternion(*arr).normalized() 2279 elif arr.size == 3: 2280 # Rotation vector 2281 theta = np.linalg.norm(arr) 2282 if theta > 1e-8: 2283 quat = quaternion.from_rotation_vector(arr) 2284 else: 2285 raise ValueError("Orientation must be quaternion (4) or rvec (3)") 2286 2287 self._transform = Transform(translation=position, rotation=quat) 2288 self.frame_id = frame_id 2289 self.child_frame_id = child_frame_id 2290 2291 @property 2292 def position(self) -> np.ndarray: 2293 """The 3D position [x, y, z] in the parent frame.""" 2294 return self._transform.translation.flatten() 2295 2296 @position.setter 2297 def position(self, value: np.ndarray | list | tuple): 2298 """Set the 3D position [x, y, z].""" 2299 self._transform.translation = ensure_translation(value, self._transform.dtype) 2300 2301 @property 2302 def orientation(self) -> quaternion.quaternion: 2303 """The orientation as a unit quaternion.""" 2304 return self._transform.rotation 2305 2306 @orientation.setter 2307 def orientation(self, value: quaternion.quaternion | np.ndarray | list | tuple): 2308 """Set the orientation from a quaternion or [w, x, y, z] array.""" 2309 self._transform.rotation = ensure_rotation(value, self._transform.dtype) 2310 2311 def as_transform(self) -> Transform: 2312 """Returns the underlying Transform object.""" 2313 return self._transform 2314 2315 @classmethod 2316 def from_transform( 2317 cls, 2318 tf: Transform, 2319 frame_id: str | int | UUID | None = None, 2320 child_frame_id: str | int | UUID | None = None, 2321 ) -> Pose: 2322 """Creates a Pose from a Transform.""" 2323 return cls( 2324 position=tf.translation.flatten(), 2325 orientation=tf.rotation, 2326 frame_id=frame_id, 2327 child_frame_id=child_frame_id, 2328 ) 2329 2330 def inverse( 2331 self, 2332 new_frame_id: str | int | UUID | None = None, 2333 new_child_frame_id: str | int | UUID | None = None, 2334 ) -> Pose: 2335 """ 2336 Returns the inverse pose. 2337 2338 By default, swaps frame_id and child_frame_id: 2339 Inverse(T_A->B) = T_B->A 2340 """ 2341 # Default behavior: swap frames 2342 target_frame_id = new_frame_id if new_frame_id is not None else self.child_frame_id 2343 target_child_frame_id = ( 2344 new_child_frame_id if new_child_frame_id is not None else self.frame_id 2345 ) 2346 2347 return Pose.from_transform( 2348 self._transform.inverse(), 2349 frame_id=target_frame_id, 2350 child_frame_id=target_child_frame_id, 2351 ) 2352 2353 def compose(self, other): 2354 """Returns self * other""" 2355 # Logic: T_A_C = T_A_B * T_B_C 2356 new_frame_id = self.frame_id 2357 new_child_frame_id = None 2358 2359 if isinstance(other, Pose): 2360 # Strict Frame Check 2361 # Only check if both are explicitly defined (not None) 2362 if ( 2363 self.child_frame_id is not None 2364 and other.frame_id is not None 2365 and self.child_frame_id != other.frame_id 2366 ): 2367 raise ValueError( 2368 f"Frame mismatch in composition: " 2369 f"Pose 1 ends in '{self.child_frame_id}' but " 2370 f"Pose 2 starts in '{other.frame_id}'." 2371 ) 2372 2373 new_child_frame_id = other.child_frame_id 2374 return Pose.from_transform( 2375 self._transform * other.as_transform(), 2376 frame_id=new_frame_id, 2377 child_frame_id=new_child_frame_id, 2378 ) 2379 2380 return Pose.from_transform( 2381 self._transform * other, frame_id=new_frame_id, child_frame_id=new_child_frame_id 2382 ) 2383 2384 def __mul__(self, other: Pose | Transform) -> Pose: 2385 return self.compose(other) 2386 2387 def to_list(self) -> list[float]: 2388 """Returns [px, py, pz, qw, qx, qy, qz]""" 2389 q = self.orientation 2390 p = self.position 2391 return [ 2392 float(p[0]), 2393 float(p[1]), 2394 float(p[2]), 2395 float(q.w), 2396 float(q.x), 2397 float(q.y), 2398 float(q.z), 2399 ] 2400 2401 def to_matrix(self) -> np.ndarray: 2402 """Return the 4x4 homogeneous transformation matrix.""" 2403 return self._transform.as_matrix() 2404 2405 def __repr__(self) -> str: 2406 elements = [f"position={self.position!r}", f"orientation={self.orientation!r}"] 2407 if self.frame_id: 2408 elements.append(f"frame_id={self.frame_id!r}") 2409 if self.child_frame_id: 2410 elements.append(f"child_frame_id={self.child_frame_id!r}") 2411 return f"Pose({', '.join(elements)})"
A user-friendly wrapper around Transform for pose representation.
Represents the pose of 'child_frame_id' relative to 'frame_id'.
2263 def __init__( 2264 self, 2265 position: np.ndarray | list | tuple | None = None, 2266 orientation: quaternion.quaternion | np.ndarray | list | tuple | None = None, 2267 frame_id: str | int | UUID | None = None, 2268 child_frame_id: str | int | UUID | None = None, 2269 ): 2270 # Use ensure_rotation logic but for rvec too 2271 quat = quaternion.one 2272 if orientation is not None: 2273 if isinstance(orientation, quaternion.quaternion): 2274 quat = orientation.normalized() 2275 elif isinstance(orientation, (list, tuple, np.ndarray)): 2276 arr = np.array(orientation, dtype=np.float64).flatten() 2277 if arr.size == 4: 2278 quat = quaternion.quaternion(*arr).normalized() 2279 elif arr.size == 3: 2280 # Rotation vector 2281 theta = np.linalg.norm(arr) 2282 if theta > 1e-8: 2283 quat = quaternion.from_rotation_vector(arr) 2284 else: 2285 raise ValueError("Orientation must be quaternion (4) or rvec (3)") 2286 2287 self._transform = Transform(translation=position, rotation=quat) 2288 self.frame_id = frame_id 2289 self.child_frame_id = child_frame_id
2291 @property 2292 def position(self) -> np.ndarray: 2293 """The 3D position [x, y, z] in the parent frame.""" 2294 return self._transform.translation.flatten()
The 3D position [x, y, z] in the parent frame.
2301 @property 2302 def orientation(self) -> quaternion.quaternion: 2303 """The orientation as a unit quaternion.""" 2304 return self._transform.rotation
The orientation as a unit quaternion.
2311 def as_transform(self) -> Transform: 2312 """Returns the underlying Transform object.""" 2313 return self._transform
Returns the underlying Transform object.
2315 @classmethod 2316 def from_transform( 2317 cls, 2318 tf: Transform, 2319 frame_id: str | int | UUID | None = None, 2320 child_frame_id: str | int | UUID | None = None, 2321 ) -> Pose: 2322 """Creates a Pose from a Transform.""" 2323 return cls( 2324 position=tf.translation.flatten(), 2325 orientation=tf.rotation, 2326 frame_id=frame_id, 2327 child_frame_id=child_frame_id, 2328 )
Creates a Pose from a Transform.
2330 def inverse( 2331 self, 2332 new_frame_id: str | int | UUID | None = None, 2333 new_child_frame_id: str | int | UUID | None = None, 2334 ) -> Pose: 2335 """ 2336 Returns the inverse pose. 2337 2338 By default, swaps frame_id and child_frame_id: 2339 Inverse(T_A->B) = T_B->A 2340 """ 2341 # Default behavior: swap frames 2342 target_frame_id = new_frame_id if new_frame_id is not None else self.child_frame_id 2343 target_child_frame_id = ( 2344 new_child_frame_id if new_child_frame_id is not None else self.frame_id 2345 ) 2346 2347 return Pose.from_transform( 2348 self._transform.inverse(), 2349 frame_id=target_frame_id, 2350 child_frame_id=target_child_frame_id, 2351 )
Returns the inverse pose.
By default, swaps frame_id and child_frame_id: Inverse(T_A->B) = T_B->A
2353 def compose(self, other): 2354 """Returns self * other""" 2355 # Logic: T_A_C = T_A_B * T_B_C 2356 new_frame_id = self.frame_id 2357 new_child_frame_id = None 2358 2359 if isinstance(other, Pose): 2360 # Strict Frame Check 2361 # Only check if both are explicitly defined (not None) 2362 if ( 2363 self.child_frame_id is not None 2364 and other.frame_id is not None 2365 and self.child_frame_id != other.frame_id 2366 ): 2367 raise ValueError( 2368 f"Frame mismatch in composition: " 2369 f"Pose 1 ends in '{self.child_frame_id}' but " 2370 f"Pose 2 starts in '{other.frame_id}'." 2371 ) 2372 2373 new_child_frame_id = other.child_frame_id 2374 return Pose.from_transform( 2375 self._transform * other.as_transform(), 2376 frame_id=new_frame_id, 2377 child_frame_id=new_child_frame_id, 2378 ) 2379 2380 return Pose.from_transform( 2381 self._transform * other, frame_id=new_frame_id, child_frame_id=new_child_frame_id 2382 )
Returns self * other
2387 def to_list(self) -> list[float]: 2388 """Returns [px, py, pz, qw, qx, qy, qz]""" 2389 q = self.orientation 2390 p = self.position 2391 return [ 2392 float(p[0]), 2393 float(p[1]), 2394 float(p[2]), 2395 float(q.w), 2396 float(q.x), 2397 float(q.y), 2398 float(q.z), 2399 ]
Returns [px, py, pz, qw, qx, qy, qz]
2414def transform_points( 2415 points: np.ndarray, 2416 transform_object: BaseTransform | TransformGraph, 2417 source_frame: str | None = None, 2418 target_frame: str | None = None, 2419) -> np.ndarray: 2420 """ 2421 Applies a transformation to a set of 3D points. 2422 2423 Supports polymorphic second argument: 2424 1. transform_points(points, transform) 2425 Directly applies a transform object. 2426 2. transform_points(points, graph, source_frame, target_frame) 2427 Uses the graph to find the transform from source to target. 2428 If target is a projection frame (e.g. image), returns unnormalized 3D 2429 coordinates [u*z, v*z, z]. 2430 2431 Args: 2432 points: Nx3 points array. 2433 transform_object: BaseTransform object OR TransformGraph. 2434 source_frame: Source frame ID (required if using graph). 2435 target_frame: Target frame ID (required if using graph). 2436 2437 Returns: 2438 np.ndarray: Nx3 array of transformed points. 2439 """ 2440 points = np.atleast_2d(points) 2441 2442 # CASE 1: TransformGraph 2443 if hasattr(transform_object, "get_transform"): 2444 graph: TransformGraph = transform_object # type: ignore 2445 if source_frame is None or target_frame is None: 2446 raise ValueError( 2447 "When using TransformGraph, both 'source_frame' and " 2448 "'target_frame' must be provided. " 2449 "Usage: transform_points(points, graph, " 2450 "source_frame='A', target_frame='B')" 2451 ) 2452 transform = graph.get_transform(source_frame, target_frame) 2453 # Recurse with resolved transform 2454 return transform_points(points, transform) 2455 2456 # CASE 2: BaseTransform 2457 elif isinstance(transform_object, BaseTransform): 2458 transform = transform_object 2459 2460 # Check for projection (special handling for "transform_points" vs "project_points") 2461 if isinstance(transform, Projection): 2462 # CameraProjection needs full model-dispatched projection (distortion-aware). 2463 # Other Projection subclasses (e.g., OrthographicProjection) are linear. 2464 if isinstance(transform, CameraProjection): 2465 pts = points 2466 if pts.shape[1] == 4: 2467 pts = pts[:, :3] / pts[:, 3:4] 2468 elif pts.shape[1] != 3: 2469 raise ValueError("Points must be Nx3 or Nx4") 2470 2471 z = pts[:, 2] 2472 uv = transform._apply(pts) # full model projection → [u, v] 2473 return np.column_stack([uv[:, 0] * z, uv[:, 1] * z, z]) 2474 else: 2475 # Linear projection (OrthographicProjection etc.) — use matrix path 2476 N = points.shape[0] 2477 if points.shape[1] == 3: 2478 hom_points = np.hstack([points, np.ones((N, 1), dtype=transform.dtype)]) 2479 elif points.shape[1] == 4: 2480 hom_points = points 2481 else: 2482 raise ValueError("Points must be Nx3 or Nx4") 2483 res_hom = (transform.as_matrix() @ hom_points.T).T 2484 return res_hom[:, :3] 2485 2486 if not isinstance( 2487 transform, 2488 (Transform, Rotation, Translation, Identity, MatrixTransform, InverseProjection), 2489 ): 2490 raise TypeError( 2491 f"Unsupported transform type: {type(transform).__name__}. " 2492 "Supported: Rigid transforms, InverseProjection, or Projection " 2493 "(for unnormalized 3D output)." 2494 ) 2495 # InverseProjection accepts Nx2 (pixel coords) → Nx3 2496 if isinstance(transform, InverseProjection) and points.shape[1] == 2: 2497 return transform._apply(points) 2498 2499 if points.shape[1] == 3: 2500 hom_points = np.hstack([points, np.ones((points.shape[0], 1), dtype=transform.dtype)]) 2501 transformed_hom = (transform.as_matrix() @ hom_points.T).T 2502 return transformed_hom[:, :3] 2503 elif points.shape[1] == 4: 2504 transformed = (transform.as_matrix() @ points.T).T 2505 return transformed 2506 else: 2507 raise ValueError("Points must be Nx2 (for InverseProjection), Nx3, or Nx4") 2508 2509 else: 2510 obj_type = type(transform_object).__name__ 2511 raise TypeError(f"transform_object must be BaseTransform or TransformGraph, got {obj_type}")
Applies a transformation to a set of 3D points.
Supports polymorphic second argument:
- transform_points(points, transform) Directly applies a transform object.
- transform_points(points, graph, source_frame, target_frame) Uses the graph to find the transform from source to target. If target is a projection frame (e.g. image), returns unnormalized 3D coordinates [uz, vz, z].
Args: points: Nx3 points array. transform_object: BaseTransform object OR TransformGraph. source_frame: Source frame ID (required if using graph). target_frame: Target frame ID (required if using graph).
Returns: np.ndarray: Nx3 array of transformed points.
2514def project_points( 2515 points: np.ndarray, 2516 transform_object: BaseTransform | TransformGraph, 2517 source_frame: str | None = None, 2518 target_frame: str | None = None, 2519) -> np.ndarray: 2520 """ 2521 Projects 3D points to 2D coordinates (homogenized). 2522 2523 Signatures: 2524 1. project_points(points, projection_transform) 2525 2. project_points(points, graph, source_frame, target_frame) 2526 2527 Returns: 2528 np.ndarray: Nx2 array of pixel coordinates [u, v]. 2529 """ 2530 points = np.atleast_2d(points) 2531 2532 # CASE 1: TransformGraph 2533 if hasattr(transform_object, "get_transform"): 2534 graph: TransformGraph = transform_object # type: ignore 2535 if source_frame is None or target_frame is None: 2536 raise ValueError( 2537 "When using TransformGraph, both 'source_frame' and " 2538 "'target_frame' must be provided." 2539 ) 2540 transform = graph.get_transform(source_frame, target_frame) 2541 return project_points(points, transform) 2542 2543 # CASE 2: BaseTransform 2544 elif isinstance(transform_object, BaseTransform): 2545 transform = transform_object 2546 2547 # If transform is Projection, _apply() does homogenization (div by z) -> 2D 2548 if isinstance(transform, Projection): 2549 return transform._apply(points) 2550 elif isinstance(transform, (Transform, Rotation, Translation, Identity, MatrixTransform)): 2551 raise TypeError( 2552 "Cannot project_points using a rigid transform. " 2553 "Target frame must be a projection frame." 2554 ) 2555 else: 2556 # Try _apply anyway if it supports it? 2557 res = transform._apply(points) 2558 if res.shape[1] != 2: 2559 raise ValueError( 2560 f"Transform returned {res.shape[1]}D points, expected 2D for project_points." 2561 ) 2562 return res 2563 2564 else: 2565 obj_type = type(transform_object).__name__ 2566 raise TypeError(f"transform_object must be BaseTransform or TransformGraph, got {obj_type}")
Projects 3D points to 2D coordinates (homogenized).
Signatures:
- project_points(points, projection_transform)
- project_points(points, graph, source_frame, target_frame)
Returns: np.ndarray: Nx2 array of pixel coordinates [u, v].