Files
gps-denied-onboard/tests/e2e/test_euroc_vo_only.py
T
Yuzviak c9bd45a098 test(e2e): add ORB VO-only diagnostic for EuRoC MH_01
Drives ORBVisualOdometry directly on raw EuRoC frames, bypassing ESKF and
satellite layers. ORB achieves 100% tracking on 99 frame pairs, confirming
that vo_success=0 in the full pipeline is caused by SequentialVisualOdometry's
MockInferenceEngine (random keypoints → RANSAC failure), not by VO backend
limitations on EuRoC indoor imagery.

Two tests: tracking rate ≥70% (passes, currently 100%), and sanity check
that at least one pair yields non-zero inliers (passes).

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

136 lines
4.7 KiB
Python

"""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."
)