[AZ-389] C5 orthorectifier emits mid-flight tiles to C6

Adds an opt-in C5-internal orthorectifier (`_orthorectifier.py`) that
emits at most one tile-aligned JPEG candidate per nav frame to the
C6 `TileStore.write_tile` API.  Quality gates fire before any
OpenCV work: covariance Frobenius, inlier floor, source-label
(`SATELLITE_ANCHORED` only), and once-per-frame rate limit.

Cross-component import rule (AZ-507) is preserved: c5_state never
imports c6_tile_cache.  `runtime_root.state_factory` carries a new
`_C6MidFlightIngestAdapter` that builds the canonical
`TileMetadata` (`ONBOARD_INGEST` / `FRESH` / `PENDING`), hashes
the JPEG, and translates `FreshnessRejectionError` to a `None`
return so the orthorectifier silently swallows freshness
rejection per AC-NEW-3.

Wiring is opt-in via `C5StateConfig.orthorectifier.enabled`;
existing tests/binaries default to disabled and are unaffected.
Both `GtsamIsam2StateEstimator` and `EskfStateEstimator`
participate through new `attach_orthorectifier` /
`set_latest_nav_frame` extension methods (Protocol surface
unchanged).

Tests: 22 new unit tests cover AC-1..AC-9 plus inlier-floor
gate plus the composition-root adapter.  216/216 c5_state and
38/38 runtime-root + compose tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-16 09:02:33 +03:00
parent 811ddc8aa7
commit c5ffc14fe9
9 changed files with 1952 additions and 20 deletions
@@ -21,6 +21,7 @@ binary may set ``OFF`` and only link the ESKF baseline.
from __future__ import annotations
import hashlib
import os
import threading
from collections.abc import Callable
@@ -37,7 +38,9 @@ from gps_denied_onboard.config import Config
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
pass
from datetime import datetime
from gps_denied_onboard._types.calibration import CameraCalibration
__all__ = [
"StateEstimatorFactory",
@@ -136,6 +139,10 @@ def build_state_estimator(
se3_utils: Any,
wgs_converter: Any,
fdr_client: Any,
tile_store: Any | None = None,
camera_calibration: "CameraCalibration | None" = None,
flight_id: str | None = None,
companion_id: str | None = None,
) -> tuple[StateEstimator, ISam2GraphHandle]:
"""Resolve + build the configured state estimator.
@@ -147,6 +154,16 @@ def build_state_estimator(
lookup. The first failure surfaces a :class:`StateEstimatorConfigError`
with the offending strategy + flag name so the operator gets a
clear next step.
AZ-389 wiring (optional): when ``tile_store`` is provided AND
``config.components['c5_state'].orthorectifier.enabled`` is true,
a :class:`_C6MidFlightIngestAdapter` wraps the C6 surface
(hiding ``TileMetadata`` / ``TileSource`` /
``FreshnessRejectionError`` so c5_state stays free of
cross-component imports per AZ-507) and is forwarded to the
estimator factory together with ``camera_calibration``,
``flight_id``, and ``companion_id``. The factory then constructs
the orthorectifier and attaches it to the estimator.
"""
block = _read_state_block(config)
strategy = block.strategy
@@ -169,16 +186,37 @@ def build_state_estimator(
f"state strategy {strategy!r} selected by config but not registered; "
f"registered strategies: {list_registered_state_strategies()}"
)
estimator, handle = factory(
config=config,
imu_preintegrator=imu_preintegrator,
se3_utils=se3_utils,
wgs_converter=wgs_converter,
fdr_client=fdr_client,
)
factory_kwargs: dict[str, Any] = {
"config": config,
"imu_preintegrator": imu_preintegrator,
"se3_utils": se3_utils,
"wgs_converter": wgs_converter,
"fdr_client": fdr_client,
}
if tile_store is not None and block.orthorectifier.enabled:
if camera_calibration is None or flight_id is None or companion_id is None:
raise StateEstimatorConfigError(
"AZ-389 orthorectifier enabled — tile_store must be paired with "
"camera_calibration + flight_id + companion_id "
"(missing: "
f"camera_calibration={camera_calibration is None}, "
f"flight_id={flight_id is None}, "
f"companion_id={companion_id is None})"
)
factory_kwargs["mid_flight_tile_writer"] = _C6MidFlightIngestAdapter(
tile_store=tile_store
)
factory_kwargs["camera_calibration"] = camera_calibration
factory_kwargs["flight_id"] = flight_id
factory_kwargs["companion_id"] = companion_id
estimator, handle = factory(**factory_kwargs)
_log_strategy_loaded(
strategy=strategy,
keyframe_window_size=block.keyframe_window_size,
orthorectifier_enabled=block.orthorectifier.enabled
and tile_store is not None,
)
return estimator, handle
@@ -199,16 +237,124 @@ def _read_state_block(config: Config) -> C5StateConfig:
)
def _log_strategy_loaded(*, strategy: str, keyframe_window_size: int) -> None:
def _log_strategy_loaded(
*,
strategy: str,
keyframe_window_size: int,
orthorectifier_enabled: bool,
) -> None:
log = get_logger("runtime_root.state_factory")
log.info(
f"c5.state.strategy_loaded: strategy={strategy} "
f"keyframe_window_size={keyframe_window_size}",
f"keyframe_window_size={keyframe_window_size} "
f"orthorectifier_enabled={orthorectifier_enabled}",
extra={
"kind": "c5.state.strategy_loaded",
"kv": {
"strategy": strategy,
"keyframe_window_size": keyframe_window_size,
"orthorectifier_enabled": orthorectifier_enabled,
},
},
)
# ----------------------------------------------------------------------
# AZ-389: composition-root bridge between c5_state and c6_tile_cache.
class _C6MidFlightIngestAdapter:
"""C5 ↔ C6 adapter for AZ-389 mid-flight tile candidates.
The c5_state component cannot import from c6_tile_cache (AZ-507;
``test_az270_compose_root.test_ac6_only_compose_root_imports_concrete_strategies``).
This adapter is the single allowed bridge: it lives in the
composition root, accepts the c6 :class:`TileStore` reference,
and exposes the
:class:`gps_denied_onboard.components.c5_state._orthorectifier.MidFlightTileWriter`
Protocol surface that c5 depends on.
Translation rules:
* ``write_mid_flight_tile`` builds the canonical
:class:`TileMetadata` (``TileSource.ONBOARD_INGEST`` +
``FreshnessLabel.FRESH`` + ``VotingStatus.PENDING``) and a
:class:`TileQualityMetadata` from the C5 inputs, hashes the
JPEG bytes, and calls :meth:`TileStore.write_tile` once
(atomic file + metadata insert).
* A :class:`FreshnessRejectionError` from C6 is translated to a
``None`` return — the orthorectifier swallows that silently
per AC-NEW-3.
* Any other C6 error propagates; the orthorectifier itself
wraps that in a WARNING log and returns ``None`` so the C5
``current_estimate`` path is never broken by tile-cache
infrastructure failures.
"""
__slots__ = ("_tile_store",)
def __init__(self, *, tile_store: Any) -> None:
self._tile_store = tile_store
def write_mid_flight_tile(
self,
*,
jpeg_bytes: bytes,
zoom_level: int,
lat: float,
lon: float,
tile_size_meters: float,
tile_size_pixels: int,
capture_timestamp: "datetime",
flight_id: str,
companion_id: str,
estimator_label: str,
covariance_2x2_horizontal: tuple[
tuple[float, float], tuple[float, float]
],
last_anchor_age_ms: int,
mre_px: float,
imu_bias_norm: float,
) -> tuple[int, float, float] | None:
from gps_denied_onboard.components.c6_tile_cache._types import (
FreshnessLabel,
TileId,
TileMetadata,
TileQualityMetadata,
TileSource,
VotingStatus,
)
from gps_denied_onboard.components.c6_tile_cache.errors import (
FreshnessRejectionError,
)
tile_id = TileId(
zoom_level=int(zoom_level),
lat=float(lat),
lon=float(lon),
)
quality = TileQualityMetadata(
estimator_label=str(estimator_label),
covariance_2x2=covariance_2x2_horizontal,
last_anchor_age_ms=int(last_anchor_age_ms),
mre_px=float(mre_px),
imu_bias_norm=float(imu_bias_norm),
)
metadata = TileMetadata(
tile_id=tile_id,
tile_size_meters=float(tile_size_meters),
tile_size_pixels=int(tile_size_pixels),
capture_timestamp=capture_timestamp,
source=TileSource.ONBOARD_INGEST,
content_sha256_hex=hashlib.sha256(jpeg_bytes).hexdigest(),
freshness_label=FreshnessLabel.FRESH,
flight_id=str(flight_id),
companion_id=str(companion_id),
quality_metadata=quality,
voting_status=VotingStatus.PENDING,
)
try:
self._tile_store.write_tile(jpeg_bytes, metadata)
except FreshnessRejectionError:
return None
return (tile_id.zoom_level, tile_id.lat, tile_id.lon)