mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
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:
parent
abfc1847b7
commit
d40a828a8b
7 changed files with 955 additions and 131 deletions
162
skills/creative/pixel-art/scripts/pixel_art.py
Normal file
162
skills/creative/pixel-art/scripts/pixel_art.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue