spindoctor.ui

Interactive manual-navigation dialog (PyQt6).

Renders a composite of the predicted scene against the source image and lets an operator pick a translation offset by hand. An Auto button runs the same masked-NCC pyramid that correlation-based techniques use, so the operator can either accept the auto-pick or override it.

This module is GUI-only; no autonomous-pipeline code imports it. It is constructed by NavTechniqueManual (which is opted out of the auto-discovery registry) on demand.

class ManualNavDialog(*args: Any, **kwargs: Any)[source]

Bases: QDialog

Manual navigation dialog for overlaying image and combined model.

The viewport uses false color (image in red/blue, blend in green). Separate Image Stretch and Model Stretch groups each provide black/white/gamma and a display toggle. Model transparency and an optional binary mask overlay sit under model stretch.

run_modal() tuple[bool, tuple[float, float] | None, float | None][source]

Run the dialog modally, creating a QApplication if necessary.

Save-as-library-entry helper for the manual-navigation dialog.

The manual-nav dialog drives library curation: an operator picks an offset by hand, then clicks “Save as Library Entry…” to drop a sidecar into tests/integration/image_library/images/<class>/<image_id>.yaml. This module owns the YAML template and the obs-introspection helper so the dialog (which is otherwise PyQt-only) keeps a thin UI surface.

The emitted YAML carries TODO-style placeholders for every field the dialog cannot infer from the observation (scene class, expected technique, etc.). The library’s own structural-invariants test (tests.integration.test_image_library) refuses to load a sidecar that still has placeholder enum values, so the operator must edit the file before committing it.

class LibraryEntryDraft(image_id: str, mission: str, camera: str, filter_combo: str, exposure_time_sec: float | None, image_datetime_utc: str | None)[source]

Bases: object

Auto-inferred fields the dialog seeds into a fresh sidecar.

camera: str
exposure_time_sec: float | None
filter_combo: str
image_datetime_utc: str | None
image_id: str
mission: str
build_sidecar_yaml(*, draft: LibraryEntryDraft, image_url: str, offset_dv_px: float, offset_du_px: float, ui_version: str, operator: str | None = None, today: date | None = None, constraint_normal_angle_deg: float | None = None) str[source]

Render the sidecar as a YAML string with placeholders for the rest.

The output is deliberately not an opaque blob: every placeholder is a clear TODO_REPLACE_* string so the operator knows exactly what to edit before committing. Fields the dialog can infer (offset_*_px, mission, camera, filter_combo, exposure_time_sec, image_id) are filled in directly.

When constraint_normal_angle_deg is set (the operator navigated with the constrained-drag mode on a rank-1 scene), the ground truth carries a constraint block — offset_along_normal_px computed from the saved 2-D offset so the two stay consistent by construction — and expected pins status_reason: rank_1_only.

compute_image_url(obs: Any, config: Any = None) str[source]

Return the best image_url for the sidecar.

When the obs’s absolute path lives under a known PDS3 holdings root (resolved from PDS3_HOLDINGS_DIR first, then config.environment.pds3_holdings_root), the URL is rewritten to the opaque pds3://<relative-path> form the regression test re-resolves at load time. Otherwise the raw absolute path is returned so the operator at least sees what was navigated.

Parameters:
  • obs – Observation snapshot; obs.abspath is the file path.

  • config – Optional Config override for the environment.pds3_holdings_root fallback.

Returns:

Either 'pds3://<relative-path>' or the raw absolute path as a string. Empty string when obs carries no abspath.

infer_obs_metadata(obs: Any) LibraryEntryDraft[source]

Pull the sidecar’s auto-fillable fields off an observation snapshot.

Missing or unknown fields are returned as empty strings (or None for exposure_time_sec / image_datetime_utc) so the operator sees them in the YAML and can decide what to write; an empty mission/camera trips tests.integration.sidecar.load_sidecar() on validation, which is the right error.

spindoctor.ui.mosaic_viewer

Projection math for alternate 2-D display modes and the 3-D sphere view.

All projection functions operate in normalized display units. The conversion between normalized units and viewport pixels uses the linear transform:

viewport_x = normalized_x * scale + cx
viewport_y = normalized_y * scale + cy

where (cx, cy) is the projection centre in viewport pixels and scale is pixels per normalized unit. ProjectionParams carries these values.

Normalized-unit conventions per kind

  • RECT – 1 unit = 1 degree. x = longitude [0, 360), y = -latitude. This projection is rendered by the existing body-sphere canvas path; these functions are used only for graticule overlays.

  • POLAR_N – 1 unit = stereographic equatorial radius. r = 0 is the north pole, r = 1 is the equator. 0 deg longitude appears at the top (screen-y direction).

  • POLAR_S – same as POLAR_N with latitude sign-flipped. r = 0 is the south pole, r = 1 is the equator.

  • MOLLWEIDE – 1 unit = sqrt(2). The full-globe ellipse spans x in [-2*sqrt(2), 2*sqrt(2)] and y in [-sqrt(2), sqrt(2)].

  • SPHERE_3D – 1 unit = sphere radius. The visible disk has r <= 1. The camera views from the +X direction; yaw/pitch define which geographic point faces the camera.

Coordinate system

Screen y increases downward (PyQt6 convention). Normalized y also increases downward to match, so vy = yn * scale + cy with no sign flip needed in the viewport transform.

class ProjectionKind(*values)[source]

Bases: Enum

Available projection modes for the body mosaic viewer.

MOLLWEIDE = 'mollweide'
POLAR_N = 'polar_n'
POLAR_S = 'polar_s'
RECT = 'rect'
SPHERE_3D = 'sphere_3d'
class ProjectionParams(kind: ProjectionKind, cx: float, cy: float, scale: float, yaw_deg: float = 0.0, pitch_deg: float = 0.0)[source]

Bases: object

Viewport geometry for a single projection frame.

Parameters:
  • kind – Which projection to use.

  • cx – Viewport X coordinate of the projection centre (pixels from left).

  • cy – Viewport Y coordinate of the projection centre (pixels from top).

  • scale – Pixels per normalized unit (see module docstring).

  • yaw_deg – SPHERE_3D only – longitude at the centre of the view (deg).

  • pitch_deg – SPHERE_3D only – latitude at the centre of the view (deg).

cx: float
cy: float
kind: ProjectionKind
pitch_deg: float = 0.0
scale: float
yaw_deg: float = 0.0
display_to_lonlat(vx: ndarray, vy: ndarray, params: ProjectionParams) tuple[ndarray, ndarray, ndarray][source]

Map viewport pixel positions to geographic coordinates.

Parameters:
  • vx – Viewport X pixel array.

  • vy – Viewport Y pixel array.

  • params – Current projection parameters.

Returns:

Tuple (lon_deg, lat_deg, valid) where valid is a bool mask (False for pixels outside the valid domain of the projection).

Raises:

ValueError – If params.kind is unknown or params.scale is zero / non-finite.

fit_scale(kind: ProjectionKind, vw: int, vh: int) float[source]

Return a scale (pixels per normalized unit) that fits the projection.

The result leaves a margin _FIT_SCALE_MARGIN (10 % inset) around the projection so it does not touch the viewport edge at the initial zoom level.

Parameters:
  • kind – Projection kind.

  • vw – Viewport width in pixels.

  • vh – Viewport height in pixels.

Returns:

Scale in pixels per normalized unit.

Raises:

ValueError – If vw or vh is not strictly positive.

lonlat_to_display(lon_deg: ndarray, lat_deg: ndarray, params: ProjectionParams) tuple[ndarray, ndarray, ndarray][source]

Map geographic coordinates to viewport pixel positions.

Parameters:
  • lon_deg – Longitude array (deg), any range.

  • lat_deg – Latitude array (deg), values in [-90, 90].

  • params – Current projection parameters.

Returns:

Tuple (vx, vy, visible) where vx and vy are float64 arrays of viewport pixel coordinates and visible is a bool array (False for points not representable in this projection at params).

Raises:

ValueError – If params.kind is unknown or params.scale is zero / non-finite.

sphere_pixel_to_lonlat(vx: ndarray, vy: ndarray, params: ProjectionParams) tuple[ndarray, ndarray, ndarray][source]

Ray-cast orthographic sphere: viewport pixels to (lon_deg, lat_deg, hit).

Points outside the sphere disk or on the back hemisphere return hit=False.

Parameters:
  • vx – Viewport X pixel array.

  • vy – Viewport Y pixel array.

  • params – Projection parameters; kind must be SPHERE_3D.

Returns:

Tuple (lon_deg, lat_deg, hit).

Raises:

ValueError – If params.kind is not SPHERE_3D or params.scale is zero / non-finite.

Software renderer for non-rectangular body-mosaic projections.

render_to_image() converts a (lon_deg, lat_deg, valid) coordinate grid – produced by projections – into a QImage (Format_RGB888) using nearest-neighbour texture lookup, the same contrast-stretch pipeline as the existing rectangular render path, and optional per-pixel metadata tinting.

The function is intentionally stateless and side-effect-free so it can be called from any paint method without lock concerns.

render_to_image(image_ma: MaskedArray, *, lon_deg: ndarray, lat_deg: ndarray, valid: ndarray, lon_min_deg: float, lat_min_deg: float, d_lon_deg: float, d_lat_deg: float, lon_bin_to_dc: ndarray, n_full_lon: int, n_data_rows: int, n_data_cols: int, black: float, white: float, gamma: float, color_tint: ndarray | None) QImage[source]

Render a mosaic texture onto an arbitrary projection grid.

Parameters:
  • image_ma – 2-D masked array (n_data_rows, n_data_cols) with the photometrically-corrected mosaic data.

  • lon_deg – 2-D float64 array of longitudes (deg) for every output pixel, in [0, 360). After binning to k, any pixel with lon_bin_to_dc[k] == -1 has no data column and is off-grid (no-data when valid is True).

  • lat_deg – 2-D float64 array of latitudes (deg), in [-90, 90]. Latitudes outside [lat_min_deg, lat_min_deg + n_data_rows * d_lat_deg) map to dr outside [0, n_data_rows) and are off-grid (no-data when valid is True).

  • valid – 2-D bool array; False pixels are painted black (off-projection background). True pixels with no usable data (outside the file’s lat/lon extent or explicitly masked) are painted dark red.

  • lon_min_deg – Geographic minimum longitude of the data grid (deg).

  • lat_min_deg – Geographic minimum latitude of the data grid (deg).

  • d_lon_deg – Longitude resolution of the data grid (deg/column).

  • d_lat_deg – Latitude resolution of the data grid (deg/row).

  • lon_bin_to_dc – 1-D int32 array mapping full-circle longitude bin index to data column index (-1 = absent).

  • n_full_lon – Length of lon_bin_to_dc.

  • n_data_rows – Number of rows in image_ma.

  • n_data_cols – Number of columns in image_ma.

  • black – Stretch black point.

  • white – Stretch white point.

  • gamma – Stretch gamma (applied as power).

  • color_tint – Optional per-pixel RGB tint, shape (n_data_rows, n_data_cols, 3), float32 in [0, 1]. None = greyscale.

Returns:

A QImage in Format_RGB888 matching the shape of lon_deg / lat_deg.

Raises:

ValueError – If d_lon_deg or d_lat_deg is zero, if gamma is not positive, if len(lon_bin_to_dc) != n_full_lon, or if lon_deg, lat_deg, and valid do not share the same shape.

Lat/lon graticule polylines for non-rectangular body-mosaic projections.

graticule_polylines() samples parallels and meridians in geographic coordinates, projects them through the current ProjectionParams, and returns screen-space polyline segments ready for QPainter.drawPolyline.

Polylines are split wherever consecutive samples jump discontinuously (e.g. at the limb of a sphere or a map seam), so each returned segment is a continuous visible arc.

graticule_label_anchors(params: ProjectionParams, *, lat_step_deg: float, lon_step_deg: float) tuple[list[tuple[float, float, str]], list[tuple[float, float, str]]][source]

Return label anchor positions for visible graticule lines.

Finds the best visible point along each parallel and meridian at which to draw a tick label. For parallels the anchor is the point closest to the screen centre along the line; for meridians similarly.

Parameters:
  • params – Current projection parameters.

  • lat_step_deg – Spacing of parallels (deg).

  • lon_step_deg – Spacing of meridians (deg).

Returns:

Tuple (parallel_anchors, meridian_anchors). Each anchor is (vx, vy, label_text).

Raises:
graticule_polylines(params: ProjectionParams, *, lat_step_deg: float, lon_step_deg: float, samples_per_line: int = 181, show_parallels: bool = True, show_meridians: bool = True) tuple[list[list[tuple[float, float]]], list[list[tuple[float, float]]]][source]

Compute graticule screen-space polylines for the given projection.

Each “line” in the returned lists is a list of (vx, vy) tuples that can be drawn as a connected polyline. Lines are pre-split at discontinuities (sphere limb, map seam) so callers do not need to check for jumps.

Parameters:
  • params – Current projection parameters. For RECT mode the caller should use the existing straight-line overlay path instead.

  • lat_step_deg – Spacing of parallels (deg). Pass 0 to disable.

  • lon_step_deg – Spacing of meridians (deg). Pass 0 to disable.

  • samples_per_line – Number of sample points along each graticule line.

  • show_parallels – Include parallel (constant-latitude) lines.

  • show_meridians – Include meridian (constant-longitude) lines.

Returns:

Tuple (parallel_lines, meridian_lines) where each element is a list of polyline segments. A segment is a list of (vx, vy) float tuples.

Raises:
  • TypeError – If params is None.

  • ValueError – If lat_step_deg or lon_step_deg is negative, or if samples_per_line is less than 2.