mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:01:14 +00:00
94d2358c8b
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>
255 lines
7.9 KiB
Python
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()
|