Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/trackers/motion/estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment on lines +253 to +255

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.
Expand Down
12 changes: 10 additions & 2 deletions src/trackers/motion/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +108 to +113

def _transform_points(self, points: np.ndarray, matrix: np.ndarray) -> np.ndarray:
"""Apply homography transformation to points.
Expand Down
45 changes: 44 additions & 1 deletion tests/motion/test_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,30 @@
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:
rng = np.random.default_rng(seed)
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.

Expand Down Expand Up @@ -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))
72 changes: 72 additions & 0 deletions tests/motion/test_transformation.py
Original file line number Diff line number Diff line change
@@ -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)