Source code for spindoctor.nav_model.nav_model_body

"""Catalog-driven body NavModel.

Renders one body's predicted appearance from SPICE, classifies it
against the design's emission rules (limb arc vs. body disc vs. blob vs.
terminator), and emits one ``NavFeature`` per surviving feature type.

The pipeline:

1. Builds an oversampled meshgrid around the predicted body bounding
   box so the limb silhouette is anti-aliased.
2. Extracts the limb and terminator polylines from the discrete
   silhouette masks.
3. Looks up the per-body shape parameters via
   :func:`spindoctor.nav_model.body_shape.load_body_shape`, which merges the
   operator-curated ``config_220_body_shape.yaml`` over the hard-coded
   ``BODY_SHAPE_TABLE`` profiles.
4. Decides which features to emit by computing
   ``limb_uncertainty_px`` and the ``visible_lit_fraction`` /
   ``overflow_fraction`` quantities the design specifies.

The feature-by-feature emission rules:

- ``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 ``max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px)``
  *and* the limb uncertainty is too high for ``LIMB_ARC`` *and* the
  rendered silhouette contains at least one lit pixel.  A body whose
  silhouette is entirely in shadow has zero photometric signal to
  centroid, so it emits no blob (only the geometric features, per the
  dev guide's Restrictions).  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
  ``BODY_DISC_MAX_OVERFLOW_FRACTION``.
- ``TERMINATOR_ARC`` is emitted when the terminator polyline has at
  least ``TERMINATOR_MIN_VERTICES`` surviving vertices and the
  phase-angle factor (``sin(phase_angle)``) is above
  ``TERMINATOR_MIN_PHASE_FACTOR``.
"""

from __future__ import annotations

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

import numpy as np
import polymath
from oops import Meshgrid
from oops.backplane import Backplane

from spindoctor.annotation import Annotations
from spindoctor.config import DEFAULT_CONFIG, Config
from spindoctor.feature.constants import (
    INCIDENCE_FACTOR_ANGLE_CAP_DEG,
    INCIDENCE_FACTOR_CLIP_DEG,
    MAX_INCIDENCE_FACTOR_CAP,
)
from spindoctor.feature.feature import NavFeature, NavReliabilityBreakdown
from spindoctor.feature.feature_type import NavFeatureType
from spindoctor.feature.flags import (
    BodyDiscFlags,
    LimbArcFlags,
    TerminatorArcFlags,
)
from spindoctor.feature.geometry import (
    BodyDiscGeometry,
    LimbPolyline,
    TerminatorPolyline,
)
from spindoctor.nav_model.body_shape import BodyShape, load_body_shape
from spindoctor.nav_model.nav_model import NavModel
from spindoctor.nav_model.nav_model_body_base import (
    BODY_BLOB_MIN_DIAMETER_PX,
    NavModelBodyBase,
    _sigmoid,
)
from spindoctor.nav_model.stars.predicted_snr import psf_sigma_px
from spindoctor.support.constants import HALFPI
from spindoctor.support.image import filter_downsample, shift_array
from spindoctor.support.time import now_dt
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType

if TYPE_CHECKING:  # pragma: no cover - typing-only import
    from oops import Observation

    from spindoctor.nav_orchestrator.nav_context import NavContext

from spindoctor.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',
    'LIMB_ARC_MAX_UNCERTAINTY_PX',
    'TERMINATOR_MIN_PHASE_FACTOR',
    'TERMINATOR_MIN_VERTICES',
    'TITAN_BODY_NAME',
    'NavModelBody',
    'bodies_in_extfov',
    'limb_reliability',
    'shape_features_suppressed',
    'terminator_reliability',
]


BODY_POSITION_SLOP_FRAC: float = 0.05
"""Inflation factor for the body bbox before clipping.

The ``oops.inventory`` bounding box is sometimes a half-pixel too small.
Inflating it by 5% before clipping into the extfov keeps anti-aliased
limb pixels from being lost on the boundary.
"""


LIMB_ARC_MAX_UNCERTAINTY_PX: float = 3.0
"""Cap on the limb normal-sigma at which LIMB_ARC remains useful.

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


LIMB_ARC_MIN_VERTICES: int = 30
"""Minimum limb-polyline vertices to emit a LIMB_ARC.

Matches ``BodyLimbNav``'s ``min_arc_vertices`` feasibility floor
(``config_510_techniques.yaml``): an arc the technique is guaranteed to
reject cannot constrain any fit, so emitting it only starves the body of
its BODY_BLOB feature.  The uncertainty cap above cannot stand in for
this check -- the per-vertex sigma scales with the shape residual in
*pixels*, so a distant small body (a few-km residual mapped through a
long range) passes the uncertainty test precisely because it is tiny,
which is when the limb fit is least usable.  Below this floor the
extractor falls through to ``BODY_BLOB``.
"""


BODY_DISC_MIN_VISIBLE_LIT_FRACTION: float = 0.4
"""Minimum lit-and-in-FOV fraction for BODY_DISC emission.

Below 40% of the lit hemisphere visible, the disc match is too
asymmetric to be useful; BODY_BLOB or LIMB_ARC carries the load.
"""


BODY_DISC_MAX_OVERFLOW_FRACTION: float = 0.3
"""Maximum overflow fraction for BODY_DISC emission.

A body whose disc is more than 30% off-frame loses too much template
support for the correlation peak to be sharp.
"""


TERMINATOR_MIN_VERTICES: int = 8
"""Minimum surviving vertices for TERMINATOR_ARC emission."""


TERMINATOR_MIN_PHASE_FACTOR: float = 0.05
"""Minimum ``sin(phase_angle)`` for TERMINATOR_ARC emission.

Below sin(phase) ~= 0.05 (phase < 3 deg) the terminator is too close to
the limb to be photometrically distinguishable.
"""


TERMINATOR_PHOTOMETRIC_SOFTNESS_COEFF: float = 0.5
"""Photometric-softness coefficient in the terminator normal-sigma.

Scales the per-vertex limb-softness length (``psf_sigma_px * km/px``)
into an additional position-uncertainty term for terminator arcs, where
the gradual brightness roll-off broadens the apparent edge beyond the
geometric terminator.  The numeric value is a config default pending
calibration against the operator-curated image library (tracked with the
other body thresholds by issue #118 / CODE-NAV-MODEL-002).
"""


TITAN_BODY_NAME: str = 'TITAN'
"""SPICE name of the one body handled as a special opaque-atmosphere case.

Titan's thick haze hides the surface and its visible limb is the haze top,
wavelength-dependent and hundreds of km above the ground, so ellipsoid
limb / terminator / disc navigation is systematically wrong rather than
merely noisy; at high phase Titan is not even a circle.  Titan builds no
shape-based ``NavModelBody`` -- :class:`~spindoctor.nav_model.nav_model_titan.NavModelTitan`
records a no-result instead.  Titan's atmosphere is unique (transparent in
some wavelengths), so it is handled as a deliberate special case; its
handling does not generalize to other thick-atmosphere bodies such as Venus.
"""


[docs] def bodies_in_extfov( obs: Observation, *, config: Config | None = None ) -> list[tuple[str, dict[str, Any]]]: """Return ``(body_name, inventory_entry)`` for each body inside the extfov. Queries ``obs.inventory`` once with the planet plus its configured satellites and keeps every body whose ``inventory_body_in_extfov`` predicate fires. Shared by the shape-based body model and the Titan model so both select from the same in-FOV body set. Parameters: obs: Observation snapshot. config: Configuration whose satellite catalog decides which bodies are considered; ``None`` uses ``DEFAULT_CONFIG``. Returns: List of ``(body_name, inventory_entry)`` pairs in planet-then-satellite order; empty when the observation exposes no usable inventory. """ cfg = config if config is not None else DEFAULT_CONFIG planet = getattr(obs, 'closest_planet', None) if planet is None: return [] body_list: list[str] = [planet, *list(cfg.satellites(planet))] inventory_method = getattr(obs, 'inventory', None) if not callable(inventory_method): return [] try: inv = inventory_method(body_list, return_type='full') except ValueError: # oops raises ValueError for a body it cannot resolve in the scene; # that is a recoverable "nothing in the extfov" outcome. Let # TypeError / AttributeError propagate -- they signal a malformed obs # or a genuine bug, not an empty FOV. return [] in_extfov = getattr(obs, 'inventory_body_in_extfov', None) if not callable(in_extfov): return [] out: list[tuple[str, dict[str, Any]]] = [] for body_name in body_list: entry = inv.get(body_name) if entry is None: continue if not in_extfov(entry): continue out.append((body_name, entry)) return out
@dataclass(frozen=True) class _PolylineSampler: """Bundle of sampled limb / terminator polyline data. Encapsulates the per-vertex outputs of the silhouette extraction so multiple feature emitters can share the same data without re-running the discrete-mask traversal. """ vertices_vu: NDArrayFloatType normals_vu: NDArrayFloatType incidence_rad: NDArrayFloatType km_per_pixel: NDArrayFloatType total_vertices: int """Ridge vertices found before per-vertex drops (resolution / future shadow). ``vertices_vu`` holds only the survivors; ``total_vertices`` is the count of the original ridge so :func:`_visible_arc_fraction` can report the surviving fraction rather than a constant. """
[docs] def shape_features_suppressed( shape: BodyShape, predicted_diameter_px: float, *, config: Config ) -> bool: """Whether a body's shape features (limb / terminator / disc) are suppressed. Highly-irregular bodies (chaotic rotators, small potato moons) have no usable ellipsoid. Once such a body is resolved beyond a few pixels the rendered limb / terminator / disc silhouette does not match the real body, so those shape features are suppressed; a point-like BODY_BLOB still navigates the centroid. The 'resolved' threshold reuses the bodies-config ``min_bounding_box_area`` -- its square root is the equivalent linear pixel extent (default 9 px^2 -> 3 px). Bodies tagged merely 'irregular' are left untouched: the continuous ellipsoid-residual widening in the sigma budget already degrades their limb reliability without dropping the feature. This is the shared emission policy: the SPICE-backed model applies it to all three shape features, and the simulated body model applies it to its TERMINATOR_ARC so the two models cannot desync on which bodies terminate. Parameters: shape: The body's catalog shape profile. predicted_diameter_px: Predicted silhouette diameter in pixels. config: Configuration supplying ``bodies.min_bounding_box_area``. Returns: True when the body's shape features must not be emitted. """ resolved_diameter_px = math.sqrt(float(config.bodies.min_bounding_box_area)) return ( shape.shape_class_hint == 'highly_irregular' and predicted_diameter_px > resolved_diameter_px )
def _build_limb_arc( *, body_name: str, sampler: _PolylineSampler, shape: BodyShape, bbox: tuple[int, int, int, int], subject_range_km: float, psf_sigma_px: float, source_model: str, ) -> NavFeature: """Construct the LIMB_ARC NavFeature for one body.""" sigma_normal_per_vertex_px = _sigma_normal_per_vertex( sampler=sampler, shape=shape, psf_sigma_px=psf_sigma_px, include_albedo=False ) sigma_tangent_per_vertex_px = np.full_like(sigma_normal_per_vertex_px, 0.5) visible_arc_fraction = _visible_arc_fraction(sampler) return NavFeature( feature_id=f'limb_arc:{body_name}', feature_type=NavFeatureType.LIMB_ARC, source_model=source_model, geometry=LimbPolyline( vertices_vu=sampler.vertices_vu, normals_vu=sampler.normals_vu, sigma_normal_per_vertex_px=sigma_normal_per_vertex_px, sigma_tangent_per_vertex_px=sigma_tangent_per_vertex_px, bbox_extfov_vu=bbox, ), subject_range_km=subject_range_km, position_cov_px=None, intensity_sigma_rel=0.0, preferred_filter=NavFilterSpec(kind=NavFilterKind.NONE), reliability=limb_reliability( visible_arc_fraction=visible_arc_fraction, visible_arc_px=float(sampler.vertices_vu.shape[0]), ), reliability_reasons=NavReliabilityBreakdown( visible_arc_fraction=visible_arc_fraction, ), usable_types=frozenset({NavFeatureType.LIMB_ARC}), flags=LimbArcFlags( body_name=body_name, visible_arc_fraction=visible_arc_fraction, ), ) def _build_polyline_sampler( *, local_mask: NDArrayBoolType, region_mask: NDArrayBoolType, incidence_local: NDArrayFloatType, km_per_pixel_local: NDArrayFloatType, ext_v0: int, ext_u0: int, ) -> _PolylineSampler: """Sample a polyline along the True pixels of ``local_mask``. The local mask is a 1-pixel-wide ridge along a feature boundary; we return a parallel array of vertex coordinates in extfov space, the outward-pointing normal at each vertex, the per-vertex incidence angle, and the per-vertex km/px scale. The outward normal is the discrete gradient of ``region_mask`` (the body silhouette for the limb, or the lit mask for the terminator), not of the ridge itself: a one-pixel ridge has no consistent interior/exterior orientation, so its gradient sign depends on the diagonal orientation of the ridge rather than on which side is the body interior. With the body-side True / space-side False convention, ``n_v = region[v-1, u] - region[v+1, u]`` and ``n_u = region[v, u-1] - region[v, u+1]`` point from inside to outside. Out-of-image neighbours are treated as space (False). Parameters: local_mask: 1-pixel-wide ridge whose True pixels become vertices. region_mask: Body-side / lit-side mask whose discrete gradient defines the outward normal. Same shape as ``local_mask``. incidence_local: Per-pixel incidence angle (radians). km_per_pixel_local: Per-pixel km/px scale. ext_v0: Extfov v-offset of the local grid origin. ext_u0: Extfov u-offset of the local grid origin. """ vs, us = np.where(local_mask) total_vertices = int(vs.size) if vs.size: # Drop zero-resolution ridge vertices (km/px <= 0): the resolution # backplane is masked / filled with 0.0 off the resolved body, so # such a vertex carries no usable scale. Keeping it would make the # per-vertex sigma NaN, which ``_sigma_normal_per_vertex`` silently # floors to a finite fallback -- letting an off-body vertex pollute # the LM fit instead of being excluded. Dropping it here keeps the # sampler's parallel arrays consistent and the downstream sigmas # finite and positive (which ``lm_subpixel_refine`` requires). resolved = km_per_pixel_local[vs, us] > 0.0 vs, us = vs[resolved], us[resolved] if vs.size == 0: empty: NDArrayFloatType = np.empty((0, 2), dtype=np.float64) return _PolylineSampler( vertices_vu=empty, normals_vu=empty, incidence_rad=np.empty(0, dtype=np.float64), km_per_pixel=np.empty(0, dtype=np.float64), total_vertices=total_vertices, ) vertices_vu: NDArrayFloatType = np.stack( [vs.astype(np.float64) + ext_v0, us.astype(np.float64) + ext_u0], axis=1 ) rows, cols = region_mask.shape region = region_mask.astype(np.float64) normals_vu = np.zeros_like(vertices_vu) for i, (v, u) in enumerate(zip(vs, us, strict=True)): # Outward normal: discrete gradient of the body-side / lit-side # mask, pointing from inside (True) to outside (False). Out-of- # image neighbours count as space (0.0). up = region[v - 1, u] if v > 0 else 0.0 down = region[v + 1, u] if v < rows - 1 else 0.0 left = region[v, u - 1] if u > 0 else 0.0 right = region[v, u + 1] if u < cols - 1 else 0.0 v_dir = up - down u_dir = left - right norm = math.hypot(v_dir, u_dir) or 1.0 normals_vu[i, 0] = v_dir / norm normals_vu[i, 1] = u_dir / norm incidence_rad = incidence_local[vs, us].astype(np.float64) km_per_pixel = km_per_pixel_local[vs, us].astype(np.float64) return _PolylineSampler( vertices_vu=vertices_vu, normals_vu=normals_vu, incidence_rad=incidence_rad, km_per_pixel=km_per_pixel, total_vertices=total_vertices, ) def _incidence_factor_array(incidence_rad: NDArrayFloatType) -> NDArrayFloatType: """Return the design's ``incidence_factor`` array, capped per the constants.""" deg = np.degrees(incidence_rad) deg_clipped = np.clip(deg, 0.0, INCIDENCE_FACTOR_CLIP_DEG) factor = 1.0 / np.cos(np.radians(np.minimum(deg_clipped, INCIDENCE_FACTOR_ANGLE_CAP_DEG))) - 1.0 factor = np.clip(factor, 0.0, MAX_INCIDENCE_FACTOR_CAP) out: NDArrayFloatType = factor.astype(np.float64) return out def _sigma_normal_per_vertex( *, sampler: _PolylineSampler, shape: BodyShape, psf_sigma_px: float, include_albedo: bool, ) -> NDArrayFloatType: """Compute the per-vertex normal-sigma per the design. Implements the formula from Part 1's "Position covariance per feature type" section, including the limb-softness term that uses the per-vertex km/px scale and the optional albedo / photometric contribution for terminator arcs. """ incidence_factor = _incidence_factor_array(sampler.incidence_rad) km_per_pixel = np.where(sampler.km_per_pixel > 0.0, sampler.km_per_pixel, np.nan) limb_softness_km = psf_sigma_px * km_per_pixel base = ( shape.ellipsoid_rms_residual_km**2 + shape.crater_scale_km**2 + (incidence_factor * limb_softness_km) ** 2 + shape.spice_orbital_residual_km**2 ) if include_albedo: albedo_term = (shape.albedo_variation * limb_softness_km) ** 2 photometric_term = (limb_softness_km * TERMINATOR_PHOTOMETRIC_SOFTNESS_COEFF) ** 2 base = base + albedo_term + photometric_term sigma_km = np.sqrt(np.maximum(base, 0.0)) sigma_px = sigma_km / km_per_pixel return np.nan_to_num(sigma_px, nan=LIMB_ARC_MAX_UNCERTAINTY_PX, posinf=1e3, neginf=1e3) def _visible_arc_fraction(sampler: _PolylineSampler) -> float: """Fraction of the predicted ridge vertices that survived to the fit. ``sampler.total_vertices`` is the ridge length found before per-vertex drops (currently zero-resolution / off-body vertices; the table-driven shadow extractor will add to this when wired), and ``vertices_vu`` holds only the survivors, so the visible-arc fraction is ``survivors / total``. Returns ``0.0`` when no ridge vertices were found at all. """ total = sampler.total_vertices if total <= 0: return 0.0 return float(sampler.vertices_vu.shape[0]) / float(total)
[docs] def limb_reliability(*, visible_arc_fraction: float, visible_arc_px: float) -> float: """Sigmoid-of-sum reliability for LIMB_ARC features. Shared emission policy: the SPICE-backed model scores its limb sampler with this, and the simulated body model feeds it the same quantities computed from its own render (arc fraction net of frame clipping and body-body occlusion), so the two models cannot desync on how a limb's reliability responds to arc visibility and length. The score answers a feature-existence question: is this limb arc a target a downstream technique should bother running on? Per-vertex geometric softness (high incidence at the terminator-adjacent end of the limb) lives in :func:`_sigma_normal_per_vertex`, where the LM fit weights individual vertices by their normal sigma; folding it into the reliability scalar as well would double-count the same physics and, because ``incidence_factor`` saturates near the cap on every fully-lit body, would penalize the cleanest possible geometries the hardest. """ z = -1.0 + 1.5 * visible_arc_fraction + 1.0 * _sigmoid(visible_arc_px / 50.0) return float(_sigmoid(z))
[docs] def terminator_reliability( *, visible_arc_fraction: float, albedo_variation: float, phase_factor: float ) -> float: """Reliability of TERMINATOR_ARC mirroring the design's formula. Shared emission policy: the SPICE-backed model scores its terminator sampler with this, and the simulated body model feeds it the same quantities computed from its own render, so the two models cannot desync on how a terminator's reliability responds to arc visibility, surface albedo variation, and phase. Parameters: visible_arc_fraction: Fraction of the predicted terminator ridge that is visible / usable for the fit. albedo_variation: The body's catalog albedo-variation figure. phase_factor: ``sin(phase_angle)``; capped at 1.0. Returns: The [0, 1] reliability score. """ base = _sigmoid(-1.0 + 1.5 * visible_arc_fraction - 1.5 * albedo_variation) return float(base * min(1.0, phase_factor))
def _disc_reliability( *, visible_lit_fraction: float, overflow_fraction: float, diameter_px: float ) -> float: """Reliability of BODY_DISC per the design (no scoring alpha coefficients yet).""" sigmoid_term = _sigmoid(diameter_px / 30.0 - 1.0) return float(visible_lit_fraction * (1.0 - overflow_fraction) * sigmoid_term)