mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 01:11: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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue