mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 22:21:13 +00:00
64d961f60c
Batch 98 (cycle 2) — first two PBIs of epic AZ-696 (real-flight validation harness): AZ-697: direct binary-tlog GPS-truth extractor - New src/gps_denied_onboard/replay_input/tlog_ground_truth.py reads GLOBAL_POSITION_INT (with GPS_RAW_INT fallback) from a binary ArduPilot tlog via pymavlink.mavutil and returns a frozen+slotted TlogGroundTruth DTO with per-record ts_ns / lat_deg / lon_deg / alt_m / hdg_deg / vx_m_s / vy_m_s / vz_m_s. - Promoted l2_horizontal_m + match_percentage + GroundTruthRow from tests/e2e/replay/_helpers.py into the new production module src/gps_denied_onboard/helpers/gps_compare.py. The e2e helper now re-exports the same objects (identity, not copies) so existing test imports continue working untouched. - tests/e2e/replay/conftest.py prefers the real derkachi.tlog when present, falls back to the CSV synth path otherwise. - 22 new unit tests cover AC-1..AC-5 (mypy --strict subprocess test included). All passing. AZ-702: Topotek KHP20S30 factory-sheet camera calibration - New _docs/00_problem/input_data/flight_derkachi/khp20s30_factory.json: fx = fy = 4644.444, cx = 960, cy = 540, HFOV ~ 23.3 deg, VFOV ~ 13.2 deg, computed from the published 8.5 mm focal length + 1/2.8" sensor + 1920x1080 capture at lowest zoom step. Distortion zeroed, body_to_camera_se3 = identity with nadir convention. Acquisition method explicitly recorded as factory_sheet so downstream code can expect higher residual error than a lab calibration. - _docs/00_problem/input_data/flight_derkachi/camera_info.md updated to document the assumptions, expected residual error window, and conftest pick-up rule. - tests/e2e/replay/conftest.py::_calibration_path() prefers khp20s30_factory.json when present, falls back to adti26.json. - 9 new unit tests cover AC-1..AC-4 (schema, intrinsics traceback, doc reference, conftest pick-up). All passing. Test run: 45 new tests, all passing. Full-suite gate deferred to Step 16 (after the last batch in cycle 2 per the implement skill). Adjacent note (not fixed in this batch, recorded in the batch report): auto_sync.py has the same redundant pymavlink type:ignore + a few numpy/cv2 mypy --strict issues. None on this batch's path. Refs: _docs/03_implementation/batch_98_cycle2_report.md Refs: _docs/02_tasks/done/AZ-697_tlog_ground_truth_extractor.md Refs: _docs/02_tasks/done/AZ-702_khp20s30_calibration.md Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
5.5 KiB
Python
185 lines
5.5 KiB
Python
"""AZ-702 — Topotek KHP20S30 factory-sheet calibration.
|
|
|
|
Covers AC-1, AC-3, AC-4 of
|
|
``_docs/02_tasks/todo/AZ-702_khp20s30_calibration.md``:
|
|
|
|
* AC-1 (JSON parses against the project schema) — same loader gate the
|
|
CLI ``replay.py::_load_calibration_json`` uses.
|
|
* AC-3 (field values match factory inputs) — ``fx == fy`` (square
|
|
pixels), principal point at image centre, zero distortion.
|
|
* AC-4 (T3 consumes this calibration) — covered by
|
|
``tests/e2e/replay/conftest.py::_calibration_path()`` returning this
|
|
file when present, exercised once T3 (AZ-699) lands.
|
|
|
|
AC-2 (`camera_info.md` updated) is a documentation AC and is verified
|
|
by inspection during code review; it does not lend itself to a runtime
|
|
assertion beyond the file-existence smoke test below.
|
|
|
|
Style: every test follows the Arrange / Act / Assert pattern.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
|
|
_FACTORY_JSON_PATH = (
|
|
Path(__file__).resolve().parents[3]
|
|
/ "_docs"
|
|
/ "00_problem"
|
|
/ "input_data"
|
|
/ "flight_derkachi"
|
|
/ "khp20s30_factory.json"
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def calibration_data() -> dict[str, Any]:
|
|
text = _FACTORY_JSON_PATH.read_text(encoding="utf-8")
|
|
return json.loads(text)
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# AC-1: JSON parses via the project's calibration schema gate
|
|
|
|
|
|
def test_ac1_required_schema_keys_present(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
"""Same gate ``cli/replay.py::_load_calibration_json`` enforces."""
|
|
# Assert
|
|
for key in ("intrinsics_3x3", "distortion", "body_to_camera_se3"):
|
|
assert key in calibration_data, f"missing required key: {key}"
|
|
|
|
|
|
def test_ac1_cli_loader_accepts_the_json(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
"""The CLI's strict loader (replay.py) returns without raising."""
|
|
# Arrange
|
|
from gps_denied_onboard.cli.replay import _load_calibration_json
|
|
|
|
# Act
|
|
loaded = _load_calibration_json(_FACTORY_JSON_PATH)
|
|
|
|
# Assert
|
|
assert loaded == calibration_data
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# AC-3: Field values match the documented factory inputs
|
|
|
|
|
|
def test_ac3_intrinsics_square_pixels_and_centred_principal_point(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
# Arrange
|
|
img_w, img_h = 1920, 1080
|
|
sensor_w_mm = 5.37
|
|
focal_mm = 4.7
|
|
expected_f = focal_mm * (img_w / sensor_w_mm)
|
|
K = calibration_data["intrinsics_3x3"]
|
|
|
|
# Assert — square pixels (fx == fy) and principal point at image centre.
|
|
fx, fy, cx, cy = K[0][0], K[1][1], K[0][2], K[1][2]
|
|
assert fx == pytest.approx(fy, rel=1e-12), "expected fx == fy (square pixels)"
|
|
assert fx == pytest.approx(expected_f, rel=1e-3), (
|
|
f"fx {fx} does not match factory-sheet derivation "
|
|
f"f * width/sensor_w = {expected_f}"
|
|
)
|
|
assert cx == pytest.approx(img_w / 2, abs=0.5)
|
|
assert cy == pytest.approx(img_h / 2, abs=0.5)
|
|
# Off-diagonal entries are zero (no skew).
|
|
assert K[0][1] == 0.0
|
|
assert K[1][0] == 0.0
|
|
assert K[2] == [0.0, 0.0, 1.0]
|
|
|
|
|
|
def test_ac3_distortion_all_zero_for_factory_sheet(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
# Assert — factory-sheet approximation skips per-unit distortion.
|
|
assert calibration_data["distortion"] == [0.0, 0.0, 0.0, 0.0, 0.0]
|
|
|
|
|
|
def test_ac3_body_to_camera_is_identity_for_nadir(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
# Arrange
|
|
expected = [
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0, 0.0],
|
|
[0.0, 0.0, 1.0, 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
]
|
|
|
|
# Assert
|
|
assert calibration_data["body_to_camera_se3"] == expected
|
|
|
|
|
|
def test_ac3_acquisition_method_is_factory_sheet(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
# Assert
|
|
assert calibration_data["acquisition_method"] == "factory_sheet"
|
|
|
|
|
|
def test_metadata_documents_assumptions(
|
|
calibration_data: dict[str, Any],
|
|
) -> None:
|
|
"""Metadata must capture the factory inputs that produced K."""
|
|
# Arrange
|
|
meta = calibration_data["metadata"]
|
|
|
|
# Assert
|
|
assert meta["model"] == "Topotek KHP20S30"
|
|
assert meta["image_resolution_px"] == [1920, 1080]
|
|
assert meta["assumed_focal_length_mm"] == 4.7
|
|
assert meta["sensor_width_mm"] == 5.37
|
|
assert meta["residual_budget_pct"] > 0.0
|
|
assert "task" in meta and meta["task"] == "AZ-702"
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# AC-2 sanity: camera_info.md exists and references this calibration
|
|
|
|
|
|
def test_camera_info_md_references_calibration() -> None:
|
|
# Arrange
|
|
camera_info = (
|
|
Path(__file__).resolve().parents[3]
|
|
/ "_docs"
|
|
/ "00_problem"
|
|
/ "input_data"
|
|
/ "flight_derkachi"
|
|
/ "camera_info.md"
|
|
)
|
|
|
|
# Act
|
|
text = camera_info.read_text(encoding="utf-8")
|
|
|
|
# Assert
|
|
assert "khp20s30_factory.json" in text
|
|
assert "factory_sheet" in text or "factory-sheet" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# AC-4 sanity: T3 will pick up this calibration when present
|
|
|
|
|
|
def test_ac4_conftest_picks_up_factory_calibration() -> None:
|
|
"""``tests/e2e/replay/conftest.py::_calibration_path()`` prefers this
|
|
file when present (the T3 / AZ-699 entry-point)."""
|
|
# Arrange
|
|
from tests.e2e.replay.conftest import _calibration_path
|
|
|
|
# Act
|
|
path = _calibration_path()
|
|
|
|
# Assert — the factory JSON is committed; conftest must prefer it.
|
|
assert path == _FACTORY_JSON_PATH
|