Commit 3c7136f8 authored by vertighel's avatar vertighel
Browse files

Downscale before colour-mapping in fits_to_png, not after



apply_viridis was running on the full-resolution frame before
array_to_png threw most of it away for the thumbnail — colour-mapping
every pixel of a 17-Mpixel frame just to keep a 128x128 preview cost
~270-290ms for zero visual benefit. Downscaling first (both the
colormap step and, when vmin/vmax are omitted, the auto_range
fallback) gives byte-identical PNG output for the common case where
vmin/vmax are supplied, since a stride-based pixel selection commutes
with a per-pixel transform. Measured on a real 4126x4145 scicam1
frame: 128px thumbnail 270ms -> 0.9ms, 256px 272ms -> 3.3ms, 512px
(the live-preview push size) 411ms -> 13ms.

When vmin/vmax are None (only stream.py's WS preview broadcast),
auto_range() now also runs on the downscaled data instead of the full
frame - a small approximation (~3% of range on the tested frame), not
used by the user-facing range shown by /info.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 53e0364c
Loading
Loading
Loading
Loading
Loading
+43 −12
Original line number Diff line number Diff line
@@ -19,6 +19,31 @@ import numpy as np
from .color_tables import viridis as _viridis


def _downscale(data, thumbnail_size):
    """Stride-sample *data* so its longest side is at most *thumbnail_size* px.

    Nearest-neighbour, pure numpy — only the first two axes are touched, so
    this works on both 2-D (grayscale) and 3-D (H, W, C) arrays. No-op if
    the array is already within *thumbnail_size*.

    Because this is a pixel *selection* (not an average/blend), applying it
    before or after a per-pixel elementwise transform (e.g. a colormap)
    gives byte-identical results — see fits_to_png, which downscales first
    to avoid transforming pixels that would just be thrown away.
    """

    h_orig, w_orig = data.shape[0], data.shape[1]
    scale = thumbnail_size / max(h_orig, w_orig)
    if scale >= 1.0:
        return data
    new_h = max(1, int(h_orig * scale))
    new_w = max(1, int(w_orig * scale))
    sy = max(1, h_orig // new_h)
    sx = max(1, w_orig // new_w)

    return data[::sy, ::sx]


def array_to_png(data, vmin=0, vmax=255, thumbnail_size=None):
    """
    Convert a numpy array to a pure-Python PNG binary buffer.
@@ -55,17 +80,8 @@ def array_to_png(data, vmin=0, vmax=255, thumbnail_size=None):
    >>> png_thumb = array_to_png(arr, vmin=0, vmax=65535, thumbnail_size=256)
    """

    # --- optional thumbnail downscale (pure numpy, no extra deps) ----------
    if thumbnail_size is not None:
        h_orig = data.shape[0]
        w_orig = data.shape[1]
        scale = thumbnail_size / max(h_orig, w_orig)
        if scale < 1.0:
            new_h = max(1, int(h_orig * scale))
            new_w = max(1, int(w_orig * scale))
            sy = max(1, h_orig // new_h)
            sx = max(1, w_orig // new_w)
            data = data[::sy, ::sx]
        data = _downscale(data, thumbnail_size)

    # --- rescale to uint8 --------------------------------------------------
    rescaled = np.clip(data, vmin, vmax)
@@ -119,13 +135,28 @@ def apply_viridis(data, vmin, vmax):


def fits_to_png(data, vmin=None, vmax=None, thumbnail_size=None):
    """Convert a 2-D float array to a viridis-coloured PNG (auto-range when vmin/vmax absent)."""
    """Convert a 2-D float array to a viridis-coloured PNG (auto-range when vmin/vmax absent).

    Downscales *before* colour-mapping, not after: colour-mapping every
    pixel of a multi-megapixel frame just to throw away all but a small
    thumbnail costs hundreds of ms for zero visual benefit (byte-identical
    output either order, since both the downscale and the colormap are
    per-pixel operations — see _downscale's docstring). When vmin/vmax are
    None, auto_range() also runs on the already-downscaled data instead of
    the full frame — a small approximation (percentiles of a ~500-pixel
    strided sample vs. the full multi-megapixel frame), acceptable here
    since this fallback only feeds the live preview push (stream.py), not
    the user-facing range shown by /info.
    """

    if thumbnail_size is not None:
        data = _downscale(data, thumbnail_size)

    if vmin is None or vmax is None:
        vmin, vmax = auto_range(data)
    rgb = apply_viridis(data, vmin, vmax)

    return array_to_png(rgb, thumbnail_size=thumbnail_size)
    return array_to_png(rgb)


def make_png(data, vmin=None, vmax=None, color=True):