Skip to content

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.

Drop your sprite frames (PNG) — several frames share one palette
Open the Pixel Lab
Runs in your browser. Files are not uploaded.
Pixel Lab: CC0 sprite frames locked to one extracted palette, with the palette and the share of the pixels in each colour
The real Pixel Lab with a CC0 asset (OpenGameArt).
Outputs
  • 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 px

The 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

  1. Drop the frames.
  2. Choose the colour count or a classic palette.
  3. Pick a dither mode (None is the default for animations).
  4. 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 .gpl in GIMP or Aseprite: it lists the same colours in the same order.

Troubleshooting

SymptomLikely causeHow to checkFix
The animation flickers after lockingAn error-diffusion dither (Floyd–Steinberg or Atkinson) is onThe Convert stage shows the flicker warningSwitch to None or a Bayer matrix; compare them side by side in the dither grid
A small detail colour disappearedMedian cut weights colours by how many pixels use them; a 2-pixel eye highlight loses to large areasLook for the colour in the Palette stage after extractionLock that colour, then press Extract again, or raise the colour count
A dropped sprite sheet came out as one frameThe Lab treats one image as one frame; it does not slice sheetsThe frame strip shows a single frameSlice the sheet first with the sprite slicer, then drop the frames
The Lab refuses an imageA frame larger than 4096 × 4096 px would freeze the tab, so it is refusedThe message names the size and the limitScale or crop it first, or slice it into frames
The colour counts cover only one frameAbove 4 MP of frame area in total, counting is limited to the visible frameThe panel says so next to the countsLoad fewer frames at once if you need exact totals

Other ways to do it

OptionWhen it is the better choice
Converting inside the Studio's Pixel workspaceThe 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 oneThe art must match a known palette such as PICO-8 or Endesga 32: see applying a Lospec palette.
Cleaning anti-aliased edges and stray pixelsLocking 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.

TargetFilesCheck
PNG framesanim_N.png · 1×–8× nearestMeasured PillowEvery exported colour re-read from the PNGs lies inside the locked palette.
Palette.gpl · .hex (Lospec) · .jsonMeasured re-importEach format was exported and imported back through the UI: 16 → 16 colours.
pixel-lab.jsongeneric frames + paletteUNVERIFIEDGeneric 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).