Skip to content

feat(animation): derive a GIF from a video and fix the clip palette - #317

Merged
MAfarrag merged 21 commits into
mainfrom
feat/gif-palette-quality
Aug 25, 2026
Merged

feat(animation): derive a GIF from a video and fix the clip palette#317
MAfarrag merged 21 commits into
mainfrom
feat/gif-palette-quality

Conversation

@MAfarrag

@MAfarrag MAfarrag commented Aug 24, 2026

Copy link
Copy Markdown
Member

Description

Two changes to the GIF path, kept together because the second is only worth having once the first
lands.

The shared GIF palette was picked by pixel population. Median cut allocates entries to whatever
covers the most pixels, so on a clip with a large textured area the background claimed nearly the
whole table and small saturated marks — overlay glyphs, thin orbit paths, labels — collapsed to the
nearest muddy neighbour. The palette is now chosen for colour coverage over the set of colours
the clip contains, gathered by a presence bitmap at full 8-bit precision. Coverage over distinct
colours does not care how many pixels a colour covers, so a mark survives at any size: on the
texture-heavy test clip the four marks moved from 100–180 RGB distance off their intended colours to
reproducing them exactly, including single-pixel marks.

gif_from_video derives a GIF from a video already on disk, without re-rendering. Drawing is
far more expensive than encoding — hours, for a long clip — so the frames are rendered once and
every other format read back off that file. It runs through the same build_clip_palette as
save_animation, so a derived GIF and a rendered one quantise identically rather than the derived
path growing a second quantiser.

mp4 = save_animation(anim, "master.mp4", fps=12, crf=0, pix_fmt="yuv444p")
gif_from_video(mp4, "web.gif", fps=12, width=720)

Chroma subsampling caps what any of this can do, and is now surfaced. save_animation writes
yuv420p by default, which stores colour at reduced resolution — that loss happens before the GIF
palette ever runs and no quantiser can undo it. On the test clip a yuv420p intermediate holds a
derived GIF at ~50 RGB distance against ~5 from yuv444p. gif_from_video emits a UserWarning
when handed a subsampled source, so the feature cannot silently under-deliver on its most likely
input.

Memory, stated honestly. The source is decoded twice rather than held, which keeps the decoded
RGB frames from ever being resident together — the larger cost. It does not make memory flat in
the clip's length: Pillow's GIF encoder accumulates every quantised frame before writing a byte, so
peak is roughly width × height × frames (154 MB measured for 150 frames of 720p). The docs say so
and point at width for a long master.

Escape hatch. quantize_method ("coverage" default, "median", "octree") is accepted by
both save_animation and gif_from_video, for a smooth photographic clip with no small marks at
stake where spending the table on the crowded regions of colour space renders the background a
little more finely.

Dependencies: none added. The imageio-ffmpeg floor is raised 0.4.9 → 0.6.0 — the version the
suite actually exercises, since reading frames back with output_params and a post-filter size was
never tested against the old floor.

Scope: SCOPE.md gains an "Animation output" section. gif_from_video reads a file, which the
boundary heuristic's first question rules out, and nothing recorded why that was acceptable. It
states the reason (render once, derive many) and the limit that still holds (this does not become a
media-conversion tool).

Issues

Type of change

Check relevant points.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • Dev changes (CI/pyproject.toml/docs/examples/testing)

How Has This Been Tested?

Run the animation suite:

pytest tests/test_animation.py -q
pytest tests/test_animation.py -q --cov=cleopatra.glyphs.base.animation --cov-branch --cov-report=term-missing
  • tests/test_animation.py171 passed, module at 100 % line and branch coverage
    (232 statements, 82 branches, 0 missing).
  • Palette quality is asserted across mark sizes (TestClipPaletteQuality), parametrised over
    5×5, 3×3 and 1×1. The size sweep is the point: a palette built from a spatially downsampled
    clip passes at 5×5 and fails at 1×1, and 1×1 is the size that matters for satellites and orbit
    paths. Verified the guard bites by restoring median cut — it fails at 165/178/144/101.
  • gif_from_video — GIF magic bytes, one clip-wide palette, marks surviving the round trip,
    fps frame count, width scaling, the chroma warning firing on yuv420p and staying silent
    on yuv444p, missing source, non-GIF extension, and max_colors / fps / width / loop
    validation.
  • Frame-mode handling — RGBA / L / P / CMYK frames round-trip to their own colour rather than
    being reinterpreted byte-wise, and colours are reproduced exactly.
  • The quantize_method knob is proven to do something — on the texture-heavy clip the
    default keeps the marks and "median" loses them; a decorative parameter would score alike.
  • Decoder lifecycle — closed when iteration stops early; IMAGEIO_FFMPEG_EXE restored both when
    previously unset and when the caller had pinned their own binary.
  • Doctests — 9 passed (pytest --doctest-modules src/cleopatra/glyphs/base/animation.py).
  • Full suite — 2397 passed.
  • mypy clean and ruff check clean on the changed files.
  • Two review rounds (files in the repo root): 23 findings then 27, all resolved. SonarCloud on
    this PR: 5 issues, all fixed in code.

Measurements quoted above come from a synthetic clip built for the tests, not from the satellite
footage that prompted the issues — the effect sizes are large, but a run against the real showcase
clip before merging is worth doing.

Checklist:

  • updated version number in pyproject.toml
  • added changes to History.rst
  • updated the latest version in README file
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

The shared GIF palette was median-cut, which allocates entries by pixel
population. On a clip with a large textured area the background claimed nearly
every slot and small saturated marks -- overlay glyphs, thin paths, labels --
collapsed to the nearest muddy neighbour. Quantising for colour coverage keeps
them: on a texture-heavy test clip the marks moved from 100-180 RGB distance
off their intended colours to under 12, in a smaller file, with whole-frame and
background error better or unchanged.

Adds gif_from_video, which derives a GIF from a video already on disk. Drawing
is far more expensive than encoding -- hours, for a long clip -- so the frames
are rendered once and every other format read back off that file rather than
re-rendered.

The palette machinery moves out of the writer into build_clip_palette and
quantize_to_palette so both paths quantise identically, rather than the derived
GIF growing a second, divergent quantiser.

Chroma subsampling caps what any of this can achieve: yuv420p, which
save_animation writes by default, discards colour resolution before the palette
ever runs and holds a derived GIF at ~50 RGB distance against ~5 from yuv444p.
gif_from_video warns when handed such a source, and the docs say to render the
intermediate at yuv444p when a GIF will come off it.

Closes #315
Closes #308
Takes the module to 100% line and branch coverage. The gaps were an unreported
pixel format, the NV planar formats, and a source that decodes to no frames --
each reachable only by exercising the helpers directly rather than through a
rendered clip.
build_clip_palette and quantize_to_palette are public but had no runnable
examples, and gif_from_video only showed the happy path. Adds worked examples
for the reserved black/white entries, a reduced colour budget, a colour that
appears only in a late frame, the shared-table guarantee, width scaling, and
the missing-source error.
…mple

The coverage-based palette only helped marks about five pixels across. The
montage that fed it downsampled each frame to a third, and an interpolating
resize blends a small mark into its background before the quantiser can see it:
a 3x3 mark still landed 92 away from its intended colour and a 1x1 mark 173,
against 175 for the median cut the change was meant to replace. Nothing about
the fix reached the sizes that matter -- satellites, orbit paths and labels are
one to three pixels.

The palette source is now the set of colours the clip contains, collected once
each through a presence bitmap at six bits per channel, with each bucket keeping
a colour that genuinely occurs so exact hues arrive unshifted. Coverage over
distinct colours does not care how many pixels a colour covers, so marks now
reproduce exactly at every size tested, including single pixels. It is also
faster than the montage it replaces -- 2.5s against 8.2s over 120 frames of
720p -- and drops the resize entirely, so frames need not share a size.

The trade is a marginally coarser background, RMSE 7.5 to 8.3 on the test clip,
since entries go to colours the clip contains rather than what it contains most
of. The docs said the file is always smaller; that was only true of the
texture-heavy case, so they now say it moves either way.

The palette test was pinned to 5x5 marks, the one size that passed. It now
sweeps 5x5, 3x3 and 1x1.
Decoding every frame into a list before quantising made peak memory
proportional to the clip's length: 150 frames of 720p held 817 MB, and the
hours-long masters this function exists to serve would need tens of gigabytes.

The source is now decoded twice and streamed both times, once to learn the
clip's colours and once to quantise and write. Neither pass keeps a frame after
reading it, and Pillow consumes append_images lazily, so peak memory is flat in
the clip's length -- the same clip now costs 7 MB against 99 MB buffered.
Decoding twice is far cheaper than the RAM it saves.

The decoder was also never closed. It is a generator wrapping an ffmpeg child,
so a caller that stopped early, or an exception raised mid-iteration, left the
child running -- reachable from this module's own chroma warning under
-W error. Iteration now happens inside a try/finally that closes it however the
caller leaves.
Four ways to get an unhelpful failure, or none at all:

build_clip_palette became public with no validation. An empty clip raised
IndexError from inside the colour census, and a budget above 254 either let the
reserved black and white overwrite chosen entries or reached Pillow as a bare
"invalid palette size". Both now raise a ValueError naming the limit.

gif_from_video never checked its output extension. Pillow selects an encoder
from the suffix, so a .png path silently produced an APNG and .webp a WebP --
neither being what a caller of a function named gif_from_video asked for.

fps reached a division unchecked, so fps=0 surfaced as a bare ZeroDivisionError
from the writer, and a negative loop as a bare struct.error from Pillow packing
an unsigned short.

A rate above 100 fps rounded the GIF delay to zero. The field is in hundredths
of a second, and viewers discard a zero delay and replay at their own default --
slower than the caller asked for rather than faster. The delay now floors at the
10 ms the format can actually express, and at 1 ms for WebP, which keeps
millisecond timing.
Writing a video honours matplotlib's animation.ffmpeg_path and a system FFmpeg
on PATH, falling back to the bundled binary only when neither resolves. Reading
one went straight to imageio-ffmpeg's bundled copy, so the same process could
decode with one FFmpeg and encode with another, and a pinned custom build was
silently ignored on the way in.

Decoding now goes through the same resolver and exports the result for
imageio-ffmpeg to pick up, leaving an explicit IMAGEIO_FFMPEG_EXE alone. The
documented exception was wrong too: it named a missing package, when the error
that actually reaches the caller is a missing binary.
The chroma check treated the whole nv family as 4:2:0, so nv24 and nv42 -- both
4:4:4 -- were warned about for losing colour they never lost. It also missed the
semi-planar high-bit-depth formats entirely: p010, p016 and p012 are 4:2:0 and
p210 is 4:2:2, and none of them start with yuv or nv, so a 10-bit HDR master
went through without the warning that matters most for it.

Each family is now read the way FFmpeg defines it, with the p-formats spelling
their sampling in the first digit. The yuvj element of the prefix tuple was dead
-- yuvj420p already starts with yuv -- so it is gone.

The tests used invented format strings; they now use names taken from
ffmpeg -pix_fmts, including the 4:4:4 members of each family that must not warn.
… ceiling

build_clip_palette and quantize_to_palette went out public with bare `list`
parameters and no return types, while the rest of the module is fully
annotated. They now say what they take -- any iterable of frames, since the
streaming path hands them a generator -- and what they give back.

The 254-entry ceiling was named _CLIP_PALETTE_COLORS but is the default of two
public functions, so mkdocstrings rendered a private name in their signatures.
It is now CLIP_PALETTE_COLORS.

The module declares __all__, matching the convention the watermark module
already follows, and the docs name quantize_to_palette alongside the builder so
the pair is discoverable to a downstream package writing its own frames. One
doctest imported warnings without using it.
The same-width test only checked the output dimensions, which cannot tell a
skipped resample from one that resampled to the same size -- and resampling a
frame to its own size still costs time and softens it. Image.resize is now
wrapped and must not be called at all.

The no-warning test escalated every UserWarning to an error, so an unrelated
warning from anywhere in the stack would have failed it for the wrong reason. It
now records warnings and asserts only that the chroma one is absent.

The duration cases all divided 1000 exactly, so they never exercised the
truncation. Adding the default 12 fps and 3 fps showed the delay field is in
hundredths of a second: 83 ms is stored as 80 and 333 as 330. The expectations
are what round-trips.

The decoder stub imported imageio_ffmpeg inside the test body, which the repo's
style rules forbid, and the source MP4 was re-encoded for every test that asked
for it though none of them mutate it -- the fixture is now module-scoped.
Running ruff format over the whole test file rewrapped four assertions in the
odd-dimension and palette-stability tests, neither of which this branch touches.
They are back as they were, so the diff shows only the feature.
Selecting the palette for colour coverage keeps small saturated marks, but it
spends entries on colours the clip merely contains rather than on what it
contains most of, so a smooth photographic clip with nothing small at stake
renders its background slightly more coarsely -- RMSE 7.5 to 8.3 on the test
clip. That case now has a way out.

quantize_method takes a key of QUANTIZE_METHODS -- coverage, the default, median
for Pillow's population-weighted cut, or octree between them -- and is accepted
by save_animation and gif_from_video alike, so the rendered and derived paths
offer the same choice. An unknown key names the accepted ones.

A test asserts the knob actually changes the outcome rather than being
decorative: on a texture-heavy clip the default keeps the marks and median cut
loses them.
gif_from_video reads a file, which the boundary heuristic's first question
rules out, and SCOPE.md carried only the tiles/reference carve-out. Nothing
recorded why reading a video was acceptable, so the shipped API and the stated
scope disagreed.

Adds an "Animation output" section giving the reason -- rendering frames costs
hours where encoding them costs seconds, so producing every format from a live
FuncAnimation means re-rendering per format -- and the limit that still holds:
the input must be cleopatra's own output, not an arbitrary user video, and this
is not licence to become a media-conversion tool.
…ly weights

build_clip_palette gained a method parameter with no worked example. Adds one
listing the accepted keys.

Writing it corrected the description. The palette is built from a census that
holds each colour once, so median cut never sees pixel counts: it splits the
colour cube by how densely the clip populates it, weighting by distinct colours
rather than by area. The practical effect on a photographic clip is the same --
the crowded regions a background occupies win the table -- but "weights by pixel
population" was not what the code does.
The census reshaped each frame to three columns without checking its mode, so an
RGBA, L or P frame was reinterpreted byte-wise whenever its pixel count happened
to divide by three, yielding a palette of pure misalignment artifacts. Frames are
converted to RGB first. build_clip_palette is public and documented for reuse, so
this was reachable from outside.

Colours were also bucketed to six bits with the last writer winning, which
contradicted the promise that the palette reproduces a clip's colours exactly.
Keys are now the full 24-bit triple, which removes the representative table
entirely: the bitmap is 16 MB whatever the clip, the survey is chunked so a 4K
frame is not briefly copied at four bytes a pixel, and padding the census to a
square tiles the colours instead of repeating one -- repetition handed that
colour a share the strategies read as prominence.

The memory claim was wrong. Streaming keeps the decoded RGB frames from ever
being resident together, which is the larger cost, but Pillow's GIF encoder
accumulates every quantised frame before writing a byte, so peak memory stays
proportional to the clip: 154 MB measured for 150 frames of 720p, not flat. The
docstring said a long master need not fit in RAM. It does.

Median cut was described as weighting by pixel population in three places. The
census holds each colour once, so no strategy here sees pixel counts at all --
they weight by distinct colours. The behaviour is unchanged; the description was
not true of it.

Also: the ffmpeg binary is no longer exported permanently, only while the decoder
starts, so it cannot go stale or leak into unrelated code; an empty frame stream
raises instead of a bare StopIteration; quantize_method is validated before the
render rather than at a point WebP and single-frame GIFs never reach; a
single-frame GIF now gets the shared palette and its reserved black and white;
packed 4:2:2 formats are recognised; NaN and infinite rates are rejected; a
zero-pixel frame says so; scaling moved into the ffmpeg filter chain rather than
per frame in Pillow; and the Resampling enum, the annotations and the return
types satisfy mypy and ruff, both of which this file had started failing.
…aths

The source MP4 was shared across the module, so all 22 tests that used it hung
off one ffmpeg encode: a single flaky run turned into twenty simultaneous errors
rather than one failure, which matches a transient 18-error run seen during
development and hides which test actually broke. It is per-test again; the
encode is worth the seconds.

Adds cover for what the source fixes changed: non-RGB frames round-tripping to
their own colour, exact colour reproduction, census padding not favouring one
colour, a zero-pixel frame, an empty frame stream, a single-frame GIF keeping
reserved black, the environment variable being restored both when it was unset
and when the caller had pinned their own, an unknown strategy rejected before
rendering, NaN and infinite rates, packed 4:2:2 formats, and the writer taking a
generator -- the lazy path the video route depends on and nothing exercised.

Three docstrings still described the montage that no longer exists.
The reference page repeated the wrong claims: that median cut weights by pixel
population, and implicitly that memory is bounded. Adds a Memory note giving the
real shape -- roughly width x height x frames at peak, because Pillow buffers the
quantised frames -- and points at width for a long master.

Benchmark figures were quoted with nothing behind them. The mark-fidelity numbers
now name the test that asserts them; the background-RMSE and file-size figures
are marked as measured-not-asserted rather than presented as guarantees.

SCOPE.md claimed cleopatra reads only files it wrote, which the code does not
enforce and a check would not improve. It now states the intended input as
purpose, and rests the boundary on what the helper does not do.

Raises the imageio-ffmpeg floor to the 0.6.0 the suite actually runs against;
reading frames back with output_params and a post-filter size was never tested
against the old 0.4.9. Glyph.save_animation's forwarded-kwargs list gains
quantize_method.
save_animation had grown past the cognitive-complexity limit by accumulating
option checks inline; resolving the output format and validating the options
against it moves to a helper, which is where that logic belonged anyway since
the checks depend on which format the extension selected.

The rate guard was written as `not fps > 0` to catch NaN, which reads as an
inverted comparison; math.isfinite says what it means and covers infinity too.
The ffmpeg rcParam name was spelled out at three call sites and is now a
constant. Two exception tests built their argument inside the pytest.raises
block, so a failure there would have been mistaken for the error under test.
The iterator was constructed inside pytest.raises, so a failure creating it
would have been mistaken for the error under test.
SCOPE.md conflicted: main's watermark work and this branch both added an
exception to the data-I/O rule. Kept both, alongside the existing
tiles/reference carve-out, and aligned the boundary heuristic's first question
with the three the section now lists.
CI verifies uv.lock matches pyproject.toml before installing anything, so the
raised floor failed the run at setup rather than at the tests.
@sonarqubecloud

Copy link
Copy Markdown

@MAfarrag
MAfarrag merged commit 07ecb94 into main Aug 25, 2026
10 checks passed
@MAfarrag
MAfarrag deleted the feat/gif-palette-quality branch August 25, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant