Source code for spacr.qt.widgets.animation_zoom

"""Content-aware zoom for the packaged setting animations.

Every generated GIF draws its scene inside the same rounded "well" — the
336-pixel frame that :func:`tools.generate_setting_animations._well` paints
at (12, 12)-(348, 348) on a 360-pixel black square. The *scene* inside it is
usually far smaller than the well. Measured across all 94 packaged
animations, the median content covers only 63.9 % of the square, 72 of them
are below 70 %, and the smallest (``nucleus_diameter``) covers 22.8 %. At
tooltip size that leaves a few dozen pixels of actual illustration floating
in black.

So the frames are not shown as generated. Each animation's real content
bounds are measured once — the union, across every frame, of everything that
is neither background nor well chrome — and the frames are then cropped and
rescaled so the content covers a fixed :data:`TARGET_FILL` share of the
square. Animations whose content already overflows that share (the
``remove_border_objects`` scenes reach 91.9 %) are scaled *down* to the same
target, so every animation in the app is presented at one consistent size.

The measurement decodes and scans every frame, which is far too expensive to
repeat on an animation tick — :func:`zoomed_animation` is therefore cached on
``(file, size, target)`` and hands back finished, display-sized frames.

This module is deliberately Qt-free except for :func:`to_qimage`, so the
measurement can be used from tests and tools without a QApplication.
"""
from __future__ import annotations

from dataclasses import dataclass
from functools import lru_cache
from typing import Optional, Sequence, Tuple

import numpy as np
from PIL import Image, ImageDraw, ImageSequence


#: Side of the generated square in source pixels. The well geometry below is
#: expressed against this and scaled for any other frame size, so an animation
#: regenerated at a different resolution still measures correctly.
SOURCE_SIZE = 360

#: The rounded field boundary drawn by ``_well()`` in
#: ``tools/generate_setting_animations.py``: an inset rounded rectangle with a
#: 20-pixel corner radius. It is chrome, not content — every scene has it, so
#: including it would make every animation measure ~93 % full and defeat the
#: whole point of the zoom.
FIELD_BOX = (12, 12, SOURCE_SIZE - 12, SOURCE_SIZE - 12)
FIELD_RADIUS = 20

#: Half-width of the band masked out around the field path. The stroke is
#: sub-pixel at output scale and LANCZOS smears it across two to three pixels,
#: so the band has to be wider than the nominal line or stray remnants of it
#: re-enter the measurement and read as content at the frame's full extent.
FIELD_PAD = 3

#: Channel value at or below which a pixel counts as background. The frames
#: are drawn on pure black and anti-aliased, so a small floor keeps the
#: faintest edge pixels of a shape without picking up encoder noise.
BACKGROUND_LEVEL = 8

#: Lit 8-neighbours a lit pixel needs before it counts as content.
#:
#: GIF quantisation can leave single specks one level above the background.
#: The synthetic regression places a value-9 pixel at (300, 300) beside a
#: 40x40 shape: counting the speck expands the content bounds to 201x201 and
#: makes the zoom frame an otherwise empty corner.
#: Real content here is anti-aliased line art and filled shapes, where every
#: pixel of a stroke has neighbours; an isolated pixel is never a drawing.
MIN_NEIGHBOURS = 1

#: Share of the square the content is scaled to occupy. The requirement is a
#: 70-80 % band; aiming at the middle leaves room for the rounding and
#: resampling slack that a hard edge of the band would not survive.
TARGET_FILL = 0.75
MIN_FILL = 0.70
MAX_FILL = 0.80

#: Frame duration used when a GIF frame declares none.
DEFAULT_DELAY_MS = 80



[docs] def read_frames(path) -> Tuple[Tuple[np.ndarray, ...], Tuple[int, ...]]: """Decode ``path`` into composed RGB frames and their delays. :param path: any path-like pointing at a packaged animation. :returns: ``(frames, delays_ms)``; frames are ``(h, w, 3)`` uint8 arrays already composed against the preceding frame by Pillow, so a GIF that encodes only the changed rectangle still yields whole pictures. """ frames: list[np.ndarray] = [] delays: list[int] = [] with Image.open(str(path)) as image: for frame in ImageSequence.Iterator(image): frames.append(np.array(frame.convert("RGB"), dtype=np.uint8)) duration = frame.info.get("duration") or 0 delays.append(int(duration) or DEFAULT_DELAY_MS) return tuple(frames), tuple(delays)
[docs] def field_geometry(size: int) -> Tuple[Tuple[float, float, float, float], float]: """Return the well rectangle and corner radius for a frame of ``size``. :param size: side of the square frame, in pixels; the geometry drawn at :data:`SOURCE_SIZE` is scaled to it. """ scale = float(size) / float(SOURCE_SIZE) box = tuple(value * scale for value in FIELD_BOX) return box, FIELD_RADIUS * scale
def _field_masks( size: int, box: Optional[Sequence[float]] = None, radius: Optional[float] = None, pad: float = FIELD_PAD, ) -> Tuple[np.ndarray, np.ndarray]: """Return ``(ring, inside_outer)`` for the well of a ``size`` frame.""" if box is None or radius is None: default_box, default_radius = field_geometry(size) box = default_box if box is None else box radius = default_radius if radius is None else radius left, top, right, bottom = (float(value) for value in box) outer = Image.new("L", (size, size), 0) ImageDraw.Draw(outer).rounded_rectangle( (left - pad, top - pad, right + pad, bottom + pad), radius=max(0.0, radius + pad), fill=255, ) inner = Image.new("L", (size, size), 0) inner_box = (left + pad, top + pad, right - pad, bottom - pad) if inner_box[2] > inner_box[0] and inner_box[3] > inner_box[1]: ImageDraw.Draw(inner).rounded_rectangle( inner_box, radius=max(0.0, radius - pad), fill=255, ) inside_outer = np.asarray(outer) > 0 inside_inner = np.asarray(inner) > 0 return inside_outer & ~inside_inner, inside_outer
[docs] def field_ring_mask( size: int, box: Optional[Sequence[float]] = None, radius: Optional[float] = None, pad: float = FIELD_PAD, ) -> np.ndarray: """Mask covering only the drawn field stroke, not the space outside it. :param size: side of the square frame, in pixels. :param box: the well rectangle ``(left, top, right, bottom)`` in pixels of this frame; ``None`` uses :func:`field_geometry` for ``size``. :param radius: the well's corner radius in pixels; ``None`` uses :func:`field_geometry` for ``size``. :param pad: half-width, in pixels, of the band masked around the field path. """ ring, _inside = _field_masks(size, box, radius, pad) return ring
[docs] def chrome_mask( size: int, box: Optional[Sequence[float]] = None, radius: Optional[float] = None, pad: float = FIELD_PAD, ) -> np.ndarray: """Mask of everything that is decoration rather than scene content. That is the band of width ``2 * pad`` straddling the rounded field path, plus everything outside the field. Used both on the source frames (to measure real content) and on the zoomed output (to measure it again after the transform, when the field survived the crop). :param size: side of the square frame, in pixels. :param box: the well rectangle ``(left, top, right, bottom)`` in pixels of this frame; ``None`` uses :func:`field_geometry` for ``size``. :param radius: the well's corner radius in pixels; ``None`` uses :func:`field_geometry` for ``size``. :param pad: half-width, in pixels, of the band masked around the field path. :returns: boolean ``(size, size)`` array, ``True`` where a pixel must be ignored by the content measurement. """ ring, inside_outer = _field_masks(size, box, radius, pad) return ring | ~inside_outer
[docs] def drop_specks(mask: np.ndarray, minimum: int = MIN_NEIGHBOURS) -> np.ndarray: """Return ``mask`` without lit pixels that have too few lit neighbours. See :data:`MIN_NEIGHBOURS` for why an isolated pixel has to go. Done with shifted slices rather than a convolution so the module keeps its only numeric dependency. :param mask: 2-D boolean mask of lit pixels. :param minimum: lit 8-neighbours a pixel needs to be kept; 0 or less returns ``mask`` unchanged. """ if minimum <= 0 or mask.size == 0: return mask height, width = mask.shape padded = np.zeros((height + 2, width + 2), dtype=np.uint8) padded[1:-1, 1:-1] = mask neighbours = np.zeros((height, width), dtype=np.uint8) for row in (0, 1, 2): for column in (0, 1, 2): if row == 1 and column == 1: continue neighbours += padded[row:row + height, column:column + width] return mask & (neighbours >= minimum)
[docs] def content_mask( frames: Sequence[np.ndarray], chrome: Optional[np.ndarray] = None, minimum_neighbours: int = MIN_NEIGHBOURS, ) -> np.ndarray: """Union across ``frames`` of every non-background, non-chrome pixel. The union — not one representative frame — is what has to be framed: an animation whose object drifts across the well would otherwise be cropped to wherever it happened to be when the measurement ran. Specks are dropped per frame, before the union: a stray pixel is noise in the frame that produced it, and unioning first would let one frame's speck borrow a neighbour from another frame's real content. :param frames: the animation's RGB uint8 frames, all the same square size, as :func:`read_frames` returns them. A pixel is lit when any channel is above :data:`BACKGROUND_LEVEL`. :param chrome: boolean mask of pixels to ignore, as :func:`chrome_mask` returns it, or ``None`` to count every pixel. :param minimum_neighbours: lit 8-neighbours a lit pixel needs to count; see :data:`MIN_NEIGHBOURS`. """ accumulated: Optional[np.ndarray] = None for frame in frames: lit = ( (frame[..., 0] > BACKGROUND_LEVEL) | (frame[..., 1] > BACKGROUND_LEVEL) | (frame[..., 2] > BACKGROUND_LEVEL) ) if chrome is not None: lit = lit & ~chrome lit = drop_specks(lit, minimum_neighbours) accumulated = lit if accumulated is None else (accumulated | lit) if accumulated is None: return np.zeros((0, 0), dtype=bool) return accumulated
[docs] def content_bounds( frames: Sequence[np.ndarray], chrome: Optional[np.ndarray] = None, minimum_neighbours: int = MIN_NEIGHBOURS, ) -> Optional[Tuple[int, int, int, int]]: """Inclusive ``(left, top, right, bottom)`` of the content, or ``None``. ``None`` means the animation is blank once chrome is discounted, which is not a failure — it cannot be zoomed, and callers show it as-is. :param frames: the animation's RGB uint8 frames, all the same square size, as :func:`read_frames` returns them. :param chrome: boolean mask of pixels to ignore, as :func:`chrome_mask` returns it, or ``None`` to count every pixel. :param minimum_neighbours: lit 8-neighbours a lit pixel needs to count; see :data:`MIN_NEIGHBOURS`. """ mask = content_mask(frames, chrome, minimum_neighbours) if not mask.size: return None rows = np.nonzero(mask.any(axis=1))[0] columns = np.nonzero(mask.any(axis=0))[0] if not rows.size or not columns.size: return None return ( int(columns[0]), int(rows[0]), int(columns[-1]), int(rows[-1]), )
[docs] def content_extent( frames: Sequence[np.ndarray], chrome: Optional[np.ndarray] = None, minimum_neighbours: int = MIN_NEIGHBOURS, ) -> float: """Share of the square the content spans, on its longer axis. This is *the* number the 70-80 % requirement is stated in, and the same function measures the source and the zoomed result — a target the transform is checked against rather than one it defines for itself. :param frames: the animation's RGB uint8 frames, all the same square size, as :func:`read_frames` returns them. An empty sequence gives 0.0. :param chrome: boolean mask of pixels to ignore, as :func:`chrome_mask` returns it, or ``None`` to count every pixel. :param minimum_neighbours: lit 8-neighbours a lit pixel needs to count; see :data:`MIN_NEIGHBOURS`. """ if not len(frames): return 0.0 bounds = content_bounds(frames, chrome, minimum_neighbours) if bounds is None: return 0.0 left, top, right, bottom = bounds side = max(right - left + 1, bottom - top + 1) return float(side) / float(frames[0].shape[0])
[docs] def source_content_extent(path) -> float: """Measure the packaged animation at ``path`` as generated. :param path: the packaged animation file, decoded with :func:`read_frames`; a file with no frames gives 0.0. """ frames, _delays = read_frames(path) if not frames: return 0.0 return content_extent(frames, chrome_mask(frames[0].shape[0]))
@dataclass(frozen=True)
[docs] class ZoomedAnimation: """One packaged animation, cropped and rescaled for display. :param path: the animation file, as passed to :func:`zoomed_animation`. :param size: side of the square display frames, in pixels. :param frames: display-sized ``(size, size, 3)`` uint8 RGB frames. :param delays: per-frame duration in milliseconds. :param source_extent: content share of the square before the zoom. :param fill: content share after it — nominally :data:`TARGET_FILL`. :param crop: ``(left, top, side)`` in source pixels; ``left``/``top`` may be negative and ``side`` may exceed the source when content had to be scaled *down*, in which case the frame is padded with background. :param shows_field: whether the rounded well survived the crop whole. When it did not it is erased, because a well sliced by the crop reads as two stray lines rather than as a boundary. """ path: str size: int frames: Tuple[np.ndarray, ...] delays: Tuple[int, ...] source_extent: float fill: float crop: Tuple[int, int, int] shows_field: bool
[docs] def chrome_mask(self) -> Optional[np.ndarray]: """Chrome mask in *output* coordinates, or ``None`` if there is none. Only meaningful when the well survived: the same rounded rectangle, mapped through the crop and the scale, so the zoomed frames can be measured by exactly the rule the source frames were measured by. """ if not self.shows_field: return None left, top, side = self.crop scale = float(self.size) / float(side) box = tuple( (value - offset) * scale for value, offset in zip( FIELD_BOX, (left, top, left, top) ) ) pad = max(3.0, FIELD_PAD * scale + 1.0) return chrome_mask(self.size, box, FIELD_RADIUS * scale, pad)
[docs] def measured_fill(self) -> float: """Re-measure the produced frames rather than trusting the maths.""" return content_extent(self.frames, self.chrome_mask())
[docs] def zoom_frames( frames: Sequence[np.ndarray], size: int, target: float = TARGET_FILL, ) -> Tuple[Tuple[np.ndarray, ...], Tuple[int, int, int], float, float, bool]: """Crop and rescale ``frames`` so their content covers ``target``. :param frames: the source RGB uint8 frames, all the same square size; the chrome mask is built for that size. :param size: side of the square output frames, in pixels. :param target: share of the output square the content should span. :returns: ``(frames, crop, source_extent, fill, shows_field)``. """ source_size = frames[0].shape[0] chrome = chrome_mask(source_size) bounds = content_bounds(frames, chrome) if bounds is None: scaled = tuple( _resize(frame, size) for frame in frames ) return scaled, (0, 0, source_size), 0.0, 0.0, True left, top, right, bottom = bounds extent = max(right - left + 1, bottom - top + 1) source_extent = float(extent) / float(source_size) side = max(1, int(round(extent / float(target)))) centre_x = (left + right + 1) / 2.0 centre_y = (top + bottom + 1) / 2.0 crop_left = int(round(centre_x - side / 2.0)) crop_top = int(round(centre_y - side / 2.0)) fill = float(extent) / float(side) field_left, field_top, field_right, field_bottom = FIELD_BOX shows_field = ( crop_left <= field_left - FIELD_PAD and crop_top <= field_top - FIELD_PAD and crop_left + side >= field_right + FIELD_PAD and crop_top + side >= field_bottom + FIELD_PAD ) erase = None if shows_field else chrome scaled = [] for frame in frames: source = frame if erase is not None: source = frame.copy() source[erase] = 0 cropped = Image.fromarray(source).crop( (crop_left, crop_top, crop_left + side, crop_top + side) ) scaled.append( np.array( cropped.resize((size, size), Image.Resampling.LANCZOS), dtype=np.uint8, ) ) return ( tuple(scaled), (crop_left, crop_top, side), source_extent, fill, shows_field, )
def _resize(frame: np.ndarray, size: int) -> np.ndarray: """Resize one frame to a square. Lanczos rather than a cheaper filter: these frames are magnified, and a nearest-neighbour enlargement of a small frame reads as a bug. :param frame: the frame. :param size: the output edge, in pixels. :returns: the resized frame. """ return np.array( Image.fromarray(frame).resize( (size, size), Image.Resampling.LANCZOS), dtype=np.uint8, ) #: How many finished animations stay resident. #: #: Deliberately small. A zoomed animation is ~18 frames of 220x220 RGB, about #: 2.6 MB, so caching all 94 would cost a quarter of a gigabyte to save 40 ms #: on a hover. Eight covers re-hovering a setting, moving to its neighbour and #: coming back, which is what people actually do while reading a form. CACHE_SIZE = 8 @lru_cache(maxsize=CACHE_SIZE)
[docs] def zoomed_animation( path: str, size: int, target: float = TARGET_FILL, ) -> Optional[ZoomedAnimation]: """Load, measure and zoom one animation — cached per ``(path, size)``. Decoding a GIF and scanning every frame costs tens of milliseconds; doing it per animation tick would be absurd, and doing it per hover would make every tooltip stutter. Callers may treat the result as immutable. :param path: the packaged animation file; the cache is keyed on it together with ``size`` and ``target``. :param size: side of the square display frames, in pixels; converted to ``int``. :param target: share of the output square the content should span; converted to ``float``. :returns: ``None`` when the file cannot be decoded, so a missing or corrupt asset degrades to a text-only tooltip instead of raising into the event loop. """ try: frames, delays = read_frames(path) except (OSError, ValueError): return None if not frames: return None scaled, crop, source_extent, fill, shows_field = zoom_frames( frames, int(size), float(target)) return ZoomedAnimation( path=str(path), size=int(size), frames=scaled, delays=delays, source_extent=source_extent, fill=fill, crop=crop, shows_field=shows_field, )
[docs] def clear_cache() -> None: """Drop every cached zoom — used by tests and by asset regeneration.""" zoomed_animation.cache_clear()
[docs] def to_qimage(frame: np.ndarray): """Convert one RGB frame to a self-owned :class:`QImage`. ``QImage`` does not take ownership of a Python buffer, so the copy is not optional: without it the image points at freed memory the moment the array goes out of scope. :param frame: one RGB frame, an array of shape (H, W, 3), converted to uint8. """ from PySide6.QtGui import QImage height, width = frame.shape[:2] contiguous = np.ascontiguousarray(frame, dtype=np.uint8) image = QImage( contiguous.tobytes(), width, height, width * 3, QImage.Format_RGB888, ) return image.copy()
[docs] def from_qimage(image) -> np.ndarray: """Convert a :class:`QImage` back to an RGB array. The inverse of :func:`to_qimage`, so what is actually on screen can be measured by the same rule the source frames were measured by — the scan lines are padded to a 4-byte boundary, hence the stride arithmetic. :param image: the ``QImage`` to convert; it is converted to RGB888 first, so any format is accepted. """ from PySide6.QtGui import QImage converted = image.convertToFormat(QImage.Format_RGB888) width = converted.width() height = converted.height() stride = converted.bytesPerLine() raw = bytes(converted.constBits())[:stride * height] rows = np.frombuffer(raw, dtype=np.uint8).reshape(height, stride) return rows[:, :width * 3].reshape(height, width, 3).copy()
__all__ = [ "BACKGROUND_LEVEL", "from_qimage", "CACHE_SIZE", "MIN_NEIGHBOURS", "drop_specks", "FIELD_BOX", "FIELD_PAD", "FIELD_RADIUS", "MAX_FILL", "MIN_FILL", "SOURCE_SIZE", "TARGET_FILL", "ZoomedAnimation", "chrome_mask", "clear_cache", "content_bounds", "content_extent", "content_mask", "field_geometry", "field_ring_mask", "read_frames", "source_content_extent", "to_qimage", "zoom_frames", "zoomed_animation", ]