mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 02:11:14 +00:00
[AZ-240] [AZ-241] [AZ-242] Add native retrieval remediation
Implement the product remediation paths required before greenfield code testability revision: native VIO backend selection, local VPR descriptor index retrieval, and computed anchor matching gates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"""Anchor verification component."""
|
||||
|
||||
from .interfaces import AnchorVerifier, GeometryGatedAnchorVerifier
|
||||
from .interfaces import (
|
||||
AnchorVerifier,
|
||||
FeatureMatcher,
|
||||
GeometryGatedAnchorVerifier,
|
||||
KeypointRansacMatcher,
|
||||
)
|
||||
from .types import (
|
||||
AnchorFrame,
|
||||
AnchorVerificationResult,
|
||||
CandidateTile,
|
||||
GeometryGateConfig,
|
||||
MatchEvidence,
|
||||
MatcherBenchmarkReport,
|
||||
@@ -15,8 +21,11 @@ __all__ = [
|
||||
"AnchorFrame",
|
||||
"AnchorVerificationResult",
|
||||
"AnchorVerifier",
|
||||
"CandidateTile",
|
||||
"FeatureMatcher",
|
||||
"GeometryGateConfig",
|
||||
"GeometryGatedAnchorVerifier",
|
||||
"KeypointRansacMatcher",
|
||||
"MatchEvidence",
|
||||
"MatcherBenchmarkReport",
|
||||
"MatcherBenchmarkResult",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Public anchor verification interfaces."""
|
||||
|
||||
from statistics import median
|
||||
from time import perf_counter
|
||||
from typing import Protocol
|
||||
|
||||
from shared.contracts import AnchorDecision
|
||||
@@ -7,10 +9,12 @@ from shared.contracts import AnchorDecision
|
||||
from .types import (
|
||||
AnchorFrame,
|
||||
AnchorVerificationResult,
|
||||
CandidateTile,
|
||||
GeometryGateConfig,
|
||||
MatchEvidence,
|
||||
MatcherBenchmarkReport,
|
||||
MatcherBenchmarkResult,
|
||||
MatcherProfile,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,11 +25,98 @@ class AnchorVerifier(Protocol):
|
||||
"""Return an anchor decision for one candidate."""
|
||||
|
||||
|
||||
class FeatureMatcher(Protocol):
|
||||
"""Computes correspondence evidence from local frame and tile inputs."""
|
||||
|
||||
def compute(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tile: CandidateTile,
|
||||
matcher_profile: MatcherProfile,
|
||||
) -> MatchEvidence:
|
||||
"""Return matcher and geometry evidence for one candidate tile."""
|
||||
|
||||
|
||||
class KeypointRansacMatcher:
|
||||
"""Small CPU matcher for keypoint fixtures and dependency-gated runs."""
|
||||
|
||||
def __init__(self, inlier_threshold_px: float = 2.0) -> None:
|
||||
self._inlier_threshold_px = inlier_threshold_px
|
||||
|
||||
def compute(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tile: CandidateTile,
|
||||
matcher_profile: MatcherProfile,
|
||||
) -> MatchEvidence:
|
||||
started = perf_counter()
|
||||
correspondences = tuple(zip(frame.keypoints, tile.keypoints))
|
||||
if len(correspondences) < 4:
|
||||
return MatchEvidence(
|
||||
candidate=tile.candidate,
|
||||
matcher_profile=matcher_profile,
|
||||
inliers=0,
|
||||
mean_reprojection_error_px=self._inlier_threshold_px + 1.0,
|
||||
homography=None,
|
||||
runtime_ms=(perf_counter() - started) * 1000.0,
|
||||
provenance_trusted=tile.provenance_trusted,
|
||||
evidence_source="computed_geometry",
|
||||
)
|
||||
|
||||
dx_values = tuple(tile_point[0] - frame_point[0] for frame_point, tile_point in correspondences)
|
||||
dy_values = tuple(tile_point[1] - frame_point[1] for frame_point, tile_point in correspondences)
|
||||
dx = median(dx_values)
|
||||
dy = median(dy_values)
|
||||
residuals = tuple(
|
||||
((frame_point[0] + dx - tile_point[0]) ** 2 + (frame_point[1] + dy - tile_point[1]) ** 2)
|
||||
** 0.5
|
||||
for frame_point, tile_point in correspondences
|
||||
)
|
||||
inlier_residuals = tuple(
|
||||
residual for residual in residuals if residual <= self._inlier_threshold_px
|
||||
)
|
||||
mean_error = (
|
||||
sum(inlier_residuals) / len(inlier_residuals)
|
||||
if inlier_residuals
|
||||
else self._inlier_threshold_px + 1.0
|
||||
)
|
||||
homography = (
|
||||
{
|
||||
"h00": 1.0,
|
||||
"h01": 0.0,
|
||||
"h02": dx,
|
||||
"h10": 0.0,
|
||||
"h11": 1.0,
|
||||
"h12": dy,
|
||||
"h20": 0.0,
|
||||
"h21": 0.0,
|
||||
"h22": 1.0,
|
||||
}
|
||||
if inlier_residuals
|
||||
else None
|
||||
)
|
||||
return MatchEvidence(
|
||||
candidate=tile.candidate,
|
||||
matcher_profile=matcher_profile,
|
||||
inliers=len(inlier_residuals),
|
||||
mean_reprojection_error_px=mean_error,
|
||||
homography=homography,
|
||||
runtime_ms=(perf_counter() - started) * 1000.0,
|
||||
provenance_trusted=tile.provenance_trusted,
|
||||
evidence_source="computed_geometry",
|
||||
)
|
||||
|
||||
|
||||
class GeometryGatedAnchorVerifier:
|
||||
"""Converts matcher evidence into accepted/rejected anchor decisions."""
|
||||
|
||||
def __init__(self, gates: GeometryGateConfig | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
gates: GeometryGateConfig | None = None,
|
||||
matcher: FeatureMatcher | None = None,
|
||||
) -> None:
|
||||
self._gates = gates or GeometryGateConfig()
|
||||
self._matcher = matcher or KeypointRansacMatcher()
|
||||
|
||||
def verify(self, frame: AnchorFrame, evidence: MatchEvidence) -> AnchorVerificationResult:
|
||||
accepted, reason = self._classify(frame, evidence)
|
||||
@@ -45,6 +136,15 @@ class GeometryGatedAnchorVerifier:
|
||||
freshness_status=evidence.candidate.freshness_status,
|
||||
)
|
||||
|
||||
def verify_candidate(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tile: CandidateTile,
|
||||
matcher_profile: MatcherProfile = "sift_orb",
|
||||
) -> AnchorVerificationResult:
|
||||
evidence = self._matcher.compute(frame, tile, matcher_profile)
|
||||
return self.verify(frame, evidence)
|
||||
|
||||
def benchmark(
|
||||
self, frame: AnchorFrame, evidences: tuple[MatchEvidence, ...]
|
||||
) -> MatcherBenchmarkReport:
|
||||
@@ -65,6 +165,17 @@ class GeometryGatedAnchorVerifier:
|
||||
results=tuple(results),
|
||||
)
|
||||
|
||||
def benchmark_candidates(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tiles: tuple[CandidateTile, ...],
|
||||
matcher_profile: MatcherProfile = "sift_orb",
|
||||
) -> MatcherBenchmarkReport:
|
||||
return self.benchmark(
|
||||
frame,
|
||||
tuple(self._matcher.compute(frame, tile, matcher_profile) for tile in tiles),
|
||||
)
|
||||
|
||||
def _classify(self, frame: AnchorFrame, evidence: MatchEvidence) -> tuple[bool, str]:
|
||||
if not frame.usable_for_anchor:
|
||||
return False, "frame_not_usable"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Anchor Verification Native Bridge
|
||||
# Anchor Verification Matcher Package
|
||||
|
||||
Reserved for native feature extraction, matching, and RANSAC acceleration code owned by `anchor_verification`.
|
||||
Exports local feature matching and geometry verification boundaries owned by `anchor_verification`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Anchor feature matching package exports."""
|
||||
|
||||
from anchor_verification.interfaces import FeatureMatcher, KeypointRansacMatcher
|
||||
|
||||
__all__ = ["FeatureMatcher", "KeypointRansacMatcher"]
|
||||
@@ -18,6 +18,14 @@ class AnchorFrame(AnchorVerificationModel):
|
||||
frame_id: str = Field(min_length=1)
|
||||
image_ref: str = Field(min_length=1)
|
||||
usable_for_anchor: bool = True
|
||||
keypoints: tuple[tuple[float, float], ...] = ()
|
||||
|
||||
|
||||
class CandidateTile(AnchorVerificationModel):
|
||||
candidate: VprCandidate
|
||||
image_ref: str = Field(min_length=1)
|
||||
keypoints: tuple[tuple[float, float], ...] = ()
|
||||
provenance_trusted: bool = True
|
||||
|
||||
|
||||
class GeometryGateConfig(AnchorVerificationModel):
|
||||
@@ -33,6 +41,7 @@ class MatchEvidence(AnchorVerificationModel):
|
||||
homography: dict[str, float] | None = None
|
||||
runtime_ms: NonNegativeFloat
|
||||
provenance_trusted: bool = True
|
||||
evidence_source: Literal["computed_geometry", "external_evidence"] = "external_evidence"
|
||||
|
||||
|
||||
class AnchorVerificationResult(AnchorVerificationModel):
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Offline satellite retrieval and synchronization component."""
|
||||
|
||||
from .interfaces import LocalVprRetriever, SatelliteService, SatelliteSyncBoundary
|
||||
from .interfaces import (
|
||||
CpuFaissDescriptorIndex,
|
||||
DescriptorIndex,
|
||||
LocalVprRetriever,
|
||||
SatelliteService,
|
||||
SatelliteSyncBoundary,
|
||||
)
|
||||
from .types import (
|
||||
DescriptorFidelityReport,
|
||||
GeneratedTileUploadRecord,
|
||||
@@ -18,7 +24,9 @@ from .types import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CpuFaissDescriptorIndex",
|
||||
"DescriptorFidelityReport",
|
||||
"DescriptorIndex",
|
||||
"GeneratedTileUploadRecord",
|
||||
"LocalVprIndexPackage",
|
||||
"LocalVprRetriever",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Public satellite service interfaces."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from math import sqrt
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from typing import Protocol
|
||||
|
||||
from shared.contracts import VprCandidate
|
||||
@@ -19,6 +21,7 @@ from .types import (
|
||||
SatelliteSyncResult,
|
||||
SatelliteSyncStatus,
|
||||
UploadOutcome,
|
||||
VprDescriptorRecord,
|
||||
VprReadinessReport,
|
||||
VprRetrievalResult,
|
||||
)
|
||||
@@ -34,95 +37,47 @@ class SatelliteService(Protocol):
|
||||
"""Return candidate anchor records for one frame."""
|
||||
|
||||
|
||||
class LocalVprRetriever:
|
||||
"""Triggered local VPR retrieval over preloaded descriptor records."""
|
||||
class DescriptorIndex(Protocol):
|
||||
"""Search boundary for local descriptor packages."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._index: LocalVprIndexPackage | None = None
|
||||
@property
|
||||
def record_count(self) -> int:
|
||||
"""Return the number of loaded descriptor records."""
|
||||
|
||||
def load_index(self, package: LocalVprIndexPackage) -> VprReadinessReport:
|
||||
self._index = package
|
||||
return VprReadinessReport(
|
||||
ready=True,
|
||||
engine=package.engine,
|
||||
loaded_records=len(package.records),
|
||||
)
|
||||
def search(
|
||||
self,
|
||||
query_descriptor: tuple[float, ...],
|
||||
top_k: int,
|
||||
) -> tuple[tuple[float, VprDescriptorRecord], ...]:
|
||||
"""Return scored descriptor records in descending score order."""
|
||||
|
||||
def readiness(self) -> VprReadinessReport:
|
||||
if self._index is None:
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._error("local VPR index is not loaded", "index_not_loaded"),
|
||||
)
|
||||
return VprReadinessReport(
|
||||
ready=True,
|
||||
engine=self._index.engine,
|
||||
loaded_records=len(self._index.records),
|
||||
)
|
||||
|
||||
def retrieve(self, request: RelocalizationRequest) -> VprRetrievalResult:
|
||||
readiness = self.readiness()
|
||||
if not readiness.ready:
|
||||
return VprRetrievalResult(
|
||||
ready=False,
|
||||
degraded=True,
|
||||
error=readiness.error,
|
||||
)
|
||||
class CpuFaissDescriptorIndex:
|
||||
"""CPU vector index with a FAISS-compatible search contract."""
|
||||
|
||||
assert self._index is not None
|
||||
query_descriptor = request.query_descriptor or self._extract_descriptor(request.image_ref)
|
||||
def __init__(self, records: tuple[VprDescriptorRecord, ...]) -> None:
|
||||
self._records = tuple(record for record in records if record.freshness_status != "rejected")
|
||||
|
||||
@property
|
||||
def record_count(self) -> int:
|
||||
return len(self._records)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query_descriptor: tuple[float, ...],
|
||||
top_k: int,
|
||||
) -> tuple[tuple[float, VprDescriptorRecord], ...]:
|
||||
scored = sorted(
|
||||
(
|
||||
(self._similarity(query_descriptor, record.descriptor), record)
|
||||
for record in self._index.records
|
||||
if record.freshness_status != "rejected"
|
||||
(self._cosine_similarity(query_descriptor, record.descriptor), record)
|
||||
for record in self._records
|
||||
),
|
||||
key=lambda item: item[0],
|
||||
reverse=True,
|
||||
)
|
||||
candidates = tuple(
|
||||
VprCandidate(
|
||||
chunk_id=record.chunk_id,
|
||||
tile_id=record.tile_id,
|
||||
score=score,
|
||||
footprint=record.footprint,
|
||||
freshness_status=record.freshness_status,
|
||||
)
|
||||
for score, record in scored[: request.top_k]
|
||||
)
|
||||
if not candidates:
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=True,
|
||||
error=self._error("local VPR index produced no valid candidates", "no_candidates"),
|
||||
)
|
||||
return tuple(scored[:top_k])
|
||||
|
||||
return VprRetrievalResult(ready=True, degraded=False, candidates=candidates)
|
||||
|
||||
def verify_descriptor_fidelity(
|
||||
self,
|
||||
reference_descriptor: tuple[float, ...],
|
||||
optimized_descriptor: tuple[float, ...],
|
||||
max_l2_delta: float,
|
||||
) -> DescriptorFidelityReport:
|
||||
observed_delta = self._l2_distance(reference_descriptor, optimized_descriptor)
|
||||
return DescriptorFidelityReport(
|
||||
accepted=observed_delta <= max_l2_delta,
|
||||
observed_l2_delta=observed_delta,
|
||||
max_l2_delta=max_l2_delta,
|
||||
)
|
||||
|
||||
def _extract_descriptor(self, image_ref: str) -> tuple[float, ...]:
|
||||
encoded = image_ref.encode("utf-8")
|
||||
buckets = [0.0, 0.0, 0.0, 0.0]
|
||||
for index, value in enumerate(encoded):
|
||||
buckets[index % len(buckets)] += value / 255.0
|
||||
magnitude = sqrt(sum(value * value for value in buckets)) or 1.0
|
||||
return tuple(value / magnitude for value in buckets)
|
||||
|
||||
def _similarity(
|
||||
def _cosine_similarity(
|
||||
self,
|
||||
query_descriptor: tuple[float, ...],
|
||||
record_descriptor: tuple[float, ...],
|
||||
@@ -138,6 +93,138 @@ class LocalVprRetriever:
|
||||
record_norm = sqrt(sum(value * value for value in padded_record)) or 1.0
|
||||
return max(0.0, min(1.0, dot_product / (query_norm * record_norm)))
|
||||
|
||||
|
||||
class LocalVprRetriever:
|
||||
"""Triggered local VPR retrieval over mission-cache descriptor indexes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._package: LocalVprIndexPackage | None = None
|
||||
self._index: DescriptorIndex | None = None
|
||||
self._load_error: ErrorEnvelope | None = None
|
||||
|
||||
def load_index(self, package: LocalVprIndexPackage) -> VprReadinessReport:
|
||||
self._package = package
|
||||
self._index = CpuFaissDescriptorIndex(package.records)
|
||||
self._load_error = None
|
||||
return VprReadinessReport(
|
||||
ready=self._index.record_count > 0,
|
||||
engine=package.engine,
|
||||
loaded_records=self._index.record_count,
|
||||
package_id=package.package_id,
|
||||
descriptor_model=package.descriptor_model,
|
||||
error=None
|
||||
if self._index.record_count > 0
|
||||
else self._error("local descriptor index has no searchable records", "empty_index"),
|
||||
)
|
||||
|
||||
def load_index_from_path(self, package_path: str | Path) -> VprReadinessReport:
|
||||
try:
|
||||
return self.load_index(LocalVprIndexPackage.from_json_file(package_path))
|
||||
except (FileNotFoundError, OSError, ValueError) as exc:
|
||||
self._package = None
|
||||
self._index = None
|
||||
self._load_error = self._error(
|
||||
f"local descriptor index package could not be loaded: {exc}",
|
||||
"index_package_invalid",
|
||||
)
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._load_error,
|
||||
)
|
||||
|
||||
def readiness(self) -> VprReadinessReport:
|
||||
if self._load_error is not None:
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._load_error,
|
||||
)
|
||||
if self._index is None or self._package is None:
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._error("local descriptor index is not loaded", "index_not_loaded"),
|
||||
)
|
||||
return VprReadinessReport(
|
||||
ready=True,
|
||||
engine=self._package.engine,
|
||||
loaded_records=self._index.record_count,
|
||||
package_id=self._package.package_id,
|
||||
descriptor_model=self._package.descriptor_model,
|
||||
)
|
||||
|
||||
def retrieve(self, request: RelocalizationRequest) -> VprRetrievalResult:
|
||||
started = perf_counter()
|
||||
readiness = self.readiness()
|
||||
if not readiness.ready:
|
||||
return VprRetrievalResult(
|
||||
ready=False,
|
||||
degraded=True,
|
||||
retrieval_path="unavailable",
|
||||
error=readiness.error,
|
||||
)
|
||||
|
||||
if request.query_descriptor is None:
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=True,
|
||||
retrieval_path="unavailable",
|
||||
error=self._error(
|
||||
"query descriptor is required for local descriptor index retrieval",
|
||||
"query_descriptor_missing",
|
||||
),
|
||||
)
|
||||
|
||||
assert self._index is not None
|
||||
scored = self._index.search(request.query_descriptor, request.top_k)
|
||||
candidates = tuple(
|
||||
VprCandidate(
|
||||
chunk_id=record.chunk_id,
|
||||
tile_id=record.tile_id,
|
||||
score=score,
|
||||
footprint=record.footprint,
|
||||
freshness_status=record.freshness_status,
|
||||
)
|
||||
for score, record in scored[: request.top_k]
|
||||
)
|
||||
latency_ms = (perf_counter() - started) * 1000.0
|
||||
if not candidates:
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=True,
|
||||
retrieval_path="local_descriptor_index",
|
||||
latency_ms=latency_ms,
|
||||
error=self._error(
|
||||
"local descriptor index produced no valid candidates",
|
||||
"no_candidates",
|
||||
),
|
||||
)
|
||||
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=False,
|
||||
candidates=candidates,
|
||||
retrieval_path="local_descriptor_index",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
def verify_descriptor_fidelity(
|
||||
self,
|
||||
reference_descriptor: tuple[float, ...],
|
||||
optimized_descriptor: tuple[float, ...],
|
||||
max_l2_delta: float,
|
||||
) -> DescriptorFidelityReport:
|
||||
observed_delta = self._l2_distance(reference_descriptor, optimized_descriptor)
|
||||
return DescriptorFidelityReport(
|
||||
accepted=observed_delta <= max_l2_delta,
|
||||
observed_l2_delta=observed_delta,
|
||||
max_l2_delta=max_l2_delta,
|
||||
)
|
||||
|
||||
def _l2_distance(
|
||||
self,
|
||||
reference_descriptor: tuple[float, ...],
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Satellite Service Native Bridge
|
||||
# Satellite Service Descriptor Index Package
|
||||
|
||||
Reserved for ONNX/TensorRT descriptor inference integrations owned by `satellite_service`.
|
||||
Exports local descriptor index search boundaries owned by `satellite_service`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Local descriptor index package exports."""
|
||||
|
||||
from satellite_service.interfaces import CpuFaissDescriptorIndex, DescriptorIndex
|
||||
|
||||
__all__ = ["CpuFaissDescriptorIndex", "DescriptorIndex"]
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Public satellite service models."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveInt
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, ValidationError
|
||||
|
||||
from shared.contracts import VprCandidate
|
||||
from shared.errors import ErrorEnvelope
|
||||
@@ -57,8 +59,14 @@ class VprDescriptorRecord(SatelliteServiceModel):
|
||||
class LocalVprIndexPackage(SatelliteServiceModel):
|
||||
package_id: str = Field(min_length=1)
|
||||
engine: Literal["cpu_faiss"] = "cpu_faiss"
|
||||
descriptor_model: str = Field(default="dinov2_vlad", min_length=1)
|
||||
records: tuple[VprDescriptorRecord, ...] = Field(min_length=1)
|
||||
|
||||
@classmethod
|
||||
def from_json_file(cls, package_path: str | Path) -> "LocalVprIndexPackage":
|
||||
payload = json.loads(Path(package_path).read_text(encoding="utf-8"))
|
||||
return cls.model_validate(payload)
|
||||
|
||||
|
||||
class RelocalizationRequest(SatelliteServiceModel):
|
||||
frame_id: str = Field(min_length=1)
|
||||
@@ -72,6 +80,8 @@ class VprReadinessReport(SatelliteServiceModel):
|
||||
ready: bool
|
||||
engine: Literal["cpu_faiss"]
|
||||
loaded_records: int = Field(ge=0)
|
||||
package_id: str | None = None
|
||||
descriptor_model: str | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
@@ -79,6 +89,8 @@ class VprRetrievalResult(SatelliteServiceModel):
|
||||
ready: bool
|
||||
degraded: bool
|
||||
candidates: tuple[VprCandidate, ...] = ()
|
||||
retrieval_path: Literal["local_descriptor_index", "unavailable"] = "unavailable"
|
||||
latency_ms: float | None = Field(default=None, ge=0.0)
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
@@ -90,3 +102,4 @@ class DescriptorFidelityReport(SatelliteServiceModel):
|
||||
|
||||
RuntimePhase = Literal["pre_flight", "in_flight", "post_flight"]
|
||||
UploadOutcome = Literal["success", "retryable_failure", "rejected"]
|
||||
IndexLoadError = FileNotFoundError | json.JSONDecodeError | ValidationError | OSError
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
"""Replaceable VIO adapter component."""
|
||||
|
||||
from .interfaces import DeterministicVioBackend, LocalVioAdapter, VioAdapter, VioBackend
|
||||
from .interfaces import (
|
||||
LocalVioAdapter,
|
||||
NativeVioBackend,
|
||||
NativeVioRunner,
|
||||
ReplayVioBackend,
|
||||
VioAdapter,
|
||||
VioBackend,
|
||||
VioBackendError,
|
||||
)
|
||||
from .types import VioBackendEstimate, VioHealthReport, VioInputPacket, VioProcessingResult
|
||||
|
||||
__all__ = [
|
||||
"DeterministicVioBackend",
|
||||
"LocalVioAdapter",
|
||||
"NativeVioBackend",
|
||||
"NativeVioRunner",
|
||||
"ReplayVioBackend",
|
||||
"VioAdapter",
|
||||
"VioBackend",
|
||||
"VioBackendError",
|
||||
"VioBackendEstimate",
|
||||
"VioHealthReport",
|
||||
"VioInputPacket",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Public VIO adapter interfaces."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
from time import perf_counter
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from shared.contracts import VioStatePacket
|
||||
from shared.errors import ErrorEnvelope
|
||||
@@ -28,7 +29,7 @@ class VioAdapter(Protocol):
|
||||
|
||||
|
||||
class VioBackend(Protocol):
|
||||
"""Backend-neutral native bridge boundary."""
|
||||
"""Backend-neutral VIO execution boundary."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize native backend resources."""
|
||||
@@ -37,8 +38,61 @@ class VioBackend(Protocol):
|
||||
"""Return one relative VIO estimate."""
|
||||
|
||||
|
||||
class DeterministicVioBackend:
|
||||
"""Small deterministic backend used until a native bridge is attached."""
|
||||
@runtime_checkable
|
||||
class NativeVioRunner(Protocol):
|
||||
"""Runtime object supplied by the selected VIO engine package."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Prepare engine resources."""
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate | dict[str, Any]:
|
||||
"""Return an estimate payload for one synchronized replay frame."""
|
||||
|
||||
|
||||
class VioBackendError(RuntimeError):
|
||||
"""Raised when the configured VIO engine cannot produce an estimate."""
|
||||
|
||||
|
||||
class NativeVioBackend:
|
||||
"""Configurable backend adapter for native VIO engine packages."""
|
||||
|
||||
def __init__(self, runner: NativeVioRunner, backend_name: str = "native_vio") -> None:
|
||||
self._runner = runner
|
||||
self.backend_name = backend_name
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
self._runner.initialize()
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} initialization failed") from exc
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
started = perf_counter()
|
||||
try:
|
||||
estimate = self._runner.estimate(frame, telemetry_window)
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} estimate failed") from exc
|
||||
|
||||
try:
|
||||
if isinstance(estimate, VioBackendEstimate):
|
||||
if estimate.processing_latency_ms is not None:
|
||||
return estimate
|
||||
payload = estimate.model_dump()
|
||||
else:
|
||||
payload = dict(estimate)
|
||||
payload.setdefault("timestamp_ns", frame.timestamp_ns)
|
||||
payload["processing_latency_ms"] = payload.get("processing_latency_ms") or (
|
||||
perf_counter() - started
|
||||
) * 1000.0
|
||||
return VioBackendEstimate.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} returned invalid estimate") from exc
|
||||
|
||||
|
||||
class ReplayVioBackend:
|
||||
"""Small local backend for replay smoke tests when no engine is configured."""
|
||||
|
||||
backend_name = "replay_vio"
|
||||
|
||||
def initialize(self) -> None:
|
||||
return None
|
||||
@@ -74,7 +128,8 @@ class LocalVioAdapter:
|
||||
timestamp_tolerance_ns: int = 5_000_000,
|
||||
degraded_quality_threshold: float = 0.35,
|
||||
) -> None:
|
||||
self._backend = backend or DeterministicVioBackend()
|
||||
self._backend = backend or ReplayVioBackend()
|
||||
self._backend_name = getattr(self._backend, "backend_name", self._backend.__class__.__name__)
|
||||
self._timestamp_tolerance_ns = timestamp_tolerance_ns
|
||||
self._degraded_quality_threshold = degraded_quality_threshold
|
||||
self._initialized = False
|
||||
@@ -82,20 +137,35 @@ class LocalVioAdapter:
|
||||
initialized=False,
|
||||
state="not_initialized",
|
||||
tracking_quality=0.0,
|
||||
backend_name=self._backend_name,
|
||||
)
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._backend.initialize()
|
||||
try:
|
||||
self._backend.initialize()
|
||||
except Exception as exc:
|
||||
self._initialized = False
|
||||
self._health = VioHealthReport(
|
||||
initialized=False,
|
||||
state="failed",
|
||||
tracking_quality=0.0,
|
||||
backend_name=self._backend_name,
|
||||
error=self._backend_error(str(exc), "backend_initialization_failed", "error"),
|
||||
)
|
||||
return None
|
||||
self._initialized = True
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state="ready",
|
||||
tracking_quality=1.0,
|
||||
backend_name=self._backend_name,
|
||||
)
|
||||
|
||||
def process(self, packet: VioInputPacket) -> VioProcessingResult:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
if self._health.state == "failed":
|
||||
return VioProcessingResult(health=self._health, error=self._health.error)
|
||||
|
||||
telemetry_timestamps = [sample.timestamp_ns for sample in packet.telemetry_samples]
|
||||
time_window = select_time_window(
|
||||
@@ -116,6 +186,7 @@ class LocalVioAdapter:
|
||||
initialized=True,
|
||||
state="degraded",
|
||||
tracking_quality=0.0,
|
||||
backend_name=self._backend_name,
|
||||
error=error,
|
||||
)
|
||||
return VioProcessingResult(health=self._health, error=error)
|
||||
@@ -125,7 +196,18 @@ class LocalVioAdapter:
|
||||
for sample in packet.telemetry_samples
|
||||
if sample.timestamp_ns in set(time_window.sample_timestamps_ns)
|
||||
)
|
||||
estimate = self._backend.estimate(packet.frame, telemetry_window)
|
||||
try:
|
||||
estimate = self._backend.estimate(packet.frame, telemetry_window)
|
||||
except Exception as exc:
|
||||
error = self._backend_error(str(exc), "backend_runtime_failed", "error")
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state="failed",
|
||||
tracking_quality=0.0,
|
||||
backend_name=self._backend_name,
|
||||
error=error,
|
||||
)
|
||||
return VioProcessingResult(health=self._health, error=error)
|
||||
state_packet = VioStatePacket(
|
||||
timestamp_ns=estimate.timestamp_ns,
|
||||
relative_pose=estimate.relative_pose,
|
||||
@@ -141,8 +223,23 @@ class LocalVioAdapter:
|
||||
initialized=True,
|
||||
state=health_state,
|
||||
tracking_quality=estimate.tracking_quality,
|
||||
backend_name=self._backend_name,
|
||||
)
|
||||
return VioProcessingResult(
|
||||
state_packet=state_packet,
|
||||
health=self._health,
|
||||
processing_latency_ms=estimate.processing_latency_ms,
|
||||
)
|
||||
return VioProcessingResult(state_packet=state_packet, health=self._health)
|
||||
|
||||
def health(self) -> VioHealthReport:
|
||||
return self._health
|
||||
|
||||
def _backend_error(self, message: str, cause: str, severity: str) -> ErrorEnvelope:
|
||||
return ErrorEnvelope(
|
||||
component="vio_adapter",
|
||||
category="runtime",
|
||||
message=message,
|
||||
severity=severity,
|
||||
retryable=False,
|
||||
cause=cause,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# VIO Native Bridge
|
||||
# VIO Native Backend Package
|
||||
|
||||
Reserved for native VIO backend integration code owned by `vio_adapter`.
|
||||
Exports the configured VIO backend package boundary owned by `vio_adapter`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Native VIO backend package exports."""
|
||||
|
||||
from vio_adapter.interfaces import NativeVioBackend, NativeVioRunner, VioBackendError
|
||||
|
||||
__all__ = ["NativeVioBackend", "NativeVioRunner", "VioBackendError"]
|
||||
@@ -21,12 +21,14 @@ class VioHealthReport(VioAdapterModel):
|
||||
initialized: bool
|
||||
state: Literal["not_initialized", "ready", "degraded", "failed"]
|
||||
tracking_quality: float = Field(ge=0.0, le=1.0)
|
||||
backend_name: str | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class VioProcessingResult(VioAdapterModel):
|
||||
state_packet: VioStatePacket | None = None
|
||||
health: VioHealthReport
|
||||
processing_latency_ms: float | None = Field(default=None, ge=0.0)
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
@@ -37,3 +39,4 @@ class VioBackendEstimate(VioAdapterModel):
|
||||
tracking_quality: float = Field(ge=0.0, le=1.0)
|
||||
bias_estimate: dict[str, float] | None = None
|
||||
covariance_hint: list[list[float]] | None = None
|
||||
processing_latency_ms: float | None = Field(default=None, ge=0.0)
|
||||
|
||||
Reference in New Issue
Block a user