Source code for spindoctor.sim.png_export

"""Human-viewable PNG export for simulated images.

The simulator renders detector counts (DN), whose absolute range depends on the
instrument full-scale and on any cosmic-ray spikes, so a raw cast to 8-bit would
be unreadable: a single hot pixel can scale the whole frame to black.  These
helpers stretch a DN image to a visible grayscale PNG with a percentile clip (so
a few outliers do not crush the body / star / ring signal) and an optional gamma
that lifts dim features -- a thin high-phase crescent, faint background stars --
without blowing out a bright disc.

The two entry points are :func:`stretch_to_uint8` (DN array to an 8-bit array)
and :func:`save_png` (write that array to a PNG file).  :func:`render_scene_png`
is the convenience that renders a ``sim_params`` scene and saves it in one call,
used by the documentation-image and sweep-dump tooling.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import numpy as np
from PIL import Image

from spindoctor.support.types import NDArrayFloatType, NDArrayUint8Type, PathLike

__all__ = ['render_scene_png', 'save_png', 'stretch_to_uint8']


[docs] def stretch_to_uint8( image: NDArrayFloatType, *, low_percentile: float = 0.5, high_percentile: float = 99.5, gamma: float = 1.0, ) -> NDArrayUint8Type: """Stretch a DN image to an 8-bit grayscale array for human viewing. The black and white points are taken at the requested percentiles of the finite pixels (not the absolute min/max), so a handful of cosmic-ray or saturated pixels do not collapse the rest of the frame to black. A ``gamma`` above 1 brightens the midtones, which makes a dim crescent or a faint star field legible alongside a bright disc. Non-finite pixels (the missing-data marker on calibrated frames) are mapped to the black point. Parameters: image: The DN image array. low_percentile: Percentile mapped to black (0). high_percentile: Percentile mapped to white (255). gamma: Display gamma; values above 1 lift dim features. Returns: A ``uint8`` array of the same shape, in [0, 255]. """ arr = np.asarray(image, dtype=np.float64) out_shape = arr.shape finite = arr[np.isfinite(arr)] if finite.size == 0: return np.zeros(out_shape, dtype=np.uint8) lo = float(np.percentile(finite, low_percentile)) hi = float(np.percentile(finite, high_percentile)) if hi <= lo: lo = float(finite.min()) hi = float(finite.max()) if hi <= lo: return np.zeros(out_shape, dtype=np.uint8) norm = np.clip((np.nan_to_num(arr, nan=lo, posinf=hi, neginf=lo) - lo) / (hi - lo), 0.0, 1.0) if gamma != 1.0: norm = np.power(norm, 1.0 / gamma) return (norm * 255.0 + 0.5).astype(np.uint8)
[docs] def save_png( image: NDArrayFloatType, path: PathLike, *, low_percentile: float = 0.5, high_percentile: float = 99.5, gamma: float = 1.0, upscale: int = 1, ) -> Path: """Stretch a DN image and write it as a grayscale PNG. Parameters: image: The DN image array. path: Destination ``.png`` path; parent directories are created. low_percentile: Percentile mapped to black. high_percentile: Percentile mapped to white. gamma: Display gamma; values above 1 lift dim features. upscale: Integer nearest-neighbour magnification, so a small frame is still legible in a document (1 disables it). Returns: The written path. """ u8 = stretch_to_uint8( image, low_percentile=low_percentile, high_percentile=high_percentile, gamma=gamma ) im = Image.fromarray(u8, mode='L') if upscale > 1: im = im.resize((u8.shape[1] * upscale, u8.shape[0] * upscale), Image.Resampling.NEAREST) out = Path(str(path)) out.parent.mkdir(parents=True, exist_ok=True) im.save(str(out)) return out
[docs] def render_scene_png( sim_params: dict[str, Any], path: PathLike, *, ignore_offset: bool = True, low_percentile: float = 0.5, high_percentile: float = 99.5, gamma: float = 1.0, upscale: int = 1, ) -> Path: """Render a ``sim_params`` scene and save it as a viewable PNG. Parameters: sim_params: The scene parameters consumed by :func:`spindoctor.sim.render.render_combined_model`. path: Destination ``.png`` path. ignore_offset: Render the unshifted geometry (the planted navigation offset is a small sub-pixel shift that is not meant to be visible); set ``False`` to render exactly what the navigator sees. low_percentile: Percentile mapped to black. high_percentile: Percentile mapped to white. gamma: Display gamma; values above 1 lift dim features. upscale: Integer nearest-neighbour magnification. Returns: The written path. """ from spindoctor.sim.render import render_combined_model img, _ = render_combined_model(sim_params, ignore_offset=ignore_offset) return save_png( img, path, low_percentile=low_percentile, high_percentile=high_percentile, gamma=gamma, upscale=upscale, )