Source code for spindoctor.nav_technique.nav_technique_ring_edge

"""``RingEdgeNav`` — translation fit from ring-edge polylines.

Consumes every ``RING_EDGE`` feature in the input set and produces a
single combined translation by minimising the joint distance-transform
cost.  When every input ring edge is flagged ``is_straight_line`` the
combined Jacobian is rank-deficient — all parallel ring edges share a
single ring-plane normal, so the along-edge axis is unobservable.  The
returned covariance is honestly rank-1 in that case; the ensemble
combine fuses it with any orthogonal-axis result (a star, body limb,
body blob) before declaring a final answer.
"""

from __future__ import annotations

import math
from typing import TYPE_CHECKING

import numpy as np

from spindoctor.config import Config
from spindoctor.feature.feature import NavFeature
from spindoctor.feature.feature_type import NavFeatureType
from spindoctor.feature.geometry import RingEdgePolyline
from spindoctor.nav_technique.confidence import evaluate_sigmoid_combination
from spindoctor.nav_technique.diagnostics import RingEdgeDiagnostics
from spindoctor.nav_technique.dt_fit_gates import DTFitGateConfig, evaluate_dt_fit_gates
from spindoctor.nav_technique.dt_fitting import (
    build_polyline_mask,
    coarse_ncc_search_scored,
    lm_subpixel_refine,
)
from spindoctor.nav_technique.feasibility import NavFeasibilityReport
from spindoctor.nav_technique.nav_technique import (
    NavTechnique,
    add_model_error_floor,
    log_confidence_breakdown,
    rotation_pivot_distance_px,
    search_window_for_obs,
)
from spindoctor.nav_technique.ring_edge_geometry import (
    _absorbed_orbit_sensitivity,
    _aggregate_normal_orientation,
    _effective_orbit_sigma_px,
    _is_rank_1,
    _orbit_inflated_covariance,
    _rank1_projected_covariance,
)
from spindoctor.nav_technique.ring_edge_stats import (
    _EdgeFitStat,
    _per_edge_fit_stats,
    _per_edge_median_max,
    _per_edge_rms_summed,
)
from spindoctor.nav_technique.technique_result import NavTechniqueResult
from spindoctor.support.types import NDArrayFloatType

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

__all__ = ['RingEdgeNav', 'aggregate_edge_normal_angle_deg']

# All numeric tunables for this technique live in
# ``config_files/config_510_techniques.yaml`` under
# ``techniques.RingEdgeNav.tuning``.  No Python-level fallback;
# missing-key access in ``__init__`` is a KeyError so a config typo
# fails fast at process startup.


def _aggregate_ring_edges(
    features: list[NavFeature],
) -> tuple[
    NDArrayFloatType,
    NDArrayFloatType,
    NDArrayFloatType,
    list[str],
    bool,
]:
    """Concatenate vertices, polarity normals, and per-vertex sigmas for ring edges.

    Returns:
        ``(vertices, polarity_normals, sigmas, feature_ids,
        every_edge_is_straight)``.  ``every_edge_is_straight`` is True
        when every input edge has ``is_straight_line=True``; the
        technique uses it to drive the rank-1 covariance path.
    """
    vert_chunks: list[NDArrayFloatType] = []
    normal_chunks: list[NDArrayFloatType] = []
    sigma_chunks: list[NDArrayFloatType] = []
    ids: list[str] = []
    every_straight = True
    seen_any = False
    for feat in features:
        if not isinstance(feat.geometry, RingEdgePolyline):
            continue
        if feat.geometry.vertices_vu.shape[0] == 0:
            continue
        seen_any = True
        if not feat.geometry.is_straight_line:
            every_straight = False
        vert_chunks.append(feat.geometry.vertices_vu.astype(np.float64))
        # Negate the radial outward normal so the polarity check
        # (``dot(model, image_gradient) > 0``) accepts edges whose image
        # gradient points across the edge in the same sense as the
        # negated normal.
        normal_chunks.append(-feat.geometry.normals_vu.astype(np.float64))
        sigma_chunks.append(feat.geometry.sigma_radial_per_vertex_px.astype(np.float64))
        ids.append(feat.feature_id)
    empty_2 = np.empty((0, 2), np.float64)
    empty_1 = np.empty(0, np.float64)
    vertices = np.concatenate(vert_chunks, axis=0) if vert_chunks else empty_2
    normals = np.concatenate(normal_chunks, axis=0) if normal_chunks else empty_2
    sigmas = np.concatenate(sigma_chunks, axis=0) if sigma_chunks else empty_1
    return vertices, normals, sigmas, ids, (every_straight if seen_any else False)


[docs] class RingEdgeNav(NavTechnique): """Ring-edge DT-based translation fit. Class attributes: accepts_feature_types: ``frozenset({RING_EDGE})``. requires_prior: ``False`` — the technique runs in pass 1. """ name = 'RingEdgeNav' accepts_feature_types = frozenset({NavFeatureType.RING_EDGE}) requires_prior = False confidence_attributes = frozenset( { 'at_edge', 'total_edge_length_px', 'per_edge_dt_rms_summed', 'per_edge_dt_rms_mean', 'per_edge_dt_median_max', 'edge_count', 'is_rank_1', } ) def __init__(self, *, config: Config | None = None) -> None: """Read the technique's tunables from config. Parameters: config: Optional ``Config`` override; ``None`` uses ``DEFAULT_CONFIG``. Raises: KeyError: If any tuning key is missing, so a config typo fails at process startup rather than mid-image. """ super().__init__(config=config) self.config.read_config() # ensure cls.tuning is populated self._at_edge_tolerance_px = float(self.tuning['at_edge_tolerance_px']) self._spurious_dt_rms_factor = float(self.tuning['spurious_dt_rms_factor']) self._spurious_dt_floor_px = float(self.tuning['spurious_dt_floor_px']) self._spurious_min_inliers = int(self.tuning['spurious_min_inliers']) self._spurious_min_inlier_fraction = float(self.tuning['spurious_min_inlier_fraction']) self._spurious_waiver_min_well_fit_edges = int( self.tuning['spurious_waiver_min_well_fit_edges'] ) self._spurious_waiver_absent_median_px = float( self.tuning['spurious_waiver_absent_median_px'] ) self._spurious_waiver_sigma_floor_px = float(self.tuning['spurious_waiver_sigma_floor_px']) self._spurious_max_lm_displacement_px = float( self.tuning['spurious_max_lm_displacement_px'] ) self._lm_trust_region_px = float(self.tuning['lm_trust_region_px']) self._lm_tikhonov_alpha = float(self.tuning['lm_tikhonov_alpha']) self._gradient_ridge_refine = bool(self.tuning['gradient_ridge_refine']) self._rotation_at_edge_fraction = float(self.tuning['rotation_at_edge_fraction']) self._dt_gate_config = DTFitGateConfig.from_tuning(self.tuning)
[docs] def is_feasible(self, features: list[NavFeature]) -> NavFeasibilityReport: """Return whether the input set carries any usable ring edge. Reads only the polyline vertex count per feature. A single non-empty ring-edge polyline is sufficient: even an all-flat scene produces a useful rank-1 constraint that the ensemble will fuse with another feature. Parameters: features: Feature list filtered to this technique's accepted types. Returns: ``NavFeasibilityReport`` with ``feasible=True`` iff at least one RING_EDGE has a non-empty polyline. """ eligible = [ f for f in features if isinstance(f.geometry, RingEdgePolyline) and f.geometry.vertices_vu.shape[0] > 0 ] if not eligible: return NavFeasibilityReport( feasible=False, reason='no_ring_edge_features', ) 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 ring-edge polylines. Parameters: features: Feature list filtered to the technique's accepted types. context: Per-image NavContext. Must carry ``image_edge_dt_ext`` and ``image_gradient_vu_ext`` — both populated by the orchestrator's ``_make_context``. Returns: A ``NavTechniqueResult`` with the recovered offset, 2x2 covariance (rank-1 when every input edge is straight), calibrated confidence, and a populated :class:`RingEdgeDiagnostics`. """ with self.logger.open(f'TECHNIQUE: {self.name}'): if context.image_edge_dt_ext is None or context.image_gradient_vu_ext is None: raise RuntimeError( 'RingEdgeNav requires NavContext.image_edge_dt_ext and ' 'NavContext.image_gradient_vu_ext to be populated by the orchestrator' ) ( vertices, polarity_normals, sigmas, feature_ids, every_straight, ) = _aggregate_ring_edges(features) self.logger.info( 'Consuming %d RING_EDGE features (out of %d offered); every_straight=%s', len(feature_ids), len(features), every_straight, ) edge_dt = context.image_edge_dt_ext gradient_vu = context.image_gradient_vu_ext edge_mask = edge_dt <= 0.5 polyline_mask = build_polyline_mask(vertices, edge_dt.shape[:2]) margin_v, margin_u = search_window_for_obs(context) self.logger.debug( 'Aggregated %d ring-edge vertices, sigma_radial range [%.3f, %.3f] px, ' 'search window (v, u) = (%d, %d) px', int(vertices.shape[0]), float(sigmas.min()) if sigmas.size else 0.0, float(sigmas.max()) if sigmas.size else 0.0, margin_v, margin_u, ) coarse = coarse_ncc_search_scored( edge_mask, polyline_mask, (margin_v, margin_u), ) coarse_dv, coarse_du = coarse.offset_vu self.logger.debug( 'Coarse NCC offset: (%d, %d), peak match fraction %.3f', coarse_dv, coarse_du, coarse.score, ) # Ring-edge polarity prediction depends on lighting / gap-vs-ringlet # context the catalog does not encode today; skip polarity until # the polarity-predictable flag is wired (deferred work). fit_rotation = bool(context.fit_camera_rotation) pivot_vu = (float(vertices[:, 0].mean()), float(vertices[:, 1].mean())) pivot_distance = ( rotation_pivot_distance_px(pivot_vu, edge_dt.shape[:2]) if fit_rotation else 0.0 ) result = lm_subpixel_refine( vertices_vu=vertices, normals_vu=polarity_normals, sigma_normal_per_vertex_px=sigmas, image_edge_dt=edge_dt, image_gradient_vu=gradient_vu, initial_offset_vu=(float(coarse_dv), float(coarse_du)), use_polarity=False, fit_rotation=fit_rotation, pivot_vu=pivot_vu if fit_rotation else None, pivot_distance_px=pivot_distance, trust_region_px=self._lm_trust_region_px, tikhonov_alpha=self._lm_tikhonov_alpha, final_gradient_ridge=self._gradient_ridge_refine, ) dv_final, du_final = result.offset_vu max_rotation_rad = math.radians(context.max_rotation_deg) rotation_at_edge = fit_rotation and ( abs(result.rotation_rad) >= self._rotation_at_edge_fraction * max_rotation_rad ) # See BodyLimbNav: ``>=`` covers both at-edge and over-edge cases # so an LM that walked past the coarse-NCC search window does # not silently report an offset outside the extfov margin. if every_straight: # Rank-1 scene: the along-edge (tangent) offset component is # unobservable and nothing constrains it, so the LM may # slide to the search-window boundary along it (Tikhonov is # off by default). Gating per axis would hard-zero a # perfectly valid normal-component fix; gate on the # observable component instead — at-edge along the normal # means the *measurement* is pinned at the window limit. n_hat = _aggregate_normal_orientation(polarity_normals) offset_along_normal = abs(dv_final * n_hat[0] + du_final * n_hat[1]) # Maximum reach of the search box along the normal. bound_along_normal = abs(n_hat[0]) * margin_v + abs(n_hat[1]) * margin_u at_edge = ( offset_along_normal >= bound_along_normal - self._at_edge_tolerance_px or rotation_at_edge ) else: at_edge = ( abs(dv_final) >= margin_v - self._at_edge_tolerance_px or abs(du_final) >= margin_u - self._at_edge_tolerance_px or rotation_at_edge ) sigma_min_px = float(sigmas.min()) if sigmas.size else 1.0 covariance = result.covariance total_edge_length_px = float(vertices.shape[0]) per_edge_rms_summed = _per_edge_rms_summed(features, result.residuals_px) per_edge_median_max = _per_edge_median_max(features, result.residuals_px) edge_count = len(feature_ids) # ``result.rms_px`` is the *Tukey-weighted* residual RMS; when # the LM converges to a local minimum where one edge fits # cleanly and the rest are wholly mis-aligned, Tukey rejects # the bad-edge vertices and ``rms_px`` collapses to near # zero — a textbook mis-convergence the ``rms_px > floor`` # threshold cannot detect (Cassini Tethys N1572471790 is the # calibration case; the LM converged on the wrong ring of # three, anchoring the fit on a third of the model vertices). # No residual statistic separates that from a correct fit # whose faintest edge simply is not detectable in the image — # in both, one edge's residuals sit at the ringlet spacing # (Keeler-gap ansa frames run 9-30 px; issue #203). What does # separate them is how much of the model the fit explains: # the Tukey inlier fraction is ~1/3 for the wrong-ring lock # and >0.8 for a correct flat fit with one undetected edge, # so the gate is a minimum inlier fraction over the aggregated # vertices. # # The aggregate fraction alone over-reaches on a multi-edge # fusion where the vertex count is dominated by edges that # are simply not detectable in the image: the fused offset # is pinned by a well-fit edge at sub-pixel RMS, yet the # missing edges' vertices drag the aggregate fraction below # the gate (Cassini C ring N1467344214 is the calibration # case -- 821 / 3188 inliers on an operator-verified fit; # issue #261). What separates that from a wrong-ring lock # is the *median DT residual of the edges the robust fit # rejected*: an undetected edge sits far from every detected # image edge (medians of tens of px -- there is nothing # there), while a mis-locked fusion leaves at least one # rejected edge lying ON a detected image edge it disagrees # with (median well under a pixel; Cassini Tethys # N1572472169's wrong lock rejects an edge whose median is # 0.04 px). The veto is therefore waived only when the low # aggregate fraction is fully explained by absent edges: # every non-well-fit edge must have a per-edge median DT # residual of at least ``spurious_waiver_absent_median_px``, # at least ``spurious_waiver_min_well_fit_edges`` edges must # clear the inlier-fraction gate on their own vertices (with # a non-negligible ``spurious_min_inliers`` count), and the # fit's translation covariance must be full rank -- the # weighted vertices are almost all from the well-fit subset, # so a full-rank covariance certifies that the surviving # edges constrain both offset axes on their own. A # single-edge fit never qualifies (its own fraction IS the # aggregate), so single-edge behavior is unchanged. total_vertices = int(vertices.shape[0]) inlier_fraction = ( float(result.inlier_count) / float(total_vertices) if total_vertices else 0.0 ) edge_stats = _per_edge_fit_stats( features, residuals=result.residuals_px, weights=result.weights ) well_fit: list[_EdgeFitStat] = [] rejected: list[_EdgeFitStat] = [] for stat in edge_stats: if ( stat.inlier_count >= self._spurious_min_inliers and stat.inlier_fraction >= self._spurious_min_inlier_fraction ): well_fit.append(stat) else: rejected.append(stat) self.logger.debug( 'Per-edge inliers/vertices: %s; median |DT| px: %s; straight: %s; ' 'well-fit edges = %d of %d', ', '.join(f'{s.inlier_count}/{s.vertex_count}' for s in edge_stats), ', '.join(f'{s.median_abs_residual_px:.3f}' for s in edge_stats), ', '.join(str(s.is_straight) for s in edge_stats), len(well_fit), len(edge_stats), ) translation_full_rank = not result.degenerate and not ( every_straight or _is_rank_1(np.asarray(covariance, np.float64)) ) veto_waived = ( len(edge_stats) >= 2 and len(well_fit) >= self._spurious_waiver_min_well_fit_edges and translation_full_rank and all( stat.median_abs_residual_px >= self._spurious_waiver_absent_median_px for stat in rejected ) ) inlier_fraction_low = inlier_fraction < self._spurious_min_inlier_fraction inlier_fraction_veto = inlier_fraction_low and not veto_waived if inlier_fraction_low and veto_waived: self.logger.info( 'Aggregate inlier fraction %.3f is below the %.2f gate, but %d of %d ' 'edges fit well independently and every rejected edge is absent from ' 'the image (median |DT| >= %.1f px); waiving the mis-convergence veto', inlier_fraction, self._spurious_min_inlier_fraction, len(well_fit), len(edge_stats), self._spurious_waiver_absent_median_px, ) if every_straight: # As with at_edge above: the tangent component of the # LM-vs-coarse displacement is unconstrained slide along the # unobservable axis, not evidence of a runaway fit. n_hat = _aggregate_normal_orientation(polarity_normals) lm_displacement_px = abs( (dv_final - float(coarse_dv)) * n_hat[0] + (du_final - float(coarse_du)) * n_hat[1] ) else: lm_displacement_px = float( math.hypot(dv_final - float(coarse_dv), du_final - float(coarse_du)) ) # Shared DT fit-quality gates. RingEdgeNav runs polarity-free # (see the lm_subpixel_refine call above), so the polarity gates # are inert here; the coarse-peak and LM-convergence signals # still apply. gate_verdict = evaluate_dt_fit_gates( result, self._dt_gate_config, coarse_peak_fraction=coarse.score, total_vertex_count=total_vertices, use_polarity=False, ) if gate_verdict.spurious_reasons: self.logger.info( 'DT fit-quality gate(s) fired: %s (coarse peak %.3f, lm_converged=%s)', ', '.join(gate_verdict.spurious_reasons), gate_verdict.coarse_peak_fraction, gate_verdict.lm_converged, ) spurious = ( result.degenerate or result.rms_px > max( self._spurious_dt_floor_px, self._spurious_dt_rms_factor * sigma_min_px, ) or result.inlier_count < self._spurious_min_inliers or inlier_fraction_veto or lm_displacement_px > self._spurious_max_lm_displacement_px or gate_verdict.spurious ) rotation_rad: float | None sigma_rotation_rad: float | None if fit_rotation: if covariance.shape != (3, 3): raise RuntimeError( f'RingEdgeNav expected 3x3 covariance with fit_rotation; ' f'got {covariance.shape}' ) rotation_rad = float(result.rotation_rad) sigma_rotation_rad = float(np.sqrt(max(float(covariance[2, 2]), 0.0))) else: if covariance.shape != (2, 2): self.logger.warning( 'RingEdgeNav: lm_subpixel_refine returned %s covariance with ' 'fit_rotation=False; truncating to (2, 2)', covariance.shape, ) covariance = covariance[:2, :2] rotation_rad = None sigma_rotation_rad = None covariance = np.asarray(covariance, np.float64) is_rank_1 = _is_rank_1(covariance) or every_straight if is_rank_1: # Enforce the rank-1 contract on the reported covariance. # On an all-straight scene the along-edge (tangent) offset # component is physically unobservable, but the LM # covariance often comes back numerically tight along the # tangent anyway: the model polyline's *ends* anchor the # along-edge coordinate against wherever the detected edge # happens to enter and leave the frame — a false # constraint. Rebuild the translation covariance as the # LM's marginal variance along the aggregate edge normal # with the tangent variance exactly zero (the Moore-Penrose # null-space convention), so the ensemble's rank-deficiency # test fires and the tangent axis is reported through the # ``sigma_along_unobservable_px = inf`` sentinel instead of # a confidently full-rank fix (issue #203). covariance = _rank1_projected_covariance(covariance, polarity_normals) if inlier_fraction_low and veto_waived: # A veto-waived fit is carried by a minority of its model # in a scene whose parallel ringlet structure offers alias # alignments the DT cannot rule out (the whole point of # the waived veto). The LM covariance measures only the # statistical precision of the anchored vertices, so add # the waiver sigma floor in quadrature (the #210 # convention) to keep the fix below the medium tier's # sigma cap: it surfaces as a low-tier result and cannot # outweigh a full-support fix in the ensemble. covariance = add_model_error_floor(covariance, self._spurious_waiver_sigma_floor_px) self.logger.info( 'Applying the spurious-waiver sigma floor of %.1f px to the ' 'reported covariance', self._spurious_waiver_sigma_floor_px, ) # Radial orbit-uncertainty channel: the LM covariance describes # the statistical lock onto the MODELED annulus; a catalog-orbit # error displaces the whole annulus coherently and does not # average down over vertices, so the declared orbit sigma is # added to the reported covariance through the translation the # fit would ABSORB from such a displacement # (``_absorbed_orbit_sensitivity``: a short arc absorbs it # one-for-one along its normal, a full annulus barely absorbs it # at all because a uniform radial error is a dilation). On a # rank-1 scene the projection's own axis is reused unchanged, so # the covariance stays exactly singular along the tangent. sigma_orbit_px = _effective_orbit_sigma_px(features, result.weights) if sigma_orbit_px > 0.0: if is_rank_1: orbit_g = _aggregate_normal_orientation(polarity_normals) else: orbit_g = _absorbed_orbit_sensitivity(polarity_normals, result.weights) covariance = _orbit_inflated_covariance(covariance, orbit_g, sigma_orbit_px) self.logger.info( 'Widening the reported covariance by the declared radial orbit ' 'uncertainty: sigma %.3f px, absorbed sensitivity (v, u) = ' '(%.3f, %.3f) (|g| = %.3f)', sigma_orbit_px, float(orbit_g[0]), float(orbit_g[1]), float(np.linalg.norm(orbit_g)), ) per_edge_rms_mean = per_edge_rms_summed / float(max(edge_count, 1)) diagnostics = RingEdgeDiagnostics( total_edge_length_px=total_edge_length_px, per_edge_dt_rms_summed=per_edge_rms_summed, per_edge_dt_rms_mean=per_edge_rms_mean, per_edge_dt_median_max=per_edge_median_max, edge_count=edge_count, is_rank_1=bool(is_rank_1), lm_converged=gate_verdict.lm_converged, coarse_peak_fraction=gate_verdict.coarse_peak_fraction, sigma_orbit_radial_px=sigma_orbit_px, ) assert self.confidence_spec is not None # set as class attribute confidence, breakdown = evaluate_sigmoid_combination( self.confidence_spec, _RingEdgeConfidenceContext(at_edge=at_edge, diagnostics=diagnostics), technique_name=self.name, return_breakdown=True, ) log_confidence_breakdown(self.logger, breakdown) if gate_verdict.confidence_cap is not None and confidence > gate_verdict.confidence_cap: self.logger.info( 'LM exited at the iteration cap without converging; capping ' 'confidence %.4f -> %.4f', float(confidence), gate_verdict.confidence_cap, ) confidence = gate_verdict.confidence_cap self.logger.info( 'Converged at offset (%.4f, %.4f) px, RMS %.4f px, inliers %d / %d, ' 'rank_1=%s, confidence %.4f', dv_final, du_final, result.rms_px, result.inlier_count, int(vertices.shape[0]), is_rank_1, float(confidence), ) if fit_rotation and sigma_rotation_rad is not None and rotation_rad is not None: self.logger.info( 'Rotation = %+.4f deg (sigma %.4f deg)%s', math.degrees(rotation_rad), math.degrees(sigma_rotation_rad), ', 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) self.logger.debug( 'LM iterations = %d, sigma_min = %.3f px, ' 'per_edge_rms_summed = %.3f, per_edge_median_max = %.3f, ' 'total_edge_length = %.1f px', result.iterations, sigma_min_px, per_edge_rms_summed, per_edge_median_max, total_edge_length_px, ) return NavTechniqueResult( technique_name=self.name, feature_ids=tuple(feature_ids), offset_px=(float(dv_final), float(du_final)), covariance_px2=covariance, confidence=float(confidence), spurious=bool(spurious), at_edge=bool(at_edge), diagnostics=diagnostics, rotation_rad=rotation_rad, sigma_rotation_rad=sigma_rotation_rad, )
class _RingEdgeConfidenceContext: """Adapter exposing ring-edge confidence terms in a single attribute set.""" def __init__(self, *, at_edge: bool, diagnostics: RingEdgeDiagnostics) -> None: """Flatten the diagnostics plus ``at_edge`` into one attribute set. Parameters: at_edge: Whether the converged offset reached the search-window boundary; it lives on the result, not the diagnostics. diagnostics: The technique's populated diagnostics. """ self.at_edge = at_edge self.total_edge_length_px = diagnostics.total_edge_length_px self.per_edge_dt_rms_summed = diagnostics.per_edge_dt_rms_summed self.per_edge_dt_rms_mean = diagnostics.per_edge_dt_rms_mean self.per_edge_dt_median_max = diagnostics.per_edge_dt_median_max self.edge_count = diagnostics.edge_count self.is_rank_1 = diagnostics.is_rank_1
[docs] def aggregate_edge_normal_angle_deg(features: list[NavFeature]) -> float | None: """Return the dominant edge-normal orientation of an all-straight scene. The angle is in degrees in the ``(v, u)`` frame, measured from ``+v`` toward ``+u`` (the unit normal is ``(cos, sin)``) — the same convention as the sidecar schema's ``ground_truth.constraint.normal_angle_deg``. The orientation is the dominant eigenvector of the per-vertex normals' outer-product sum, so it is independent of each edge's polarity sign. Parameters: features: Any feature list; only ``RING_EDGE`` polylines are read. Returns: The orientation in degrees, normalised to ``(-90, 90]``, or ``None`` unless at least one ring-edge polyline is present and every one is straight — the constraint direction is only meaningful for a rank-1 scene. """ _vertices, normals, _sigmas, ids, every_straight = _aggregate_ring_edges(features) if not ids or not every_straight: return None n_hat = _aggregate_normal_orientation(normals) angle = math.degrees(math.atan2(float(n_hat[1]), float(n_hat[0]))) # An orientation, not a direction: fold to (-90, 90]. if angle <= -90.0: angle += 180.0 elif angle > 90.0: angle -= 180.0 return float(angle)