Files
gps-denied-onboard/tests/unit/c1_vio/test_az920_gsd_scale.py
T
Oleksandr Bezdieniezhnykh 94d2358c8b [AZ-918] [AZ-919] [AZ-920] [AZ-921] [AZ-922] VIO/ESKF baseline fixes
Derkachi e2e Tier-2 divergence had three stacked root causes; this
commit ships fixes for all three plus the IMU prerequisite they
depend on, plus a baseline cheirality gate for cv2.recoverPose.

AZ-918  MAVLink IMU adapters now convert raw mG/mrad-s + FRD body to
        SI m/s^2 + rad/s + FLU body via helpers.imu_units. Without
        this the ESKF receives values ~1000x too small with wrong-
        sign Y/Z and cannot function at all.

AZ-919  Composition root wires EskfNominalAltitudeProvider into the
        KLT/RANSAC strategy via the AZ-331 factory introspect path;
        OKVIS2 and VINS-Mono are unaffected.

AZ-920  KLT/RANSAC recovers metric translation via Ground Sampling
        Distance when AGL is available; otherwise falls through with
        scale_quality=direction_only/unknown (no fake scale invented).

AZ-921  VioOutput.scale_quality signal; ESKF add_vio adapts R_meas
        position block based on the flag (1e6 inflation when scale is
        direction_only/unknown to keep the filter consistent).

AZ-922  KLT/RANSAC cheirality gate rejects single-frame rotations
        beyond a config threshold (default 30 deg), catching
        cv2.recoverPose twisted-pair flips that cause immediate ESKF
        divergence on low-parallax aerial scenes.

Verification:
- Tier-1 (macOS) unit suite: 2346 passed, 0 failed.
- Tier-2 (Jetson) Derkachi e2e: divergence moves from frame 5
  (mahalanobis^2 3757) to frame 233 (mahalanobis^2 212). Remaining
  drift is open-loop attitude accumulation, not cheirality.

Follow-up tickets filed:
- AZ-923 closed as misdiagnosed: EskfNominalAltitudeProvider was
  already correct (nominal_pos.z IS the AGL when takeoff origin sits
  at ground level); the early-frame AGL near zero reflects the drone
  being stationary on the ground, not a provider bug.
- AZ-942 filed: cross-check VIO rotation against IMU preintegrator
  (consistency gate) - more physically grounded than the coarse
  AZ-922 threshold and likely required to absorb the frame-233 drift.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 22:28:40 +03:00

255 lines
7.9 KiB
Python

"""AZ-920 — `recover_scale` pure-function tests.
Pins the GSD scale-recovery contract that the KLT/RANSAC integration
in ``KltRansacStrategy.process_frame`` relies on. The integration
test is in ``test_az920_klt_ransac_scale_integration.py``; this file
covers the math in isolation.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from gps_denied_onboard.components.c1_vio._gsd_scale import (
INFINITE_SCALE_UNCERTAINTY_M,
RELATIVE_DISPLACEMENT_NOISE,
ScaleRecoveryResult,
recover_scale,
)
# ----------------------------------------------------------------------
# Helpers
def _intrinsics(fx: float = 1680.4469) -> np.ndarray:
"""Derkachi-grade nadir intrinsics by default (fx=fy, cx/cy at centre)."""
return np.array(
[
[fx, 0.0, 960.0],
[0.0, fx, 540.0],
[0.0, 0.0, 1.0],
],
dtype=np.float64,
)
def _uniform_flow(n: int, dx_px: float, dy_px: float = 0.0) -> tuple[np.ndarray, np.ndarray]:
"""Build Nx2 prev / curr point clouds with a uniform pixel-flow shift."""
rng = np.random.default_rng(seed=2026)
base = rng.uniform(100.0, 1800.0, size=(n, 2))
prev = base.copy()
curr = base + np.array([dx_px, dy_px], dtype=np.float64)
return prev, curr
# ----------------------------------------------------------------------
# Happy path
def test_nadir_identity_rotation_with_known_gsd_yields_expected_scale() -> None:
# Arrange — at AGL=100 m, fx=1680 px → GSD ≈ 0.0595 m/px. A 20 px
# mean flow corresponds to ≈1.19 m of horizontal motion. t_unit
# along +X means t_horizontal = 1; scale_factor must equal 1.19.
t_unit = np.array([1.0, 0.0, 0.0], dtype=np.float64)
pts_prev, pts_curr = _uniform_flow(n=64, dx_px=20.0)
# Act
result = recover_scale(
t_unit=t_unit,
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=100.0,
)
# Assert
assert isinstance(result, ScaleRecoveryResult)
expected_disp_m = 20.0 * 100.0 / 1680.4469
assert result.scale_factor == pytest.approx(expected_disp_m, rel=1e-9)
assert result.scale_uncertainty_m == pytest.approx(
expected_disp_m * RELATIVE_DISPLACEMENT_NOISE, rel=1e-9
)
assert result.is_recoverable()
# AZ-921 — happy-path categorisation is "metric".
assert result.quality == "metric"
def test_diagonal_flow_uses_l2_norm_for_mean_magnitude() -> None:
# Arrange — (dx=3, dy=4) flow has L2 magnitude 5 px per feature.
t_unit = np.array([1.0, 0.0, 0.0], dtype=np.float64)
pts_prev, pts_curr = _uniform_flow(n=50, dx_px=3.0, dy_px=4.0)
# Act
result = recover_scale(
t_unit=t_unit,
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=100.0,
)
# Assert
expected_disp_m = 5.0 * 100.0 / 1680.4469
assert result.scale_factor == pytest.approx(expected_disp_m, rel=1e-9)
def test_off_axis_t_unit_uses_horizontal_component_magnitude() -> None:
# Arrange — t_unit splits 0.6/0.8 between camera-X and camera-Y;
# the horizontal magnitude is sqrt(0.36 + 0.64) = 1.0. The scale
# therefore stays the displacement / 1.0.
t_unit = np.array([0.6, 0.8, 0.0], dtype=np.float64)
pts_prev, pts_curr = _uniform_flow(n=40, dx_px=10.0)
# Act
result = recover_scale(
t_unit=t_unit,
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=120.0,
)
# Assert
expected_disp_m = 10.0 * 120.0 / 1680.4469
assert result.scale_factor == pytest.approx(expected_disp_m, rel=1e-9)
# ----------------------------------------------------------------------
# Degenerate / fallback paths — each must produce inf uncertainty.
def test_agl_none_returns_infinite_uncertainty() -> None:
pts_prev, pts_curr = _uniform_flow(n=32, dx_px=20.0)
result = recover_scale(
t_unit=np.array([1.0, 0.0, 0.0]),
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=None,
)
assert result.scale_factor == 0.0
assert result.scale_uncertainty_m == INFINITE_SCALE_UNCERTAINTY_M
assert not result.is_recoverable()
# AZ-921 — AGL-unknown is "unknown", not "direction_only".
assert result.quality == "unknown"
def test_zero_or_negative_agl_returns_infinite_uncertainty() -> None:
pts_prev, pts_curr = _uniform_flow(n=32, dx_px=20.0)
for bad_agl in (0.0, -1.0):
result = recover_scale(
t_unit=np.array([1.0, 0.0, 0.0]),
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=bad_agl,
)
assert math.isinf(result.scale_uncertainty_m), f"agl={bad_agl}"
def test_near_pure_vertical_t_unit_returns_infinite_uncertainty() -> None:
# Arrange — t along the optical axis (camera-Z) only; the GSD
# method cannot tie horizontal pixel flow to vertical motion.
pts_prev, pts_curr = _uniform_flow(n=40, dx_px=5.0)
result = recover_scale(
t_unit=np.array([0.01, 0.01, 0.9998]),
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=100.0,
)
assert not result.is_recoverable()
# AZ-921 — near-vertical motion routes to direction_only: the
# direction of t_unit is still meaningful, only the magnitude is
# not derivable from horizontal pixel flow.
assert result.quality == "direction_only"
def test_zero_inlier_flow_returns_infinite_uncertainty() -> None:
# Arrange — stationary inliers (dx=dy=0). Cannot disambiguate the
# scale of a zero motion.
pts_prev, pts_curr = _uniform_flow(n=40, dx_px=0.0, dy_px=0.0)
result = recover_scale(
t_unit=np.array([1.0, 0.0, 0.0]),
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=100.0,
)
assert not result.is_recoverable()
# AZ-921 — stale features routes to "unknown", since neither the
# magnitude nor the direction of motion is trustworthy.
assert result.quality == "unknown"
def test_empty_inlier_set_returns_infinite_uncertainty() -> None:
empty = np.zeros((0, 2), dtype=np.float64)
result = recover_scale(
t_unit=np.array([1.0, 0.0, 0.0]),
pts_prev=empty,
pts_curr=empty,
intrinsics_3x3=_intrinsics(),
agl_m=100.0,
)
assert not result.is_recoverable()
def test_zero_focal_length_returns_infinite_uncertainty() -> None:
pts_prev, pts_curr = _uniform_flow(n=32, dx_px=20.0)
result = recover_scale(
t_unit=np.array([1.0, 0.0, 0.0]),
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(fx=0.0),
agl_m=100.0,
)
assert not result.is_recoverable()
# ----------------------------------------------------------------------
# Numerical stability with a Derkachi-grade inlier set
def test_derkachi_grade_inlier_count_yields_stable_scale() -> None:
# Arrange — 120 inliers (representative of the Derkachi cruise
# observation) with a small per-feature noise around a uniform
# 30 px optical flow at 150 m AGL.
rng = np.random.default_rng(seed=271828)
base = rng.uniform(100.0, 1800.0, size=(120, 2))
flow_mean_px = 30.0
noise = rng.normal(0.0, 0.5, size=(120, 2))
pts_prev = base
pts_curr = base + np.array([flow_mean_px, 0.0]) + noise
t_unit = np.array([1.0, 0.0, 0.0])
agl_m = 150.0
# Act
result = recover_scale(
t_unit=t_unit,
pts_prev=pts_prev,
pts_curr=pts_curr,
intrinsics_3x3=_intrinsics(),
agl_m=agl_m,
)
# Assert — observed scale must be within 5 % of the analytic value
# at this noise level (sigma = 0.5 px over 120 inliers).
expected = flow_mean_px * agl_m / 1680.4469
assert result.scale_factor == pytest.approx(expected, rel=5e-2)
assert result.is_recoverable()