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 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 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 — zoomed_animation() is therefore cached on (file, size, target) and hands back finished, display-sized frames.

This module is deliberately Qt-free except for to_qimage(), so the measurement can be used from tests and tools without a QApplication.

Classes

ZoomedAnimation

One packaged animation, cropped and rescaled for display.

Functions

chrome_mask(→ numpy.ndarray)

Mask of everything that is decoration rather than scene content.

clear_cache(→ None)

Drop every cached zoom — used by tests and by asset regeneration.

content_bounds(→ Optional[Tuple[int, int, int, int]])

Inclusive (left, top, right, bottom) of the content, or None.

content_extent(→ float)

Share of the square the content spans, on its longer axis.

content_mask(→ numpy.ndarray)

Union across frames of every non-background, non-chrome pixel.

drop_specks(→ numpy.ndarray)

Return mask without lit pixels that have too few lit neighbours.

field_geometry(→ Tuple[Tuple[float, float, float, ...)

Return the well rectangle and corner radius for a frame of size.

field_ring_mask(→ numpy.ndarray)

Mask covering only the drawn field stroke, not the space outside it.

from_qimage(→ numpy.ndarray)

Convert a QImage back to an RGB array.

read_frames(→ Tuple[Tuple[numpy.ndarray, ...], ...)

Decode path into composed RGB frames and their delays.

source_content_extent(→ float)

Measure the packaged animation at path as generated.

to_qimage(frame)

Convert one RGB frame to a self-owned QImage.

zoom_frames(→ Tuple[Tuple[numpy.ndarray, ...], ...)

Crop and rescale frames so their content covers target.

zoomed_animation(→ Optional[ZoomedAnimation])

Load, measure and zoom one animation — cached per (path, size).

Module Contents

class spacr.qt.widgets.animation_zoom.ZoomedAnimation[source]

One packaged animation, cropped and rescaled for display.

Parameters:
  • path – the animation file, as passed to zoomed_animation().

  • size – side of the square display frames, in pixels.

  • frames – display-sized (size, size, 3) uint8 RGB frames.

  • delays – per-frame duration in milliseconds.

  • source_extent – content share of the square before the zoom.

  • fill – content share after it — nominally TARGET_FILL.

  • 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.

  • 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.

chrome_mask() → numpy.ndarray | None[source]

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.

measured_fill() → float[source]

Re-measure the produced frames rather than trusting the maths.

spacr.qt.widgets.animation_zoom.chrome_mask(size: int, box: Sequence[float] | None = None, radius: float | None = None, pad: float = FIELD_PAD) → numpy.ndarray[source]

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).

Parameters:
  • size – side of the square frame, in pixels.

  • box – the well rectangle (left, top, right, bottom) in pixels of this frame; None uses field_geometry() for size.

  • radius – the well’s corner radius in pixels; None uses field_geometry() for size.

  • 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.

spacr.qt.widgets.animation_zoom.clear_cache() → None[source]

Drop every cached zoom — used by tests and by asset regeneration.

spacr.qt.widgets.animation_zoom.content_bounds(frames: Sequence[numpy.ndarray], chrome: numpy.ndarray | None = None, minimum_neighbours: int = MIN_NEIGHBOURS) → Tuple[int, int, int, int] | None[source]

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.

Parameters:
  • frames – the animation’s RGB uint8 frames, all the same square size, as read_frames() returns them.

  • chrome – boolean mask of pixels to ignore, as chrome_mask() returns it, or None to count every pixel.

  • minimum_neighbours – lit 8-neighbours a lit pixel needs to count; see MIN_NEIGHBOURS.

spacr.qt.widgets.animation_zoom.content_extent(frames: Sequence[numpy.ndarray], chrome: numpy.ndarray | None = None, minimum_neighbours: int = MIN_NEIGHBOURS) → float[source]

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.

Parameters:
  • frames – the animation’s RGB uint8 frames, all the same square size, as read_frames() returns them. An empty sequence gives 0.0.

  • chrome – boolean mask of pixels to ignore, as chrome_mask() returns it, or None to count every pixel.

  • minimum_neighbours – lit 8-neighbours a lit pixel needs to count; see MIN_NEIGHBOURS.

spacr.qt.widgets.animation_zoom.content_mask(frames: Sequence[numpy.ndarray], chrome: numpy.ndarray | None = None, minimum_neighbours: int = MIN_NEIGHBOURS) → numpy.ndarray[source]

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.

Parameters:
  • frames – the animation’s RGB uint8 frames, all the same square size, as read_frames() returns them. A pixel is lit when any channel is above BACKGROUND_LEVEL.

  • chrome – boolean mask of pixels to ignore, as chrome_mask() returns it, or None to count every pixel.

  • minimum_neighbours – lit 8-neighbours a lit pixel needs to count; see MIN_NEIGHBOURS.

spacr.qt.widgets.animation_zoom.drop_specks(mask: numpy.ndarray, minimum: int = MIN_NEIGHBOURS) → numpy.ndarray[source]

Return mask without lit pixels that have too few lit neighbours.

See 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.

Parameters:
  • mask – 2-D boolean mask of lit pixels.

  • minimum – lit 8-neighbours a pixel needs to be kept; 0 or less returns mask unchanged.

spacr.qt.widgets.animation_zoom.field_geometry(size: int) → Tuple[Tuple[float, float, float, float], float][source]

Return the well rectangle and corner radius for a frame of size.

Parameters:

size – side of the square frame, in pixels; the geometry drawn at SOURCE_SIZE is scaled to it.

spacr.qt.widgets.animation_zoom.field_ring_mask(size: int, box: Sequence[float] | None = None, radius: float | None = None, pad: float = FIELD_PAD) → numpy.ndarray[source]

Mask covering only the drawn field stroke, not the space outside it.

Parameters:
  • size – side of the square frame, in pixels.

  • box – the well rectangle (left, top, right, bottom) in pixels of this frame; None uses field_geometry() for size.

  • radius – the well’s corner radius in pixels; None uses field_geometry() for size.

  • pad – half-width, in pixels, of the band masked around the field path.

spacr.qt.widgets.animation_zoom.from_qimage(image) → numpy.ndarray[source]

Convert a QImage back to an RGB array.

The inverse of 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.

Parameters:

image – the QImage to convert; it is converted to RGB888 first, so any format is accepted.

spacr.qt.widgets.animation_zoom.read_frames(path) → Tuple[Tuple[numpy.ndarray, ...], Tuple[int, ...]][source]

Decode path into composed RGB frames and their delays.

Parameters:

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.

spacr.qt.widgets.animation_zoom.source_content_extent(path) → float[source]

Measure the packaged animation at path as generated.

Parameters:

path – the packaged animation file, decoded with read_frames(); a file with no frames gives 0.0.

spacr.qt.widgets.animation_zoom.to_qimage(frame: numpy.ndarray)[source]

Convert one RGB frame to a self-owned 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.

Parameters:

frame – one RGB frame, an array of shape (H, W, 3), converted to uint8.

spacr.qt.widgets.animation_zoom.zoom_frames(frames: Sequence[numpy.ndarray], size: int, target: float = TARGET_FILL) → Tuple[Tuple[numpy.ndarray, ...], Tuple[int, int, int], float, float, bool][source]

Crop and rescale frames so their content covers target.

Parameters:
  • frames – the source RGB uint8 frames, all the same square size; the chrome mask is built for that size.

  • size – side of the square output frames, in pixels.

  • target – share of the output square the content should span.

Returns:

(frames, crop, source_extent, fill, shows_field).

spacr.qt.widgets.animation_zoom.zoomed_animation(path: str, size: int, target: float = TARGET_FILL) → ZoomedAnimation | None[source]

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.

Parameters:
  • path – the packaged animation file; the cache is keyed on it together with size and target.

  • size – side of the square display frames, in pixels; converted to int.

  • 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.