True pixel art from an AI image: quantize to 16 colors in Pillow
AI models draw pixel-art looks at full resolution. Downscale with NEAREST, quantize to 16 colors without dither, and upscale clean to get real pixel art.
Generate the picture on Sume, shrink it to a small grid such as 64 by 64 with Image.NEAREST, call quantize(colors=16, dither=Image.Dither.NONE), and scale it back up with NEAREST. The result has hard pixel edges and exactly 16 palette entries, which a model prompted for pixel art almost never delivers on its own, because it paints soft gradients inside each fake pixel.
Why prompts alone are not enough
An image model has no pixel grid. Ask for 16-bit pixel art and you get an image that looks like it, with anti-aliased edges, in-between colors, and blocks that wobble in size. For a game sprite or an icon that has to sit on a real grid, that is a defect.
The fix is to impose the grid after generation. It costs nothing, so the model call is the only cost: one ChatGPT Image 2.5 or Seedream call per source image.
The three operations
Order matters. Downscale first so each remaining pixel is one block. Quantize at the small size, where there are few pixels to map. Upscale last, with nearest-neighbor, so every block becomes a crisp square. If you quantize at full size and then shrink, the palette is already smeared.
import os, io, requests
from PIL import Image
H = {"Authorization": "Bearer " + os.environ["SUME_API_KEY"]}
def gen(**body):
r = requests.post("https://api.sume.com/v1/images", json=body, headers=H, timeout=60)
r.raise_for_status()
if r.status_code == 202:
raise SystemExit("queued, read /v1/jobs/{id}/result: " + r.text)
return r.json()
def fetch(u):
return Image.open(io.BytesIO(requests.get(u, timeout=60).content))
out = gen(model="bytedance-seed/seedream-4.5", aspect_ratio="1:1",
prompt="Pixel art treasure chest, bold outlines, flat colors, plain background")
src = fetch(out["data"][0]["url"]).convert("RGB")
small = src.resize((64, 64), Image.NEAREST)
pal = small.quantize(colors=16, dither=Image.Dither.NONE)
big = pal.resize((512, 512), Image.NEAREST)
big.convert("RGB").save("chest_16c.png")
used = len(set(pal.getdata()))
print("grid", small.size, "palette entries used", used, "cost", out["usage"]["cost"])Choosing grid and palette
Grid size sets the detail, and palette size sets the style. A 64 pixel grid with 16 colors reads as a console icon. Dropping to 32 pixels and 8 colors reads as a phone-era sprite.
| Look | Grid | colors= | Upscale factor to 512 |
|---|---|---|---|
| Console icon | 64x64 | 16 | 8 |
| Chunky sprite | 32x32 | 8 | 16 |
| Detailed scene tile | 128x128 | 32 | 4 |
Pitfalls
- Leave dither off. The default dither scatters pixels and breaks the flat-color look.
- Plain backgrounds quantize cleanly. A noisy background eats palette slots.
- Save as PNG. JPEG will reintroduce soft edges around every block, as the PNG-between-passes note explains.
- Resize with NEAREST both ways. LANCZOS blurs blocks.
Where to go next
For animation, ask for a grid of poses and slice it, as in the sprite sheet recipe. For a sprite on a transparent background, request one from the model with the transparent PNG option and quantize the RGB channels separately from the alpha, so the outline keeps a hard edge.
Sources
Related posts
More in Media tools
- Join TTS lines into one track: match output_format before concat
Sume timeline audio concat joins 1 to 20 parts but needs one channel layout. Set the same TTS output_format on every line, then concat once.
- TTS mp3 bit rate: 32k to 192k file size per minute for voice audio
Sume TTS 1.0 accepts mp3 bit_rate 32000, 64000, 96000, 128000 or 192000. See the size per minute of each, plus a two-take listening test before you pick one.
- Two-color duotone poster from an AI image with ImageOps.colorize
Turn a Sume image into a duotone: grayscale, autocontrast, then ImageOps.colorize with a dark and a light brand color. Code and ink pairs inside.
- Fades between 2-second AI shots: the 1 s cap and the 8-fade chain
Joining 15 two-second Wan 3.0 shots on Sume Timeline: a fade can be at most 1 s, at most half of the shorter shot, and 8 chained fades are refused. Python.
Written by Sume