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.

5 min readSume
All posts

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.

Grid and palette presets, Pillow arguments read 2026-10-05
LookGridcolors=Upscale factor to 512
Console icon64x64168
Chunky sprite32x32816
Detailed scene tile128x128324

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

All Media tools posts

Written by Sume