diff --git a/SCOPE.md b/SCOPE.md index 70477ffc..f0b60f0a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -65,7 +65,8 @@ colorbars, ticks, classification, and animation. - `tiles` and `reference` are cleopatra's **only** networked features. - `projection`: lightweight axes-frame / coordinate helpers. - `animation`: turn a matplotlib `FuncAnimation` into a saved file, GIF bytes, - or an embeddable IPython image (via ffmpeg). + or an embeddable IPython image (via ffmpeg), and derive one output format from + another (`gif_from_video`) — see "Animation output". - `config` (`Config`): opt-in matplotlib-backend selection; notebook detection. ### What new work generally belongs here @@ -85,13 +86,14 @@ colorbars, ticks, classification, and animation. - **Data I/O and formats:** reading/writing *user* GeoTIFF, NetCDF, shapefiles, GeoJSON, CSV, databases. Users bring NumPy arrays already; file/raster I/O of user data belongs in sibling packages (e.g. `pyramids`), - not here. The deliberate exception is the `tiles` / `reference` basemap + not here. Three deliberate exceptions: the `tiles` / `reference` basemap helpers, which fetch a handful of *fixed public* reference datasets (never user data) that cleopatra re-hosts as dependency-light artifacts — see - "Supporting utilities". Reading a **presentation asset** — a logo / watermark - image for `styling.watermark.stamp_mark` — is likewise allowed: it is - decoration on the rendered figure, not user data, and it loads via Pillow (an - existing dependency), never GDAL/geopandas. + "Supporting utilities"; reading a **presentation asset** — a logo / watermark + image for `styling.watermark.stamp_mark` — which is decoration on the rendered + figure, not user data, and loads via Pillow (an existing dependency), never + GDAL/geopandas; and re-encoding cleopatra's **own animation output** between + formats — see "Animation output" below. - **GIS / geoprocessing:** reprojection of user data, clipping, resampling, zonal stats, CRS management beyond what the optional `tiles` basemap needs. - **Interactive / GUI apps:** dashboards, widget servers, event callbacks, @@ -110,13 +112,38 @@ colorbars, ticks, classification, and animation. than the `tiles` / `reference` basemap helpers, and general-purpose plotting that matplotlib already does well without added value. +## Animation output + +Rendering frames is by far the most expensive part of an animation — hours, for a +long scientific clip — while encoding them is cheap. Forcing every output format +to be produced from a live `FuncAnimation` therefore means re-rendering the same +frames once per format, which is the wrong trade at any real size. + +So a helper may **read back a rendered video** and re-encode it to another +supported format, as `gif_from_video` does. The intended input is cleopatra's own +output, produced by `save_animation` moments earlier. Nothing in the code +enforces that — it decodes whatever FFmpeg can read, and a check would buy +nothing but a worse error message — so this is a statement of *purpose*, not a +guarantee about the argument. + +What keeps it inside the line is what it does not do: it reads no user dataset in +an analytical format, opens no GIS format, exposes no transcoding matrix, and +adds no dependency — the FFmpeg it decodes with is the one `save_animation` +already encodes with. What it returns is an animation, not data. + +The boundary that still holds: cleopatra does not become a general +media-conversion tool. A helper whose purpose was ingesting arbitrary user video, +or that grew a codec/container matrix, would be out of scope. + ## Boundary heuristic for a feature request Ask, in order: 1. **Input** — does it start from in-memory NumPy data (not a file/CRS/URL)? - (The `tiles` / `reference` basemap helpers are the deliberate exception: - they acquire fixed *public* reference data, never user files.) + (Three deliberate exceptions: the `tiles` / `reference` basemap helpers, + which acquire fixed *public* reference data, never user files; a presentation + asset such as a logo for `stamp_mark`; and the animation re-encoders, whose + input is cleopatra's own output — see "Animation output".) 2. **Output** — does it produce a matplotlib `Figure`/`Axes`/artist (or an animation of one)? 3. **Reuse** — can it build on `Glyph` and the shared colour/colorbar/legend diff --git a/docs/reference/animation.md b/docs/reference/animation.md index 63894ba2..152db9ba 100644 --- a/docs/reference/animation.md +++ b/docs/reference/animation.md @@ -18,6 +18,69 @@ machinery as **glyph-independent** helpers. They operate on *any* IPython is imported **lazily** (and is bundled with Jupyter, so any notebook already has it); if it is absent, `embed_gif` raises a clear `ModuleNotFoundError` with a `pip install ipython` hint — or use `to_gif` for raw bytes with no IPython dependency. +- `gif_from_video(src, path, fps=12, width=None, max_colors=254, ...)` derives a GIF from a + video **already on disk**, without re-rendering. Drawing is usually far more expensive than + encoding, so a long clip is best rendered once to MP4 and every other format derived from + that file. + +## The GIF palette + +Both GIF paths — `save_animation` and `gif_from_video` — quantise through one palette shared +by every frame, built by `build_clip_palette` from the colours the whole clip contains and +applied by `quantize_to_palette`. Both are public, so a downstream package writing its own +frames can reuse the same table rather than re-deriving one. Per-frame +palettes would make constant regions shimmer and let a colour drift between frames; two of +the 256 entries are pinned to pure black and white so single-colour overlays stay crisp. + +The palette is chosen for colour **coverage**, not pixel population, over the set of colours the +clip contains. The distinction matters on exactly the clips this package produces: with a +population-weighted split (median cut) a large textured background claims nearly every palette +slot, and small saturated marks — overlay glyphs, thin paths, labels — collapse to the nearest +muddy neighbour. On the texture-heavy clip in `tests/test_animation.py` those marks landed 100–180 away (in RGB +distance) from the colours they were drawn in; selecting for coverage reproduces them exactly, and +`TestClipPaletteQuality` asserts it — so the claim is checked, not remembered. + +Because coverage is computed over **distinct colours rather than pixels**, a mark survives no +matter how small it is: a one-pixel orbit path is kept as faithfully as a large glyph. Sampling +the frames spatially to build a cheaper palette source would undo that — an interpolating resize +blends a one-pixel mark into its background before the quantiser ever sees it. + +The trade is a marginally coarser background, because palette entries now go to colours the clip +contains rather than to the colours it contains *most of*, and file size moves either way depending +on the clip. Both were measured while developing this and neither is asserted by a test, so treat +the direction as reliable and the magnitude as indicative. + +`quantize_method` is the opt-out, on both `save_animation` and `gif_from_video`. It takes a key of +`QUANTIZE_METHODS` — `"coverage"` (the default), `"median"`, or `"octree"`. Reach for `"median"` on +a smooth photographic clip with no small marks at stake: it splits the colour cube by how densely +the clip populates it, so the crowded regions a photographic background occupies win the table. +Note none of these see pixel counts — the palette is built from each colour once, so they weight by +distinct colours, not by area: + +```python +save_animation(anim, "clip.gif", fps=12, quantize_method="median") +``` + +!!! warning "Render the intermediate with `pix_fmt="yuv444p"` if a GIF will be derived from it" + + `save_animation` writes `yuv420p` by default — the right choice for playback compatibility, + but it stores colour at half resolution in each direction. That loss happens *before* the + GIF palette ever runs, and no quantiser can undo it: on the same test clip a `yuv420p` + intermediate caps the derived GIF at ~50 RGB distance, against ~5 from a `yuv444p` one. + `gif_from_video` emits a `UserWarning` when it is handed a subsampled source. + +!!! note "Memory" + + `gif_from_video` decodes the source twice rather than holding it, so the decoded RGB frames are + never all resident. The quantised frames still are — Pillow's GIF encoder accumulates every + frame before writing its first byte. Expect roughly `width × height × frames` bytes at peak: a + third of what the RGB frames would cost, but still proportional to the clip's length. Use + `width` to bring a long master down. + + ```python + mp4 = save_animation(anim, "master.mp4", fps=12, crf=0, pix_fmt="yuv444p") + gif_from_video(mp4, "web.gif", fps=12, width=720) + ``` `SUPPORTED_VIDEO_FORMAT` is `["gif", "mov", "avi", "mp4", "webp"]`. `Glyph.save_animation` delegates to `save_animation`, so the writer/format logic has a single source of truth. diff --git a/pyproject.toml b/pyproject.toml index f467bcf6..fab3bc1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,11 @@ dependencies = [ "numpy>=2.0.0", "matplotlib>=3.9", "pillow>=12.1.1", - "imageio-ffmpeg>=0.4.9", + # 0.6.0 is the floor the suite actually exercises. gif_from_video reads + # frames back through read_frames with output_params and relies on the + # reported size being post-filter, none of which the previous 0.4.9 floor + # was ever tested against. + "imageio-ffmpeg>=0.6.0", "hpc-utils>=0.1.4", ] diff --git a/src/cleopatra/glyphs/base/animation.py b/src/cleopatra/glyphs/base/animation.py index 841cc8b1..8674e301 100644 --- a/src/cleopatra/glyphs/base/animation.py +++ b/src/cleopatra/glyphs/base/animation.py @@ -14,27 +14,389 @@ from __future__ import annotations +import itertools +import math import os import shutil import tempfile import warnings +from collections.abc import Generator, Iterable, Iterator from typing import TYPE_CHECKING, Any import matplotlib as mpl +import numpy as np from matplotlib.animation import FFMpegWriter, FuncAnimation, PillowWriter from PIL import Image as PILImage if TYPE_CHECKING: # import only for type checkers; IPython stays optional from IPython.display import Image +__all__ = [ + "CLIP_PALETTE_COLORS", + "QUANTIZE_METHODS", + "SUPPORTED_VIDEO_FORMAT", + "build_clip_palette", + "embed_gif", + "gif_from_video", + "quantize_to_palette", + "save_animation", + "to_bytes", + "to_gif", + "to_mp4", +] + #: Container formats `save_animation` can write. GIF and (animated) WebP use #: Pillow (`_OptimizedPillowWriter`); mov/avi/mp4 require FFmpeg (`FFMpegWriter`). #: WebP is typically 3-5x smaller than GIF for photographic/satellite frames. SUPPORTED_VIDEO_FORMAT = ["gif", "mov", "avi", "mp4", "webp"] +#: The matplotlib rcParam naming the ffmpeg binary to shell out to. +_FFMPEG_PATH_RCPARAM = "animation.ffmpeg_path" + #: Formats written by Pillow rather than FFmpeg. _PILLOW_FORMATS = {"gif", "webp"} +#: Palette entries a GIF clip's shared colour table is quantised to. Two of the +#: 256 are held back for pure black and white (see `build_clip_palette`). Public +#: because it is the default of `build_clip_palette` and `gif_from_video`, so it +#: appears in their rendered signatures. +CLIP_PALETTE_COLORS = 254 + +#: Palette strategies `build_clip_palette` accepts. `"coverage"` spans the +#: clip's colour range and is the default: it keeps small saturated marks that +#: population-weighted splitting discards. `"median"` is Pillow's median cut, +#: which splits the colour cube by how densely the clip populates it. Note the +#: census holds each colour once, so no strategy here sees pixel counts: they +#: weight by distinct colours, not by area. `"median"` still favours the crowded +#: regions of colour space a photographic background occupies, which is why it +#: renders such a background a little more finely. `"octree"` sits between them. +QUANTIZE_METHODS = { + "coverage": PILImage.Quantize.MAXCOVERAGE, + "median": PILImage.Quantize.MEDIANCUT, + "octree": PILImage.Quantize.FASTOCTREE, +} + +#: Pixels hashed per pass when collecting a clip's colours. Bounds the +#: temporary index array so a 4K frame does not briefly cost hundreds of +#: megabytes just to be surveyed. +_GAMUT_CHUNK = 1 << 20 + + +def _clip_gamut(frames: Iterable[PILImage.Image]) -> PILImage.Image: + """Collect every colour the clip contains, once each, as a compact image. + + The palette is chosen for colour *coverage*, so what the quantiser needs is + the set of colours present, not how many pixels each covers. Spatially + downsampling the frames to save time -- the obvious way to build a cheap + palette source -- destroys exactly the information this exists to keep: an + interpolating resize blends a one-pixel mark into its background before the + quantiser ever sees it, and a nearest-neighbour resize drops it whenever it + falls between samples. Counting distinct colours instead is independent of + how large a feature is on screen. + + Colours are recorded at full 8-bit precision in a presence bitmap indexed by + the packed RGB triple, so the census reproduces every colour exactly and the + survey is a single linear pass with no sort. The bitmap is 16 MB regardless + of how long the clip runs or how many colours it holds. + + Args: + frames: The clip's frames as `PIL.Image.Image` objects. Any mode is + accepted and converted to RGB; they need not share a size. + + Returns: + PIL.Image.Image: An RGB image holding each distinct colour exactly once. + It is padded to a square by tiling the colour list, so no single colour + is over-represented in what the quantiser then sees. + + Raises: + ValueError: If `frames` is empty, or if the frames hold no pixels. + """ + seen = np.zeros(1 << 24, dtype=bool) + saw_frame = False + for frame in frames: + saw_frame = True + # A frame that is not already RGB -- RGBA, L, P -- would otherwise be + # reinterpreted by the reshape below whenever its byte count happens to + # divide by three, producing a palette of pure misalignment artifacts. + rgb = frame if frame.mode == "RGB" else frame.convert("RGB") + flat = np.asarray(rgb, dtype=np.uint8).reshape(-1, 3) + for begin in range(0, len(flat), _GAMUT_CHUNK): + block = flat[begin : begin + _GAMUT_CHUNK].astype(np.uint32) + seen[(block[:, 0] << 16) | (block[:, 1] << 8) | block[:, 2]] = True + + if not saw_frame: + raise ValueError("a clip palette needs at least one frame, got none.") + keys = np.flatnonzero(seen).astype(np.uint32) + if not len(keys): + raise ValueError("the clip's frames hold no pixels, so it has no colours.") + + colours = np.stack( + [(keys >> 16) & 0xFF, (keys >> 8) & 0xFF, keys & 0xFF], axis=-1 + ).astype(np.uint8) + side = int(np.ceil(np.sqrt(len(colours)))) + # Tile rather than repeat the last colour: padding with one colour would + # hand it a share of the census that the strategies read as prominence. + canvas = np.resize(colours, (side * side, 3)) + return PILImage.fromarray(canvas.reshape(side, side, 3), "RGB") + + +def build_clip_palette( + frames: Iterable[PILImage.Image], + colors: int = CLIP_PALETTE_COLORS, + method: str = "coverage", +) -> PILImage.Image: + """Build one colour palette shared by every frame of a clip. + + Quantising each frame independently makes constant regions shimmer and lets + the same colour drift between frames, so one table is derived from the whole + clip and every frame is mapped through it. + + The table is chosen for colour *coverage* (Pillow's `MAXCOVERAGE`) over the + set of colours the clip contains, gathered by `_clip_gamut`. Median cut, the + obvious alternative, splits by pixel population instead: on a clip with a + large textured area the background claims nearly every slot and small + saturated marks -- overlay glyphs, thin paths, labels -- collapse to the + nearest muddy neighbour. Because coverage is computed over distinct colours + rather than pixels, a mark survives no matter how few pixels it covers; a + single-pixel mark is kept as faithfully as a large one. + + Args: + frames: The clip's frames as RGB `PIL.Image.Image` objects. + colors: How many palette entries to quantise to. The remaining entries + up to 256 are reserved -- pure black and white are pinned so + single-colour overlays stay crisp. + method: Which strategy picks the entries; a key of `QUANTIZE_METHODS`. + Defaults to ``"coverage"``, which spreads them across the clip's + colour range. ``"median"`` splits the colour cube by how densely + the clip populates it, so it spends the table on the crowded regions + a photographic background occupies -- worth choosing for a smooth + clip with no small marks at stake, where that renders the dominant + colours a little more finely. Note it sees each colour once, not + once per pixel, so it weights by distinct colours rather than by + area. + + Returns: + PIL.Image.Image: A ``"P"``-mode image carrying the shared palette, + ready to pass to `Image.quantize(palette=...)`. + + Raises: + ValueError: If `frames` is empty, if `colors` is outside ``2-254`` -- + above 254 the reserved black and white would displace chosen + entries -- or if `method` is not a key of `QUANTIZE_METHODS`. + + Examples: + - Pure black and white are held back at the top of the table, so a + single-colour overlay drawn on the clip stays crisp: + ```python + >>> from PIL import Image + >>> from cleopatra.glyphs.base.animation import build_clip_palette + >>> frames = [ + ... Image.new("RGB", (12, 12), (200, 30, 30)), + ... Image.new("RGB", (12, 12), (30, 30, 200)), + ... ] + >>> entries = build_clip_palette(frames).getpalette() + >>> entries[254 * 3 : 254 * 3 + 3] + [0, 0, 0] + >>> entries[255 * 3 : 255 * 3 + 3] + [255, 255, 255] + + ``` + - A smaller budget moves the reserved pair up behind it, so asking for + 16 colours still leaves black and white reachable: + ```python + >>> from PIL import Image + >>> from cleopatra.glyphs.base.animation import build_clip_palette + >>> frames = [ + ... Image.new("RGB", (12, 12), (200, 30, 30)), + ... Image.new("RGB", (12, 12), (30, 30, 200)), + ... ] + >>> entries = build_clip_palette(frames, colors=16).getpalette() + >>> entries[16 * 3 : 16 * 3 + 3] + [0, 0, 0] + + ``` + - The table spans the whole clip, so a colour introduced only in the + last frame is still represented: + ```python + >>> from PIL import Image + >>> from cleopatra.glyphs.base.animation import build_clip_palette + >>> frames = [Image.new("RGB", (9, 9), (0, 0, 0))] * 4 + >>> frames.append(Image.new("RGB", (9, 9), (255, 0, 255))) + >>> entries = build_clip_palette(frames).getpalette() + >>> triples = [tuple(entries[i : i + 3]) for i in range(0, 254 * 3, 3)] + >>> any(r > 200 and g < 40 and b > 200 for r, g, b in triples) + True + + ``` + + - The strategy is selectable. On a clip with few enough colours to fit + the budget every strategy keeps them all -- the choice only starts to + matter once colours must be discarded: + ```python + >>> from PIL import Image + >>> from cleopatra.glyphs.base.animation import ( + ... QUANTIZE_METHODS, + ... build_clip_palette, + ... ) + >>> sorted(QUANTIZE_METHODS) + ['coverage', 'median', 'octree'] + >>> frame = Image.new("RGB", (40, 40), (30, 30, 30)) + >>> frame.putpixel((0, 0), (255, 0, 255)) # one magenta pixel + >>> table = build_clip_palette([frame], colors=4, method="median").getpalette() + >>> triples = [tuple(table[i : i + 3]) for i in range(0, 4 * 3, 3)] + >>> any(r > 200 and g < 40 and b > 200 for r, g, b in triples) + True + + ``` + + See Also: + quantize_to_palette: Map the frames onto the palette this returns. + gif_from_video: Derives a GIF through this same palette. + """ + if method not in QUANTIZE_METHODS: + raise ValueError( + f"method must be one of {sorted(QUANTIZE_METHODS)}, got {method!r}." + ) + if not 2 <= colors <= CLIP_PALETTE_COLORS: + # Above 254 the reserved black/white pair would overwrite chosen entries, + # and Pillow rejects 256 outright with a bare "invalid palette size". + raise ValueError(f"colors must be in 2-{CLIP_PALETTE_COLORS}, got {colors!r}.") + + census = _clip_gamut(frames) + base = census.quantize(colors=colors, method=QUANTIZE_METHODS[method]) + entries = (list(base.getpalette() or []) + [0] * 768)[:768] + entries[colors * 3 : colors * 3 + 6] = [0, 0, 0, 255, 255, 255] + palette = PILImage.new("P", (1, 1)) + palette.putpalette(entries) + return palette + + +def quantize_to_palette( + frames: Iterable[PILImage.Image], palette: PILImage.Image +) -> list[PILImage.Image]: + """Map every frame onto a shared palette, dithering the residual error. + + Args: + frames: The clip's frames as RGB `PIL.Image.Image` objects. + palette: A ``"P"``-mode image carrying the palette, from + `build_clip_palette`. + + Returns: + list: The frames as ``"P"``-mode images sharing `palette`. + + Examples: + - Every frame comes back palette-mode, carrying the same table -- which + is what keeps a constant region byte-stable from frame to frame: + ```python + >>> from PIL import Image + >>> from cleopatra.glyphs.base.animation import ( + ... build_clip_palette, + ... quantize_to_palette, + ... ) + >>> frames = [ + ... Image.new("RGB", (8, 8), (255, 0, 0)), + ... Image.new("RGB", (8, 8), (0, 0, 255)), + ... ] + >>> quantised = quantize_to_palette(frames, build_clip_palette(frames)) + >>> len(quantised) + 2 + >>> quantised[0].mode + 'P' + >>> quantised[0].getpalette() == quantised[1].getpalette() + True + + ``` + - A colour the palette holds exactly survives the round trip unchanged: + ```python + >>> from PIL import Image + >>> from cleopatra.glyphs.base.animation import ( + ... build_clip_palette, + ... quantize_to_palette, + ... ) + >>> frames = [Image.new("RGB", (8, 8), (255, 0, 0))] + >>> quantised = quantize_to_palette(frames, build_clip_palette(frames)) + >>> quantised[0].convert("RGB").getpixel((0, 0)) + (255, 0, 0) + + ``` + + See Also: + build_clip_palette: Builds the shared palette these frames map onto. + """ + return [ + frame.quantize(palette=palette, dither=PILImage.Dither.FLOYDSTEINBERG) + for frame in frames + ] + + +def _validate_pillow_options(fps: float, loop: int) -> None: + """Check the options Pillow cannot fail cleanly on itself. + + Args: + fps: Playback rate; becomes a per-frame duration in milliseconds. + loop: Loop count; `0` loops forever. + + Raises: + ValueError: If `fps` is not positive (Pillow would surface a bare + `ZeroDivisionError` from the duration conversion) or if `loop` is + negative (it is written as an unsigned short, so Pillow would raise + a bare `struct.error`). + """ + if not math.isfinite(fps) or fps <= 0: + # isfinite rejects NaN and infinity, both of which slip past `fps <= 0`. + raise ValueError(f"fps must be a positive finite number, got {fps!r}.") + if loop < 0: + raise ValueError(f"loop must be zero or positive, got {loop!r}.") + + +def _write_pillow_animation( + frames: Iterable[PILImage.Image], + path: str | os.PathLike, + fps: float, + loop: int, + optimize: bool, +) -> None: + """Write frames out as an animated GIF or WebP via Pillow. + + Args: + frames: The clip's frames, as a sequence or a lazy iterable. For GIF + these are ``"P"``-mode images sharing one palette; for WebP they are + passed through as grabbed. + path: Where to write the animation. + fps: Playback rate, converted to a per-frame duration in + milliseconds. GIF's delay field is in hundredths of a second, so + rates above 100 fps are held at that format's 10 ms floor rather + than rounding to a zero delay viewers discard; WebP keeps + millisecond timing and only floors at 1 ms. + loop: How many times to loop; `0` loops forever. + optimize: Run Pillow's optimisation pass (a no-op for WebP). + + Raises: + ValueError: If `frames` is empty, `fps` is not positive, or `loop` is + negative. + """ + _validate_pillow_options(fps, loop) + # GIF stores its frame delay in hundredths of a second, so anything under + # 10 ms rounds to zero on disk and viewers fall back to their own default -- + # far slower than asked for. WebP keeps millisecond timing, so it only needs + # to stay above zero. + floor = 10 if str(path).lower().endswith(".gif") else 1 + duration = max(floor, int(1000 / fps)) + stream = iter(frames) + first = next(stream, None) + if first is None: + raise ValueError("an animation needs at least one frame, got none.") + # Pillow consumes `append_images` lazily, so handing it the rest of the + # iterator keeps a streamed clip from being materialised just to be written. + first.save( + path, + save_all=True, + append_images=stream, + duration=duration, + loop=loop, + optimize=optimize, + ) + def _ensure_ffmpeg_available() -> None: """Make sure matplotlib can find an ffmpeg binary to shell out to. @@ -52,7 +414,7 @@ def _ensure_ffmpeg_available() -> None: FileNotFoundError: If neither a system ffmpeg nor `imageio-ffmpeg`'s bundled binary can be located. """ - configured = mpl.rcParams["animation.ffmpeg_path"] + configured = mpl.rcParams[_FFMPEG_PATH_RCPARAM] if os.path.isfile(configured) or shutil.which(configured): return try: @@ -71,7 +433,7 @@ def _ensure_ffmpeg_available() -> None: RuntimeWarning, stacklevel=3, ) - mpl.rcParams["animation.ffmpeg_path"] = bundled + mpl.rcParams[_FFMPEG_PATH_RCPARAM] = bundled class _OptimizedPillowWriter(PillowWriter): @@ -88,38 +450,30 @@ class _OptimizedPillowWriter(PillowWriter): compression); the WebP encoder ignores it, so it is a no-op there. loop: Number of times the animation loops; `0` means loop forever (Pillow's convention). Honoured by both GIF and WebP. + quantize_method: Palette strategy for GIF; a key of `QUANTIZE_METHODS`. """ - def __init__(self, *args, optimize: bool = True, loop: int = 0, **kwargs): + def __init__( + self, + *args, + optimize: bool = True, + loop: int = 0, + quantize_method: str = "coverage", + **kwargs, + ): super().__init__(*args, **kwargs) self._optimize = optimize self._loop = loop + self._quantize_method = quantize_method def finish(self): frames = self._frames # type: ignore[attr-defined] - if str(self.outfile).lower().endswith(".gif") and len(frames) > 1: + if str(self.outfile).lower().endswith(".gif") and frames: rgb = [f.convert("RGB") for f in frames] - w, h = rgb[0].size - tw, th = max(1, w // 3), max(1, h // 3) - montage = PILImage.new("RGB", (tw, th * len(rgb))) - for i, frame in enumerate(rgb): - montage.paste(frame.resize((tw, th)), (0, i * th)) - base = montage.quantize(colors=254, method=PILImage.Quantize.MEDIANCUT) - pal = (list(base.getpalette() or []) + [0] * 768)[:768] - pal[254 * 3 : 254 * 3 + 6] = [0, 0, 0, 255, 255, 255] - palette = PILImage.new("P", (1, 1)) - palette.putpalette(pal) - frames = [ - f.quantize(palette=palette, dither=PILImage.Dither.FLOYDSTEINBERG) - for f in rgb - ] - frames[0].save( - self.outfile, - save_all=True, - append_images=frames[1:], - duration=int(1000 / self.fps), - loop=self._loop, - optimize=self._optimize, + palette = build_clip_palette(rgb, method=self._quantize_method) + frames = quantize_to_palette(rgb, palette) + _write_pillow_animation( + frames, self.outfile, self.fps, self._loop, self._optimize ) @@ -213,6 +567,62 @@ def _build_ffmpeg_extra_args( return built +def _validated_output_format( + path: str, + fps: float, + loop: int, + quantize_method: str, + crf: int | None, + bitrate: int | None, +) -> str: + """Resolve the output format from the path and check the options against it. + + Args: + path: The output path; its extension selects the format. + fps: Frames per second. + loop: Loop count for the Pillow formats. + quantize_method: Palette strategy for the Pillow formats. + crf: Constant Rate Factor for the FFmpeg formats. + bitrate: Target bitrate for the FFmpeg formats. + + Returns: + The lower-cased extension, one of `SUPPORTED_VIDEO_FORMAT`. + + Raises: + ValueError: If the path has no extension or an unsupported one, if the + Pillow options are invalid for a Pillow format, or if both `crf` and + `bitrate` are given. + """ + video_format = os.path.splitext(path)[1].lstrip(".").lower() + if not video_format: + raise ValueError( + f"The output path {path!r} has no file extension; the output " + f"format is taken from the extension, so use one of " + f"{SUPPORTED_VIDEO_FORMAT}." + ) + if video_format not in SUPPORTED_VIDEO_FORMAT: + raise ValueError( + f"The given extension {video_format} implies a format that is " + f"not supported, only {SUPPORTED_VIDEO_FORMAT} are supported" + ) + if video_format in _PILLOW_FORMATS: + _validate_pillow_options(fps, loop) + if quantize_method not in QUANTIZE_METHODS: + # Checked here rather than only where the palette is built, which a + # WebP or single-frame GIF never reaches -- a typo would otherwise + # survive the whole render and then be silently ignored. + raise ValueError( + f"quantize_method must be one of {sorted(QUANTIZE_METHODS)}, " + f"got {quantize_method!r}." + ) + if crf is not None and bitrate is not None: + raise ValueError( + "Pass either crf or bitrate, not both: they are competing " + "rate-control modes for the encoder." + ) + return video_format + + def save_animation( anim: FuncAnimation, path: str | os.PathLike, @@ -226,6 +636,7 @@ def save_animation( dpi: int | None = None, optimize: bool = True, loop: int = 0, + quantize_method: str = "coverage", extra_args: list[str] | None = None, ) -> str: """Save any `FuncAnimation` to a file. @@ -273,6 +684,13 @@ def save_animation( optimize: GIF only — run Pillow's palette optimisation pass (a no-op for WebP, whose encoder ignores it). Default `True`. loop: GIF/WebP only — number of times to loop; `0` loops forever. + quantize_method: GIF only -- which strategy picks the shared palette; a + key of `QUANTIZE_METHODS`. Defaults to ``"coverage"``, which keeps + small saturated marks. ``"median"`` splits the colour cube by how + densely the clip populates it, which suits a smooth photographic + clip with no small marks at stake. Neither sees pixel counts: the + palette is built from each colour once, so they weight by distinct + colours rather than by area. extra_args: Extra ffmpeg flags. A `-vf` filter here is merged with the automatic even-dimension pad and a `-pix_fmt` overrides `pix_fmt`. Note these flags bypass the `crf`/`bitrate` @@ -285,8 +703,10 @@ def save_animation( back as its string form, not the original object. Raises: - ValueError: If the file format is not supported, or if both `crf` - and `bitrate` are given (competing rate-control modes). + ValueError: If the file format is not supported, if both `crf` + and `bitrate` are given (competing rate-control modes), or -- for + the Pillow formats -- if `fps` is not positive or `loop` is + negative. FileNotFoundError: If a video format is requested but neither a system FFmpeg nor imageio-ffmpeg's bundled binary can be found. @@ -356,31 +776,21 @@ def save_animation( embed_gif: Wrap an animation as an `IPython.display.Image`. """ path = os.fspath(path) - video_format = os.path.splitext(path)[1].lstrip(".").lower() - if not video_format: - raise ValueError( - f"The output path {path!r} has no file extension; the output " - f"format is taken from the extension, so use one of " - f"{SUPPORTED_VIDEO_FORMAT}." - ) - if video_format not in SUPPORTED_VIDEO_FORMAT: - raise ValueError( - f"The given extension {video_format} implies a format that is " - f"not supported, only {SUPPORTED_VIDEO_FORMAT} are supported" - ) - - if crf is not None and bitrate is not None: - raise ValueError( - "Pass either crf or bitrate, not both: they are competing " - "rate-control modes for the encoder." - ) + video_format = _validated_output_format( + path, fps, loop, quantize_method, crf, bitrate + ) save_kwargs: dict[str, Any] = {} if dpi is None else {"dpi": dpi} if video_format in _PILLOW_FORMATS: anim.save( path, - writer=_OptimizedPillowWriter(fps=fps, optimize=optimize, loop=loop), + writer=_OptimizedPillowWriter( + fps=fps, + optimize=optimize, + loop=loop, + quantize_method=quantize_method, + ), **save_kwargs, ) else: @@ -615,6 +1025,315 @@ def to_mp4(anim: FuncAnimation, fps: int = 2, **kwargs) -> bytes: return to_bytes(anim, fmt="mp4", fps=fps, **kwargs) +def _is_chroma_subsampled(pix_fmt: str | None) -> bool: + """Whether a pixel format stores colour at reduced resolution. + + Chroma subsampling is decided by the family a format belongs to, not by a + prefix: `nv24` and `nv42` are 4:4:4 despite sharing the `nv` family with + 4:2:0 `nv12`, and the semi-planar high-bit-depth formats spell their + sampling in the first digit (`p010` is 4:2:0, `p210` 4:2:2, `p410` 4:4:4). + The packed formats -- `yuyv422`, `uyvy422`, `y210le` -- name their sampling + rather than their layout, and are all 4:2:2. + + Args: + pix_fmt: The source's pixel format as FFmpeg reports it, e.g. + ``"yuv420p"`` or ``"p010le"``. `None` when none was reported. + + Returns: + bool: `True` when colour resolution is reduced, `False` for full-chroma + and RGB-family formats, and for an unreported format -- guessing there + would mean warning about a file that may be fine. + """ + if not pix_fmt: + return False + fmt = pix_fmt.lower() + if fmt.startswith("yuv"): # also covers the full-range yuvj variants + return "444" not in fmt + if fmt.startswith("nv"): + return not fmt.startswith(("nv24", "nv42")) + if fmt.startswith("p") and fmt[1:2].isdigit(): + return not fmt.startswith("p4") + # Packed formats name their sampling instead of their layout. + return fmt.startswith(("yuyv", "uyvy", "yvyu", "y210", "y212", "y216")) + + +def _read_video_frames(src: str, **kwargs) -> tuple[dict, Generator[bytes, None, None]]: + """Open a video for raw-frame reading via the bundled FFmpeg. + + Args: + src: Path to the video. + **kwargs: Passed through to `imageio_ffmpeg.read_frames` (e.g. + `output_params`). + + Returns: + tuple: The decoder's metadata dict, and a generator yielding each frame + as raw ``rgb24`` bytes. Close the generator when done. + + Raises: + FileNotFoundError: If neither a system FFmpeg nor imageio-ffmpeg's + bundled binary can be found. + + Notes: + Decoding resolves its binary the same way writing does, through + `_ensure_ffmpeg_available` -- a system FFmpeg on `PATH` or the one named + by matplotlib's ``animation.ffmpeg_path`` takes precedence over the + bundled copy -- so reading and writing in the same process never + disagree about which FFmpeg to run. + + imageio-ffmpeg takes its binary from ``IMAGEIO_FFMPEG_EXE``, so that + variable is set only while the decoder starts and then restored. Setting + it permanently would outlive the call, go stale if the rcParam later + changed, and leak into unrelated code in the same process. + """ + try: + import imageio_ffmpeg + except ModuleNotFoundError as e: # pragma: no cover - imageio-ffmpeg is a dep + raise FileNotFoundError( + "Deriving a GIF from a video needs FFmpeg. Install imageio-ffmpeg " + "(ships a bundled binary) or an ffmpeg on PATH." + ) from e + _ensure_ffmpeg_available() + previous = os.environ.get("IMAGEIO_FFMPEG_EXE") + os.environ["IMAGEIO_FFMPEG_EXE"] = mpl.rcParams[_FFMPEG_PATH_RCPARAM] + try: + reader = imageio_ffmpeg.read_frames(src, **kwargs) + metadata = next(reader) # starts the child while the variable is set + finally: + if previous is None: + os.environ.pop("IMAGEIO_FFMPEG_EXE", None) + else: + os.environ["IMAGEIO_FFMPEG_EXE"] = previous + return metadata, reader + + +def _video_metadata(src: str) -> dict: + """Read a video's header without decoding any of it. + + Args: + src: Path to the video. + + Returns: + dict: The decoder's metadata, including ``size`` and ``pix_fmt``. + """ + metadata, reader = _read_video_frames(src) + reader.close() + return dict(metadata) + + +def _iter_video_frames( + src: str, fps: float, width: int | None +) -> Iterator[PILImage.Image]: + """Yield a video's frames as RGB images, closing the decoder afterwards. + + Frames are produced lazily so a clip never has to be held in memory all at + once, and the decoder is closed on the way out however the caller stops -- + exhausted, `break`, or an exception -- rather than leaving an orphaned + ffmpeg child behind. + + Args: + src: Path to the video. + fps: Rate to sample at; FFmpeg drops or duplicates frames to hit it. + width: Width to scale to, preserving aspect, or `None` to keep the + source size. Applied by FFmpeg's own scaler. + + Yields: + PIL.Image.Image: Each sampled frame, as RGB. + """ + # Scaling in the filter chain rather than per frame in Pillow: ffmpeg is + # already touching every pixel, and it reports the post-filter size, so the + # frames arrive at their final dimensions in both passes. + filters = [f"fps={fps}"] + if width is not None: + filters.append(f"scale={width}:-2:flags=lanczos") + metadata, reader = _read_video_frames(src, output_params=["-vf", ",".join(filters)]) + try: + frame_w, frame_h = metadata["size"] + for buffer in reader: + yield PILImage.frombytes("RGB", (frame_w, frame_h), bytes(buffer)) + finally: + reader.close() + + +def gif_from_video( + src: str | os.PathLike, + path: str | os.PathLike, + *, + fps: float = 12, + width: int | None = None, + max_colors: int = CLIP_PALETTE_COLORS, + loop: int = 0, + optimize: bool = True, + quantize_method: str = "coverage", +) -> str: + """Derive a GIF from an existing video, without re-rendering the frames. + + Drawing is usually far more expensive than encoding -- hours, for a long + scientific animation -- so a clip is best rendered once to a video and every + other format derived from that file. `save_animation` needs a live + `FuncAnimation` and would re-render; this reads the frames back off disk + instead. + + The frames go through exactly the same clip-wide palette as + `save_animation`'s GIF path (`build_clip_palette`), so a GIF derived from a + video and one rendered straight from the animation quantise identically. + + The source is decoded twice -- once to learn the clip's colours, once to + quantise and write -- rather than decoded once into a list. That keeps the + decoded RGB frames from ever being held together, which is the larger of the + two costs: 150 frames of 720p are 415 MB as RGB against 138 MB as palette + indices. + + It does **not** make memory flat in the clip's length. Pillow's GIF encoder + accumulates every frame before writing its first byte, so the quantised + frames are all resident by the end -- measured at a 154 MB peak for those + same 150 frames. A clip whose palette-indexed frames do not fit in memory + will still not encode; use `width` to bring them down. + + Args: + src: The source video. Any container the bundled FFmpeg can decode. + path: Where to write the GIF. + fps: Frames per second to sample the source at. Frames are dropped or + duplicated by FFmpeg's `fps` filter as needed. Defaults to `12`. + width: Scale the output to this width in pixels, preserving aspect. + `None` (the default) keeps the source's own size. + max_colors: Palette size, in ``2-254``. The rest of the 256 entries are + reserved for pure black and white. + loop: How many times the GIF loops; `0` loops forever. + optimize: Run Pillow's optimisation pass. + quantize_method: Which strategy picks the shared palette; a key of + `QUANTIZE_METHODS`. Defaults to ``"coverage"``. + + Returns: + The output path as a `str`, convenient for chaining. + + Raises: + FileNotFoundError: If `src` does not exist, or if neither a system + FFmpeg nor imageio-ffmpeg's bundled binary can be found. + ValueError: If `max_colors` is outside ``2-254``, if `fps` is not + positive, if `width` is not positive, if `loop` is negative, if + `path` does not end in ``.gif``, or if `src` yields no frames. + + Warns: + UserWarning: If `src` is chroma-subsampled (e.g. the ``yuv420p`` that + `save_animation` writes by default). Colour resolution is already + gone from such a file, which caps how well saturated detail can + survive whatever the GIF palette then does -- render the + intermediate with ``pix_fmt="yuv444p"`` and a low `crf` when the + plan is to derive a GIF from it. + + Examples: + - Render an animation once to MP4, then derive a GIF from that file: + ```python + >>> import os, shutil, tempfile, matplotlib + >>> matplotlib.use("Agg") + >>> import matplotlib.pyplot as plt + >>> from matplotlib.animation import FuncAnimation + >>> from cleopatra.glyphs.base.animation import gif_from_video, save_animation + >>> tmp = tempfile.mkdtemp() + >>> fig, ax = plt.subplots() + >>> (line,) = ax.plot([0, 1], [0, 0]) + >>> anim = FuncAnimation(fig, lambda i: (line,), frames=4) + >>> mp4 = save_animation(anim, os.path.join(tmp, "clip.mp4"), fps=4, + ... pix_fmt="yuv444p") + >>> gif = gif_from_video(mp4, os.path.join(tmp, "clip.gif"), fps=4) + >>> from pathlib import Path + >>> Path(gif).read_bytes()[:6] in (b"GIF87a", b"GIF89a") + True + >>> plt.close(fig) + >>> shutil.rmtree(tmp) + + ``` + - `width` scales the output for a web copy, keeping the aspect ratio of + the source and leaving the master untouched: + ```python + >>> import os, shutil, tempfile, matplotlib + >>> matplotlib.use("Agg") + >>> import matplotlib.pyplot as plt + >>> from PIL import Image + >>> from matplotlib.animation import FuncAnimation + >>> from cleopatra.glyphs.base.animation import gif_from_video, save_animation + >>> tmp = tempfile.mkdtemp() + >>> fig, ax = plt.subplots() + >>> (line,) = ax.plot([0, 1], [0, 0]) + >>> anim = FuncAnimation(fig, lambda i: (line,), frames=4) + >>> mp4 = save_animation(anim, os.path.join(tmp, "master.mp4"), fps=4, + ... pix_fmt="yuv444p") + >>> gif = gif_from_video(mp4, os.path.join(tmp, "web.gif"), fps=4, width=160) + >>> with Image.open(gif) as web: + ... web.size + (160, 120) + >>> plt.close(fig) + >>> shutil.rmtree(tmp) + + ``` + - A source that does not exist is reported up front, rather than + failing later inside the decoder: + ```python + >>> from cleopatra.glyphs.base.animation import gif_from_video + >>> gif_from_video("no-such-clip.mp4", "out.gif") + Traceback (most recent call last): + ... + FileNotFoundError: The source video 'no-such-clip.mp4' does not exist. + + ``` + + See Also: + save_animation: Write a live `FuncAnimation` straight to a file. + build_clip_palette: The shared palette both paths quantise through. + """ + src = os.fspath(src) + path = os.fspath(path) + if not os.path.isfile(src): + raise FileNotFoundError(f"The source video {src!r} does not exist.") + if not 2 <= max_colors <= CLIP_PALETTE_COLORS: + raise ValueError( + f"max_colors must be in 2-{CLIP_PALETTE_COLORS}, got {max_colors!r}." + ) + if width is not None and width <= 0: + raise ValueError(f"width must be positive, got {width!r}.") + # Pillow picks its encoder from the extension, so a stray one silently + # writes a different format -- .png yields an APNG that no caller of a + # function named gif_from_video is expecting. + extension = os.path.splitext(path)[1].lstrip(".").lower() + if extension != "gif": + raise ValueError( + f"gif_from_video writes GIFs, but {path!r} implies {extension or 'no'} " + "format. Use a .gif extension." + ) + _validate_pillow_options(fps, loop) + + meta = _video_metadata(src) + if _is_chroma_subsampled(meta.get("pix_fmt")): + warnings.warn( + f"{src!r} is {meta.get('pix_fmt')}, which stores colour at reduced " + "resolution; saturated detail is already degraded before the GIF " + "palette sees it. Render the intermediate with pix_fmt='yuv444p' " + "and a low crf when a GIF will be derived from it.", + UserWarning, + stacklevel=2, + ) + + # Two passes rather than one buffered one: the palette must see the whole + # clip before any frame can be quantised. The survey pass keeps nothing, so + # the decoded RGB frames are never all resident -- the dominant cost. The + # write pass is still bounded by Pillow, which accumulates the quantised + # frames before emitting anything; those are a third the size. + survey = _iter_video_frames(src, fps, width) + first = next(survey, None) + if first is None: + raise ValueError(f"The source video {src!r} yielded no frames.") + palette = build_clip_palette( + itertools.chain([first], survey), colors=max_colors, method=quantize_method + ) + + quantised = ( + frame.quantize(palette=palette, dither=PILImage.Dither.FLOYDSTEINBERG) + for frame in _iter_video_frames(src, fps, width) + ) + _write_pillow_animation(quantised, path, fps, loop, optimize) + return path + + def embed_gif(anim: FuncAnimation, fps: int = 2) -> Image: """Return an `IPython.display.Image` of the animation for inline display. diff --git a/src/cleopatra/glyphs/base/glyph.py b/src/cleopatra/glyphs/base/glyph.py index ef1d2474..5848cd3c 100644 --- a/src/cleopatra/glyphs/base/glyph.py +++ b/src/cleopatra/glyphs/base/glyph.py @@ -1465,7 +1465,10 @@ def create_color_bar(self, ax: Axes, im: Any, cbar_kw: dict) -> Colorbar: f"'bottom', or None, got {location!r}." ) orientation_opt = self.default_options.get("cbar_orientation") - if orientation_opt is not None and orientation_opt not in ("vertical", "horizontal"): + if orientation_opt is not None and orientation_opt not in ( + "vertical", + "horizontal", + ): raise ValueError( "cbar_orientation must be 'vertical' or 'horizontal', got " f"{orientation_opt!r}." @@ -1747,7 +1750,7 @@ def save_animation(self, path: str | os.PathLike, fps: int = 2, **kwargs) -> Non **kwargs: Additional keyword arguments forwarded to `cleopatra.glyphs.base.animation.save_animation`, e.g. `crf`, `bitrate`, `codec`, `preset`, `pix_fmt`, `dpi` (ffmpeg formats) or - `optimize` and `loop` (GIF). + `optimize`, `loop` and `quantize_method` (GIF/WebP). Raises: ValueError: If `animate()` has not been called yet, if the file diff --git a/tests/test_animation.py b/tests/test_animation.py index 27b325d5..0f289e15 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -9,9 +9,12 @@ import builtins import doctest +import os +import warnings from pathlib import Path from unittest.mock import MagicMock +import imageio_ffmpeg import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np @@ -113,7 +116,9 @@ def test_routes_gif_to_pillow_writer(self, monkeypatch): result = save_animation(anim, "clip.gif", fps=7) - pillow.assert_called_once_with(fps=7, optimize=True, loop=0) + pillow.assert_called_once_with( + fps=7, optimize=True, loop=0, quantize_method="coverage" + ) anim.save.assert_called_once_with("clip.gif", writer=pillow.return_value) assert result == "clip.gif", f"should return the path, got {result!r}" @@ -202,7 +207,9 @@ def test_default_fps_is_two(self, monkeypatch): save_animation(MagicMock(spec=FuncAnimation), "clip.gif") - pillow.assert_called_once_with(fps=2, optimize=True, loop=0) + pillow.assert_called_once_with( + fps=2, optimize=True, loop=0, quantize_method="coverage" + ) class TestToGif: @@ -469,8 +476,12 @@ def test_odd_dimension_mp4_encodes(self, tmp_path): (line,) = ax.plot([0, 1], [0, 0]) width = int(round(fig.get_figwidth() * fig.dpi)) height = int(round(fig.get_figheight() * fig.dpi)) - assert width % 2 == 1, f'fixture must be odd-sized to exercise the pad, got {width}x{height}' - assert height % 2 == 1, f'fixture must be odd-sized to exercise the pad, got {width}x{height}' + assert width % 2 == 1, ( + f'fixture must be odd-sized to exercise the pad, got {width}x{height}' + ) + assert height % 2 == 1, ( + f'fixture must be odd-sized to exercise the pad, got {width}x{height}' + ) anim = FuncAnimation(fig, lambda i: (line,), frames=2) out = tmp_path / "odd.mp4" @@ -516,7 +527,9 @@ def test_webp_routes_to_pillow_writer(self, monkeypatch): save_animation(anim, "clip.webp", fps=4, loop=1) - pillow.assert_called_once_with(fps=4, optimize=True, loop=1) + pillow.assert_called_once_with( + fps=4, optimize=True, loop=1, quantize_method="coverage" + ) ffmpeg.assert_not_called() @@ -734,10 +747,14 @@ def update(_): gif.seek(5) last = np.asarray(gif.convert("RGB")).copy() top = slice(0, first.shape[0] // 3) # safely inside the constant white top - changed = (np.abs(first[top].astype(int) - last[top].astype(int)).sum(2) > 8).mean() + changed = ( + np.abs(first[top].astype(int) - last[top].astype(int)).sum(2) > 8 + ).mean() plt.close("all") - assert changed == 0.0, f"shared palette should keep a constant region byte-stable, got {changed}" + assert changed == 0.0, ( + f"shared palette should keep a constant region byte-stable, got {changed}" + ) def test_gif_reserves_black_for_crisp_overlays(self, tmp_path): """A pure-black overlay on a colourful field stays black in the GIF. @@ -995,7 +1012,9 @@ def test_gif_loop_and_optimize_forwarded(self, monkeypatch): MagicMock(spec=FuncAnimation), "clip.gif", optimize=False, loop=3 ) - pillow.assert_called_once_with(fps=2, optimize=False, loop=3) + pillow.assert_called_once_with( + fps=2, optimize=False, loop=3, quantize_method="coverage" + ) class TestSupportedVideoFormat: @@ -1034,3 +1053,1102 @@ def test_module_doctests_execute(): "no doctest examples were collected from animation; the module's docstring " "examples may have been moved or removed, silently dropping this coverage" ) + + +#: A textured background with four small, highly saturated marks that move each +#: frame -- the shape of a satellite-showcase clip in miniature. The texture owns +#: ~99% of the pixels, so it is exactly the case where a population-weighted +#: palette starves the marks. +_CLIP_W, _CLIP_H, _CLIP_FRAMES = 320, 180, 12 +_MARK_COLORS = ((255, 0, 255), (0, 255, 255), (255, 255, 0), (255, 40, 0)) +_MARK_ROWS = (40, 80, 120, 160) +_MARK_RADIUS = 2 + + +def _clip_frames(radius=_MARK_RADIUS): + """Build the texture-heavy clip. + + Args: + radius: Half-width of each saturated mark, so a test can vary how many + pixels a mark covers. ``0`` gives a single-pixel mark. + + Returns: + list: One ``(frame, boxes)`` pair per frame, where `frame` is a float + ``(H, W, 3)`` array in ``[0, 1]`` and `boxes` are the + ``(y0, y1, x0, x1)`` slices holding each saturated mark. + """ + rng = np.random.default_rng(0) + yy, xx = np.mgrid[0:_CLIP_H, 0:_CLIP_W] + base = np.clip( + np.stack( + [ + 0.45 + 0.20 * np.sin(xx / 37.0) + 0.10 * np.cos(yy / 23.0), + 0.40 + 0.18 * np.cos(xx / 29.0) + 0.12 * np.sin(yy / 19.0), + 0.35 + 0.15 * np.sin((xx + yy) / 41.0), + ], + axis=-1, + ) + + rng.normal(0, 0.035, (_CLIP_H, _CLIP_W, 3)), + 0, + 1, + ) + frames = [] + for index in range(_CLIP_FRAMES): + arr = base.copy() + boxes = [] + for slot, rgb in enumerate(_MARK_COLORS): + cy, cx = _MARK_ROWS[slot], 30 + index * 20 + y0, y1 = cy - radius, cy + radius + 1 + x0, x1 = cx - radius, cx + radius + 1 + arr[y0:y1, x0:x1] = np.array(rgb) / 255.0 + boxes.append((y0, y1, x0, x1)) + frames.append((arr, boxes)) + return frames + + +def _clip_animation(frames): + """Wrap `_clip_frames` output in a pixel-exact `FuncAnimation`. + + Args: + frames: The output of `_clip_frames`. + + Returns: + tuple: The `Figure` and its `FuncAnimation`. The figure is sized so one + array cell maps to one output pixel, letting a test read a mark's colour + straight back out of the decoded GIF. + """ + fig = plt.figure(figsize=(_CLIP_W / 100, _CLIP_H / 100), dpi=100) + ax = fig.add_axes((0, 0, 1, 1)) + ax.set_axis_off() + image = ax.imshow(frames[0][0], interpolation="nearest") + + def update(i): + image.set_data(frames[i][0]) + return (image,) + + return fig, FuncAnimation(fig, update, frames=len(frames)) + + +def _decode(path): + """Read every frame of an animated image back as an RGB array. + + Args: + path: The animation to read. + + Returns: + list: One ``(H, W, 3)`` ``uint8`` array per frame. + """ + decoded = [] + with Image.open(path) as handle: + try: + while True: + decoded.append(np.asarray(handle.convert("RGB"), dtype=np.uint8)) + handle.seek(handle.tell() + 1) + except EOFError: + pass + return decoded + + +def _mark_distances(decoded, frames): + """Mean RGB distance between each decoded mark and its intended colour. + + Args: + decoded: Frames read back from the written file. + frames: The source `_clip_frames` output the marks came from. + + Returns: + list: One mean distance per mark, in the order of `_MARK_COLORS`. + """ + distances = [] + for slot, rgb in enumerate(_MARK_COLORS): + samples = [] + for index in range(min(len(decoded), len(frames))): + y0, y1, x0, x1 = frames[index][1][slot] + patch = decoded[index][y0:y1, x0:x1].reshape(-1, 3).astype(float) + samples.append(patch.mean(axis=0)) + distances.append( + float(np.linalg.norm(np.mean(samples, axis=0) - np.array(rgb, dtype=float))) + ) + return distances + + +class TestClipPaletteQuality: + """The shared GIF palette must not starve small saturated colours (#315).""" + + @pytest.mark.parametrize("radius, size", [(2, "5x5"), (1, "3x3"), (0, "1x1")]) + def test_small_saturated_marks_survive_quantisation(self, tmp_path, radius, size): + """Saturated marks stay their own colour however few pixels they cover. + + Args: + tmp_path: pytest temp directory. + radius: Half-width of the marks under test. + size: Human-readable mark size, for the failure message. + + Test scenario: + The background owns ~99% of the pixels. Allocating palette slots by + pixel population (median cut) hands nearly all of them to the + texture and the marks decode ~100-180 away from the colours they + were drawn in. The size sweep is the point: a palette built from a + spatially downsampled clip passed at 5x5 and failed at 1x1, because + a resize blends a one-pixel mark away before the quantiser sees it. + Building it from the clip's distinct colours is independent of + how large a mark is, so every size must hold -- and 1x1 is the size + that actually matters for satellites, orbit paths and labels. + """ + frames = _clip_frames(radius) + fig, anim = _clip_animation(frames) + out = tmp_path / "clip.gif" + save_animation(anim, str(out), fps=12) + plt.close(fig) + + distances = _mark_distances(_decode(out), frames) + assert max(distances) < 40, ( + f"{size} saturated marks were quantised away; distances from the " + f"intended colours were {[round(d, 1) for d in distances]}" + ) + + def test_palette_is_shared_across_the_clip(self, tmp_path): + """Every frame draws from one 256-entry table, not its own. + + Test scenario: + Independently quantised frames would between them use far more than + 256 distinct colours. The union across all frames must stay within + a single palette. + """ + frames = _clip_frames() + fig, anim = _clip_animation(frames) + out = tmp_path / "clip.gif" + save_animation(anim, str(out), fps=12) + plt.close(fig) + + decoded = _decode(out) + union = set() + for frame in decoded: + union |= {tuple(pixel) for pixel in frame.reshape(-1, 3)} + assert len(union) <= 256, ( + f"frames appear to be quantised independently: {len(union)} distinct " + f"colours across {len(decoded)} frames" + ) + + +class TestGifFromVideo: + """`gif_from_video` derives a GIF from an already-rendered video (#308).""" + + @pytest.fixture + def clip_mp4(self, tmp_path): + """Render the texture clip to a full-chroma MP4, per test. + + Deliberately not shared across the module. Every test here depends on + it, so a single flaky encode in a module-scoped fixture fails all of + them at once as errors rather than one of them as a failure -- which + hides which test actually broke. + + Returns: + tuple: The MP4 path and the `_clip_frames` output behind it. + """ + frames = _clip_frames() + fig, anim = _clip_animation(frames) + path = tmp_path / "clip.mp4" + save_animation(anim, str(path), fps=12, crf=0, pix_fmt="yuv444p") + plt.close(fig) + return str(path), frames + + def test_derives_a_gif_from_an_mp4(self, clip_mp4, tmp_path): + """A GIF is written from the video and the path comes back. + + Test scenario: + The rendered MP4 is converted without touching the original + animation, and the result carries the GIF magic bytes. + """ + src, _ = clip_mp4 + out = tmp_path / "derived.gif" + returned = anim_mod.gif_from_video(src, str(out), fps=12) + assert returned == str(out), "the output path should be returned" + assert out.read_bytes()[:6] in (b"GIF87a", b"GIF89a"), "not a GIF" + + def test_uses_one_clip_wide_palette(self, clip_mp4, tmp_path): + """The derived GIF shares a single palette across its frames. + + Test scenario: + `gif_from_video` runs the decoded frames through the same + `build_clip_palette` the live-animation path uses, so the colour + union across frames stays within one table. + """ + src, _ = clip_mp4 + out = tmp_path / "derived.gif" + anim_mod.gif_from_video(src, str(out), fps=12) + union = set() + for frame in _decode(out): + union |= {tuple(pixel) for pixel in frame.reshape(-1, 3)} + assert len(union) <= 256, f"palette is not clip-wide: {len(union)} colours" + + def test_saturated_marks_survive_the_round_trip(self, clip_mp4, tmp_path): + """Marks survive decode + re-quantisation from a full-chroma source. + + Test scenario: + Deriving from a video only pays off if the marks are still there + afterwards. With a `yuv444p` source the round trip must land close + to what rendering the GIF directly achieves. + """ + src, frames = clip_mp4 + out = tmp_path / "derived.gif" + anim_mod.gif_from_video(src, str(out), fps=12) + distances = _mark_distances(_decode(out), frames) + assert max(distances) < 40, ( + f"marks degraded through the video round trip: " + f"{[round(d, 1) for d in distances]}" + ) + + def test_fps_controls_the_frame_count(self, clip_mp4, tmp_path): + """Sampling at a lower fps yields proportionally fewer frames. + + Test scenario: + The source is 12 frames at 12 fps (one second). Sampling at 6 fps + halves the frame count. + """ + src, _ = clip_mp4 + full = tmp_path / "full.gif" + half = tmp_path / "half.gif" + anim_mod.gif_from_video(src, str(full), fps=12) + anim_mod.gif_from_video(src, str(half), fps=6) + assert len(_decode(half)) < len(_decode(full)), ( + "a lower fps should sample fewer frames" + ) + + def test_width_scales_and_preserves_aspect(self, clip_mp4, tmp_path): + """`width` resizes the output, keeping the source's aspect ratio. + + Test scenario: + Asking for 160px from a 320x180 source gives a 160x90 GIF. + """ + src, _ = clip_mp4 + out = tmp_path / "small.gif" + anim_mod.gif_from_video(src, str(out), fps=12, width=160) + with Image.open(out) as handle: + assert handle.size == (160, 90), f"unexpected size {handle.size}" + + def test_warns_on_a_chroma_subsampled_source(self, tmp_path): + """A 4:2:0 source warns that colour detail is already gone. + + Test scenario: + `save_animation` writes `yuv420p` by default, which discards colour + resolution before the GIF palette ever runs. Deriving a GIF from + such a file must say so rather than silently under-delivering. + """ + frames = _clip_frames() + fig, anim = _clip_animation(frames) + src = tmp_path / "subsampled.mp4" + save_animation(anim, str(src), fps=12) + plt.close(fig) + + with pytest.warns(UserWarning, match="yuv420p|reduced resolution"): + anim_mod.gif_from_video(str(src), str(tmp_path / "out.gif"), fps=12) + + def test_full_chroma_source_does_not_warn(self, clip_mp4, tmp_path): + """A `yuv444p` source is the recommended input and stays quiet. + + Test scenario: + The warning must not fire for a source that kept its colour + resolution, or it would be noise. + """ + src, _ = clip_mp4 + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + anim_mod.gif_from_video(src, str(tmp_path / "out.gif"), fps=12) + chroma = [w for w in caught if "reduced" in str(w.message)] + assert not chroma, f"a full-chroma source should not warn: {chroma}" + + def test_decoder_is_closed_when_iteration_stops_early(self, monkeypatch): + """Abandoning the frame stream closes the decoder rather than leaking it. + + Args: + monkeypatch: pytest monkeypatch fixture. + + Test scenario: + A caller that stops early -- or an exception raised mid-iteration -- + must not leave an ffmpeg child running. The decoder is replaced with + a generator that records its own closure, and the frame iterator is + abandoned after one frame. + """ + closed = [] + + def frames(): + try: + while True: + yield bytes(12) + finally: + closed.append(True) + + def fake_reader(src, **kwargs): + return {"size": (2, 2), "pix_fmt": "yuv444p"}, frames() + + monkeypatch.setattr(anim_mod, "_read_video_frames", fake_reader) + stream = anim_mod._iter_video_frames("ignored.mp4", 12, None) + next(stream) + stream.close() + assert closed, "the decoder was not closed when the stream was abandoned" + + @pytest.mark.parametrize("name", ["out.png", "out.webp", "out.mp4", "out"]) + def test_non_gif_output_extension_raises(self, clip_mp4, tmp_path, name): + """An output path that is not a GIF raises rather than writing one. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + name: The wrong-extension output name under test. + + Test scenario: + Pillow picks its encoder from the extension, so `.png` silently + produced an APNG and `.webp` a WebP -- neither of which a caller of + `gif_from_video` asked for. + """ + src, _ = clip_mp4 + with pytest.raises(ValueError, match="writes GIFs"): + anim_mod.gif_from_video(src, str(tmp_path / name), fps=6) + + def test_negative_loop_raises(self, clip_mp4, tmp_path): + """A negative `loop` is rejected before any decoding starts. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + + Test scenario: + The same unsigned-short limit as the writer, surfaced up front. + """ + src, _ = clip_mp4 + with pytest.raises(ValueError, match="loop must be zero or positive"): + anim_mod.gif_from_video(src, str(tmp_path / "out.gif"), loop=-1) + + def test_unknown_quantize_method_raises_before_rendering(self, tiny_anim, tmp_path): + """A bad strategy is rejected up front, not after the whole render. + + Args: + tiny_anim: Small animation fixture. + tmp_path: pytest temp directory. + + Test scenario: + WebP and a single-frame GIF never reach the palette builder, so a + typo checked only there would survive the render and be silently + ignored. + """ + with pytest.raises(ValueError, match="quantize_method must be one of"): + save_animation(tiny_anim, str(tmp_path / "a.webp"), quantize_method="nope") + + def test_variable_is_restored_when_previously_unset(self, clip_mp4, monkeypatch): + """An unset variable is unset again afterwards. + + Args: + clip_mp4: The rendered source fixture. + monkeypatch: pytest monkeypatch fixture. + + Test scenario: + Setting it permanently would outlive the call, go stale if the + rcParam changed, and leak into unrelated code in the same process. + """ + src, _ = clip_mp4 + monkeypatch.delenv("IMAGEIO_FFMPEG_EXE", raising=False) + metadata, reader = anim_mod._read_video_frames(src) + reader.close() + assert metadata["size"], "the decoder should still have reported metadata" + assert "IMAGEIO_FFMPEG_EXE" not in os.environ, ( + "an unset variable should not be left behind" + ) + + def test_existing_value_is_put_back(self, clip_mp4, monkeypatch): + """A caller's own value survives the call unchanged. + + Args: + clip_mp4: The rendered source fixture. + monkeypatch: pytest monkeypatch fixture. + + Test scenario: + Someone who pinned their own binary must get it back, not ours. + """ + src, _ = clip_mp4 + monkeypatch.setenv("IMAGEIO_FFMPEG_EXE", imageio_ffmpeg.get_ffmpeg_exe()) + chosen = os.environ["IMAGEIO_FFMPEG_EXE"] + metadata, reader = anim_mod._read_video_frames(src) + reader.close() + assert os.environ["IMAGEIO_FFMPEG_EXE"] == chosen, ( + "the caller's pinned binary should be restored" + ) + + def test_gif_from_video_forwards_the_method(self, clip_mp4, tmp_path): + """`gif_from_video` passes the strategy through to the palette. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + + Test scenario: + The derived path must offer the same choice as the rendered one, or + the two would quantise differently for the same request. + """ + src, frames = clip_mp4 + out = tmp_path / "median.gif" + anim_mod.gif_from_video(src, str(out), fps=12, quantize_method="median") + assert max(_mark_distances(_decode(out), frames)) > 40, ( + "median cut should have been applied to the derived GIF" + ) + + def test_missing_source_raises(self, tmp_path): + """A source that does not exist raises `FileNotFoundError`. + + Test scenario: + The failure surfaces up front, naming the path, rather than as a + decoder error later. + """ + with pytest.raises(FileNotFoundError, match="does not exist"): + anim_mod.gif_from_video( + str(tmp_path / "nope.mp4"), str(tmp_path / "out.gif") + ) + + @pytest.mark.parametrize("max_colors", [1, 0, 255, 300]) + def test_invalid_max_colors_raises(self, clip_mp4, tmp_path, max_colors): + """A palette size outside ``2-254`` raises `ValueError`. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + max_colors: The out-of-range palette size under test. + + Test scenario: + Two entries are reserved for pure black and white, so 254 is the + ceiling; fewer than two colours is not a palette. + """ + src, _ = clip_mp4 + with pytest.raises(ValueError, match="max_colors must be"): + anim_mod.gif_from_video( + src, str(tmp_path / "out.gif"), max_colors=max_colors + ) + + def test_width_matching_the_source_skips_resizing( + self, clip_mp4, tmp_path, monkeypatch + ): + """Asking for the source's own width performs no resample at all. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + monkeypatch: pytest monkeypatch fixture. + + Test scenario: + Checking the output size alone cannot tell a skipped resize from one + that resampled to the same dimensions -- and resampling a frame to + its own size still costs time and softens it. `Image.resize` is + wrapped to record any call, and must not fire. + """ + src, _ = clip_mp4 + resizes = [] + original = Image.Image.resize + + def spy(self, size, *args, **kwargs): + resizes.append(size) + return original(self, size, *args, **kwargs) + + monkeypatch.setattr(Image.Image, "resize", spy) + out = tmp_path / "same.gif" + anim_mod.gif_from_video(src, str(out), fps=12, width=_CLIP_W) + assert not resizes, f"a same-width request should not resample: {resizes}" + with Image.open(out) as handle: + assert handle.size == (_CLIP_W, _CLIP_H), f"unexpected size {handle.size}" + + def test_source_yielding_no_frames_raises(self, clip_mp4, tmp_path, monkeypatch): + """A video that decodes to nothing raises a clear `ValueError`. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + monkeypatch: pytest monkeypatch fixture. + + Test scenario: + A truncated or empty stream would otherwise fail deep inside Pillow + with an IndexError; the decoder is stubbed to yield only metadata so + the guard is exercised directly. + """ + src, _ = clip_mp4 + + def empty_frames(): + return + yield # pragma: no cover - never reached, makes this a generator + + def only_meta(src, **kwargs): + return {"size": (4, 4), "pix_fmt": "yuv444p"}, empty_frames() + + monkeypatch.setattr(anim_mod, "_read_video_frames", only_meta) + with pytest.raises(ValueError, match="yielded no frames"): + anim_mod.gif_from_video(src, str(tmp_path / "out.gif")) + + def test_max_colors_limits_the_palette(self, clip_mp4, tmp_path): + """A small `max_colors` narrows the colours actually used. + + Test scenario: + Quantising to 8 entries must yield far fewer distinct colours than + the default 254, proving the argument reaches the palette builder + rather than being ignored. + """ + src, _ = clip_mp4 + narrow = tmp_path / "narrow.gif" + anim_mod.gif_from_video(src, str(narrow), fps=6, max_colors=8) + union = set() + for frame in _decode(narrow): + union |= {tuple(pixel) for pixel in frame.reshape(-1, 3)} + assert len(union) <= 16, ( + f"max_colors=8 should keep the palette tiny, saw {len(union)} colours" + ) + + @pytest.mark.parametrize("kwargs", [{"fps": 0}, {"fps": -1}, {"width": 0}]) + def test_invalid_sampling_arguments_raise(self, clip_mp4, tmp_path, kwargs): + """Non-positive `fps` or `width` raise `ValueError`. + + Args: + clip_mp4: The rendered source fixture. + tmp_path: pytest temp directory. + kwargs: The invalid argument under test. + + Test scenario: + Both are rejected before any decoding starts. + """ + src, _ = clip_mp4 + with pytest.raises(ValueError, match="must be positive|positive finite"): + anim_mod.gif_from_video(src, str(tmp_path / "out.gif"), **kwargs) + + +class TestIsChromaSubsampled: + """Tests for `_is_chroma_subsampled`.""" + + @pytest.mark.parametrize( + "pix_fmt, expected", + [ + ("yuv420p", True), + ("yuv422p", True), + ("yuv440p", True), + ("yuvj420p", True), + ("yuvj440p", True), + ("YUV420P", True), + ("yuv420p10le", True), + ("nv12", True), + ("nv21", True), + ("nv16", True), + ("nv20le", True), + ("p010le", True), + ("p016be", True), + ("p210le", True), + ("yuyv422", True), + ("uyvy422", True), + ("y210le", True), + ("yuv444p", False), + ("yuvj444p", False), + ("yuv444p10le", False), + ("nv24", False), + ("nv42", False), + ("p410le", False), + ("p416be", False), + ("rgb24", False), + ("rgb444le", False), + ("gbrp", False), + ("", False), + (None, False), + ], + ) + def test_classifies_pixel_formats(self, pix_fmt, expected): + """Only sub-4:4:4 YUV and the NV planar formats count as subsampled. + + Args: + pix_fmt: The pixel format string under test. + expected: Whether it should be reported as chroma-subsampled. + + Test scenario: + Every case is a real FFmpeg format name. The families do not split + on a prefix: `nv24` / `nv42` are 4:4:4 while `nv12` / `nv16` are + not, and the semi-planar `p0xx` / `p2xx` / `p4xx` formats spell + their sampling in the first digit. RGB-family names and an + unreported format must never trigger a warning. + """ + result = anim_mod._is_chroma_subsampled(pix_fmt) + assert result is expected, ( + f"{pix_fmt!r} should be {'subsampled' if expected else 'full chroma'}, got {result}" + ) + + +class TestBuildClipPalette: + """Tests for `build_clip_palette`.""" + + @staticmethod + def _solid(color, size=(12, 12)): + """Build a solid-colour RGB frame. + + Args: + color: The ``(r, g, b)`` fill. + size: The frame size. + + Returns: + PIL.Image.Image: The filled frame. + """ + return Image.new("RGB", size, color) + + def test_reserves_pure_black_and_white(self): + """Entries 254 and 255 are pinned to black and white. + + Test scenario: + A colourful clip would otherwise spend every slot on its own + colours, leaving a single-colour overlay to snap to the nearest + photographic neighbour. The last two entries are held back. + """ + frames = [self._solid((200, 30, 30)), self._solid((30, 200, 30))] + palette = anim_mod.build_clip_palette(frames) + entries = palette.getpalette() + assert entries[254 * 3 : 254 * 3 + 3] == [0, 0, 0], "entry 254 should be black" + assert entries[255 * 3 : 255 * 3 + 3] == [255, 255, 255], ( + "entry 255 should be white" + ) + + def test_returns_a_palette_mode_image(self): + """The result is a ``"P"``-mode image usable as a quantize palette. + + Test scenario: + `Image.quantize(palette=...)` requires a palette-mode image, so the + builder must hand one back rather than a raw list of entries. + """ + palette = anim_mod.build_clip_palette([self._solid((10, 20, 30))]) + assert palette.mode == "P", f"expected a P-mode image, got {palette.mode}" + assert len(palette.getpalette()) == 768, "palette should carry 256 RGB entries" + + def test_honours_the_colors_argument(self): + """A smaller `colors` budget reserves black/white at that offset. + + Test scenario: + Passing ``colors=16`` quantises to 16 entries and pins black and + white immediately after them, so a caller asking for a small + palette still gets the reserved pair. + """ + frames = [self._solid((200, 30, 30)), self._solid((30, 30, 200))] + entries = anim_mod.build_clip_palette(frames, colors=16).getpalette() + assert entries[16 * 3 : 16 * 3 + 3] == [0, 0, 0], ( + "black should follow the budget" + ) + assert entries[17 * 3 : 17 * 3 + 3] == [255, 255, 255], ( + "white should follow black" + ) + + def test_palette_spans_the_whole_clip_not_one_frame(self): + """A colour that appears only in a late frame still reaches the palette. + + Test scenario: + The palette is built from a montage of every frame. A distinctive + colour introduced in the last frame must therefore be represented, + which a first-frame-only palette would miss. + """ + frames = [self._solid((0, 0, 0))] * 4 + [self._solid((255, 0, 255))] + entries = anim_mod.build_clip_palette(frames).getpalette() + triples = [tuple(entries[i : i + 3]) for i in range(0, 254 * 3, 3)] + assert any( + abs(r - 255) < 30 and g < 30 and abs(b - 255) < 30 for r, g, b in triples + ), "the late frame's magenta is absent from the clip-wide palette" + + def test_single_frame_clip(self): + """A one-frame clip builds a palette without special-casing. + + Test scenario: + The montage degenerates to a single tile; the builder must still + return a usable palette. + """ + palette = anim_mod.build_clip_palette([self._solid((123, 45, 67))]) + assert palette.mode == "P", "a single-frame clip should still yield a palette" + + def test_tiny_frames_do_not_collapse_to_zero(self): + """Frames smaller than the montage divisor still produce a tile. + + Test scenario: + A 2x2 frame divided by the montage divisor floors to 0, which would + be an invalid image size; the builder clamps each tile to at least + one pixel. + """ + palette = anim_mod.build_clip_palette([self._solid((1, 2, 3), size=(2, 2))]) + assert palette.mode == "P", "a sub-divisor frame should not break the montage" + + +class TestClipPaletteValidation: + """Input validation on the public palette builder.""" + + def test_empty_frame_list_raises(self): + """No frames raises `ValueError`, not `IndexError` from deep inside. + + Test scenario: + A palette needs at least one frame to describe; an empty clip used + to surface as a confusing IndexError while indexing the colour list. + """ + with pytest.raises(ValueError, match="at least one frame"): + anim_mod.build_clip_palette([]) + + @pytest.mark.parametrize("colors", [1, 0, -5, 255, 256, 300]) + def test_out_of_range_colors_raises(self, colors): + """A palette budget outside ``2-254`` raises `ValueError`. + + Args: + colors: The out-of-range budget under test. + + Test scenario: + Two entries are reserved for pure black and white, so 254 is the + ceiling -- above it the reserved pair would overwrite chosen colours + and Pillow rejects 256 with a bare "invalid palette size". + """ + frames = [Image.new("RGB", (8, 8), (10, 20, 30))] + with pytest.raises(ValueError, match="colors must be"): + anim_mod.build_clip_palette(frames, colors=colors) + + def test_frames_of_differing_sizes_are_accepted(self): + """Frames need not share a size for the palette to be built. + + Test scenario: + The palette is derived from the colours present, not from any + spatial layout, so a clip whose frames differ in size is still + describable. + """ + frames = [ + Image.new("RGB", (8, 8), (255, 0, 0)), + Image.new("RGB", (16, 4), (0, 0, 255)), + ] + palette = anim_mod.build_clip_palette(frames) + assert palette.mode == "P", "mismatched frame sizes should still quantise" + + +class TestClipGamutInputs: + """Frame-mode handling and exactness of the colour census.""" + + @pytest.mark.parametrize("mode", ["RGBA", "L", "P", "CMYK"]) + def test_non_rgb_frames_are_converted_not_misread(self, mode): + """A non-RGB frame is converted, never reinterpreted byte-wise. + + Args: + mode: The source image mode under test. + + Test scenario: + Flattening an RGBA frame and reshaping it to three columns succeeds + whenever the byte count divides by three, and yields a palette of + pure misalignment artifacts rather than the frame's actual colours. + A solid frame must produce its own colour whatever mode it arrives + in. + """ + frame = Image.new("RGB", (12, 12), (200, 40, 90)).convert(mode) + expected = frame.convert("RGB").getpixel((0, 0)) + palette = anim_mod.build_clip_palette([frame], colors=8) + quantised = anim_mod.quantize_to_palette([frame.convert("RGB")], palette) + assert quantised[0].convert("RGB").getpixel((0, 0)) == expected, ( + f"{mode} frame round-tripped to " + f"{quantised[0].convert('RGB').getpixel((0, 0))}, expected {expected}" + ) + + def test_colours_are_reproduced_exactly(self): + """Every distinct colour reaches the palette unshifted. + + Test scenario: + The census records colours at full 8-bit precision, so a palette + with room for them all must hold them exactly -- bucketing to fewer + bits would return neighbours a step or two away. + """ + colours = [(255, 0, 255), (1, 2, 3), (128, 129, 130), (0, 255, 254)] + frames = [Image.new("RGB", (4, 4), c) for c in colours] + table = anim_mod.build_clip_palette(frames, colors=8).getpalette() + triples = {tuple(table[i : i + 3]) for i in range(0, 8 * 3, 3)} + missing = [c for c in colours if c not in triples] + assert not missing, f"colours shifted by the census: {missing}" + + def test_zero_pixel_frame_says_so(self): + """A frame with no pixels reports that, not "no frames". + + Test scenario: + An empty image is a different mistake from an empty clip, and the + message should say which one happened. + """ + empty = Image.new("RGB", (0, 0)) + with pytest.raises(ValueError, match="hold no pixels"): + anim_mod.build_clip_palette([empty]) + + def test_padding_does_not_favour_one_colour(self): + """Squaring the census tiles the colours instead of repeating one. + + Test scenario: + A five-colour census pads to nine pixels. Repeating the last colour + would give it more than half the census, which the strategies read + as prominence; tiling keeps the five roughly even. + """ + colours = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (0, 255, 255)] + frames = [Image.new("RGB", (2, 2), c) for c in colours] + census = np.asarray(anim_mod._clip_gamut(frames)).reshape(-1, 3) + counts = {tuple(c): 0 for c in colours} + for pixel in census: + counts[tuple(pixel)] += 1 + assert max(counts.values()) <= 2, ( + f"one colour dominates the padded census: {counts}" + ) + + +class TestQuantizeMethod: + """The palette strategy is selectable, with coverage as the default.""" + + def test_unknown_method_raises(self): + """A method outside `QUANTIZE_METHODS` raises `ValueError`. + + Test scenario: + The message names the accepted keys, so a typo is self-correcting. + """ + frames = [Image.new("RGB", (8, 8), (10, 20, 30))] + with pytest.raises(ValueError, match="method must be one of"): + anim_mod.build_clip_palette(frames, method="nearest") + + @pytest.mark.parametrize("method", ["coverage", "median", "octree"]) + def test_every_documented_method_builds_a_palette(self, method): + """Each key of `QUANTIZE_METHODS` produces a usable palette. + + Args: + method: The strategy under test. + + Test scenario: + All three map to a Pillow quantiser that returns a palette image. + """ + frames = [Image.new("RGB", (8, 8), c) for c in ((255, 0, 0), (0, 0, 255))] + palette = anim_mod.build_clip_palette(frames, method=method) + assert palette.mode == "P", f"{method} should yield a palette image" + + def test_median_trades_marks_away_and_coverage_keeps_them(self, tmp_path): + """The opt-out really does change the outcome it exists to change. + + Args: + tmp_path: pytest temp directory. + + Test scenario: + This is what makes the knob meaningful rather than decorative: on a + texture-heavy clip the default keeps the saturated marks, while + median cut -- which weights by pixel population -- discards them. + If both scored alike the parameter would be doing nothing. + """ + frames = _clip_frames() + results = {} + for method in ("coverage", "median"): + fig, anim = _clip_animation(frames) + out = tmp_path / f"{method}.gif" + save_animation(anim, str(out), fps=12, quantize_method=method) + plt.close(fig) + results[method] = max(_mark_distances(_decode(out), frames)) + + assert results["coverage"] < 40, ( + f"the default should keep the marks, got {results['coverage']:.1f}" + ) + assert results["median"] > results["coverage"] * 3, ( + "median cut should visibly lose the marks the default keeps: " + f"{results['median']:.1f} vs {results['coverage']:.1f}" + ) + + +class TestPillowOptionValidation: + """Rate and loop validation shared by the Pillow-backed writers.""" + + @pytest.mark.parametrize("fps", [0, -1, -0.5, float("nan"), float("inf")]) + def test_non_positive_fps_raises(self, tiny_anim, tmp_path, fps): + """A non-positive `fps` raises `ValueError`, not `ZeroDivisionError`. + + Args: + tiny_anim: Small animation fixture. + tmp_path: pytest temp directory. + fps: The invalid rate under test. + + Test scenario: + The rate becomes a per-frame duration by division, so zero used to + surface as a bare ZeroDivisionError from inside the writer. NaN and + infinity are included because a plain ``fps <= 0`` test lets both + through -- NaN compares false against everything, and infinity + divides to a zero delay. + """ + with pytest.raises(ValueError, match="fps must be a positive finite"): + save_animation(tiny_anim, str(tmp_path / "a.gif"), fps=fps) + + def test_negative_loop_raises(self, tiny_anim, tmp_path): + """A negative `loop` raises `ValueError`, not `struct.error`. + + Args: + tiny_anim: Small animation fixture. + tmp_path: pytest temp directory. + + Test scenario: + GIF stores the loop count as an unsigned short, so a negative value + used to fail inside Pillow's struct packing. + """ + with pytest.raises(ValueError, match="loop must be zero or positive"): + save_animation(tiny_anim, str(tmp_path / "a.gif"), loop=-1) + + def test_very_high_fps_keeps_a_visible_delay(self, tiny_anim, tmp_path): + """Above 100 fps the GIF delay is held at the format's 10 ms floor. + + Args: + tiny_anim: Small animation fixture. + tmp_path: pytest temp directory. + + Test scenario: + GIF stores its delay in hundredths of a second, so a sub-10 ms delay + rounds to zero on disk; viewers discard a zero delay and replay at + their own default, far slower than asked for. Flooring at 10 ms + keeps the fastest timing the format can actually express. + """ + out = tmp_path / "fast.gif" + save_animation(tiny_anim, str(out), fps=2000) + with Image.open(out) as handle: + assert handle.info.get("duration", 0) >= 10, ( + "a sub-centisecond delay is dropped by viewers: " + f"{handle.info.get('duration')}" + ) + + +class TestQuantizeToPalette: + """Tests for `quantize_to_palette`.""" + + def test_returns_one_palette_frame_per_input(self): + """Every input frame comes back as a ``"P"``-mode image. + + Test scenario: + The count is preserved and each frame is converted, so the writer + can hand the list straight to Pillow. + """ + frames = [Image.new("RGB", (8, 8), c) for c in ((255, 0, 0), (0, 255, 0))] + palette = anim_mod.build_clip_palette(frames) + result = anim_mod.quantize_to_palette(frames, palette) + assert len(result) == len(frames), ( + f"expected {len(frames)} frames, got {len(result)}" + ) + assert all(f.mode == "P" for f in result), "every frame should be palette-mode" + + def test_frames_share_one_palette(self): + """All quantised frames carry the same colour table. + + Test scenario: + A shared table is what keeps constant regions byte-stable between + frames, so the palettes must be identical, not merely similar. + """ + frames = [Image.new("RGB", (8, 8), c) for c in ((255, 0, 0), (0, 0, 255))] + result = anim_mod.quantize_to_palette( + frames, anim_mod.build_clip_palette(frames) + ) + first = result[0].getpalette() + assert all(f.getpalette() == first for f in result[1:]), ( + "frames do not share a single palette" + ) + + +class TestWritePillowAnimation: + """Tests for `_write_pillow_animation`.""" + + @pytest.fixture + def palette_frames(self): + """Two palette-mode frames sharing one table. + + Returns: + list: The quantised frames. + """ + frames = [Image.new("RGB", (8, 8), c) for c in ((255, 0, 0), (0, 0, 255))] + return anim_mod.quantize_to_palette(frames, anim_mod.build_clip_palette(frames)) + + @pytest.mark.parametrize( + "fps, expected", [(4, 250), (10, 100), (2, 500), (12, 80), (3, 330)] + ) + def test_duration_follows_fps(self, palette_frames, tmp_path, fps, expected): + """Frame duration is the millisecond reciprocal of `fps`. + + Args: + palette_frames: The quantised-frames fixture. + tmp_path: pytest temp directory. + fps: Playback rate under test. + expected: The per-frame duration it implies. + + Test scenario: + GIF stores a per-frame delay, so fps has to be converted; 4 fps is + 250 ms per frame. The default 12 fps and 3 fps are included because + they do not divide 1000 exactly, and the delay field is in + hundredths of a second -- so 83 ms is stored as 80 and 333 as 330. + The expectations are what actually round-trips, which is where a + rounding change would show up. + """ + out = tmp_path / "d.gif" + anim_mod._write_pillow_animation(palette_frames, str(out), fps, 0, True) + with Image.open(out) as handle: + assert handle.info["duration"] == expected, ( + f"fps={fps} should give {expected}ms, got {handle.info['duration']}" + ) + + def test_loop_is_written(self, palette_frames, tmp_path): + """A non-zero `loop` reaches the written file. + + Test scenario: + `loop=3` must be recorded rather than silently defaulting to the + forever-loop Pillow's writer hardcodes. + """ + out = tmp_path / "l.gif" + anim_mod._write_pillow_animation(palette_frames, str(out), 5, 3, True) + with Image.open(out) as handle: + assert handle.info.get("loop") == 3, ( + f"expected loop=3, got {handle.info.get('loop')}" + ) + + def test_accepts_a_lazy_iterable(self, palette_frames, tmp_path): + """Frames may arrive as a generator, not only as a list. + + Args: + palette_frames: The quantised-frames fixture. + tmp_path: pytest temp directory. + + Test scenario: + The video path hands the writer a generator so the clip is never + materialised to be written; the writer must consume one. + """ + out = tmp_path / "lazy.gif" + anim_mod._write_pillow_animation(iter(palette_frames), str(out), 5, 0, True) + assert len(_decode(out)) == len(palette_frames), "generator input lost frames" + + def test_empty_stream_raises(self, tmp_path): + """No frames raises `ValueError`, not a bare `StopIteration`. + + Args: + tmp_path: pytest temp directory. + + Test scenario: + Pulling the first frame off an exhausted iterator used to surface as + StopIteration, which reads as a generator bug rather than an empty + clip. + """ + out = str(tmp_path / "e.gif") + empty = iter(()) + with pytest.raises(ValueError, match="at least one frame"): + anim_mod._write_pillow_animation(empty, out, 5, 0, True) + + def test_single_frame_gif_reserves_black_and_white(self, tmp_path): + """A one-frame GIF still goes through the shared palette. + + Args: + tmp_path: pytest temp directory. + + Test scenario: + The palette branch used to require more than one frame, so a + single-frame GIF skipped it and lost the reserved black and white + the docs promise for overlays. + """ + frame = Image.new("RGB", (16, 16), (120, 30, 200)) + frame.putpixel((0, 0), (0, 0, 0)) + out = tmp_path / "one.gif" + palette = anim_mod.build_clip_palette([frame]) + anim_mod._write_pillow_animation( + anim_mod.quantize_to_palette([frame], palette), str(out), 5, 0, True + ) + assert _decode(out)[0][0, 0].tolist() == [0, 0, 0], ( + "pure black should survive in a single-frame GIF" + ) + + def test_all_frames_are_written(self, palette_frames, tmp_path): + """Every frame ends up in the file, not just the first. + + Test scenario: + The first frame is saved with the rest appended; a mistake there + would silently produce a single-frame GIF. + """ + out = tmp_path / "n.gif" + anim_mod._write_pillow_animation(palette_frames, str(out), 5, 0, True) + assert len(_decode(out)) == len(palette_frames), "frame count mismatch" diff --git a/uv.lock b/uv.lock index d244920b..c929282f 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ notebook = [ requires-dist = [ { name = "cmap", marker = "extra == 'science-colors'", specifier = ">=0.7.2" }, { name = "hpc-utils", specifier = ">=0.1.4" }, - { name = "imageio-ffmpeg", specifier = ">=0.4.9" }, + { name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "matplotlib", specifier = ">=3.9" }, { name = "numpy", specifier = ">=2.0.0" }, { name = "pillow", specifier = ">=12.1.1" },