feat(pixel-art): add hardware palettes and video animation (#12725)

Expand the pixel-art skill from 2 presets (arcade, snes) to 14 presets
with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, Apple II,
MS Paint, CRT mono), plus a procedural video overlay pipeline.

Ported from Synero/pixel-art-studio (MIT). Full attribution in
ATTRIBUTION.md.

What's in:
- scripts/palettes.py — 28 named RGB palettes (hardware + artistic)
- scripts/pixel_art.py — 14 presets, named palette support, CLI
- scripts/pixel_art_video.py — 12 animation scenes (stars, rain,
  fireflies, snow, embers, lightning, etc.) → MP4/GIF via ffmpeg
- references/palettes.md — palette catalog
- SKILL.md — clarify-tool workflow (offer style, then optional scene)

What's out (intentional):
- Wu's quantizer (PIL's built-in quantize suffices)
- Sobel edge-aware downsample (scipy dep not worth it)
- Atkinson/Bayer dither (would need numpy reimpl)
- Pollinations text-to-image (Hermes uses image_generate instead)

Video pipeline uses subprocess.run with check=True (replaces os.system)
and tempfile.TemporaryDirectory (replaces manual cleanup).
This commit is contained in:
Teknium 2026-04-19 16:59:20 -07:00 committed by GitHub
parent abfc1847b7
commit d40a828a8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 955 additions and 131 deletions

View file

@ -1,170 +1,217 @@
---
name: pixel-art
description: Convert images into retro pixel art using named presets (arcade, snes) with Floyd-Steinberg dithering. Arcade is bold and chunky; SNES is cleaner with more detail retention.
version: 1.2.0
description: Convert images into retro pixel art with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, etc.), and animate them into short videos. Presets cover arcade, SNES, and 10+ era-correct looks. Use `clarify` to let the user pick a style before generating.
version: 2.0.0
author: dodo-reach
license: MIT
metadata:
hermes:
tags: [creative, pixel-art, arcade, snes, retro, image]
tags: [creative, pixel-art, arcade, snes, nes, gameboy, retro, image, video]
category: creative
credits:
- "Hardware palettes and animation loops ported from Synero/pixel-art-studio (MIT) — https://github.com/Synero/pixel-art-studio"
---
# Pixel Art
Convert any image into retro-style pixel art. One function with named presets that select different aesthetics:
Convert any image into retro pixel art, then optionally animate it into a short
MP4 or GIF with era-appropriate effects (rain, fireflies, snow, embers).
- `arcade` — 16-color palette, 8px blocks. Bold, chunky, high-impact. 80s/90s arcade cabinet feel.
- `snes` — 32-color palette, 4px blocks. Cleaner 16-bit console look with more detail retention.
Two scripts ship with this skill:
The core pipeline is identical across presets — what changes is palette size, block size, and the strength of contrast/color/posterize pre-processing. All presets use Floyd-Steinberg dithering applied AFTER downscale so error diffusion aligns with the final pixel grid.
- `scripts/pixel_art.py` — photo → pixel-art PNG (Floyd-Steinberg dithering)
- `scripts/pixel_art_video.py` — pixel-art PNG → animated MP4 (+ optional GIF)
Each is importable or runnable directly. Presets snap to hardware palettes
when you want era-accurate colors (NES, Game Boy, PICO-8, etc.), or use
adaptive N-color quantization for arcade/SNES-style looks.
## When to Use
- User wants retro pixel art from a source image
- Posters, album covers, social posts, sprites, characters, backgrounds
- Subject can tolerate aggressive simplification (arcade) or benefits from retained detail (snes)
- User asks for NES / Game Boy / PICO-8 / C64 / arcade / SNES styling
- User wants a short looping animation (rain scene, night sky, snow, etc.)
- Posters, album covers, social posts, sprites, characters, avatars
## Preset Picker
## Workflow
| Preset | Palette | Block | Best for |
|--------|---------|-------|----------|
| `arcade` | 16 colors | 8px | Posters, hero images, bold covers, simple subjects |
| `snes` | 32 colors | 4px | Characters, sprites, detailed illustrations, photos |
Before generating, confirm the style with the user. Different presets produce
very different outputs and regenerating is costly.
Default is `arcade` for maximum stylistic punch. Switch to `snes` when the subject has detail worth preserving.
### Step 1 — Offer a style
## Procedure
Call `clarify` with 4 representative presets. Pick the set based on what the
user asked for — don't just dump all 14.
1. Pick a preset (`arcade` or `snes`) based on the aesthetic you want.
2. Boost contrast, color, and sharpness using the preset's enhancement values.
3. Lightly posterize the image to simplify tonal regions before quantization.
4. Downscale to `w // block` by `h // block` with `Image.NEAREST`.
5. Quantize the reduced image to the preset's palette size with Floyd-Steinberg dithering.
6. Upscale back to the original size with `Image.NEAREST`.
7. Save the output as PNG.
## Code
Default menu when the user's intent is unclear:
```python
from PIL import Image, ImageEnhance, ImageOps
PRESETS = {
"arcade": {
"contrast": 1.8,
"color": 1.5,
"sharpness": 1.2,
"posterize_bits": 5,
"block": 8,
"palette": 16,
},
"snes": {
"contrast": 1.6,
"color": 1.4,
"sharpness": 1.2,
"posterize_bits": 6,
"block": 4,
"palette": 32,
},
}
def pixel_art(input_path, output_path, preset="arcade", **overrides):
"""
Convert an image to retro pixel art.
Args:
input_path: path to source image
output_path: path to save the resulting PNG
preset: "arcade" or "snes"
**overrides: optionally override any preset field
(contrast, color, sharpness, posterize_bits, block, palette)
Returns:
The resulting PIL.Image.
"""
if preset not in PRESETS:
raise ValueError(
f"Unknown preset {preset!r}. Choose from: {sorted(PRESETS)}"
)
cfg = {**PRESETS[preset], **overrides}
img = Image.open(input_path).convert("RGB")
# Stylistic boost — stronger for smaller palettes
img = ImageEnhance.Contrast(img).enhance(cfg["contrast"])
img = ImageEnhance.Color(img).enhance(cfg["color"])
img = ImageEnhance.Sharpness(img).enhance(cfg["sharpness"])
# Light posterization separates tonal regions before quantization
img = ImageOps.posterize(img, cfg["posterize_bits"])
w, h = img.size
block = cfg["block"]
small = img.resize(
(max(1, w // block), max(1, h // block)),
Image.NEAREST,
)
# Quantize AFTER downscaling so dithering aligns with the final pixel grid
quantized = small.quantize(
colors=cfg["palette"], dither=Image.FLOYDSTEINBERG
)
result = quantized.resize((w, h), Image.NEAREST)
result.save(output_path, "PNG")
return result
```
## Example Usage
```python
# Bold arcade look (default)
pixel_art("/path/to/image.jpg", "/path/to/arcade.png")
# Cleaner SNES look with more detail
pixel_art("/path/to/image.jpg", "/path/to/snes.png", preset="snes")
# Override individual parameters — e.g. tighter palette with SNES block size
pixel_art(
"/path/to/image.jpg",
"/path/to/custom.png",
preset="snes",
palette=16,
clarify(
question="Which pixel-art style do you want?",
choices=[
"arcade — bold, chunky 80s cabinet feel (16 colors, 8px)",
"nes — Nintendo 8-bit hardware palette (54 colors, 8px)",
"gameboy — 4-shade green Game Boy DMG",
"snes — cleaner 16-bit look (32 colors, 4px)",
],
)
```
## Why This Order Works
When the user already named an era (e.g. "80s arcade", "Gameboy"), skip
`clarify` and use the matching preset directly.
Floyd-Steinberg dithering distributes quantization error to adjacent pixels. Applying it AFTER downscaling keeps that error diffusion aligned with the reduced pixel grid, so each dithered pixel maps cleanly to a final enlarged block. Quantizing before downscaling wastes the dithering pattern on full-resolution detail that disappears during resize.
### Step 2 — Offer animation (optional)
A light posterization step before downscaling improves separation between tonal regions, which helps photographic inputs read as stylized pixel art instead of simple pixelated photos.
If the user asked for a video/GIF, or the output might benefit from motion,
ask which scene:
Stronger pre-processing (higher contrast/color) pairs with smaller palettes because fewer colors have to carry the whole image. SNES runs softer enhancements because 32 colors can represent gradients and mid-tones directly.
```python
clarify(
question="Want to animate it? Pick a scene or skip.",
choices=[
"night — stars + fireflies + leaves",
"urban — rain + neon pulse",
"snow — falling snowflakes",
"skip — just the image",
],
)
```
## Pitfalls
Do NOT call `clarify` more than twice in a row. One for style, one for scene if
animation is on the table. If the user explicitly asked for a specific style
and scene in their message, skip `clarify` entirely.
- `arcade` 8px blocks are aggressive and can destroy fine detail — use `snes` for subjects that need retention
- Busy photographs can become noisy under `snes` because the larger palette preserves small variations — use `arcade` to flatten them
- Very small source images (<~100px wide) may collapse under 8px blocks. `max(1, w // block)` guards against zero dimensions, but output will be visually degenerate.
- Fractional overrides for `block` or `palette` will break quantization — keep them as positive integers.
### Step 3 — Generate
## Verification
Run `pixel_art()` first; if animation was requested, chain into
`pixel_art_video()` on the result.
Output is correct if:
## Preset Catalog
- A PNG file is created at the output path
- The image shows clear square pixel blocks at the preset's block size
- Dithering is visible in gradients
- The palette is limited to approximately the preset's color count
- The overall look matches the targeted era (arcade or SNES)
| Preset | Era | Palette | Block | Best for |
|--------|-----|---------|-------|----------|
| `arcade` | 80s arcade | adaptive 16 | 8px | Bold posters, hero art |
| `snes` | 16-bit | adaptive 32 | 4px | Characters, detailed scenes |
| `nes` | 8-bit | NES (54) | 8px | True NES look |
| `gameboy` | DMG handheld | 4 green shades | 8px | Monochrome Game Boy |
| `gameboy_pocket` | Pocket handheld | 4 grey shades | 8px | Mono GB Pocket |
| `pico8` | PICO-8 | 16 fixed | 6px | Fantasy-console look |
| `c64` | Commodore 64 | 16 fixed | 8px | 8-bit home computer |
| `apple2` | Apple II hi-res | 6 fixed | 10px | Extreme retro, 6 colors |
| `teletext` | BBC Teletext | 8 pure | 10px | Chunky primary colors |
| `mspaint` | Windows MS Paint | 24 fixed | 8px | Nostalgic desktop |
| `mono_green` | CRT phosphor | 2 green | 6px | Terminal/CRT aesthetic |
| `mono_amber` | CRT amber | 2 amber | 6px | Amber monitor look |
| `neon` | Cyberpunk | 10 neons | 6px | Vaporwave/cyber |
| `pastel` | Soft pastel | 10 pastels | 6px | Kawaii / gentle |
Named palettes live in `scripts/palettes.py` (see `references/palettes.md` for
the complete list — 28 named palettes total). Any preset can be overridden:
```python
pixel_art("in.png", "out.png", preset="snes", palette="PICO_8", block=6)
```
## Scene Catalog (for video)
| Scene | Effects |
|-------|---------|
| `night` | Twinkling stars + fireflies + drifting leaves |
| `dusk` | Fireflies + sparkles |
| `tavern` | Dust motes + warm sparkles |
| `indoor` | Dust motes |
| `urban` | Rain + neon pulse |
| `nature` | Leaves + fireflies |
| `magic` | Sparkles + fireflies |
| `storm` | Rain + lightning |
| `underwater` | Bubbles + light sparkles |
| `fire` | Embers + sparkles |
| `snow` | Snowflakes + sparkles |
| `desert` | Heat shimmer + dust |
## Invocation Patterns
### Python (import)
```python
import sys
sys.path.insert(0, "/home/teknium/.hermes/skills/creative/pixel-art/scripts")
from pixel_art import pixel_art
from pixel_art_video import pixel_art_video
# 1. Convert to pixel art
pixel_art("/path/to/photo.jpg", "/tmp/pixel.png", preset="nes")
# 2. Animate (optional)
pixel_art_video(
"/tmp/pixel.png",
"/tmp/pixel.mp4",
scene="night",
duration=6,
fps=15,
seed=42,
export_gif=True,
)
```
### CLI
```bash
cd /home/teknium/.hermes/skills/creative/pixel-art/scripts
python pixel_art.py in.jpg out.png --preset gameboy
python pixel_art.py in.jpg out.png --preset snes --palette PICO_8 --block 6
python pixel_art_video.py out.png out.mp4 --scene night --duration 6 --gif
```
## Pipeline Rationale
**Pixel conversion:**
1. Boost contrast/color/sharpness (stronger for smaller palettes)
2. Posterize to simplify tonal regions before quantization
3. Downscale by `block` with `Image.NEAREST` (hard pixels, no interpolation)
4. Quantize with Floyd-Steinberg dithering — against either an adaptive
N-color palette OR a named hardware palette
5. Upscale back with `Image.NEAREST`
Quantizing AFTER downscale keeps dithering aligned with the final pixel grid.
Quantizing before would waste error-diffusion on detail that disappears.
**Video overlay:**
- Copies the base frame each tick (static background)
- Overlays stateless-per-frame particle draws (one function per effect)
- Encodes via ffmpeg `libx264 -pix_fmt yuv420p -crf 18`
- Optional GIF via `palettegen` + `paletteuse`
## Dependencies
- Python 3
- Pillow
- Python 3.9+
- Pillow (`pip install Pillow`)
- ffmpeg on PATH (only needed for video — Hermes installs package this)
```bash
pip install Pillow
```
## Pitfalls
- Pallet keys are case-sensitive (`"NES"`, `"PICO_8"`, `"GAMEBOY_ORIGINAL"`).
- Very small sources (<100px wide) collapse under 8-10px blocks. Upscale the
source first if it's tiny.
- Fractional `block` or `palette` will break quantization — keep them positive ints.
- Animation particle counts are tuned for ~640x480 canvases. On very large
images you may want a second pass with a different seed for density.
- `mono_green` / `mono_amber` force `color=0.0` (desaturate). If you override
and keep chroma, the 2-color palette can produce stripes on smooth regions.
- `clarify` loop: call it at most twice per turn (style, then scene). Don't
pepper the user with more picks.
## Verification
- PNG is created at the output path
- Clear square pixel blocks visible at the preset's block size
- Color count matches preset (eyeball the image or run `Image.open(p).getcolors()`)
- Video is a valid MP4 (`ffprobe` can open it) with non-zero size
## Attribution
Named hardware palettes and the procedural animation loops in `pixel_art_video.py`
are ported from [pixel-art-studio](https://github.com/Synero/pixel-art-studio)
(MIT). See `ATTRIBUTION.md` in this skill directory for details.