mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 21:11:13 +00:00
33486588de
E-CC-HELPERS closes with the three remaining Layer-1 helpers and E-CC-CONF closes with the env > YAML > defaults precedence test gate. All four tickets ship with frozen public surfaces, hermetic unit tests, and no upward (components.*) imports. * AZ-271 — tests/unit/shared/config/test_precedence.py (5 ACs + smoke test + helper that names the layer in failure messages). * AZ-282 — helpers/ransac_filter.py: static RansacFilter + RansacResult; cv2.setRNGSeed(0) for byte-equal determinism; median residual semantics pinned by contract. * AZ-276 — helpers/imu_preintegrator.py + make_imu_preintegrator; GTSAM PreintegratedCombinedMeasurements; strict-monotonic ts_ns guard runs before any state mutation. Adjacent hygiene: _types/nav.py ImuSample/ImuWindow now use ts_ns:int and the spec-mandated ImuBias dataclass. * AZ-278 — helpers/lightglue_runtime.py: structural R14 fix. LightGlueRuntime + non-blocking concurrent-access guard that raises rather than serialising. EngineHandle Protocol in _types/manifests.py + KeypointSet/CorrespondenceSet in _types/matching.py (Protocol surface adds approved by spec). Dependency conflict (Finding 1, user-approved): gtsam 4.2 (PyPI) is numpy-1.x-ABI only; opencv-python>=4.12 needs numpy>=2 at runtime. Resolution: opencv-python pin relaxed to >=4.11.0.86,<4.12. The D-CROSS-CVE-1 ratchet at ci/opencv_pin_gate.py is held at 4.11.0 with the original 4.12.0 floor restored once a numpy-2-compatible gtsam wheel ships. Full replay procedure in _docs/_process_leftovers/2026-05-11_d_cross_cve_1_opencv_pin_deferred.md. Tests: 294 passed, 2 skipped (cmake/actionlint env-skips, pre-existing). 43 new tests added for batch 5. Ruff check + format clean. Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
5.5 KiB
Python
134 lines
5.5 KiB
Python
"""`LightGlueRuntime` — shared LightGlue matcher (AZ-278 / E-CC-HELPERS / R14 fix).
|
|
|
|
Implements the `lightglue_runtime` contract v1.0.0 at
|
|
`_docs/02_document/contracts/shared_helpers/lightglue_runtime.md`.
|
|
|
|
Layer 1 helper — NO `gps_denied_onboard.components.*` imports. The
|
|
engine handle is an opaque Protocol defined in `_types/manifests.py`;
|
|
C7's `InferenceRuntime.deserialize_engine` produces the concrete handle
|
|
and the composition root injects ONE shared instance into both C2.5
|
|
(InlierBasedReranker) and C3 (CrossDomainMatcher) — the structural
|
|
fix for R14.
|
|
|
|
Single-threaded by contract. The concurrent-access guard is
|
|
non-blocking: concurrent entry RAISES `LightGlueConcurrentAccessError`
|
|
rather than serialising, so a composition-root regression that wires
|
|
the runtime into multiple threads is caught immediately instead of
|
|
silently corrupting CUDA state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
from gps_denied_onboard._types.manifests import EngineHandle
|
|
from gps_denied_onboard._types.matching import CorrespondenceSet, KeypointSet
|
|
|
|
__all__ = [
|
|
"LightGlueConcurrentAccessError",
|
|
"LightGlueRuntime",
|
|
"LightGlueRuntimeError",
|
|
]
|
|
|
|
|
|
class LightGlueRuntimeError(RuntimeError):
|
|
"""Raised on construction guards or descriptor-dim mismatch."""
|
|
|
|
|
|
class LightGlueConcurrentAccessError(RuntimeError):
|
|
"""Raised when a concurrent ``match`` / ``match_batch`` entry is detected.
|
|
|
|
The serial-access invariant is a composition-root contract — if you
|
|
see this exception, the runtime was wired into more than one thread
|
|
by mistake. Fix the composition root, do NOT add a lock here.
|
|
"""
|
|
|
|
|
|
def _validate_keypoint_set(features: KeypointSet, *, name: str, expected_dim: int) -> None:
|
|
if features.descriptors.ndim != 2 or features.descriptors.shape[1] != expected_dim:
|
|
actual_dim = (
|
|
features.descriptors.shape[1] if features.descriptors.ndim == 2 else "<bad shape>"
|
|
)
|
|
raise LightGlueRuntimeError(
|
|
f"{name}: descriptor dim mismatch — engine expects {expected_dim}, "
|
|
f"got {actual_dim} (descriptors.shape={features.descriptors.shape})"
|
|
)
|
|
|
|
|
|
class LightGlueRuntime:
|
|
"""Shared LightGlue inference runtime.
|
|
|
|
Single-thread by contract; concurrent entry raises.
|
|
"""
|
|
|
|
def __init__(self, engine_handle: EngineHandle) -> None:
|
|
if engine_handle is None:
|
|
raise LightGlueRuntimeError(
|
|
"LightGlueRuntime requires a non-None engine_handle (got None); "
|
|
"composition root must inject the engine produced by C7's "
|
|
"InferenceRuntime.deserialize_engine"
|
|
)
|
|
try:
|
|
descriptor_dim = int(engine_handle.descriptor_dim)
|
|
except AttributeError as exc:
|
|
raise LightGlueRuntimeError(
|
|
f"engine_handle missing required Protocol attribute 'descriptor_dim': {exc}"
|
|
) from exc
|
|
if descriptor_dim < 1:
|
|
raise LightGlueRuntimeError(
|
|
f"engine_handle.descriptor_dim must be >= 1; got {descriptor_dim}"
|
|
)
|
|
self._engine = engine_handle
|
|
self._descriptor_dim = descriptor_dim
|
|
# Non-blocking guard: ``try_acquire`` raises on contention rather
|
|
# than serialising callers, per the contract's "concurrent calls
|
|
# are a bug" stance.
|
|
self._in_use = threading.Lock()
|
|
|
|
def descriptor_dim(self) -> int:
|
|
return self._descriptor_dim
|
|
|
|
def match(self, features_a: KeypointSet, features_b: KeypointSet) -> CorrespondenceSet:
|
|
"""Match a single pair (C2.5 path)."""
|
|
if not self._in_use.acquire(blocking=False):
|
|
raise LightGlueConcurrentAccessError(
|
|
"LightGlueRuntime.match called from a second thread while another "
|
|
"match is in flight — the runtime owns ONE CUDA stream and must be "
|
|
"bound to a single hot-path thread by the composition root"
|
|
)
|
|
try:
|
|
_validate_keypoint_set(features_a, name="features_a", expected_dim=self._descriptor_dim)
|
|
_validate_keypoint_set(features_b, name="features_b", expected_dim=self._descriptor_dim)
|
|
return self._engine.forward(features_a, features_b)
|
|
finally:
|
|
self._in_use.release()
|
|
|
|
def match_batch(
|
|
self,
|
|
features_a_list: list[KeypointSet],
|
|
features_b_list: list[KeypointSet],
|
|
) -> list[CorrespondenceSet]:
|
|
"""Batch-match (C3 path) — iterates serially over the single CUDA stream."""
|
|
if len(features_a_list) != len(features_b_list):
|
|
raise LightGlueRuntimeError(
|
|
f"match_batch: features_a_list (len={len(features_a_list)}) and "
|
|
f"features_b_list (len={len(features_b_list)}) must have equal length"
|
|
)
|
|
if not self._in_use.acquire(blocking=False):
|
|
raise LightGlueConcurrentAccessError(
|
|
"LightGlueRuntime.match_batch called concurrently with another match"
|
|
)
|
|
try:
|
|
results: list[CorrespondenceSet] = []
|
|
for idx, (fa, fb) in enumerate(zip(features_a_list, features_b_list, strict=True)):
|
|
_validate_keypoint_set(
|
|
fa, name=f"features_a_list[{idx}]", expected_dim=self._descriptor_dim
|
|
)
|
|
_validate_keypoint_set(
|
|
fb, name=f"features_b_list[{idx}]", expected_dim=self._descriptor_dim
|
|
)
|
|
results.append(self._engine.forward(fa, fb))
|
|
return results
|
|
finally:
|
|
self._in_use.release()
|