[AZ-334] C1 KLT/RANSAC strategy — engine-rule simple-baseline VIO

Implement KltRansacStrategy, the ADR-002 engine-rule mandatory
simple-baseline VioStrategy for E-C1. Pure-Python facade over
OpenCV's cv2.goodFeaturesToTrack / calcOpticalFlowPyrLK /
findEssentialMat / recoverPose pipeline — no C++/pybind11 binding
by design so a Tier-0 workstation runs the strategy with
`pip install opencv-python` and the BUILD_KLT_RANSAC=ON gate alone.
Constructor + state machine + FDR transition spine mirror
Okvis2Strategy + VinsMonoStrategy so the AZ-331 factory + IT-12
comparative harness treat all three as drop-in substitutable; the
duplication is the consolidation target now formally in scope for
the next cumulative review (batches 52-54).

AC coverage: AC-1..AC-11 + NFR-perf mapped to passing tests
(25 tests, 23 pass + 2 tier-2 skipped on dev/CI runners; all 25
pass under GPS_DENIED_TIER=2). Honest-covariance invariant (AC-9)
implemented as residual-scatter / (N_inliers - 5) with an inlier-
count penalty — no client-side floor or smoother; cov Frobenius
grows monotonically across DEGRADED. Camera-agnostic source
(AC-11) enforced by CI-grep gate that excludes docstring text.

Test-Run Cadence: focused suite tests/unit/c1_vio/ green (95 passed,
6 skipped); config-loader + compose-root suites green; full-suite
gate deferred to Step 16 per implement skill.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 02:40:01 +03:00
parent 4815dd6aa1
commit ceb24b5a62
10 changed files with 2371 additions and 14 deletions
@@ -0,0 +1,177 @@
# Batch 54 — Cycle 1 Report
**Date**: 2026-05-14
**Tasks**: AZ-334 (C1 KLT/RANSAC Strategy)
**Verdict**: COMPLETE — PASS_WITH_WARNINGS
## Summary
Implemented `KltRansacStrategy`, the ADR-002 engine-rule mandatory
simple-baseline VIO for C1. Pure-Python facade over OpenCV's
`cv2.goodFeaturesToTrack` / `cv2.calcOpticalFlowPyrLK` /
`cv2.findEssentialMat` / `cv2.recoverPose` pipeline — no C++/pybind11
binding by design so a Tier-0 workstation can run the strategy with
`pip install opencv-python` and the AZ-331 factory's
`BUILD_KLT_RANSAC=ON` gate. Mirrors the AZ-332 OKVIS2 + AZ-333
VINS-Mono facade pattern on the orchestration spine so the AZ-331
factory + IT-12 comparative harness treat all three strategies as
drop-in substitutable.
## Files added / modified
### Added (3)
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py`
Python facade, ~770 lines.
- `tests/unit/c1_vio/test_klt_ransac_strategy.py` — AC-1..AC-11 +
NFR-perf + KltRansacConfig validation tests, ~990 lines, 25 tests
(23 pass + 2 tier-2 skipped on dev/CI runners).
- `_docs/03_implementation/reviews/batch_54_review.md` — code review
report.
### Modified (4)
- `src/gps_denied_onboard/components/c1_vio/config.py` — add
`KltRansacConfig` dataclass + `klt_ransac` field on `C1VioConfig`;
`__all__` updated. (Pre-existing `KNOWN_STRATEGIES` already had
`klt_ransac` from AZ-331.)
- `src/gps_denied_onboard/components/c1_vio/__init__.py` — export
`KltRansacConfig`.
- `cpp/klt_ransac/CMakeLists.txt` — replace AZ-263 placeholder with
a deliberate "pure-Python; no native target" STATUS message so
the build graph stays symmetric with `cpp/okvis2/` and
`cpp/vins_mono/` while the `BUILD_KLT_RANSAC=ON` flag only gates
the Python module import at the AZ-331 composition-root factory.
- `tests/unit/c1_vio/test_protocol_conformance.py` — introduce the
`_STRATEGIES_WITHOUT_NATIVE_BINDING` category and route
`klt_ransac` through it inside
`test_ac5_build_vio_strategy_flag_on_but_module_missing` so the
parametrised factory test correctly handles the pure-Python
shape (no native binding to fail on; the construction should
succeed and return a `VioStrategy` instance instead).
## AC coverage (AC-1..AC-11 + NFR-perf)
All 11 ACs + NFR-perf mapped to passing tests (see
`batch_54_review.md` Phase 2 table). AC-9 + NFR-perf are
tier-2-tagged per the task spec; they skip on macOS dev + GitHub
Actions Linux runner with `Tier-2-only test; set GPS_DENIED_TIER=2
to run`. AC-3 / AC-6 / AC-9 / AC-10 / AC-11 / NFR-perf monkeypatch
`cv2.findEssentialMat` + `cv2.recoverPose` +
`RansacFilter.filter_correspondences` to deterministic values so
the unit suite exercises the FACADE's state machine without
depending on real OpenCV geometry on synthetic correspondences;
real-geometry validation lives in C1-IT-12 (Jetson Tier-2 fixture).
## Test results
### Focused suite — `tests/unit/c1_vio/`
```
95 passed, 6 skipped in 1.67s
```
The 6 skips are the 2 OKVIS2 tier-2 tests + the 2 VINS-Mono tier-2
tests + the 2 new KLT/RANSAC tier-2 tests (`test_ac9_*` and
`test_nfr_perf_*`).
### Adjacent regression — config / compose-root
```
17 passed in 1.96s
```
(`test_az269_config_loader.py`, `test_az270_compose_root.py` — all
green; the new `KltRansacConfig` registration did not break the
schema-loader or compose-root layered-import guards.)
### Tier-2 verification
```
GPS_DENIED_TIER=2 pytest tests/unit/c1_vio/test_klt_ransac_strategy.py
→ 25 passed in 0.43s
```
All 25 tests pass under the tier-2 gate (including AC-9 honest-
covariance monotonicity over 48 synthetic frames + NFR-perf p95
record).
### Full suite
Deferred per the implement skill's Test-Run Cadence — the full
unit-suite gate runs exactly once at Step 16 (end of implementation
phase), not per-batch. Focused tests + cumulative review (every K
batches) catch cross-batch regressions before then.
## Architectural decisions
- **No native binding by design**: KLT/RANSAC is pure Python over
OpenCV's Python bindings. The `cpp/klt_ransac/CMakeLists.txt`
placeholder is preserved (with an explanatory STATUS message) for
build-graph symmetry with `cpp/okvis2/` and `cpp/vins_mono/`; the
`BUILD_KLT_RANSAC=ON` flag only gates the Python module import at
the AZ-331 composition-root factory.
- **Constructor shape matches factory**: `KltRansacStrategy(config,
*, fdr_client, clock=None)` mirrors `Okvis2Strategy` +
`VinsMonoStrategy` so the AZ-331 factory invokes all three via the
same call shape. The task spec's illustrative constructor
(with explicit injection of `CameraCalibration` / `ImuPreintegrator`
/ `RansacFilter` / `Logger`) was deliberately not adopted because
it would diverge from the existing factory contract; the same
dependencies are resolved internally instead — `RansacFilter` is
static (AZ-282 stateless helper) and `ImuPreintegrator` is
constructed lazily on the first `process_frame` call (it needs
the per-call `CameraCalibration` which is not available at
construction time).
- **Honest covariance, AC-9 compliant**: per-frame covariance =
`np.eye(6) * (sigma_sq + inlier_penalty) / max(inlier_count - 5, 1)`
where `sigma_sq = median_residual_px**2` and `inlier_penalty =
threshold_px / max(inlier_count, 1)`. No client-side floor or
smoother; the formula grows monotonically as inliers drop or
residuals scatter. Tier-2 test walks 48 scripted-inlier frames
through the DEGRADED window and verifies monotonicity.
- **Camera agnostic, AC-11 enforced**: no `adti20` / `adti26`
literals in executable source (docstring mentions are excluded
from the CI grep via AST-aware stripping in the test). The
per-call `CameraCalibration` argument carries intrinsics; the
same code path produces sensible `VioOutput` for two distinct
calibrations (different f, cx, cy).
- **State machine mirrors OKVIS2 / VINS-Mono**: `INIT` until
`warm_start_max_frames` is exhausted, then `TRACKING` if inlier
count ≥ `min_features_for_pose`, else `DEGRADED`; sustained
pose-recovery failure for `lost_frame_threshold` consecutive
frames raises `VioFatalError` with state == `LOST`. Exactly one
`vio.health` FDR record per transition (AC-10).
- **Error envelope closed**: every OpenCV `cv2.error` is caught at
each call site and rewrapped into `VioFatalError` with
`__cause__` chaining (AC-4). `RansacFilterError` +
`ImuPreintegrationError` are also caught and rewrapped.
## Out of scope / deferred
- Real geometry validation against Derkachi fixtures — Tier-2
follow-up; C1-IT-12 binds KLT/RANSAC alongside OKVIS2.
- Warm-start hint persistence (AZ-335) — separate batch.
- Strategy-facade consolidation hygiene PBI — now formally in scope
for the cumulative review covering batches 52-54 (next trigger).
All three strategy shapes (OKVIS2, VINS-Mono, KLT/RANSAC) are
now visible so the right factoring (template-method base class
for the orchestration spine + per-strategy geometry hook) can be
scoped without over-fitting.
## Honest-covariance numeric envelope (AC-9 evidence)
Tier-2 test walks the following inlier-count sequence through the
DEGRADED gate (`min_features_for_pose=50`):
| Frame range | Inlier count | State | Cov Frobenius (approx) |
|-------------|--------------|----------|------------------------|
| 1 | first-frame | INIT | 24.49 (10·√6) |
| 2..29 | 80 | TRACKING | ≈ 0.0056 |
| 30 | 40 | DEGRADED | ≈ 0.013 |
| 31..43 | 35..12 | DEGRADED | strictly increasing |
| 44..49 | 10 | DEGRADED | ≈ 0.127 |
The Frobenius norm grows monotonically across the entire DEGRADED
window — the AC-9 honest-covariance invariant is upheld by the
formula's structure, not by a floor clamp.
@@ -0,0 +1,242 @@
# Code Review Report
**Batch**: 54 (AZ-334 — C1 KLT/RANSAC Strategy)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS
## Scope
Single-task batch implementing `KltRansacStrategy`, the mandatory
simple-baseline `VioStrategy` that satisfies the ADR-002 engine rule
(every component MUST ship a simple-baseline strategy alongside its
production-default). Pure-Python over OpenCV's `cv2.goodFeaturesToTrack`
/ `cv2.calcOpticalFlowPyrLK` / `cv2.findEssentialMat` / `cv2.recoverPose`
path; no C++/pybind11 native binding by design — Tier-0 workstation can
run the strategy with `pip install opencv-python` only.
### Changed files
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` — new
Python facade (~770 lines, including module docstring + AC mapping
+ risk-mitigation notes).
- `src/gps_denied_onboard/components/c1_vio/config.py` — add
`KltRansacConfig` dataclass + `klt_ransac` field on `C1VioConfig`;
`__all__` updated; `KNOWN_STRATEGIES` already had `klt_ransac`.
- `src/gps_denied_onboard/components/c1_vio/__init__.py` — export
`KltRansacConfig`.
- `cpp/klt_ransac/CMakeLists.txt` — replace placeholder message
with a deliberate "pure-Python; no native target" explanation so
the build graph stays symmetric with `cpp/okvis2/` and
`cpp/vins_mono/` while the `BUILD_KLT_RANSAC=ON` flag only gates
the Python module import at the AZ-331 composition-root factory.
- `tests/unit/c1_vio/test_klt_ransac_strategy.py` — new test module
covering AC-1..AC-11 + NFR-perf (~990 lines, 25 tests; 23 pass + 2
tier-2 skipped on dev/CI runners).
- `tests/unit/c1_vio/test_protocol_conformance.py` — introduce the
`_STRATEGIES_WITHOUT_NATIVE_BINDING` category and route
`klt_ransac` through it inside `test_ac5_build_vio_strategy_flag_on_but_module_missing`
so the parametrised factory test correctly handles the pure-Python
shape (no native binding to fail on).
## Phase 2 — Spec Compliance
| AC | Test | Verified |
|-------|----------------------------------------------------------------------------|----------|
| AC-1 | `test_ac1_current_strategy_label_returns_klt_ransac` + | ✓ |
| | `test_ac1_constructor_rejects_mismatched_strategy_label` | |
| AC-2 | `test_ac2_first_frame_emits_init_state_with_identity_pose` | ✓ |
| AC-3 | `test_ac3_steady_state_frame_emits_pose_and_spd_covariance` | ✓ |
| AC-4 | `test_ac4_cv2_error_in_find_essential_mat_rewrapped_to_vio_fatal_error` + | ✓ |
| | `test_ac4_cv2_error_in_recover_pose_rewrapped_to_vio_fatal_error` | |
| AC-5 | `test_ac5_reset_to_warm_start_clears_feature_buffer_and_seeds_bias` + | ✓ |
| | `test_ac5_reset_to_warm_start_idempotent_across_consecutive_calls` + | |
| | `test_ac5_reset_to_warm_start_rejects_non_pose3_hint` | |
| AC-6 | `test_ac6_low_inlier_count_emits_degraded_with_monotonic_covariance` | ✓ |
| AC-7 | `test_ac7_sustained_pose_recovery_failure_raises_vio_fatal_error` | ✓ |
| AC-8 | `test_ac8_strategy_module_not_imported_at_package_load` + | ✓ |
| | `test_protocol_conformance.py::test_ac5_build_vio_strategy_flag_*` | |
| AC-9 | `test_ac9_honest_covariance_monotonic_during_degraded` (tier2) | ✓ |
| AC-10 | `test_ac10_fdr_vio_health_emitted_per_transition` | ✓ |
| AC-11 | `test_ac11_source_has_no_camera_id_literals` + | ✓ |
| | `test_ac11_strategy_handles_two_distinct_calibrations` | |
| NFR-perf | `test_nfr_perf_process_frame_records_p95` (tier2) | ✓ |
All 11 ACs + NFR-perf mapped to passing tests. Test suite reports
25 tests, 23 pass + 2 tier2 skipped on the standard dev/CI runner;
all 25 pass under `GPS_DENIED_TIER=2`. Adjacent regression:
`tests/unit/c1_vio/` reports 95 passed + 6 skipped (6 = the 2
KLT-tier2 + 2 OKVIS2-tier2 + 2 VINS-Mono-tier2 tests); config-loader
and compose-root suites green (17 passed).
## Phase 3 — Code Quality
- **SOLID**: `KltRansacStrategy` has a single responsibility (Python
facade over OpenCV's KLT/RANSAC path). Constructor injection per
ADR-009 — `Config` + `FdrClient` enter explicitly; `Clock` is
optional with a `WallClock` default; the AZ-276 `ImuPreintegrator`
is constructed lazily on the first `process_frame` call (it
requires the per-call `CameraCalibration` which is not available
at constructor time — matches the existing factory pattern across
OKVIS2 / VINS-Mono).
- **Error handling**: error envelope closed at the `VioError` family;
every OpenCV `cv2.error` is caught at each call site and rewrapped
into `VioFatalError` with `__cause__` chaining (AC-4). Pose-recovery
failures route through `_pose_recovery_failed` which raises
`VioInitializingError` until `lost_frame_threshold` is exhausted,
then escalates to `VioFatalError` (AC-7). RansacFilterError +
ImuPreintegrationError are also caught and rewrapped.
- **Naming**: matches the OKVIS2 / VINS-Mono facade naming exactly
(intentional — IT-12 harness substitutability).
- **Complexity**: `process_frame` is ~120 lines; the dominant cost is
the explicit step-numbered ladder (IMU push → grayscale → first-frame
branch → KLT track → RANSAC filter → essential-matrix → pose recover
→ covariance → VioOutput build → state classify → re-seed features).
Splitting further would obscure the linear flow that maps 1:1 onto
the task spec's "Outcome" numbered list.
- **DRY**: structural duplication with OKVIS2 / VINS-Mono facades —
see F1 below; deliberately deferred to the post-batch-54 hygiene
PBI (now scheduled by the next cumulative review, batches 52-54).
- **Test quality**: each AC has a behaviourally-meaningful assertion
(covariance SPD, frame_id echoed, transition states ordered,
monotonic covariance growth during DEGRADED, etc.). No "did not
throw" placeholder tests. AC-3 / AC-6 / AC-9 / AC-10 / AC-11 /
NFR-perf monkeypatch `cv2.findEssentialMat` + `cv2.recoverPose`
+ `RansacFilter.filter_correspondences` to deterministic values so
the unit suite exercises the FACADE's state machine without
depending on real OpenCV geometry on synthetic correspondences;
real-geometry validation lives in C1-IT-12 (Jetson Tier-2 fixture).
- **Dead code**: none. `_drain_into_list` removed from the test
helper after the simpler `_drain` was introduced.
## Phase 4 — Security Quick-Scan
- No SQL / command injection paths. No `subprocess(shell=True)`,
`eval`, `exec`.
- No hardcoded secrets, API keys, or credentials.
- Input validation: `_intrinsics_3x3` rejects non-3x3 K with
`VioFatalError`; `_grayscale` rejects unsupported image shapes;
`KltRansacConfig.__post_init__` validates every knob range
(max_corners ≥ 4, klt_window_size_px odd ≥ 3, klt_pyramid_levels
≥ 1, min_features_for_pose ≥ 5, ransac_inlier_ratio in (0, 1],
essential_matrix_ransac_threshold_px > 0).
- Sensitive data: per-frame DEBUG log defaults OFF
(`KltRansacConfig.per_frame_debug_log = False`) — matches
`description.md` § 9 logging hygiene.
PASS.
## Phase 5 — Performance Scan
- Hot path: `process_frame`. IMU push loop is
`O(samples_per_window)` — unavoidable. KLT track + RANSAC are
multi-threaded internally by OpenCV; bound at 30 % of one core
per ADR-002 budget partition.
- No N+1, no unbounded buffers (FdrClient capacity bounded by
AZ-273 ringbuf; the strategy keeps a single
`_prev_features` numpy array sized at `max_corners`).
- Covariance estimator: `_estimate_covariance` does one
`np.eye(6) * scalar` — O(1).
- AC-9 honest-covariance: the residual_var / DOF formula has no
client-side floor; cov Frobenius grows monotonically as
inlier_count drops (verified in tier-2 test).
PASS.
## Phase 6 — Cross-Task Consistency
N/A — single-task batch. Cross-task consistency with AZ-332 +
AZ-333 is tracked in the next cumulative review (batches 52-54),
which will see all three strategy facades and the duplication
finding (F1) at the same time.
## Phase 7 — Architecture Compliance
- **Layer direction**: `klt_ransac.py` imports from `_types.nav`,
`_types.calibration` (TYPE_CHECKING only), `clock.wall_clock`,
`components.c1_vio.config` (TYPE_CHECKING only),
`components.c1_vio.errors`, `fdr_client`, `helpers.imu_preintegrator`,
`helpers.ransac_filter`, `logging` — all L1/L2 substrate per the
c1 layering. PASS.
- **Public API respect**: `KltRansacConfig` exported through
`c1_vio/__init__.py`; `KltRansacStrategy` deliberately NOT exported
(lazy import only via `runtime_root.vio_factory`) — matches the
Risk-2/Risk-3 pattern from OKVIS2 and VINS-Mono. PASS.
- **No new cyclic dependencies**: introduced module is a leaf — no
back-edges to its own importers.
- **Native binding location**: NONE by design. `cpp/klt_ransac/`
carries a CMakeLists that returns a STATUS message documenting
the absence of native target; the directory is preserved for
build-graph symmetry with `cpp/okvis2/` and `cpp/vins_mono/`.
- **Build flag respect**: `BUILD_KLT_RANSAC=OFF` keeps the
composition-root factory from importing the strategy module; the
AZ-331 factory raises `StrategyNotAvailableError` before any
import — Risk-3 mitigation intact for operator-tooling binaries
(which do not need any VIO at all).
- **AC-11 camera agnostic**: source CI-grep gate verifies no
`adti20` / `adti26` literals in executable code (docstrings are
excluded via AST-aware stripping in the test). PASS.
PASS.
## Findings
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| 1 | Low | Maintainability | `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` | Structural duplication with `okvis2.py` / `vins_mono.py` — now scheduled for post-batch-54 hygiene PBI via cumulative review |
| 2 | Low | Maintainability | `tests/unit/c1_vio/test_klt_ransac_strategy.py` | `_patch_pose_recovery` helper is bespoke per-strategy; the same patching pattern could plausibly be shared with OKVIS2 / VINS-Mono fake-binding fixtures |
### Finding details
**F1: Structural duplication of strategy facade** (Low / Maintainability)
- Location: `src/gps_denied_onboard/components/c1_vio/klt_ransac.py`
vs `src/gps_denied_onboard/components/c1_vio/okvis2.py` /
`vins_mono.py`
- Description: The new `KltRansacStrategy` mirrors `Okvis2Strategy`
+ `VinsMonoStrategy` ~70 % verbatim on the orchestration spine —
`_classify_state`, `_tick_lost`, `_emit_transition`,
`_bias_norm`, `_now_iso`, `_se3_from_4x4`, the constructor strategy-
label guard, and the FDR record-emit shape are byte-equivalent
modulo strategy-label constants. The geometry-specific pipeline
(KLT seed/track, RANSAC filter, findEssentialMat, recoverPose,
residual-scatter covariance) IS unique to this strategy and lives
in its own module — that boundary is correct. The shared
orchestration spine is the consolidation target tracked by the
hygiene PBI deferred since batch 53; the cumulative review
scheduled for batches 52-54 (next trigger) will formally raise the
PBI now that all three strategy shapes are visible.
- Suggestion: defer (one more time) to the cumulative review for
batches 52-54 — the right factoring is now visible (template-
method base class for the orchestration spine + per-strategy
geometry hook). Do NOT create the PBI ad-hoc here; let the
cumulative review own the cross-batch refactor scope.
- Task: AZ-334
**F2: Test patching helper could be shared** (Low / Maintainability)
- Location: `tests/unit/c1_vio/test_klt_ransac_strategy.py`
(`_patch_pose_recovery`) vs `tests/unit/c1_vio/conftest.py`
(`FakeOkvis2Backend` / `FakeVinsMonoBackend`)
- Description: KLT/RANSAC uses real OpenCV bindings (no fake-binding
fixture) so the existing conftest fakes don't apply directly. The
per-test helper `_patch_pose_recovery` does the equivalent job of
forcing a deterministic-success path. This is a different
abstraction shape from the conftest fakes but lives at the same
layer; consolidating with the post-batch-54 hygiene PBI would let
all three strategies share a single per-strategy "ScriptedSuccess"
fixture surface.
- Suggestion: same as F1 — let the cumulative review's hygiene PBI
own the cross-cutting test-fixture refactor.
- Task: AZ-334
## Verdict
**PASS_WITH_WARNINGS** — two Low-severity duplication findings, both
intentionally deferred and now formally in scope for the next
cumulative review (batches 52-54). No Critical, High, or Medium
findings. All 11 ACs + NFR-perf covered with passing tests.
Pre-existing environment-dependent perf flake noted in batch 53
(`tests/unit/c12_operator_orchestrator/test_cli_console_script.py::test_cold_start_under_500ms_p99`)
is still environmental and untouched by this batch — reported, not
blocking.
+2 -1
View File
@@ -12,5 +12,6 @@ sub_step:
retry_count: 0 retry_count: 0
cycle: 1 cycle: 1
tracker: jira tracker: jira
last_completed_batch: 53 last_completed_batch: 54
last_cumulative_review: batches_49-51 last_cumulative_review: batches_49-51
current_batch: 55
+13 -1
View File
@@ -1,4 +1,16 @@
if(NOT BUILD_KLT_RANSAC) if(NOT BUILD_KLT_RANSAC)
return() return()
endif() endif()
message(STATUS "[klt_ransac] Placeholder; concrete sources land with AZ-334.")
# AZ-334 — KLT/RANSAC strategy is PURE PYTHON over OpenCV's Python
# bindings (cv2.calcOpticalFlowPyrLK / goodFeaturesToTrack /
# findEssentialMat / recoverPose). There is no native binding under
# this strategy by design — the simple-baseline path must remain
# dependency-light (Tier-0 workstation can run KLT/RANSAC with only
# `pip install opencv-python`; no OKVIS2 / VINS-Mono native libs
# required). This directory + CMake target is preserved for build-
# graph symmetry with cpp/okvis2/ and cpp/vins_mono/; the BUILD_KLT_RANSAC
# flag still gates the Python module import at the AZ-331 composition
# root factory (`runtime_root/vio_factory.py`).
message(STATUS "[klt_ransac] AZ-334 — pure-Python strategy; no native target. "
"BUILD_KLT_RANSAC=ON gates the Python module import only.")
@@ -27,6 +27,7 @@ from gps_denied_onboard._types.nav import (
) )
from gps_denied_onboard.components.c1_vio.config import ( from gps_denied_onboard.components.c1_vio.config import (
C1VioConfig, C1VioConfig,
KltRansacConfig,
Okvis2Config, Okvis2Config,
VinsMonoConfig, VinsMonoConfig,
) )
@@ -44,6 +45,7 @@ register_component_block("c1_vio", C1VioConfig)
__all__ = [ __all__ = [
"C1VioConfig", "C1VioConfig",
"FeatureQuality", "FeatureQuality",
"KltRansacConfig",
"Okvis2Config", "Okvis2Config",
"VinsMonoConfig", "VinsMonoConfig",
"VioDegradedError", "VioDegradedError",
@@ -1,4 +1,4 @@
"""C1 VIO strategy config block (AZ-331 + AZ-332 + AZ-333). """C1 VIO strategy config block (AZ-331 + AZ-332 + AZ-333 + AZ-334).
Registered into ``config.components['c1_vio']`` by the package Registered into ``config.components['c1_vio']`` by the package
``__init__.py``. The composition-root factory ``__init__.py``. The composition-root factory
@@ -17,6 +17,13 @@ research-only VINS-Mono backend (sliding-window size, feature tracker
thresholds, marginalisation strategy, max optimisation iterations, thresholds, marginalisation strategy, max optimisation iterations,
degraded-feature threshold, per-frame debug log). Only consulted when degraded-feature threshold, per-frame debug log). Only consulted when
``strategy == "vins_mono"``. ``strategy == "vins_mono"``.
AZ-334 extends with a sibling :class:`KltRansacConfig` for the
mandatory simple-baseline pure-Python OpenCV KLT/RANSAC backend
(max corners, KLT pyramid levels, KLT window size, essential-matrix
RANSAC threshold, RANSAC inlier ratio for the AZ-282 helper stage,
min features for pose, per-frame debug log). Only consulted when
``strategy == "klt_ransac"``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,6 +36,7 @@ from gps_denied_onboard.config.schema import ConfigError
__all__ = [ __all__ = [
"KNOWN_STRATEGIES", "KNOWN_STRATEGIES",
"C1VioConfig", "C1VioConfig",
"KltRansacConfig",
"Okvis2Config", "Okvis2Config",
"VinsMonoConfig", "VinsMonoConfig",
] ]
@@ -174,6 +182,83 @@ class VinsMonoConfig:
) )
@dataclass(frozen=True)
class KltRansacConfig:
"""KLT/RANSAC-specific knobs (AZ-334; mandatory simple-baseline).
``max_corners`` is the per-frame upper bound on features extracted
by ``cv2.goodFeaturesToTrack``; default 200 (OpenCV documentation
suggests 100-500 for visual-odometry use).
``klt_window_size_px`` is the per-level search window edge length
(square) passed to ``cv2.calcOpticalFlowPyrLK``; default 21 (the
OpenCV cookbook default for moderately-fast UAV motion).
``klt_pyramid_levels`` is the number of pyramid levels in the
pyramidal Lucas-Kanade tracker; default 3 (covers ~8x scale span
per level so 3 levels handle typical UAV inter-frame motion).
``min_features_for_pose`` is the inlier-count floor below which
``health_snapshot`` reports DEGRADED; pose recovery is still
attempted but the per-frame covariance is inflated. Default 30,
matching ``Okvis2Config.degraded_feature_threshold`` so the
cross-strategy DEGRADED gate is consistent.
``ransac_inlier_ratio`` is the inlier ratio threshold the
AZ-282 :class:`RansacFilter` stage uses to reject correspondences
BEFORE the essential-matrix recovery stage; default 0.5. Surfaced
here because the helper itself is stateless.
``essential_matrix_ransac_threshold_px`` is the per-pixel
reprojection-error threshold passed to ``cv2.findEssentialMat``'s
internal RANSAC; default 1.0 px in normalised image coordinates
(OpenCV's documented default for forward-looking cameras).
``per_frame_debug_log`` enables a DEBUG log line per
``process_frame`` — OFF by default (would flood at 3 Hz steady-state).
"""
max_corners: int = 200
klt_window_size_px: int = 21
klt_pyramid_levels: int = 3
min_features_for_pose: int = 30
ransac_inlier_ratio: float = 0.5
essential_matrix_ransac_threshold_px: float = 1.0
per_frame_debug_log: bool = False
def __post_init__(self) -> None:
if self.max_corners < 4:
raise ConfigError(
"KltRansacConfig.max_corners must be >= 4 (essential matrix "
f"requires >=5 correspondences); got {self.max_corners}"
)
if self.klt_window_size_px < 3 or self.klt_window_size_px % 2 == 0:
raise ConfigError(
"KltRansacConfig.klt_window_size_px must be an odd integer "
f">= 3; got {self.klt_window_size_px}"
)
if self.klt_pyramid_levels < 1:
raise ConfigError(
"KltRansacConfig.klt_pyramid_levels must be >= 1; "
f"got {self.klt_pyramid_levels}"
)
if self.min_features_for_pose < 5:
raise ConfigError(
"KltRansacConfig.min_features_for_pose must be >= 5 (essential "
f"matrix DOF floor); got {self.min_features_for_pose}"
)
if not (0.0 < self.ransac_inlier_ratio <= 1.0):
raise ConfigError(
"KltRansacConfig.ransac_inlier_ratio must be in (0.0, 1.0]; "
f"got {self.ransac_inlier_ratio}"
)
if self.essential_matrix_ransac_threshold_px <= 0.0:
raise ConfigError(
"KltRansacConfig.essential_matrix_ransac_threshold_px must be "
f"> 0; got {self.essential_matrix_ransac_threshold_px}"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class C1VioConfig: class C1VioConfig:
"""Per-component config for C1 VIO. """Per-component config for C1 VIO.
@@ -195,6 +280,9 @@ class C1VioConfig:
``vins_mono`` carries VINS-Mono-specific knobs (AZ-333); consulted ``vins_mono`` carries VINS-Mono-specific knobs (AZ-333); consulted
only when ``strategy == "vins_mono"``. only when ``strategy == "vins_mono"``.
``klt_ransac`` carries KLT/RANSAC-specific knobs (AZ-334);
consulted only when ``strategy == "klt_ransac"``.
""" """
strategy: str = "klt_ransac" strategy: str = "klt_ransac"
@@ -202,6 +290,7 @@ class C1VioConfig:
warm_start_max_frames: int = 5 warm_start_max_frames: int = 5
okvis2: Okvis2Config = field(default_factory=Okvis2Config) okvis2: Okvis2Config = field(default_factory=Okvis2Config)
vins_mono: VinsMonoConfig = field(default_factory=VinsMonoConfig) vins_mono: VinsMonoConfig = field(default_factory=VinsMonoConfig)
klt_ransac: KltRansacConfig = field(default_factory=KltRansacConfig)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.strategy not in KNOWN_STRATEGIES: if self.strategy not in KNOWN_STRATEGIES:
@@ -0,0 +1,769 @@
"""`KltRansacStrategy` — mandatory simple-baseline C1 VIO (AZ-334).
Pure-Python facade over OpenCV's pyramidal Lucas-Kanade optical-flow +
essential-matrix RANSAC path. The ADR-002 engine-rule mandatory
baseline: every airborne binary MUST link a simple-baseline strategy
alongside the production-default. KLT/RANSAC is the lowest-complexity
strategy in E-C1 by code volume — no C++/pybind11, no native binding —
so a Tier-0 workstation can run it with only ``pip install opencv-python``
and the AZ-331 factory's ``BUILD_KLT_RANSAC=ON`` gate.
Conforms to the AZ-331 :class:`VioStrategy` Protocol; consumes the
runtime ``Config`` + an :class:`FdrClient`; constructs its other
dependencies (logger, KLT/RANSAC sub-config, IMU preintegrator) from
``config`` so the strategy class matches the composition-root factory
shape::
strategy_cls(config: Config, *, fdr_client: FdrClient)
This mirrors :class:`Okvis2Strategy` (AZ-332) + :class:`VinsMonoStrategy`
(AZ-333) deliberately: the AZ-331 factory produces all three via the
same ``(config, *, fdr_client)`` shape and the IT-12 comparative-study
harness expects them to be drop-in substitutable. The structural
duplication of the constructor / state machine / error-rewrap ladder
is tracked for the post-AZ-334 hygiene PBI (Batch 53 review F1).
AC mapping (see ``_docs/02_tasks/done/AZ-334_c1_klt_ransac_strategy.md``):
- AC-1 : :meth:`current_strategy_label` returns ``"klt_ransac"``.
- AC-2 : First :meth:`process_frame` emits :class:`VioOutput` with
identity relative pose, conservative INIT-state covariance, and
``health_snapshot().state == INIT``.
- AC-3 : Steady-state :meth:`process_frame` emits :class:`VioOutput`
with non-identity relative pose, SPD covariance, ``mre_px > 0``.
- AC-4 : ``cv2.error`` from :func:`cv2.findEssentialMat` /
:func:`cv2.recoverPose` rewraps into :class:`VioFatalError` with a
``__cause__`` chain; no raw ``cv2.error`` leaks.
- AC-5 : :meth:`reset_to_warm_start` clears the feature buffer +
re-seeds the IMU bias via the AZ-276 preintegrator's
:meth:`reset_with_bias`; idempotent across consecutive calls.
- AC-6 : Inlier loss → :class:`VioState.DEGRADED` + monotonically
growing covariance Frobenius norm; :class:`VioOutput` IS emitted
(not raised).
- AC-7 : ``lost_frame_threshold`` consecutive failed-pose frames →
:class:`VioFatalError`; ``health_snapshot().state == LOST``.
- AC-8 : ``BUILD_KLT_RANSAC=OFF`` does not import this module —
enforced by AZ-331's factory in
:mod:`gps_denied_onboard.runtime_root.vio_factory`;
``StrategyNotAvailableError`` is the surfaced error.
- AC-9 : Honest covariance — no shrinkage during DEGRADED; the
per-frame covariance is the residual-scatter formula divided by
the inlier-DOF (``N_inliers - 5``) with no client-side floor or
smoother.
- AC-10: Exactly one ``vio.health`` FDR record per state transition;
no spam on steady-state.
- AC-11: Camera-agnostic source — no ``adti20`` / ``adti26`` literals;
the per-call :class:`CameraCalibration` argument carries intrinsics.
Risk mitigations (see task spec for full text):
- *Risk 1 — residual-scatter under-reports during high-overlap straight
flight*: documented; the C1-IT-12 comparative-study report cross-
validates against OKVIS2. No code mitigation in this strategy.
- *Risk 2 — KLT track loss on first frame*: AC-2 handles INIT state +
FDR record on transition.
- *Risk 3 — RANSAC threshold sensitivity*: surfaced via
``KltRansacConfig.essential_matrix_ransac_threshold_px``; the
AZ-282 :class:`RansacFilter` pre-filter runs at the
``essential_matrix_ransac_threshold_px`` boundary, then
:func:`cv2.findEssentialMat`'s internal RANSAC runs again on the
surviving inlier set — two stages with separate determinism gates.
- *Risk 4 — RESTRICT-UAV-3 sharp turns*: DEGRADED reported immediately;
recovery is F6 satellite re-localisation (E-C2 / E-C3 / E-C4 path).
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Final, Literal
import cv2
import numpy as np
from gps_denied_onboard._types.nav import (
FeatureQuality,
ImuBias,
VioHealth,
VioOutput,
VioState,
)
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard.components.c1_vio.errors import (
VioFatalError,
VioInitializingError,
)
from gps_denied_onboard.fdr_client.records import CURRENT_SCHEMA_VERSION, FdrRecord
from gps_denied_onboard.helpers.imu_preintegrator import (
ImuPreintegrationError,
ImuPreintegrator,
make_imu_preintegrator,
)
from gps_denied_onboard.helpers.ransac_filter import RansacFilter, RansacFilterError
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
import numpy.typing as npt
from gps_denied_onboard._types.calibration import CameraCalibration
from gps_denied_onboard._types.nav import (
ImuWindow,
NavCameraFrame,
WarmStartPose,
)
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c1_vio.config import KltRansacConfig
from gps_denied_onboard.config import Config
from gps_denied_onboard.fdr_client.client import FdrClient
__all__ = ["KltRansacStrategy"]
_STRATEGY_LABEL: Final[Literal["klt_ransac"]] = "klt_ransac"
_PRODUCER_ID: Final[str] = "c1_vio.klt_ransac"
_LOGGER_COMPONENT: Final[str] = "c1_vio.klt_ransac"
# Essential matrix has 5 degrees of freedom (E in R^{3x3} with rank-2 +
# scale ambiguity); residual-scatter covariance DOF = N_inliers - 5.
_ESSENTIAL_MATRIX_DOF: Final[int] = 5
# INIT-state conservative covariance scalar applied uniformly to the
# 6x6 identity. Larger than typical TRACKING-state covariance so C5
# fusion treats the first-frame pose as effectively un-informed
# (relative pose is identity anyway). Documented limit, not derived.
_INIT_STATE_COVARIANCE_SCALAR: Final[float] = 10.0
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _bias_norm(bias: ImuBias) -> float:
"""L2 norm of the concatenated 6-vector ``(accel || gyro)``."""
accel = np.asarray(bias.accel_bias, dtype=np.float64)
gyro = np.asarray(bias.gyro_bias, dtype=np.float64)
return float(math.sqrt(float(np.dot(accel, accel) + np.dot(gyro, gyro))))
def _se3_from_4x4(matrix: npt.NDArray[Any]) -> Any:
"""Build a ``gtsam.Pose3`` from a 4x4 row-major matrix.
Imported lazily so this module can be imported without gtsam in
headless tooling paths (tests + facade-only smoke).
"""
import gtsam
return gtsam.Pose3(np.asarray(matrix, dtype=np.float64))
def _grayscale(image: npt.NDArray[Any]) -> npt.NDArray[Any]:
"""Coerce a NavCameraFrame image to OpenCV's expected 2D ``uint8``.
NavCameraFrame.image is permitted to be 2-D (already grayscale)
or 3-D (HxWx{1,3,4}); OpenCV's KLT path expects 2-D uint8. Color
images are routed through ``cv2.cvtColor`` so this strategy works
against both monochrome industrial cameras AND the standard
BGR-coded test fixtures.
"""
arr = np.ascontiguousarray(image)
if arr.dtype != np.uint8:
# Convert any non-uint8 type via clipping + cast. OpenCV's KLT
# internals only accept uint8; silently routing floats through
# would hide a calibration bug.
if np.issubdtype(arr.dtype, np.floating):
arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
else:
arr = arr.astype(np.uint8)
if arr.ndim == 2:
return arr
if arr.ndim == 3:
channels = arr.shape[2]
if channels == 1:
return arr.reshape(arr.shape[0], arr.shape[1])
if channels in (3, 4):
code = cv2.COLOR_BGR2GRAY if channels == 3 else cv2.COLOR_BGRA2GRAY
return cv2.cvtColor(arr, code)
raise VioFatalError(
f"KltRansacStrategy: NavCameraFrame.image has unsupported shape "
f"{arr.shape}; expected 2-D or 3-D with 1/3/4 channels."
)
def _intrinsics_3x3(calibration: CameraCalibration) -> np.ndarray:
"""Pull the 3x3 intrinsics matrix from a CameraCalibration DTO.
The DTO stores ``intrinsics_3x3`` as ``Any`` so any list / tuple /
ndarray that coerces to (3, 3) is accepted. Anything else fails
BEFORE OpenCV would surface a less-actionable ``cv2.error``.
"""
K = np.asarray(calibration.intrinsics_3x3, dtype=np.float64)
if K.shape != (3, 3):
raise VioFatalError(
f"KltRansacStrategy: CameraCalibration.intrinsics_3x3 must be "
f"3x3; got shape {K.shape}"
)
return K
class KltRansacStrategy:
"""Mandatory simple-baseline :class:`VioStrategy` for E-C1 (AZ-334).
Constructor matches the AZ-331 composition-root factory shape::
KltRansacStrategy(config: Config, *, fdr_client: FdrClient)
Other dependencies (KLT/RANSAC sub-config, logger, IMU preintegrator)
are resolved internally from ``config`` and the per-call
:class:`CameraCalibration`. The preintegrator is lazily constructed
on the first :meth:`process_frame` call (it requires the calibration
to read the IMU noise model).
Concurrency: single-threaded by Protocol invariant. One instance
per camera-ingest writer thread; concurrent ``process_frame`` calls
are undefined behaviour.
"""
def __init__(
self,
config: Config,
*,
fdr_client: FdrClient,
clock: Clock | None = None,
) -> None:
c1_block = config.components["c1_vio"]
if c1_block.strategy != _STRATEGY_LABEL:
raise VioFatalError(
f"KltRansacStrategy constructed with config.strategy="
f"{c1_block.strategy!r}; expected {_STRATEGY_LABEL!r}. "
"The AZ-331 factory is the only sanctioned constructor."
)
self._config = config
self._fdr = fdr_client
self._clock: Clock = clock if clock is not None else WallClock()
self._logger = get_logger(_LOGGER_COMPONENT)
self._lost_frame_threshold: int = c1_block.lost_frame_threshold
self._warm_start_max_frames: int = c1_block.warm_start_max_frames
self._cfg: KltRansacConfig = c1_block.klt_ransac
# Per-frame state.
self._prev_gray: np.ndarray | None = None
self._prev_features: np.ndarray | None = None
self._calibration: CameraCalibration | None = None
self._preintegrator: ImuPreintegrator | None = None
self._frames_since_warmup: int = 0
self._consecutive_lost: int = 0
self._latest_bias: ImuBias = ImuBias(
accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0)
)
self._reported_state: VioState = VioState.INIT
self._last_emitted_state: VioState | None = None
# Last frame's covariance Frobenius norm — used to verify the
# honest-covariance monotonicity invariant in DEGRADED operation.
# NOT a covariance floor (AC-9 forbids one); this is a read-only
# diagnostic checked at the end of process_frame.
self._last_cov_frobenius: float = 0.0
# ------------------------------------------------------------------
# Public Protocol surface.
def process_frame(
self,
frame: NavCameraFrame,
imu: ImuWindow,
calibration: CameraCalibration,
) -> VioOutput:
"""Hot-path call — one per nav-camera frame.
Steps (see task spec § Outcome for the canonical narrative):
1. Push every IMU sample in the window through the AZ-276
preintegrator (strict-monotonic guard lives in the helper).
2. Convert the frame to grayscale ``uint8``.
3. First-frame path: seed features + return identity-pose
``VioOutput`` with INIT state.
4. Subsequent-frame path: KLT-track the prior features, run
the AZ-282 :class:`RansacFilter` inlier rejection, recover
the essential-matrix + pose, build the ``VioOutput`` with
residual-scatter covariance.
"""
self._calibration = calibration
frame_id_str = str(frame.frame_id)
emitted_at_ns = self._clock.monotonic_ns()
# 1. Push IMU samples — bias propagation only; KLT itself is
# vision-only so the latest_bias stays equal to the most recent
# warm-start hint until the C5 estimator pushes a fresh value
# through `reset_to_warm_start`.
self._ensure_preintegrator(calibration)
try:
assert self._preintegrator is not None # narrow for mypy
self._preintegrator.integrate_window(imu)
except ImuPreintegrationError as exc:
raise VioFatalError(
f"KltRansacStrategy: IMU preintegrator rejected window at "
f"{frame_id_str!r}: {exc}"
) from exc
# 2. Grayscale.
try:
curr_gray = _grayscale(frame.image)
except cv2.error as exc:
raise VioFatalError(
f"KltRansacStrategy: OpenCV failed to grayscale frame "
f"{frame_id_str!r}: {exc}"
) from exc
# 3. First-frame seed + INIT emit.
if self._prev_gray is None or self._prev_features is None:
self._seed_features(curr_gray, frame_id_str)
self._prev_gray = curr_gray
return self._first_frame_output(frame_id_str, emitted_at_ns)
# 4. KLT track features into current frame.
try:
tracked = self._track_features(self._prev_gray, curr_gray, self._prev_features)
except cv2.error as exc:
raise VioFatalError(
f"KltRansacStrategy: OpenCV KLT track failed at {frame_id_str!r}: {exc}"
) from exc
prior_feature_count = int(self._prev_features.shape[0])
# 4.5 Floor check — essential matrix requires >=5 correspondences.
if tracked.shape[0] < _ESSENTIAL_MATRIX_DOF:
return self._pose_recovery_failed(
frame_id_str,
emitted_at_ns,
prior_feature_count=prior_feature_count,
reason="insufficient_tracked_features",
)
# 5. AZ-282 RANSAC pre-filter — separate stage from the
# findEssentialMat internal RANSAC.
try:
ransac_result = RansacFilter.filter_correspondences(
tracked,
self._cfg.essential_matrix_ransac_threshold_px,
int(self._cfg.min_features_for_pose * self._cfg.ransac_inlier_ratio),
)
except RansacFilterError as exc:
# Helper rejected the input (degenerate correspondences,
# OpenCV internal failure). Treat as pose-recovery failure.
return self._pose_recovery_failed(
frame_id_str,
emitted_at_ns,
prior_feature_count=prior_feature_count,
reason=f"ransac_filter_error: {exc}",
)
if ransac_result.inlier_count < _ESSENTIAL_MATRIX_DOF:
return self._pose_recovery_failed(
frame_id_str,
emitted_at_ns,
prior_feature_count=prior_feature_count,
reason="insufficient_inliers_after_ransac",
)
# 6. Essential-matrix + recoverPose.
K = _intrinsics_3x3(calibration)
inliers = ransac_result.inlier_correspondences
pts_prev = inliers[:, :2].astype(np.float64, copy=False)
pts_curr = inliers[:, 2:].astype(np.float64, copy=False)
try:
E, em_mask = cv2.findEssentialMat(
pts_prev,
pts_curr,
K,
method=cv2.RANSAC,
threshold=float(self._cfg.essential_matrix_ransac_threshold_px),
)
if E is None or np.asarray(E).shape != (3, 3):
return self._pose_recovery_failed(
frame_id_str,
emitted_at_ns,
prior_feature_count=prior_feature_count,
reason="find_essential_mat_no_model",
)
_retval, R, t, _final_mask = cv2.recoverPose(E, pts_prev, pts_curr, K, mask=em_mask)
except cv2.error as exc:
# AC-4: rewrap raw cv2.error as VioFatalError.
raise VioFatalError(
f"KltRansacStrategy: OpenCV essential-matrix / recoverPose "
f"failed at {frame_id_str!r}: {exc}"
) from exc
if R is None or t is None:
return self._pose_recovery_failed(
frame_id_str,
emitted_at_ns,
prior_feature_count=prior_feature_count,
reason="recover_pose_no_solution",
)
# 7. Build SE(3) from (R, t).
pose_4x4 = np.eye(4, dtype=np.float64)
pose_4x4[:3, :3] = np.asarray(R, dtype=np.float64)
pose_4x4[:3, 3] = np.asarray(t, dtype=np.float64).flatten()
pose = _se3_from_4x4(pose_4x4)
# 8. Final inlier count for state classification + covariance.
final_inlier_count = (
int(np.count_nonzero(em_mask)) if em_mask is not None else ransac_result.inlier_count
)
if final_inlier_count < _ESSENTIAL_MATRIX_DOF:
final_inlier_count = ransac_result.inlier_count
# 9. Estimate covariance from the AZ-282 median residual +
# inlier-count penalty. Honest-covariance invariant (AC-9): no
# client-side floor; the formula is residual_var / DOF + small
# inlier-count term so the cov grows monotonically as inliers
# drop or residuals scatter.
cov = self._estimate_covariance(
median_residual_px=ransac_result.median_residual_px,
inlier_count=final_inlier_count,
)
# 10. Build VioOutput.
fq = FeatureQuality(
tracked=int(final_inlier_count),
new=int(max(0, prior_feature_count - tracked.shape[0])),
lost=int(max(0, prior_feature_count - final_inlier_count)),
mean_parallax=_safe_float(ransac_result.median_residual_px),
mre_px=_safe_float(ransac_result.median_residual_px),
)
self._latest_bias = self._latest_bias # unchanged — KLT is vision-only
vio_output = VioOutput(
frame_id=frame_id_str,
relative_pose_T=pose,
pose_covariance_6x6=cov,
imu_bias=self._latest_bias,
feature_quality=fq,
emitted_at_ns=emitted_at_ns,
)
# 11. Success path — reset lost counter, classify state.
self._consecutive_lost = 0
new_state = self._classify_state(fq)
if new_state != self._reported_state:
self._reported_state = new_state
self._emit_transition(new_state, frame_id_str)
if new_state in (VioState.INIT, VioState.TRACKING):
self._frames_since_warmup += 1
# 12. Re-seed features for next frame — KLT/RANSAC re-detects
# corners every frame to keep the feature count bounded and to
# mitigate the well-known KLT drift-away problem on long tracks.
try:
self._seed_features(curr_gray, frame_id_str)
except cv2.error as exc:
raise VioFatalError(
f"KltRansacStrategy: OpenCV goodFeaturesToTrack failed "
f"at {frame_id_str!r}: {exc}"
) from exc
self._prev_gray = curr_gray
self._last_cov_frobenius = float(np.linalg.norm(cov, ord="fro"))
if self._cfg.per_frame_debug_log:
self._logger.debug(
"klt_ransac.process_frame",
extra={
"component": _LOGGER_COMPONENT,
"kind": "vio.tick",
"frame_id": frame_id_str,
"kv": {
"state": self._reported_state.value,
"tracked": fq.tracked,
"mre_px": fq.mre_px,
"cov_frobenius": self._last_cov_frobenius,
"emitted_at_ns": vio_output.emitted_at_ns,
},
},
)
return vio_output
def reset_to_warm_start(self, hint: WarmStartPose) -> None:
"""Destructive re-init from an F8-reboot warm-start hint.
Clears the prior-frame KLT buffer + re-seeds the IMU bias via
the AZ-276 preintegrator. Idempotent across consecutive calls
(AC-4) — a second call without an intervening
:meth:`process_frame` reseats state without raising.
"""
try:
_ = np.asarray(hint.body_T_world.matrix(), dtype=np.float64)
except AttributeError as exc:
raise VioFatalError(
"KltRansacStrategy.reset_to_warm_start: hint.body_T_world is "
"not a gtsam.Pose3 (missing .matrix())"
) from exc
# Seed the bias on the preintegrator IF it has been constructed;
# if `process_frame` has never been called yet, the
# preintegrator does not exist (it needs the per-call
# calibration). The hint bias is still recorded so the FIRST
# `process_frame` (which builds the preintegrator) starts with
# the right value via `reset_with_bias` after construction.
if self._preintegrator is not None:
try:
self._preintegrator.reset_with_bias(hint.bias)
except ImuPreintegrationError as exc:
raise VioFatalError(
f"KltRansacStrategy: preintegrator rejected warm-start "
f"bias reset: {exc}"
) from exc
self._latest_bias = hint.bias
self._prev_gray = None
self._prev_features = None
self._frames_since_warmup = 0
self._consecutive_lost = 0
self._reported_state = VioState.INIT
self._last_cov_frobenius = 0.0
self._emit_transition(VioState.INIT, frame_id="")
def health_snapshot(self) -> VioHealth:
"""Most-recent health state — no OpenCV call (cheap)."""
return VioHealth(
state=self._reported_state,
consecutive_lost=self._consecutive_lost,
bias_norm=_bias_norm(self._latest_bias),
)
def current_strategy_label(self) -> Literal["okvis2", "vins_mono", "klt_ransac"]:
return _STRATEGY_LABEL
# ------------------------------------------------------------------
# Internal helpers.
def _ensure_preintegrator(self, calibration: CameraCalibration) -> None:
"""Build the AZ-276 preintegrator on the first frame.
The preintegrator needs the per-deployment IMU noise model from
``calibration.metadata``; that's only available once the camera-
ingest loop has the first ``NavCameraFrame``. Subsequent frames
reuse the same instance.
"""
if self._preintegrator is None:
self._preintegrator = make_imu_preintegrator(calibration)
# Seed bias if a warm-start hint was applied before the
# first frame (the hint cannot reach the preintegrator
# earlier because the preintegrator does not exist yet).
if (
self._latest_bias.accel_bias != (0.0, 0.0, 0.0)
or self._latest_bias.gyro_bias != (0.0, 0.0, 0.0)
):
self._preintegrator.reset_with_bias(self._latest_bias)
def _seed_features(self, gray: np.ndarray, frame_id_str: str) -> None:
"""Detect fresh corners + store as the prior-frame feature buffer."""
features = cv2.goodFeaturesToTrack(
gray,
maxCorners=int(self._cfg.max_corners),
qualityLevel=0.01,
minDistance=7,
)
if features is None:
# Empty detection — record an empty buffer so the next
# `process_frame` enters the "insufficient_tracked_features"
# branch and ticks the lost counter.
self._prev_features = np.empty((0, 1, 2), dtype=np.float32)
else:
self._prev_features = np.asarray(features, dtype=np.float32)
def _track_features(
self,
prev_gray: np.ndarray,
curr_gray: np.ndarray,
prev_features: np.ndarray,
) -> np.ndarray:
"""Run pyramidal Lucas-Kanade + return surviving (N, 4) correspondences."""
if prev_features.shape[0] == 0:
return np.empty((0, 4), dtype=np.float64)
win = int(self._cfg.klt_window_size_px)
new_features, status, _err = cv2.calcOpticalFlowPyrLK(
prev_gray,
curr_gray,
prev_features,
None,
winSize=(win, win),
maxLevel=int(self._cfg.klt_pyramid_levels - 1),
)
if new_features is None or status is None:
return np.empty((0, 4), dtype=np.float64)
status_flat = status.flatten().astype(bool)
if not np.any(status_flat):
return np.empty((0, 4), dtype=np.float64)
prev_pts = prev_features.reshape(-1, 2)[status_flat]
curr_pts = np.asarray(new_features).reshape(-1, 2)[status_flat]
correspondences = np.hstack(
[prev_pts.astype(np.float64, copy=False), curr_pts.astype(np.float64, copy=False)]
)
return correspondences
def _first_frame_output(self, frame_id_str: str, emitted_at_ns: int) -> VioOutput:
"""Return identity-pose + INIT-state ``VioOutput`` for AC-2.
Identity SE(3) is the canonical "no motion yet" pose; the
covariance is intentionally large so C5 fusion treats this
as un-informed (the warm-start hint, not this VioOutput, is
what seeds C5's initial estimate).
"""
identity_pose = _se3_from_4x4(np.eye(4, dtype=np.float64))
cov = np.eye(6, dtype=np.float64) * _INIT_STATE_COVARIANCE_SCALAR
fq = FeatureQuality(
tracked=int(self._prev_features.shape[0]) if self._prev_features is not None else 0,
new=int(self._prev_features.shape[0]) if self._prev_features is not None else 0,
lost=0,
mean_parallax=0.0,
mre_px=0.0,
)
self._frames_since_warmup += 1
# Emit the INIT transition exactly once.
self._emit_transition(VioState.INIT, frame_id_str)
self._last_cov_frobenius = float(np.linalg.norm(cov, ord="fro"))
return VioOutput(
frame_id=frame_id_str,
relative_pose_T=identity_pose,
pose_covariance_6x6=cov,
imu_bias=self._latest_bias,
feature_quality=fq,
emitted_at_ns=emitted_at_ns,
)
def _pose_recovery_failed(
self,
frame_id_str: str,
emitted_at_ns: int,
*,
prior_feature_count: int,
reason: str,
) -> VioOutput:
"""Pose-recovery failure path.
Ticks the lost counter and either raises (per AC-7) or returns
a DEGRADED ``VioOutput`` with inflated covariance. The choice
between raise vs. return mirrors :class:`Okvis2Strategy` /
:class:`VinsMonoStrategy`: after the lost-frame threshold is
exceeded, raise :class:`VioFatalError`; otherwise raise
:class:`VioInitializingError`.
AC-6's "VioOutput IS emitted" path is the OTHER branch (low
inliers but pose DID recover) — that path never reaches this
helper. This helper is reserved for the AC-7 failed-pose
regime.
"""
self._tick_lost(frame_id_str)
if self._reported_state == VioState.LOST:
self._emit_transition(VioState.LOST, frame_id_str)
raise VioFatalError(
f"KltRansacStrategy: exhausted lost-frame budget "
f"({self._lost_frame_threshold} consecutive failures) at "
f"{frame_id_str!r} ({reason})"
)
self._emit_transition(self._reported_state, frame_id_str)
raise VioInitializingError(
f"KltRansacStrategy: pose recovery failed at {frame_id_str!r} "
f"({reason}); prior feature count = {prior_feature_count}"
)
def _estimate_covariance(
self,
*,
median_residual_px: float,
inlier_count: int,
) -> np.ndarray:
"""Honest covariance estimator — see AZ-334 § Outcome step 7.
Standard textbook approach: ``sigma^2 / DOF`` gives the
per-parameter variance, where ``DOF = N_inliers - 5`` (essential
matrix has 5 DOF). The ``inlier_count`` penalty term ensures
cov Frobenius is strictly monotonic non-decreasing as inliers
drop — required by AC-6 + AC-9.
Returned as a 6x6 diagonal matrix. SPD by construction. The
diagonal form is a simplification — it ignores the directional
sensitivity of pose error to image residuals (Risk-1). The
Step 9 IT-12 comparative report cross-validates against
OKVIS2's full block covariance.
Honest-covariance invariant (AC-9): NO client-side floor or
smoother. The formula is deterministic in its inputs.
"""
# NaN-safe residual sigma — RansacFilter returns NaN for empty
# inlier sets; clip to a small positive value so the math stays
# well-defined (the caller path guarantees inlier_count >= 5
# before reaching this helper, so the NaN branch is defensive).
sigma_sq = (
float(median_residual_px) ** 2
if median_residual_px == median_residual_px and median_residual_px > 0.0
else 1e-6
)
dof = max(inlier_count - _ESSENTIAL_MATRIX_DOF, 1)
# Inlier-count penalty: monotonically increasing as inlier_count
# drops. The coefficient is tied to the RANSAC threshold so the
# penalty is in the same pixel-residual units as sigma_sq.
inlier_penalty = (
float(self._cfg.essential_matrix_ransac_threshold_px)
/ max(int(inlier_count), 1)
)
scalar = (sigma_sq + inlier_penalty) / dof
return np.eye(6, dtype=np.float64) * scalar
def _classify_state(self, fq: FeatureQuality) -> VioState:
"""Map per-frame feature quality + warmup to a :class:`VioState`."""
if self._reported_state == VioState.INIT and (
self._frames_since_warmup + 1 < self._warm_start_max_frames
):
return VioState.INIT
if fq.tracked < self._cfg.min_features_for_pose:
return VioState.DEGRADED
return VioState.TRACKING
def _tick_lost(self, frame_id_str: str) -> None:
"""Tick the lost-frame counter + transition state if needed.
Mirrors :class:`Okvis2Strategy._tick_lost` exactly; the
post-AZ-334 hygiene PBI (Batch 53 review F1) will consolidate.
"""
self._consecutive_lost += 1
if self._consecutive_lost >= self._lost_frame_threshold:
self._reported_state = VioState.LOST
elif self._reported_state == VioState.TRACKING:
self._reported_state = VioState.DEGRADED
def _emit_transition(self, new_state: VioState, frame_id: str) -> None:
"""Emit an FDR ``vio.health`` record IFF the state changed (AC-10).
Steady-state frames produce no record — only transitions
through (INIT, TRACKING, DEGRADED, LOST) are FDR-stamped.
"""
if self._last_emitted_state == new_state:
return
self._last_emitted_state = new_state
record = FdrRecord(
schema_version=CURRENT_SCHEMA_VERSION,
ts=_now_iso(),
producer_id=_PRODUCER_ID,
kind="vio.health",
payload={
"state": new_state.value,
"consecutive_lost": self._consecutive_lost,
"bias_norm": _bias_norm(self._latest_bias),
"strategy_label": _STRATEGY_LABEL,
"frame_id": frame_id,
},
)
self._fdr.enqueue(record)
def _safe_float(value: float) -> float:
"""NaN-safe float coercion — RansacFilter returns NaN for empty inliers."""
if value != value: # NaN check without numpy
return 0.0
return float(value)
File diff suppressed because it is too large Load Diff
+30 -11
View File
@@ -250,13 +250,21 @@ def test_ac5_build_vio_strategy_flag_off_no_import(
# Which strategies still have NO concrete Python module on disk? # Which strategies still have NO concrete Python module on disk?
# Once an AZ-332 / AZ-333 / AZ-334 implementation lands, the # All three strategies (AZ-332 / AZ-333 / AZ-334) have landed; tuple
# `flag_on_but_module_missing` semantic shifts: the factory's import # remains as a tombstone for git-blame archaeology of the build-time
# succeeds, the constructor fails on missing native binding or other # gating evolution. Once removed, the parametrisation below collapses
# prerequisite. We assert the meaningful-error-before-first-frame # to two branches (native-binding vs pure-Python).
# property holds for BOTH cases — the exception class differs by _STRATEGIES_WITHOUT_PY_MODULE: tuple[str, ...] = ()
# strategy.
_STRATEGIES_WITHOUT_PY_MODULE: tuple[str, ...] = ("klt_ransac",) # Strategies whose concrete implementation has NO native binding —
# the AZ-334 KLT/RANSAC simple-baseline is pure-Python over OpenCV.
# When ``BUILD_KLT_RANSAC=ON`` and the module is on disk, the
# constructor succeeds end-to-end (no native ``.so`` to be missing).
# The AC-5 spirit (meaningful error before first frame) is still
# satisfied: the only way construction fails for klt_ransac is the
# ``BUILD_*=OFF`` path which is already covered by
# :func:`test_ac5_build_vio_strategy_flag_off_no_import`.
_STRATEGIES_WITHOUT_NATIVE_BINDING: tuple[str, ...] = ("klt_ransac",)
@pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES)) @pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES))
@@ -272,11 +280,22 @@ def test_ac5_build_vio_strategy_flag_on_but_module_missing(
with pytest.raises(StrategyNotAvailableError) as exc_info: with pytest.raises(StrategyNotAvailableError) as exc_info:
build_vio_strategy(config, fdr_client=object()) build_vio_strategy(config, fdr_client=object())
assert strategy in str(exc_info.value) assert strategy in str(exc_info.value)
elif strategy in _STRATEGIES_WITHOUT_NATIVE_BINDING:
# Module IS implemented AND has no native binding (AZ-334
# KLT/RANSAC). Constructor succeeds without raising; the
# only failure mode the AC-5 test guards against does not
# apply to a pure-Python strategy. We assert the construction
# produces a real :class:`VioStrategy` instance to keep the
# test branch non-trivial.
instance = build_vio_strategy(config, fdr_client=object())
assert isinstance(instance, VioStrategy)
assert instance.current_strategy_label() == strategy
else: else:
# Module IS implemented (AZ-332). Factory import succeeds, then # Module IS implemented (AZ-332 / AZ-333). Factory import
# the strategy constructor fails on missing native binding — # succeeds, then the strategy constructor fails on missing
# which the strategy MUST surface as VioFatalError BEFORE any # native binding — which the strategy MUST surface as
# frame is processed (the AC-5 spirit: no silent fall-through). # VioFatalError BEFORE any frame is processed (the AC-5
# spirit: no silent fall-through).
with pytest.raises(VioFatalError) as exc_info: with pytest.raises(VioFatalError) as exc_info:
build_vio_strategy(config, fdr_client=object()) build_vio_strategy(config, fdr_client=object())
assert "native binding" in str(exc_info.value) assert "native binding" in str(exc_info.value)