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¶
One packaged animation, cropped and rescaled for display. |
Functions¶
|
Mask of everything that is decoration rather than scene content. |
|
Drop every cached zoom — used by tests and by asset regeneration. |
|
Inclusive |
|
Share of the square the content spans, on its longer axis. |
|
Union across |
|
Return |
|
Return the well rectangle and corner radius for a frame of |
|
Mask covering only the drawn field stroke, not the space outside it. |
|
Convert a |
|
Decode |
|
Measure the packaged animation at |
|
Convert one RGB frame to a self-owned |
|
Crop and rescale |
|
Load, measure and zoom one animation — cached per |
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/topmay be negative andsidemay 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
Noneif 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.
- 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 * padstraddling 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;Noneusesfield_geometry()forsize.radius – the well’s corner radius in pixels;
Noneusesfield_geometry()forsize.pad – half-width, in pixels, of the band masked around the field path.
- Returns:
boolean
(size, size)array,Truewhere 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, orNone.Nonemeans 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, orNoneto 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, orNoneto 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
framesof 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 aboveBACKGROUND_LEVEL.chrome – boolean mask of pixels to ignore, as
chrome_mask()returns it, orNoneto 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
maskwithout lit pixels that have too few lit neighbours.See
MIN_NEIGHBOURSfor 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
maskunchanged.
- 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_SIZEis 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;Noneusesfield_geometry()forsize.radius – the well’s corner radius in pixels;
Noneusesfield_geometry()forsize.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
QImageback 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
QImageto 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
pathinto 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
pathas 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.QImagedoes 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
framesso their content coverstarget.- 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
sizeandtarget.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:
Nonewhen the file cannot be decoded, so a missing or corrupt asset degrades to a text-only tooltip instead of raising into the event loop.