mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:21:16 +00:00
[AZ-343] C2.5 InlierCountReRanker + shared FeatureExtractor helper
Implements the production-default ReRankStrategy: K=10 → N=3 by single-pair LightGlue inlier count, with strict drop-and-continue (INV-8) on per-candidate TileFetch / backbone / zero-inlier failures and RerankAllCandidatesFailedError on zero survivors. Composition root injects the shared LightGlueRuntime + Clock + the new FeatureExtractor helper (an L1 placeholder OpenCvOrbExtractor that unblocks AZ-343 and future C3 strategies — task scope expansion). Architectural notes: - Cross-component imports stay banned; tile_store types as `object` and the C6 TileCacheError family is duck-typed by class module prefix (same workaround AZ-348 adopted for c7_inference; proper fix is to relocate TileCacheError to _types/ in a follow-up). - Clock injection follows the replay contract (AZ-398 Invariant 2); reranked_at is sourced from clock.monotonic_ns(). - AZ-342 factory grew `feature_extractor` + `clock` + `fdr_client` parameters; existing AZ-342 conformance tests updated. Tests: 19 new AC-1..AC-12 + mixed-failure scenarios in test_inlier_count_reranker.py; existing AZ-342 suite (26) still green. Full repo sweep 1093 passed / 2 skipped (cmake/actionlint not on PATH). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""`FeatureExtractor` — shared image → :class:`KeypointSet` helper (AZ-343 scope expansion).
|
||||
|
||||
L1 helper analogous to :mod:`gps_denied_onboard.helpers.lightglue_runtime`
|
||||
and :mod:`gps_denied_onboard.helpers.ransac_filter`. Produces a
|
||||
:class:`gps_denied_onboard._types.matching.KeypointSet` (the same
|
||||
DTO that :class:`LightGlueRuntime.match` consumes) from a raw BGR
|
||||
image.
|
||||
|
||||
Why a shared helper:
|
||||
|
||||
- C2.5 :class:`InlierCountReRanker` (AZ-343) consumes one
|
||||
:class:`FeatureExtractor` instance to extract features from each
|
||||
per-frame nav-camera image AND from each candidate tile's JPEG
|
||||
bytes. The same instance MUST produce comparable feature sets
|
||||
for both inputs — otherwise the LightGlue inlier count would
|
||||
collapse to noise.
|
||||
- A future C3 backbone that wants to share keypoints with C2.5
|
||||
(rather than re-extracting them) can read the same handle from
|
||||
the composition root, mirroring the
|
||||
:class:`LightGlueRuntime` ownership pattern (R14 fix).
|
||||
|
||||
Concrete impls:
|
||||
|
||||
- :class:`OpenCvOrbExtractor`: CPU, deterministic, placeholder used
|
||||
by tests and by the airborne binary until the C7
|
||||
:class:`InferenceRuntime`-backed DISK / ALIKED extractor lands.
|
||||
ORB returns binary (``uint8``) descriptors of 32 bytes; we
|
||||
convert to ``float32`` per the
|
||||
:class:`gps_denied_onboard._types.matching.KeypointSet` contract.
|
||||
- Future: TensorRT-backed DISK / ALIKED extractor; consumes
|
||||
:class:`InferenceRuntime` from C7.
|
||||
|
||||
This helper is intentionally L1 — it imports only ``numpy`` and
|
||||
``cv2`` plus the L1 :class:`KeypointSet` DTO. Concrete strategies
|
||||
that need GPU backbones live in their own modules and accept the
|
||||
:class:`InferenceRuntime` via constructor injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.matching import KeypointSet
|
||||
|
||||
__all__ = [
|
||||
"FeatureExtractor",
|
||||
"FeatureExtractorError",
|
||||
"OpenCvOrbExtractor",
|
||||
]
|
||||
|
||||
|
||||
# ORB descriptors are 32 bytes (256 bits). LightGlue's KeypointSet
|
||||
# requires float32 descriptors so we widen ORB's uint8 output. This is
|
||||
# a placeholder choice; production will swap in DISK/ALIKED (128-d
|
||||
# float32) via the C7 InferenceRuntime path.
|
||||
_ORB_DESCRIPTOR_BYTES = 32
|
||||
_ORB_FLOAT_DESCRIPTOR_DIM = _ORB_DESCRIPTOR_BYTES * 8 # 256-d float32
|
||||
|
||||
|
||||
class FeatureExtractorError(RuntimeError):
|
||||
"""Raised on extractor construction or per-image failure."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FeatureExtractor(Protocol):
|
||||
"""Image → :class:`KeypointSet` Protocol.
|
||||
|
||||
Implementations are constructor-injected by the composition root
|
||||
and shared across consumers (e.g., C2.5 :class:`InlierCountReRanker`
|
||||
uses one instance for both query frames and tile pixels).
|
||||
|
||||
Invariants:
|
||||
|
||||
- :meth:`extract` returns a :class:`KeypointSet` whose
|
||||
``descriptors.shape[1] == self.descriptor_dim()``.
|
||||
- ``keypoints`` is shape ``(N, 2)`` ``float32`` pixel coordinates.
|
||||
- ``descriptors`` is shape ``(N, descriptor_dim)`` ``float32``.
|
||||
- Empty inputs (zero keypoints detected) return an empty-but-shaped
|
||||
:class:`KeypointSet` (``N == 0``) rather than raising — the
|
||||
C2.5 strategy treats zero-feature candidates as drop events.
|
||||
- Deterministic for fixed inputs (no internal RNG state).
|
||||
"""
|
||||
|
||||
def extract(self, image_bgr: np.ndarray) -> KeypointSet:
|
||||
"""Detect keypoints + compute descriptors on a single image."""
|
||||
...
|
||||
|
||||
def descriptor_dim(self) -> int:
|
||||
"""Return the dim of every descriptor row produced by :meth:`extract`."""
|
||||
...
|
||||
|
||||
|
||||
class OpenCvOrbExtractor:
|
||||
"""CPU :class:`FeatureExtractor` backed by ``cv2.ORB_create``.
|
||||
|
||||
Placeholder implementation: ORB is fast (~5 ms / 480p image on a
|
||||
modern CPU) and stable enough to exercise the C2.5 strategy's
|
||||
orchestration logic, but its uint8 binary descriptors are NOT a
|
||||
drop-in for LightGlue-trained DISK/ALIKED features. Production
|
||||
deployments MUST replace this extractor with a deep-learning
|
||||
backbone before flight (tracked under the future C2.5
|
||||
backbone-extractor task).
|
||||
|
||||
The ``nfeatures`` constructor arg caps the number of keypoints
|
||||
per image; default 1024 mirrors typical DISK / ALIKED budgets.
|
||||
"""
|
||||
|
||||
def __init__(self, *, nfeatures: int = 1024) -> None:
|
||||
if nfeatures < 1:
|
||||
raise FeatureExtractorError(
|
||||
f"OpenCvOrbExtractor.nfeatures must be >= 1; got {nfeatures}"
|
||||
)
|
||||
self._nfeatures: int = nfeatures
|
||||
# ORB itself is created lazily so test environments without
|
||||
# a working OpenCV install can still import this module.
|
||||
# Cached on first call to amortise the per-image cost.
|
||||
self._orb: cv2.ORB | None = None
|
||||
|
||||
def descriptor_dim(self) -> int:
|
||||
return _ORB_FLOAT_DESCRIPTOR_DIM
|
||||
|
||||
def _get_orb(self) -> cv2.ORB:
|
||||
if self._orb is None:
|
||||
self._orb = cv2.ORB_create(nfeatures=self._nfeatures)
|
||||
return self._orb
|
||||
|
||||
def extract(self, image_bgr: np.ndarray) -> KeypointSet:
|
||||
if image_bgr.ndim == 3:
|
||||
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
||||
elif image_bgr.ndim == 2:
|
||||
gray = image_bgr
|
||||
else:
|
||||
raise FeatureExtractorError(
|
||||
"image_bgr must be 2-D (gray) or 3-D (BGR); "
|
||||
f"got ndim={image_bgr.ndim} shape={image_bgr.shape}"
|
||||
)
|
||||
if gray.dtype != np.uint8:
|
||||
gray = gray.astype(np.uint8)
|
||||
try:
|
||||
keypoints_cv, descriptors_uint8 = self._get_orb().detectAndCompute(
|
||||
gray, mask=None
|
||||
)
|
||||
except cv2.error as exc:
|
||||
raise FeatureExtractorError(f"cv2.ORB.detectAndCompute failed: {exc}") from exc
|
||||
if descriptors_uint8 is None or len(keypoints_cv) == 0:
|
||||
keypoints = np.zeros((0, 2), dtype=np.float32)
|
||||
descriptors = np.zeros((0, _ORB_FLOAT_DESCRIPTOR_DIM), dtype=np.float32)
|
||||
return KeypointSet(keypoints=keypoints, descriptors=descriptors)
|
||||
keypoints = np.array(
|
||||
[(kp.pt[0], kp.pt[1]) for kp in keypoints_cv], dtype=np.float32
|
||||
)
|
||||
# Expand each 32-byte ORB descriptor to a 256-d float32 vector
|
||||
# of bit indicators (0/1). Matches the contract that
|
||||
# ``KeypointSet.descriptors`` is float32.
|
||||
bits = np.unpackbits(descriptors_uint8, axis=1).astype(np.float32)
|
||||
return KeypointSet(keypoints=keypoints, descriptors=bits)
|
||||
Reference in New Issue
Block a user