diff --git a/src/trackers/motion/estimator.py b/src/trackers/motion/estimator.py index dcc6b4a3..4aa63dab 100644 --- a/src/trackers/motion/estimator.py +++ b/src/trackers/motion/estimator.py @@ -25,6 +25,10 @@ _OPTICAL_FLOW_EPSILON = 0.01 _MIN_POINTS_FOR_HOMOGRAPHY = 4 +# Below this the accumulated projective scale is treated as degenerate rather than renormalized, +# since dividing by it would amplify the matrix into uselessly large values. +_MIN_HOMOGRAPHY_SCALE = 1e-8 + class MotionEstimator: """Estimates camera motion between consecutive video frames. @@ -242,9 +246,25 @@ def _estimate_homography( return None # H_total = H_current @ H_previous gives transformation from frame 0 - self._accumulated_homography = homography_matrix @ self._accumulated_homography + accumulated_homography = homography_matrix @ self._accumulated_homography + + # Chained products drift in overall projective scale; pinning w back to 1 keeps the + # accumulator bounded over long sequences instead of compounding every frame. + scale = accumulated_homography[2, 2] + if np.isfinite(scale) and abs(scale) >= _MIN_HOMOGRAPHY_SCALE: + accumulated_homography = accumulated_homography / scale + + try: + transformation = HomographyTransformation(accumulated_homography) + except ValueError: + # The accumulator has degenerated past the point of being invertible. Re-baseline the + # world frame instead of raising, since a corrupt accumulator never recovers on its own. + logger.warning("MotionEstimator: accumulated homography degenerated; re-baselining the world frame") + self._reset_accumulator() + return IdentityTransformation() - return HomographyTransformation(self._accumulated_homography) + self._accumulated_homography = accumulated_homography + return transformation def _get_current_transformation(self) -> CoordinatesTransformation: """Get the current accumulated transformation. diff --git a/src/trackers/motion/transformation.py b/src/trackers/motion/transformation.py index c7eda905..faa03c17 100644 --- a/src/trackers/motion/transformation.py +++ b/src/trackers/motion/transformation.py @@ -78,7 +78,8 @@ class HomographyTransformation(CoordinatesTransformation): coordinates. Raises: - ValueError: If the matrix is not 3x3. + ValueError: If the matrix is not 3x3, is not finite, or is singular + and therefore cannot be inverted. Example: ```python @@ -102,7 +103,14 @@ def __init__(self, homography_matrix: np.ndarray) -> None: self.homography_matrix = np.array(homography_matrix, dtype=np.float64) if self.homography_matrix.shape != (3, 3): raise ValueError(f"Homography matrix must be 3x3, got {self.homography_matrix.shape}") - self.inverse_homography_matrix = np.linalg.inv(self.homography_matrix) + if not np.isfinite(self.homography_matrix).all(): + raise ValueError(f"Homography matrix must be finite, got:\n{self.homography_matrix}") + try: + self.inverse_homography_matrix = np.linalg.inv(self.homography_matrix) + except np.linalg.LinAlgError as error: + raise ValueError( + f"Homography matrix is singular and cannot be inverted, got:\n{self.homography_matrix}" + ) from error def _transform_points(self, points: np.ndarray, matrix: np.ndarray) -> np.ndarray: """Apply homography transformation to points. diff --git a/tests/motion/test_estimator.py b/tests/motion/test_estimator.py index 5c6d46c8..09726ff7 100644 --- a/tests/motion/test_estimator.py +++ b/tests/motion/test_estimator.py @@ -7,9 +7,14 @@ from __future__ import annotations import numpy as np +import pytest from trackers.motion.estimator import MotionEstimator -from trackers.motion.transformation import CoordinatesTransformation +from trackers.motion.transformation import ( + CoordinatesTransformation, + HomographyTransformation, + IdentityTransformation, +) def _noise_frame(height: int, width: int, seed: int) -> np.ndarray: @@ -17,6 +22,15 @@ def _noise_frame(height: int, width: int, seed: int) -> np.ndarray: return rng.integers(0, 255, (height, width, 3), dtype=np.uint8) +def _translated_correspondences() -> tuple[np.ndarray, np.ndarray]: + """Return point sets related by a pure translation, enough for findHomography to succeed.""" + previous = np.array( + [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [5.0, 2.0], [2.0, 7.0]], + dtype=np.float32, + ) + return previous, previous + np.array([3.0, -2.0], dtype=np.float32) + + def test_motion_estimator_survives_resolution_change() -> None: """A frame size change mid-stream returns a transformation instead of crashing. @@ -59,3 +73,32 @@ def test_motion_estimator_resets_frame_on_resolution_change() -> None: point = np.array([[0.0, 0.0]], dtype=np.float32) np.testing.assert_allclose(transform.abs_to_rel(point), point) # re-baselined, not 50/30 + + +def test_estimate_homography_normalizes_accumulated_scale() -> None: + """Chained homographies are renormalized so the projective scale stays pinned at 1. + + Each `update` multiplies the accumulator by the frame-to-frame homography. Without renormalizing, the overall scale + compounds every frame and eventually drives the accumulator into a numerically degenerate state. + """ + estimator = MotionEstimator() + estimator._accumulated_homography = np.eye(3) * 4.0 # scale left over from earlier chaining + previous, current = _translated_correspondences() + + transform = estimator._estimate_homography(previous, current) + + assert isinstance(transform, HomographyTransformation) + assert transform.homography_matrix[2, 2] == pytest.approx(1.0) + assert estimator._accumulated_homography[2, 2] == pytest.approx(1.0) + + +def test_estimate_homography_rebaselines_degenerate_accumulator() -> None: + """A degenerate accumulator re-baselines to identity instead of raising.""" + estimator = MotionEstimator() + estimator._accumulated_homography = np.zeros((3, 3)) + previous, current = _translated_correspondences() + + transform = estimator._estimate_homography(previous, current) + + assert isinstance(transform, IdentityTransformation) + np.testing.assert_allclose(estimator._accumulated_homography, np.eye(3)) diff --git a/tests/motion/test_transformation.py b/tests/motion/test_transformation.py new file mode 100644 index 00000000..b18ac681 --- /dev/null +++ b/tests/motion/test_transformation.py @@ -0,0 +1,72 @@ +# ------------------------------------------------------------------------ +# Trackers +# Copyright (c) 2026 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +"""Unit tests for coordinate transformations.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from trackers.motion.transformation import HomographyTransformation, IdentityTransformation + +TRANSLATION_MATRIX = np.array([[1.0, 0.0, 10.0], [0.0, 1.0, 20.0], [0.0, 0.0, 1.0]]) + + +def test_identity_transformation_returns_points_unchanged() -> None: + """IdentityTransformation is a no-op in both directions.""" + points = np.array([[100.0, 200.0], [300.0, 400.0]]) + transformation = IdentityTransformation() + + np.testing.assert_allclose(transformation.abs_to_rel(points), points) + np.testing.assert_allclose(transformation.rel_to_abs(points), points) + + +def test_homography_transformation_roundtrip() -> None: + """abs_to_rel followed by rel_to_abs recovers the original points.""" + points = np.array([[100.0, 200.0], [300.0, 400.0]]) + transformation = HomographyTransformation(TRANSLATION_MATRIX) + + relative = transformation.abs_to_rel(points) + + np.testing.assert_allclose(relative, points + np.array([10.0, 20.0])) + np.testing.assert_allclose(transformation.rel_to_abs(relative), points) + + +def test_homography_transformation_rejects_wrong_shape() -> None: + """A matrix that is not 3x3 is rejected.""" + with pytest.raises(ValueError, match="must be 3x3"): + HomographyTransformation(np.eye(2)) + + +@pytest.mark.parametrize( + "matrix", + [ + pytest.param(np.zeros((3, 3)), id="all-zero"), + pytest.param(np.ones((3, 3)), id="rank-one"), + pytest.param(np.array([[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 0.0, 1.0]]), id="duplicate-row"), + ], +) +def test_homography_transformation_rejects_singular_matrix(matrix: np.ndarray) -> None: + """A singular matrix raises ValueError rather than surfacing a bare LinAlgError.""" + with pytest.raises(ValueError, match="singular"): + HomographyTransformation(matrix) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(np.nan, id="nan"), + pytest.param(np.inf, id="inf"), + ], +) +def test_homography_transformation_rejects_non_finite_matrix(value: float) -> None: + """A matrix carrying NaN or infinity is rejected before it can poison every transform.""" + matrix = TRANSLATION_MATRIX.copy() + matrix[0, 2] = value + + with pytest.raises(ValueError, match="finite"): + HomographyTransformation(matrix)