Files
gps-denied-onboard/tests/test_vo.py
T
Yuzviak 84e2f048e3 fix(lint): ruff --fix import ordering in new test files
CI lint job flagged I001 (un-sorted imports) in:
- tests/test_gps_input_encoding.py (top-level)
- tests/test_vo.py (2 inline imports in new mono_depth tests)

Applied ruff --fix: stdlib / third-party / first-party blocks with correct
blank-line separators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:41:41 +03:00

346 lines
13 KiB
Python

"""Tests for Sequential Visual Odometry (F07)."""
import numpy as np
import pytest
from gps_denied.core.models import ModelManager
from gps_denied.core.vo import (
CuVSLAMVisualOdometry,
ISequentialVisualOdometry,
ORBVisualOdometry,
SequentialVisualOdometry,
create_vo_backend,
)
from gps_denied.schemas import CameraParameters
from gps_denied.schemas.vo import Features, Matches
@pytest.fixture
def vo():
manager = ModelManager()
return SequentialVisualOdometry(manager)
@pytest.fixture
def cam_params():
return CameraParameters(
focal_length=5.0,
sensor_width=6.4,
sensor_height=4.8,
resolution_width=640,
resolution_height=480,
principal_point=(320.0, 240.0)
)
def test_extract_features(vo):
img = np.zeros((480, 640, 3), dtype=np.uint8)
features = vo.extract_features(img)
assert isinstance(features, Features)
assert features.keypoints.shape == (500, 2)
assert features.descriptors.shape == (500, 256)
def test_match_features(vo):
f1 = Features(
keypoints=np.random.rand(100, 2),
descriptors=np.random.rand(100, 256),
scores=np.random.rand(100)
)
f2 = Features(
keypoints=np.random.rand(100, 2),
descriptors=np.random.rand(100, 256),
scores=np.random.rand(100)
)
matches = vo.match_features(f1, f2)
assert isinstance(matches, Matches)
assert matches.matches.shape == (100, 2)
def test_estimate_motion_insufficient_matches(vo, cam_params):
matches = Matches(
matches=np.zeros((5, 2)),
scores=np.zeros(5),
keypoints1=np.zeros((5, 2)),
keypoints2=np.zeros((5, 2))
)
# Less than 8 points should return None
motion = vo.estimate_motion(matches, cam_params)
assert motion is None
def test_estimate_motion_synthetic(vo, cam_params):
# To reliably test compute_relative_pose, we create points strictly satisfying epipolar constraint
# Simple straight motion: Add a small shift on X axis
n_pts = 100
pts1 = np.random.rand(n_pts, 2) * 400 + 100
pts2 = pts1 + np.array([10.0, 0.0]) # moving 10 pixels right
matches = Matches(
matches=np.column_stack([np.arange(n_pts), np.arange(n_pts)]),
scores=np.ones(n_pts),
keypoints1=pts1,
keypoints2=pts2
)
motion = vo.estimate_motion(matches, cam_params)
assert motion is not None
assert motion.inlier_count > 20
assert motion.translation.shape == (3,)
assert motion.rotation.shape == (3, 3)
def test_compute_relative_pose(vo, cam_params):
img1 = np.zeros((480, 640, 3), dtype=np.uint8)
img2 = np.zeros((480, 640, 3), dtype=np.uint8)
# Given the random nature of our mock, OpenCV's findEssentialMat will likely find 0 inliers
# or fail. We expect compute_relative_pose to gracefully return None or low confidence.
pose = vo.compute_relative_pose(img1, img2, cam_params)
if pose is not None:
assert pose.translation.shape == (3,)
assert pose.rotation.shape == (3, 3)
# Because we randomize points in the mock manager, inliers will be extremely low
assert pose.tracking_good is False
# ---------------------------------------------------------------
# VO-02: ORBVisualOdometry interface contract
# ---------------------------------------------------------------
@pytest.fixture
def orb_vo():
return ORBVisualOdometry()
def test_orb_implements_interface(orb_vo):
"""ORBVisualOdometry must satisfy ISequentialVisualOdometry."""
assert isinstance(orb_vo, ISequentialVisualOdometry)
def test_orb_extract_features(orb_vo):
img = np.zeros((480, 640, 3), dtype=np.uint8)
feats = orb_vo.extract_features(img)
assert isinstance(feats, Features)
# Black image has no corners — empty result is valid
assert feats.keypoints.ndim == 2 and feats.keypoints.shape[1] == 2
def test_orb_match_features(orb_vo):
"""match_features returns Matches even when features are empty."""
empty_f = Features(
keypoints=np.zeros((0, 2), dtype=np.float32),
descriptors=np.zeros((0, 32), dtype=np.float32),
scores=np.zeros(0, dtype=np.float32),
)
m = orb_vo.match_features(empty_f, empty_f)
assert isinstance(m, Matches)
assert m.matches.shape[1] == 2 if len(m.matches) > 0 else True
def test_orb_compute_relative_pose_synthetic(orb_vo, cam_params):
"""ORB can track a small synthetic shift between frames."""
base = np.random.randint(50, 200, (480, 640, 3), dtype=np.uint8)
shifted = np.roll(base, 10, axis=1) # shift 10px right
pose = orb_vo.compute_relative_pose(base, shifted, cam_params)
# May return None on blank areas, but if not None must be well-formed
if pose is not None:
assert pose.translation.shape == (3,)
assert pose.rotation.shape == (3, 3)
assert pose.scale_ambiguous is True # ORB = monocular = scale ambiguous
def test_orb_scale_ambiguous(orb_vo, cam_params):
"""ORB RelativePose always has scale_ambiguous=True (monocular)."""
img1 = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
img2 = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
pose = orb_vo.compute_relative_pose(img1, img2, cam_params)
if pose is not None:
assert pose.scale_ambiguous is True
# ---------------------------------------------------------------
# VO-01: CuVSLAMVisualOdometry (dev/CI fallback path)
# ---------------------------------------------------------------
def test_cuvslam_implements_interface():
"""CuVSLAMVisualOdometry satisfies ISequentialVisualOdometry on dev/CI."""
vo = CuVSLAMVisualOdometry()
assert isinstance(vo, ISequentialVisualOdometry)
def test_cuvslam_scale_not_ambiguous_on_dev(cam_params):
"""On dev/CI (no cuVSLAM), CuVSLAMVO still marks scale_ambiguous=False (metric intent)."""
vo = CuVSLAMVisualOdometry()
img1 = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
img2 = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
pose = vo.compute_relative_pose(img1, img2, cam_params)
if pose is not None:
assert pose.scale_ambiguous is False # VO-04
# ---------------------------------------------------------------
# VO-03: ModelManager auto-selects Mock on dev/CI
# ---------------------------------------------------------------
def test_model_manager_mock_on_dev():
"""On non-Jetson, get_inference_engine returns MockInferenceEngine."""
from gps_denied.core.models import MockInferenceEngine
manager = ModelManager()
engine = manager.get_inference_engine("SuperPoint")
# On dev/CI we always get Mock
assert isinstance(engine, MockInferenceEngine)
def test_model_manager_trt_engine_loader():
"""TRTInferenceEngine falls back to Mock when engine file is absent."""
from gps_denied.core.models import TRTInferenceEngine
engine = TRTInferenceEngine("SuperPoint", "/nonexistent/superpoint.engine")
# Must not crash; should have a mock fallback
assert engine._mock_fallback is not None
# Infer via mock fallback must work
dummy_img = np.zeros((480, 640, 3), dtype=np.uint8)
result = engine.infer(dummy_img)
assert "keypoints" in result
# ---------------------------------------------------------------
# Factory: create_vo_backend
# ---------------------------------------------------------------
def test_create_vo_backend_returns_interface():
"""create_vo_backend() always returns an ISequentialVisualOdometry."""
manager = ModelManager()
backend = create_vo_backend(model_manager=manager)
assert isinstance(backend, ISequentialVisualOdometry)
def test_create_vo_backend_orb_fallback():
"""Without model_manager and no cuVSLAM, falls back to ORBVisualOdometry."""
backend = create_vo_backend(model_manager=None)
assert isinstance(backend, ORBVisualOdometry)
# ---------------------------------------------------------------------------
# CuVSLAMMonoDepthVisualOdometry tests
# ---------------------------------------------------------------------------
def test_mono_depth_implements_interface():
"""CuVSLAMMonoDepthVisualOdometry implements ISequentialVisualOdometry."""
from gps_denied.core.vo import CuVSLAMMonoDepthVisualOdometry
vo = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=600.0)
assert isinstance(vo, ISequentialVisualOdometry)
def test_mono_depth_scale_not_ambiguous():
"""Mono-Depth backend always returns scale_ambiguous=False."""
from gps_denied.core.vo import CuVSLAMMonoDepthVisualOdometry
vo = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=600.0)
prev = np.zeros((480, 640), dtype=np.uint8)
curr = np.zeros((480, 640), dtype=np.uint8)
cam = CameraParameters(
focal_length=16.0, sensor_width=23.2, sensor_height=17.4,
resolution_width=640, resolution_height=480,
)
pose = vo.compute_relative_pose(prev, curr, cam)
# may be None when images are blank — but if present, scale_ambiguous=False
if pose is not None:
assert pose.scale_ambiguous is False
def test_mono_depth_depth_hint_scales_translation():
"""depth_hint_m scales translation in dev/CI ORB fallback by depth_hint_m / 600.0."""
from unittest.mock import patch
from gps_denied.core.vo import CuVSLAMMonoDepthVisualOdometry, ORBVisualOdometry
from gps_denied.schemas.vo import RelativePose
cam = CameraParameters(
focal_length=16.0, sensor_width=23.2, sensor_height=17.4,
resolution_width=640, resolution_height=480,
)
img = np.zeros((480, 640), dtype=np.uint8)
# Mock ORB to always return a unit translation — lets us verify the scale factor cleanly
unit_pose = RelativePose(
translation=np.array([1.0, 0.0, 0.0]),
rotation=np.eye(3),
confidence=1.0,
inlier_count=100,
total_matches=100,
tracking_good=True,
scale_ambiguous=True,
)
with patch.object(ORBVisualOdometry, "compute_relative_pose", return_value=unit_pose):
vo_300 = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=300.0)
vo_600 = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=600.0)
vo_1200 = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=1200.0)
pose_300 = vo_300.compute_relative_pose(img, img, cam)
pose_600 = vo_600.compute_relative_pose(img, img, cam)
pose_1200 = vo_1200.compute_relative_pose(img, img, cam)
# At 600m reference altitude, scale = 1.0 → unchanged
assert np.allclose(pose_600.translation, [1.0, 0.0, 0.0])
# At 300m, scale = 0.5 → halved
assert np.allclose(pose_300.translation, [0.5, 0.0, 0.0])
# At 1200m, scale = 2.0 → doubled
assert np.allclose(pose_1200.translation, [2.0, 0.0, 0.0])
# All must report metric scale
assert pose_300.scale_ambiguous is False
assert pose_600.scale_ambiguous is False
assert pose_1200.scale_ambiguous is False
def test_mono_depth_create_vo_backend_selects_it():
"""create_vo_backend with prefer_mono_depth=True returns CuVSLAMMonoDepthVisualOdometry."""
from gps_denied.core.vo import CuVSLAMMonoDepthVisualOdometry, create_vo_backend
vo = create_vo_backend(prefer_mono_depth=True, depth_hint_m=600.0)
assert isinstance(vo, CuVSLAMMonoDepthVisualOdometry)
def test_mono_depth_update_depth_hint_clamps_below_one():
"""update_depth_hint clamps bogus/negative barometer values to minimum 1.0m."""
from gps_denied.core.vo import CuVSLAMMonoDepthVisualOdometry
vo = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=600.0)
vo.update_depth_hint(-5.0)
assert vo._depth_hint_m == 1.0
vo.update_depth_hint(0.0)
assert vo._depth_hint_m == 1.0
vo.update_depth_hint(0.5)
assert vo._depth_hint_m == 1.0
def test_mono_depth_update_depth_hint_affects_subsequent_scale():
"""update_depth_hint changes scale used by next compute_relative_pose call."""
from unittest.mock import patch
from gps_denied.core.vo import CuVSLAMMonoDepthVisualOdometry, ORBVisualOdometry
from gps_denied.schemas.vo import RelativePose
cam = CameraParameters(
focal_length=16.0, sensor_width=23.2, sensor_height=17.4,
resolution_width=640, resolution_height=480,
)
img = np.zeros((480, 640), dtype=np.uint8)
unit_pose = RelativePose(
translation=np.array([1.0, 0.0, 0.0]),
rotation=np.eye(3),
confidence=1.0, inlier_count=100, total_matches=100,
tracking_good=True, scale_ambiguous=True,
)
with patch.object(ORBVisualOdometry, "compute_relative_pose", return_value=unit_pose):
vo = CuVSLAMMonoDepthVisualOdometry(depth_hint_m=600.0)
pose_before = vo.compute_relative_pose(img, img, cam)
vo.update_depth_hint(1200.0)
pose_after = vo.compute_relative_pose(img, img, cam)
assert np.allclose(pose_before.translation, [1.0, 0.0, 0.0]) # 600/600 = 1.0x
assert np.allclose(pose_after.translation, [2.0, 0.0, 0.0]) # 1200/600 = 2.0x