[AZ-345] [AZ-346] [AZ-347] [AZ-349] C3 matchers + C3.5 AdHoP refiner

Implement the three concrete C3 CrossDomainMatcher strategies plus the
C3.5 production-default AdHoPRefiner.

C3 (AZ-345/346/347):
- DiskLightGlueMatcher + AlikedLightGlueMatcher share a single shared
  _pipeline.run_lightglue_pipeline orchestrator (decode -> query
  extract -> per-candidate loop -> RANSAC sort -> health update ->
  FDR emit) so the only per-backbone delta is the keypoint+descriptor
  extractor closure. ALIKED adds a create-time engine output-schema
  probe (AC-special-1).
- XFeatMatcher owns its own per-candidate loop (single forward fuses
  extraction + matching); it re-uses the shared FDR emission helpers
  to keep telemetry byte-identical across strategies. lightglue_runtime
  parameter accepted by factory but discarded (AC-special-1).
- All three consume the shared LightGlueRuntime / RansacFilter /
  RollingHealthWindow helpers; no helper forks. InferenceRuntimeCut
  consumer-side Protocol added per AZ-507.

C3.5 (AZ-349):
- AdHoPRefiner implements the <= conditional gate, runs the OrthoLoC
  AdHoP TRT engine over best-candidate correspondences, re-runs RANSAC
  on the perspective-preconditioned set, and emits an enriched
  MatchResult with refinement_label="adhop".
- Invariant 4 passthrough fall-through: any RefinerBackboneError (TRT
  failure, OOM, NaN, bad shape) is caught, logged ERROR, FDR-emitted
  with error: true, and converted to passthrough that still counts
  against the rolling invocation-rate window. MemoryError and other
  non-listed exceptions propagate by design (AC-5 closed-set
  semantics).
- Rolling 60-s invocation-rate window + rate-limited WARN log
  (configurable via ratelimited_warn_window_ns; default 60 s).

Shared changes:
- C3MatcherConfig + C3_5RefinerConfig extended with the new
  weights/threshold/window fields.
- matcher_factory + refiner_factory optionally forward clock +
  fdr_client to the strategy's create(); backward-compatible.
- fdr_client.records registers five new kinds: matcher.frame_done,
  matcher.backbone_error, matcher.insufficient_inliers,
  matcher.all_failed, refiner.frame_done.

Tests: 66 new (43 C3 parametrised + 23 AdHoP) covering 47/47 ACs;
focused suite green; full project test suite green except for one
pre-existing flaky CLI cold-start timing test unrelated to this batch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 04:09:22 +03:00
parent 06f655d8fb
commit a1185d0a28
19 changed files with 4855 additions and 6 deletions
@@ -0,0 +1,289 @@
"""``AlikedLightGlueMatcher`` — C3 secondary CrossDomainMatcher (AZ-346).
Mirrors :mod:`gps_denied_onboard.components.c3_matcher.disk_lightglue`
modulo backbone choice: ALIKED replaces DISK for the per-frame
keypoint+descriptor extraction step; LightGlue + RANSAC stages are
unchanged.
ALIKED is the documented fallback if a future D-C3-1 IT-12 verdict
shifts away from DISK, or if DISK's licensing / upstream maintenance
changes mid-cycle. Both backbones ship in airborne / research
binaries (ADR-002 allows multiple matchers at link time; only one is
selected at runtime by ``config.matcher.strategy``).
Preprocessor parameters (input size, normalisation) are hard-coded
per AZ-346 Constraint — weights-coupled, same rule as DISK.
See ``disk_lightglue.py`` for the layering + composition rationale;
this module follows the same pattern.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final, Literal
import cv2
import numpy as np
from gps_denied_onboard._types.inference import (
BuildConfig,
PrecisionMode,
)
from gps_denied_onboard._types.matching import KeypointSet
from gps_denied_onboard.components.c3_matcher._engine_output_assertion import (
assert_keypoint_engine_output_schema,
)
from gps_denied_onboard.components.c3_matcher._pipeline import (
TileExtractError,
run_lightglue_pipeline,
)
from gps_denied_onboard.components.c3_matcher.inference_runtime_cut import (
InferenceRuntimeCut,
)
if TYPE_CHECKING:
from gps_denied_onboard._types.calibration import CameraCalibration
from gps_denied_onboard._types.matcher import MatchResult, MatcherHealth
from gps_denied_onboard._types.nav import NavCameraFrame
from gps_denied_onboard._types.rerank import RerankResult
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c3_matcher._health_window import (
RollingHealthWindow,
)
from gps_denied_onboard.config.schema import Config
from gps_denied_onboard.fdr_client import FdrClient
from gps_denied_onboard.helpers.lightglue_runtime import LightGlueRuntime
from gps_denied_onboard.helpers.ransac_filter import RansacFilter
__all__ = ["MODEL_NAME", "AlikedLightGlueMatcher", "create"]
MODEL_NAME: Final[str] = "aliked"
_MATCHER_LABEL: Final[Literal["aliked_lightglue"]] = "aliked_lightglue"
_FDR_PRODUCER_ID: Final[str] = "c3_matcher.aliked_lightglue"
_LOG_KIND_READY: Final[str] = "c3.matcher.ready"
# ALIKED input contract: NCHW float32 RGB at 480x480, ImageNet-style
# normalisation. Hard-coded per Constraint — weights-coupled.
_INPUT_SIZE: Final[int] = 480
_INPUT_KEY: Final[str] = "image"
_OUTPUT_KEYPOINTS: Final[str] = "keypoints"
_OUTPUT_DESCRIPTORS: Final[str] = "descriptors"
# ImageNet mean/std for ALIKED. Same convention as UltraVPR's preprocessor.
_IMAGENET_MEAN: Final[tuple[float, float, float]] = (0.485, 0.456, 0.406)
_IMAGENET_STD: Final[tuple[float, float, float]] = (0.229, 0.224, 0.225)
class AlikedLightGlueMatcher:
"""ALIKED + LightGlue cross-domain matcher.
Stateless per-frame except for the rolling health window. See
module docstring for the architectural picture and the
``cross_domain_matcher_protocol.md`` v1.0.0 contract for the
public invariants this class satisfies.
"""
def __init__(
self,
config: "Config",
*,
lightglue_runtime: "LightGlueRuntime",
ransac_filter: type["RansacFilter"],
inference_runtime: InferenceRuntimeCut,
health_window: "RollingHealthWindow",
engine_handle: object,
clock: "Clock",
fdr_client: "FdrClient | None",
logger: logging.Logger,
) -> None:
block = config.components["c3_matcher"]
self._config = config
self._lightglue_runtime = lightglue_runtime
self._ransac_filter = ransac_filter
self._inference_runtime = inference_runtime
self._engine_handle = engine_handle
self._health_window = health_window
self._clock = clock
self._fdr_client = fdr_client
self._logger = logger
self._min_inliers_threshold: int = int(block.min_inliers_threshold)
self._residual_warn_threshold_px: float = float(
block.residual_warn_threshold_px
)
self._ransac_threshold_px: float = float(block.ransac_threshold_px)
def match(
self,
frame: "NavCameraFrame",
rerank_result: "RerankResult",
calibration: "CameraCalibration",
) -> "MatchResult":
del calibration
return run_lightglue_pipeline(
frame=frame,
rerank_result=rerank_result,
matcher_label=_MATCHER_LABEL,
extract_features=self._extract_features,
lightglue_runtime=self._lightglue_runtime,
ransac_filter=self._ransac_filter,
health_window=self._health_window,
clock=self._clock,
fdr_client=self._fdr_client,
fdr_producer_id=_FDR_PRODUCER_ID,
min_inliers_threshold=self._min_inliers_threshold,
ransac_threshold_px=self._ransac_threshold_px,
residual_warn_threshold_px=self._residual_warn_threshold_px,
logger=self._logger,
)
def health_snapshot(self) -> "MatcherHealth":
return self._health_window.snapshot()
def _extract_features(self, image_bgr: np.ndarray) -> KeypointSet:
tensor = _preprocess_aliked(image_bgr)
outputs = self._inference_runtime.infer(
self._engine_handle, {_INPUT_KEY: tensor}
)
return _outputs_to_keypoint_set(outputs)
def _preprocess_aliked(image_bgr: np.ndarray) -> np.ndarray:
"""BGR → RGB 480×480 ImageNet-normalised float32 NCHW.
Hard-coded weights-coupled preprocessing. Accepts (H, W, 3) BGR
or (H, W) grayscale (broadcast to 3 channels).
"""
if image_bgr.ndim == 3:
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
elif image_bgr.ndim == 2:
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_GRAY2RGB)
else:
raise TileExtractError(
f"ALIKED preprocessor: image must be 2-D (gray) or 3-D (BGR); "
f"got ndim={image_bgr.ndim} shape={image_bgr.shape}"
)
if rgb.shape[0] != _INPUT_SIZE or rgb.shape[1] != _INPUT_SIZE:
rgb = cv2.resize(
rgb, (_INPUT_SIZE, _INPUT_SIZE), interpolation=cv2.INTER_AREA
)
rgb_f = rgb.astype(np.float32) / 255.0
mean = np.asarray(_IMAGENET_MEAN, dtype=np.float32)
std = np.asarray(_IMAGENET_STD, dtype=np.float32)
normalised = (rgb_f - mean) / std
tensor = np.transpose(normalised, (2, 0, 1))[None, :, :, :]
return np.ascontiguousarray(tensor, dtype=np.float32)
def _outputs_to_keypoint_set(outputs: dict[str, np.ndarray]) -> KeypointSet:
if _OUTPUT_KEYPOINTS not in outputs or _OUTPUT_DESCRIPTORS not in outputs:
raise TileExtractError(
f"ALIKED forward returned unexpected keys: "
f"{sorted(outputs.keys())!r}; expected {_OUTPUT_KEYPOINTS!r} + "
f"{_OUTPUT_DESCRIPTORS!r}"
)
keypoints = np.asarray(outputs[_OUTPUT_KEYPOINTS], dtype=np.float32)
descriptors = np.asarray(outputs[_OUTPUT_DESCRIPTORS], dtype=np.float32)
if keypoints.ndim != 2 or keypoints.shape[1] != 2:
raise TileExtractError(
f"ALIKED keypoints must have shape (N, 2); got {keypoints.shape}"
)
if descriptors.ndim != 2 or descriptors.shape[0] != keypoints.shape[0]:
raise TileExtractError(
f"ALIKED descriptors shape {descriptors.shape} inconsistent with "
f"keypoints {keypoints.shape}"
)
return KeypointSet(keypoints=keypoints, descriptors=descriptors)
def _build_aliked_build_config() -> BuildConfig:
return BuildConfig(
precision=PrecisionMode.FP16,
workspace_mb=512,
calibration_dataset=None,
optimization_profiles=(),
)
def create(
config: "Config",
*,
lightglue_runtime: "LightGlueRuntime",
ransac_filter: type["RansacFilter"],
inference_runtime: InferenceRuntimeCut,
health_window: "RollingHealthWindow",
clock: "Clock | None" = None,
fdr_client: "FdrClient | None" = None,
logger: logging.Logger | None = None,
) -> "AlikedLightGlueMatcher":
"""Module-level factory consumed by :func:`build_matcher_strategy`."""
block = config.components["c3_matcher"]
weights_path = block.aliked_weights_path
if weights_path is None:
raise ValueError(
"AlikedLightGlueMatcher.create: config.components['c3_matcher']"
".aliked_weights_path is None; the runtime root MUST populate "
"the ALIKED engine path before constructing this strategy."
)
if clock is None:
from gps_denied_onboard.clock.wall_clock import WallClock
clock = WallClock()
if logger is None:
logger = logging.getLogger("gps_denied_onboard.c3_matcher.aliked_lightglue")
cache_entry = inference_runtime.compile_engine(
weights_path, _build_aliked_build_config()
)
entry_for_deserialize = type(cache_entry)(
engine_path=cache_entry.engine_path,
sha256_hex=cache_entry.sha256_hex,
sm=cache_entry.sm,
jp=cache_entry.jp,
trt=cache_entry.trt,
precision=cache_entry.precision,
extras={**cache_entry.extras, "model_name": MODEL_NAME},
)
engine_handle = inference_runtime.deserialize_engine(entry_for_deserialize)
# AZ-346 AC-special-1: validate the engine's output schema once
# at startup so a misconfigured ALIKED engine surfaces as a
# composition-time ConfigError instead of a mid-flight
# InsufficientInliersError cascade.
assert_keypoint_engine_output_schema(
inference_runtime,
engine_handle,
input_size=_INPUT_SIZE,
input_key=_INPUT_KEY,
keypoints_key=_OUTPUT_KEYPOINTS,
descriptors_key=_OUTPUT_DESCRIPTORS,
)
logger.info(
_LOG_KIND_READY,
extra={
"kind": _LOG_KIND_READY,
"kv": {
"strategy": _MATCHER_LABEL,
"min_inliers_threshold": int(block.min_inliers_threshold),
"residual_warn_threshold_px": float(
block.residual_warn_threshold_px
),
"ransac_threshold_px": float(block.ransac_threshold_px),
},
},
)
return AlikedLightGlueMatcher(
config,
lightglue_runtime=lightglue_runtime,
ransac_filter=ransac_filter,
inference_runtime=inference_runtime,
health_window=health_window,
engine_handle=engine_handle,
clock=clock,
fdr_client=fdr_client,
logger=logger,
)