Source code for spindoctor.navigate_image_files

"""Top-level driver that navigates a single image and writes results.

Given an observation class and an ``ImageFiles`` batch of size one, this
module reads the image, builds a ``NavOrchestrator`` configured with the
caller's model and technique filters, runs ``orchestrator.navigate(obs)``,
and writes the curated metadata (and a summary PNG when requested) to
``nav_results_root``.

This is the function ``sd_offset`` and ``sd_offset_cloud_tasks`` invoke
once per image.  Errors from image loading, missing SPICE coverage, or
unexpected exceptions during navigation are captured into the output
metadata so the driver always returns a structured result (never a raised
exception that crashes the worker process).
"""

from __future__ import annotations

import argparse
import sys
from datetime import UTC, datetime
from io import BytesIO
from pathlib import Path
from typing import Any, cast

from filecache import FCPath
from PIL import Image

from spindoctor.config import DEFAULT_CONFIG, IMAGE_LOGGER, MAIN_LOGGER, image_log_handlers
from spindoctor.dataset.dataset import ImageFiles
from spindoctor.nav_model import build_models_for_obs
from spindoctor.nav_orchestrator import (
    NavOrchestrator,
    NavResult,
    build_metadata_dict,
)
from spindoctor.obs import ObsSnapshotInst, obs_class_to_inst_name
from spindoctor.support.file import json_as_string
from spindoctor.support.misc import log_run_environment
from spindoctor.support.summary_png import render_annotated_summary_rgb

__all__ = [
    'build_metadata_from_result',
    'build_timing_section',
    'navigate_image_files',
    'write_summary_png',
]


_SPICE_DATA_HINTS = (
    'SPICE(CKINSUFFDATA)',
    'SPICE(SPKINSUFFDATA)',
    'SPICE(NOFRAMECONNECT)',
)


def _iso8601_utc(moment: datetime) -> str:
    """UTC ISO8601 string (``Z`` suffix) for a timezone-aware datetime.

    Parameters:
        moment: Timezone-aware datetime to format.

    Returns:
        The moment rendered in UTC as ``YYYY-MM-DDTHH:MM:SS.ffffffZ``.
    """
    return moment.astimezone(UTC).isoformat().replace('+00:00', 'Z')


[docs] def build_timing_section(start: datetime, end: datetime) -> dict[str, Any]: """Build the ``timing`` metadata section from run start and end moments. Every metadata document carries this section so downstream statistics (``sd_stats_ingest`` / ``sd_stats_report``) can aggregate per-image run times. Parameters: start: Timezone-aware run start (captured before the image load). end: Timezone-aware run end (captured after navigation, or at error time). Returns: Dict with ``start_iso8601`` and ``end_iso8601`` (UTC ISO8601 strings) and ``elapsed_s`` (float seconds). """ return { 'start_iso8601': _iso8601_utc(start), 'end_iso8601': _iso8601_utc(end), 'elapsed_s': (end - start).total_seconds(), }
def _metadata_for_load_error( image_path: Path, image_name: str, exc: BaseException, *, logger: Any, instrument: str, image_et: float | None, camera: str | None, timing: dict[str, Any], ) -> dict[str, Any]: """Build a metadata dict for an image-load or kernel-coverage failure. No image shape is recorded because the load never produced pixel data. The epoch and camera are recorded when the caller could read them from the index, so an image that failed for want of a SPICE kernel is still placed in time and attributed to its camera. """ message = str(exc) if any(hint in message for hint in _SPICE_DATA_HINTS): logger.exception('No SPICE kernel available for "%s": %s', image_path, message) status_error = 'missing_spice_data' else: logger.exception('Error reading image "%s": %s', image_path, message) status_error = 'image_read_error' observation: dict[str, Any] = { 'image_path': str(image_path), 'image_name': image_name, 'instrument': instrument, } if image_et is not None: observation['image_et'] = image_et if camera is not None: observation['camera'] = camera return { 'status': 'error', 'status_error': status_error, 'status_exception': message, 'observation': observation, 'timing': timing, }
[docs] def build_metadata_from_result( result: NavResult, image_path: Path, image_name: str, *, instrument: str, camera: str | None = None, image_shape: tuple[int, int] | None = None, timing: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the JSON metadata dict from a NavResult. Used by both the autonomous pipeline and the manual-nav driver so a manually-picked offset writes the same ``_metadata.json`` schema. Parameters: result: NavResult to curate. image_path: Absolute path to the source image; written to the ``observation.image_path`` field. image_name: Basename of the source image; written to the ``observation.image_name`` field. instrument: Registered instrument name for the observation class (see :func:`spindoctor.obs.obs_class_to_inst_name`); written to the ``observation.instrument`` field. camera: The camera that took the image (``ObsInst.camera``, e.g. ``'NAC'``); written to the ``observation.camera`` field. None omits the field. image_shape: ``(v, u)`` pixel dimensions of the loaded image data; written to the ``observation.image_shape`` field. None omits the field. timing: Run-timing section from :func:`build_timing_section`; written to the top-level ``timing`` field. None omits the field. """ observation: dict[str, Any] = { 'image_path': str(image_path), 'image_name': image_name, 'instrument': instrument, } if camera is not None: observation['camera'] = camera if image_shape is not None: observation['image_shape'] = [int(image_shape[0]), int(image_shape[1])] metadata: dict[str, Any] = { 'status': result.status, 'observation': observation, 'navigation_result': build_metadata_dict(result), } if timing is not None: metadata['timing'] = timing if result.offset_px is not None: metadata['offset'] = list(result.offset_px) metadata['confidence'] = result.confidence return metadata
[docs] def write_summary_png( obs: ObsSnapshotInst, result: NavResult, png_path: FCPath, logger: Any, ) -> None: """Composite the source image with the orchestrator's annotation overlay. Thin driver around :func:`spindoctor.support.summary_png.render_annotated_summary_rgb`; writes the resulting RGB to ``png_path`` as a PNG. Parameters: obs: Observation snapshot used as the background. result: Navigation result; ``offset_px`` shifts the overlay to the best-fit pose, ``annotations`` carries every NavModel's overlay. png_path: Destination path; supports ``FCPath`` URLs. logger: ``pdslogger`` to emit one INFO line on success. """ overlay_offset = result.offset_px if result.offset_px is not None else (0.0, 0.0) rgb = render_annotated_summary_rgb(obs, result.annotations, overlay_offset) buf = BytesIO() Image.fromarray(rgb, mode='RGB').save(buf, format='PNG') png_path.write_bytes(buf.getvalue()) logger.info('Wrote summary PNG to %s', png_path)