From a14363924a3a3969ce28c8279bf20d287eb8fba6 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Thu, 21 Aug 2025 19:17:06 -0700 Subject: [PATCH 01/18] refactor: rename registry.py to transforms.py --- frame_transforms/__init__.py | 2 +- frame_transforms/{registry.py => transforms.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename frame_transforms/{registry.py => transforms.py} (100%) diff --git a/frame_transforms/__init__.py b/frame_transforms/__init__.py index fb63b9b..677b8ab 100644 --- a/frame_transforms/__init__.py +++ b/frame_transforms/__init__.py @@ -1,4 +1,4 @@ -from .registry import ( +from .transforms import ( Registry as Registry, InvaidTransformationError as InvaidTransformationError, ) diff --git a/frame_transforms/registry.py b/frame_transforms/transforms.py similarity index 100% rename from frame_transforms/registry.py rename to frame_transforms/transforms.py From 8e3513c1e633eba018b9c29a5a30f7f9696b3b80 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:42:32 -0700 Subject: [PATCH 02/18] feat: Pose and Transform classes --- frame_transforms/__init__.py | 4 +- frame_transforms/transforms.py | 197 +++++++++++++++++++++++++++++---- 2 files changed, 178 insertions(+), 23 deletions(-) diff --git a/frame_transforms/__init__.py b/frame_transforms/__init__.py index 677b8ab..24c1367 100644 --- a/frame_transforms/__init__.py +++ b/frame_transforms/__init__.py @@ -1,5 +1,7 @@ from .transforms import ( + Pose as Pose, + Transform as Transform, Registry as Registry, - InvaidTransformationError as InvaidTransformationError, + InvalidTransformationError as InvalidTransformationError, ) from .utils import make_3d_transformation as make_3d_transformation diff --git a/frame_transforms/transforms.py b/frame_transforms/transforms.py index 645551c..db9511c 100644 --- a/frame_transforms/transforms.py +++ b/frame_transforms/transforms.py @@ -1,18 +1,149 @@ -from typing import Any, Callable, Generic, Hashable, TypeVar from threading import Lock +from typing import Any, Callable, Generic, Hashable, TypeVar +from dataclasses import dataclass import numpy as np - +from scipy.spatial.transform import Rotation # Key to identify coordinate frames in the registry. FrameID_T = TypeVar("FrameID_T", bound=Hashable) Ret_T = TypeVar("Ret_T", bound=Any) -class InvaidTransformationError(Exception): +class InvalidTransformationError(Exception): pass +TransformTarget_t = TypeVar("TransformTarget_t", bound="Transform|Pose|np.ndarray") + + +@dataclass(frozen=True) +class Transform: + """ + Similarly to posetree, this represents an action ("to transform") + to be applied to a pose. + """ + + _translation: np.ndarray + _rotation: Rotation + + def __post_init__(self): + if self._translation.shape != (3,): + raise ValueError("Translation must be a 3D vector.") + if not isinstance(self._rotation, Rotation): + raise TypeError( + "Rotation must be an instance of scipy.spatial.transform.Rotation." + ) + + @property + def translation(self) -> np.ndarray: + """ + Returns the translation vector. + """ + return self._translation.copy() + + @property + def rotation(self) -> Rotation: + """ + Returns the rotation. + """ + return self._rotation + + def as_matrix(self) -> np.ndarray: + """ + Converts the transformation to a 4x4 transformation matrix. + """ + matrix = np.eye(4) + matrix[:3, :3] = self._rotation.as_matrix() + matrix[:3, 3] = self._translation + return matrix + + def _apply_to_pose(self, pose: "Pose[FrameID_T]") -> "Pose[FrameID_T]": + """ + Applies this transformation to a given pose and returns a new pose. + """ + new_translation = self._translation + pose.transform.translation + new_rotation = self._rotation * pose.transform.rotation + + return Pose( + Transform(new_translation, new_rotation), + pose.parent_frame, + pose.registry, + ) + + def __matmul__(self, other: TransformTarget_t) -> TransformTarget_t: + """ + Applies the transformation to another Transform, Pose, 3D vector (point), + or a 4x4 homogeneous transformation matrix. + """ + if isinstance(other, Transform): + new_translation = self._translation + other.translation + new_rotation = self._rotation * other.rotation + return Transform(new_translation, new_rotation) # type: ignore[return-value] + + elif isinstance(other, Pose): + return self._apply_to_pose(other) + + elif isinstance(other, np.ndarray): + match other.shape: + case (4, 4): + return self.as_matrix() @ other + case (3,): + return np.array(self._rotation.apply(other + self._translation)) # type: ignore[return-value] + case _: + raise ValueError( + "Invalid shape for transformation application. Expected (4, 4) or (3,)." + ) + else: + raise TypeError( + "Unsupported type for transformation application. Expected Transform, Pose, or np.ndarray." + ) + + +@dataclass(frozen=True) +class Pose(Generic[FrameID_T]): + """ + Represents a 3D pose with position and orientation. + """ + + transform: Transform + parent_frame: FrameID_T + registry: "Registry[FrameID_T]" + + def __post_init__(self): + if not isinstance(self.transform, Transform): + raise TypeError("Transform must be an instance of Transform.") + if not isinstance(self.registry, Registry): + raise TypeError("Registry must be an instance of Registry.") + + def apply_transform( + self, transform: Transform, new_frame: FrameID_T | None + ) -> "Pose": + """ + Applies a transformation to the pose and returns a new pose. + + The new pose will be attached to the specified new frame, if provided. + If no new frame is specified, the pose remains in its current frame. + """ + new_translation = self.transform.translation + transform.translation + new_rotation = self.transform.rotation * transform.rotation + + return Pose( + Transform(new_translation, new_rotation), + new_frame or self.parent_frame, + self.registry, + ) + + def in_frame(self, frame: FrameID_T) -> "Pose": + """ + Transforms the pose to the specified frame. + I.e., where is the pose in the specified frame? + """ + transform = self.registry.get_transform(self.parent_frame, frame) + new_transform = self.transform @ transform + return Pose(new_transform, frame, self.registry) + + class Registry(Generic[FrameID_T]): """ Registry of coordinate frames and corresponding transforms. @@ -47,7 +178,7 @@ def __init__(self, world_frame: FrameID_T): self._counts_lock = Lock() self._service_queue = Lock() - def get_transform(self, from_frame: FrameID_T, to_frame: FrameID_T) -> np.ndarray: + def get_transform(self, from_frame: FrameID_T, to_frame: FrameID_T) -> Transform: """ Gets the transformation matrix from one frame to another. @@ -56,7 +187,7 @@ def get_transform(self, from_frame: FrameID_T, to_frame: FrameID_T) -> np.ndarra to_frame: The destination frame. Returns: - The transformation matrix from `from_frame` to `to_frame`. + The transformation from `from_frame` to `to_frame`. """ return self._concurrent_read( lambda: self._get_transform_unsafe(from_frame, to_frame) @@ -64,21 +195,22 @@ def get_transform(self, from_frame: FrameID_T, to_frame: FrameID_T) -> np.ndarra def _get_transform_unsafe( self, from_frame: FrameID_T, to_frame: FrameID_T - ) -> np.ndarray: + ) -> Transform: path = self._get_path(from_frame, to_frame) - transformation = np.eye(4) + trans = np.eye(4) for i in range(len(path) - 1): current_frame = path[i] next_frame = path[i + 1] - transformation = ( - transformation @ self._adjacencies[current_frame][next_frame] - ) + trans = trans @ self._adjacencies[current_frame][next_frame] - return transformation + return Transform(trans[:3, 3], Rotation.from_matrix(trans[:3, :3])) def add_transform( - self, from_frame: FrameID_T, to_frame: FrameID_T, transform: np.ndarray + self, + from_frame: FrameID_T, + to_frame: FrameID_T, + transform: Transform | np.ndarray, ): """ Adds a transformation from one frame to another. @@ -88,25 +220,33 @@ def add_transform( Args: from_frame: The source frame. to_frame: The destination frame. - transform: The transformation matrix from `from_frame` to `to_frame`. + transform: The Transform or 4x4 transformation matrix from `from_frame` to `to_frame`. """ self._concurrent_write( lambda: self._add_transform_unsafe(from_frame, to_frame, transform) ) def _add_transform_unsafe( - self, from_frame: FrameID_T, to_frame: FrameID_T, transform: np.ndarray + self, + from_frame: FrameID_T, + to_frame: FrameID_T, + transform: Transform | np.ndarray, ): if from_frame in self._adjacencies and to_frame in self._adjacencies: - raise InvaidTransformationError( + raise InvalidTransformationError( "Both frames already exist in the registry." ) if from_frame not in self._adjacencies and to_frame not in self._adjacencies: - raise InvaidTransformationError( + raise InvalidTransformationError( "At least one of the frames must exist in the registry." ) + if isinstance(transform, Transform): + transform = transform.as_matrix() + elif not (isinstance(transform, np.ndarray) and transform.shape == (4, 4)): + raise ValueError("Transform must be a Transform or a 4x4 numpy array.") + if from_frame not in self._adjacencies: self._adjacencies[from_frame] = {to_frame: transform} self._adjacencies[to_frame][from_frame] = np.linalg.inv(transform) @@ -116,7 +256,12 @@ def _add_transform_unsafe( self._adjacencies[to_frame] = {from_frame: np.linalg.inv(transform)} self._update_paths(to_frame) - def update(self, from_frame: FrameID_T, to_frame: FrameID_T, transform: np.ndarray): + def update( + self, + from_frame: FrameID_T, + to_frame: FrameID_T, + transform: Transform | np.ndarray, + ): """ Updates the transforms of an existing frame. In effect, this moves all children of the given frame as well (e.g., moving a robot base). @@ -134,7 +279,10 @@ def update(self, from_frame: FrameID_T, to_frame: FrameID_T, transform: np.ndarr ) def _update_unsafe( - self, from_frame: FrameID_T, to_frame: FrameID_T, transform: np.ndarray + self, + from_frame: FrameID_T, + to_frame: FrameID_T, + transform: Transform | np.ndarray, ): """ Internal method to update the transformation between two frames. @@ -146,20 +294,25 @@ def _update_unsafe( transform: The new transformation matrix from `from_frame` to `to_frame`. """ if from_frame not in self._adjacencies: - raise InvaidTransformationError( + raise InvalidTransformationError( f"Frame {from_frame} does not exist in the registry." ) if to_frame not in self._adjacencies: - raise InvaidTransformationError( + raise InvalidTransformationError( f"Frame {to_frame} does not exist in the registry." ) if to_frame not in self._adjacencies[from_frame]: - raise InvaidTransformationError( + raise InvalidTransformationError( f"Frame {to_frame} is not attached to {from_frame}." ) + if isinstance(transform, Transform): + transform = transform.as_matrix() + elif not (isinstance(transform, np.ndarray) and transform.shape == (4, 4)): + raise ValueError("Transform must be a Transform or a 4x4 numpy array.") + self._adjacencies[from_frame][to_frame] = transform self._adjacencies[to_frame][from_frame] = np.linalg.inv(transform) @@ -239,6 +392,6 @@ def _get_path(self, from_frame: FrameID_T, to_frame: FrameID_T) -> list[FrameID_ try: return self._paths[from_frame][to_frame] except KeyError: - raise InvaidTransformationError( + raise InvalidTransformationError( f"Either {from_frame} or {to_frame} does not exist in the registry." ) From 75068c75b17d493424f32a722fd22fe5bf2e7b35 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:47:20 -0700 Subject: [PATCH 03/18] feat(example): update to use Transform --- example.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/example.py b/example.py index 31e2e75..2deb2d4 100644 --- a/example.py +++ b/example.py @@ -3,7 +3,12 @@ import numpy as np from scipy.spatial.transform import Rotation -from frame_transforms import Registry, make_3d_transformation, InvaidTransformationError +from frame_transforms import ( + Pose, + Transform, + Registry, + InvalidTransformationError, +) class Frame(Enum): @@ -21,12 +26,12 @@ def make_example_registry(): # Add transformations between frames - world_to_base_transform = make_3d_transformation( + world_to_base_transform = Transform( np.array([0, 1, 0]), Rotation.from_euler("xyz", [0, 0, 0], degrees=True) ) registry.add_transform(Frame.WORLD, Frame.BASE, world_to_base_transform) - base_to_camera_transform = make_3d_transformation( + base_to_camera_transform = Transform( np.array([0, 0, 1]), Rotation.from_euler("xyz", [0, 90, 0], degrees=True) ) registry.add_transform(Frame.BASE, Frame.CAMERA, base_to_camera_transform) @@ -44,7 +49,7 @@ def add_cycle_example(): # Attempt to add a transformation that creates a cycle try: registry.add_transform(Frame.CAMERA, Frame.WORLD, np.zeros(4)) - except InvaidTransformationError: + except InvalidTransformationError: print( "Caught invalid transformation because there is already a path between CAMERA and WORLD." ) @@ -56,12 +61,14 @@ def transitive_transformation_example(): """ registry = make_example_registry() - expected = make_3d_transformation( + expected = Transform( np.array([0, 1, 1]), Rotation.from_euler("xyz", [0, 90, 0], degrees=True) ) actual = registry.get_transform(Frame.WORLD, Frame.CAMERA) - assert np.allclose(actual[:3], expected[:3]), "Position mismatch" - assert np.allclose(actual[3:], expected[3:]), "Rotation mismatch" + assert np.allclose(actual.translation, expected.translation), "Position mismatch" + assert np.allclose( + actual.rotation.as_matrix(), expected.rotation.as_matrix() + ), "Rotation mismatch" print("Transformation from WORLD to CAMERA is correct.") @@ -73,7 +80,7 @@ def update_transformation_example(): registry = make_example_registry() # Update the transformation from WORLD to BASE - new_transform = make_3d_transformation( + new_transform = Transform( np.array([0, 2, 0]), Rotation.from_euler("xyz", [0, 0, 0], degrees=True) ) registry.update(Frame.WORLD, Frame.BASE, new_transform) @@ -81,18 +88,22 @@ def update_transformation_example(): # Attempt to add instead of update the transformation try: registry.add_transform(Frame.WORLD, Frame.BASE, new_transform) - except InvaidTransformationError: + except InvalidTransformationError: print( "Caught invalid transformation because both frames already exist in the registry." ) # Check the updated transformation - expected = make_3d_transformation( + expected = Transform( np.array([0, 2, 1]), Rotation.from_euler("xyz", [0, 90, 0], degrees=True) ) actual = registry.get_transform(Frame.WORLD, Frame.CAMERA) - assert np.allclose(actual[:3], expected[:3]), "Position mismatch after update" - assert np.allclose(actual[3:], expected[3:]), "Rotation mismatch after update" + assert np.allclose( + actual.translation, expected.translation + ), "Position mismatch after update" + assert np.allclose( + actual.rotation.as_matrix(), expected.rotation.as_matrix() + ), "Rotation mismatch after update" print("Transformation from WORLD to CAMERA updated correctly.") From f7274c068e26b94663a7fd2183dd515ad479b2b2 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:05:02 -0700 Subject: [PATCH 04/18] feat(Pose): get_position and get_orientation --- frame_transforms/transforms.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frame_transforms/transforms.py b/frame_transforms/transforms.py index db9511c..da471ef 100644 --- a/frame_transforms/transforms.py +++ b/frame_transforms/transforms.py @@ -143,6 +143,32 @@ def in_frame(self, frame: FrameID_T) -> "Pose": new_transform = self.transform @ transform return Pose(new_transform, frame, self.registry) + def get_position(self, frame: FrameID_T | None) -> np.ndarray: + """ + Gets the position of the pose in the specified frame. + + If frame is None, the position is returned in the pose's parent frame. + """ + if frame is None: + frame = self.parent_frame + + return self.in_frame( + frame if frame is not None else self.parent_frame + ).transform.translation + + def get_orientation(self, frame: FrameID_T | None) -> Rotation: + """ + Gets the orientation of the pose in the specified frame. + + If frame is None, the orientation is returned in the pose's parent frame. + """ + if frame is None: + frame = self.parent_frame + + return self.in_frame( + frame if frame is not None else self.parent_frame + ).transform.rotation + class Registry(Generic[FrameID_T]): """ From 932cb5229eb0baf0c759eedab7125c4313601425 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:05:44 -0700 Subject: [PATCH 05/18] docs(README): use Pose --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0743014..470098a 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,26 @@ FrameTransforms is a lightweight, native Python pacakge to simplify frame transf ## Application Consider a simple robot consisting of a mobile base and a camera mounted on a gimbal. -The camera detects an obstacle in its coordinate frame. Where is it in world frame? +The camera detects an object in its coordinate frame. Where is it in world frame? ```python +# Setup registry.update(Frame.WORLD, Frame.BASE, base_pose) registry.update(Frame.BASE, Frame.CAMERA, camera_pose) -# Locations are in homogenous coordinates -obstacle_in_world = registry.get_transform(Frame.CAMERA, Frame.WORLD) @ obstacle_in_camera +# Define the Pose +object_pose = Pose( + Transform( + np.array([1, 0, 0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ), + parent_frame=Frame.CAMERA, + registry=registry, + ) + +# Get the position and orientation of the object in world frame +position_in_world = object_pose.get_position(Frame.WORLD) +orientation_in_world = object_pose.get_orientation(Frame.WORLD) ``` -# [Examples](https://github.com/MinhxNguyen7/FrameTransforms/blob/main/example.py) \ No newline at end of file +# [Examples](https://github.com/MinhxNguyen7/FrameTransforms/blob/main/example.py) From bca8ff268f1c8c5c127ff1a295ab2939066692b4 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:09:07 -0700 Subject: [PATCH 06/18] docs(example): example with Pose --- example.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/example.py b/example.py index 2deb2d4..8e91654 100644 --- a/example.py +++ b/example.py @@ -26,15 +26,15 @@ def make_example_registry(): # Add transformations between frames - world_to_base_transform = Transform( + world_to_base = Transform( np.array([0, 1, 0]), Rotation.from_euler("xyz", [0, 0, 0], degrees=True) ) - registry.add_transform(Frame.WORLD, Frame.BASE, world_to_base_transform) + registry.add_transform(Frame.WORLD, Frame.BASE, world_to_base) - base_to_camera_transform = Transform( + base_to_camera = Transform( np.array([0, 0, 1]), Rotation.from_euler("xyz", [0, 90, 0], degrees=True) ) - registry.add_transform(Frame.BASE, Frame.CAMERA, base_to_camera_transform) + registry.add_transform(Frame.BASE, Frame.CAMERA, base_to_camera) return registry @@ -107,7 +107,30 @@ def update_transformation_example(): print("Transformation from WORLD to CAMERA updated correctly.") +def pose_example(): + """ + Demonstrates creating and using a Pose object. + """ + registry = make_example_registry() + + object_pose = Pose( + Transform( + np.array([1, 0, 0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ), + parent_frame=Frame.CAMERA, + registry=registry, + ) + + position_in_world = object_pose.get_position(Frame.WORLD) + print("Object position in WORLD frame:", position_in_world) + + orientation_in_world = object_pose.get_orientation(Frame.WORLD) + print("Object orientation in WORLD frame:", orientation_in_world.as_matrix()) + + if __name__ == "__main__": add_cycle_example() transitive_transformation_example() update_transformation_example() + pose_example() From 2bfb5b05599f175ead74ad07b533eefde83ab480 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:11:00 -0700 Subject: [PATCH 07/18] refactor: transform instead of Transformation --- README.md | 4 ++-- example.py | 38 +++++++++++++++++----------------- frame_transforms/__init__.py | 2 +- frame_transforms/transforms.py | 16 +++++++------- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 470098a..03097b2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Description -FrameTransforms is a lightweight, native Python pacakge to simplify frame transformations. It supports: +FrameTransforms is a lightweight, native Python pacakge to simplify frame transforms. It supports: 1. Registration and update of relative coordinate frames. -2. Automatic computation of transitive transformations. +2. Automatic computation of transitive transforms. 3. Multithreaded access. ## Application diff --git a/example.py b/example.py index 8e91654..ac91c85 100644 --- a/example.py +++ b/example.py @@ -7,7 +7,7 @@ Pose, Transform, Registry, - InvalidTransformationError, + InvalidTransformError, ) @@ -41,23 +41,23 @@ def make_example_registry(): def add_cycle_example(): """ - Demonstrates adding a transformation that would create a cycle in the registry, - raising an InvaidTransformationError. + Demonstrates adding a transform that would create a cycle in the registry, + raising an InvalidTransformError. """ registry = make_example_registry() - # Attempt to add a transformation that creates a cycle + # Attempt to add a transform that creates a cycle try: registry.add_transform(Frame.CAMERA, Frame.WORLD, np.zeros(4)) - except InvalidTransformationError: + except InvalidTransformError: print( - "Caught invalid transformation because there is already a path between CAMERA and WORLD." + "Caught invalid transform because there is already a path between CAMERA and WORLD." ) -def transitive_transformation_example(): +def transitive_transform_example(): """ - Demonstrates getting a transformation from one frame to another through an intermediate frame. + Demonstrates getting a transform from one frame to another through an intermediate frame. """ registry = make_example_registry() @@ -69,31 +69,31 @@ def transitive_transformation_example(): assert np.allclose( actual.rotation.as_matrix(), expected.rotation.as_matrix() ), "Rotation mismatch" - print("Transformation from WORLD to CAMERA is correct.") + print("Transform from WORLD to CAMERA is correct.") -def update_transformation_example(): +def update_transform_example(): """ - Demonstrates updating an existing transformation in the registry, + Demonstrates updating an existing transform in the registry, specifically, moving the base on which the camera sits. """ registry = make_example_registry() - # Update the transformation from WORLD to BASE + # Update the transform from WORLD to BASE new_transform = Transform( np.array([0, 2, 0]), Rotation.from_euler("xyz", [0, 0, 0], degrees=True) ) registry.update(Frame.WORLD, Frame.BASE, new_transform) - # Attempt to add instead of update the transformation + # Attempt to add instead of update the transform try: registry.add_transform(Frame.WORLD, Frame.BASE, new_transform) - except InvalidTransformationError: + except InvalidTransformError: print( - "Caught invalid transformation because both frames already exist in the registry." + "Caught invalid transform because both frames already exist in the registry." ) - # Check the updated transformation + # Check the updated transform expected = Transform( np.array([0, 2, 1]), Rotation.from_euler("xyz", [0, 90, 0], degrees=True) ) @@ -104,7 +104,7 @@ def update_transformation_example(): assert np.allclose( actual.rotation.as_matrix(), expected.rotation.as_matrix() ), "Rotation mismatch after update" - print("Transformation from WORLD to CAMERA updated correctly.") + print("Transform from WORLD to CAMERA updated correctly.") def pose_example(): @@ -131,6 +131,6 @@ def pose_example(): if __name__ == "__main__": add_cycle_example() - transitive_transformation_example() - update_transformation_example() + transitive_transform_example() + update_transform_example() pose_example() diff --git a/frame_transforms/__init__.py b/frame_transforms/__init__.py index 24c1367..8b28901 100644 --- a/frame_transforms/__init__.py +++ b/frame_transforms/__init__.py @@ -2,6 +2,6 @@ Pose as Pose, Transform as Transform, Registry as Registry, - InvalidTransformationError as InvalidTransformationError, + InvalidTransformError as InvalidTransformError, ) from .utils import make_3d_transformation as make_3d_transformation diff --git a/frame_transforms/transforms.py b/frame_transforms/transforms.py index da471ef..49c5779 100644 --- a/frame_transforms/transforms.py +++ b/frame_transforms/transforms.py @@ -10,7 +10,7 @@ Ret_T = TypeVar("Ret_T", bound=Any) -class InvalidTransformationError(Exception): +class InvalidTransformError(Exception): pass @@ -259,12 +259,10 @@ def _add_transform_unsafe( transform: Transform | np.ndarray, ): if from_frame in self._adjacencies and to_frame in self._adjacencies: - raise InvalidTransformationError( - "Both frames already exist in the registry." - ) + raise InvalidTransformError("Both frames already exist in the registry.") if from_frame not in self._adjacencies and to_frame not in self._adjacencies: - raise InvalidTransformationError( + raise InvalidTransformError( "At least one of the frames must exist in the registry." ) @@ -320,17 +318,17 @@ def _update_unsafe( transform: The new transformation matrix from `from_frame` to `to_frame`. """ if from_frame not in self._adjacencies: - raise InvalidTransformationError( + raise InvalidTransformError( f"Frame {from_frame} does not exist in the registry." ) if to_frame not in self._adjacencies: - raise InvalidTransformationError( + raise InvalidTransformError( f"Frame {to_frame} does not exist in the registry." ) if to_frame not in self._adjacencies[from_frame]: - raise InvalidTransformationError( + raise InvalidTransformError( f"Frame {to_frame} is not attached to {from_frame}." ) @@ -418,6 +416,6 @@ def _get_path(self, from_frame: FrameID_T, to_frame: FrameID_T) -> list[FrameID_ try: return self._paths[from_frame][to_frame] except KeyError: - raise InvalidTransformationError( + raise InvalidTransformError( f"Either {from_frame} or {to_frame} does not exist in the registry." ) From dbeb18de5bd37ca40c2397a95d36f63adcb90483 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:13:49 -0700 Subject: [PATCH 08/18] fix(README): indentation --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 03097b2..3905027 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ registry.update(Frame.BASE, Frame.CAMERA, camera_pose) # Define the Pose object_pose = Pose( - Transform( - np.array([1, 0, 0]), - Rotation.from_euler("xyz", [0, 0, 0], degrees=True), - ), - parent_frame=Frame.CAMERA, - registry=registry, - ) + Transform( + np.array([1, 0, 0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ), + parent_frame=Frame.CAMERA, + registry=registry, +) # Get the position and orientation of the object in world frame position_in_world = object_pose.get_position(Frame.WORLD) From 0f92f6cf9f2a6504bce8f4dfc3eb2641189af15e Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:38:10 -0700 Subject: [PATCH 09/18] test: compregensive test suite --- .coveragerc | 2 + .gitignore | 9 +- .vscode/settings.json | 11 + frame_transforms/transforms.py | 13 +- pyproject.toml | 1 + tests/dummy.py | 9 - tests/run_tests.py | 40 +++ tests/test_composition_bugs.py | 154 ++++++++++++ tests/test_integration.py | 316 ++++++++++++++++++++++++ tests/test_pose.py | 265 ++++++++++++++++++++ tests/test_registry.py | 370 ++++++++++++++++++++++++++++ tests/test_transform_composition.py | 154 ++++++++++++ tests/test_transforms.py | 193 +++++++++++++++ tests/test_utils.py | 180 ++++++++++++++ 14 files changed, 1703 insertions(+), 14 deletions(-) create mode 100644 .coveragerc create mode 100644 .vscode/settings.json delete mode 100644 tests/dummy.py create mode 100644 tests/run_tests.py create mode 100644 tests/test_composition_bugs.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_pose.py create mode 100644 tests/test_registry.py create mode 100644 tests/test_transform_composition.py create mode 100644 tests/test_transforms.py create mode 100644 tests/test_utils.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..e4c81e1 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = ./example*, ./tests/* diff --git a/.gitignore b/.gitignore index f55750c..ce42477 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ -*venv/ +# Generated __pycache__/ dist/ -.env \ No newline at end of file +# Environment +*venv/ +.env + +# Output +.coverage diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..21e0ca1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "*test*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/frame_transforms/transforms.py b/frame_transforms/transforms.py index 49c5779..37a0e47 100644 --- a/frame_transforms/transforms.py +++ b/frame_transforms/transforms.py @@ -62,7 +62,10 @@ def _apply_to_pose(self, pose: "Pose[FrameID_T]") -> "Pose[FrameID_T]": """ Applies this transformation to a given pose and returns a new pose. """ - new_translation = self._translation + pose.transform.translation + # Correct transformation composition: rotate the pose's translation, then add this translation + new_translation = self._translation + self._rotation.apply( + pose.transform.translation + ) new_rotation = self._rotation * pose.transform.rotation return Pose( @@ -77,7 +80,10 @@ def __matmul__(self, other: TransformTarget_t) -> TransformTarget_t: or a 4x4 homogeneous transformation matrix. """ if isinstance(other, Transform): - new_translation = self._translation + other.translation + # Correct transformation composition: rotate other's translation, then add this translation + new_translation = self._translation + self._rotation.apply( + other.translation + ) new_rotation = self._rotation * other.rotation return Transform(new_translation, new_rotation) # type: ignore[return-value] @@ -89,7 +95,8 @@ def __matmul__(self, other: TransformTarget_t) -> TransformTarget_t: case (4, 4): return self.as_matrix() @ other case (3,): - return np.array(self._rotation.apply(other + self._translation)) # type: ignore[return-value] + # Correct vector transformation: rotate first, then translate + return np.array(self._rotation.apply(other) + self._translation) # type: ignore[return-value] case _: raise ValueError( "Invalid shape for transformation application. Expected (4, 4) or (3,)." diff --git a/pyproject.toml b/pyproject.toml index d4efaf5..0de5591 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dev = [ test = [ "pytest >= 8.4", + "pytest-cov >= 4.0", ] [project.urls] diff --git a/tests/dummy.py b/tests/dummy.py deleted file mode 100644 index 3b71880..0000000 --- a/tests/dummy.py +++ /dev/null @@ -1,9 +0,0 @@ -from unittest import TestCase - - -class DummyTest(TestCase): - def test_dummy(self): - """ - A dummy test to ensure the test suite is working. - """ - self.assertTrue(True) diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..a7b5519 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,40 @@ +"""Test runner for the frame_transforms package.""" + +import unittest +import sys +import os + +# Add the parent directory to the path so we can import the package +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Import all test modules +from test_transforms import TestTransform +from test_pose import TestPose +from test_registry import TestRegistry +from test_utils import TestUtils +from test_integration import TestIntegration + + +def run_all_tests(): + """Run all tests in the test suite.""" + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + suite.addTests(loader.loadTestsFromTestCase(TestTransform)) + suite.addTests(loader.loadTestsFromTestCase(TestPose)) + suite.addTests(loader.loadTestsFromTestCase(TestRegistry)) + suite.addTests(loader.loadTestsFromTestCase(TestUtils)) + suite.addTests(loader.loadTestsFromTestCase(TestIntegration)) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + return result.wasSuccessful() + + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/test_composition_bugs.py b/tests/test_composition_bugs.py new file mode 100644 index 0000000..57e5caf --- /dev/null +++ b/tests/test_composition_bugs.py @@ -0,0 +1,154 @@ +"""Tests that reveal bugs in the current Transform implementation. + +These tests demonstrate that the current Transform composition logic is incorrect. +The @ operator for Transform objects doesn't properly compose 3D transformations. +""" + +import unittest +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms import Transform + + +class TestTransformCompositionBugs(unittest.TestCase): + """Test cases that reveal bugs in the current implementation.""" + + def test_transform_composition_simple_case(self): + """Test that reveals the translation composition bug.""" + # First transform: translate [1,0,0] then rotate 90° around Z + t1 = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + # Second transform: translate [1,0,0], no rotation + t2 = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + # Current (incorrect) implementation + current_result = t1 @ t2 + + # Correct result using matrix composition + correct_matrix = t1.as_matrix() @ t2.as_matrix() + expected_translation = correct_matrix[:3, 3] + expected_rotation = Rotation.from_matrix(correct_matrix[:3, :3]) + + print(f"Current result: {current_result.translation}") + print(f"Expected result: {expected_translation}") + + # This test should now PASS with the fixed implementation + np.testing.assert_array_almost_equal( + current_result.translation, + expected_translation, + err_msg="Transform composition should now be correct", + ) + + def test_vector_transformation_bug(self): + """Test that reveals the vector transformation bug.""" + transform = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + vector = np.array([1.0, 0.0, 0.0]) + + # Current (incorrect) implementation + current_result = transform @ vector + + # Correct transformation: rotate first, then translate + correct_result = transform.rotation.apply(vector) + transform.translation + + print(f"Current vector result: {current_result}") + print(f"Expected vector result: {correct_result}") + + # This test should now PASS with the fixed implementation + np.testing.assert_array_almost_equal( + current_result, + correct_result, + err_msg="Vector transformation should now be correct", + ) + + def test_matrix_vs_transform_composition(self): + """Test showing matrix composition gives different results than Transform @.""" + t1 = Transform( + np.array([2.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 45], degrees=True), + ) + + t2 = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [0, 90, 0], degrees=True), + ) + + # Transform composition (current implementation) + transform_result = t1 @ t2 + + # Matrix composition (correct) + matrix_result_matrix = t1.as_matrix() @ t2.as_matrix() + matrix_result = Transform( + matrix_result_matrix[:3, 3], + Rotation.from_matrix(matrix_result_matrix[:3, :3]), + ) + + print("Transform @ result:") + print(f" Translation: {transform_result.translation}") + print(f" Rotation: {transform_result.rotation.as_euler('xyz', degrees=True)}") + + print("Matrix @ result:") + print(f" Translation: {matrix_result.translation}") + print(f" Rotation: {matrix_result.rotation.as_euler('xyz', degrees=True)}") + + # These should now be the same with the fixed implementation + translation_same = np.allclose( + transform_result.translation, matrix_result.translation + ) + rotation_same = np.allclose( + transform_result.rotation.as_matrix(), matrix_result.rotation.as_matrix() + ) + + self.assertTrue( + translation_same and rotation_same, + "Transform @ and matrix @ should give same result with fixed implementation", + ) + + def test_identity_composition_bug(self): + """Test showing that even identity composition can be wrong.""" + # Non-identity transform + transform = Transform( + np.array([1.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 45], degrees=True), + ) + + # Identity transform + identity = Transform( + np.array([0.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + # These should give the same result as the original transform + result1 = transform @ identity + result2 = identity @ transform + + print("Original transform translation:", transform.translation) + print("transform @ identity:", result1.translation) + print("identity @ transform:", result2.translation) + + # Both should now pass with the fixed implementation + same1 = np.allclose(result1.translation, transform.translation) + same2 = np.allclose(result2.translation, transform.translation) + + if not same1: + print("BUG: transform @ identity != transform") + if not same2: + print("BUG: identity @ transform != transform") + + # Both should now work with the fixed implementation + self.assertTrue(same1, "transform @ identity should equal transform") + self.assertTrue(same2, "identity @ transform should equal transform") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..fab4a4e --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,316 @@ +"""Integration tests for the frame_transforms package.""" + +import unittest +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms import ( + Transform, + Pose, + Registry, + InvalidTransformError, + make_3d_transformation, +) + + +class TestIntegration(unittest.TestCase): + """Integration test cases that test the complete workflow.""" + + def setUp(self): + """Set up test fixtures.""" + self.registry = Registry("world") + + def test_robot_scenario(self): + """Test the complete robot scenario from the README.""" + # Setup robot frames + base_pose = Transform( + np.array([2.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 45], degrees=True), + ) + self.registry.add_transform("world", "base", base_pose) + + camera_pose = Transform( + np.array([0.0, 0.0, 1.5]), + Rotation.from_euler("xyz", [0, 90, 0], degrees=True), + ) + self.registry.add_transform("base", "camera", camera_pose) + + # Define object pose in camera frame + object_transform = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + object_pose = Pose(object_transform, "camera", self.registry) + + # Get position and orientation in world frame + position_in_world = object_pose.get_position("world") + orientation_in_world = object_pose.get_orientation("world") + + # Verify results are reasonable + self.assertEqual(position_in_world.shape, (3,)) + self.assertIsInstance(orientation_in_world, Rotation) + + # Position should be transformed through the chain + self.assertFalse(np.allclose(position_in_world, [1.0, 0.0, 0.0])) + + def test_complex_frame_hierarchy(self): + """Test complex frame hierarchy with multiple objects.""" + # Create robot with multiple sensors + base_transform = Transform( + np.array([1.0, 2.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 30], degrees=True), + ) + self.registry.add_transform("world", "base", base_transform) + + # Camera on robot + camera_transform = Transform( + np.array([0.3, 0.0, 1.2]), + Rotation.from_euler("xyz", [0, 15, 0], degrees=True), + ) + self.registry.add_transform("base", "camera", camera_transform) + + # Object detected by camera + object_in_camera = Pose( + Transform( + np.array([2.5, -0.5, 1.0]), + Rotation.from_euler("xyz", [45, 30, 15], degrees=True), + ), + "camera", + self.registry, + ) + + # Transform object to different frames + object_in_base = object_in_camera.in_frame("base") + object_in_world = object_in_camera.in_frame("world") + + # Verify frame assignments + self.assertEqual(object_in_camera.parent_frame, "camera") + self.assertEqual(object_in_base.parent_frame, "base") + self.assertEqual(object_in_world.parent_frame, "world") + + # Verify round-trip consistency + back_to_camera = object_in_world.in_frame("camera") + np.testing.assert_array_almost_equal( + object_in_camera.transform.translation, + back_to_camera.transform.translation, + decimal=6, # Reduced precision due to numerical accumulation in complex transforms + ) + + def test_dynamic_frame_updates(self): + """Test updating frame transforms dynamically.""" + # Initial robot pose + initial_base = Transform( + np.array([0.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + self.registry.add_transform("world", "base", initial_base) + + # Camera fixed relative to base + camera_transform = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + self.registry.add_transform("base", "camera", camera_transform) + + # Object in camera frame + object_pose = Pose( + Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ), + "camera", + self.registry, + ) + + # Get initial world position + initial_world_pos = object_pose.get_position("world") + + # Move the robot base + new_base = Transform( + np.array([5.0, 3.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + self.registry.update("world", "base", new_base) + + # Get new world position + new_world_pos = object_pose.get_position("world") + + # Positions should be different + self.assertFalse(np.allclose(initial_world_pos, new_world_pos)) + + # Object should still be at same position relative to camera + camera_pos = object_pose.get_position("camera") + np.testing.assert_array_almost_equal(camera_pos, [1.0, 0.0, 0.0]) + + def test_transform_matrix_integration(self): + """Test integration between Transform objects and numpy matrices.""" + # Create transforms using different methods + translation = np.array([1.0, 2.0, 3.0]) + rotation = Rotation.from_euler("xyz", [30, 45, 60], degrees=True) + + # Method 1: Transform object + transform1 = Transform(translation, rotation) + + # Method 2: Utility function + matrix = make_3d_transformation(translation, rotation) + + # Method 3: Add to registry using matrix + self.registry.add_transform("world", "base", matrix) + retrieved_transform = self.registry.get_transform("world", "base") + + # All should be equivalent + np.testing.assert_array_almost_equal(transform1.as_matrix(), matrix) + np.testing.assert_array_almost_equal( + transform1.translation, retrieved_transform.translation + ) + np.testing.assert_array_almost_equal( + transform1.rotation.as_matrix(), retrieved_transform.rotation.as_matrix() + ) + + def test_error_handling_workflow(self): + """Test error handling in typical workflows.""" + # Add base frame + base_transform = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + self.registry.add_transform("world", "base", base_transform) + + # Try to add conflicting transform + with self.assertRaises(InvalidTransformError): + self.registry.add_transform("world", "base", base_transform) + + # Try to transform to nonexistent frame + pose = Pose(base_transform, "world", self.registry) + with self.assertRaises(InvalidTransformError): + pose.in_frame("nonexistent") + + # Try to update nonexistent transform + with self.assertRaises(InvalidTransformError): + self.registry.update("world", "nonexistent", base_transform) + + def test_example_from_readme(self): + """Test the exact example from the README.""" + registry = Registry("world") + + # Setup + base_pose = Transform( + np.array([1.0, 2.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 30], degrees=True), + ) + registry.add_transform("world", "base", base_pose) + + camera_pose = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [0, 90, 0], degrees=True), + ) + registry.add_transform("base", "camera", camera_pose) + + # Define the Pose + object_pose = Pose( + Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ), + parent_frame="camera", + registry=registry, + ) + + # Get the position and orientation of the object in world frame + position_in_world = object_pose.get_position("world") + orientation_in_world = object_pose.get_orientation("world") + + # Should not raise exceptions + self.assertEqual(position_in_world.shape, (3,)) + self.assertIsInstance(orientation_in_world, Rotation) + + def test_multiple_registry_isolation(self): + """Test that multiple registries are properly isolated.""" + # Create two separate registries + registry1 = Registry("world1") + registry2 = Registry("world2") + + # Add transforms to each + transform1 = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + transform2 = Transform( + np.array([0.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + registry1.add_transform("world1", "base1", transform1) + registry2.add_transform("world2", "base2", transform2) + + # Poses should be isolated + pose1 = Pose(transform1, "world1", registry1) + pose2 = Pose(transform2, "world2", registry2) + + # Should not be able to transform between registries + # (This would require frames to exist in the same registry) + self.assertEqual(pose1.parent_frame, "world1") + self.assertEqual(pose2.parent_frame, "world2") + + def test_coordinate_frame_consistency(self): + """Test coordinate frame consistency across transformations.""" + # Set up right-handed coordinate system test + self.registry.add_transform( + "world", + "base", + Transform( + np.array([1.0, 0.0, 0.0]), # Move 1 unit in X + Rotation.from_euler( + "xyz", [0, 0, 90], degrees=True + ), # Rotate 90° around Z + ), + ) + + # Point at origin in base frame + point_in_base = Pose( + Transform( + np.array([0.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ), + "base", + self.registry, + ) + + # Transform to world frame + point_in_world = point_in_base.in_frame("world") + + # After 90° rotation around Z and translation [1,0,0], + # a point at origin in base should be at [0, 1, 0] in world + world_pos = point_in_world.transform.translation + np.testing.assert_array_almost_equal(world_pos, [0.0, 1.0, 0.0]) + + def test_numerical_precision(self): + """Test numerical precision in transform compositions.""" + # Create a chain of small transforms + current_frame = "world" + + # Add 10 small transforms (reduced from 100 for simpler test) + for i in range(1, 11): + next_frame = f"frame_{i}" + small_transform = Transform( + np.array([0.1, 0.0, 0.0]), # Small translation + Rotation.from_euler("xyz", [0, 0, 1.0], degrees=True), # Small rotation + ) + self.registry.add_transform(current_frame, next_frame, small_transform) + current_frame = next_frame + + # Transform through entire chain + final_transform = self.registry.get_transform("world", "frame_10") + + # Result should be reasonable (approximately 1.0 in X, some Y from rotation) + translation = final_transform.translation + self.assertAlmostEqual(translation[0], 1.0, places=1) # 10 * 0.1 + + # Rotation should be approximately 10 degrees total + total_rotation = final_transform.rotation + euler = total_rotation.as_euler("xyz", degrees=True) + self.assertAlmostEqual(euler[2], 10.0, places=1) # 10 * 1.0 + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pose.py b/tests/test_pose.py new file mode 100644 index 0000000..684ad77 --- /dev/null +++ b/tests/test_pose.py @@ -0,0 +1,265 @@ +"""Tests for the Pose class.""" + +import unittest +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms import Transform, Pose, Registry + + +class TestPose(unittest.TestCase): + """Test cases for the Pose class.""" + + def setUp(self): + """Set up test fixtures.""" + self.registry = Registry("world") + + # Add a base frame + world_to_base = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [0, 0, 45], degrees=True), + ) + self.registry.add_transform("world", "base", world_to_base) + + # Add a camera frame + base_to_camera = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [0, 90, 0], degrees=True), + ) + self.registry.add_transform("base", "camera", base_to_camera) + + self.test_transform = Transform( + np.array([0.5, 0.5, 0.5]), + Rotation.from_euler("xyz", [30, 0, 0], degrees=True), + ) + + def test_init_valid(self): + """Test valid Pose initialization.""" + pose = Pose(self.test_transform, "world", self.registry) + + self.assertEqual(pose.transform, self.test_transform) + self.assertEqual(pose.parent_frame, "world") + self.assertIs(pose.registry, self.registry) + + def test_init_invalid_transform_type(self): + """Test Pose initialization with invalid transform type.""" + with self.assertRaises(TypeError): + Pose("not_a_transform", "world", self.registry) + + def test_init_invalid_registry_type(self): + """Test Pose initialization with invalid registry type.""" + with self.assertRaises(TypeError): + Pose(self.test_transform, "world", "not_a_registry") + + def test_apply_transform_same_frame(self): + """Test applying transform without changing frame.""" + original_pose = Pose(self.test_transform, "world", self.registry) + additional_transform = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + new_pose = original_pose.apply_transform(additional_transform, None) + + # Should remain in same frame + self.assertEqual(new_pose.parent_frame, "world") + self.assertIs(new_pose.registry, self.registry) + + # Transform should be combined + expected_translation = ( + original_pose.transform.translation + additional_transform.translation + ) + np.testing.assert_array_almost_equal( + new_pose.transform.translation, expected_translation + ) + + expected_rotation = ( + original_pose.transform.rotation * additional_transform.rotation + ) + np.testing.assert_array_almost_equal( + new_pose.transform.rotation.as_matrix(), expected_rotation.as_matrix() + ) + + def test_apply_transform_new_frame(self): + """Test applying transform with changing frame.""" + original_pose = Pose(self.test_transform, "world", self.registry) + additional_transform = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + new_pose = original_pose.apply_transform(additional_transform, "base") + + # Should be in new frame + self.assertEqual(new_pose.parent_frame, "base") + self.assertIs(new_pose.registry, self.registry) + + def test_in_frame_same_frame(self): + """Test transforming pose to the same frame.""" + pose = Pose(self.test_transform, "world", self.registry) + transformed_pose = pose.in_frame("world") + + # Should be unchanged + np.testing.assert_array_almost_equal( + pose.transform.translation, transformed_pose.transform.translation + ) + np.testing.assert_array_almost_equal( + pose.transform.rotation.as_matrix(), + transformed_pose.transform.rotation.as_matrix(), + ) + self.assertEqual(transformed_pose.parent_frame, "world") + + def test_in_frame_different_frame(self): + """Test transforming pose to a different frame.""" + pose = Pose(self.test_transform, "world", self.registry) + transformed_pose = pose.in_frame("base") + + self.assertEqual(transformed_pose.parent_frame, "base") + self.assertIs(transformed_pose.registry, self.registry) + + # Should not be the same as original + self.assertFalse( + np.allclose( + pose.transform.translation, transformed_pose.transform.translation + ) + ) + + def test_in_frame_through_intermediate(self): + """Test transforming pose through intermediate frames.""" + pose = Pose(self.test_transform, "world", self.registry) + transformed_pose = pose.in_frame("camera") + + self.assertEqual(transformed_pose.parent_frame, "camera") + + # Transform should be valid (no exceptions) + self.assertIsInstance(transformed_pose.transform.translation, np.ndarray) + self.assertIsInstance(transformed_pose.transform.rotation, Rotation) + + def test_get_position_none_frame(self): + """Test getting position with None frame (should use parent frame).""" + pose = Pose(self.test_transform, "world", self.registry) + position = pose.get_position(None) + + np.testing.assert_array_almost_equal(position, pose.transform.translation) + + def test_get_position_same_frame(self): + """Test getting position in the same frame.""" + pose = Pose(self.test_transform, "world", self.registry) + position = pose.get_position("world") + + np.testing.assert_array_almost_equal(position, pose.transform.translation) + + def test_get_position_different_frame(self): + """Test getting position in a different frame.""" + pose = Pose(self.test_transform, "world", self.registry) + position_in_base = pose.get_position("base") + + self.assertIsInstance(position_in_base, np.ndarray) + self.assertEqual(position_in_base.shape, (3,)) + + # Should be different from original position + self.assertFalse(np.allclose(position_in_base, pose.transform.translation)) + + def test_get_orientation_none_frame(self): + """Test getting orientation with None frame (should use parent frame).""" + pose = Pose(self.test_transform, "world", self.registry) + orientation = pose.get_orientation(None) + + np.testing.assert_array_almost_equal( + orientation.as_matrix(), pose.transform.rotation.as_matrix() + ) + + def test_get_orientation_same_frame(self): + """Test getting orientation in the same frame.""" + pose = Pose(self.test_transform, "world", self.registry) + orientation = pose.get_orientation("world") + + np.testing.assert_array_almost_equal( + orientation.as_matrix(), pose.transform.rotation.as_matrix() + ) + + def test_get_orientation_different_frame(self): + """Test getting orientation in a different frame.""" + pose = Pose(self.test_transform, "world", self.registry) + orientation_in_base = pose.get_orientation("base") + + self.assertIsInstance(orientation_in_base, Rotation) + + # Should be different from original orientation (in most cases) + original_matrix = pose.transform.rotation.as_matrix() + transformed_matrix = orientation_in_base.as_matrix() + + # At least one element should be different (unless identity transform) + self.assertIsInstance(transformed_matrix, np.ndarray) + self.assertEqual(transformed_matrix.shape, (3, 3)) + + def test_round_trip_transformation(self): + """Test that transforming to a frame and back gives original pose.""" + # Use a simpler setup with just one intermediate frame + simple_registry = Registry("world") + simple_transform = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), # No rotation + ) + simple_registry.add_transform("world", "base", simple_transform) + + original_pose = Pose( + Transform( + np.array([0.5, 0.5, 0.5]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), # No rotation + ), + "world", + simple_registry, + ) + + # Transform to base and back to world + intermediate_pose = original_pose.in_frame("base") + final_pose = intermediate_pose.in_frame("world") + + # Should be approximately equal to original (simple case should be exact) + np.testing.assert_array_almost_equal( + original_pose.transform.translation, + final_pose.transform.translation, + decimal=10, + ) + np.testing.assert_array_almost_equal( + original_pose.transform.rotation.as_matrix(), + final_pose.transform.rotation.as_matrix(), + decimal=10, + ) + + def test_pose_chain_transformation(self): + """Test transforming pose through a chain of frames.""" + original_pose = Pose(self.test_transform, "world", self.registry) + + # Transform world -> base -> camera + pose_in_camera = original_pose.in_frame("camera") + + # Should maintain consistency + self.assertEqual(pose_in_camera.parent_frame, "camera") + + # Position and orientation should be reasonable + position = pose_in_camera.transform.translation + self.assertEqual(position.shape, (3,)) + self.assertTrue(np.all(np.isfinite(position))) + + rotation_matrix = pose_in_camera.transform.rotation.as_matrix() + self.assertEqual(rotation_matrix.shape, (3, 3)) + self.assertTrue(np.all(np.isfinite(rotation_matrix))) + + def test_frozen_dataclass(self): + """Test that Pose is immutable (frozen dataclass).""" + pose = Pose(self.test_transform, "world", self.registry) + + with self.assertRaises(Exception): # Should be FrozenInstanceError + pose.transform = Transform( + np.array([999.0, 999.0, 999.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + with self.assertRaises(Exception): # Should be FrozenInstanceError + pose.parent_frame = "base" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..44a09e7 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,370 @@ +"""Tests for the Registry class.""" + +import unittest +import threading +import time +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms import Transform, Registry, InvalidTransformError + + +class TestRegistry(unittest.TestCase): + """Test cases for the Registry class.""" + + def setUp(self): + """Set up test fixtures.""" + self.registry = Registry("world") + + self.simple_transform = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + self.complex_transform = Transform( + np.array([0.5, -0.5, 1.0]), + Rotation.from_euler("xyz", [45, 30, 60], degrees=True), + ) + + def test_init(self): + """Test Registry initialization.""" + registry = Registry("test_world") + + # Should have the world frame + self.assertIn("test_world", registry._adjacencies) + self.assertEqual(registry._parents["test_world"], None) + + def test_add_transform_valid(self): + """Test adding a valid transform.""" + self.registry.add_transform("world", "base", self.simple_transform) + + # Both frames should exist + self.assertIn("world", self.registry._adjacencies) + self.assertIn("base", self.registry._adjacencies) + + # Bidirectional connection should exist + self.assertIn("base", self.registry._adjacencies["world"]) + self.assertIn("world", self.registry._adjacencies["base"]) + + def test_add_transform_with_numpy_array(self): + """Test adding transform with numpy array.""" + matrix = self.simple_transform.as_matrix() + self.registry.add_transform("world", "base", matrix) + + # Should work the same as with Transform object + retrieved = self.registry.get_transform("world", "base") + np.testing.assert_array_almost_equal( + retrieved.translation, self.simple_transform.translation + ) + + def test_add_transform_both_frames_exist(self): + """Test adding transform when both frames already exist.""" + self.registry.add_transform("world", "base", self.simple_transform) + + with self.assertRaises(InvalidTransformError): + self.registry.add_transform("world", "base", self.complex_transform) + + def test_add_transform_neither_frame_exists(self): + """Test adding transform when neither frame exists.""" + with self.assertRaises(InvalidTransformError): + self.registry.add_transform("base", "camera", self.simple_transform) + + def test_add_transform_invalid_matrix_shape(self): + """Test adding transform with invalid matrix shape.""" + invalid_matrix = np.eye(3) # Wrong shape + + with self.assertRaises(ValueError): + self.registry.add_transform("world", "base", invalid_matrix) + + def test_add_transform_invalid_type(self): + """Test adding transform with invalid type.""" + with self.assertRaises(ValueError): + self.registry.add_transform("world", "base", "invalid") + + def test_get_transform_same_frame(self): + """Test getting transform from frame to itself.""" + transform = self.registry.get_transform("world", "world") + + # Should be identity transform + np.testing.assert_array_almost_equal( + transform.translation, np.array([0.0, 0.0, 0.0]) + ) + np.testing.assert_array_almost_equal(transform.rotation.as_matrix(), np.eye(3)) + + def test_get_transform_direct_connection(self): + """Test getting transform with direct connection.""" + self.registry.add_transform("world", "base", self.simple_transform) + + # Forward direction + transform = self.registry.get_transform("world", "base") + np.testing.assert_array_almost_equal( + transform.translation, self.simple_transform.translation + ) + np.testing.assert_array_almost_equal( + transform.rotation.as_matrix(), self.simple_transform.rotation.as_matrix() + ) + + # Reverse direction (should be inverse) + inverse_transform = self.registry.get_transform("base", "world") + expected_inverse_matrix = np.linalg.inv(self.simple_transform.as_matrix()) + actual_inverse_matrix = inverse_transform.as_matrix() + np.testing.assert_array_almost_equal( + actual_inverse_matrix, expected_inverse_matrix + ) + + def test_get_transform_transitive(self): + """Test getting transform through intermediate frame.""" + # Create chain: world -> base -> camera + self.registry.add_transform("world", "base", self.simple_transform) + self.registry.add_transform("base", "camera", self.complex_transform) + + # Get transform from world to camera + transform = self.registry.get_transform("world", "camera") + + # Should be composition of transforms + expected_matrix = ( + self.simple_transform.as_matrix() @ self.complex_transform.as_matrix() + ) + actual_matrix = transform.as_matrix() + np.testing.assert_array_almost_equal(actual_matrix, expected_matrix) + + def test_get_transform_nonexistent_frame(self): + """Test getting transform to nonexistent frame.""" + with self.assertRaises(InvalidTransformError): + self.registry.get_transform("world", "nonexistent") + + def test_update_transform_valid(self): + """Test updating an existing transform.""" + self.registry.add_transform("world", "base", self.simple_transform) + + # Update the transform + new_transform = Transform( + np.array([5.0, 6.0, 7.0]), + Rotation.from_euler("xyz", [30, 45, 60], degrees=True), + ) + self.registry.update("world", "base", new_transform) + + # Retrieved transform should be the new one + retrieved = self.registry.get_transform("world", "base") + np.testing.assert_array_almost_equal( + retrieved.translation, new_transform.translation + ) + np.testing.assert_array_almost_equal( + retrieved.rotation.as_matrix(), new_transform.rotation.as_matrix() + ) + + def test_update_transform_with_numpy_array(self): + """Test updating transform with numpy array.""" + self.registry.add_transform("world", "base", self.simple_transform) + + new_matrix = self.complex_transform.as_matrix() + self.registry.update("world", "base", new_matrix) + + retrieved = self.registry.get_transform("world", "base") + np.testing.assert_array_almost_equal( + retrieved.translation, self.complex_transform.translation + ) + + def test_update_nonexistent_from_frame(self): + """Test updating transform with nonexistent from_frame.""" + with self.assertRaises(InvalidTransformError): + self.registry.update("nonexistent", "world", self.simple_transform) + + def test_update_nonexistent_to_frame(self): + """Test updating transform with nonexistent to_frame.""" + with self.assertRaises(InvalidTransformError): + self.registry.update("world", "nonexistent", self.simple_transform) + + def test_update_unconnected_frames(self): + """Test updating transform between unconnected frames.""" + self.registry.add_transform("world", "base", self.simple_transform) + + # Add another frame not connected to base + registry2 = Registry("other_world") + registry2.add_transform("other_world", "other_base", self.complex_transform) + + # Try to update unconnected frames in original registry + with self.assertRaises(InvalidTransformError): + self.registry.update("world", "other_base", self.simple_transform) + + def test_update_invalid_matrix_shape(self): + """Test updating with invalid matrix shape.""" + self.registry.add_transform("world", "base", self.simple_transform) + + invalid_matrix = np.eye(3) # Wrong shape + with self.assertRaises(ValueError): + self.registry.update("world", "base", invalid_matrix) + + def test_update_invalid_type(self): + """Test updating with invalid type.""" + self.registry.add_transform("world", "base", self.simple_transform) + + with self.assertRaises(ValueError): + self.registry.update("world", "base", "invalid") + + def test_complex_frame_hierarchy(self): + """Test complex frame hierarchy with multiple branches.""" + # Create hierarchy: + # world + # / \ + # base1 base2 + # | | + # camera1 camera2 + + base1_transform = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + base2_transform = Transform( + np.array([0.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + camera1_transform = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [90, 0, 0], degrees=True), + ) + camera2_transform = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [0, 90, 0], degrees=True), + ) + + self.registry.add_transform("world", "base1", base1_transform) + self.registry.add_transform("world", "base2", base2_transform) + self.registry.add_transform("base1", "camera1", camera1_transform) + self.registry.add_transform("base2", "camera2", camera2_transform) + + # Test transforms across branches + transform_camera1_to_camera2 = self.registry.get_transform("camera1", "camera2") + self.assertIsInstance(transform_camera1_to_camera2, Transform) + + # Test round trip + forward = self.registry.get_transform("camera1", "camera2") + backward = self.registry.get_transform("camera2", "camera1") + + # Forward @ backward should be approximately identity + composed = forward.as_matrix() @ backward.as_matrix() + np.testing.assert_array_almost_equal(composed, np.eye(4), decimal=10) + + def test_thread_safety_concurrent_reads(self): + """Test thread safety with concurrent reads.""" + # Add some transforms + self.registry.add_transform("world", "base", self.simple_transform) + self.registry.add_transform("base", "camera", self.complex_transform) + + results = [] + errors = [] + + def read_transform(): + try: + for _ in range(100): + transform = self.registry.get_transform("world", "camera") + results.append(transform.translation.copy()) + time.sleep(0.001) # Small delay to encourage race conditions + except Exception as e: + errors.append(e) + + # Start multiple reader threads + threads = [] + for _ in range(5): + thread = threading.Thread(target=read_transform) + threads.append(thread) + thread.start() + + # Wait for all threads + for thread in threads: + thread.join() + + # Should have no errors + self.assertEqual(len(errors), 0) + + # All results should be the same + expected = self.registry.get_transform("world", "camera").translation + for result in results: + np.testing.assert_array_almost_equal(result, expected) + + def test_thread_safety_concurrent_writes(self): + """Test thread safety with concurrent writes.""" + self.registry.add_transform("world", "base", self.simple_transform) + + errors = [] + + def update_transform(value): + try: + for i in range(10): + new_transform = Transform( + np.array([value, value, value]), + Rotation.from_euler("xyz", [0, 0, value], degrees=True), + ) + self.registry.update("world", "base", new_transform) + time.sleep(0.001) + except Exception as e: + errors.append(e) + + # Start multiple writer threads with different values + threads = [] + for i in range(3): + thread = threading.Thread(target=update_transform, args=(i + 1,)) + threads.append(thread) + thread.start() + + # Wait for all threads + for thread in threads: + thread.join() + + # Should have no errors (though final state is unpredictable) + self.assertEqual(len(errors), 0) + + # Registry should still be in a valid state + final_transform = self.registry.get_transform("world", "base") + self.assertIsInstance(final_transform, Transform) + + def test_thread_safety_mixed_operations(self): + """Test thread safety with mixed read/write operations.""" + self.registry.add_transform("world", "base", self.simple_transform) + + errors = [] + read_results = [] + + def reader(): + try: + for _ in range(50): + transform = self.registry.get_transform("world", "base") + read_results.append(transform.translation.copy()) + time.sleep(0.001) + except Exception as e: + errors.append(e) + + def writer(): + try: + for i in range(25): + new_transform = Transform( + np.array([i, i, i]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + self.registry.update("world", "base", new_transform) + time.sleep(0.002) + except Exception as e: + errors.append(e) + + # Start mixed threads + threads = [] + for _ in range(3): + threads.append(threading.Thread(target=reader)) + for _ in range(2): + threads.append(threading.Thread(target=writer)) + + for thread in threads: + thread.start() + + for thread in threads: + thread.join() + + # Should have no errors + self.assertEqual(len(errors), 0) + + # Should have some read results + self.assertGreater(len(read_results), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transform_composition.py b/tests/test_transform_composition.py new file mode 100644 index 0000000..0255a0d --- /dev/null +++ b/tests/test_transform_composition.py @@ -0,0 +1,154 @@ +"""Tests for Transform composition behavior. + +These tests verify that Transform composition works correctly and that +the @ operator properly implements 3D transformation composition. +""" + +import unittest +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms import Transform + + +class TestTransformComposition(unittest.TestCase): + """Test cases for Transform composition behavior.""" + + def test_transform_composition_simple_case(self): + """Test that transform composition works correctly.""" + # First transform: translate [1,0,0] then rotate 90° around Z + t1 = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + # Second transform: translate [1,0,0], no rotation + t2 = Transform( + np.array([1.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + # Current (incorrect) implementation + current_result = t1 @ t2 + + # Correct result using matrix composition + correct_matrix = t1.as_matrix() @ t2.as_matrix() + expected_translation = correct_matrix[:3, 3] + expected_rotation = Rotation.from_matrix(correct_matrix[:3, :3]) + + print(f"Current result: {current_result.translation}") + print(f"Expected result: {expected_translation}") + + # This test should now PASS with the fixed implementation + np.testing.assert_array_almost_equal( + current_result.translation, + expected_translation, + err_msg="Transform composition should now be correct", + ) + + def test_vector_transformation(self): + """Test that vector transformation works correctly.""" + transform = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [0, 0, 90], degrees=True), + ) + + vector = np.array([1.0, 0.0, 0.0]) + + # Current (incorrect) implementation + current_result = transform @ vector + + # Correct transformation: rotate first, then translate + correct_result = transform.rotation.apply(vector) + transform.translation + + print(f"Current vector result: {current_result}") + print(f"Expected vector result: {correct_result}") + + # This test should now PASS with the fixed implementation + np.testing.assert_array_almost_equal( + current_result, + correct_result, + err_msg="Vector transformation should now be correct", + ) + + def test_matrix_vs_transform_composition(self): + """Test that Transform @ gives same results as matrix composition.""" + t1 = Transform( + np.array([2.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 45], degrees=True), + ) + + t2 = Transform( + np.array([0.0, 0.0, 1.0]), + Rotation.from_euler("xyz", [0, 90, 0], degrees=True), + ) + + # Transform composition (current implementation) + transform_result = t1 @ t2 + + # Matrix composition (correct) + matrix_result_matrix = t1.as_matrix() @ t2.as_matrix() + matrix_result = Transform( + matrix_result_matrix[:3, 3], + Rotation.from_matrix(matrix_result_matrix[:3, :3]), + ) + + print("Transform @ result:") + print(f" Translation: {transform_result.translation}") + print(f" Rotation: {transform_result.rotation.as_euler('xyz', degrees=True)}") + + print("Matrix @ result:") + print(f" Translation: {matrix_result.translation}") + print(f" Rotation: {matrix_result.rotation.as_euler('xyz', degrees=True)}") + + # These should now be the same with the fixed implementation + translation_same = np.allclose( + transform_result.translation, matrix_result.translation + ) + rotation_same = np.allclose( + transform_result.rotation.as_matrix(), matrix_result.rotation.as_matrix() + ) + + self.assertTrue( + translation_same and rotation_same, + "Transform @ and matrix @ should give same result with fixed implementation", + ) + + def test_identity_composition(self): + """Test that identity composition works correctly.""" + # Non-identity transform + transform = Transform( + np.array([1.0, 1.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 45], degrees=True), + ) + + # Identity transform + identity = Transform( + np.array([0.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + # These should give the same result as the original transform + result1 = transform @ identity + result2 = identity @ transform + + print("Original transform translation:", transform.translation) + print("transform @ identity:", result1.translation) + print("identity @ transform:", result2.translation) + + # Both should now pass with the fixed implementation + same1 = np.allclose(result1.translation, transform.translation) + same2 = np.allclose(result2.translation, transform.translation) + + if not same1: + print("BUG: transform @ identity != transform") + if not same2: + print("BUG: identity @ transform != transform") + + # Both should now work with the fixed implementation + self.assertTrue(same1, "transform @ identity should equal transform") + self.assertTrue(same2, "identity @ transform should equal transform") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transforms.py b/tests/test_transforms.py new file mode 100644 index 0000000..68ad953 --- /dev/null +++ b/tests/test_transforms.py @@ -0,0 +1,193 @@ +"""Tests for the Transform class.""" + +import unittest +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms import Transform, Pose, Registry, InvalidTransformError + + +class TestTransform(unittest.TestCase): + """Test cases for the Transform class.""" + + def setUp(self): + """Set up test fixtures.""" + self.identity_transform = Transform( + np.array([0.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + self.translation_transform = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + self.rotation_transform = Transform( + np.array([0.0, 0.0, 0.0]), + Rotation.from_euler("xyz", [90, 0, 0], degrees=True), + ) + + self.combined_transform = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [90, 45, 30], degrees=True), + ) + + def test_init_valid(self): + """Test valid Transform initialization.""" + transform = Transform( + np.array([1.0, 2.0, 3.0]), + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + np.testing.assert_array_equal(transform.translation, [1.0, 2.0, 3.0]) + self.assertIsInstance(transform.rotation, Rotation) + + def test_init_invalid_translation_shape(self): + """Test Transform initialization with invalid translation shape.""" + with self.assertRaises(ValueError): + Transform( + np.array([1.0, 2.0]), # Wrong shape + Rotation.from_euler("xyz", [0, 0, 0], degrees=True), + ) + + def test_init_invalid_rotation_type(self): + """Test Transform initialization with invalid rotation type.""" + with self.assertRaises(TypeError): + Transform(np.array([1.0, 2.0, 3.0]), "not_a_rotation") # Wrong type + + def test_translation_property_immutable(self): + """Test that translation property returns a copy.""" + transform = self.translation_transform + translation = transform.translation + translation[0] = 999.0 # Modify the returned array + # Original should be unchanged + np.testing.assert_array_equal(transform.translation, [1.0, 2.0, 3.0]) + + def test_rotation_property(self): + """Test rotation property access.""" + transform = self.rotation_transform + rotation = transform.rotation + self.assertIsInstance(rotation, Rotation) + + def test_as_matrix_identity(self): + """Test conversion to matrix for identity transform.""" + matrix = self.identity_transform.as_matrix() + expected = np.eye(4) + np.testing.assert_array_almost_equal(matrix, expected) + + def test_as_matrix_translation_only(self): + """Test conversion to matrix for translation-only transform.""" + matrix = self.translation_transform.as_matrix() + expected = np.eye(4) + expected[:3, 3] = [1.0, 2.0, 3.0] + np.testing.assert_array_almost_equal(matrix, expected) + + def test_as_matrix_rotation_only(self): + """Test conversion to matrix for rotation-only transform.""" + matrix = self.rotation_transform.as_matrix() + expected = np.eye(4) + expected[:3, :3] = Rotation.from_euler( + "xyz", [90, 0, 0], degrees=True + ).as_matrix() + np.testing.assert_array_almost_equal(matrix, expected) + + def test_matmul_with_transform(self): + """Test matrix multiplication with another Transform.""" + result = self.translation_transform @ self.rotation_transform + + self.assertIsInstance(result, Transform) + # Translation should be combined + expected_translation = ( + self.translation_transform.translation + self.rotation_transform.translation + ) + np.testing.assert_array_almost_equal(result.translation, expected_translation) + + # Rotation should be composed + expected_rotation = ( + self.translation_transform.rotation * self.rotation_transform.rotation + ) + np.testing.assert_array_almost_equal( + result.rotation.as_matrix(), expected_rotation.as_matrix() + ) + + def test_matmul_with_3d_vector(self): + """Test matrix multiplication with a 3D vector.""" + vector = np.array([1.0, 0.0, 0.0]) + result = self.combined_transform @ vector + + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (3,)) + + # Should apply rotation then translation + expected = ( + self.combined_transform.rotation.apply(vector) + + self.combined_transform.translation + ) + np.testing.assert_array_almost_equal(result, expected) + + def test_matmul_with_4x4_matrix(self): + """Test matrix multiplication with a 4x4 matrix.""" + matrix = np.eye(4) + matrix[:3, 3] = [5.0, 6.0, 7.0] # Add some translation + + result = self.combined_transform @ matrix + + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (4, 4)) + + # Should be equivalent to matrix multiplication + expected = self.combined_transform.as_matrix() @ matrix + np.testing.assert_array_almost_equal(result, expected) + + def test_matmul_with_pose(self): + """Test matrix multiplication with a Pose.""" + registry = Registry("world") + pose = Pose(self.translation_transform, "world", registry) + + result = self.rotation_transform @ pose + + self.assertIsInstance(result, Pose) + self.assertEqual(result.parent_frame, "world") + self.assertIs(result.registry, registry) + + def test_matmul_invalid_shape(self): + """Test matrix multiplication with invalid array shape.""" + invalid_array = np.array([1.0, 2.0]) # Wrong shape + + with self.assertRaises(ValueError): + _ = self.translation_transform @ invalid_array + + def test_matmul_invalid_type(self): + """Test matrix multiplication with invalid type.""" + with self.assertRaises(TypeError): + _ = self.translation_transform @ "invalid" + + def test_transform_composition_associative(self): + """Test that transform composition is associative.""" + t1 = self.translation_transform + t2 = self.rotation_transform + t3 = self.combined_transform + + # (t1 @ t2) @ t3 should equal t1 @ (t2 @ t3) + left_assoc = (t1 @ t2) @ t3 + right_assoc = t1 @ (t2 @ t3) + + np.testing.assert_array_almost_equal( + left_assoc.translation, right_assoc.translation + ) + np.testing.assert_array_almost_equal( + left_assoc.rotation.as_matrix(), right_assoc.rotation.as_matrix() + ) + + def test_frozen_dataclass(self): + """Test that Transform is immutable (frozen dataclass).""" + transform = self.translation_transform + + with self.assertRaises(Exception): # Should be FrozenInstanceError + transform._translation = np.array([999.0, 999.0, 999.0]) + + with self.assertRaises(Exception): # Should be FrozenInstanceError + transform._rotation = Rotation.from_euler("xyz", [180, 0, 0], degrees=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..27a4644 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,180 @@ +"""Tests for the utils module.""" + +import unittest +import numpy as np +from scipy.spatial.transform import Rotation + +from frame_transforms.utils import make_3d_transformation + + +class TestUtils(unittest.TestCase): + """Test cases for the utils module.""" + + def test_make_3d_transformation_identity(self): + """Test creating identity transformation matrix.""" + translation = np.array([0.0, 0.0, 0.0]) + rotation = Rotation.from_euler("xyz", [0, 0, 0], degrees=True) + + matrix = make_3d_transformation(translation, rotation) + + expected = np.eye(4) + np.testing.assert_array_almost_equal(matrix, expected) + + def test_make_3d_transformation_translation_only(self): + """Test creating transformation matrix with translation only.""" + translation = np.array([1.0, 2.0, 3.0]) + rotation = Rotation.from_euler("xyz", [0, 0, 0], degrees=True) + + matrix = make_3d_transformation(translation, rotation) + + expected = np.eye(4) + expected[:3, 3] = [1.0, 2.0, 3.0] + np.testing.assert_array_almost_equal(matrix, expected) + + def test_make_3d_transformation_rotation_only(self): + """Test creating transformation matrix with rotation only.""" + translation = np.array([0.0, 0.0, 0.0]) + rotation = Rotation.from_euler("xyz", [90, 0, 0], degrees=True) + + matrix = make_3d_transformation(translation, rotation) + + expected = np.eye(4) + expected[:3, :3] = rotation.as_matrix() + np.testing.assert_array_almost_equal(matrix, expected) + + def test_make_3d_transformation_combined(self): + """Test creating transformation matrix with both translation and rotation.""" + translation = np.array([1.5, -2.3, 4.7]) + rotation = Rotation.from_euler("xyz", [45, 30, 60], degrees=True) + + matrix = make_3d_transformation(translation, rotation) + + # Check shape + self.assertEqual(matrix.shape, (4, 4)) + + # Check homogeneous coordinates + np.testing.assert_array_almost_equal(matrix[3, :], [0, 0, 0, 1]) + + # Check translation part + np.testing.assert_array_almost_equal(matrix[:3, 3], translation) + + # Check rotation part + np.testing.assert_array_almost_equal(matrix[:3, :3], rotation.as_matrix()) + + def test_make_3d_transformation_invalid_translation_shape(self): + """Test creating transformation with invalid translation shape.""" + translation = np.array([1.0, 2.0]) # Wrong shape + rotation = Rotation.from_euler("xyz", [0, 0, 0], degrees=True) + + with self.assertRaises(AssertionError): + make_3d_transformation(translation, rotation) + + def test_make_3d_transformation_invalid_translation_type(self): + """Test creating transformation with invalid translation type.""" + translation = [1.0, 2.0, 3.0] # List instead of numpy array + rotation = Rotation.from_euler("xyz", [0, 0, 0], degrees=True) + + with self.assertRaises(AttributeError): # .shape attribute doesn't exist + make_3d_transformation(translation, rotation) + + def test_make_3d_transformation_various_rotations(self): + """Test creating transformations with various rotation representations.""" + translation = np.array([1.0, 1.0, 1.0]) + + # Euler angles + rotation_euler = Rotation.from_euler("xyz", [30, 45, 60], degrees=True) + matrix_euler = make_3d_transformation(translation, rotation_euler) + + # Quaternion (same rotation) + quat = rotation_euler.as_quat() + rotation_quat = Rotation.from_quat(quat) + matrix_quat = make_3d_transformation(translation, rotation_quat) + + # Should be the same + np.testing.assert_array_almost_equal(matrix_euler, matrix_quat) + + def test_make_3d_transformation_matrix_properties(self): + """Test that created matrix has proper transformation matrix properties.""" + translation = np.array([2.0, -1.5, 3.7]) + rotation = Rotation.from_euler("xyz", [15, 25, 35], degrees=True) + + matrix = make_3d_transformation(translation, rotation) + + # Should be 4x4 + self.assertEqual(matrix.shape, (4, 4)) + + # Bottom row should be [0, 0, 0, 1] + np.testing.assert_array_almost_equal(matrix[3, :], [0, 0, 0, 1]) + + # Rotation part should be orthogonal + rotation_part = matrix[:3, :3] + should_be_identity = rotation_part @ rotation_part.T + np.testing.assert_array_almost_equal(should_be_identity, np.eye(3)) + + # Determinant of rotation part should be 1 + self.assertAlmostEqual(np.linalg.det(rotation_part), 1.0, places=10) + + def test_make_3d_transformation_edge_cases(self): + """Test edge cases for transformation creation.""" + # Very small translation + small_translation = np.array([1e-15, 1e-15, 1e-15]) + rotation = Rotation.from_euler("xyz", [0, 0, 0], degrees=True) + + matrix = make_3d_transformation(small_translation, rotation) + self.assertEqual(matrix.shape, (4, 4)) + + # Very large translation + large_translation = np.array([1e6, -1e6, 1e6]) + matrix_large = make_3d_transformation(large_translation, rotation) + np.testing.assert_array_almost_equal(matrix_large[:3, 3], large_translation) + + def test_make_3d_transformation_consistency_with_transform_class(self): + """Test that utils function creates same matrix as Transform.as_matrix().""" + from frame_transforms import Transform + + translation = np.array([1.2, -3.4, 5.6]) + rotation = Rotation.from_euler("xyz", [78, 123, 45], degrees=True) + + # Create using utils function + matrix_utils = make_3d_transformation(translation, rotation) + + # Create using Transform class + transform = Transform(translation, rotation) + matrix_transform = transform.as_matrix() + + # Should be identical + np.testing.assert_array_almost_equal(matrix_utils, matrix_transform) + + def test_make_3d_transformation_multiple_calls_consistent(self): + """Test that multiple calls with same inputs produce same result.""" + translation = np.array([0.5, 1.5, 2.5]) + rotation = Rotation.from_euler("xyz", [10, 20, 30], degrees=True) + + matrix1 = make_3d_transformation(translation, rotation) + matrix2 = make_3d_transformation(translation, rotation) + + np.testing.assert_array_equal(matrix1, matrix2) + + def test_make_3d_transformation_copy_safety(self): + """Test that modifying input arrays doesn't affect the result.""" + translation = np.array([1.0, 2.0, 3.0]) + rotation = Rotation.from_euler("xyz", [0, 0, 0], degrees=True) + + matrix = make_3d_transformation(translation, rotation) + original_matrix = matrix.copy() + + # Modify input translation + translation[0] = 999.0 + + # Create new matrix with modified input + new_matrix = make_3d_transformation(translation, rotation) + + # Original matrix should be unchanged + np.testing.assert_array_equal(matrix, original_matrix) + + # New matrix should be different + self.assertFalse(np.array_equal(matrix[:3, 3], new_matrix[:3, 3])) + + +if __name__ == "__main__": + unittest.main() From 005d2f607a9880453c5b6c73efe1b5c8b2a10b29 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:40:02 -0700 Subject: [PATCH 10/18] chore(pyproject): remove duplicate dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0de5591..812150f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ build = [ ] dev = [ - "pytest >= 7.4", "flake8 >= 7.3", "isort >= 5.12", "scipy-stubs >= 1.15", From 8849468cf9503bbd73c7a5ff7c48ce516ba362bb Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:42:42 -0700 Subject: [PATCH 11/18] docs(README): dev setup --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 3905027..6f87f27 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,12 @@ orientation_in_world = object_pose.get_orientation(Frame.WORLD) ``` # [Examples](https://github.com/MinhxNguyen7/FrameTransforms/blob/main/example.py) + +# Development Setup + +- Clone and `cd` into this repository. +- Set up virtual environment. + - `python -m venv venv` + - `source venv/bin/activate` (Linux/Mac) or `venv\Scripts\activate` (Windows) +- Install package with dev and test dependencies + - `pip install -e '.[dev,test]'` From 59342476a16040046c12d2bb02c588d3ea411dfd Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:57:08 -0700 Subject: [PATCH 12/18] chore(pyproject): add python versions --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 812150f..8b7e6cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "hatchling.build" name = "frame-transforms" version = "0.2.0" readme = "README.md" +python = "^3.10" description = "Automatically compute and apply coordinate frame transformations" keywords = ["coordinate", "frame", "transformation", "pose", "geometry", "robotics"] @@ -13,6 +14,10 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] requires-python = ">=3.10" From 9de1bbb951e1d835a96b59a1a624fe5689e292d6 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:57:19 -0700 Subject: [PATCH 13/18] feat(workflows): test coverage report --- .github/workflows/test.yml | 49 ++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8a5507..197fe55 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,22 +2,45 @@ name: Run Tests on: push: + pull_request: workflow_dispatch: jobs: test: - name: Run Tests 🧪 + name: Run Tests with coverage runs-on: ubuntu-latest - + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - name: Install dependencies - run: python3 -m pip install .[test] - - name: Run tests - run: python3 -m pytest tests/*.py + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install '.[test]' + + - name: Run tests with coverage + run: | + pytest --cov=frame_transforms --cov-report=xml --cov-report=term-missing tests/ + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + verbose: true + + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + uses: py-cov-action/python-coverage-comment-action@v3 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MINIMUM_GREEN: 80 + MINIMUM_ORANGE: 70 From 5e48de50331f531b3de3664d47f4e208e5e919c6 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:02:44 -0700 Subject: [PATCH 14/18] fix(coverage): relative path for comment action --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index e4c81e1..abb7c2a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,3 @@ [run] +relative_files = true omit = ./example*, ./tests/* From ddbe9d2fc9589453601fc57a7942113d2dbc21e1 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:09:23 -0700 Subject: [PATCH 15/18] fix(test.yml): remove codecov --- .github/workflows/test.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 197fe55..7dbc8f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,18 +24,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install '.[test]' + pip install -e '.[test]' - name: Run tests with coverage run: | - pytest --cov=frame_transforms --cov-report=xml --cov-report=term-missing tests/ - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - file: ./coverage.xml - fail_ci_if_error: false - verbose: true + python -m pytest --cov=frame_transforms --cov-report=xml --cov-report=term-missing tests/ - name: Comment coverage on PR if: github.event_name == 'pull_request' From 8893d47e078af2cb9f78e228dc2805439621ead6 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:37:00 -0700 Subject: [PATCH 16/18] docs(README): update with more information --- README.md | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f87f27..2ceeb66 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,19 @@ -# Description -FrameTransforms is a lightweight, native Python pacakge to simplify frame transforms. It supports: +# frame-transforms +`frame-transforms` is a lightweight, thread-safe, Python-native package to simplify frame transforms in robotics. With it, you can manage and translate between coordinate frames with ease. It features: -1. Registration and update of relative coordinate frames. -2. Automatic computation of transitive transforms. -3. Multithreaded access. +1. Automatic computation of transitive transforms. +2. Registration and update of relative coordinate frames. +3. An intuitive, object-oriented API. + +Though in beta, the library is extensively tested. + +This package was inspired by the interface of `posetree` and shares much of its functionality but offers a more batteries-included experience. Similarly to [posetree](https://github.com/robobenjie/posetree?tab=readme-ov-file#philosophy-of-transforms-poses-and-frames)'s nomenclature, `Pose` is a location and orientation in space, whereas `Transform` is an action that describes the change in position and orientation to get from one `Pose` to another. + +## Installation + +```bash +pip install frame-transforms +``` ## Application Consider a simple robot consisting of a mobile base and a camera mounted on a gimbal. @@ -12,8 +22,8 @@ The camera detects an object in its coordinate frame. Where is it in world frame ```python # Setup -registry.update(Frame.WORLD, Frame.BASE, base_pose) -registry.update(Frame.BASE, Frame.CAMERA, camera_pose) +registry.update(Frame.WORLD, Frame.BASE, base_transform) +registry.update(Frame.BASE, Frame.CAMERA, camera_transform) # Define the Pose object_pose = Pose( @@ -30,6 +40,14 @@ position_in_world = object_pose.get_position(Frame.WORLD) orientation_in_world = object_pose.get_orientation(Frame.WORLD) ``` +## ROS Integration + +Simply wrap the `Register` in a ROS node, subscribing to pose updates and publishing/service-calling the poses. The `Register` is thread-safe, so callbacks are simple. + +## Alternatives +- `tf2`: Heavyweight, requires ROS. +- `posetree`: Requires `PoseTree` subclass implementation. + # [Examples](https://github.com/MinhxNguyen7/FrameTransforms/blob/main/example.py) # Development Setup From 5fe07b053d70c92f0535324d300adc2c9fe0373b Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:37:32 -0700 Subject: [PATCH 17/18] chore(pyproject): bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8b7e6cf..61cd52f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "frame-transforms" -version = "0.2.0" +version = "0.3.0" readme = "README.md" python = "^3.10" From 13825a1173af7edfa2c2e5dd0b3f6cac048e8831 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:42:35 -0700 Subject: [PATCH 18/18] ci(test.yml): branch coverage --- .github/workflows/test.yml | 4 +++- pyproject.toml | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7dbc8f6..2b5d525 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: - name: Run tests with coverage run: | - python -m pytest --cov=frame_transforms --cov-report=xml --cov-report=term-missing tests/ + python -m pytest --cov-report=xml tests/ - name: Comment coverage on PR if: github.event_name == 'pull_request' @@ -37,3 +37,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MINIMUM_GREEN: 80 MINIMUM_ORANGE: 70 + ANNOTATE_MISSING_LINES: true + ANNOTATION_TYPE: notice diff --git a/pyproject.toml b/pyproject.toml index 61cd52f..e9da53f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,4 +46,12 @@ test = [ ] [project.urls] -Homepage = "https://github.com/MinhxNguyen7/FrameTransforms/" \ No newline at end of file +Homepage = "https://github.com/MinhxNguyen7/FrameTransforms/" + +[tool.pytest.ini_options] +addopts = ["--cov=frame_transforms", "--cov-branch", "--cov-report=term-missing", "--cov-report=html"] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["frame_transforms"]