mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 16:41:13 +00:00
[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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user