Pixel Art Palette Lock — One Palette for Every Frame
Several frames exported from different tools rarely share a palette. The Pixel Lab builds one palette from all of them, locks every frame to it and proves it: every exported colour is in the palette.
Frames exported from different tools, or resized and re-saved, drift apart: the same shade becomes three slightly different colours, and the animation shimmers. Locking fixes that: build one palette from all frames together (2–256 colours, 16 by default, or a preset such as PICO-8), map every pixel of every frame to it, and keep dithering off or ordered so still areas do not flicker. The Pixel Lab does this in the browser and exports the frames as PNG at 1×–8×, the palette as .gpl and a pixel-lab.json summary; every exported colour is checked to be in the palette.

- PNG framesMeasured· Pillow
- PaletteMeasured· re-import
- pixel-lab.jsonUNVERIFIED
Why frames drift, and how one palette and the right dither stop it
Each tool that touches a frame can nudge its colours: resampling blends neighbours, JPEG or lossy WebP adds noise, and a canvas that stores colour premultiplied by alpha shifts semi-transparent pixels. Quantising each frame on its own then gives each frame its own palette. The Lab instead feeds all frames into one histogram weighted by alpha, splits it by median cut in Oklab (so both frequency and visible difference count), and refines the result, which yields one palette shared by every frame.
Dithering decides whether the animation flickers. Ordered (Bayer) dithering adds a bias that depends only on the pixel's position, so an area that does not change gets the same pixels in every frame. Error diffusion (Floyd–Steinberg, Atkinson) carries the rounding error across the whole frame, so one moving pixel changes the decisions of every pixel after it, and a still wall can flicker. The Lab defaults to None and warns when diffusion is chosen with more than one frame loaded.
The Lab writes the PNGs from the RGBA values directly instead of through a canvas, because a canvas rounds semi-transparent colours through premultiplied alpha. Before that change, 68 of 84 exported colours fell outside a 16-colour locked palette; after it, none did.
- Palette lock
- Mapping every pixel of every frame onto one fixed palette.
- Median cut
- Splitting the colour histogram repeatedly at the median of its widest axis until the target count is reached.
- Ordered (Bayer) dither
- A fixed threshold pattern by pixel position; it cannot flicker between frames.
- Error diffusion
- Dithering that spreads each pixel's rounding error to its neighbours; it can flicker in animation.
Example: eight anti-aliased frames locked to 16 colours
The Lab's browser test, with the exported files re-read by an independent decoder:
input 8 frames × 48×48 px = 18,432 px, anti-aliased edges
extract one palette of 16 colours from all 8 frames
lock every pixel → nearest of the 16 in Oklab, dither None
check colours in all exported PNGs: 16; outside the palette: 0
flicker an unchanging band: 1 distinct version across 8 frames (None and Bayer 4×4)
counting exact up to 4 MP of frame area in total; here 0.018 MP
size cap each frame at most 4096 × 4096 pxThe last check is the one that matters for a game: open the exported PNGs, not the preview, and count their colours. The Lab's test does exactly that with Pillow.
What it does
- One alpha-weighted palette from all frames (Oklab median cut), 2–256 colours, or a classic palette (Game Boy, PICO-8, Sweetie 16, Endesga 32, NES).
- Dither: None, Floyd–Steinberg, Atkinson, Bayer 2×2/4×4/8×8 — ordered dithering does not flicker between frames, and the page warns when error diffusion might.
- Palette editing: sort by lightness/hue/saturation/frequency, lock colours that survive re-extraction.
- Export PNG frames at 1×–8× nearest, the palette as .gpl, and a JSON envelope.
How to do it
- Drop the frames.
- Choose the colour count or a classic palette.
- Pick a dither mode (None is the default for animations).
- Export the ZIP.
Check the locked frames
- Step through the frames with ← and →: the same shade should show the same swatch in the palette on every frame.
- Open two exported PNGs in any image editor and sample the same spot of a still area: the values must be identical.
- Count the colours of one exported frame in another tool: the count must not exceed the palette size.
- Load the exported
.gplin GIMP or Aseprite: it lists the same colours in the same order.
Troubleshooting
| Symptom | Likely cause | How to check | Fix |
|---|---|---|---|
| The animation flickers after locking | An error-diffusion dither (Floyd–Steinberg or Atkinson) is on | The Convert stage shows the flicker warning | Switch to None or a Bayer matrix; compare them side by side in the dither grid |
| A small detail colour disappeared | Median cut weights colours by how many pixels use them; a 2-pixel eye highlight loses to large areas | Look for the colour in the Palette stage after extraction | Lock that colour, then press Extract again, or raise the colour count |
| A dropped sprite sheet came out as one frame | The Lab treats one image as one frame; it does not slice sheets | The frame strip shows a single frame | Slice the sheet first with the sprite slicer, then drop the frames |
| The Lab refuses an image | A frame larger than 4096 × 4096 px would freeze the tab, so it is refused | The message names the size and the limit | Scale or crop it first, or slice it into frames |
| The colour counts cover only one frame | Above 4 MP of frame area in total, counting is limited to the visible frame | The panel says so next to the counts | Load fewer frames at once if you need exact totals |
Other ways to do it
| Option | When it is the better choice |
|---|---|
| Converting inside the Studio's Pixel workspace | The frames belong to a layered sprite with a timeline: Colour mode… makes it indexed and keeps layers and tags. See the palette editor. |
| A published palette instead of an extracted one | The art must match a known palette such as PICO-8 or Endesga 32: see applying a Lospec palette. |
| Cleaning anti-aliased edges and stray pixels | Locking left odd edge pixels or specks: the Lab's Cleanup stage, described on pixel-art cleanup. |
Outputs and how each was checked
"Measured" means the downloaded file was opened again outside the page (Pillow, numpy or an independent parser) and the property was measured. Loading these files in a game engine was not tested.
| Target | Files | Check |
|---|---|---|
| PNG frames | anim_N.png · 1×–8× nearest | Measured PillowEvery exported colour re-read from the PNGs lies inside the locked palette. |
| Palette | .gpl · .hex (Lospec) · .json | Measured re-importEach format was exported and imported back through the UI: 16 → 16 colours. |
| pixel-lab.json | generic frames + palette | UNVERIFIEDGeneric JSON; not loaded in any engine. |
Evidence
8 anti-aliased frames → one 16-colour palette with 0 exported colours outside it; an unchanged region identical in all 8 frames with dithering off and with Bayer 4×4; the anti-alias remover took 271 colours to 16 without moving a single silhouette pixel; integer scale, offset and 2.5× non-integer scaling detected exactly.
Limits
- No animation playback: frames are stepped, not played.
- Everything runs on the main thread with no cancel button; very large batches block the tab until they finish.
- Verified in Chromium only.
- Frames larger than 4096 × 4096 are refused: every pass runs on the main thread, and there is no cancel button yet.
- A sprite sheet is treated as one image; cut it into frames first (sprite sheet cutter).
- With a dithering mode on, the cleanup counts include dither pixels; set dithering to None first.
- The exported JSON is generic; importing it into Godot or Unity was not tested.
Versions and sources
Nerulio: docs/PIXEL-LAB.md, measured in Chromium through Playwright on a built site, with every number read back from the downloaded files (Pillow, zipfile, json); unit tests in tests/game-palette.test.mjs. The .gpl written follows the GIMP palette format.
Official documentation used
Questions
Why does my animation flicker after dithering?
Error diffusion carries error across the whole image, so one moving pixel changes later decisions. Ordered (Bayer) dithering depends only on position and does not flicker; the Lab defaults to None.
Can I use a Lospec palette?
Yes: paste or import a Lospec HEX list, .gpl or JSON, and the frames lock to it.
Is it free, and do I need an account?
Everything on these pages is free and works without an account. There is no watermark and no export that is held back.
Are my sprites uploaded?
No. The Studio decodes, packs and exports in this browser tab. Projects are autosaved in the browser (IndexedDB) and can be saved as a .nerulio file.
Does it need Aseprite or an engine plugin installed?
No. .aseprite files are read and written in the browser, and each engine bundle carries what that engine needs (for Godot, the import settings and scene; for Unity and the Godot TileSet, a small editor script).