[AZ-358] [AZ-361] C4 OpenCVGtsamPoseEstimator + Jacobian thermal hybrid

Implement the single production-default C4 PoseEstimator strategy.

AZ-358 — Marginals path: OpenCV solvePnPRansac (SOLVEPNP_IPPE) on
best-candidate inliers, PriorFactorPose3 with Jacobian-derived initial
covariance, flushed into C5's iSAM2 graph via the widened
ISam2GraphHandle.update(graph, values, None) (Option B). Posterior
covariance from compute_marginals().marginalCovariance(pose_key) with
SPD-defensive Cholesky check. Tile pixel -> ENU world conversion via
the shared WgsConverter + a configurable tile_size_px. Two spec
deviations now documented in the AZ-358 task file: PriorFactorPose3
over GenericProjectionFactorCal3DS2 (avoids unbounded landmark
variables; same Fisher information on the pose marginal) and explicit
(graph, values, timestamps) update args (aligns with C5's impl).

AZ-361 — Jacobian + thermal hybrid: per-frame dispatch on
thermal_state.thermal_throttle_active selects the cv2.projectPoints-
derived 6x6 information matrix (with ridge regularisation) as the
emitted covariance. Skips the iSAM2 factor add under throttle
(Invariant 12). Emits CovarianceDegradedWarning via warnings.warn
(never raised); paired WARN log + FDR record rate-limited per
covariance_degraded_warn_window_ns (default 60 s) via an injected
monotonic Clock. Supersedes the AZ-358 NotImplementedError stub.

Widens ISam2GraphHandle from get_pose_key only to all five C4-facing
methods (add_factor, update, compute_marginals, last_anchor_age_ms);
C5's existing ISam2GraphHandleImpl already satisfies the superset, so
no C5 source change this batch. Threads fdr_client + clock through
pose_factory composition.

Registers two new FDR payload kinds: pose.frame_done (per-call
telemetry; both success and PnpFailureError paths) and
pose.covariance_degraded (per-window throttle exposure).

Tests: 21 new (AZ-358 AC-1..11 + AZ-361 AC-1..10/12/13; AZ-361 AC-11
RMSE-ratio informational per spec, not asserted). Updates 2 existing
test files for Protocol widening and the FDR-schema round trip.

Code review verdict: PASS_WITH_WARNINGS (5 findings: Medium x2,
Low x3; none blocking). Full suite: 1958 passed, 1 unrelated
host-dependent perf failure (c12 CLI cold-start, pre-existing).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 05:01:14 +03:00
parent 360aece7a6
commit 4eac24f37a
13 changed files with 2452 additions and 35 deletions
+46 -13
View File
@@ -78,11 +78,28 @@ def _build_config(**overrides: Any) -> Config:
class _FakeISam2GraphHandle:
"""Minimal handle stub for factory / Protocol tests."""
"""Minimal handle stub for factory / Protocol tests.
Implements the AZ-358-extended 5-method surface — the AZ-355
AC-10 ``isinstance(handle, ISam2GraphHandle)`` runtime-checkable
test now expects all five methods.
"""
def get_pose_key(self, frame_id: int) -> int:
return int(frame_id)
def add_factor(self, factor: Any) -> None:
return None
def update(self, graph: Any, values: Any, timestamps: Any | None = None) -> None:
return None
def compute_marginals(self) -> Any:
return None
def last_anchor_age_ms(self) -> int:
return 0
class _FakePoseEstimator:
"""Test double satisfying the full PoseEstimator Protocol."""
@@ -378,6 +395,18 @@ def test_ac10_isam2_graph_handle_rejects_missing_method() -> None:
assert not isinstance(_NoMethod(), ISam2GraphHandle)
def test_ac10_isam2_graph_handle_rejects_partial_surface() -> None:
"""AZ-358 widened the Protocol to 5 methods; a handle that only
implements the original ``get_pose_key`` no longer satisfies
runtime_checkable conformance."""
class _OnlyGetPoseKey:
def get_pose_key(self, frame_id: int) -> int:
return int(frame_id)
assert not isinstance(_OnlyGetPoseKey(), ISam2GraphHandle)
# ---------------------------------------------------------------------
# Bonus: factory wires constructor dependencies through to the strategy
@@ -411,19 +440,23 @@ def test_factory_passes_dependencies_to_strategy() -> None:
def test_factory_lazy_imports_when_registry_empty() -> None:
# Arrange — registry is empty (fixture cleared it); the
# lazy-import fallback should pick up the AZ-358 concrete
# ``opencv_gtsam_estimator`` module and resolve its ``create``
# callable.
cfg = _build_config()
# Registry is cleared by the fixture; the lazy-import fallback
# should attempt to import the concrete module. We have not
# shipped opencv_gtsam_estimator yet (AZ-358), so the import
# raises and gets wrapped in PoseEstimatorConfigError.
with pytest.raises(PoseEstimatorConfigError):
build_pose_estimator(
cfg,
ransac_filter=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
se3_utils=mock.MagicMock(),
isam2_graph_handle=_FakeISam2GraphHandle(),
)
# Act — call should succeed (lazy import resolves to AZ-358).
estimator = build_pose_estimator(
cfg,
ransac_filter=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
se3_utils=mock.MagicMock(),
isam2_graph_handle=_FakeISam2GraphHandle(),
)
# Assert — the returned object satisfies the PoseEstimator Protocol.
assert isinstance(estimator, PoseEstimator)
# ---------------------------------------------------------------------