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]
class BaseTransform(abc.ABC):
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.

dtype
@abstractmethod
def as_matrix() -> numpy.ndarray:
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.

@abstractmethod
def inverse() -> BaseTransform:
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.

@abstractmethod
def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
@abstractmethod
def from_dict(cls, data: dict[str, typing.Any]) -> BaseTransform:
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.

@register_transform
class Transform(tgraph.BaseTransform):
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).

Transform( translation: numpy.ndarray | list | tuple | None = None, rotation: quaternion.quaternion | numpy.ndarray | list | tuple | Transform | None = None, dtype: numpy.dtype = <class 'numpy.float64'>)
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)
translation
rotation
@classmethod
def from_matrix( cls, matrix: numpy.ndarray, dtype: numpy.dtype | None = None) -> Transform:
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.

@classmethod
def from_rotation_matrix( cls, rotation_matrix: numpy.ndarray, t: numpy.ndarray | list | tuple | None = None, *, validate: bool = True, dtype: numpy.dtype = <class 'numpy.float64'>) -> Transform:
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])

@classmethod
def from_quaternion( cls, q: quaternion.quaternion | numpy.ndarray | list | tuple, t: numpy.ndarray | list | tuple | None = None, *, convention: str = 'wxyz', dtype: numpy.dtype = <class 'numpy.float64'>) -> Transform:
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")

@classmethod
def from_axis_angle( cls, axis: numpy.ndarray | list | tuple, angle: float, t: numpy.ndarray | list | tuple | None = None, *, dtype: numpy.dtype = <class 'numpy.float64'>) -> Transform:
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)

def as_matrix(self) -> numpy.ndarray:
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].

def inverse(self) -> Transform:
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].

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> Transform:
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.

class Translation(tgraph.Transform):
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).

Translation( x: float = 0.0, y: float = 0.0, z: float = 0.0, translation: numpy.ndarray | list | tuple | None = None)
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])
class Rotation(tgraph.Transform):
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) ...
Rotation( w: float = 1.0, x: float = 0.0, y: float = 0.0, z: float = 0.0, rotation: quaternion.quaternion | numpy.ndarray | list | tuple | None = None)
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])
@classmethod
def from_roll_pitch_yaw( cls, roll: float = 0.0, pitch: float = 0.0, yaw: float = 0.0) -> Rotation:
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)

def as_roll_pitch_yaw(self) -> tuple[float, float, float]:
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()

@classmethod
def from_rotation_matrix( cls, rotation_matrix: numpy.ndarray, *, validate: bool = True) -> Rotation:
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.

@classmethod
def from_quaternion( cls, q: quaternion.quaternion | numpy.ndarray | list | tuple, *, convention: str = 'wxyz') -> Rotation:
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.

@classmethod
def from_axis_angle( cls, axis: numpy.ndarray | list | tuple, angle: float) -> Rotation:
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.

class Identity(tgraph.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).

@register_transform
class MatrixTransform(tgraph.BaseTransform):
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.

MatrixTransform(matrix: numpy.ndarray, dtype: numpy.dtype = <class 'numpy.float64'>)
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)
matrix
def as_matrix(self) -> numpy.ndarray:
912    def as_matrix(self) -> np.ndarray:
913        """Return the stored 4x4 matrix."""
914        return self.matrix

Return the stored 4x4 matrix.

def inverse(self) -> MatrixTransform:
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.

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> MatrixTransform:
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)

Deserialize transform from a dictionary.

@register_transform
class Projection(tgraph.BaseTransform):
 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.

Projection( matrix: numpy.ndarray | list, dtype: numpy.dtype = <class 'numpy.float64'>)
 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.

matrix
def as_matrix(self) -> numpy.ndarray:
1005    def as_matrix(self) -> np.ndarray:
1006        """Returns the 4x4 projection matrix."""
1007        return self.matrix

Returns the 4x4 projection matrix.

def as_matrix_3x4(self) -> numpy.ndarray:
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).

def inverse(self) -> InverseProjection:
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.

def project_points(self, points: numpy.ndarray | list | tuple) -> numpy.ndarray:
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.

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> Projection:
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)

Deserialize projection from a dictionary.

@register_transform
class InverseProjection(tgraph.BaseTransform):
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
InverseProjection( original_matrix: numpy.ndarray | list, dtype: numpy.dtype = <class 'numpy.float64'>)
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.

original_matrix: numpy.ndarray
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.

def as_matrix(self) -> numpy.ndarray:
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.

def inverse(self) -> Projection:
1153    def inverse(self) -> Projection:
1154        """Returns the original Projection."""
1155        return Projection(self._original_matrix, dtype=self.dtype)

Returns the original Projection.

def unproject(self, pixels: numpy.ndarray, depths: numpy.ndarray) -> numpy.ndarray:
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.

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> InverseProjection:
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.

@register_transform
class CameraProjection(tgraph.Projection):
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

CameraProjection( intrinsic_matrix: numpy.ndarray | list | None = None, dist_coeffs: list | numpy.ndarray | None = None, projection_model: ProjectionModel | str | None = None, image_size: tuple[int, int] | None = None, dtype: numpy.dtype = <class 'numpy.float64'>, K: numpy.ndarray | list | None = None, D: list | numpy.ndarray | None = None)
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.

@classmethod
def from_intrinsics_and_transform(cls, *args, **kwargs):
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.

intrinsic_matrix: numpy.ndarray
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.

fx: float
1368    @property
1369    def fx(self) -> float:
1370        """Focal length x."""
1371        return float(self._intrinsic_matrix[0, 0])

Focal length x.

fy: float
1373    @property
1374    def fy(self) -> float:
1375        """Focal length y."""
1376        return float(self._intrinsic_matrix[1, 1])

Focal length y.

cx: float
1378    @property
1379    def cx(self) -> float:
1380        """Principal point x."""
1381        return float(self._intrinsic_matrix[0, 2])

Principal point x.

cy: float
1383    @property
1384    def cy(self) -> float:
1385        """Principal point y."""
1386        return float(self._intrinsic_matrix[1, 2])

Principal point y.

focal_length: tuple[float, float]
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.

principal_point: tuple[float, float]
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.

dist_coeffs: numpy.ndarray
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, ...).

distortion_coefficients: numpy.ndarray
1403    @property
1404    def distortion_coefficients(self) -> np.ndarray:
1405        """Alias for dist_coeffs."""
1406        return self._dist_coeffs

Alias for dist_coeffs.

projection_model: ProjectionModel
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.).

image_size: tuple[int, int] | None
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.

K: numpy.ndarray
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).

D: numpy.ndarray
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).

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> CameraProjection:
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.

def inverse(self) -> InverseCameraProjection:
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.

@register_transform
class InverseCameraProjection(tgraph.InverseProjection):
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.

InverseCameraProjection(camera_projection: CameraProjection)
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.

camera_projection: CameraProjection
1732    @property
1733    def camera_projection(self) -> CameraProjection:
1734        """The original CameraProjection instance."""
1735        return self._camera_projection

The original CameraProjection instance.

fx: float
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.

fy: float
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.

cx: float
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.

cy: float
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.

intrinsic_matrix: numpy.ndarray
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.

def inverse(self) -> CameraProjection:
1763    def inverse(self) -> CameraProjection:
1764        """Returns the original CameraProjection."""
1765        return self._camera_projection

Returns the original CameraProjection.

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict( cls, data: dict[str, typing.Any]) -> InverseCameraProjection:
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.

@register_transform
class OrthographicProjection(tgraph.Projection):
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.

OrthographicProjection( axis: str = 'top', u_range: tuple[float, float] = (-50.0, 50.0), v_range: tuple[float, float] = (-50.0, 50.0), resolution: float = 0.1, dtype: numpy.dtype = <class 'numpy.float64'>)
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.

axis: str
1881    @property
1882    def axis(self) -> str:
1883        """Projection axis preset."""
1884        return self._axis

Projection axis preset.

u_range: tuple[float, float]
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).

v_range: tuple[float, float]
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).

resolution: float
1896    @property
1897    def resolution(self) -> float:
1898        """Metres per pixel."""
1899        return self._resolution

Metres per pixel.

grid_shape: tuple[int, int]
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.

origin_pixel: tuple[int, int]
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).

def project_points(self, points: numpy.ndarray | list | tuple) -> numpy.ndarray:
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().

def inverse(self) -> InverseOrthographicProjection:
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).

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict( cls, data: dict[str, typing.Any]) -> OrthographicProjection:
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.

@register_transform
class InverseOrthographicProjection(tgraph.InverseProjection):
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).

InverseOrthographicProjection(ortho: OrthographicProjection)
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.

orthographic_projection: OrthographicProjection
2007    @property
2008    def orthographic_projection(self) -> OrthographicProjection:
2009        """The original OrthographicProjection."""
2010        return self._ortho

The original OrthographicProjection.

def inverse(self) -> OrthographicProjection:
2052    def inverse(self) -> OrthographicProjection:
2053        """Return the original OrthographicProjection."""
2054        return self._ortho

Return the original OrthographicProjection.

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict( cls, data: dict[str, typing.Any]) -> InverseOrthographicProjection:
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.

@register_transform
class CompositeProjection(tgraph.Projection):
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)
CompositeProjection( projection: Projection, transform: Transform, dtype: numpy.dtype = <class 'numpy.float64'>)
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.

projection: Projection
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).

transform: Transform
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).

def inverse(self) -> InverseCompositeProjection:
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).

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> CompositeProjection:
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.

@register_transform
class InverseCompositeProjection(tgraph.InverseProjection):
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)
InverseCompositeProjection( transform: Transform, projection: InverseProjection, dtype: numpy.dtype = <class 'numpy.float64'>)
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.

transform: Transform
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).

projection: InverseProjection
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).

def as_matrix(self) -> numpy.ndarray:
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.

def inverse(self) -> CompositeProjection:
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).

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict( cls, data: dict[str, typing.Any]) -> InverseCompositeProjection:
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.

class ProjectionModel(enum.Enum):
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, ROS kannala_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.
Pinhole = <ProjectionModel.Pinhole: 'Pinhole'>
BrownConrady = <ProjectionModel.BrownConrady: 'BrownConrady'>
KannalaBrandt = <ProjectionModel.KannalaBrandt: 'KannalaBrandt'>
Division = <ProjectionModel.Division: 'Division'>
Rational = <ProjectionModel.Rational: 'Rational'>
Fisheye62 = <ProjectionModel.Fisheye62: 'Fisheye62'>
MeiUnified = <ProjectionModel.MeiUnified: 'MeiUnified'>
@classmethod
def from_string(cls, model_str: str) -> ProjectionModel:
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.

class TransformGraph:
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

ADDED_EDGE_WEIGHT = 1.0
CACHED_EDGE_WEIGHT = 1.0
graph: networkx.classes.graph.Graph
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).

frames: list[str]
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.

edges: list[tuple[str, str]]
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.

def has_frame(self, frame_id: str) -> bool:
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.

def has_transform(self, source_frame: str, target_frame: str) -> bool:
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.

def add_transform( self, source_frame: str, target_frame: str, transform: BaseTransform) -> None:
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.

def update_transform( self, source_frame: str, target_frame: str, transform: BaseTransform) -> None:
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.

def remove_transform(self, frame_a: str, frame_b: str) -> None:
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.

def get_transform( self, source_frame: str, target_frame: str) -> BaseTransform:
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.

def is_projection_frame(self, frame_id: str) -> bool:
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.
def get_essential_matrix(self, image_frame_1: str, image_frame_2: str) -> numpy.ndarray:
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.

def get_fundamental_matrix(self, image_frame_1: str, image_frame_2: str) -> numpy.ndarray:
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

def get_homography( self, image_frame_1: str, image_frame_2: str, plane_normal: numpy.ndarray, plane_distance: float) -> numpy.ndarray:
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).

@staticmethod
def estimate_skew(intrinsic_matrix: numpy.ndarray) -> float:
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.

def clear_cache(self) -> None:
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.

def get_connected_components(self) -> list[set[str]]:
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.

def get_connected_nodes(self, frame_id: str) -> set[str]:
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.

def to_dict(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]) -> TransformGraph:
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.

def register_transform( cls: type[BaseTransform]) -> type[BaseTransform]:
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): ...

def serialize_transform(transform: BaseTransform) -> dict[str, typing.Any]:
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.

def deserialize_transform(data: dict[str, typing.Any]) -> BaseTransform:
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.

def from_roll_pitch_yaw( roll: float = 0.0, pitch: float = 0.0, yaw: float = 0.0) -> quaternion.quaternion:
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.

def as_roll_pitch_yaw(q: quaternion.quaternion) -> tuple[float, float, float]:
 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.

class Pose:
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'.

Pose( position: numpy.ndarray | list | tuple | None = None, orientation: quaternion.quaternion | numpy.ndarray | list | tuple | None = None, frame_id: str | int | uuid.UUID | None = None, child_frame_id: str | int | uuid.UUID | None = None)
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
frame_id
child_frame_id
position: numpy.ndarray
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.

orientation: quaternion.quaternion
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.

def as_transform(self) -> Transform:
2311    def as_transform(self) -> Transform:
2312        """Returns the underlying Transform object."""
2313        return self._transform

Returns the underlying Transform object.

@classmethod
def from_transform( cls, tf: Transform, frame_id: str | int | uuid.UUID | None = None, child_frame_id: str | int | uuid.UUID | None = None) -> Pose:
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.

def inverse( self, new_frame_id: str | int | uuid.UUID | None = None, new_child_frame_id: str | int | uuid.UUID | None = None) -> Pose:
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

def compose(self, other):
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

def to_list(self) -> list[float]:
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]

def to_matrix(self) -> numpy.ndarray:
2401    def to_matrix(self) -> np.ndarray:
2402        """Return the 4x4 homogeneous transformation matrix."""
2403        return self._transform.as_matrix()

Return the 4x4 homogeneous transformation matrix.

def transform_points( points: numpy.ndarray, transform_object: BaseTransform | TransformGraph, source_frame: str | None = None, target_frame: str | None = None) -> numpy.ndarray:
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:

  1. transform_points(points, transform) Directly applies a transform object.
  2. 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.

def project_points( points: numpy.ndarray, transform_object: BaseTransform | TransformGraph, source_frame: str | None = None, target_frame: str | None = None) -> numpy.ndarray:
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:

  1. project_points(points, projection_transform)
  2. project_points(points, graph, source_frame, target_frame)

Returns: np.ndarray: Nx2 array of pixel coordinates [u, v].