diff --git a/tests/e2e/test_euroc_vo_only.py b/tests/e2e/test_euroc_vo_only.py new file mode 100644 index 0000000..957fb33 --- /dev/null +++ b/tests/e2e/test_euroc_vo_only.py @@ -0,0 +1,135 @@ +"""VO-only diagnostic for EuRoC MH_01. + +Bypasses the full pipeline (ESKF, satellite matching, recovery) and drives +ORBVisualOdometry directly on raw EuRoC frames. Reports the per-frame tracking +success rate so we can distinguish: + + - ORB can track EuRoC frames → vo_success=0 in the full pipeline means the + ESKF/satellite layer is the problem, not VO itself. + - ORB cannot track EuRoC frames → VO backend is the root cause; need to swap + to a real SuperPoint+LightGlue model or cuVSLAM. + +Skipped if EuRoC MH_01 is not installed. +""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from gps_denied.core.vo import ORBVisualOdometry +from gps_denied.schemas import CameraParameters +from gps_denied.testing.datasets.euroc import EuRoCAdapter + +# Match CI-tier frame cap used in test_euroc.py +EUROC_MH01_MAX_FRAMES = 100 + +# EuRoC cam0 intrinsics from the dataset sensor.yaml (focal length ≈ 458.654 px) +# Expressed as physical params so CameraParameters can build K. +# sensor_width chosen so focal_length * (res_w / sensor_width) ≈ 458 px. +EUROC_CAM_PARAMS = CameraParameters( + focal_length=4.586, # mm (synthetic — tuned so f_px ≈ 458 px) + sensor_width=4.586, # mm → f_px = 4.586 * (752/4.586) = 752... no + sensor_height=4.586, + resolution_width=752, + resolution_height=480, + # principal_point omitted → uses image centre (376, 240) +) + +# Actually compute correct params: f_px = focal_length * (res_w / sensor_w) +# We want f_px = 458.654. Set focal_length=458.654, sensor_width=752 so that +# f_px = 458.654 * (752/752) = 458.654. +EUROC_CAM_PARAMS = CameraParameters( + focal_length=458.654, + sensor_width=752.0, + sensor_height=480.0, + resolution_width=752, + resolution_height=480, +) + +# Minimum acceptable VO tracking rate to call the integration healthy. +MIN_TRACKING_RATE = 0.70 # 70 % of consecutive frame pairs + + +@pytest.mark.e2e +@pytest.mark.needs_dataset +@pytest.mark.asyncio +async def test_euroc_mh01_orb_tracking_rate(euroc_mh01_root: Path): + """ORB VO should track ≥70 % of consecutive EuRoC frame pairs. + + If this xfails it means ORB itself cannot engage on EuRoC imagery — the + root cause of vo_success=0 in the full pipeline is the VO backend, not the + ESKF or satellite layer. + """ + adapter = EuRoCAdapter(euroc_mh01_root) + frames = list(adapter.iter_frames())[:EUROC_MH01_MAX_FRAMES] + + vo = ORBVisualOdometry() + success_count = 0 + total_pairs = 0 + prev_image: np.ndarray | None = None + + for frame in frames: + img = cv2.imread(frame.image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + continue + if prev_image is not None: + pose = vo.compute_relative_pose(prev_image, img, EUROC_CAM_PARAMS) + total_pairs += 1 + if pose is not None and pose.tracking_good: + success_count += 1 + prev_image = img + + tracking_rate = success_count / max(1, total_pairs) + + # Always report numbers — visible in pytest -v output even on xfail. + summary = ( + f"ORB tracking: {success_count}/{total_pairs} pairs " + f"({tracking_rate*100:.1f}%)" + ) + + if tracking_rate < MIN_TRACKING_RATE: + pytest.xfail( + f"{summary} — below {MIN_TRACKING_RATE*100:.0f}% threshold. " + "ORB struggles on EuRoC indoor imagery; upgrading to real " + "SuperPoint+LightGlue or cuVSLAM is required to make the full " + "pipeline viable on this dataset." + ) + + assert tracking_rate >= MIN_TRACKING_RATE, summary + + +@pytest.mark.e2e +@pytest.mark.needs_dataset +@pytest.mark.asyncio +async def test_euroc_mh01_orb_reports_nonzero_inliers(euroc_mh01_root: Path): + """Sanity: ORB must find at least some inlier matches on at least one pair. + + If *every* pair has zero inliers the images are likely being loaded + incorrectly (wrong path, wrong colour mode, etc.). + """ + adapter = EuRoCAdapter(euroc_mh01_root) + frames = list(adapter.iter_frames())[:20] # small — just a sanity check + + vo = ORBVisualOdometry() + any_inliers = False + prev_image: np.ndarray | None = None + + for frame in frames: + img = cv2.imread(frame.image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + continue + if prev_image is not None: + pose = vo.compute_relative_pose(prev_image, img, EUROC_CAM_PARAMS) + if pose is not None and pose.inlier_count > 0: + any_inliers = True + break + prev_image = img + + assert any_inliers, ( + "ORB found zero inliers across the first 20 EuRoC frame pairs. " + "Check that image paths are correct and images load as valid grayscale arrays." + )