From d0414557b3e76b2bae2c92cbfc6806dcf75836a1 Mon Sep 17 00:00:00 2001 From: Robert French Date: Wed, 29 Apr 2026 11:45:40 -0700 Subject: [PATCH 1/4] Phase 5: Body disc + body blob techniques Add the two body-side NavTechniques that don't fit the DT-fitting shape: full-disc NCC correlation and brightness-weighted-moment centroid fitting. After this phase, body-fills-FOV, body-mostly-off-frame-with-irregular-shape, and unresolved-body scenes can navigate end-to-end through the orchestrator. BodyDiscCorrelateNav fuses every BODY_DISC feature's template into a single composite via Z-buffer paint (closer body's nonzero pixels overwrite farther body's), then runs the existing pyramid kpeaks NCC with use_gradient='auto' so the technique self-selects raw vs gradient mode per image. Multi-body composites improve disambiguation because the correlation peak's SNR grows roughly as sqrt(N) and the joint geometric constraint removes the swap-moon ambiguity solo correlation suffers from. BodyBlobNav computes a brightness-weighted-moment centroid for each predicted bbox, then fits a single 2-D translation that maps the predicted centroids onto the observed centroids in least-squares. Per-blob weight is the inverse of the centroid CRLB variance: N_lit * SNR^2 / radius^2. Confidence is intrinsically capped at 0.4 since a brightness-weighted centroid is a much weaker observation than a limb fit. Body extractor emission gate is now aligned with the design's default config values: LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0 (was 2.0) and a new BODY_BLOB_MIN_DIAMETER_PX = 8.0 floor. Per-body shape overrides (gas giants, irregular satellites) can only push the floor upward, never downward. navigate_with_pyramid_kpeaks gains a backwards-compatible 'top_k_peaks' field exposing per-peak telemetry (sorted by quality descending) so BodyDiscCorrelateNav can populate peak_to_runner_up_ratio without re-running the correlation. Also adds 'used_gradient' to the result dict so the auto picker's choice is recorded honestly in BodyDiscDiagnostics. NavModelBody and NavModelBodySimulated now ship bbox-sized postage-stamp BODY_DISC templates (compose_template_features expects this contract; previous extfov-shaped buffers would have made the Z-buffer paint slice the wrong region the first time it ran on a real BODY_DISC feature). Co-Authored-By: Claude Opus 4.7 (1M context) --- AUTONAV_PLAN.md | 193 ++++++- PHASE5_LIBRARY_SEED.md | 216 ++++++++ docs/developer_guide_techniques.rst | 198 ++++++- src/nav/nav_model/body_shape.py | 5 +- src/nav/nav_model/nav_model_body.py | 64 ++- src/nav/nav_model/nav_model_body_simulated.py | 7 +- src/nav/nav_technique/__init__.py | 4 + src/nav/nav_technique/dt_fitting.py | 17 + .../nav_technique/nav_technique_body_blob.py | 487 ++++++++++++++++++ .../nav_technique/nav_technique_body_disc.py | 304 +++++++++++ .../nav_technique/nav_technique_body_limb.py | 20 +- .../nav_technique_body_terminator.py | 20 +- src/nav/support/correlate.py | 27 +- tests/nav/nav_model/test_body_shape.py | 2 +- tests/nav/nav_model/test_nav_model_body.py | 2 +- .../test_nav_model_body_integration.py | 18 +- .../nav/nav_orchestrator/test_orchestrator.py | 55 ++ .../test_nav_technique_body_blob.py | 263 ++++++++++ .../test_nav_technique_body_disc.py | 289 +++++++++++ tests/nav/support/test_correlate.py | 33 ++ 20 files changed, 2157 insertions(+), 67 deletions(-) create mode 100644 PHASE5_LIBRARY_SEED.md create mode 100644 src/nav/nav_technique/nav_technique_body_blob.py create mode 100644 src/nav/nav_technique/nav_technique_body_disc.py create mode 100644 tests/nav/nav_technique/test_nav_technique_body_blob.py create mode 100644 tests/nav/nav_technique/test_nav_technique_body_disc.py diff --git a/AUTONAV_PLAN.md b/AUTONAV_PLAN.md index bffa389..42f6be3 100644 --- a/AUTONAV_PLAN.md +++ b/AUTONAV_PLAN.md @@ -1297,6 +1297,38 @@ above operationalise. + ``compute_image_gradient_vu``; the orchestrator's ``_make_context`` populates both fields. +**Body-disc and body-blob techniques (Part 3, Phase 5)** + +- ``nav.nav_technique.BodyDiscCorrelateNav`` — full-disc NCC with + Z-buffer paint per Part 0 §2. Composes per-body ``BODY_DISC`` + templates via ``nav.feature.composition.compose_template_features`` + (closer body's nonzero pixels overwrite farther body's), runs the + shared ``navigate_with_pyramid_kpeaks`` with ``use_gradient='auto'``, + and emits ``BodyDiscDiagnostics`` populated with the pyramid's + ``ncc_peak`` (PSR), ``consistency_px``, ``used_gradient``, and + ``body_count``. ``hard_zero_if`` fires on ``at_edge`` or + ``spurious``. The pyramid wrapper now returns + ``'used_gradient': bool`` so the technique can record the chosen + mode honestly (backwards-compatible addition to + ``nav.support.correlate``). +- ``nav.nav_technique.BodyBlobNav`` — joint-translation fit from + brightness-weighted-moment centroids over each blob's predicted + bbox. Per-blob weight ``w_i = N_lit_i * SNR_i^2 / radius_i^2`` + per the BODY_BLOB position-covariance derivation; joint covariance + is diagonal with per-axis variance ``1 / sum(w)`` floored to the + inverse-precision and inflated by residual scatter for ``N >= 2``. + Confidence intrinsically capped at 0.4 via ``ConfidenceSpec.hard_cap`` + per the design's BODY_BLOB reliability formula. +- Body-extractor emission gate aligned with Part 5: + ``LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0`` (was 2.0) and new module-level + ``BODY_BLOB_MIN_DIAMETER_PX = 8.0`` floor. The gate reads + ``max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px)`` so the + per-body table can override the floor upward but not downward. +- BODY_DISC ``template_img`` / ``template_mask`` payloads on + ``NavModelBody`` and ``NavModelBodySimulated`` now ship as bbox-sized + postage stamps, matching the contract + ``compose_template_features`` expects. + **NavModel infrastructure (Part 1, Part 8)** - `nav.nav_model.NavModel` ABC + `__init_subclass__` registry + @@ -1447,11 +1479,12 @@ above operationalise. **Concrete NavTechniques (Part 3)** -- `BodyDiscCorrelateNav`, `BodyBlobNav`, `RingAnnulusNav`, - `StarFieldFromCatalogNav`, `StarUniqueMatchNav`, `StarRefineNav`, - `CartographicNav`, `TitanNav`. ``BodyLimbNav``, - ``BodyTerminatorNav``, and ``RingEdgeNav`` are implemented (see - "DT-based techniques" under Implemented). +- `RingAnnulusNav`, `StarFieldFromCatalogNav`, `StarUniqueMatchNav`, + `StarRefineNav`, `CartographicNav`, `TitanNav`. ``BodyLimbNav``, + ``BodyTerminatorNav``, ``RingEdgeNav``, ``BodyDiscCorrelateNav``, + and ``BodyBlobNav`` are implemented (see "DT-based techniques" plus + the new "Body-disc and body-blob techniques" entries in the + "Implemented" section above; Phase 5 shipped the latter two). **NavContext shared derivatives** — *Superseded by `core_rewrite_catchup` (shipped); see "Phase 3 — Foundation completion + per-instrument config wiring (complete)".* @@ -2652,7 +2685,9 @@ Pre-existing low-coverage modules (`nav.nav_orchestrator.feature_summary` 64 %, --- -## Phase 5 — Body disc + body blob techniques +## Phase 5 — Body disc + body blob techniques (complete) + +**Status:** Shipped on branch `rf_core_rewrite`. The original specification (Goal / Scope / Tests / Documentation / Definition of done) is preserved verbatim below for reference; the post-merge "What shipped" subsection at the end records the actual delivery, the operator-curated library follow-ups, and the binding conventions established during the phase. **Goal:** Ship `BodyDiscCorrelateNav` (full-disc NCC) and `BodyBlobNav` (blob centroid) — the two body-side techniques that @@ -2747,6 +2782,152 @@ link to the confidence formula source-of-truth **Definition of done:** see "Per-phase definition of done". +### What shipped in Phase 5 + +- **`nav.nav_technique.BodyDiscCorrelateNav`** (new module + `src/nav/nav_technique/nav_technique_body_disc.py`). Filters input + to `BODY_DISC` features carrying a template payload, fuses them via + `nav.feature.composition.compose_template_features` (Z-buffer paint + by `subject_range_km` ascending so closer bodies overwrite farther + bodies), and runs `nav.support.correlate.navigate_with_pyramid_kpeaks` + with `use_gradient='auto'`. The auto picker now also surfaces a + `'used_gradient': bool` flag in the result dict so the technique can + populate `BodyDiscDiagnostics.used_gradient` honestly (the only + change to `support/correlate.py` in this phase — backwards-compatible + addition). Confidence spec carries the placeholder coefficients + documented in `developer_guide_techniques.rst` plus + `hard_zero_if={'at_edge': True, 'spurious': True}`. Registered in + `nav.nav_technique.__init__.py`. +- **`nav.nav_technique.BodyBlobNav`** (new module + `src/nav/nav_technique/nav_technique_body_blob.py`). Computes a + brightness-weighted-moment centroid for each `BODY_BLOB` feature + inside its predicted bbox (above-noise pixels only — pixels at or + below `3 * image_noise_sigma` carry no weight, so background DN + never biases the moment), then fits a precision-weighted joint + translation across all surviving blobs. Per-blob weight is the + inverse of the design's centroid-CRLB variance: + `w_i = N_lit_i * SNR_i^2 / radius_i^2`. Joint covariance is + diagonal: per-axis variance `1 / sum(w)` floored to inverse + precision and inflated by residual scatter when `N >= 2`. Confidence + spec uses `hard_cap=0.4` so the technique cannot drive the ensemble + past 0.4 confidence even when every term saturates (per Part 1's + BODY_BLOB reliability formula). Registered in + `nav.nav_technique.__init__.py`. +- **Body extractor emission gate aligned with Part 5.** + `nav.nav_model.nav_model_body.LIMB_ARC_MAX_UNCERTAINTY_PX` bumped + from 2.0 to 3.0 (matches Part 5 `limb_uncertainty_px_max_for_arc` + default). New module-level `BODY_BLOB_MIN_DIAMETER_PX = 8.0` floor + (Part 5 `body_blob_min_px` default). The gate now reads + `max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px)` so the + per-body table can override the floor upward (gas giants stay at 20 + px) but cannot go below the global default. `DEFAULT_BODY_SHAPE` + and `_SATURN_MOON_SHAPE` `min_blob_diameter_px` bumped from 5 to 8 + to align with the new floor (the per-body field still records the + most-conservative blob-min for that body). +- **`compose_template_features` template-payload contract honored by + body NavModels.** `NavModelBody._build_disc_feature` and + `NavModelBodySimulated.to_features` now crop `template_img` / + `template_mask` to the body's bbox before storing on the + `NavFeature` (was: full extfov-shaped buffer with non-zero values + only inside the bbox). The composition helper expects bbox-sized + postage stamps; the previous extfov-sized templates would have made + `compose_template_features` slice the wrong region of memory the + first time it ran on a real BODY_DISC feature. Phase 4 sidecars + never tripped this because none of them emitted a BODY_DISC. +- **`_filter_models` glob-negation extension verified end-to-end.** + The shared `nav.nav_technique.nav_technique.filter_technique_names` + helper already supports gitignore-style `!`-prefixed exclusion + patterns and is used by both the `only_techniques` filter and the + `_ModelRegistry.filter_by_glob` path that backs `only_models`. + Added `tests/nav/nav_orchestrator/test_orchestrator.py` + `test_orchestrator_only_models_mixed_include_exclude`, + `test_orchestrator_only_models_mixed_keeps_matching_inclusion`, + and `test_orchestrator_only_techniques_mixed_include_exclude` to + pin the mixed include/exclude behavior at the orchestrator surface + (Part 0 §9 / Phase 5 §E). +- **Operator-facing seed instructions + (`PHASE5_LIBRARY_SEED.md`).** Top-level operator runbook describing + the 2–4 candidate scenarios for the new techniques (body fills FOV, + body partial overflow, below-resolution / irregular body, + multi-body Z-buffer paint), the per-scenario sidecar location and + expected status / confidence_tier values, and the deferred + confidence-formula calibration note (Phase 10 retunes alphas + against the full ~50-image library). Out of the pymarkdown scan + scope (top-level non-README files are not linted), per the Phase 4 + convention. +- **Documentation.** `docs/developer_guide_techniques.rst` gains a + "Body-disc and body-blob techniques" section documenting both new + techniques' algorithms, confidence-spec coefficients (with explicit + `config_510_techniques.yaml.` source-of-truth + pointers per the design), diagnostics fields, infeasibility cases, + and a separate "Body-extractor emission gate" subsection capturing + the Part 5 emission rule. +- **Test coverage.** New end-to-end test files + `tests/nav/nav_technique/test_nav_technique_body_disc.py` and + `tests/nav/nav_technique/test_nav_technique_body_blob.py` cover: + single-body planted-offset recovery, multi-body Z-buffer paint / + joint LS, infeasibility on empty / no-template / zero-diameter + inputs, at-edge detection at the search-window boundary, blank-image + spurious-result fallback for the blob technique, the 0.4 hard cap on + blob confidence, and registry presence for both techniques. + Existing unit tests under `tests/nav/nav_model/` updated to match + the new gate constants (`LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0`, + `DEFAULT_BODY_SHAPE.min_blob_diameter_px = 8.0`, + worked-example km/px values for the threshold-crossing tests). + +### Logging / API conventions established in Phase 5 (binding) + +- **`navigate_with_pyramid_kpeaks` returns `'used_gradient': bool`.** + Backwards-compatible addition; non-auto callers see `bool(use_gradient)`, + auto callers see whichever mode the picker chose. Future correlation + techniques (e.g. `RingAnnulusNav` in Phase 6) should read this field + rather than re-running the pyramid in both modes. +- **BODY_DISC `template_img` is a postage stamp sized to + `bbox_extfov_vu`.** Both `NavModelBody` and `NavModelBodySimulated` + produce postage stamps; future body-emitting NavModels (cartographic, + custom-irregular) must follow the same convention so + `compose_template_features` Z-buffer paint works without a + bbox-vs-shape branch. +- **`max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px)`** is + the canonical body-blob emission gate. The per-body + `min_blob_diameter_px` field is a *floor override* — it can only + push the global default upward (gas giants stay at 20 px), never + downward. Future per-body table additions follow the same + one-direction policy. +- **`BodyBlobNav` uses an above-noise predicted-bbox centroid, not a + predicted-disc-mask centroid.** The design says "centroid intensity- + weighted over predicted-lit pixels"; in practice the predicted disc + mask drifts off the actual body whenever the SPICE pointing offset + exceeds the body radius, so the technique uses the predicted bbox + (which carries per-body slop) and brightness-thresholds pixels at + `3 * image_noise_sigma`. This produces correct results for moderate + pointing errors at the cost of being biased by other bright sources + inside the bbox; the operator runbook calls this out and recommends + picking blob-only scenes with dark backgrounds. + +### Phase 5 follow-ups uncovered during implementation + +These are real, reproducible gaps surfaced by the synthetic-image +unit tests and the design review during Phase 5. They do not block +Phase 5 (the technique handles all the documented happy and boundary +paths) but are concrete starting points for Phase 6 / Phase 10 +calibration work. + +- **`peak_to_runner_up_ratio` placeholder.** + `BodyDiscDiagnostics.peak_to_runner_up_ratio` is populated with + `0.0` because `navigate_with_pyramid_kpeaks` does not surface + multi-peak telemetry past the auto picker. Phase 6 follow-up: when + `RingAnnulusNav` lands, extend the pyramid wrapper to return the + top-K peak values from its final pass so both correlation + techniques can populate this diagnostic; until then, the field is + inert and the confidence formula does not consume it. +- **`config_510_techniques.yaml` not yet shipped.** The per-technique + confidence-formula coefficients live as Python constants + (`_BODY_DISC_CONFIDENCE_SPEC`, `_BODY_BLOB_CONFIDENCE_SPEC`) until + the corresponding YAML config file ships in a later phase. When it + does, the confidence specs should be loaded from YAML at config + init so the operator can retune without a code change. + --- ## Phase 6 — Ring-annulus technique diff --git a/PHASE5_LIBRARY_SEED.md b/PHASE5_LIBRARY_SEED.md new file mode 100644 index 0000000..15ed5a9 --- /dev/null +++ b/PHASE5_LIBRARY_SEED.md @@ -0,0 +1,216 @@ +# Phase 5 — image library seed (operator instructions) + +This file walks an operator through curating the 2–4 additional library +entries Phase 5 calls for. The two new techniques — +`BodyDiscCorrelateNav` and `BodyBlobNav` — both ship with end-to-end +unit tests against synthetic inputs, but the integration regression +suite needs new sidecars on real images that exercise their happy and +boundary paths. + +The Phase 4 runbook (`PHASE4_LIBRARY_SEED.md`) covers the manual-nav +workflow, the `Save as Library Entry…` button, the `pds3://` URL +convention, and the `` filename rule. **Read it first.** This +file only documents the Phase 5 scenario picks plus the +technique-specific gotchas for the two new features. + +## What's new in Phase 5 + +- `BodyDiscCorrelateNav` consumes per-body `BODY_DISC` features (the + full-disc Lambert-shaded postage-stamp template). It is the right + technique for scenes where the body fits inside the FOV with a + well-lit, mostly visible disc — *not* the partial-overflow scenes the + Phase 4 limb runbook targeted. +- `BodyBlobNav` consumes `BODY_BLOB` features. The body extractor emits + `BODY_BLOB` instead of `LIMB_ARC` whenever + `limb_uncertainty_px > LIMB_ARC_MAX_UNCERTAINTY_PX` (default 3 px), + which fires on close-range irregular satellites and on under-resolved + bodies. So a Prometheus-at-approach scene goes through `BodyBlobNav`, + while Prometheus in a wide F-ring mosaic goes through `BodyLimbNav` — + same body, different per-image path. + +## Body extractor emission gate (recap) + +The body NavModel decides per-image, per-body which feature(s) to emit: + +``` +if limb_uncertainty_px <= 3 px: # LIMB_ARC_MAX_UNCERTAINTY_PX + emit LIMB_ARC + if visible_lit_fraction >= 0.4 and overflow_fraction <= 0.3: + also emit BODY_DISC +elif predicted_diameter_px >= 8 px: # BODY_BLOB_MIN_DIAMETER_PX + emit BODY_BLOB +else: + emit nothing +``` + +The `BODY_DISC` companion gates ensure we only run NCC pyramids when the +body actually fills enough of the FOV for the correlation peak to be +sharp. A body that overflows by > 30 % falls back to LIMB_ARC alone. + +## Picking Phase 5 candidates + +Aim for **2–4 sidecars**, choosing whichever subset of the four +scenarios below your holdings cover. Prioritise scenario **A** because +it exercises both Phase-5 techniques back-to-back (limb + disc on the +same body); scenario **C** is the cheap "blob-only" win on a small or +distant moon. Scenario **D** stretches the multi-body Z-buffer paint +path. + +### Scenario A — Body fills FOV with small overflow (`BodyDiscCorrelateNav` + `BodyLimbNav`) + +A scene where one regular moon dominates the frame with `<= 30 %` +overflow and `>= 40 %` of the lit hemisphere visible. Both `BODY_DISC` +and `LIMB_ARC` get emitted; the ensemble combines them. + +| Field | What to look for | +|---|---| +| Mission / camera | Cassini ISS, NAC | +| Body | Mimas, Enceladus, Tethys, Dione, Rhea, Iapetus | +| Body diameter | 800–1024 px (NAC frame is 1024 px) | +| Visible lit fraction | `>= 40 %` of the disc area | +| Overflow fraction | `<= 30 %` | +| Phase angle | < 60° (most of the disc is bright) | +| Other features | None preferred | + +**Sidecar location**: +`tests/integration/image_library/images/body_full_fov/.yaml` + +**Expected behavior**: +- `expected.status: ok` +- `expected.confidence_tier: high` +- `expected.primary_technique: BodyDiscCorrelateNav` (NCC against the + full disc usually wins on confidence; `BodyLimbNav` is the runner-up) +- `expected.techniques_must_run: [BodyDiscCorrelateNav, BodyLimbNav]` +- `expected.techniques_must_skip: [RingEdgeNav, StarFieldFromCatalogNav]` + +### Scenario B — Body partial overflow (`BodyLimbNav` only, no disc) + +A scene where the body overflows the FOV by > 30 % but the limb is +sharp enough for `LIMB_ARC` to fire. The body extractor emits +`LIMB_ARC` only — the disc gate fails on overflow. This complements +the Phase 4 Tethys scene (`body_mostly_offscreen` was the +`BodyLimbNav`-LM-divergence test); pick a different body to broaden +the calibration sample. + +| Field | What to look for | +|---|---| +| Mission / camera | Cassini ISS, NAC | +| Body | Different from the Phase 4 Tethys (Rhea / Dione / Mimas) | +| Body fraction in FOV | 50–80 % (overflow_fraction 0.2–0.5) | +| Limb visible | Long bright arc (`>= 60 px`) | +| Other features | None | + +**Sidecar location**: +`tests/integration/image_library/images/body_partial_overflow/.yaml` + +**Expected behavior**: +- `expected.status: ok` (or `failed` if the LM divergence pattern + re-fires on a heavily-cratered body — record honestly) +- `expected.primary_technique: BodyLimbNav` +- `expected.techniques_must_run: [BodyLimbNav]` +- `expected.techniques_must_skip: [BodyDiscCorrelateNav]` + +### Scenario C — Below-resolution / irregular body (`BodyBlobNav`) + +A scene where a small or irregular moon is too unresolved or +shape-irregular for the limb fit. The body extractor emits `BODY_BLOB` +only. + +| Field | What to look for | +|---|---| +| Mission / camera | Cassini ISS, NAC | +| Body | Prometheus, Pandora, Atlas, Pan, Hyperion at close range, **or** any regular moon at a distance where `predicted_diameter_px` is 10–30 px | +| Body diameter in FOV | 8–30 px | +| Other bright sources | None inside the predicted bbox (otherwise the centroid is biased) | +| Background | Dark sky preferred | + +**Sidecar location**: +- Irregular body: `tests/integration/image_library/images/body_irregular/.yaml` +- Distant regular body: `tests/integration/image_library/images/below_resolution_body/.yaml` + +**Expected behavior**: +- `expected.status: ok` (or `failed` if calibration discovers the + centroid is too soft on the chosen frame; the confidence cap of 0.4 + may push the orchestrator below `min_confidence` — record honestly) +- `expected.confidence_tier: low` or `medium` (cap is 0.4 — never + `high`) +- `expected.primary_technique: BodyBlobNav` +- `expected.techniques_must_run: [BodyBlobNav]` +- `expected.techniques_must_skip: [BodyLimbNav, BodyDiscCorrelateNav]` + +### Scenario D — Multi-body Z-buffer paint (`BodyDiscCorrelateNav` joint fit) + +Optional but high-value: a single Cassini frame with two visible +moons. The Z-buffer paint composes both per-body `BODY_DISC` templates +into one composite, and the joint NCC removes the swap-moon +ambiguity that solo correlation suffers from. The trickiest part is +finding a real Cassini frame with two non-trivially-sized moons in the +same FOV; mosaic frames around saturnian conjunctions are the natural +hunting ground. + +| Field | What to look for | +|---|---| +| Mission / camera | Cassini ISS, NAC or WAC | +| Bodies | Two regular moons, both `>= 30 px` diameter, both inside FOV | +| Subject ranges | Different (so depth ordering is well-defined) | +| Other features | Saturn rings OK; star field OK | + +**Sidecar location**: +`tests/integration/image_library/images/multi_body/.yaml` + +**Expected behavior**: +- `expected.status: ok` +- `expected.primary_technique: BodyDiscCorrelateNav` (joint NCC) +- `expected.techniques_must_run: [BodyDiscCorrelateNav]` +- `expected.techniques_must_skip: []` (limb / blob may also fire — list + only the ones the orchestrator must NOT run) + +## Workflow per image + +Same as the Phase 4 runbook (steps 1–7). The `Save as Library Entry…` +button writes a sidecar pre-filled with auto-derivable fields plus +`TODO_REPLACE_*` placeholders for the operator-curated entries. +Edit those, drop the file under the correct scene-class directory, and +run: + +```bash +pytest tests/integration/test_image_library.py -v +pytest tests/integration/test_autonomous_nav.py -v -k +``` + +Both must pass before you commit. + +## Confidence-formula calibration deferred + +Phase 5 ships placeholder coefficients on both new techniques' confidence +specs. Phase 10 (image-library expansion + confidence calibration) is +when those alphas get retuned against the full ~50-image library. Until +then, expect the new sidecars to need conservative +`expected.confidence_tier` values: + +- `BodyDiscCorrelateNav`: `low` to `medium` even on textbook-clean + scenes. +- `BodyBlobNav`: `low` (the 0.4 hard cap is permanent — even Phase 10 + calibration cannot push past it). + +Record the actual orchestrator-reported tier in your sidecar's +`expected.confidence_tier` and let the regression test pin today's +behavior. When Phase 10 retunes the coefficients the sidecars will +need a corresponding `expected.confidence_tier` bump; that's a +bookkeeping pass, not a re-curation. + +## After the seed lands + +- The new sidecars enter the regression suite the moment they are + committed; CI runs them on every PR via the `integration` mark + (gated on `PDS3_HOLDINGS_DIR` etc.). +- For each `expected.status: ok` sidecar, the operator may also seed a + baseline JSON under `tests/integration/baselines/.json` so + any orchestrator drift trips the byte-level regression test. Use + `python -c "from tests.integration.baseline import seed_baseline; …"` + or the helper Phase 4 documented. +- Phase 5 follow-ups uncovered during integration runs (e.g. a + `BodyDiscCorrelateNav` consistency value that calibration will need + to retune) belong in `AUTONAV_PLAN.md` under the new + "Phase 6 / Phase 10 follow-ups uncovered by Phase 5 integration" + subsection. diff --git a/docs/developer_guide_techniques.rst b/docs/developer_guide_techniques.rst index 0eb6c4c..58f18f5 100644 --- a/docs/developer_guide_techniques.rst +++ b/docs/developer_guide_techniques.rst @@ -162,6 +162,189 @@ the M-estimator pseudoinverse, honestly rank-deficient on flat-only scenes; the ensemble combine fuses it with any orthogonal feature (star, body limb, body blob) before declaring a final answer. +Body-disc and body-blob techniques +================================== + +Two body-side techniques do not fit the DT-fitting shape: full-disc NCC +correlation (``BodyDiscCorrelateNav``) and brightness-weighted-moment +centroid fitting (``BodyBlobNav``). Both consume a single feature type +and produce one combined translation, so multi-body inputs constrain the +fit jointly without per-body offset ambiguity. + +BodyDiscCorrelateNav +-------------------- + +Accepts ``BODY_DISC`` features. Each ``BODY_DISC`` carries a +postage-stamp Lambert-shaded disc template (``template_img``) plus a +boolean ``template_mask`` and a ``bbox_extfov_vu`` placement. The +technique: + +1. Fuses the per-body templates into a single extfov-shaped composite + via :func:`nav.feature.composition.compose_template_features`. The + helper sorts features by ``subject_range_km`` ascending so closer + bodies overwrite farther bodies on overlap (Z-buffer paint per + Part 0 §2 of the autonav design). The combined mask is the OR of + per-body masks. +2. Runs :func:`nav.support.correlate.navigate_with_pyramid_kpeaks` + against the composite with ``use_gradient='auto'``. Auto mode tries + both raw-intensity and gradient-magnitude NCC and keeps the better + result by ``non-spurious > not-at-edge > higher-quality`` ordering. +3. Reads the pyramid wrapper's ``offset``, ``cov``, ``quality``, + ``consistency``, ``spurious``, ``at_edge``, and ``used_gradient`` + fields directly into ``BodyDiscDiagnostics``. + +``is_feasible`` returns True iff at least one input ``BODY_DISC`` +feature carries a template payload. The technique reports +``spurious=True`` when the pyramid's quality metric falls below +``quality_thresh`` or pyramid consistency drifts past ``consistency_tol``; +``at_edge=True`` when the converged peak lies within 2 px of the search +window edge. + +Confidence spec coefficients (placeholders pending calibration +against the operator-curated image library): + +- ``alpha0 = -2`` +- ``alpha(ncc_peak / 6, capped at 1) = 1.5`` +- ``alpha(consistency_px / 2) = -1`` +- ``alpha(body_count / 3, capped at 1) = 0.4`` +- ``alpha(peak_to_runner_up_ratio / 2, capped at 1) = 0.0`` — wired in + but disabled until calibration tunes the alpha +- hard zero when ``at_edge`` or ``spurious`` + +.. note:: + + ``config_510_techniques.yaml`` is not yet in the tree; the + coefficients above live as the Python module constant + ``_BODY_DISC_CONFIDENCE_SPEC`` in + ``nav.nav_technique.nav_technique_body_disc``. When the YAML + config ships the loader's defaults will mirror these values and + the technique will read them from + ``config_510_techniques.yaml.body_disc_correlate``. + +Diagnostics fields: + +- ``ncc_peak``: pyramid-wrapper quality metric (PSR by default). +- ``peak_to_runner_up_ratio``: ratio of the winning peak's quality to + the runner-up's, derived from the pyramid wrapper's + ``top_k_peaks`` field (sorted by quality descending). Returns + ``1.0`` when only one peak survives non-maximum suppression — the + unambiguous-peak case. +- ``consistency_px``: maximum per-axis disagreement between coarse and + fine pyramid levels. +- ``used_gradient``: ``True`` when auto-mode picked the gradient pass. +- ``body_count``: number of fused BODY_DISC features. + +Infeasibility cases: + +- No input feature carries a template. + +BodyBlobNav +----------- + +Accepts ``BODY_BLOB`` features. Each blob carries only a predicted +centroid and a predicted bounding box (no template — the body is +either irregular or under-resolved, so a Lambert template cannot be +rendered usefully). The technique: + +1. For each blob, computes a brightness-weighted-moment centroid over + every above-noise pixel inside the predicted bbox. Above-noise + means ``image_DN > 3 * image_noise_sigma``; background DN never + biases the moment. +2. Computes the per-blob residual ``observed_centroid - predicted_center`` + and forms a precision-weighted joint translation (the simple + weighted mean across all surviving blobs). +3. Per-blob weight ``w_i = N_lit_i * SNR_i^2 / radius_i^2`` from the + BODY_BLOB centroid CRLB derivation; the joint covariance is + diagonal with per-axis variance ``1 / sum(w)`` floored to the + inverse-precision and inflated by residual scatter when ``N >= 2``. + +``is_feasible`` returns True iff at least one input ``BODY_BLOB`` +carries a non-zero ``predicted_diameter_px``. The technique flags +``spurious=True`` when no blob has any above-noise signal in its +predicted bbox; ``at_edge=True`` when the converged offset lies within +1 px of the search-window axis bounds. + +The confidence formula intrinsically caps at ``0.4`` (the BODY_BLOB +reliability ceiling). A brightness-weighted centroid is weaker than +a limb fit by design; the cap ensures the technique cannot dominate +the ensemble even when every term saturates. Coefficient +placeholders pending calibration against the operator-curated image +library: + +- ``alpha0 = -1`` +- ``alpha(body_snr_inside_predicted_bbox / 4, capped at 1) = 0.5`` +- ``alpha((body_extent_px - 8) / 8, capped at 1) = 1`` +- ``alpha(blob_count / 3, capped at 1) = 0.4`` +- hard zero when ``at_edge`` +- hard cap ``0.4`` after the sigmoid + +.. note:: + + ``config_510_techniques.yaml`` is not yet in the tree; the + coefficients above live as the Python module constant + ``_BODY_BLOB_CONFIDENCE_SPEC`` in + ``nav.nav_technique.nav_technique_body_blob``. When the YAML + config ships the loader's defaults will mirror these values and + the technique will read them from + ``config_510_techniques.yaml.body_blob``. + +Sample confidence breakdown +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Running the BodyBlobNav unit tests with DEBUG logging on a 3-blob +synthetic scene (mean-extent 16 px, mean-SNR 8) produces a per-term +trace of the form: + +.. code-block:: text + + Confidence breakdown: alpha0=-1.000, sigmoid_arg=1.133 -> confidence=0.4000 (hard_cap applied) + term 'body_snr_inside_predicted_bbox': raw=8.00, normalized=1.000, alpha=+0.500 -> contribution=+0.500 + term 'body_extent_px': raw=16.00, normalized=1.000, alpha=+1.000 -> contribution=+1.000 + term 'blob_count': raw=3.00, normalized=1.000, alpha=+0.400 -> contribution=+0.400 + term 'residual_px': raw=0.10, normalized=0.100, alpha=+0.000 -> contribution=+0.000 + +The sigmoid argument before clamping is ``-1.0 + 0.5 + 1.0 + 0.4 = +0.9``, the sigmoid evaluates to ``0.711``, and the ``hard_cap = 0.4`` +post-sigmoid clamp drops the headline confidence to the BODY_BLOB +ceiling. + +Diagnostics fields: + +- ``body_snr_inside_predicted_bbox``: mean SNR of above-noise pixels + inside each predicted bbox, averaged across consumed blobs. +- ``body_extent_px``: mean predicted disc diameter in pixels across + consumed blobs. +- ``blob_count``: number of blobs that contributed (after dropping + blobs with no above-noise signal). +- ``residual_px``: RMS scatter of per-blob ``observed - predicted`` + vectors around the joint mean. + +Infeasibility cases: + +- No input feature carries a non-zero predicted diameter. + +Body-extractor emission gate +============================ + +The body NavModel picks which feature types to emit per-image, +per-body: + +1. If ``limb_uncertainty_px <= LIMB_ARC_MAX_UNCERTAINTY_PX`` (default + 3 px) and the limb sampler has surviving vertices: emit + ``LIMB_ARC``. +2. Else if ``predicted_diameter_px >= max(BODY_BLOB_MIN_DIAMETER_PX, + shape.min_blob_diameter_px)`` (default 8 px, with per-body + overrides for highly-irregular satellites and gas giants): emit + ``BODY_BLOB``. +3. Else: emit no body feature for the image. + +``limb_uncertainty_px = ellipsoid_residual_km / km_per_px_at_limb`` — +per-image, per-body. The same body becomes a usable limb arc at one +distance and a blob at another (worked example: an irregular +ring-shepherd moon at 100 km/px has ``limb_uncertainty_px = 0.08`` +and emits ``LIMB_ARC``; the same moon at 1 km/px has +``limb_uncertainty_px = 8`` and emits ``BODY_BLOB`` instead). + Logging ======= @@ -180,4 +363,17 @@ See also - :doc:`developer_guide_uncertainty` — derivation of the M-estimator information-matrix to covariance step that turns the LM Jacobian at convergence into the per-technique 2x2 (or 3x3) covariance reported - on every ``NavTechniqueResult``. + on every ``NavTechniqueResult``. ``BodyDiscCorrelateNav``'s + covariance comes from the pyramid wrapper's Hessian-of-NCC; both + ``BodyLimbNav`` and ``BodyTerminatorNav`` derive theirs from the + Tukey-reweighted information matrix; ``BodyBlobNav`` derives a + diagonal precision-weighted-mean covariance from the per-blob CRLB + weights. +- :func:`nav.feature.composition.compose_template_features` — the + Z-buffer paint helper that ``BodyDiscCorrelateNav`` uses to fuse + per-body templates into a single composite for the NCC. +- :func:`nav.support.correlate.navigate_with_pyramid_kpeaks` — the + shared pyramid-NCC entry point. Returns ``offset``, ``cov``, + ``quality``, ``consistency``, ``spurious``, ``at_edge``, + ``used_gradient``, and ``top_k_peaks`` (per-peak telemetry the + ``BodyDiscCorrelateNav`` peak-to-runner-up diagnostic reads). diff --git a/src/nav/nav_model/body_shape.py b/src/nav/nav_model/body_shape.py index 380f552..b89d8fe 100644 --- a/src/nav/nav_model/body_shape.py +++ b/src/nav/nav_model/body_shape.py @@ -64,12 +64,13 @@ class BodyShape: crater_scale_km=5.0, albedo_variation=0.15, spice_orbital_residual_km=2.0, - min_blob_diameter_px=5.0, + min_blob_diameter_px=8.0, ) """Fallback shape used when a body has no specific entry. The numbers reflect a generic small icy moon: ~2 km bulk-shape residual, ~5 km crater scale, modest albedo variation, generous 2 km SPK residual. +``min_blob_diameter_px`` matches the Part 5 default (``body_blob_min_px``). """ @@ -78,7 +79,7 @@ class BodyShape: crater_scale_km=2.0, albedo_variation=0.10, spice_orbital_residual_km=0.5, - min_blob_diameter_px=5.0, + min_blob_diameter_px=8.0, ) """Profile for the major Saturn moons whose shape is well-measured.""" diff --git a/src/nav/nav_model/nav_model_body.py b/src/nav/nav_model/nav_model_body.py index 4c9a666..91954bd 100644 --- a/src/nav/nav_model/nav_model_body.py +++ b/src/nav/nav_model/nav_model_body.py @@ -20,9 +20,11 @@ - ``LIMB_ARC`` is emitted when ``limb_uncertainty_px <= LIMB_ARC_MAX_UNCERTAINTY_PX`` and there are surviving limb vertices. -- ``BODY_BLOB`` is emitted when the predicted disc diameter is at least - the body's ``min_blob_diameter_px`` *and* the limb uncertainty is - too high for ``LIMB_ARC``. +- ``BODY_BLOB`` is emitted when the predicted disc diameter is at + least ``max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px)`` + *and* the limb uncertainty is too high for ``LIMB_ARC``. The + per-body shape floor can override the global default upward but + not downward. - ``BODY_DISC`` is emitted alongside ``LIMB_ARC`` when the body fits inside the FOV with at least ``BODY_DISC_MIN_VISIBLE_LIT_FRACTION`` of its lit side visible and ``overflow_fraction`` below @@ -82,6 +84,7 @@ from nav.support.filters import NavFilterKind, NavFilterSpec __all__ = [ + 'BODY_BLOB_MIN_DIAMETER_PX', 'BODY_DISC_MAX_OVERFLOW_FRACTION', 'BODY_DISC_MIN_VISIBLE_LIT_FRACTION', 'BODY_POSITION_SLOP_FRAC', @@ -101,13 +104,25 @@ """ -LIMB_ARC_MAX_UNCERTAINTY_PX: float = 2.0 +LIMB_ARC_MAX_UNCERTAINTY_PX: float = 3.0 """Cap on the limb normal-sigma at which LIMB_ARC remains useful. -Above this value the feature is unreliable enough that BODY_BLOB or -BODY_DISC is the right emission instead. Phase-5 calibration may -tighten this; the default is the design's "limb fits within a couple of -pixels" guideline. +Above this value the per-vertex normal uncertainty is too large for +the DT-based limb fit; the extractor switches to ``BODY_BLOB`` so the +brightness-weighted-centroid technique still has something to work +with. The numeric value is a config default pending calibration +against the operator-curated image library. +""" + + +BODY_BLOB_MIN_DIAMETER_PX: float = 8.0 +"""Minimum predicted disc diameter (px) at which BODY_BLOB is emitted. + +Below this diameter the predicted body silhouette is too small for a +brightness-weighted centroid to pin the body to better than ~1 px, so +the extractor emits no body feature for the image. The per-body +shape table can override this floor for known irregular / gas-giant +bodies. """ @@ -543,9 +558,11 @@ def _build_backplane_model( lit_mask = lit_arr body_total = int(np.count_nonzero(body_mask)) body_visible = int(np.count_nonzero(body_mask & in_sensor)) - # Per Part 1: ``visible_lit_fraction`` is the fraction of the - # *whole predicted disc* (lit and dark) whose cos(incidence) >= 0 - # *and* which lies inside the sensor FOV. + # ``visible_lit_fraction`` measures the fraction of the *whole + # predicted disc* (lit + dark together) whose cos(incidence) >= 0 + # AND which lies inside the sensor FOV — not the lit hemisphere + # alone, which would always score ~1.0 for a fully-in-frame + # body and lose discriminating power for the BODY_DISC gate. lit_visible_in_fov = int(np.count_nonzero(lit_mask & in_sensor)) visible_lit_fraction = lit_visible_in_fov / max(body_total, 1) overflow_fraction = 1.0 - (body_visible / max(body_total, 1)) @@ -583,6 +600,7 @@ def to_features(self, context: NavContext) -> list[NavFeature]: getattr(shape, 'shape_class_hint', 'unknown'), ) limb_arc_emitted = False + blob_min_px = max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px) if ( self._limb_sampler is not None and self._limb_sampler.vertices_vu.size > 0 @@ -600,9 +618,17 @@ def to_features(self, context: NavContext) -> list[NavFeature]: ) ) limb_arc_emitted = True + elif self._predicted_diameter_px >= blob_min_px: + features.append(self._build_blob_feature(shape)) else: - if self._predicted_diameter_px >= shape.min_blob_diameter_px: - features.append(self._build_blob_feature(shape)) + self._logger.debug( + 'No body feature emitted: limb_uncertainty %.3f > %.3f and ' + 'predicted_diameter %.3f < %.3f', + limb_uncertainty_px, + LIMB_ARC_MAX_UNCERTAINTY_PX, + self._predicted_diameter_px, + blob_min_px, + ) if self._should_emit_disc(limb_arc_emitted): features.append(self._build_disc_feature(shape)) @@ -647,6 +673,14 @@ def _build_disc_feature(self, shape: BodyShape) -> NavFeature: """Construct the BODY_DISC feature (template + geometry + flags).""" assert self._model_img is not None assert self._body_mask is not None + # ``compose_template_features`` expects the template payload to be a + # postage-stamp sized to ``bbox_extfov_vu``. ``self._model_img`` is + # an extfov-shaped buffer with non-zero values only inside the body + # bbox, so crop here to the body's bbox. The .copy() detaches from + # the parent so the feature can freeze its own array safely. + v_min, u_min, v_max, u_max = self._bbox_extfov_vu + template_img = self._model_img[v_min:v_max, u_min:u_max].copy() + template_mask = self._body_mask[v_min:v_max, u_min:u_max].copy() return NavFeature( feature_id=f'body_disc:{self._body_name}', feature_type=NavFeatureType.BODY_DISC, @@ -674,8 +708,8 @@ def _build_disc_feature(self, shape: BodyShape) -> NavFeature: body_name=self._body_name, overflow_fov_fraction=self._overflow_fraction, ), - template_img=self._model_img, - template_mask=self._body_mask, + template_img=template_img, + template_mask=template_mask, ) def _build_blob_feature(self, shape: BodyShape) -> NavFeature: diff --git a/src/nav/nav_model/nav_model_body_simulated.py b/src/nav/nav_model/nav_model_body_simulated.py index fc4cac2..b10f3ac 100644 --- a/src/nav/nav_model/nav_model_body_simulated.py +++ b/src/nav/nav_model/nav_model_body_simulated.py @@ -149,6 +149,9 @@ def to_features(self, context: NavContext) -> list[NavFeature]: """Emit a single BODY_DISC feature carrying the rendered template.""" if self._model_img is None or self._body_mask is None: return [] + v_min, u_min, v_max, u_max = self._bbox_extfov_vu + template_img = self._model_img[v_min:v_max, u_min:u_max].copy() + template_mask = self._body_mask[v_min:v_max, u_min:u_max].copy() feature = NavFeature( feature_id=f'body_disc:{self._body_name}', feature_type=NavFeatureType.BODY_DISC, @@ -168,8 +171,8 @@ def to_features(self, context: NavContext) -> list[NavFeature]: ), usable_types=frozenset({NavFeatureType.BODY_DISC}), flags=BodyDiscFlags(body_name=self._body_name, overflow_fov_fraction=0.0), - template_img=self._model_img, - template_mask=self._body_mask, + template_img=template_img, + template_mask=template_mask, ) return [feature] diff --git a/src/nav/nav_technique/__init__.py b/src/nav/nav_technique/__init__.py index a34a7c3..6cdac64 100644 --- a/src/nav/nav_technique/__init__.py +++ b/src/nav/nav_technique/__init__.py @@ -38,6 +38,8 @@ ) from nav.nav_technique.feasibility import NavFeasibilityReport from nav.nav_technique.nav_technique import NavTechnique, filter_technique_names +from nav.nav_technique.nav_technique_body_blob import BodyBlobNav +from nav.nav_technique.nav_technique_body_disc import BodyDiscCorrelateNav from nav.nav_technique.nav_technique_body_limb import BodyLimbNav from nav.nav_technique.nav_technique_body_terminator import BodyTerminatorNav from nav.nav_technique.nav_technique_manual import NavTechniqueManual, run_manual_nav @@ -46,6 +48,8 @@ __all__ = [ 'BodyBlobDiagnostics', + 'BodyBlobNav', + 'BodyDiscCorrelateNav', 'BodyDiscDiagnostics', 'BodyLimbDiagnostics', 'BodyLimbNav', diff --git a/src/nav/nav_technique/dt_fitting.py b/src/nav/nav_technique/dt_fitting.py index 3be402e..0c7537a 100644 --- a/src/nav/nav_technique/dt_fitting.py +++ b/src/nav/nav_technique/dt_fitting.py @@ -32,6 +32,7 @@ from nav.support.types import NDArrayBoolType, NDArrayFloatType __all__ = [ + 'AT_EDGE_TOLERANCE_PX', 'DEFAULT_LM_DAMPING', 'DEFAULT_LM_MAX_ITERATIONS', 'DEFAULT_LM_STEP_TOLERANCE', @@ -46,6 +47,22 @@ ] +AT_EDGE_TOLERANCE_PX: float = 1.0 +"""Pixels of slack around the search-window axis bounds for at-edge detection. + +A converged offset whose absolute distance from any axis bound +(``+/- margin_v``, ``+/- margin_u``) falls within this tolerance is +flagged ``at_edge=True`` and forced to zero confidence by the +technique's ``hard_zero_if`` gate. One pixel matches the bilinear +DT half-cell width: any closer to the boundary and the LM gradient +information is unreliable. + +Shared across every translation-fit technique (DT-based limb / +terminator / ring-edge plus the brightness-weighted-moment blob fit) +so the at-edge convention stays uniform. +""" + + DEFAULT_TUKEY_C: float = 4.685 """Holland-Welsch redescender constant. diff --git a/src/nav/nav_technique/nav_technique_body_blob.py b/src/nav/nav_technique/nav_technique_body_blob.py new file mode 100644 index 0000000..68e9327 --- /dev/null +++ b/src/nav/nav_technique/nav_technique_body_blob.py @@ -0,0 +1,487 @@ +"""``BodyBlobNav`` — joint-translation fit from body brightness centroids. + +Consumes every ``BODY_BLOB`` feature in the input set, computes a +brightness-weighted-moment centroid for each body inside its predicted +bounding box, and recovers a single 2-D translation that maps the +predicted centroids onto the observed centroids in least-squares. With +``N >= 2`` blobs the fit is over-determined, which makes the technique +robust to centroid errors on any single body. + +The technique reports a confidence intrinsically capped at 0.4: a +brightness-weighted centroid is much weaker than a limb fit, so even an +ideal blob match cannot dominate the ensemble. Per-blob centroid +uncertainty follows the standard CRLB scaling for a uniform-brightness +disc: ``sigma ~ predicted_diameter_px / (2 * sqrt(N_lit) * SNR)``; the +joint fit inherits that scaling. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +from pdslogger import PdsLogger + +from nav.config import Config +from nav.feature.feature import NavFeature +from nav.feature.feature_type import NavFeatureType +from nav.feature.geometry import BodyBlobGeometry +from nav.nav_technique.confidence import ( + ConfidenceSpec, + ConfidenceTerm, + evaluate_sigmoid_combination, +) +from nav.nav_technique.diagnostics import BodyBlobDiagnostics +from nav.nav_technique.dt_fitting import AT_EDGE_TOLERANCE_PX +from nav.nav_technique.feasibility import NavFeasibilityReport +from nav.nav_technique.nav_technique import NavTechnique, log_confidence_breakdown +from nav.nav_technique.technique_result import NavTechniqueResult +from nav.support.types import NDArrayBoolType, NDArrayFloatType + +if TYPE_CHECKING: # pragma: no cover - typing-only import + from nav.nav_orchestrator.nav_context import NavContext + +__all__ = ['BodyBlobNav'] + + +_BODY_BLOB_CONFIDENCE_SPEC = ConfidenceSpec( + alpha0=-1.0, + terms=( + # Brightness-weighted centroid uncertainty shrinks with SNR. + ConfidenceTerm( + feature='body_snr_inside_predicted_bbox', + alpha=0.5, + divisor=4.0, + cap_at=1.0, + ), + # Larger blobs carry more centroid signal per the design's + # ``sigmoid(extent_px / 8 - 1)`` factor. + ConfidenceTerm( + feature='body_extent_px', + alpha=1.0, + offset=8.0, + divisor=8.0, + cap_at=1.0, + ), + # Multi-body geometry over-determines the joint translation. + ConfidenceTerm(feature='blob_count', alpha=0.4, divisor=3.0, cap_at=1.0), + ), + hard_zero_if={'at_edge': True}, + hard_cap=0.4, +) +"""Confidence spec for the body-blob technique. + +The hard cap of 0.4 mirrors the BODY_BLOB reliability formula: a +brightness-weighted centroid is a weaker observation than a limb fit, +so the technique cannot drive the ensemble past 0.4 confidence even +when every term saturates. Coefficients are placeholders pending +calibration against the operator-curated image library. +""" + + +@dataclass(frozen=True) +class _BlobResiduals: + """Per-blob residual + statistics arrays for the joint fit. + + ``offsets_v`` / ``offsets_u`` are the per-blob ``observed - predicted`` + centroid vectors, ``weights`` are the per-blob inverse-variance + weights, ``snrs`` and ``extents`` are diagnostic samples that feed + the confidence formula via :class:`BodyBlobDiagnostics`. + """ + + consumed: list[NavFeature] + offsets_v: NDArrayFloatType + offsets_u: NDArrayFloatType + weights: NDArrayFloatType + snrs: list[float] + extents: list[float] + + +@dataclass(frozen=True) +class _JointFit: + """Joint translation result derived from per-blob residuals.""" + + dv: float + du: float + covariance: NDArrayFloatType + residual_rms: float + + +def _filter_blob_features(features: list[NavFeature]) -> list[NavFeature]: + """Return the subset that carries a ``BODY_BLOB`` geometry payload.""" + return [ + f + for f in features + if f.feature_type is NavFeatureType.BODY_BLOB and isinstance(f.geometry, BodyBlobGeometry) + ] + + +def _clamp_bbox( + bbox_extfov_vu: tuple[int, int, int, int], + extfov_shape: tuple[int, int], +) -> tuple[int, int, int, int]: + """Clamp ``(v_min, u_min, v_max, u_max)`` to lie inside ``extfov_shape``.""" + v_min, u_min, v_max, u_max = bbox_extfov_vu + h, w = extfov_shape + return ( + max(0, int(v_min)), + max(0, int(u_min)), + min(int(h), int(v_max)), + min(int(w), int(u_max)), + ) + + +def _brightness_weighted_centroid( + image_ext: NDArrayFloatType, + image_noise_sigma: float, + geometry: BodyBlobGeometry, +) -> tuple[tuple[float, float] | None, float, int, tuple[int, int, int, int]]: + """Return the brightness-weighted centroid + signal stats for one blob. + + The centroid is computed over every above-noise pixel inside the + feature's predicted bounding box (the bbox includes per-body slop so + the actual body silhouette stays inside it under moderate SPICE + pointing error). Above-noise pixels are those exceeding + ``3 * image_noise_sigma``; background DN never biases the moment. + A blob whose bbox carries no above-noise pixels returns + ``(None, 0.0, 0, clamped_bbox)`` so the caller can drop it from the + joint fit and still log its bbox in the per-blob rejection line. + + Returns: + ``(centroid_vu, mean_signal_above_noise, n_lit_pixels, + clamped_bbox)``. ``centroid_vu`` is ``None`` when the blob has + no usable signal. + """ + extfov_shape = (image_ext.shape[0], image_ext.shape[1]) + clamped_bbox = _clamp_bbox(geometry.bbox_extfov_vu, extfov_shape) + v_min, u_min, v_max, u_max = clamped_bbox + if v_max <= v_min or u_max <= u_min: + return None, 0.0, 0, clamped_bbox + patch = image_ext[v_min:v_max, u_min:u_max] + noise_threshold = 3.0 * max(image_noise_sigma, 1e-9) + signal_mask: NDArrayBoolType = patch > noise_threshold + n_lit = int(signal_mask.sum()) + if n_lit == 0: + return None, 0.0, 0, clamped_bbox + weights = np.where(signal_mask, patch, 0.0) + total_weight = float(weights.sum()) + if total_weight <= 0.0: + return None, 0.0, 0, clamped_bbox + vs = np.arange(v_min, v_max, dtype=np.float64) + us = np.arange(u_min, u_max, dtype=np.float64) + centroid_v = float(np.sum(weights * vs[:, None]) / total_weight) + centroid_u = float(np.sum(weights * us[None, :]) / total_weight) + mean_signal = float(weights[signal_mask].mean()) + return (centroid_v, centroid_u), mean_signal, n_lit, clamped_bbox + + +def _collect_per_blob_residuals( + features: list[NavFeature], + image_ext: NDArrayFloatType, + image_noise_sigma: float, + logger: PdsLogger, +) -> _BlobResiduals: + """Extract the per-blob ``observed - predicted`` residuals + weights. + + Iterates the input features in order and computes a + brightness-weighted-moment centroid inside each predicted bbox. + Blobs with no above-noise signal in their bbox are dropped (and + logged at DEBUG with the bbox bounds and noise threshold so the + operator can tell why). The remaining blobs contribute to the + joint fit with weight ``N_lit * SNR^2 / radius_px^2`` per the + BODY_BLOB centroid CRLB. + """ + consumed: list[NavFeature] = [] + offsets_v: list[float] = [] + offsets_u: list[float] = [] + weights: list[float] = [] + snrs: list[float] = [] + extents: list[float] = [] + noise_threshold = 3.0 * max(image_noise_sigma, 1e-9) + for feature in features: + assert isinstance(feature.geometry, BodyBlobGeometry) + centroid, mean_signal, n_lit, clamped_bbox = _brightness_weighted_centroid( + image_ext, image_noise_sigma, feature.geometry + ) + if centroid is None: + logger.debug( + 'Blob %s has no above-noise signal in predicted bbox %s ' + '(noise threshold = %.4f DN); dropping', + feature.feature_id, + clamped_bbox, + noise_threshold, + ) + continue + pred_v, pred_u = feature.geometry.predicted_center_vu + obs_v, obs_u = centroid + dv = obs_v - pred_v + du = obs_u - pred_u + snr = mean_signal / max(image_noise_sigma, 1e-9) + # Per-blob weight is the inverse of the centroid CRLB + # variance: weight ~ N_lit * SNR^2 / R^2. The upstream + # emission gate guarantees ``predicted_diameter_px >= 8``, + # so the radius_px denominator is bounded away from zero + # and no floor is needed. + radius_px = feature.geometry.predicted_diameter_px / 2.0 + radius_sq = radius_px * radius_px + weight = float(n_lit) * snr * snr / radius_sq + consumed.append(feature) + offsets_v.append(dv) + offsets_u.append(du) + weights.append(max(weight, 1e-9)) + snrs.append(snr) + extents.append(float(feature.geometry.predicted_diameter_px)) + logger.debug( + 'Blob %s: predicted (%.2f, %.2f), observed (%.2f, %.2f), SNR %.2f, ' + 'N_lit %d, weight %.3g', + feature.feature_id, + pred_v, + pred_u, + obs_v, + obs_u, + snr, + n_lit, + weight, + ) + return _BlobResiduals( + consumed=consumed, + offsets_v=np.asarray(offsets_v, np.float64), + offsets_u=np.asarray(offsets_u, np.float64), + weights=np.asarray(weights, np.float64), + snrs=snrs, + extents=extents, + ) + + +def _joint_offset_from_residuals(residuals: _BlobResiduals) -> _JointFit: + """Solve the precision-weighted joint translation across the per-blob residuals.""" + offsets_v = residuals.offsets_v + offsets_u = residuals.offsets_u + weights = residuals.weights + total_weight = float(weights.sum()) + dv = float(np.sum(weights * offsets_v) / total_weight) + du = float(np.sum(weights * offsets_u) / total_weight) + res_norms = np.hypot(offsets_v - dv, offsets_u - du) + rms = float(np.sqrt(float(np.mean(res_norms * res_norms)))) + cov = _joint_covariance( + offsets_v=offsets_v, + offsets_u=offsets_u, + weights=weights, + dv=dv, + du=du, + ) + return _JointFit(dv=dv, du=du, covariance=cov, residual_rms=rms) + + +def _joint_covariance( + *, + offsets_v: NDArrayFloatType, + offsets_u: NDArrayFloatType, + weights: NDArrayFloatType, + dv: float, + du: float, +) -> NDArrayFloatType: + """Return the per-axis precision-weighted covariance of the joint fit. + + The covariance is diagonal: independent per-axis weighted-mean + variances ``1 / sum(weights)`` inflated by the residual scatter + when more than one blob participates. A single-blob fit reports + the inverse-precision floor; a multi-blob fit reports the actual + disagreement. + + The cross-term ``cov(v, u)`` is intentionally zero — per-axis + residuals are independent under the BODY_BLOB CRLB derivation, + and the precision-weighted ensemble combine downstream consumes + diagonals correctly. Future readers tempted to add + ``cov_vu = sum(w * res_v * res_u) / sum(w)``: that term has no + physical interpretation here because the per-axis errors come + from independent moment integrals along orthogonal axes. + """ + total_weight = float(weights.sum()) + floor = 1.0 / max(total_weight, 1e-12) + if offsets_v.size <= 1: + return float(floor) * np.eye(2, dtype=np.float64) + residuals_v = offsets_v - dv + residuals_u = offsets_u - du + var_v = max(float(np.sum(weights * residuals_v * residuals_v) / total_weight), floor) + var_u = max(float(np.sum(weights * residuals_u * residuals_u) / total_weight), floor) + return np.diag([var_v, var_u]).astype(np.float64) + + +class BodyBlobNav(NavTechnique): + """Body-blob brightness-weighted centroid translation fit. + + Class attributes: + accepts_feature_types: ``frozenset({BODY_BLOB})``. + requires_prior: ``False`` — the technique runs in pass 1. + """ + + name = 'BodyBlobNav' + accepts_feature_types = frozenset({NavFeatureType.BODY_BLOB}) + requires_prior = False + confidence_spec = _BODY_BLOB_CONFIDENCE_SPEC + confidence_attributes = frozenset( + { + 'at_edge', + 'body_snr_inside_predicted_bbox', + 'body_extent_px', + 'blob_count', + 'residual_px', + } + ) + + def __init__(self, *, config: Config | None = None) -> None: + super().__init__(config=config) + + def is_feasible(self, features: list[NavFeature]) -> NavFeasibilityReport: + """Return whether the input set carries any usable BODY_BLOB feature. + + Reads only feature metadata; never any pixels. The technique + requires at least one ``BODY_BLOB`` with a non-zero predicted + diameter — otherwise the centroid moment is degenerate. + """ + eligible = _eligible_blobs(features) + if not eligible: + return NavFeasibilityReport( + feasible=False, + reason='no_body_blob_features_with_predicted_diameter', + ) + return NavFeasibilityReport( + feasible=True, + reason='ok', + consumed_feature_count=len(eligible), + ) + + def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechniqueResult: + """Compute the joint translation that maps predicted to observed centroids. + + Parameters: + features: Feature list filtered to the technique's accepted + types. Blobs that fall outside the extfov or have no + above-noise signal in their predicted bbox are dropped. + context: Per-image NavContext. Reads ``image_ext``, + ``image_noise_sigma``, and ``obs.extfov_margin_vu``. + + Returns: + A ``NavTechniqueResult`` with the recovered offset, 2x2 + covariance, calibrated confidence, and a populated + :class:`BodyBlobDiagnostics`. + """ + with self.logger.open(f'TECHNIQUE: {self.name}'): + eligible = _eligible_blobs(features) + self.logger.info( + 'Consuming %d BODY_BLOB features (out of %d offered)', + len(eligible), + len(features), + ) + margin_v, margin_u = _search_window_for_obs(context) + self.logger.debug('Search window (v, u) = (%d, %d) px', margin_v, margin_u) + image_ext = np.asarray(context.image_ext, np.float64) + noise_sigma = float(max(context.image_noise_sigma, 1e-9)) + residuals = _collect_per_blob_residuals(eligible, image_ext, noise_sigma, self.logger) + if not residuals.consumed: + return self._fail_no_signal(features=eligible, noise_sigma=noise_sigma) + fit = _joint_offset_from_residuals(residuals) + at_edge = ( + abs(fit.dv - margin_v) <= AT_EDGE_TOLERANCE_PX + or abs(fit.dv + margin_v) <= AT_EDGE_TOLERANCE_PX + or abs(fit.du - margin_u) <= AT_EDGE_TOLERANCE_PX + or abs(fit.du + margin_u) <= AT_EDGE_TOLERANCE_PX + ) + mean_snr = float(np.mean(residuals.snrs)) + mean_extent = float(np.mean(residuals.extents)) + diagnostics = BodyBlobDiagnostics( + body_snr_inside_predicted_bbox=mean_snr, + body_extent_px=mean_extent, + blob_count=len(residuals.consumed), + residual_px=fit.residual_rms, + ) + assert self.confidence_spec is not None + confidence, breakdown = evaluate_sigmoid_combination( + self.confidence_spec, + _BlobConfidenceContext(at_edge=at_edge, diagnostics=diagnostics), + technique_name=self.name, + return_breakdown=True, + ) + log_confidence_breakdown(self.logger, breakdown) + self.logger.info( + 'Converged at offset (%.4f, %.4f) px, residual RMS %.4f px, mean SNR %.2f, ' + 'mean extent %.2f px, blobs %d, confidence %.4f', + fit.dv, + fit.du, + fit.residual_rms, + mean_snr, + mean_extent, + len(residuals.consumed), + float(confidence), + ) + if at_edge: + self.logger.info('Diagnostic flags: at_edge=%s', at_edge) + return NavTechniqueResult( + technique_name=self.name, + feature_ids=tuple(f.feature_id for f in residuals.consumed), + offset_px=(fit.dv, fit.du), + covariance_px2=fit.covariance, + confidence=float(confidence), + spurious=False, + at_edge=at_edge, + diagnostics=diagnostics, + ) + + def _fail_no_signal( + self, *, features: list[NavFeature], noise_sigma: float + ) -> NavTechniqueResult: + """Return a zero-confidence spurious result when no blob carries signal.""" + diagnostics = BodyBlobDiagnostics( + body_snr_inside_predicted_bbox=0.0, + body_extent_px=0.0, + blob_count=0, + residual_px=0.0, + ) + self.logger.info( + 'No BODY_BLOB feature carried above-noise signal in its predicted bbox ' + '(noise threshold = %.4f DN, %d candidate blob(s)); reporting spurious result', + 3.0 * noise_sigma, + len(features), + ) + return NavTechniqueResult( + technique_name=self.name, + feature_ids=tuple(f.feature_id for f in features), + offset_px=(0.0, 0.0), + covariance_px2=1e6 * np.eye(2, dtype=np.float64), + confidence=0.0, + spurious=True, + at_edge=False, + diagnostics=diagnostics, + ) + + +def _eligible_blobs(features: list[NavFeature]) -> list[NavFeature]: + """Filter the input set to BODY_BLOB features with non-zero diameter.""" + blob_features = _filter_blob_features(features) + return [ + f + for f in blob_features + if isinstance(f.geometry, BodyBlobGeometry) and f.geometry.predicted_diameter_px > 0.0 + ] + + +class _BlobConfidenceContext: + """Adapter binding ``BodyBlobDiagnostics`` plus ``at_edge`` for confidence eval.""" + + def __init__(self, *, at_edge: bool, diagnostics: BodyBlobDiagnostics) -> None: + self.at_edge = at_edge + self.body_snr_inside_predicted_bbox = diagnostics.body_snr_inside_predicted_bbox + self.body_extent_px = diagnostics.body_extent_px + self.blob_count = float(diagnostics.blob_count) + self.residual_px = diagnostics.residual_px + + +def _search_window_for_obs(context: NavContext) -> tuple[int, int]: + """Return the ``(margin_v, margin_u)`` search window for at-edge detection.""" + obs = context.obs + margin = getattr(obs, 'extfov_margin_vu', None) + if margin is None: + return (32, 32) + return (int(margin[0]), int(margin[1])) diff --git a/src/nav/nav_technique/nav_technique_body_disc.py b/src/nav/nav_technique/nav_technique_body_disc.py new file mode 100644 index 0000000..117c33b --- /dev/null +++ b/src/nav/nav_technique/nav_technique_body_disc.py @@ -0,0 +1,304 @@ +"""``BodyDiscCorrelateNav`` — full-disc NCC translation fit. + +Consumes every ``BODY_DISC`` feature in the input set, fuses the per-body +templates into a single composite by Z-buffer paint (closer body's pixels +overwrite farther body's), runs the existing pyramid kpeaks NCC against +the composite, and returns one combined translation. ``use_gradient`` +defaults to ``'auto'`` so the NCC self-selects raw vs gradient mode per +image — raw wins on smooth Lambert-shaded discs that fill the FOV; +gradient wins when only the limb carries unique-alignment signal. + +Multi-body composites improve disambiguation: with ``N`` bodies the +correlation peak's SNR grows roughly as ``sqrt(N)`` if backgrounds are +independent, and the joint geometric constraint removes the +"swap moon assignments" mode-failure that plagues per-body solo +correlation. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from nav.config import Config +from nav.feature.composition import compose_template_features +from nav.feature.feature import NavFeature +from nav.feature.feature_type import NavFeatureType +from nav.feature.geometry import BodyDiscGeometry +from nav.nav_technique.confidence import ( + ConfidenceSpec, + ConfidenceTerm, + evaluate_sigmoid_combination, +) +from nav.nav_technique.diagnostics import BodyDiscDiagnostics +from nav.nav_technique.feasibility import NavFeasibilityReport +from nav.nav_technique.nav_technique import NavTechnique, log_confidence_breakdown +from nav.nav_technique.technique_result import NavTechniqueResult +from nav.support.correlate import navigate_with_pyramid_kpeaks + +if TYPE_CHECKING: # pragma: no cover - typing-only import + from nav.nav_orchestrator.nav_context import NavContext + +__all__ = ['BodyDiscCorrelateNav'] + + +_BODY_DISC_CONFIDENCE_SPEC = ConfidenceSpec( + alpha0=-2.0, + terms=( + # PSR-style quality measure of the chosen NCC peak. Healthy + # body-disc fits report quality 6-15; the divisor maps that range + # onto the sigmoid's responsive interval. + ConfidenceTerm(feature='ncc_peak', alpha=1.5, divisor=6.0, cap_at=1.0), + # Pyramid-level consistency: low values (<= 0.5 px disagreement + # across pyramid levels) indicate a globally unambiguous peak. + ConfidenceTerm(feature='consistency_px', alpha=-1.0, divisor=2.0), + # Reward additional bodies in the composite (joint geometric + # constraint sharpens the peak); cap so a 3-body scene saturates. + ConfidenceTerm(feature='body_count', alpha=0.4, divisor=3.0, cap_at=1.0), + # Peak-to-runner-up ratio: a sharp, unambiguous correlation + # peak has ratio >> 1 vs the next-best peak. ``alpha=0.0`` + # for now so the term carries no weight pending calibration; + # the wiring is in place so once a calibration sweep tunes + # the alpha the formula picks it up without code changes. + ConfidenceTerm( + feature='peak_to_runner_up_ratio', + alpha=0.0, + divisor=2.0, + cap_at=1.0, + ), + ), + hard_zero_if={'at_edge': True, 'spurious': True}, +) +"""Default confidence spec for the body-disc NCC technique. + +Coefficients are placeholders pending calibration against the +operator-curated image library. Future config-file lookups will read +``config_510_techniques.yaml.body_disc_correlate`` once that file +ships; until then the constants live here. +""" + + +def _filter_disc_features(features: list[NavFeature]) -> list[NavFeature]: + """Return the subset that carries a ``BODY_DISC`` template payload.""" + return [ + f + for f in features + if f.feature_type is NavFeatureType.BODY_DISC + and isinstance(f.geometry, BodyDiscGeometry) + and f.template_img is not None + and f.template_mask is not None + ] + + +def _peak_to_runner_up_ratio(top_k_peaks: list[tuple[float, float, float]]) -> float: + """Return the ratio of the winning peak's quality to the runner-up's. + + ``top_k_peaks`` is ``[(quality, dv, du), ...]`` sorted by quality + descending (the convention :func:`navigate_with_pyramid_kpeaks` + uses). Returns ``1.0`` when only one peak survives non-maximum + suppression — which is what an unambiguous correlation looks + like, so a value at or above 1.0 is the "good" tail. Returns + ``0.0`` when no peaks are present. Negative-quality runners-up + (rare; happens with the prior penalty) are floored at a small + positive value so the ratio stays well-defined. + """ + if not top_k_peaks: + return 0.0 + if len(top_k_peaks) == 1: + return 1.0 + winner_q = top_k_peaks[0][0] + runner_q = top_k_peaks[1][0] + if runner_q <= 1e-9: + return float(max(winner_q, 0.0)) / 1e-9 + return float(winner_q) / float(runner_q) + + +class BodyDiscCorrelateNav(NavTechnique): + """Body-disc full-disc NCC translation fit (multi-body, Z-buffer paint). + + Class attributes: + accepts_feature_types: ``frozenset({BODY_DISC})``. + requires_prior: ``False`` — the technique runs in pass 1. + """ + + name = 'BodyDiscCorrelateNav' + accepts_feature_types = frozenset({NavFeatureType.BODY_DISC}) + requires_prior = False + confidence_spec = _BODY_DISC_CONFIDENCE_SPEC + confidence_attributes = frozenset( + { + 'at_edge', + 'spurious', + 'ncc_peak', + 'peak_to_runner_up_ratio', + 'consistency_px', + 'used_gradient', + 'body_count', + } + ) + + def __init__(self, *, config: Config | None = None) -> None: + super().__init__(config=config) + + def is_feasible(self, features: list[NavFeature]) -> NavFeasibilityReport: + """Return whether the input set carries any usable BODY_DISC feature. + + Reads only feature metadata — never any pixels — so the report is + cheap to obtain even on large feature sets. + + Parameters: + features: Feature list filtered to this technique's accepted + types. + + Returns: + ``NavFeasibilityReport`` with ``feasible=True`` iff at least + one ``BODY_DISC`` feature carries a template payload. + """ + eligible = _filter_disc_features(features) + if not eligible: + return NavFeasibilityReport( + feasible=False, + reason='no_body_disc_features_with_template', + ) + return NavFeasibilityReport( + feasible=True, + reason='ok', + consumed_feature_count=len(eligible), + ) + + def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechniqueResult: + """Compute the joint-translation offset from the input BODY_DISC templates. + + Parameters: + features: Feature list filtered to this technique's accepted + types. Features without a template payload are dropped + before fitting. + context: Per-image NavContext. Reads ``image_ext``, + ``sensor_mask_ext``, and ``obs.extfov_margin_vu``. + + Returns: + A ``NavTechniqueResult`` with the recovered offset, 2x2 + covariance, calibrated confidence, and a populated + :class:`BodyDiscDiagnostics`. + """ + with self.logger.open(f'TECHNIQUE: {self.name}'): + eligible = _filter_disc_features(features) + self.logger.info( + 'Consuming %d BODY_DISC features (out of %d offered)', + len(eligible), + len(features), + ) + extfov_shape = context.image_ext.shape + template_img, template_mask = compose_template_features(eligible, extfov_shape) + margin_v, margin_u = _search_window_for_obs(context) + up_factor = self._upsample_factor() + self.logger.debug( + 'Composite template: %d painted pixels; search window (v, u) = (%d, %d) px; ' + 'upsample factor = %d', + int(template_mask.sum()), + margin_v, + margin_u, + up_factor, + ) + ncc_result = navigate_with_pyramid_kpeaks( + image=context.image_ext, + model=template_img, + mask=template_mask, + upsample_factor=up_factor, + max_offset_vu=(margin_v, margin_u), + data_mask=context.sensor_mask_ext, + use_gradient='auto', + logger=self.logger, + ) + dv = float(ncc_result['offset'][0]) + du = float(ncc_result['offset'][1]) + covariance = np.asarray(ncc_result['cov'], np.float64) + if covariance.shape != (2, 2): + covariance = covariance[:2, :2] + spurious = bool(ncc_result['spurious']) + at_edge = bool(ncc_result['at_edge']) + quality = float(ncc_result['quality']) + consistency = float(ncc_result['consistency']) + used_gradient = bool(ncc_result.get('used_gradient', False)) + top_k_peaks = ncc_result.get('top_k_peaks', []) + diagnostics = BodyDiscDiagnostics( + ncc_peak=quality, + peak_to_runner_up_ratio=_peak_to_runner_up_ratio(top_k_peaks), + consistency_px=consistency, + used_gradient=used_gradient, + body_count=len(eligible), + ) + assert self.confidence_spec is not None # set as class attribute + confidence, breakdown = evaluate_sigmoid_combination( + self.confidence_spec, + _DiscConfidenceContext(at_edge=at_edge, spurious=spurious, diagnostics=diagnostics), + technique_name=self.name, + return_breakdown=True, + ) + log_confidence_breakdown(self.logger, breakdown) + self.logger.info( + 'Converged at offset (%.4f, %.4f) px, quality %.3f, consistency %.3f, ' + 'mode=%s, bodies=%d, confidence %.4f', + dv, + du, + quality, + consistency, + 'gradient' if used_gradient else 'raw', + len(eligible), + float(confidence), + ) + if spurious or at_edge: + self.logger.info('Diagnostic flags: spurious=%s, at_edge=%s', spurious, at_edge) + return NavTechniqueResult( + technique_name=self.name, + feature_ids=tuple(f.feature_id for f in eligible), + offset_px=(dv, du), + covariance_px2=covariance, + confidence=float(confidence), + spurious=spurious, + at_edge=at_edge, + diagnostics=diagnostics, + ) + + def _upsample_factor(self) -> int: + """Return the FFT upsample factor configured under ``config.offset``.""" + offset_block = getattr(self.config, 'offset', None) + if offset_block is None: + return 128 + return int(getattr(offset_block, 'correlation_fft_upsample_factor', 128)) + + +class _DiscConfidenceContext: + """Adapter binding ``BodyDiscDiagnostics`` plus ``at_edge`` / ``spurious``. + + The shared :func:`evaluate_sigmoid_combination` helper accepts any + object whose attributes match the spec's term names. ``at_edge`` and + ``spurious`` are not part of ``BodyDiscDiagnostics`` (they live on + ``NavTechniqueResult``) so this small adapter exposes both alongside + the diagnostic fields the spec consumes. + """ + + def __init__(self, *, at_edge: bool, spurious: bool, diagnostics: BodyDiscDiagnostics) -> None: + self.at_edge = at_edge + self.spurious = spurious + self.ncc_peak = diagnostics.ncc_peak + self.peak_to_runner_up_ratio = diagnostics.peak_to_runner_up_ratio + self.consistency_px = diagnostics.consistency_px + self.used_gradient = diagnostics.used_gradient + self.body_count = float(diagnostics.body_count) + + +def _search_window_for_obs(context: NavContext) -> tuple[int, int]: + """Return the ``(margin_v, margin_u)`` search window for the NCC. + + Mirrors the helper used by the DT techniques: the technique respects + the per-instrument extfov margin attached to the observation; if the + obs does not expose that attribute (test fixtures) a 32 x 32 fallback + is used so the technique still runs end-to-end. + """ + obs = context.obs + margin = getattr(obs, 'extfov_margin_vu', None) + if margin is None: + return (32, 32) + return (int(margin[0]), int(margin[1])) diff --git a/src/nav/nav_technique/nav_technique_body_limb.py b/src/nav/nav_technique/nav_technique_body_limb.py index d6eb3e4..76fff43 100644 --- a/src/nav/nav_technique/nav_technique_body_limb.py +++ b/src/nav/nav_technique/nav_technique_body_limb.py @@ -26,6 +26,7 @@ ) from nav.nav_technique.diagnostics import BodyLimbDiagnostics from nav.nav_technique.dt_fitting import ( + AT_EDGE_TOLERANCE_PX, coarse_ncc_search, lm_subpixel_refine, ) @@ -79,17 +80,6 @@ """ -_AT_EDGE_TOLERANCE_PX: float = 1.0 -"""Pixels of slack around the search-window axis bounds for at-edge detection. - -A converged offset whose absolute distance from any axis bound (``+/-margin_v``, -``+/-margin_u``) falls within this tolerance is flagged ``at_edge=True`` and -forced to zero confidence by the technique's ``hard_zero_if`` gate. One pixel -matches the bilinear DT half-cell width: any closer to the boundary and the -LM gradient information is unreliable. -""" - - _BODY_LIMB_CONFIDENCE_SPEC = ConfidenceSpec( alpha0=-1.0, terms=( @@ -300,10 +290,10 @@ def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechni ) dv_final, du_final = result.offset_vu at_edge = ( - abs(dv_final - margin_v) <= _AT_EDGE_TOLERANCE_PX - or abs(dv_final + margin_v) <= _AT_EDGE_TOLERANCE_PX - or abs(du_final - margin_u) <= _AT_EDGE_TOLERANCE_PX - or abs(du_final + margin_u) <= _AT_EDGE_TOLERANCE_PX + abs(dv_final - margin_v) <= AT_EDGE_TOLERANCE_PX + or abs(dv_final + margin_v) <= AT_EDGE_TOLERANCE_PX + or abs(du_final - margin_u) <= AT_EDGE_TOLERANCE_PX + or abs(du_final + margin_u) <= AT_EDGE_TOLERANCE_PX ) sigma_min_px = float(sigmas.min()) if sigmas.size else 1.0 n_vertices = int(vertices.shape[0]) diff --git a/src/nav/nav_technique/nav_technique_body_terminator.py b/src/nav/nav_technique/nav_technique_body_terminator.py index 55cf29c..81aa634 100644 --- a/src/nav/nav_technique/nav_technique_body_terminator.py +++ b/src/nav/nav_technique/nav_technique_body_terminator.py @@ -31,6 +31,7 @@ ) from nav.nav_technique.diagnostics import BodyTerminatorDiagnostics from nav.nav_technique.dt_fitting import ( + AT_EDGE_TOLERANCE_PX, coarse_ncc_search, lm_subpixel_refine, ) @@ -61,17 +62,6 @@ """Below this Tukey-inlier count the final fit is flagged spurious.""" -_AT_EDGE_TOLERANCE_PX: float = 1.0 -"""Pixels of slack around the search-window axis bounds for at-edge detection. - -A converged offset whose absolute distance from any axis bound (``+/-margin_v``, -``+/-margin_u``) falls within this tolerance is flagged ``at_edge=True`` and -forced to zero confidence by the technique's ``hard_zero_if`` gate. One pixel -matches the bilinear DT half-cell width: any closer to the boundary and the -LM gradient information is unreliable. -""" - - _BODY_TERMINATOR_CONFIDENCE_SPEC = ConfidenceSpec( alpha0=-1.0, terms=( @@ -301,10 +291,10 @@ def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechni ) dv_final, du_final = result.offset_vu at_edge = ( - abs(dv_final - margin_v) <= _AT_EDGE_TOLERANCE_PX - or abs(dv_final + margin_v) <= _AT_EDGE_TOLERANCE_PX - or abs(du_final - margin_u) <= _AT_EDGE_TOLERANCE_PX - or abs(du_final + margin_u) <= _AT_EDGE_TOLERANCE_PX + abs(dv_final - margin_v) <= AT_EDGE_TOLERANCE_PX + or abs(dv_final + margin_v) <= AT_EDGE_TOLERANCE_PX + or abs(du_final - margin_u) <= AT_EDGE_TOLERANCE_PX + or abs(du_final + margin_u) <= AT_EDGE_TOLERANCE_PX ) sigma_min_px = float(sigmas.min()) if sigmas.size else 1.0 spurious = ( diff --git a/src/nav/support/correlate.py b/src/nav/support/correlate.py index 0105e7a..7a0b523 100644 --- a/src/nav/support/correlate.py +++ b/src/nav/support/correlate.py @@ -622,8 +622,15 @@ def navigate_single_scale_kpeaks( 'cov': np.diag([1e6, 1e6]), 'sigma_xy': (1e3, 1e3), 'quality': -np.inf, + 'all_candidates': [], } - return max(candidates, key=lambda r: r['quality']) + winner = max(candidates, key=lambda r: r['quality']) + # Carry every evaluated candidate so callers that want to inspect + # runner-up peaks (e.g. peak-to-runner-up ratio diagnostics) can do + # so without re-running the correlation. Sorted by quality desc so + # ``all_candidates[0]`` is the winner and ``[1:]`` are runner-ups. + winner['all_candidates'] = sorted(candidates, key=lambda r: r['quality'], reverse=True) + return winner # ============================================================== @@ -799,6 +806,7 @@ def navigate_with_pyramid_kpeaks( result_grad['spurious'], result_grad['at_edge'], ) + winner['used_gradient'] = chosen == 'gradient' return winner logger.debug('Navigating with pyramid kpeaks:') @@ -937,6 +945,21 @@ def navigate_with_pyramid_kpeaks( 'Correlation peak within 2 pixels of max-offset window edge; marking result spurious' ) + # Surface the per-peak telemetry from the final-pass single-scale + # call so callers can derive a peak-to-runner-up ratio without + # re-running the correlation. Each entry is + # ``(quality, offset_dv, offset_du)``; the winner is index 0 and + # any runner-ups follow in descending quality order. + all_candidates = result.get('all_candidates', []) + top_k_peaks: list[tuple[float, float, float]] = [ + ( + float(c['quality']), + float(c['offset'][0]), + float(c['offset'][1]), + ) + for c in all_candidates + ] + ret = { 'offset': result['offset'], 'cov': result['cov'], @@ -946,6 +969,8 @@ def navigate_with_pyramid_kpeaks( 'consistency': consistency, 'spurious': bool(spurious), 'at_edge': bool(at_edge), + 'used_gradient': bool(use_gradient), + 'top_k_peaks': top_k_peaks, } logger.debug( diff --git a/tests/nav/nav_model/test_body_shape.py b/tests/nav/nav_model/test_body_shape.py index 19a001a..8847078 100644 --- a/tests/nav/nav_model/test_body_shape.py +++ b/tests/nav/nav_model/test_body_shape.py @@ -35,7 +35,7 @@ def test_default_body_shape_values() -> None: assert DEFAULT_BODY_SHAPE.crater_scale_km == 5.0 assert DEFAULT_BODY_SHAPE.albedo_variation == 0.15 assert DEFAULT_BODY_SHAPE.spice_orbital_residual_km == 2.0 - assert DEFAULT_BODY_SHAPE.min_blob_diameter_px == 5.0 + assert DEFAULT_BODY_SHAPE.min_blob_diameter_px == 8.0 def test_body_shape_dataclass_is_frozen() -> None: diff --git a/tests/nav/nav_model/test_nav_model_body.py b/tests/nav/nav_model/test_nav_model_body.py index 68c3143..bcc6670 100644 --- a/tests/nav/nav_model/test_nav_model_body.py +++ b/tests/nav/nav_model/test_nav_model_body.py @@ -39,7 +39,7 @@ def test_constants_have_design_values() -> None: """Module-level constants match the design's defaults.""" assert pytest.approx(0.05) == BODY_POSITION_SLOP_FRAC - assert pytest.approx(2.0) == LIMB_ARC_MAX_UNCERTAINTY_PX + assert pytest.approx(3.0) == LIMB_ARC_MAX_UNCERTAINTY_PX assert pytest.approx(0.4) == BODY_DISC_MIN_VISIBLE_LIT_FRACTION assert pytest.approx(0.3) == BODY_DISC_MAX_OVERFLOW_FRACTION assert TERMINATOR_MIN_VERTICES == 8 diff --git a/tests/nav/nav_model/test_nav_model_body_integration.py b/tests/nav/nav_model/test_nav_model_body_integration.py index 82b1f28..478496c 100644 --- a/tests/nav/nav_model/test_nav_model_body_integration.py +++ b/tests/nav/nav_model/test_nav_model_body_integration.py @@ -227,16 +227,18 @@ def test_to_features_skips_terminator_when_polyline_too_short(fake_obs: FakeObs) def test_to_features_limb_uncertainty_at_threshold(fake_obs: FakeObs) -> None: - """A body sitting exactly at the LIMB_ARC threshold still emits the arc.""" - # ellipsoid_residual_km = 50 (gas-giant default -- SATURN is mapped to it), - # so limb_uncertainty_px = ellipsoid_residual_km / km_per_pixel_at_limb. - # km_per_pixel_at_limb=25 yields uncertainty=2.0 == LIMB_ARC_MAX_UNCERTAINTY_PX. - saturn_model = _build_body(obs=fake_obs, body_name='SATURN', km_per_pixel_at_limb=25.0) + """A body sitting exactly at the LIMB_ARC threshold still emits the arc. + + With ``LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0`` and Saturn's + ``ellipsoid_residual_km = 50`` (gas-giant default), the threshold + is reached at ``km_per_pixel_at_limb = 50 / 3.0 ~= 16.67``. + """ + # km_per_pixel_at_limb=17 yields uncertainty ~ 2.94 < 3.0 -> LIMB_ARC. + saturn_model = _build_body(obs=fake_obs, body_name='SATURN', km_per_pixel_at_limb=17.0) features = saturn_model.to_features(cast(Any, None)) assert any(f.feature_type is NavFeatureType.LIMB_ARC for f in features) - # Now push it over -- 26 km/px -> uncertainty < 2 still passes; 24 km/px -> - # uncertainty > 2 forces the blob branch. - saturn_blob = _build_body(obs=fake_obs, body_name='SATURN', km_per_pixel_at_limb=24.0) + # km_per_pixel_at_limb=15 yields uncertainty ~ 3.33 > 3.0 -> blob branch. + saturn_blob = _build_body(obs=fake_obs, body_name='SATURN', km_per_pixel_at_limb=15.0) features_blob = saturn_blob.to_features(cast(Any, None)) types = {f.feature_type for f in features_blob} assert NavFeatureType.LIMB_ARC not in types diff --git a/tests/nav/nav_orchestrator/test_orchestrator.py b/tests/nav/nav_orchestrator/test_orchestrator.py index 7c5065b..ef4c354 100644 --- a/tests/nav/nav_orchestrator/test_orchestrator.py +++ b/tests/nav/nav_orchestrator/test_orchestrator.py @@ -210,6 +210,61 @@ def test_orchestrator_only_techniques_filter_drops_techniques( assert result.status_reason == NavStatusReason.NO_FEASIBLE_TECHNIQUES +def test_orchestrator_only_models_mixed_include_exclude(fake_obs: _FakeObs) -> None: + """Mixed include/exclude patterns on ``only_models`` apply both gates. + + ``only_models`` accepts the same glob-with-negation grammar as + ``only_techniques``: include patterns admit every match; the + leading-bang exclusion drops any name matching the exclude + pattern, applied after inclusion. + """ + obs = fake_obs + model = _FakeStarModel(obs, feature_count=3) + # Include everything ('*') but exclude names starting with 'st' (the + # stars model). No models survive, so feature extraction yields the + # NO_FEATURES_EXTRACTED status the single-pattern test already covers. + orch = NavOrchestrator([model], only_models=['*', '!st*']) + result = orch.navigate(obs) # type: ignore[arg-type] + assert result.status == 'failed' + assert result.status_reason == NavStatusReason.NO_FEATURES_EXTRACTED + + +def test_orchestrator_only_models_mixed_keeps_matching_inclusion( + fake_obs: _FakeObs, +) -> None: + """Mixed pattern ``['stars', '!ring*']`` keeps ``stars`` (no exclusion match).""" + obs = fake_obs + model = _FakeStarModel(obs, feature_count=3) + # Include only 'stars'; exclude any 'ring*' (no match — kept). + orch = NavOrchestrator( + [model], + only_models=['stars', '!ring*'], + only_techniques=['_FakeStarTechnique'], + ) + result = orch.navigate(obs) # type: ignore[arg-type] + assert result.status == 'ok' + + +def test_orchestrator_only_techniques_mixed_include_exclude( + fake_obs: _FakeObs, +) -> None: + """Mixed include/exclude patterns on ``only_techniques`` apply both gates.""" + obs = fake_obs + model = _FakeStarModel(obs, feature_count=3) + # Include everything but exclude techniques whose name contains 'Pass'. + # ``_FakeStarTechnique`` survives; ``_PassTwoTechnique`` (registered + # later in this module) does not. + orch = NavOrchestrator( + [model], + only_techniques=['*', '!*Pass*'], + ) + result = orch.navigate(obs) # type: ignore[arg-type] + assert result.status == 'ok' + technique_names = {t.technique_name for t in result.per_technique} + assert '_FakeStarTechnique' in technique_names + assert '_PassTwoTechnique' not in technique_names + + def test_orchestrator_marks_technique_results_in_inventory( fake_obs: _FakeObs, ) -> None: diff --git a/tests/nav/nav_technique/test_nav_technique_body_blob.py b/tests/nav/nav_technique/test_nav_technique_body_blob.py new file mode 100644 index 0000000..7f41031 --- /dev/null +++ b/tests/nav/nav_technique/test_nav_technique_body_blob.py @@ -0,0 +1,263 @@ +"""End-to-end tests for ``BodyBlobNav``.""" + +from __future__ import annotations + +import numpy as np +import pytest +from tests.nav.nav_technique.conftest import ( + DiscImageFactory, + NavContextFactory, +) + +from nav.feature.feature import NavFeature, NavReliabilityBreakdown +from nav.feature.feature_type import NavFeatureType +from nav.feature.flags import BodyBlobFlags +from nav.feature.geometry import BodyBlobGeometry +from nav.nav_technique.diagnostics import BodyBlobDiagnostics +from nav.nav_technique.nav_technique_body_blob import BodyBlobNav +from nav.support.filters import NavFilterKind, NavFilterSpec + + +def _make_blob_feature( + body_name: str, + *, + predicted_center_vu: tuple[float, float], + predicted_diameter_px: float, + bbox_pad: int = 4, +) -> NavFeature: + """Build a BODY_BLOB feature whose bbox tightly bounds the predicted disc.""" + radius = predicted_diameter_px / 2.0 + v_min = int(np.floor(predicted_center_vu[0] - radius - bbox_pad)) + u_min = int(np.floor(predicted_center_vu[1] - radius - bbox_pad)) + v_max = int(np.ceil(predicted_center_vu[0] + radius + bbox_pad)) + u_max = int(np.ceil(predicted_center_vu[1] + radius + bbox_pad)) + sigma_centroid = max(predicted_diameter_px / 6.0, 0.5) + cov = (sigma_centroid * sigma_centroid) * np.eye(2, dtype=np.float64) + return NavFeature( + feature_id=f'body_blob:{body_name}', + feature_type=NavFeatureType.BODY_BLOB, + source_model='body', + geometry=BodyBlobGeometry( + predicted_center_vu=predicted_center_vu, + bbox_extfov_vu=(v_min, u_min, v_max, u_max), + predicted_diameter_px=predicted_diameter_px, + ), + subject_range_km=5.0e5, + position_cov_px=cov, + intensity_sigma_rel=0.0, + preferred_filter=NavFilterSpec(kind=NavFilterKind.NONE), + reliability=0.4, + reliability_reasons=NavReliabilityBreakdown( + blob_snr=0.5, + blob_extent_px=predicted_diameter_px / 30.0, + ), + usable_types=frozenset({NavFeatureType.BODY_BLOB}), + flags=BodyBlobFlags(body_name=body_name, predicted_diameter_px=predicted_diameter_px), + ) + + +def test_body_blob_recovers_planted_offset_single_blob( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """A single bright disc + blob feature predicting an offset center recovers offset.""" + shape = (200, 200) + actual_center = (100.0, 100.0) + radius = 8.0 + image = disc_image(shape, actual_center, radius) + planted_dv, planted_du = 2.0, -3.0 + pred_center = (actual_center[0] - planted_dv, actual_center[1] - planted_du) + feature = _make_blob_feature( + 'moonA', + predicted_center_vu=pred_center, + predicted_diameter_px=2.0 * radius, + ) + technique = BodyBlobNav() + context = make_nav_context(image) + feasibility = technique.is_feasible([feature]) + assert feasibility.feasible is True + result = technique.navigate([feature], context) + assert result.offset_px[0] == pytest.approx(planted_dv, abs=0.5) + assert result.offset_px[1] == pytest.approx(planted_du, abs=0.5) + assert isinstance(result.diagnostics, BodyBlobDiagnostics) + assert result.diagnostics.blob_count == 1 + # Hard cap at 0.4 — the technique cannot dominate the ensemble + # even with perfect inputs. + assert result.confidence <= 0.4 + 1e-12 + + +def test_body_blob_multi_body_least_squares_average( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """Two blob features with the same planted offset average to that offset.""" + shape = (220, 220) + radius = 7.0 + actual_centers = [(60.0, 70.0), (150.0, 140.0)] + image = np.zeros(shape, dtype=np.float64) + for c in actual_centers: + image += disc_image(shape, c, radius) + image = np.clip(image, 0.0, 100.0) + planted = (1.0, 1.5) + features = [ + _make_blob_feature( + f'moon_{i}', + predicted_center_vu=(c[0] - planted[0], c[1] - planted[1]), + predicted_diameter_px=2.0 * radius, + ) + for i, c in enumerate(actual_centers) + ] + technique = BodyBlobNav() + context = make_nav_context(image) + result = technique.navigate(features, context) + assert result.offset_px[0] == pytest.approx(planted[0], abs=0.5) + assert result.offset_px[1] == pytest.approx(planted[1], abs=0.5) + assert isinstance(result.diagnostics, BodyBlobDiagnostics) + assert result.diagnostics.blob_count == 2 + + +def test_body_blob_returns_zero_confidence_when_image_blank( + make_nav_context: NavContextFactory, +) -> None: + """A blob feature whose predicted bbox lies in a blank image is dropped.""" + shape = (200, 200) + image = np.zeros(shape, dtype=np.float64) + feature = _make_blob_feature( + 'moonA', + predicted_center_vu=(100.0, 100.0), + predicted_diameter_px=20.0, + ) + technique = BodyBlobNav() + context = make_nav_context(image) + result = technique.navigate([feature], context) + assert result.spurious is True + assert result.confidence == pytest.approx(0.0) + assert isinstance(result.diagnostics, BodyBlobDiagnostics) + assert result.diagnostics.blob_count == 0 + + +def test_body_blob_infeasible_on_empty_input() -> None: + technique = BodyBlobNav() + report = technique.is_feasible([]) + assert report.feasible is False + assert 'no_body_blob_features' in report.reason + + +def test_body_blob_infeasible_on_zero_diameter() -> None: + feature = NavFeature( + feature_id='body_blob:zero', + feature_type=NavFeatureType.BODY_BLOB, + source_model='body', + geometry=BodyBlobGeometry( + predicted_center_vu=(50.0, 50.0), + bbox_extfov_vu=(40, 40, 60, 60), + predicted_diameter_px=0.0, + ), + subject_range_km=1.0e6, + position_cov_px=None, + intensity_sigma_rel=0.0, + preferred_filter=NavFilterSpec(kind=NavFilterKind.NONE), + reliability=0.1, + reliability_reasons=NavReliabilityBreakdown(blob_snr=0.0), + usable_types=frozenset({NavFeatureType.BODY_BLOB}), + flags=BodyBlobFlags(body_name='zero', predicted_diameter_px=0.0), + ) + technique = BodyBlobNav() + report = technique.is_feasible([feature]) + assert report.feasible is False + + +def test_body_blob_confidence_capped_at_0_4( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """Even an ideal multi-blob fit cannot exceed 0.4 confidence.""" + shape = (240, 240) + radius = 12.0 + centers = [(60.0, 60.0), (60.0, 180.0), (180.0, 60.0), (180.0, 180.0)] + image = np.zeros(shape, dtype=np.float64) + for c in centers: + image += disc_image(shape, c, radius) + image = np.clip(image, 0.0, 100.0) + features = [ + _make_blob_feature( + f'moon_{i}', + predicted_center_vu=c, + predicted_diameter_px=2.0 * radius, + ) + for i, c in enumerate(centers) + ] + technique = BodyBlobNav() + context = make_nav_context(image) + result = technique.navigate(features, context) + assert result.confidence <= 0.4 + 1e-12 + + +def test_body_blob_registered_with_navtechnique_registry() -> None: + from nav.nav_technique.nav_technique import NavTechnique + + assert BodyBlobNav in NavTechnique._registry + + +def test_body_blob_marks_at_edge_when_centroid_hits_window( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """A converged offset within ``AT_EDGE_TOLERANCE_PX`` of the search-window + edge is flagged ``at_edge=True`` and forced to zero confidence by the + ``hard_zero_if`` gate. + """ + shape = (200, 200) + margin_v = 6 + margin_u = 6 + actual_center = (100.0, 100.0) + radius = 8.0 + image = disc_image(shape, actual_center, radius) + # Plant the predicted center exactly ``margin_v`` rows above the + # actual center so the recovered offset lands on the search-window + # boundary. + pred_center = (actual_center[0] - float(margin_v), actual_center[1]) + feature = _make_blob_feature( + 'edge_moon', + predicted_center_vu=pred_center, + predicted_diameter_px=2.0 * radius, + ) + technique = BodyBlobNav() + context = make_nav_context(image, extfov_margin_vu=(margin_v, margin_u)) + result = technique.navigate([feature], context) + assert result.at_edge is True + assert result.confidence == pytest.approx(0.0) + + +def test_body_blob_diagnostics_records_residual_and_snr( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """Multi-blob agreement reports sub-pixel residual scatter and high SNR. + + The two blobs share the same planted offset so per-blob residuals + around the joint mean must be sub-pixel; the bright synthetic discs + push mean SNR well above 5. + """ + shape = (220, 220) + radius = 7.0 + actual_centers = [(60.0, 70.0), (150.0, 140.0)] + image = np.zeros(shape, dtype=np.float64) + for c in actual_centers: + image += disc_image(shape, c, radius) + image = np.clip(image, 0.0, 100.0) + planted = (1.0, 1.5) + features = [ + _make_blob_feature( + f'moon_{i}', + predicted_center_vu=(c[0] - planted[0], c[1] - planted[1]), + predicted_diameter_px=2.0 * radius, + ) + for i, c in enumerate(actual_centers) + ] + technique = BodyBlobNav() + context = make_nav_context(image) + result = technique.navigate(features, context) + assert isinstance(result.diagnostics, BodyBlobDiagnostics) + assert result.diagnostics.residual_px < 0.5 + assert result.diagnostics.body_snr_inside_predicted_bbox > 5.0 diff --git a/tests/nav/nav_technique/test_nav_technique_body_disc.py b/tests/nav/nav_technique/test_nav_technique_body_disc.py new file mode 100644 index 0000000..de9f3aa --- /dev/null +++ b/tests/nav/nav_technique/test_nav_technique_body_disc.py @@ -0,0 +1,289 @@ +"""End-to-end tests for ``BodyDiscCorrelateNav``.""" + +from __future__ import annotations + +import numpy as np +import pytest +from tests.nav.nav_technique.conftest import ( + DiscImageFactory, + NavContextFactory, +) + +from nav.feature.feature import NavFeature, NavReliabilityBreakdown +from nav.feature.feature_type import NavFeatureType +from nav.feature.flags import BodyDiscFlags +from nav.feature.geometry import BodyDiscGeometry +from nav.nav_technique.diagnostics import BodyDiscDiagnostics +from nav.nav_technique.nav_technique_body_disc import BodyDiscCorrelateNav +from nav.nav_technique.technique_result import NavTechniqueResult +from nav.support.filters import NavFilterKind, NavFilterSpec +from nav.support.types import NDArrayBoolType, NDArrayFloatType + + +def _disc_template( + *, + bbox_extfov_vu: tuple[int, int, int, int], + center_in_template_vu: tuple[float, float], + radius: float, +) -> tuple[NDArrayFloatType, NDArrayBoolType]: + """Build a postage-stamp BODY_DISC template (anti-aliased bright disc).""" + h = bbox_extfov_vu[2] - bbox_extfov_vu[0] + w = bbox_extfov_vu[3] - bbox_extfov_vu[1] + vs, us = np.meshgrid(np.arange(h), np.arange(w), indexing='ij') + rr = np.hypot(vs - center_in_template_vu[0], us - center_in_template_vu[1]) + inside = rr <= radius - 0.5 + outside = rr >= radius + 0.5 + ramp = np.clip(radius + 0.5 - rr, 0.0, 1.0) + template_img = np.where(inside, 100.0, np.where(outside, 0.0, 100.0 * ramp)).astype(np.float64) + template_mask: NDArrayBoolType = template_img > 1e-6 + return template_img, template_mask + + +def _make_disc_feature( + body_name: str, + *, + extfov_shape: tuple[int, int], + image_center_vu: tuple[float, float], + radius: float, + planted_offset_vu: tuple[float, float] = (0.0, 0.0), + subject_range_km: float = 1.0e6, + overflow_fraction: float = 0.0, +) -> NavFeature: + """Build a BODY_DISC feature whose template is shifted by a planted offset. + + The template is a bright anti-aliased disc placed inside a postage-stamp + bbox. ``planted_offset_vu`` shifts the predicted center relative to the + actual image center, so the technique should report + ``offset_px = planted_offset_vu`` (predicted + offset = actual). + """ + pred_v = image_center_vu[0] - planted_offset_vu[0] + pred_u = image_center_vu[1] - planted_offset_vu[1] + half_extent = round(radius) + 8 + v_min = max(0, round(pred_v - half_extent)) + u_min = max(0, round(pred_u - half_extent)) + v_max = min(extfov_shape[0], round(pred_v + half_extent)) + u_max = min(extfov_shape[1], round(pred_u + half_extent)) + bbox = (v_min, u_min, v_max, u_max) + center_in_template = (pred_v - v_min, pred_u - u_min) + template_img, template_mask = _disc_template( + bbox_extfov_vu=bbox, + center_in_template_vu=center_in_template, + radius=radius, + ) + return NavFeature( + feature_id=f'body_disc:{body_name}', + feature_type=NavFeatureType.BODY_DISC, + source_model='body', + geometry=BodyDiscGeometry( + bbox_extfov_vu=bbox, + predicted_center_vu=(pred_v, pred_u), + overflow_fraction=overflow_fraction, + ), + subject_range_km=subject_range_km, + position_cov_px=None, + intensity_sigma_rel=0.05, + preferred_filter=NavFilterSpec(kind=NavFilterKind.NONE), + reliability=0.85, + reliability_reasons=NavReliabilityBreakdown( + visible_lit_fraction=1.0 - overflow_fraction, + overflow_fraction=overflow_fraction, + ), + usable_types=frozenset({NavFeatureType.BODY_DISC}), + flags=BodyDiscFlags(body_name=body_name, overflow_fov_fraction=overflow_fraction), + template_img=template_img, + template_mask=template_mask, + ) + + +def test_body_disc_correlate_recovers_planted_offset_single_body( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """One BODY_DISC against an anti-aliased disc image converges below 1 px.""" + shape = (160, 160) + image_center = (80.0, 80.0) + radius = 20.0 + image = disc_image(shape, image_center, radius) + feature = _make_disc_feature( + 'moonA', + extfov_shape=shape, + image_center_vu=image_center, + radius=radius, + planted_offset_vu=(2.0, -3.0), + ) + technique = BodyDiscCorrelateNav() + context = make_nav_context(image, extfov_margin_vu=(16, 16)) + feasibility = technique.is_feasible([feature]) + assert feasibility.feasible is True + assert feasibility.consumed_feature_count == 1 + result = technique.navigate([feature], context) + assert result.offset_px[0] == pytest.approx(2.0, abs=1.0) + assert result.offset_px[1] == pytest.approx(-3.0, abs=1.0) + assert isinstance(result.diagnostics, BodyDiscDiagnostics) + assert result.diagnostics.body_count == 1 + + +def test_body_disc_correlate_multi_body_z_buffer_paint( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """Two BODY_DISC features fuse via Z-buffer paint and recover one offset.""" + shape = (220, 220) + radius = 18.0 + centers = [(60.0, 70.0), (150.0, 140.0)] + image = np.zeros(shape, dtype=np.float64) + for c in centers: + image += disc_image(shape, c, radius) + image = np.clip(image, 0.0, 100.0) + planted = (1.0, 1.5) + features = [ + _make_disc_feature( + f'moon_{i}', + extfov_shape=shape, + image_center_vu=c, + radius=radius, + planted_offset_vu=planted, + # Vary subject_range so depth ordering is well-defined + subject_range_km=1.0e6 * (i + 1), + ) + for i, c in enumerate(centers) + ] + technique = BodyDiscCorrelateNav() + context = make_nav_context(image, extfov_margin_vu=(16, 16)) + result = technique.navigate(features, context) + assert result.offset_px[0] == pytest.approx(planted[0], abs=1.0) + assert result.offset_px[1] == pytest.approx(planted[1], abs=1.0) + assert isinstance(result.diagnostics, BodyDiscDiagnostics) + assert result.diagnostics.body_count == 2 + + +def test_body_disc_correlate_infeasible_on_empty_input() -> None: + technique = BodyDiscCorrelateNav() + report = technique.is_feasible([]) + assert report.feasible is False + assert 'no_body_disc_features' in report.reason + + +def test_body_disc_correlate_infeasible_when_no_template( + disc_image: DiscImageFactory, +) -> None: + """A BODY_DISC feature without a template payload is rejected.""" + feature = NavFeature( + feature_id='body_disc:no_template', + feature_type=NavFeatureType.BODY_DISC, + source_model='body', + geometry=BodyDiscGeometry( + bbox_extfov_vu=(0, 0, 10, 10), + predicted_center_vu=(5.0, 5.0), + overflow_fraction=0.0, + ), + subject_range_km=1.0e6, + position_cov_px=None, + intensity_sigma_rel=0.0, + preferred_filter=NavFilterSpec(kind=NavFilterKind.NONE), + reliability=0.7, + reliability_reasons=NavReliabilityBreakdown(visible_lit_fraction=1.0), + usable_types=frozenset({NavFeatureType.BODY_DISC}), + flags=BodyDiscFlags(body_name='no_template'), + ) + technique = BodyDiscCorrelateNav() + report = technique.is_feasible([feature]) + assert report.feasible is False + + +@pytest.fixture +def at_edge_disc_result( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> NavTechniqueResult: + """Build a BodyDiscCorrelateNav result whose offset hits the search-window edge. + + Plants a disc at exactly the search-window axis bound, runs + ``BodyDiscCorrelateNav``, and returns the resulting + :class:`NavTechniqueResult` for the at-edge / hard-zero assertions + to consume. Splitting into a fixture lets each property be + asserted in its own test so a regression points at the failing + branch directly. + """ + shape = (160, 160) + image_center = (80.0, 80.0) + radius = 16.0 + image = disc_image(shape, image_center, radius) + margin = 5 + feature = _make_disc_feature( + 'edge_moon', + extfov_shape=shape, + image_center_vu=image_center, + radius=radius, + planted_offset_vu=(float(margin), 0.0), + ) + technique = BodyDiscCorrelateNav() + context = make_nav_context(image, extfov_margin_vu=(margin, margin)) + return technique.navigate([feature], context) + + +def test_body_disc_correlate_marks_at_edge_when_offset_hits_window( + at_edge_disc_result: NavTechniqueResult, +) -> None: + """The pyramid wrapper flags the boundary peak as ``at_edge``.""" + assert at_edge_disc_result.at_edge is True + + +def test_body_disc_correlate_at_edge_forces_zero_confidence( + at_edge_disc_result: NavTechniqueResult, +) -> None: + """The ``hard_zero_if={'at_edge': True}`` gate drives confidence to 0.""" + assert at_edge_disc_result.confidence == pytest.approx(0.0) + + +def test_body_disc_correlate_registered_with_navtechnique_registry() -> None: + from nav.nav_technique.nav_technique import NavTechnique + + assert BodyDiscCorrelateNav in NavTechnique._registry + + +def test_body_disc_diagnostics_records_peak_to_runner_up_ratio( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """A clean single-body scene reports peak-to-runner-up ratio > 1.0.""" + shape = (160, 160) + image_center = (80.0, 80.0) + radius = 20.0 + image = disc_image(shape, image_center, radius) + feature = _make_disc_feature( + 'moonA', + extfov_shape=shape, + image_center_vu=image_center, + radius=radius, + planted_offset_vu=(1.0, 1.0), + ) + technique = BodyDiscCorrelateNav() + context = make_nav_context(image, extfov_margin_vu=(16, 16)) + result = technique.navigate([feature], context) + assert isinstance(result.diagnostics, BodyDiscDiagnostics) + assert result.diagnostics.peak_to_runner_up_ratio > 1.0 + + +def test_body_disc_diagnostics_records_consistency_and_quality( + disc_image: DiscImageFactory, + make_nav_context: NavContextFactory, +) -> None: + """The clean planted-offset case reports sub-pixel consistency and positive ncc_peak.""" + shape = (160, 160) + image_center = (80.0, 80.0) + radius = 20.0 + image = disc_image(shape, image_center, radius) + feature = _make_disc_feature( + 'moonA', + extfov_shape=shape, + image_center_vu=image_center, + radius=radius, + planted_offset_vu=(1.0, 1.0), + ) + technique = BodyDiscCorrelateNav() + context = make_nav_context(image, extfov_margin_vu=(16, 16)) + result = technique.navigate([feature], context) + assert isinstance(result.diagnostics, BodyDiscDiagnostics) + assert result.diagnostics.ncc_peak > 0.0 + assert result.diagnostics.consistency_px < 1.0 diff --git a/tests/nav/support/test_correlate.py b/tests/nav/support/test_correlate.py index 9230415..dc3ece3 100644 --- a/tests/nav/support/test_correlate.py +++ b/tests/nav/support/test_correlate.py @@ -1,5 +1,7 @@ """Tests for nav.support.correlate, focused on masked NCC and pyramid navigation.""" +from itertools import pairwise + import numpy as np import pytest @@ -492,3 +494,34 @@ def test_pyramid_gradient_converges_on_star_scene(self) -> None: dy, dx = result['offset'] assert dy == pytest.approx(1.5, abs=0.3) assert dx == pytest.approx(-0.5, abs=0.3) + + +class TestPyramidTopKPeaks: + """``navigate_with_pyramid_kpeaks`` surfaces a sorted ``top_k_peaks`` list.""" + + def test_top_k_peaks_present_and_sorted(self) -> None: + """The returned dict carries the per-peak telemetry sorted by quality.""" + image, model, mask = _make_single_star(image_offset=(1.0, 0.0)) + result = navigate_with_pyramid_kpeaks( + image, + model, + mask, + pyramid_levels=3, + max_peaks=3, + upsample_factor=16, + metric='psr', + quality_thresh=0.0, + consistency_tol=10.0, + max_offset_vu=(10, 10), + ) + peaks = result['top_k_peaks'] + assert isinstance(peaks, list) + assert len(peaks) >= 1 + # First peak quality matches the headline ``quality`` value. + assert peaks[0][0] == pytest.approx(result['quality']) + # Sorted by quality descending. + for prev, cur in pairwise(peaks): + assert prev[0] >= cur[0] + # Each entry has shape (quality, dv, du). + for entry in peaks: + assert len(entry) == 3 From 33fdb83e7b93c9008294bf370d029e9b594c273d Mon Sep 17 00:00:00 2001 From: Robert French Date: Wed, 29 Apr 2026 13:16:23 -0700 Subject: [PATCH 2/4] Phase 5: integration seeding + spurious-gate + pytest cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin today's behavior on the four operator-curated Phase 5 sidecars (body_full_fov, body_partial_overflow, multi_body, below_resolution_body), fix the BodyLimbNav / BodyTerminatorNav confidence specs to hard-zero on spurious results, and tighten the per-image logger-section context so unraisable-exception cleanup warnings stop leaking out of pytest workers. The ``hard_zero_if={'spurious': True}`` addition mirrors the existing gate on BodyDiscCorrelateNav. Without it, a degenerate LM run that returns ``rms_px = 0`` (default value on a zero-inlier result) would feed the ``-alpha * rms_px`` term with the artificial-perfect 0 and the formula would report high confidence on a clearly-spurious result. Caught when the operator's Rhea-partial-overflow integration sidecar (N1484593951) recorded BodyTerminatorNav at confidence 0.903 with 0/895 inliers. The CLI-driver pytest cleanup wraps each ``IMAGE_LOGGER.open(...)`` section in a ``try/finally`` that closes the per-image file handler once the section exits. Without it, pytest's gc-driven unraisable- exception detector reports a ResourceWarning for every per-image log file that the worker did not close before the gc cycle, polluting test output and tripping ``filterwarnings = ["error"]`` on the strict-config CI matrix. PHASE5_LIBRARY_SEED.md Scenario C now has a prominent caveat explaining that BodyBlobNav requires an irregular body (high ellipsoid_residual_km), not just a small-in-pixels regular moon — the body extractor's gate is shape-uncertainty-based, not size-based. The previous wording sent the operator down a false-trail path on Mimas. AUTONAV_PLAN.md gains a new "Final Phase-5 check matrix" plus the post-seeding follow-up list (LIMB_ARC reliability formula too punitive on fully-lit limbs, BodyDiscCorrelateNav ``consistency_tol`` too tight on auto-gradient mode-switch scenes, BodyTerminatorNav coarse-NCC fragile in multi-body crescent geometry, ensemble ``agreement_gap`` calibration). Co-Authored-By: Claude Opus 4.7 (1M context) --- AUTONAV_PLAN.md | 160 +++++++++++++++--- PHASE5_LIBRARY_SEED.md | 35 +++- src/main/nav_mosaic.py | 64 +++---- src/main/nav_mosaic_cloud_tasks.py | 74 ++++---- src/main/nav_offset.py | 32 ++-- .../nav_technique/nav_technique_body_limb.py | 20 ++- .../nav_technique_body_terminator.py | 6 +- src/nav/navigate_image_files.py | 46 ++--- .../N1777325846_1_CALIB.yaml | 49 ++++++ .../body_full_fov/N1572105349_1_CALIB.yaml | 70 ++++++++ .../N1484593951_2_CALIB.yaml | 41 +++++ .../multi_body/N1487595731_1_CALIB.yaml | 63 +++++++ 12 files changed, 520 insertions(+), 140 deletions(-) create mode 100644 tests/integration/image_library/images/below_resolution_body/N1777325846_1_CALIB.yaml create mode 100644 tests/integration/image_library/images/body_full_fov/N1572105349_1_CALIB.yaml create mode 100644 tests/integration/image_library/images/body_partial_overflow/N1484593951_2_CALIB.yaml create mode 100644 tests/integration/image_library/images/multi_body/N1487595731_1_CALIB.yaml diff --git a/AUTONAV_PLAN.md b/AUTONAV_PLAN.md index 42f6be3..9dd1d67 100644 --- a/AUTONAV_PLAN.md +++ b/AUTONAV_PLAN.md @@ -2869,11 +2869,57 @@ link to the confidence formula source-of-truth joint LS, infeasibility on empty / no-template / zero-diameter inputs, at-edge detection at the search-window boundary, blank-image spurious-result fallback for the blob technique, the 0.4 hard cap on - blob confidence, and registry presence for both techniques. - Existing unit tests under `tests/nav/nav_model/` updated to match - the new gate constants (`LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0`, - `DEFAULT_BODY_SHAPE.min_blob_diameter_px = 8.0`, - worked-example km/px values for the threshold-crossing tests). + blob confidence, registry presence for both techniques, plus the + Phase-5-fix-up additions: at-edge fixture splitting the + disjunctive assertion into two named cases, diagnostic-field + assertions on `peak_to_runner_up_ratio` / `consistency_px` / + `residual_px`, and the new `TestPyramidTopKPeaks` class in + `tests/nav/support/test_correlate.py` pinning the + ``top_k_peaks`` field shape. Existing unit tests under + `tests/nav/nav_model/` updated to match the new gate constants + (`LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0`, + `DEFAULT_BODY_SHAPE.min_blob_diameter_px = 8.0`, worked-example + km/px values for the threshold-crossing tests). +- **Spurious-result hard-zero gate on the limb / terminator + techniques.** ``BodyLimbNav`` and ``BodyTerminatorNav`` confidence + specs now include ``hard_zero_if={'spurious': True}`` (matching the + pre-existing gate on ``BodyDiscCorrelateNav``). Without this gate + a degenerate LM run that returns ``rms_px = 0`` (default value + on a zero-inlier result) would feed the ``-alpha * rms_px`` term + with the artificial-perfect 0, and the formula would report + high confidence on a clearly-spurious result. The fix added + ``'spurious'`` to ``confidence_attributes`` on both techniques and + plumbed the flag through ``_LimbConfidenceContext`` / + ``_TerminatorConfidenceContext``. Caught when the operator's + Rhea-partial-overflow integration sidecar (N1484593951) recorded + BodyTerminatorNav at confidence 0.903 with 0/895 inliers. +- **Library expansion.** Four operator-curated sidecars added, + covering all four design-recommended Phase 5 scenarios: + * `body_full_fov/N1572105349_1_CALIB.yaml` — Dione fully in FOV; + pinned `expected.status=failed` because the LIMB_ARC reliability + gate drops the limb on a fully-lit body and the + BodyDiscCorrelateNav consistency check trips on the auto-gradient + mode-switch (both calibration follow-ups, listed below). + * `body_partial_overflow/N1484593951_2_CALIB.yaml` — Rhea with + overflow 0.222; `expected.status=ok`, + `primary_technique=BodyLimbNav` (BodyDiscCorrelateNav fires per + the disc gate but self-flags spurious because the heavy crop + collapses the NCC peak). + * `multi_body/N1487595731_1_CALIB.yaml` — Dione+Rhea high-phase + scene; `expected.status=conflicted` because BodyTerminatorNav + latches onto a wrong local minimum (multi-body crescent + coarse-NCC ambiguity, listed below) at confidence 0.744 even + though Disc + Limb agree with the operator at confidences 0.246 + + 0.239 and the ensemble's `agreement_gap` threshold (0.5) + refuses to commit. + * `below_resolution_body/N1777325846_1_CALIB.yaml` — Mimas at + ~20 px diameter; pinned `primary_technique=BodyLimbNav`. + Confirms the design's emission-gate behavior: a regular moon's + `limb_uncertainty_px` stays well below the 3 px threshold even + at low resolution, so LIMB_ARC always wins over BODY_BLOB on + well-shaped bodies. `PHASE5_LIBRARY_SEED.md` Scenario C + rewritten with a prominent ⚠️ block clarifying that BodyBlobNav + requires an irregular body, not just a small one. ### Logging / API conventions established in Phase 5 (binding) @@ -2908,25 +2954,95 @@ link to the confidence formula source-of-truth ### Phase 5 follow-ups uncovered during implementation These are real, reproducible gaps surfaced by the synthetic-image -unit tests and the design review during Phase 5. They do not block -Phase 5 (the technique handles all the documented happy and boundary -paths) but are concrete starting points for Phase 6 / Phase 10 -calibration work. - -- **`peak_to_runner_up_ratio` placeholder.** - `BodyDiscDiagnostics.peak_to_runner_up_ratio` is populated with - `0.0` because `navigate_with_pyramid_kpeaks` does not surface - multi-peak telemetry past the auto picker. Phase 6 follow-up: when - `RingAnnulusNav` lands, extend the pyramid wrapper to return the - top-K peak values from its final pass so both correlation - techniques can populate this diagnostic; until then, the field is - inert and the confidence formula does not consume it. +unit tests, the design review, and the four-sidecar integration +seeding during Phase 5. They do not block Phase 5 (every technique +handles its documented happy and boundary paths) but are concrete +starting points for Phase 6 / Phase 10 calibration work. + - **`config_510_techniques.yaml` not yet shipped.** The per-technique confidence-formula coefficients live as Python constants - (`_BODY_DISC_CONFIDENCE_SPEC`, `_BODY_BLOB_CONFIDENCE_SPEC`) until - the corresponding YAML config file ships in a later phase. When it - does, the confidence specs should be loaded from YAML at config - init so the operator can retune without a code change. + (`_BODY_DISC_CONFIDENCE_SPEC`, `_BODY_BLOB_CONFIDENCE_SPEC`, + `_BODY_LIMB_CONFIDENCE_SPEC`, `_BODY_TERMINATOR_CONFIDENCE_SPEC`) + until the corresponding YAML config file ships in a later phase. + When it does, the confidence specs should be loaded from YAML at + config init so the operator can retune without a code change. +- **LIMB_ARC reliability formula too punitive on fully-lit limbs** + (Dione `body_full_fov/N1572105349`). The `_limb_reliability` + formula carries a ``-0.7 * mean_incidence_factor`` term where the + incidence-factor cap is 4.76; on a fully-lit body whose limb + vertices live at 80–90° incidence the penalty saturates, dragging + reliability to ~0.14 — below the LIMB_ARC gate threshold (0.30). + The textbook full-disc body's limb is rejected before BodyLimbNav + can consume it. Phase 10 calibration target: lower the + ``incidence_factor`` alpha, replace the penalty with a + per-vertex sigma_normal-weighted average that already encodes the + photometric softness, or move the incidence-factor consideration + entirely into ``sigma_normal_px`` (where it already lives) and + drop it from reliability. +- **`BodyDiscCorrelateNav` `consistency_tol` too tight on + auto-gradient mode-switch scenes** (Dione + `body_full_fov/N1572105349`). The technique converges within 0.5 + px of the operator's truth on a fully-in-FOV body but the pyramid + wrapper flags `spurious=True` because `consistency=2.78 px` + exceeds the `consistency_tol=2.0` threshold — pyramid drift on the + auto-gradient pass when the picker swaps modes between coarse and + fine levels. Phase 6 / Phase 10 follow-up: loosen + ``consistency_tol`` for ``BodyDiscCorrelateNav`` (or apply it only + when raw and gradient picks disagreed at any level) so a strong, + consistent gradient-mode peak does not get rejected for sub-3-px + coarse-vs-fine drift. +- **`BodyTerminatorNav` coarse-NCC fragile in multi-body crescent + geometry** (multi-body `multi_body/N1487595731`). The technique + concatenates per-body terminator polylines into one combined mask + for the coarse search; on a high-phase scene with two bodies the + combined mask plus image edges at the wrong location find an + incorrect global maximum, and LM converges on the wrong seed — + reporting sub-pixel RMS with 76% inliers and confidence 0.744 on + an offset 31 px from the operator's truth. This is a *different* + failure mode from the Tethys-N1716186428 LM-divergence case + (where coarse-NCC was right and LM walked away); here the coarse + search itself goes wrong. Phase 6 / Phase 10 follow-ups: + per-body coarse search fused only after each body's individual + peak passes a sanity test, polarity-aware terminator extraction, + or inter-technique sanity-check (terminator must agree with limb + on the same image, otherwise spurious). +- **Ensemble `agreement_gap` threshold needs calibration** + (multi-body N1487595731 + high-phase-terminator N1597846115). + Two scenes now showcase the same conflict-detector miscalibration: + in N1597846115 limb + terminator agree within 1 px but their + summed-confidence gap (0.045) is below the 0.5 threshold; in + N1487595731 disc + limb agree with the operator while a + wrong-answer terminator runs away alone, and the gap (0.259) + again falls short. Phase 10 target: replace the + summed-confidence gap with a per-axis offset-disagreement test + in pixels, or recalibrate the threshold against the broadened + library so disc + limb agreement weighs more strongly against + isolated wrong-answer high-confidence runner-ups. +- **Resolved during Phase 5 (no longer follow-ups):** + * ``peak_to_runner_up_ratio`` now populated honestly via the + new `top_k_peaks` field on + ``navigate_with_pyramid_kpeaks``'s result dict (the placeholder + that lived here at Phase 5's first close-out is gone — see + ``BodyDiscCorrelateNav._peak_to_runner_up_ratio``). The + confidence-spec term reading the field is wired with + ``alpha=0.0`` until calibration tunes it. + * `BodyLimbNav` / `BodyTerminatorNav` spurious-confidence gate + (the bug that flagged terminator at 0.903 confidence with 0 + inliers) — fixed by adding + ``hard_zero_if={'spurious': True}`` to both confidence specs. + +### Final Phase-5 check matrix + +| Check | Status | +|---|---| +| `ruff check src tests` | clean | +| `ruff format --check src tests` | clean | +| `mypy --strict src tests` | clean (270 source files) | +| `pytest -n auto --dist=loadfile` (unit) | 1097 passed | +| `pytest -n auto --dist=loadfile tests/integration/` | 28 passed (4 new sidecars + Phase 4 carry-overs) | +| `sphinx-build -W -b html docs docs/_build` | clean | +| `pymarkdown scan docs/ .cursor/ README.md CONTRIBUTING.md` | clean | +| `phase_05_review/CRITIQUE_*.md` | written; resolution log records every Medium/Low fix or deliberate deferral | --- diff --git a/PHASE5_LIBRARY_SEED.md b/PHASE5_LIBRARY_SEED.md index 15ed5a9..4f91daa 100644 --- a/PHASE5_LIBRARY_SEED.md +++ b/PHASE5_LIBRARY_SEED.md @@ -110,23 +110,40 @@ the calibration sample. - `expected.techniques_must_run: [BodyLimbNav]` - `expected.techniques_must_skip: [BodyDiscCorrelateNav]` -### Scenario C — Below-resolution / irregular body (`BodyBlobNav`) - -A scene where a small or irregular moon is too unresolved or -shape-irregular for the limb fit. The body extractor emits `BODY_BLOB` -only. +### Scenario C — Irregular body (`BodyBlobNav`) + +A scene where the body's shape is too irregular for the ellipsoid +limb fit. The body extractor emits `BODY_BLOB` only. + +> ⚠️ **Important — the gate is shape-uncertainty-based, not +> size-based.** The body extractor decides between LIMB_ARC and +> BODY_BLOB by computing +> `limb_uncertainty_px = ellipsoid_residual_km / km_per_px_at_limb`. +> Regular moons (Mimas, Tethys, Dione, Rhea, …) carry +> `ellipsoid_residual_km ≈ 1` so their limb uncertainty stays well +> below the 3 px threshold *at any reasonable resolution*, including +> when they show up at only 10–20 px diameter in the frame. On +> regular moons the extractor always emits LIMB_ARC; BodyBlobNav +> never fires. To exercise BodyBlobNav you must pick an +> **irregular** body whose `ellipsoid_residual_km` in the body-shape +> table is large enough that `1 km/px · ellipsoid_residual_km > 3 px` +> on the chosen image — i.e. Prometheus, Pandora, Atlas, Pan, +> Hyperion, or Phoebe at close range. | Field | What to look for | |---|---| | Mission / camera | Cassini ISS, NAC | -| Body | Prometheus, Pandora, Atlas, Pan, Hyperion at close range, **or** any regular moon at a distance where `predicted_diameter_px` is 10–30 px | -| Body diameter in FOV | 8–30 px | +| Body | Prometheus, Pandora, Atlas, Pan, Hyperion, Phoebe (highly_irregular shape class) | +| Body diameter in FOV | >= 8 px (so the BODY_BLOB diameter floor is satisfied) | +| Resolution | High enough that `limb_uncertainty_px > 3` for the chosen body — the more irregular the body, the higher km/px the gate accepts | | Other bright sources | None inside the predicted bbox (otherwise the centroid is biased) | | Background | Dark sky preferred | **Sidecar location**: -- Irregular body: `tests/integration/image_library/images/body_irregular/.yaml` -- Distant regular body: `tests/integration/image_library/images/below_resolution_body/.yaml` +`tests/integration/image_library/images/body_irregular/.yaml` +(use `body_irregular` — the `below_resolution_body` class is reserved +for the case where even the BODY_BLOB diameter floor is violated and +the extractor emits no body feature at all). **Expected behavior**: - `expected.status: ok` (or `failed` if calibration discovers the diff --git a/src/main/nav_mosaic.py b/src/main/nav_mosaic.py index 9cceb38..636ceb8 100644 --- a/src/main/nav_mosaic.py +++ b/src/main/nav_mosaic.py @@ -148,36 +148,40 @@ def _run_reproject_pass( continue local_handlers, image_log_path = _reproject_image_log_handlers(output_dir, image_file, args) - with IMAGE_LOGGER.open( - f'REPROJECT {image_file.image_file_url}', - handler=local_handlers, - ): - try: - image_path = image_file.image_file_path.absolute() - obs = obs_class.from_file(image_path, extfov_margin_vu=(0, 0)) - - offset = load_offset_if_any(nav_results_root_path, image_file) - if offset is not None: - apply_offset_to_obs(cast(ObsSnapshotInst, obs), offset[0], offset[1]) - - img_label = ( - args.image_name - if args.image_name is not None - else image_file.image_file_path.stem - ) - obs_inst = cast(ObsSnapshotInst, obs) - result = reproject_fn(obs_inst, img_label) - - if not args.no_write_output_files: - out_path.parent.mkdir(parents=True, exist_ok=True) - result.save(out_path) - MAIN_LOGGER.info('Saved reproj: %s', out_path) - n_done += 1 - except Exception: - _log_main_exception('Error reprojecting %s', image_file.image_file_url) - finally: - if local_handlers: - MAIN_LOGGER.info('Wrote reprojection log to %s', image_log_path) + try: + with IMAGE_LOGGER.open( + f'REPROJECT {image_file.image_file_url}', + handler=local_handlers, + ): + try: + image_path = image_file.image_file_path.absolute() + obs = obs_class.from_file(image_path, extfov_margin_vu=(0, 0)) + + offset = load_offset_if_any(nav_results_root_path, image_file) + if offset is not None: + apply_offset_to_obs(cast(ObsSnapshotInst, obs), offset[0], offset[1]) + + img_label = ( + args.image_name + if args.image_name is not None + else image_file.image_file_path.stem + ) + obs_inst = cast(ObsSnapshotInst, obs) + result = reproject_fn(obs_inst, img_label) + + if not args.no_write_output_files: + out_path.parent.mkdir(parents=True, exist_ok=True) + result.save(out_path) + MAIN_LOGGER.info('Saved reproj: %s', out_path) + n_done += 1 + except Exception: + _log_main_exception('Error reprojecting %s', image_file.image_file_url) + finally: + if local_handlers: + MAIN_LOGGER.info('Wrote reprojection log to %s', image_log_path) + finally: + for handler in local_handlers: + handler.close() return n_done, n_skipped diff --git a/src/main/nav_mosaic_cloud_tasks.py b/src/main/nav_mosaic_cloud_tasks.py index 3fc0331..2d1c80f 100644 --- a/src/main/nav_mosaic_cloud_tasks.py +++ b/src/main/nav_mosaic_cloud_tasks.py @@ -251,42 +251,46 @@ def process_task( image_log_path.parent.mkdir(parents=True, exist_ok=True) local_handlers = image_log_handlers(image_log_path, cli_args, DEFAULT_CONFIG) - with IMAGE_LOGGER.open( - f'REPROJECT {image_file.image_file_url}', - handler=local_handlers, - ): - try: - image_path = image_file.image_file_path.absolute() - obs = obs_class.from_file(image_path, extfov_margin_vu=(0, 0)) - - offset = load_offset_if_any(nav_results_root_path, image_file) - if offset is not None: - apply_offset_to_obs(cast(ObsSnapshotInst, obs), offset[0], offset[1]) - - img_label = ( - image_name_override - if image_name_override is not None - else image_file.image_file_path.stem - ) - obs_inst = cast(ObsSnapshotInst, obs) - result: BodyReprojResult | RingReprojResult - if mode == 'body': - result = reproject_one_body( - obs_inst, cast(BodyMosaic, mosaic), image_name=img_label - ) - else: - result = reproject_one_ring( - obs_inst, task_args, cast(RingMosaic, mosaic), image_name=img_label + try: + with IMAGE_LOGGER.open( + f'REPROJECT {image_file.image_file_url}', + handler=local_handlers, + ): + try: + image_path = image_file.image_file_path.absolute() + obs = obs_class.from_file(image_path, extfov_margin_vu=(0, 0)) + + offset = load_offset_if_any(nav_results_root_path, image_file) + if offset is not None: + apply_offset_to_obs(cast(ObsSnapshotInst, obs), offset[0], offset[1]) + + img_label = ( + image_name_override + if image_name_override is not None + else image_file.image_file_path.stem ) - - if not no_write_output_files: - out_path.parent.mkdir(parents=True, exist_ok=True) - result.save(out_path) - MAIN_LOGGER.info('Saved reproj: %s', out_path) - except Exception: - _log_main_exception('Error reprojecting %s', image_file.image_file_url) - finally: - MAIN_LOGGER.info('Wrote reprojection log to %s', image_log_path) + obs_inst = cast(ObsSnapshotInst, obs) + result: BodyReprojResult | RingReprojResult + if mode == 'body': + result = reproject_one_body( + obs_inst, cast(BodyMosaic, mosaic), image_name=img_label + ) + else: + result = reproject_one_ring( + obs_inst, task_args, cast(RingMosaic, mosaic), image_name=img_label + ) + + if not no_write_output_files: + out_path.parent.mkdir(parents=True, exist_ok=True) + result.save(out_path) + MAIN_LOGGER.info('Saved reproj: %s', out_path) + except Exception: + _log_main_exception('Error reprojecting %s', image_file.image_file_url) + finally: + MAIN_LOGGER.info('Wrote reprojection log to %s', image_log_path) + finally: + for handler in local_handlers: + handler.close() return False, {'status': 'success'} # No retry under any circumstances diff --git a/src/main/nav_offset.py b/src/main/nav_offset.py index 2c57581..ac7feca 100755 --- a/src/main/nav_offset.py +++ b/src/main/nav_offset.py @@ -284,20 +284,24 @@ def _run_manual_pass( ) local_handlers = image_log_handlers(image_log_path, arguments, DEFAULT_CONFIG) - with IMAGE_LOGGER.open(str(image_url), handler=local_handlers): - obs = cast(ObsSnapshotInst, obs_class.from_file(image_url, **extra_params)) - result = run_manual_nav(obs, config=DEFAULT_CONFIG) - if result is None: - # ``run_manual_nav`` already logged the precise infeasibility - # reason (no_renderable_features_for_manual_nav, empty composed - # overlay, etc.); avoid re-logging stale template-only wording. - sys.exit(2) - if result.spurious: - IMAGE_LOGGER.warning('Manual navigation cancelled') - sys.exit(2) - - dv, du = result.offset_px - IMAGE_LOGGER.info('Manual nav: offset_dv_px=%.4f, offset_du_px=%.4f', dv, du) + try: + with IMAGE_LOGGER.open(str(image_url), handler=local_handlers): + obs = cast(ObsSnapshotInst, obs_class.from_file(image_url, **extra_params)) + result = run_manual_nav(obs, config=DEFAULT_CONFIG) + if result is None: + # ``run_manual_nav`` already logged the precise infeasibility + # reason (no_renderable_features_for_manual_nav, empty composed + # overlay, etc.); avoid re-logging stale template-only wording. + sys.exit(2) + if result.spurious: + IMAGE_LOGGER.warning('Manual navigation cancelled') + sys.exit(2) + + dv, du = result.offset_px + IMAGE_LOGGER.info('Manual nav: offset_dv_px=%.4f, offset_du_px=%.4f', dv, du) + finally: + for handler in local_handlers: + handler.close() # The dv / du print statements are the CLI's machine-parsable contract; # they go to stdout regardless of the per-image log routing. print(f'offset_dv_px={dv:.4f}') diff --git a/src/nav/nav_technique/nav_technique_body_limb.py b/src/nav/nav_technique/nav_technique_body_limb.py index 76fff43..b166f7c 100644 --- a/src/nav/nav_technique/nav_technique_body_limb.py +++ b/src/nav/nav_technique/nav_technique_body_limb.py @@ -92,7 +92,7 @@ cap_at=1.0, ), ), - hard_zero_if={'at_edge': True}, + hard_zero_if={'at_edge': True, 'spurious': True}, ) """Default confidence spec for the body-limb technique. @@ -179,6 +179,7 @@ class BodyLimbNav(NavTechnique): confidence_attributes = frozenset( { 'at_edge', + 'spurious', 'visible_limb_arc_fraction', 'visible_arc_px', 'dt_fit_rms_px', @@ -316,7 +317,9 @@ def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechni assert self.confidence_spec is not None # set as class attribute confidence, breakdown = evaluate_sigmoid_combination( self.confidence_spec, - _LimbConfidenceContext(at_edge=at_edge, diagnostics=diagnostics), + _LimbConfidenceContext( + at_edge=at_edge, spurious=bool(spurious), diagnostics=diagnostics + ), technique_name=self.name, return_breakdown=True, ) @@ -355,17 +358,18 @@ def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechni class _LimbConfidenceContext: - """Adapter binding ``BodyLimbDiagnostics`` plus ``at_edge`` for confidence eval. + """Adapter binding ``BodyLimbDiagnostics`` plus ``at_edge`` / ``spurious``. The shared :func:`evaluate_sigmoid_combination` helper accepts any - object whose attributes match the spec's term names. ``at_edge`` is - not part of ``BodyLimbDiagnostics`` (it lives on - ``NavTechniqueResult``) so this small adapter exposes both as - attributes of one object the spec can dot into. + object whose attributes match the spec's term names. ``at_edge`` + and ``spurious`` are not part of ``BodyLimbDiagnostics`` (they live + on ``NavTechniqueResult``) so this small adapter exposes them + alongside the diagnostic fields the spec consumes. """ - def __init__(self, *, at_edge: bool, diagnostics: BodyLimbDiagnostics) -> None: + def __init__(self, *, at_edge: bool, spurious: bool, diagnostics: BodyLimbDiagnostics) -> None: self.at_edge = at_edge + self.spurious = spurious self.visible_limb_arc_fraction = diagnostics.visible_limb_arc_fraction self.visible_arc_px = diagnostics.visible_arc_px self.dt_fit_rms_px = diagnostics.dt_fit_rms_px diff --git a/src/nav/nav_technique/nav_technique_body_terminator.py b/src/nav/nav_technique/nav_technique_body_terminator.py index 81aa634..f667e52 100644 --- a/src/nav/nav_technique/nav_technique_body_terminator.py +++ b/src/nav/nav_technique/nav_technique_body_terminator.py @@ -76,7 +76,7 @@ ConfidenceTerm(feature='mean_phase_angle_factor', alpha=1.0), ConfidenceTerm(feature='mean_albedo_penalty', alpha=-1.5), ), - hard_zero_if={'at_edge': True}, + hard_zero_if={'at_edge': True, 'spurious': True}, ) """Default confidence spec for the body-terminator technique. @@ -174,6 +174,7 @@ class BodyTerminatorNav(NavTechnique): confidence_attributes = frozenset( { 'at_edge', + 'spurious', 'visible_terminator_arc_fraction', 'visible_arc_px', 'dt_fit_rms_px', @@ -313,6 +314,7 @@ def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechni ) confidence_context = _TerminatorConfidenceContext( at_edge=at_edge, + spurious=bool(spurious), diagnostics=diagnostics, mean_phase_angle_factor=mean_phase, mean_albedo_penalty=mean_albedo, @@ -369,11 +371,13 @@ def __init__( self, *, at_edge: bool, + spurious: bool, diagnostics: BodyTerminatorDiagnostics, mean_phase_angle_factor: float, mean_albedo_penalty: float, ) -> None: self.at_edge = at_edge + self.spurious = spurious self.visible_terminator_arc_fraction = diagnostics.visible_terminator_arc_fraction self.visible_arc_px = diagnostics.visible_arc_px self.dt_fit_rms_px = diagnostics.dt_fit_rms_px diff --git a/src/nav/navigate_image_files.py b/src/nav/navigate_image_files.py index a7acdda..0a0dbd8 100644 --- a/src/nav/navigate_image_files.py +++ b/src/nav/navigate_image_files.py @@ -120,30 +120,34 @@ def navigate_image_files( ) local_handlers = image_log_handlers(image_log_path, log_arguments, DEFAULT_CONFIG) - with logger.open(str(image_url), handler=local_handlers): - log_run_environment(logger, sys.argv[1:]) - try: - snapshot = obs_class.from_file(image_url, **extra_params) - except (OSError, RuntimeError) as exc: - metadata = _metadata_for_load_error(image_path, image_name, exc, logger) + try: + with logger.open(str(image_url), handler=local_handlers): + log_run_environment(logger, sys.argv[1:]) + try: + snapshot = obs_class.from_file(image_url, **extra_params) + except (OSError, RuntimeError) as exc: + metadata = _metadata_for_load_error(image_path, image_name, exc, logger) + if write_output_files: + public_metadata_file.write_text(json_as_string(metadata)) + MAIN_LOGGER.info('Wrote log to %s', image_log_path) + return False, metadata + snapshot_inst = cast(ObsSnapshotInst, snapshot) + orchestrator = NavOrchestrator( + build_models_for_obs(snapshot_inst), + only_models=nav_models or '*', + only_techniques=nav_techniques or '*', + ) + nav_result = orchestrator.navigate(snapshot_inst) + metadata = _metadata_from_result(nav_result, image_path, image_name) if write_output_files: + logger.info('Writing metadata to %s', public_metadata_file) public_metadata_file.write_text(json_as_string(metadata)) + _write_summary_png(snapshot_inst, nav_result, summary_png_file, logger) MAIN_LOGGER.info('Wrote log to %s', image_log_path) - return False, metadata - snapshot_inst = cast(ObsSnapshotInst, snapshot) - orchestrator = NavOrchestrator( - build_models_for_obs(snapshot_inst), - only_models=nav_models or '*', - only_techniques=nav_techniques or '*', - ) - nav_result = orchestrator.navigate(snapshot_inst) - metadata = _metadata_from_result(nav_result, image_path, image_name) - if write_output_files: - logger.info('Writing metadata to %s', public_metadata_file) - public_metadata_file.write_text(json_as_string(metadata)) - _write_summary_png(snapshot_inst, nav_result, summary_png_file, logger) - MAIN_LOGGER.info('Wrote log to %s', image_log_path) - return nav_result.status == 'ok', metadata + return nav_result.status == 'ok', metadata + finally: + for handler in local_handlers: + handler.close() def _metadata_for_load_error( diff --git a/tests/integration/image_library/images/below_resolution_body/N1777325846_1_CALIB.yaml b/tests/integration/image_library/images/below_resolution_body/N1777325846_1_CALIB.yaml new file mode 100644 index 0000000..482d829 --- /dev/null +++ b/tests/integration/image_library/images/below_resolution_body/N1777325846_1_CALIB.yaml @@ -0,0 +1,49 @@ +schema_version: 1 +image_id: N1777325846_1_CALIB +mission: CASSINI_ISS # CASSINI_ISS | VOYAGER_ISS | GOSSI | NHLORRI +camera: NAC # NAC | WAC | SSI | NA | WA | LORRI +filter_combo: 'CL1+CL2' # canonicalized: filters sorted, '+'-joined +image_url: 'pds3://calibrated/COISS_2xxx/COISS_2090/data/1776647595_1777844466/N1777325846_1_CALIB.IMG' + +scene_tags: + - below_resolution_body # First tag is the primary class; + # must match the directory the + # sidecar lives in. + +ground_truth: + offset_dv_px: 6.3849 + offset_du_px: -2.4536 + offset_uncertainty_px: 1.0 # 1sigma marginal; tighten + # for bright stars / sharp limbs. + source: operator_verified + operator: rfrench + verified_date: 2026-04-29 + ui_version: 'rms-nav 0.1.dev25' + notes: | + Small Mimas ~20 px diameter in lower left corner. + Slightly overexposed, 72 degree phase angle. + + BodyBlobNav does NOT fire on this image: the body extractor's + emission gate uses ``limb_uncertainty_px = + ellipsoid_residual_km / km_per_px_at_limb`` (not predicted + diameter) to decide between LIMB_ARC and BODY_BLOB. Mimas is a + regular moon with ellipsoid_residual = 1 km, and at the image's + km/px the limb uncertainty works out to 0.05 px — well below + the 3 px threshold — so the extractor emits LIMB_ARC, and + BodyLimbNav becomes the only body-side technique. BodyBlobNav + is reserved for irregular bodies (high ellipsoid_residual_km) + where the ellipsoid limb is unreliable, not for small-in-pixels + regular moons. + + BodyLimbNav converges to (5.91, -1.92), within the 1.0 px + ground-truth uncertainty of the operator's (6.38, -2.45). The + BODY_DISC feature is also emitted (overflow_fraction below + threshold) but BodyDiscCorrelateNav flags spurious on the small + disc and contributes nothing to the ensemble. + +expected: + status: ok # ok | failed | conflicted + confidence_tier: low # high | medium | low | failed + primary_technique: BodyLimbNav # e.g. BodyLimbNav + techniques_must_run: [BodyLimbNav] + techniques_must_skip: [] diff --git a/tests/integration/image_library/images/body_full_fov/N1572105349_1_CALIB.yaml b/tests/integration/image_library/images/body_full_fov/N1572105349_1_CALIB.yaml new file mode 100644 index 0000000..a108cae --- /dev/null +++ b/tests/integration/image_library/images/body_full_fov/N1572105349_1_CALIB.yaml @@ -0,0 +1,70 @@ +schema_version: 1 +image_id: N1572105349_1_CALIB +mission: CASSINI_ISS # CASSINI_ISS | VOYAGER_ISS | GOSSI | NHLORRI +camera: NAC # NAC | WAC | SSI | NA | WA | LORRI +filter_combo: 'CL1+IR1' # canonicalized: filters sorted, '+'-joined +image_url: 'pds3://calibrated/COISS_2xxx/COISS_2038/data/1572094226_1572114418/N1572105349_1_CALIB.IMG' + +scene_tags: + - body_full_fov # First tag is the primary class; + # must match the directory the + # sidecar lives in. + +ground_truth: + offset_dv_px: 8.6806 + offset_du_px: -17.3740 + offset_uncertainty_px: 1.0 # 1sigma marginal; tighten + # for bright stars / sharp limbs. + source: operator_verified + operator: rfrench + verified_date: 2026-04-29 + ui_version: 'rms-nav 0.1.dev25' + notes: | + Full-body view of Dione in the center. Mostly lit with sliver + of terminator in upper left. Predicted diameter 155 px, + overflow 0.0, visible-lit fraction 0.97 — textbook + body-fills-FOV scenario. + + Phase-5 status: BodyDiscCorrelateNav converges to (9.17, -17.01), + within 0.5 px of the operator's ground truth. But the technique + flags itself spurious=True because the pyramid wrapper's + ``consistency`` (2.78 px disagreement between coarse and fine + pyramid levels in the auto-gradient pass) exceeds the + consistency_tol=2.0 threshold. Gradient-mode picks a strong + peak (quality 19.96) but the pyramid drift trips the spurious + detector even though the final offset is correct. + + BodyLimbNav does NOT run because the reliability gate drops the + LIMB_ARC feature. The limb-reliability formula carries a + ``-0.7 * mean_incidence_factor`` term where the incidence-factor + cap is 4.76; on a fully-lit body the limb vertices live near + 80-90 deg incidence and the penalty saturates, dragging + reliability to ~0.14, below the LIMB_ARC gate threshold (0.30). + The textbook full-disc body's limb is rejected before BodyLimbNav + can consume it. + + Phase 6 / Phase 10 follow-ups: + + 1. Recalibrate the LIMB_ARC reliability formula so a fully-lit + limb does not saturate the incidence-factor penalty. + Candidates: lower the alpha on incidence_factor, replace the + penalty with a per-vertex sigma_normal-weighted average that + already encodes the photometric softness, or move the + incidence-factor consideration entirely into sigma_normal_px + (where it already lives) and drop it from reliability. + 2. Loosen ``consistency_tol`` for BodyDiscCorrelateNav (or apply + it only when the auto-gradient picker swapped modes between + pyramid levels), so a strong, consistent gradient-mode peak + on a fully-in-FOV body does not get rejected for sub-3-px + coarse-vs-fine drift. + + Today's behavior: status=failed (every technique spurious), + primary_technique=BodyDiscCorrelateNav (only technique that ran, + flagged spurious). + +expected: + status: failed + confidence_tier: failed + primary_technique: BodyDiscCorrelateNav # e.g. BodyLimbNav + techniques_must_run: [BodyDiscCorrelateNav] + techniques_must_skip: [BodyLimbNav, BodyTerminatorNav] diff --git a/tests/integration/image_library/images/body_partial_overflow/N1484593951_2_CALIB.yaml b/tests/integration/image_library/images/body_partial_overflow/N1484593951_2_CALIB.yaml new file mode 100644 index 0000000..074765f --- /dev/null +++ b/tests/integration/image_library/images/body_partial_overflow/N1484593951_2_CALIB.yaml @@ -0,0 +1,41 @@ +schema_version: 1 +image_id: N1484593951_2_CALIB +mission: CASSINI_ISS # CASSINI_ISS | VOYAGER_ISS | GOSSI | NHLORRI +camera: NAC # NAC | WAC | SSI | NA | WA | LORRI +filter_combo: 'CL1+CL2' # canonicalized: filters sorted, '+'-joined +image_url: 'pds3://calibrated/COISS_2xxx/COISS_2009/data/1484573295_1484664788/N1484593951_2_CALIB.IMG' + +scene_tags: + - body_partial_overflow # First tag is the primary class; + # must match the directory the + # sidecar lives in. + +ground_truth: + offset_dv_px: 11.0000 + offset_du_px: 29.5000 + offset_uncertainty_px: 1.0 # 1sigma marginal; tighten + # for bright stars / sharp limbs. + source: operator_verified + operator: rfrench + verified_date: 2026-04-29 + ui_version: 'rms-nav 0.1.dev25' + notes: | + Large Rhea partially visible continuing to the upper right. + Good limb. Body extractor reports overflow_fraction = 0.222 + (i.e. 22 % of the disc is off-frame), which sits below the + BODY_DISC emission gate (overflow <= 0.3) so BodyDiscCorrelateNav + runs on this image — and correctly flags itself spurious because + the disc-template NCC peak collapses against the heavily-cropped + silhouette. BodyLimbNav converges to (12.06, 30.53) px, within + the 1.0 px ground-truth uncertainty of the operator's (11.0, + 29.5). BodyTerminatorNav runs but flags itself spurious (0 / 895 + inliers; LM did not iterate) so the spec's hard-zero gate forces + its confidence to 0. BodyLimbNav is therefore the only + non-spurious technique and becomes the orchestrator's primary. + +expected: + status: ok # ok | failed | conflicted + confidence_tier: low # high | medium | low | failed + primary_technique: BodyLimbNav # e.g. BodyLimbNav + techniques_must_run: [BodyLimbNav] + techniques_must_skip: [] diff --git a/tests/integration/image_library/images/multi_body/N1487595731_1_CALIB.yaml b/tests/integration/image_library/images/multi_body/N1487595731_1_CALIB.yaml new file mode 100644 index 0000000..867a971 --- /dev/null +++ b/tests/integration/image_library/images/multi_body/N1487595731_1_CALIB.yaml @@ -0,0 +1,63 @@ +schema_version: 1 +image_id: N1487595731_1_CALIB +mission: CASSINI_ISS # CASSINI_ISS | VOYAGER_ISS | GOSSI | NHLORRI +camera: NAC # NAC | WAC | SSI | NA | WA | LORRI +filter_combo: 'CL1+CL2' # canonicalized: filters sorted, '+'-joined +image_url: 'pds3://calibrated/COISS_2xxx/COISS_2009/data/1487544030_1487616958/N1487595731_1_CALIB.IMG' + +scene_tags: + - multi_body # First tag is the primary class; + # must match the directory the + # sidecar lives in. + +ground_truth: + offset_dv_px: 7.0329 + offset_du_px: -18.4178 + offset_uncertainty_px: 1.0 # 1sigma marginal; tighten + # for bright stars / sharp limbs. + source: operator_verified + operator: rfrench + verified_date: 2026-04-29 + ui_version: 'rms-nav 0.1.dev25' + notes: | + Dione and Rhea both visible and overlapping. Phase angle ~90 deg. + + Phase-5 status: BodyDiscCorrelateNav and BodyLimbNav both converge + within ~1 px of the operator's ground truth (disc: (6.76, -17.71) + confidence 0.246; limb: (7.00, -18.00) confidence 0.239). + BodyTerminatorNav, however, latches onto a wrong local minimum at + (11.58, +12.64) — about 31 px off in the U axis — and reports + sub-pixel RMS with 76 % inliers, scoring confidence 0.744 on the + wrong answer. The combined-edge-mask coarse NCC for the two + bodies' concatenated terminator polylines finds an incorrect + global maximum at (11, 13) on this high-phase, multi-body + crescent geometry, and LM converges near the wrong seed. + + The ensemble correctly refuses to commit: disc + limb sum to 0.485 + while the lone terminator scores 0.744, gap 0.259, below the + agreement_gap threshold (0.5), so the orchestrator returns + status=conflicted. Headline (disc + limb) answer is right; the + multi-body terminator coarse-NCC failure plus the + summed-confidence-gap conflict-detector rule produce the + conflicted verdict. + + Phase 6 / Phase 10 follow-ups: (a) make BodyTerminatorNav's + coarse NCC robust to multi-body crescent geometry (e.g. + polarity-aware terminator extraction, or per-body coarse search + fused only after each body's individual peak passes a sanity + test); (b) lower agreement_gap (or replace it with a per-axis + offset-disagreement-in-pixels test) so disc + limb agreement + weighs more strongly against an isolated wrong-answer + high-confidence runner-up. + +expected: + status: conflicted + confidence_tier: conflicted + primary_technique: BodyTerminatorNav # highest-confidence + # per-technique result, even + # though wrong — the test's + # primary_technique rule is + # max-confidence with + # tie-break by name + techniques_must_run: [BodyDiscCorrelateNav, BodyLimbNav, BodyTerminatorNav] + techniques_must_skip: [] From 8486850838a6c3e153884d4c4332c79e5aa58826 Mon Sep 17 00:00:00 2001 From: Robert French Date: Wed, 29 Apr 2026 15:23:57 -0700 Subject: [PATCH 3/4] fix: BodyBlob covariance + at_edge, correlate self-ref, doc accuracy * BodyBlob _joint_covariance: divide weighted-residual sum by total_weight^2 so the diagonal is 1/sum(w) inflated by residual scatter, matching the docstring. * BodyBlob at_edge now flags fits beyond the search window (|fit.dv| >= margin_v - tol) instead of only a thin band around the boundary; the moment-based offset is unbounded so beyond-window fits must zero out. * navigate_single_scale_kpeaks returns a copy of the winner with all_candidates attached separately, breaking the prior self-reference (winner was an entry inside its own all_candidates list). * Doc accuracy: AUTONAV_PLAN now lists peak_to_runner_up_ratio in BodyDiscDiagnostics and documents that navigate_with_pyramid_kpeaks returns top_k_peaks; developer guide describes consistency_px as the maximum Euclidean drift across pyramid levels and points the BodyDisc covariance at fisher_covariance() inside evaluate_candidate(); BodyBlob breakdown sample matches the three real spec terms. * Tests: BodyBlob zero-diameter test asserts the predicted_diameter reason; cap test asserts confidence == 0.4 exactly; missing docstrings added; LIMB_ARC threshold test docstring rewritten to describe the around-the-threshold check. Co-Authored-By: Claude Opus 4.7 (1M context) --- AUTONAV_PLAN.md | 26 ++++++++++++------- docs/developer_guide_techniques.rst | 24 ++++++++++------- .../nav_technique/nav_technique_body_blob.py | 11 ++++---- src/nav/support/correlate.py | 9 +++++-- .../test_nav_model_body_integration.py | 12 +++++++-- .../test_nav_technique_body_blob.py | 10 ++++++- 6 files changed, 62 insertions(+), 30 deletions(-) diff --git a/AUTONAV_PLAN.md b/AUTONAV_PLAN.md index 9dd1d67..b8370ae 100644 --- a/AUTONAV_PLAN.md +++ b/AUTONAV_PLAN.md @@ -1305,11 +1305,13 @@ above operationalise. (closer body's nonzero pixels overwrite farther body's), runs the shared ``navigate_with_pyramid_kpeaks`` with ``use_gradient='auto'``, and emits ``BodyDiscDiagnostics`` populated with the pyramid's - ``ncc_peak`` (PSR), ``consistency_px``, ``used_gradient``, and - ``body_count``. ``hard_zero_if`` fires on ``at_edge`` or + ``ncc_peak``, ``consistency_px``, ``used_gradient``, ``body_count``, + and ``peak_to_runner_up_ratio`` (derived from the wrapper's + ``top_k_peaks`` return). ``hard_zero_if`` fires on ``at_edge`` or ``spurious``. The pyramid wrapper now returns - ``'used_gradient': bool`` so the technique can record the chosen - mode honestly (backwards-compatible addition to + ``'used_gradient': bool`` and ``'top_k_peaks': list`` so the technique + can record the chosen mode honestly and compute the runner-up ratio + without re-running the correlation (backwards-compatible additions to ``nav.support.correlate``). - ``nav.nav_technique.BodyBlobNav`` — joint-translation fit from brightness-weighted-moment centroids over each blob's predicted @@ -2923,11 +2925,17 @@ link to the confidence formula source-of-truth ### Logging / API conventions established in Phase 5 (binding) -- **`navigate_with_pyramid_kpeaks` returns `'used_gradient': bool`.** - Backwards-compatible addition; non-auto callers see `bool(use_gradient)`, - auto callers see whichever mode the picker chose. Future correlation - techniques (e.g. `RingAnnulusNav` in Phase 6) should read this field - rather than re-running the pyramid in both modes. +- **`navigate_with_pyramid_kpeaks` returns `'used_gradient': bool` and + `'top_k_peaks': list[tuple[quality, dv, du]]`.** Both are + backwards-compatible additions. ``used_gradient`` reports + ``bool(use_gradient)`` for non-auto callers and the picker's choice + for ``auto``; ``top_k_peaks`` carries the final-pass per-peak + telemetry (winner at index 0, runner-ups in descending quality) + from which ``BodyDiscCorrelateNav`` derives the + ``peak_to_runner_up_ratio`` diagnostic without re-running the + correlation. Future correlation techniques (e.g. `RingAnnulusNav` + in Phase 6) should read these fields rather than re-running the + pyramid in both modes. - **BODY_DISC `template_img` is a postage stamp sized to `bbox_extfov_vu`.** Both `NavModelBody` and `NavModelBodySimulated` produce postage stamps; future body-emitting NavModels (cartographic, diff --git a/docs/developer_guide_techniques.rst b/docs/developer_guide_techniques.rst index 58f18f5..fa3932e 100644 --- a/docs/developer_guide_techniques.rst +++ b/docs/developer_guide_techniques.rst @@ -229,8 +229,10 @@ Diagnostics fields: ``top_k_peaks`` field (sorted by quality descending). Returns ``1.0`` when only one peak survives non-maximum suppression — the unambiguous-peak case. -- ``consistency_px``: maximum per-axis disagreement between coarse and - fine pyramid levels. +- ``consistency_px``: maximum Euclidean drift across pyramid levels — + ``np.max(np.linalg.norm(level_shifts - final_prior, axis=1))`` over + the coarse-to-fine cascade in + :func:`nav.support.correlate.navigate_with_pyramid_kpeaks`. - ``used_gradient``: ``True`` when auto-mode picked the gradient pass. - ``body_count``: number of fused BODY_DISC features. @@ -297,14 +299,14 @@ trace of the form: .. code-block:: text - Confidence breakdown: alpha0=-1.000, sigmoid_arg=1.133 -> confidence=0.4000 (hard_cap applied) + Confidence breakdown: alpha0=-1.000, sigmoid_arg=0.900 -> confidence=0.4000 (hard_cap applied) term 'body_snr_inside_predicted_bbox': raw=8.00, normalized=1.000, alpha=+0.500 -> contribution=+0.500 term 'body_extent_px': raw=16.00, normalized=1.000, alpha=+1.000 -> contribution=+1.000 term 'blob_count': raw=3.00, normalized=1.000, alpha=+0.400 -> contribution=+0.400 - term 'residual_px': raw=0.10, normalized=0.100, alpha=+0.000 -> contribution=+0.000 The sigmoid argument before clamping is ``-1.0 + 0.5 + 1.0 + 0.4 = -0.9``, the sigmoid evaluates to ``0.711``, and the ``hard_cap = 0.4`` +0.9`` (matching the three terms in ``_BODY_BLOB_CONFIDENCE_SPEC``), +the sigmoid evaluates to ``0.711``, and the ``hard_cap = 0.4`` post-sigmoid clamp drops the headline confidence to the BODY_BLOB ceiling. @@ -364,11 +366,13 @@ See also information-matrix to covariance step that turns the LM Jacobian at convergence into the per-technique 2x2 (or 3x3) covariance reported on every ``NavTechniqueResult``. ``BodyDiscCorrelateNav``'s - covariance comes from the pyramid wrapper's Hessian-of-NCC; both - ``BodyLimbNav`` and ``BodyTerminatorNav`` derive theirs from the - Tukey-reweighted information matrix; ``BodyBlobNav`` derives a - diagonal precision-weighted-mean covariance from the per-blob CRLB - weights. + covariance is the Fisher / CRLB covariance produced by + :func:`nav.support.correlate.fisher_covariance` inside + :func:`nav.support.correlate.evaluate_candidate` and forwarded + through ``navigate_with_pyramid_kpeaks``; both ``BodyLimbNav`` and + ``BodyTerminatorNav`` derive theirs from the Tukey-reweighted + information matrix; ``BodyBlobNav`` derives a diagonal + precision-weighted-mean covariance from the per-blob CRLB weights. - :func:`nav.feature.composition.compose_template_features` — the Z-buffer paint helper that ``BodyDiscCorrelateNav`` uses to fuse per-body templates into a single composite for the NCC. diff --git a/src/nav/nav_technique/nav_technique_body_blob.py b/src/nav/nav_technique/nav_technique_body_blob.py index 68e9327..4fc8d08 100644 --- a/src/nav/nav_technique/nav_technique_body_blob.py +++ b/src/nav/nav_technique/nav_technique_body_blob.py @@ -304,8 +304,9 @@ def _joint_covariance( return float(floor) * np.eye(2, dtype=np.float64) residuals_v = offsets_v - dv residuals_u = offsets_u - du - var_v = max(float(np.sum(weights * residuals_v * residuals_v) / total_weight), floor) - var_u = max(float(np.sum(weights * residuals_u * residuals_u) / total_weight), floor) + total_weight_sq = max(total_weight * total_weight, 1e-24) + var_v = max(float(np.sum(weights * residuals_v * residuals_v) / total_weight_sq), floor) + var_u = max(float(np.sum(weights * residuals_u * residuals_u) / total_weight_sq), floor) return np.diag([var_v, var_u]).astype(np.float64) @@ -384,10 +385,8 @@ def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechni return self._fail_no_signal(features=eligible, noise_sigma=noise_sigma) fit = _joint_offset_from_residuals(residuals) at_edge = ( - abs(fit.dv - margin_v) <= AT_EDGE_TOLERANCE_PX - or abs(fit.dv + margin_v) <= AT_EDGE_TOLERANCE_PX - or abs(fit.du - margin_u) <= AT_EDGE_TOLERANCE_PX - or abs(fit.du + margin_u) <= AT_EDGE_TOLERANCE_PX + abs(fit.dv) >= margin_v - AT_EDGE_TOLERANCE_PX + or abs(fit.du) >= margin_u - AT_EDGE_TOLERANCE_PX ) mean_snr = float(np.mean(residuals.snrs)) mean_extent = float(np.mean(residuals.extents)) diff --git a/src/nav/support/correlate.py b/src/nav/support/correlate.py index 7a0b523..f313def 100644 --- a/src/nav/support/correlate.py +++ b/src/nav/support/correlate.py @@ -629,8 +629,13 @@ def navigate_single_scale_kpeaks( # runner-up peaks (e.g. peak-to-runner-up ratio diagnostics) can do # so without re-running the correlation. Sorted by quality desc so # ``all_candidates[0]`` is the winner and ``[1:]`` are runner-ups. - winner['all_candidates'] = sorted(candidates, key=lambda r: r['quality'], reverse=True) - return winner + # Return a shallow copy of the winner with the per-candidate list + # attached separately so the returned dict is not self-referential + # (the original winner remains an entry inside the new list). + sorted_candidates = sorted(candidates, key=lambda r: r['quality'], reverse=True) + result = dict(winner) + result['all_candidates'] = sorted_candidates + return result # ============================================================== diff --git a/tests/nav/nav_model/test_nav_model_body_integration.py b/tests/nav/nav_model/test_nav_model_body_integration.py index 478496c..b80143c 100644 --- a/tests/nav/nav_model/test_nav_model_body_integration.py +++ b/tests/nav/nav_model/test_nav_model_body_integration.py @@ -227,11 +227,19 @@ def test_to_features_skips_terminator_when_polyline_too_short(fake_obs: FakeObs) def test_to_features_limb_uncertainty_at_threshold(fake_obs: FakeObs) -> None: - """A body sitting exactly at the LIMB_ARC threshold still emits the arc. + """The LIMB_ARC vs BODY_BLOB switch is correctly placed around the threshold. With ``LIMB_ARC_MAX_UNCERTAINTY_PX = 3.0`` and Saturn's ``ellipsoid_residual_km = 50`` (gas-giant default), the threshold - is reached at ``km_per_pixel_at_limb = 50 / 3.0 ~= 16.67``. + sits at ``km_per_pixel_at_limb = 50 / 3.0 ~= 16.67``. This test + probes either side of that threshold: + + * ``km_per_pixel_at_limb = 17.0`` (uncertainty ~ 2.94 px, below + threshold) — ``saturn_model.to_features`` must emit a + ``LIMB_ARC``. + * ``km_per_pixel_at_limb = 15.0`` (uncertainty ~ 3.33 px, above + threshold) — the limb branch must drop and the technique falls + through to ``BODY_BLOB``. """ # km_per_pixel_at_limb=17 yields uncertainty ~ 2.94 < 3.0 -> LIMB_ARC. saturn_model = _build_body(obs=fake_obs, body_name='SATURN', km_per_pixel_at_limb=17.0) diff --git a/tests/nav/nav_technique/test_nav_technique_body_blob.py b/tests/nav/nav_technique/test_nav_technique_body_blob.py index 7f41031..8778043 100644 --- a/tests/nav/nav_technique/test_nav_technique_body_blob.py +++ b/tests/nav/nav_technique/test_nav_technique_body_blob.py @@ -137,6 +137,7 @@ def test_body_blob_returns_zero_confidence_when_image_blank( def test_body_blob_infeasible_on_empty_input() -> None: + """``is_feasible([])`` reports infeasibility with a no-features reason.""" technique = BodyBlobNav() report = technique.is_feasible([]) assert report.feasible is False @@ -144,6 +145,11 @@ def test_body_blob_infeasible_on_empty_input() -> None: def test_body_blob_infeasible_on_zero_diameter() -> None: + """A BODY_BLOB feature with ``predicted_diameter_px == 0`` is infeasible. + + Asserts the reason names the predicted-diameter requirement so a + regression that returns a generic infeasibility message is caught. + """ feature = NavFeature( feature_id='body_blob:zero', feature_type=NavFeatureType.BODY_BLOB, @@ -165,6 +171,7 @@ def test_body_blob_infeasible_on_zero_diameter() -> None: technique = BodyBlobNav() report = technique.is_feasible([feature]) assert report.feasible is False + assert 'predicted_diameter' in report.reason def test_body_blob_confidence_capped_at_0_4( @@ -190,10 +197,11 @@ def test_body_blob_confidence_capped_at_0_4( technique = BodyBlobNav() context = make_nav_context(image) result = technique.navigate(features, context) - assert result.confidence <= 0.4 + 1e-12 + assert result.confidence == pytest.approx(0.4, abs=1e-12) def test_body_blob_registered_with_navtechnique_registry() -> None: + """``BodyBlobNav`` is auto-registered in ``NavTechnique._registry`` on import.""" from nav.nav_technique.nav_technique import NavTechnique assert BodyBlobNav in NavTechnique._registry From 34956a274338f94248d873f45f3e6367955d00cc Mon Sep 17 00:00:00 2001 From: Robert French Date: Wed, 29 Apr 2026 15:38:32 -0700 Subject: [PATCH 4/4] docs: mark Phase 5 complete and drop dead extfov fallback * AUTONAV_PLAN overview table: Phase 5 row updated from "Pending" to "Complete (branch core_rewrite_phase5)" so it agrees with the Phase 5 section header further down. * nav_technique_body_blob._search_window_for_obs: drop the unreachable getattr(obs, 'extfov_margin_vu', None) -> (32, 32) fallback. ObsSnapshot.__init__ always sets the property to a validated (v, u) tuple so the None branch can never run. Co-Authored-By: Claude Opus 4.7 (1M context) --- AUTONAV_PLAN.md | 2 +- src/nav/nav_technique/nav_technique_body_blob.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/AUTONAV_PLAN.md b/AUTONAV_PLAN.md index b8370ae..595f886 100644 --- a/AUTONAV_PLAN.md +++ b/AUTONAV_PLAN.md @@ -1026,7 +1026,7 @@ techniques broaden coverage. | **2** | DT-based NavTechniques (`BodyLimbNav`, `BodyTerminatorNav`, `RingEdgeNav`) | **Complete** (branch `core_rewrite_dt_techniques`) | | **3** | Foundation completion + per-instrument config wiring | **Complete** (branch `core_rewrite_catchup`) | | **4** | First navigable image (end-to-end DT-only) | **Complete** (branch `core_rewrite_phase4`) | -| **5** | Body disc + body blob techniques | Pending | +| **5** | Body disc + body blob techniques | **Complete** (branch `core_rewrite_phase5`) | | **6** | Ring-annulus technique | Pending | | **7** | Star techniques part 1 (unique-match + refine) | Pending | | **8** | `StarFieldFromCatalogNav` (multi-star RANSAC) | Pending | diff --git a/src/nav/nav_technique/nav_technique_body_blob.py b/src/nav/nav_technique/nav_technique_body_blob.py index 4fc8d08..c976eb4 100644 --- a/src/nav/nav_technique/nav_technique_body_blob.py +++ b/src/nav/nav_technique/nav_technique_body_blob.py @@ -479,8 +479,5 @@ def __init__(self, *, at_edge: bool, diagnostics: BodyBlobDiagnostics) -> None: def _search_window_for_obs(context: NavContext) -> tuple[int, int]: """Return the ``(margin_v, margin_u)`` search window for at-edge detection.""" - obs = context.obs - margin = getattr(obs, 'extfov_margin_vu', None) - if margin is None: - return (32, 32) - return (int(margin[0]), int(margin[1])) + margin_v, margin_u = context.obs.extfov_margin_vu + return (int(margin_v), int(margin_u))