hermes-agent/skills/creative/pixel-art/scripts/pixel_art.py
Teknium d40a828a8b
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).
2026-04-19 16:59:20 -07:00

162 lines
5.7 KiB
Python

"""Pixel art converter — Floyd-Steinberg dithering with preset or named palette.
Named hardware palettes (NES, GameBoy, PICO-8, C64, etc.) ported from
pixel-art-studio (MIT) — see ATTRIBUTION.md.
Usage (import):
from pixel_art import pixel_art
pixel_art("in.png", "out.png", preset="arcade")
pixel_art("in.png", "out.png", preset="nes")
pixel_art("in.png", "out.png", palette="PICO_8", block=6)
Usage (CLI):
python pixel_art.py in.png out.png --preset nes
"""
from PIL import Image, ImageEnhance, ImageOps
try:
from .palettes import PALETTES, build_palette_image
except ImportError:
from palettes import PALETTES, build_palette_image
PRESETS = {
# ── Original presets (adaptive palette) ─────────────────────────────
"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,
},
# ── Hardware-accurate presets (named palette) ───────────────────────
"nes": {
"contrast": 1.5, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "NES",
},
"gameboy": {
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_ORIGINAL",
},
"gameboy_pocket": {
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_POCKET",
},
"pico8": {
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
"posterize_bits": 6, "block": 6, "palette": "PICO_8",
},
"c64": {
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "C64",
},
"apple2": {
"contrast": 1.8, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 5, "block": 10, "palette": "APPLE_II_HI",
},
"teletext": {
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
"posterize_bits": 5, "block": 10, "palette": "TELETEXT",
},
"mspaint": {
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "MICROSOFT_WINDOWS_PAINT",
},
"mono_green": {
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
"posterize_bits": 5, "block": 6, "palette": "MONO_GREEN",
},
"mono_amber": {
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
"posterize_bits": 5, "block": 6, "palette": "MONO_AMBER",
},
# ── Artistic palette presets ────────────────────────────────────────
"neon": {
"contrast": 1.8, "color": 1.6, "sharpness": 1.2,
"posterize_bits": 5, "block": 6, "palette": "NEON_CYBER",
},
"pastel": {
"contrast": 1.2, "color": 1.3, "sharpness": 1.1,
"posterize_bits": 6, "block": 6, "palette": "PASTEL_DREAM",
},
}
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: one of PRESETS (arcade, snes, nes, gameboy, pico8, c64, ...)
**overrides: optionally override any preset field. In particular:
palette: int (adaptive N colors) OR str (named palette from PALETTES)
block: int pixel block size
contrast / color / sharpness / posterize_bits: numeric enhancers
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")
img = ImageEnhance.Contrast(img).enhance(cfg["contrast"])
img = ImageEnhance.Color(img).enhance(cfg["color"])
img = ImageEnhance.Sharpness(img).enhance(cfg["sharpness"])
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 downscale so Floyd-Steinberg aligns with final pixel grid.
pal = cfg["palette"]
if isinstance(pal, str):
# Named hardware/artistic palette
pal_img = build_palette_image(pal)
quantized = small.quantize(palette=pal_img, dither=Image.FLOYDSTEINBERG)
else:
# Adaptive N-color palette (original behavior)
quantized = small.quantize(colors=int(pal), dither=Image.FLOYDSTEINBERG)
result = quantized.resize((w, h), Image.NEAREST)
result.save(output_path, "PNG")
return result
def main():
import argparse
p = argparse.ArgumentParser(description="Convert image to pixel art.")
p.add_argument("input")
p.add_argument("output")
p.add_argument("--preset", default="arcade", choices=sorted(PRESETS))
p.add_argument("--palette", default=None,
help=f"Override palette: int or name from {sorted(PALETTES)}")
p.add_argument("--block", type=int, default=None)
args = p.parse_args()
overrides = {}
if args.palette is not None:
try:
overrides["palette"] = int(args.palette)
except ValueError:
overrides["palette"] = args.palette
if args.block is not None:
overrides["block"] = args.block
pixel_art(args.input, args.output, preset=args.preset, **overrides)
print(f"Wrote {args.output}")
if __name__ == "__main__":
main()