Source code for spindoctor.nav_technique.nav_technique_body_disc

"""``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

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import numpy as np
from scipy.ndimage import rotate as ndimage_rotate

from spindoctor.config import Config
from spindoctor.feature.composition import compose_template_features
from spindoctor.feature.feature import NavFeature, body_names_from_features
from spindoctor.feature.feature_type import NavFeatureType
from spindoctor.feature.geometry import BodyDiscGeometry
from spindoctor.nav_technique.confidence import evaluate_sigmoid_combination
from spindoctor.nav_technique.diagnostics import BodyDiscDiagnostics
from spindoctor.nav_technique.feasibility import NavFeasibilityReport
from spindoctor.nav_technique.nav_technique import (
    ROTATION_UNOBSERVABLE_VARIANCE,
    NavTechnique,
    add_size_scaled_model_error,
    load_ncc_covariance_tuning,
    log_confidence_breakdown,
    search_window_for_obs,
)
from spindoctor.nav_technique.technique_result import NavTechniqueResult
from spindoctor.support.correlate import navigate_with_pyramid_kpeaks, peak_to_runner_up_ratio
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType

if TYPE_CHECKING:  # pragma: no cover - typing-only import
    from spindoctor.nav_orchestrator.nav_context import NavContext

__all__ = ['BodyDiscCorrelateNav']


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
    ]


@dataclass(frozen=True)
class _RotationCandidate:
    """One rotation sample's NCC pyramid result.

    Stored across the multi-level rotation schedule so the final
    refinement can pick the global winner and reuse the per-rotation
    outcome for the rotation-axis curvature estimate.

    Parameters:
        theta_rad: Rotation angle (radians) at which the template was
            rotated about the body-centroid pivot.
        ncc_result: Raw dictionary returned by
            :func:`spindoctor.support.correlate.navigate_with_pyramid_kpeaks`
            for this rotation sample.
    """

    theta_rad: float
    ncc_result: dict[str, Any]


def _zero_padded_shift(
    arr: NDArrayFloatType,
    shift_v: int,
    shift_u: int,
    *,
    fill_value: float = 0.0,
) -> NDArrayFloatType:
    """Translate ``arr`` by ``(shift_v, shift_u)`` with zero (or ``fill_value``)
    padding on the boundary.

    Equivalent to ``np.roll`` but drops out-of-bounds pixels rather than
    wrapping them to the opposite edge — the wrapping behaviour is wrong
    for body / ring templates whose support sits anywhere off the array
    centre, because rotation about a pivot followed by a shift back
    would otherwise smear template content into the diametrically
    opposite corner of the image.

    Works on 2-D arrays of any dtype; the destination is allocated with
    ``arr.dtype`` so boolean masks stay boolean.
    """
    h, w = arr.shape[:2]
    out = np.full(arr.shape, fill_value, dtype=arr.dtype)
    src_v_lo = max(0, -shift_v)
    src_v_hi = min(h, h - shift_v)
    src_u_lo = max(0, -shift_u)
    src_u_hi = min(w, w - shift_u)
    if src_v_hi <= src_v_lo or src_u_hi <= src_u_lo:
        return out
    dst_v_lo = src_v_lo + shift_v
    dst_u_lo = src_u_lo + shift_u
    dst_v_hi = dst_v_lo + (src_v_hi - src_v_lo)
    dst_u_hi = dst_u_lo + (src_u_hi - src_u_lo)
    out[dst_v_lo:dst_v_hi, dst_u_lo:dst_u_hi] = arr[src_v_lo:src_v_hi, src_u_lo:src_u_hi]
    return out


def _rotate_template(
    template_img: NDArrayFloatType,
    template_mask: NDArrayBoolType,
    pivot_vu: tuple[float, float],
    theta_rad: float,
) -> tuple[NDArrayFloatType, NDArrayBoolType]:
    """Rotate the composite template + mask about ``pivot_vu`` by ``theta_rad``.

    Uses :func:`scipy.ndimage.rotate` followed by a zero-padded translate
    that re-centres the rotation pivot — ``ndimage.rotate`` rotates about
    the array centre, so we shift the array so the pivot is at the centre,
    rotate, then shift back.  Both shifts use :func:`_zero_padded_shift`
    rather than ``np.roll`` so out-of-bounds pixels are dropped instead of
    wrapping to the opposite edge (a wrap would smear template content
    across the image after the second shift).  The shift is integer-
    rounded which sacrifices < 1 px of pivot accuracy for a much simpler
    implementation; the rotation samples are coarse (≥ 0.25 deg) so the
    rounding error is well below the per-pixel template grid.

    Returns the rotated template (float64, same shape as input) and the
    rotated mask (bool, same shape).  The mask uses nearest-neighbour
    interpolation so it stays binary.
    """
    if abs(theta_rad) < 1e-12:
        return template_img.astype(np.float64, copy=True), template_mask.astype(bool, copy=True)
    pivot_v, pivot_u = pivot_vu
    h, w = template_img.shape[:2]
    centre_v = (h - 1) / 2.0
    centre_u = (w - 1) / 2.0
    shift_v = round(centre_v - pivot_v)
    shift_u = round(centre_u - pivot_u)
    shifted = _zero_padded_shift(template_img.astype(np.float64, copy=False), shift_v, shift_u)
    shifted_mask = _zero_padded_shift(
        template_mask.astype(np.uint8, copy=False), shift_v, shift_u, fill_value=0
    )
    rotated = ndimage_rotate(
        shifted,
        angle=math.degrees(theta_rad),
        reshape=False,
        order=1,
        mode='constant',
        cval=0.0,
    )
    rotated_mask = ndimage_rotate(
        shifted_mask,
        angle=math.degrees(theta_rad),
        reshape=False,
        order=0,
        mode='constant',
        cval=0,
    )
    rotated = _zero_padded_shift(rotated, -shift_v, -shift_u)
    rotated_mask = _zero_padded_shift(rotated_mask, -shift_v, -shift_u, fill_value=0)
    return (
        rotated.astype(np.float64, copy=False),
        rotated_mask.astype(bool, copy=False),
    )


def _composite_pivot_vu(features: list[NavFeature]) -> tuple[float, float]:
    """Return the centroid-of-body-centres pivot for a multi-body composite.

    The composite template is the union of every body's per-body template
    painted into ext-FOV coordinates; the natural rotation pivot is the
    centroid of the bodies' predicted centres in that same frame.  A
    single-body composite reduces to that body's predicted centre.

    Raises:
        ValueError: if ``features`` is empty (the upstream feasibility
            gate should never let this happen, but the check guards
            against a degenerate divide-by-zero).
    """
    if not features:
        raise ValueError(
            '_composite_pivot_vu requires at least one BODY_DISC feature; got an empty list'
        )
    centres = [
        feat.geometry.predicted_center_vu  # type: ignore[union-attr]
        for feat in features
    ]
    cv = sum(c[0] for c in centres) / len(centres)
    cu = sum(c[1] for c in centres) / len(centres)
    return float(cv), float(cu)


[docs] 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_attributes = frozenset( { 'at_edge', 'spurious', 'ncc_peak', 'peak_to_runner_up_ratio', 'consistency_px', 'consistency_ratio', 'used_gradient', 'body_count', } ) def __init__(self, *, config: Config | None = None) -> None: super().__init__(config=config) self.config.read_config() # ensure cls.tuning is populated self._rotation_at_edge_fraction = float(self.tuning['rotation_at_edge_fraction']) self._consistency_max_fraction_of_diameter = float( self.tuning['consistency_max_fraction_of_diameter'] ) self._consistency_max_px = float(self.tuning['consistency_max_px']) self._refine_lowpass_sigma_px = float(self.tuning['refine_lowpass_sigma_px']) self._cov_tuning = load_ncc_covariance_tuning(self.tuning, self.name)
[docs] 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), )
[docs] def navigate(self, features: list[NavFeature], context: NavContext) -> NavTechniqueResult: """Compute the joint-translation offset from the input BODY_DISC templates. When ``context.fit_camera_rotation`` is False the technique runs a single 2-D NCC pyramid against the unrotated composite template. When the flag is True it runs the rotation-aware schedule described in :meth:`_run_3dof_pyramid` — 11 + 5 + 3 NCC pyramid evaluations across rotation samples spanning ``±max_rotation_deg``, picking the (dv, du, theta) with the best NCC quality. 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``, ``obs.extfov_margin_vu``, plus ``fit_camera_rotation`` / ``max_rotation_deg``. Returns: A ``NavTechniqueResult`` with the recovered offset, 2x2 or 3x3 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() consistency_tol = self._consistency_tol_for(eligible) self.logger.debug( 'Composite template: %d painted pixels; search window (v, u) = (%d, %d) px; ' 'upsample factor = %d; consistency tolerance = %.2f px', int(template_mask.sum()), margin_v, margin_u, up_factor, consistency_tol, ) fit_rotation = bool(context.fit_camera_rotation) if fit_rotation: pivot = _composite_pivot_vu(eligible) best_theta_rad, ncc_result, sigma_theta_rad = self._run_3dof_pyramid( image=context.image_ext, template_img=template_img, template_mask=template_mask, pivot_vu=pivot, max_offset_vu=(margin_v, margin_u), data_mask=context.sensor_mask_ext, upsample_factor=up_factor, max_rotation_deg=float(context.max_rotation_deg), consistency_tol=consistency_tol, ) else: best_theta_rad = 0.0 sigma_theta_rad = None ncc_result = navigate_with_pyramid_kpeaks( image=context.image_ext, model=template_img, mask=template_mask, upsample_factor=up_factor, consistency_tol=consistency_tol, max_offset_vu=(margin_v, margin_u), data_mask=context.sensor_mask_ext, use_gradient='auto', refine_lowpass_sigma_px=self._refine_lowpass_sigma_px, localization_uncertainty_scale=( self._cov_tuning.localization_uncertainty_scale ), logger=self.logger, ) dv = float(ncc_result['offset'][0]) du = float(ncc_result['offset'][1]) covariance_2x2 = np.asarray(ncc_result['cov'], np.float64) if covariance_2x2.shape != (2, 2): raise RuntimeError( f'BodyDiscCorrelateNav: navigate_with_pyramid_kpeaks returned ' f'covariance with shape {covariance_2x2.shape}; expected (2, 2). ' 'Investigate the pyramid output rather than silently slicing.' ) # Size-scaled silhouette model error plus absolute floor: the bare # peak-curvature covariance measures statistical precision only, so # add the body-diameter-proportional shape-mismatch term and the # pointing / distortion floor (the localization-spread term was # already folded in by the correlator). covariance_2x2 = add_size_scaled_model_error( covariance_2x2, size_px=self._max_body_extent_px(eligible), size_frac=self._cov_tuning.model_error_size_frac, floor_px=self._cov_tuning.model_error_floor_px, ) spurious = bool(ncc_result['spurious']) at_edge_translation = 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, # ``consistency_tol`` is the same diameter-scaled cap # used by the spurious test, so a result that just # barely cleared the spurious test reads as ratio # slightly under 1.0 and a clean fit on a large body # reads as the small ratio it deserves regardless of # body size. consistency_ratio=consistency / consistency_tol, used_gradient=used_gradient, body_count=len(eligible), ) covariance: NDArrayFloatType rotation_rad: float | None sigma_rotation_rad: float | None rotation_at_edge = False if fit_rotation: max_rotation_rad = math.radians(context.max_rotation_deg) rotation_at_edge = ( abs(best_theta_rad) >= self._rotation_at_edge_fraction * max_rotation_rad ) cov = np.zeros((3, 3), dtype=np.float64) cov[:2, :2] = covariance_2x2 cov[2, 2] = float( sigma_theta_rad * sigma_theta_rad if sigma_theta_rad is not None else ROTATION_UNOBSERVABLE_VARIANCE ) covariance = cov rotation_rad = float(best_theta_rad) sigma_rotation_rad = float(np.sqrt(max(float(cov[2, 2]), 0.0))) else: covariance = covariance_2x2 rotation_rad = None sigma_rotation_rad = None at_edge = at_edge_translation or rotation_at_edge 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 fit_rotation: self.logger.info( 'Rotation = %+.4f deg (sigma %.4f deg)%s', math.degrees(rotation_rad if rotation_rad is not None else 0.0), math.degrees(sigma_rotation_rad if sigma_rotation_rad is not None else 0.0), ', AT_EDGE' if rotation_at_edge else '', ) 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, rotation_rad=rotation_rad, sigma_rotation_rad=sigma_rotation_rad, source_bodies=body_names_from_features(eligible), )
def _consistency_tol_for(self, features: list[NavFeature]) -> float: """Return the diameter-scaled inter-pyramid consistency cap. A small body's NCC peak is naturally sharper in absolute pixels than a large body's, so a flat cap penalizes large bodies twice (real peak walks across pyramid levels scale with the silhouette extent). The applied cap is ``max(consistency_max_px, consistency_max_fraction_of_diameter * max_diameter_px)`` where ``max_diameter_px`` is the largest ext-FOV bbox extent across the consumed BODY_DISC features. """ if not features: return self._consistency_max_px scaled_px = self._consistency_max_fraction_of_diameter * self._max_body_extent_px(features) return max(self._consistency_max_px, scaled_px) @staticmethod def _max_body_extent_px(features: list[NavFeature]) -> float: """Return the largest ext-FOV bbox extent across the consumed features. Used both as the diameter that scales the consistency cap and as the silhouette-model-error size in the reported covariance. Parameters: features: Consumed BODY_DISC features. Returns: The largest ``max(v_extent, u_extent)`` in pixels, or ``0.0`` when ``features`` is empty. """ max_extent_px = 0 for feature in features: v_min, u_min, v_max, u_max = feature.geometry.bbox_extfov_vu max_extent_px = max(max_extent_px, v_max - v_min, u_max - u_min) return float(max_extent_px) def _run_3dof_pyramid( self, *, image: NDArrayFloatType, template_img: NDArrayFloatType, template_mask: NDArrayBoolType, pivot_vu: tuple[float, float], max_offset_vu: tuple[int, int], data_mask: NDArrayBoolType | None, upsample_factor: int, max_rotation_deg: float, consistency_tol: float, ) -> tuple[float, dict[str, Any], float | None]: """Run the multi-level rotation-sample schedule. Three coarse-to-fine passes: * Level 0 — 11 samples spanning ``±max_rotation_deg`` (step = ``2 * max_rotation_deg / 10`` deg, which is exactly 1 deg at the default ``max_rotation_deg = 5``). Pick the rotation with the highest NCC peak quality. * Level 1 — 5 samples in 0.5 deg steps centred on the level-0 winner. Pick the new winner. * Level 2 — 3 samples in 0.25 deg steps centred on the level-1 winner. The peak's local quality curvature feeds the rotation uncertainty estimate. Returns ``(best_theta_rad, best_ncc_result, sigma_theta_rad)``. ``sigma_theta_rad`` is ``None`` when the level-2 quality is not concave around the winner — in that case the rotation slot of the 3x3 covariance carries the unobservable sentinel. """ # Level 0: coarse 11-sample search across the configured cap. max_rotation_rad = math.radians(max_rotation_deg) l0_thetas = np.linspace(-max_rotation_rad, max_rotation_rad, 11) self.logger.debug( '3-DoF pyramid level 0: %d samples across +-%.2f deg (step %.3f deg)', l0_thetas.size, max_rotation_deg, 2.0 * max_rotation_deg / 10.0, ) l0_winner = self._evaluate_rotation_samples( thetas_rad=l0_thetas.tolist(), image=image, template_img=template_img, template_mask=template_mask, pivot_vu=pivot_vu, max_offset_vu=max_offset_vu, data_mask=data_mask, upsample_factor=upsample_factor, consistency_tol=consistency_tol, ) # Level 1: 5 samples in 0.5 deg steps centred on the level-0 winner, # clamped to the configured ``+-max_rotation_deg`` cap so the # outer-loop search never proposes a rotation outside the # operator-supplied envelope. step_l1 = math.radians(0.5) l1_thetas = [ max(-max_rotation_rad, min(max_rotation_rad, l0_winner.theta_rad + i * step_l1)) for i in (-2, -1, 0, 1, 2) ] self.logger.debug( '3-DoF pyramid level 1: 5 samples around %+.4f deg in 0.5 deg steps (clamped)', math.degrees(l0_winner.theta_rad), ) l1_winner = self._evaluate_rotation_samples( thetas_rad=l1_thetas, image=image, template_img=template_img, template_mask=template_mask, pivot_vu=pivot_vu, max_offset_vu=max_offset_vu, data_mask=data_mask, upsample_factor=upsample_factor, consistency_tol=consistency_tol, ) # Level 2: 3 samples in 0.25 deg steps centred on the level-1 # winner, again clamped to the cap. step_l2 = math.radians(0.25) l2_thetas = [ max(-max_rotation_rad, min(max_rotation_rad, l1_winner.theta_rad + i * step_l2)) for i in (-1, 0, 1) ] self.logger.debug( '3-DoF pyramid level 2: 3 samples around %+.4f deg in 0.25 deg steps (clamped)', math.degrees(l1_winner.theta_rad), ) l2_candidates = [ _RotationCandidate( theta_rad=theta, ncc_result=self._ncc_at_rotation( image=image, template_img=template_img, template_mask=template_mask, pivot_vu=pivot_vu, theta_rad=theta, max_offset_vu=max_offset_vu, data_mask=data_mask, upsample_factor=upsample_factor, consistency_tol=consistency_tol, ), ) for theta in l2_thetas ] l2_winner = max(l2_candidates, key=lambda c: float(c.ncc_result['quality'])) sigma_theta_rad = self._rotation_sigma_from_quality( candidates=l2_candidates, winner=l2_winner, step_rad=step_l2, ) sigma_str = f'{math.degrees(sigma_theta_rad):.4f}' if sigma_theta_rad is not None else 'inf' self.logger.info( 'Rotation pyramid winner: %+.4f deg, quality %.3f, sigma_theta %s deg', math.degrees(l2_winner.theta_rad), float(l2_winner.ncc_result['quality']), sigma_str, ) return l2_winner.theta_rad, l2_winner.ncc_result, sigma_theta_rad def _evaluate_rotation_samples( self, *, thetas_rad: list[float], image: NDArrayFloatType, template_img: NDArrayFloatType, template_mask: NDArrayBoolType, pivot_vu: tuple[float, float], max_offset_vu: tuple[int, int], data_mask: NDArrayBoolType | None, upsample_factor: int, consistency_tol: float, ) -> _RotationCandidate: """Run an NCC pyramid for each rotation sample; return the highest-quality candidate. Parameters: thetas_rad: Rotation samples (radians) to evaluate. image: Source image (ext-FOV) shared across rotation samples. template_img: Composite body-disc template (pre-rotation). template_mask: Mask of the composite template. pivot_vu: Centroid-of-body-centres pivot ``(v, u)``; the template is rotated about this pixel before each NCC. max_offset_vu: Translation-search window in pixels. data_mask: Optional sensor mask passed through to the pyramid for the bi-directional NCC path. upsample_factor: FFT upsample factor. Returns: The :class:`_RotationCandidate` whose NCC pyramid reported the highest quality across the supplied rotation samples. """ best: _RotationCandidate | None = None for theta in thetas_rad: ncc_result = self._ncc_at_rotation( image=image, template_img=template_img, template_mask=template_mask, pivot_vu=pivot_vu, theta_rad=theta, max_offset_vu=max_offset_vu, data_mask=data_mask, upsample_factor=upsample_factor, consistency_tol=consistency_tol, ) candidate = _RotationCandidate(theta_rad=theta, ncc_result=ncc_result) if best is None or float(ncc_result['quality']) > float(best.ncc_result['quality']): best = candidate assert best is not None # thetas_rad guaranteed non-empty by callers return best def _ncc_at_rotation( self, *, image: NDArrayFloatType, template_img: NDArrayFloatType, template_mask: NDArrayBoolType, pivot_vu: tuple[float, float], theta_rad: float, max_offset_vu: tuple[int, int], data_mask: NDArrayBoolType | None, upsample_factor: int, consistency_tol: float, ) -> dict[str, Any]: """Rotate the template about ``pivot_vu`` and run a single NCC pyramid. Parameters: image: Source ext-FOV image. template_img: Composite body-disc template (pre-rotation). template_mask: Mask of the composite template. pivot_vu: Pivot ``(v, u)`` about which the template is rotated before correlation. theta_rad: Rotation angle (radians) to apply. max_offset_vu: Translation-search window in pixels. data_mask: Optional sensor mask threaded into the pyramid. upsample_factor: FFT upsample factor. Returns: The raw dict returned by :func:`spindoctor.support.correlate.navigate_with_pyramid_kpeaks` (offset, covariance, quality, consistency, top-K peaks, spurious / at-edge flags, gradient-mode tag). """ rotated_img, rotated_mask = _rotate_template( template_img, template_mask, pivot_vu=pivot_vu, theta_rad=theta_rad, ) return navigate_with_pyramid_kpeaks( image=image, model=rotated_img, mask=rotated_mask, upsample_factor=upsample_factor, consistency_tol=consistency_tol, max_offset_vu=max_offset_vu, data_mask=data_mask, use_gradient='auto', refine_lowpass_sigma_px=self._refine_lowpass_sigma_px, localization_uncertainty_scale=self._cov_tuning.localization_uncertainty_scale, logger=self.logger, ) @staticmethod def _rotation_sigma_from_quality( *, candidates: list[_RotationCandidate], winner: _RotationCandidate, step_rad: float, ) -> float | None: """Report disc rotation uncertainty as unobservable (always ``None``). NAV-010. The NCC peak quality returned by :func:`spindoctor.support.correlate.navigate_with_pyramid_kpeaks` is a PSR / PMR-style separation ratio, not a log-likelihood. The former curvature-to-variance map ``sigma_theta**2 = 1 / (-H * q_centre)`` (with ``H`` the level-2 quality second derivative) is dimensionally ``rad**2 / quality**2`` and has no calibrated relationship to angular variance: PSR/PMR do not live on a log-likelihood scale, so the curvature carries no Fisher information about rotation. A calibrated mapping from the NCC-peak rotation curvature to an angular variance is not yet available, so disc rotation uncertainty is reported as unobservable. Returning ``None`` routes the caller to the :data:`~spindoctor.nav_technique.nav_technique.ROTATION_UNOBSERVABLE_VARIANCE` sentinel in the 3x3 covariance's rotation slot (the ensemble's ``pinvh`` combine already maps that huge variance to a near-zero rotation information contribution), so the disc technique contributes a translation estimate while abstaining on rotation. Parameters: candidates: The three level-2 :class:`_RotationCandidate` samples (unused; retained for the call-site signature and a future calibrated implementation). winner: The rotation-pyramid winner (unused; see above). step_rad: Angular sampling step in radians (unused). Returns: Always ``None`` -- disc rotation variance is reported as unobservable pending a calibrated curvature -> variance map. """ return None 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.consistency_ratio = diagnostics.consistency_ratio self.used_gradient = diagnostics.used_gradient self.body_count = float(diagnostics.body_count)