diff --git a/skills/creative/pixel-art/ATTRIBUTION.md b/skills/creative/pixel-art/ATTRIBUTION.md new file mode 100644 index 000000000..20bb126b6 --- /dev/null +++ b/skills/creative/pixel-art/ATTRIBUTION.md @@ -0,0 +1,54 @@ +# Attribution + +This skill bundles code ported from a third-party MIT-licensed project. +All reuse is credited here. + +## pixel-art-studio (Synero) + +- Source: https://github.com/Synero/pixel-art-studio +- License: MIT +- Copyright: © Synero, MIT-licensed contributors + +### What was ported + +**`scripts/palettes.py`** — the `PALETTES` dict containing 23 named RGB +palettes (hardware and artistic). Values are reproduced verbatim from +`scripts/pixelart.py` of pixel-art-studio. + +**`scripts/pixel_art_video.py`** — the 12 procedural animation init/draw pairs +(`stars`, `fireflies`, `leaves`, `dust_motes`, `sparkles`, `rain`, +`lightning`, `bubbles`, `embers`, `snowflakes`, `neon_pulse`, `heat_shimmer`) +and the `SCENES` → layer mapping. Ported from `scripts/pixelart_video.py` +with minor refactors: +- Names prefixed with `_` for private helpers (`_px`, `_pixel_cross`) +- `SCENE_ANIMATIONS` renamed to `SCENES` and restructured to hold layer + names (strings) instead of function-name strings resolved via `globals()` +- `generate_video()` split: the Pollinations text-to-image call was removed + (Hermes uses its own `image_generate` + `pixel_art()` pipeline for base + frames). Only the overlay + ffmpeg encoding remains. +- Frame directory is now a `tempfile.TemporaryDirectory` instead of + hand-managed cleanup. +- `ffmpeg` invocation switched from `os.system` to `subprocess.run(check=True)` + for safety. + +### What was NOT ported + +- Wu's Color Quantization (PIL's built-in `quantize` suffices) +- Sobel edge-aware downsampling (requires scipy; not worth the dep) +- Bayer / Atkinson dither (would need numpy reimplementation; kept scope tight) +- Pollinations text-to-image generation (`pixelart_image.py`, + `generate_base()` in `pixelart_video.py`) — Hermes has `image_generate` + +### License compatibility + +pixel-art-studio ships under the MIT License, which permits redistribution +with attribution. This skill preserves the original copyright notice here +and in the SKILL.md credits block. No code was relicensed. + +--- + +## pixel-art skill itself + +- License: MIT (inherits from hermes-agent repo) +- Original author of the skill shell: dodo-reach +- Expansion with palettes + video: Hermes Agent contributors diff --git a/skills/creative/pixel-art/SKILL.md b/skills/creative/pixel-art/SKILL.md index 96e1e4f10..e123fc632 100644 --- a/skills/creative/pixel-art/SKILL.md +++ b/skills/creative/pixel-art/SKILL.md @@ -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. diff --git a/skills/creative/pixel-art/references/palettes.md b/skills/creative/pixel-art/references/palettes.md new file mode 100644 index 000000000..6902ecb74 --- /dev/null +++ b/skills/creative/pixel-art/references/palettes.md @@ -0,0 +1,49 @@ +# Named Palettes + +28 hardware-accurate and artistic palettes available to `pixel_art()`. +Palette values are sourced from `pixel-art-studio` (MIT) — see ATTRIBUTION.md in the skill root. + +Usage: pass the palette name as `palette=` or let a preset select it. + +```python +pixel_art("in.png", "out.png", preset="nes") # preset selects NES +pixel_art("in.png", "out.png", preset="custom", palette="PICO_8", block=6) +``` + +## Hardware Palettes + +| Name | Colors | Source | +|------|--------|--------| +| `NES` | 54 | Nintendo NES | +| `C64` | 16 | Commodore 64 | +| `COMMODORE_64` | 16 | Commodore 64 (alt) | +| `ZX_SPECTRUM` | 8 | Sinclair ZX Spectrum | +| `APPLE_II_LO` | 16 | Apple II lo-res | +| `APPLE_II_HI` | 6 | Apple II hi-res | +| `GAMEBOY_ORIGINAL` | 4 | Game Boy DMG (green) | +| `GAMEBOY_POCKET` | 4 | Game Boy Pocket (grey) | +| `GAMEBOY_VIRTUALBOY` | 4 | Virtual Boy (red) | +| `PICO_8` | 16 | PICO-8 fantasy console | +| `TELETEXT` | 8 | BBC Teletext | +| `CGA_MODE4_PAL1` | 4 | IBM CGA | +| `MSX` | 15 | MSX | +| `MICROSOFT_WINDOWS_16` | 16 | Windows 3.x default | +| `MICROSOFT_WINDOWS_PAINT` | 24 | MS Paint classic | +| `MONO_BW` | 2 | Black and white | +| `MONO_AMBER` | 2 | Amber monochrome | +| `MONO_GREEN` | 2 | Green monochrome | + +## Artistic Palettes + +| Name | Colors | Feel | +|------|--------|------| +| `PASTEL_DREAM` | 10 | Soft pastels | +| `NEON_CYBER` | 10 | Cyberpunk neon | +| `RETRO_WARM` | 10 | Warm 70s | +| `OCEAN_DEEP` | 10 | Blue gradient | +| `FOREST_MOSS` | 10 | Green naturals | +| `SUNSET_FIRE` | 10 | Red to yellow | +| `ARCTIC_ICE` | 10 | Cool blues and whites | +| `VINTAGE_ROSE` | 10 | Rose mauves | +| `EARTH_CLAY` | 10 | Terracotta browns | +| `ELECTRIC_VIOLET` | 10 | Violet gradient | diff --git a/skills/creative/pixel-art/scripts/__init__.py b/skills/creative/pixel-art/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/skills/creative/pixel-art/scripts/palettes.py b/skills/creative/pixel-art/scripts/palettes.py new file mode 100644 index 000000000..adf0f1b34 --- /dev/null +++ b/skills/creative/pixel-art/scripts/palettes.py @@ -0,0 +1,167 @@ +"""Named RGB palettes for pixel_art() and pixel_art_video(). + +Palette RGB values sourced from pixel-art-studio (MIT License) +https://github.com/Synero/pixel-art-studio — see ATTRIBUTION.md. +""" + +PALETTES = { + # ── Hardware palettes ─────────────────────────────────────────────── + "NES": [ + (0, 0, 0), (124, 124, 124), (0, 0, 252), (0, 0, 188), (68, 40, 188), + (148, 0, 132), (168, 0, 32), (168, 16, 0), (136, 20, 0), (0, 116, 0), + (0, 148, 0), (0, 120, 0), (0, 88, 0), (0, 64, 88), (188, 188, 188), + (0, 120, 248), (0, 88, 248), (104, 68, 252), (216, 0, 204), (228, 0, 88), + (248, 56, 0), (228, 92, 16), (172, 124, 0), (0, 184, 0), (0, 168, 0), + (0, 168, 68), (0, 136, 136), (248, 248, 248), (60, 188, 252), + (104, 136, 252), (152, 120, 248), (248, 120, 248), (248, 88, 152), + (248, 120, 88), (252, 160, 68), (248, 184, 0), (184, 248, 24), + (88, 216, 84), (88, 248, 152), (0, 232, 216), (120, 120, 120), + (252, 252, 252), (164, 228, 252), (184, 184, 248), (216, 184, 248), + (248, 184, 248), (248, 164, 192), (240, 208, 176), (252, 224, 168), + (248, 216, 120), (216, 248, 120), (184, 248, 184), (184, 248, 216), + (0, 252, 252), (216, 216, 216), + ], + "C64": [ + (0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 191, 199), + (161, 87, 164), (92, 172, 95), (64, 64, 223), (191, 206, 137), + (161, 104, 60), (108, 80, 21), (203, 126, 117), (98, 98, 98), + (137, 137, 137), (154, 226, 155), (124, 124, 255), (173, 173, 173), + ], + "COMMODORE_64": [ + (0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 192, 200), + (161, 87, 165), (92, 172, 95), (64, 68, 227), (203, 214, 137), + (163, 104, 58), (110, 84, 11), (204, 127, 118), (99, 99, 99), + (139, 139, 139), (154, 227, 157), (139, 127, 205), (175, 175, 175), + ], + "ZX_SPECTRUM": [ + (0, 0, 0), (0, 39, 251), (252, 48, 22), (255, 63, 252), + (0, 249, 44), (0, 252, 254), (255, 253, 51), (255, 255, 255), + ], + "APPLE_II_LO": [ + (0, 0, 0), (133, 59, 81), (80, 71, 137), (234, 93, 240), + (0, 104, 82), (146, 146, 146), (0, 168, 241), (202, 195, 248), + (81, 92, 15), (235, 127, 35), (146, 146, 146), (246, 185, 202), + (0, 202, 41), (203, 211, 155), (155, 220, 203), (255, 255, 255), + ], + "APPLE_II_HI": [ + (0, 0, 0), (255, 0, 255), (0, 255, 0), (255, 255, 255), + (0, 175, 255), (255, 80, 0), + ], + "GAMEBOY_ORIGINAL": [ + (0, 63, 0), (46, 115, 32), (140, 191, 10), (160, 207, 10), + ], + "GAMEBOY_POCKET": [ + (0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255), + ], + "GAMEBOY_VIRTUALBOY": [ + (239, 0, 0), (164, 0, 0), (85, 0, 0), (0, 0, 0), + ], + "PICO_8": [ + (0, 0, 0), (29, 43, 83), (126, 37, 83), (0, 135, 81), (171, 82, 54), + (95, 87, 79), (194, 195, 199), (255, 241, 232), (255, 0, 77), + (255, 163, 0), (255, 236, 39), (0, 228, 54), (41, 173, 255), + (131, 118, 156), (255, 119, 168), (255, 204, 170), + ], + "TELETEXT": [ + (0, 0, 0), (255, 0, 0), (0, 128, 0), (255, 255, 0), + (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), + ], + "CGA_MODE4_PAL1": [ + (0, 0, 0), (255, 255, 255), (0, 255, 255), (255, 0, 255), + ], + "MSX": [ + (0, 0, 0), (62, 184, 73), (116, 208, 125), (89, 85, 224), + (128, 118, 241), (185, 94, 81), (101, 219, 239), (219, 101, 89), + (255, 137, 125), (204, 195, 94), (222, 208, 135), (58, 162, 65), + (183, 102, 181), (204, 204, 204), (255, 255, 255), + ], + "MICROSOFT_WINDOWS_16": [ + (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), + (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), + (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), + (255, 0, 255), (0, 255, 255), (255, 255, 255), + ], + "MICROSOFT_WINDOWS_PAINT": [ + (0, 0, 0), (255, 255, 255), (123, 123, 123), (189, 189, 189), + (123, 12, 2), (255, 37, 0), (123, 123, 2), (255, 251, 2), + (0, 123, 2), (2, 249, 2), (0, 123, 122), (2, 253, 254), + (2, 19, 122), (5, 50, 255), (123, 25, 122), (255, 64, 254), + (122, 57, 2), (255, 122, 57), (123, 123, 56), (255, 252, 122), + (2, 57, 57), (5, 250, 123), (0, 123, 255), (255, 44, 123), + ], + "MONO_BW": [(0, 0, 0), (255, 255, 255)], + "MONO_AMBER": [(40, 40, 40), (255, 176, 0)], + "MONO_GREEN": [(40, 40, 40), (51, 255, 51)], + + # ── Artistic palettes ─────────────────────────────────────────────── + "PASTEL_DREAM": [ + (255, 218, 233), (255, 229, 204), (255, 255, 204), (204, 255, 229), + (204, 229, 255), (229, 204, 255), (255, 204, 229), (204, 255, 255), + (255, 245, 220), (230, 230, 250), + ], + "NEON_CYBER": [ + (0, 0, 0), (255, 0, 128), (0, 255, 255), (255, 0, 255), + (0, 255, 128), (255, 255, 0), (128, 0, 255), (255, 128, 0), + (0, 128, 255), (255, 255, 255), + ], + "RETRO_WARM": [ + (62, 39, 35), (139, 69, 19), (210, 105, 30), (244, 164, 96), + (255, 218, 185), (255, 245, 238), (178, 34, 34), (205, 92, 92), + (255, 99, 71), (255, 160, 122), + ], + "OCEAN_DEEP": [ + (0, 25, 51), (0, 51, 102), (0, 76, 153), (0, 102, 178), + (0, 128, 204), (51, 153, 204), (102, 178, 204), (153, 204, 229), + (204, 229, 255), (229, 245, 255), + ], + "FOREST_MOSS": [ + (34, 51, 34), (51, 76, 51), (68, 102, 51), (85, 128, 68), + (102, 153, 85), (136, 170, 102), (170, 196, 136), (204, 221, 170), + (238, 238, 204), (245, 245, 220), + ], + "SUNSET_FIRE": [ + (51, 0, 0), (102, 0, 0), (153, 0, 0), (204, 0, 0), (255, 0, 0), + (255, 51, 0), (255, 102, 0), (255, 153, 0), (255, 204, 0), + (255, 255, 51), + ], + "ARCTIC_ICE": [ + (0, 0, 51), (0, 0, 102), (0, 51, 153), (0, 102, 153), + (51, 153, 204), (102, 204, 255), (153, 229, 255), (204, 242, 255), + (229, 247, 255), (255, 255, 255), + ], + "VINTAGE_ROSE": [ + (103, 58, 63), (137, 72, 81), (170, 91, 102), (196, 113, 122), + (219, 139, 147), (232, 168, 175), (240, 196, 199), (245, 215, 217), + (249, 232, 233), (255, 245, 245), + ], + "EARTH_CLAY": [ + (62, 39, 35), (89, 56, 47), (116, 73, 59), (143, 90, 71), + (170, 107, 83), (197, 124, 95), (210, 155, 126), (222, 186, 160), + (235, 217, 196), (248, 248, 232), + ], + "ELECTRIC_VIOLET": [ + (26, 0, 51), (51, 0, 102), (76, 0, 153), (102, 0, 204), + (128, 0, 255), (153, 51, 255), (178, 102, 255), (204, 153, 255), + (229, 204, 255), (245, 229, 255), + ], +} + + +def build_palette_image(palette_name): + """Build a 1x1 PIL 'P'-mode image with the named palette for Image.quantize(palette=...).""" + from PIL import Image + + if palette_name not in PALETTES: + raise ValueError( + f"Unknown palette {palette_name!r}. " + f"Choose from: {sorted(PALETTES)}" + ) + flat = [] + for (r, g, b) in PALETTES[palette_name]: + flat.extend([r, g, b]) + # Pad to 768 bytes (256 colors) as PIL requires + while len(flat) < 768: + flat.append(0) + pal_img = Image.new("P", (1, 1)) + pal_img.putpalette(flat) + return pal_img diff --git a/skills/creative/pixel-art/scripts/pixel_art.py b/skills/creative/pixel-art/scripts/pixel_art.py new file mode 100644 index 000000000..67987e418 --- /dev/null +++ b/skills/creative/pixel-art/scripts/pixel_art.py @@ -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() diff --git a/skills/creative/pixel-art/scripts/pixel_art_video.py b/skills/creative/pixel-art/scripts/pixel_art_video.py new file mode 100644 index 000000000..3b584144d --- /dev/null +++ b/skills/creative/pixel-art/scripts/pixel_art_video.py @@ -0,0 +1,345 @@ +"""Pixel art video — overlay procedural animations onto a source image. + +Takes any image (typically pre-processed with pixel_art()) and overlays +animated pixel effects (stars, rain, fireflies, etc.), then encodes to MP4 +(and optionally GIF) via ffmpeg. + +Scene animations ported from pixel-art-studio (MIT) — see ATTRIBUTION.md. +The generative/Pollinations code is intentionally dropped — Hermes uses +`image_generate` + `pixel_art()` for base frames instead. + +Usage (import): + from pixel_art_video import pixel_art_video + pixel_art_video("frame.png", "out.mp4", scene="night", duration=6) + +Usage (CLI): + python pixel_art_video.py frame.png out.mp4 --scene night --duration 6 --gif +""" + +import math +import os +import random +import shutil +import subprocess +import tempfile + +from PIL import Image, ImageDraw + + +# ── Pixel drawing helpers ────────────────────────────────────────────── + +def _px(draw, x, y, color, size=2): + x, y = int(x), int(y) + W, H = draw.im.size + if 0 <= x < W and 0 <= y < H: + draw.rectangle([x, y, x + size - 1, y + size - 1], fill=color) + + +def _pixel_cross(draw, x, y, color, arm=2): + x, y = int(x), int(y) + for i in range(-arm, arm + 1): + _px(draw, x + i, y, color, 1) + _px(draw, x, y + i, color, 1) + + +# ── Animation init/draw pairs ────────────────────────────────────────── + +def init_stars(rng, W, H): + return [(rng.randint(0, W), rng.randint(0, H // 2)) for _ in range(15)] + +def draw_stars(draw, stars, t, W, H): + for i, (sx, sy) in enumerate(stars): + if math.sin(t * 2.0 + i * 0.7) > 0.65: + _pixel_cross(draw, sx, sy, (255, 255, 220), arm=2) + + +def init_fireflies(rng, W, H): + return [{"x": rng.randint(20, W - 20), "y": rng.randint(H // 4, H - 20), + "phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.3, 0.8)} + for _ in range(10)] + +def draw_fireflies(draw, ff, t, W, H): + for f in ff: + if math.sin(t * 1.5 + f["phase"]) < 0.15: + continue + _px(draw, + f["x"] + math.sin(t * f["speed"] + f["phase"]) * 3, + f["y"] + math.cos(t * f["speed"] * 0.7) * 2, + (200, 255, 100), 2) + + +def init_leaves(rng, W, H): + return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0), + "speed": rng.uniform(0.5, 1.5), "wobble": rng.uniform(0.02, 0.05), + "phase": rng.uniform(0, 6.28), + "color": rng.choice([(180, 120, 50), (160, 100, 40), (200, 140, 60)])} + for _ in range(12)] + +def draw_leaves(draw, leaves, t, W, H): + for leaf in leaves: + _px(draw, + leaf["x"] + math.sin(t * leaf["wobble"] + leaf["phase"]) * 15, + (leaf["y"] + t * leaf["speed"] * 20) % (H + 40) - 20, + leaf["color"], 2) + + +def init_dust_motes(rng, W, H): + return [{"x": rng.randint(30, W - 30), "y": rng.randint(30, H - 30), + "phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.2, 0.5), + "amp": rng.uniform(2, 6)} for _ in range(20)] + +def draw_dust_motes(draw, motes, t, W, H): + for m in motes: + if math.sin(t * 2.0 + m["phase"]) > 0.3: + _px(draw, + m["x"] + math.sin(t * 0.3 + m["phase"]) * m["amp"], + m["y"] - (m["speed"] * t * 15) % H, + (255, 210, 100), 1) + + +def init_sparkles(rng, W, H): + return [(rng.randint(W // 4, 3 * W // 4), rng.randint(H // 4, 3 * H // 4), + rng.uniform(0, 6.28), + rng.choice([(180, 200, 255), (255, 220, 150), (200, 180, 255)])) + for _ in range(10)] + +def draw_sparkles(draw, sparkles, t, W, H): + for sx, sy, phase, color in sparkles: + if math.sin(t * 1.8 + phase) > 0.6: + _pixel_cross(draw, sx, sy, color, arm=2) + + +def init_rain(rng, W, H): + return [{"x": rng.randint(0, W), "y": rng.randint(0, H), + "speed": rng.uniform(4, 8)} for _ in range(30)] + +def draw_rain(draw, rain, t, W, H): + for r in rain: + y = (r["y"] + t * r["speed"] * 20) % H + _px(draw, r["x"], y, (120, 150, 200), 1) + _px(draw, r["x"], y + 4, (100, 130, 180), 1) + + +def init_lightning(rng, W, H): + return {"timer": 0, "flash": False, "rng": rng} + +def draw_lightning(draw, state, t, W, H): + state["timer"] += 1 + if state["timer"] > 45 and state["rng"].random() < 0.04: + state["flash"] = True + state["timer"] = 0 + if state["flash"]: + for x in range(0, W, 4): + for y in range(0, H // 3, 3): + if state["rng"].random() < 0.12: + _px(draw, x, y, (255, 255, 240), 2) + state["flash"] = False + + +def init_bubbles(rng, W, H): + return [{"x": rng.randint(20, W - 20), "y": rng.randint(H, H * 2), + "speed": rng.uniform(0.3, 0.8), "size": rng.choice([1, 2, 2])} + for _ in range(15)] + +def draw_bubbles(draw, bubbles, t, W, H): + for b in bubbles: + x = b["x"] + math.sin(t * 0.5 + b["x"]) * 3 + y = b["y"] - (t * b["speed"] * 20) % (H + 40) + if 0 < y < H: + _px(draw, x, y, (150, 200, 255), b["size"]) + + +def init_embers(rng, W, H): + return [{"x": rng.randint(0, W), "y": rng.randint(0, H), + "speed": rng.uniform(0.3, 0.9), "phase": rng.uniform(0, 6.28), + "color": rng.choice([(255, 150, 30), (255, 100, 20), (255, 200, 50)])} + for _ in range(18)] + +def draw_embers(draw, embers, t, W, H): + for e in embers: + x = e["x"] + math.sin(t * 0.4 + e["phase"]) * 5 + y = e["y"] - (t * e["speed"] * 15) % H + if math.sin(t * 2.5 + e["phase"]) > 0.2: + _px(draw, x, y, e["color"], 2) + + +def init_snowflakes(rng, W, H): + return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0), + "speed": rng.uniform(0.3, 0.6), "wobble": rng.uniform(0.04, 0.09), + "size": rng.choice([2, 2, 3])} + for _ in range(40)] + +def draw_snowflakes(draw, flakes, t, W, H): + for f in flakes: + x = f["x"] + math.sin(t * f["wobble"] + f["x"]) * 20 + y = (f["y"] + t * f["speed"] * 8) % (H + 20) - 10 + if f["size"] >= 3: + _pixel_cross(draw, x, y, (230, 235, 255), arm=1) + else: + _px(draw, x, y, (230, 235, 255), 2) + + +def init_neon_pulse(rng, W, H): + return [(rng.randint(0, W), rng.randint(0, H), rng.uniform(0, 6.28), + rng.choice([(255, 0, 200), (0, 255, 255), (255, 50, 150)])) + for _ in range(8)] + +def draw_neon_pulse(draw, points, t, W, H): + for x, y, phase, color in points: + if math.sin(t * 2.5 + phase) > 0.5: + _pixel_cross(draw, x, y, color, arm=3) + + +def init_heat_shimmer(rng, W, H): + return [{"x": rng.randint(0, W), "y": rng.randint(H // 2, H), + "phase": rng.uniform(0, 6.28)} for _ in range(12)] + +def draw_heat_shimmer(draw, points, t, W, H): + for p in points: + x = p["x"] + math.sin(t * 0.8 + p["phase"]) * 2 + y = p["y"] + math.sin(t * 1.2 + p["phase"]) * 1 + if abs(math.sin(t * 1.5 + p["phase"])) > 0.6: + _px(draw, x, y, (255, 200, 100), 1) + + +# ── Scene → animation mapping ────────────────────────────────────────── + +SCENES = { + "night": ["stars", "fireflies", "leaves"], + "dusk": ["fireflies", "sparkles"], + "tavern": ["dust_motes", "sparkles"], + "indoor": ["dust_motes"], + "urban": ["rain", "neon_pulse"], + "nature": ["leaves", "fireflies"], + "magic": ["sparkles", "fireflies"], + "storm": ["rain", "lightning"], + "underwater": ["bubbles", "sparkles"], + "fire": ["embers", "sparkles"], + "snow": ["snowflakes", "sparkles"], + "desert": ["heat_shimmer", "dust_motes"], +} + +# Map scene layer name to (init_fn, draw_fn). +_LAYERS = { + "stars": (init_stars, draw_stars), + "fireflies": (init_fireflies, draw_fireflies), + "leaves": (init_leaves, draw_leaves), + "dust_motes": (init_dust_motes, draw_dust_motes), + "sparkles": (init_sparkles, draw_sparkles), + "rain": (init_rain, draw_rain), + "lightning": (init_lightning, draw_lightning), + "bubbles": (init_bubbles, draw_bubbles), + "embers": (init_embers, draw_embers), + "snowflakes": (init_snowflakes, draw_snowflakes), + "neon_pulse": (init_neon_pulse, draw_neon_pulse), + "heat_shimmer": (init_heat_shimmer, draw_heat_shimmer), +} + + +def _ensure_ffmpeg(): + if shutil.which("ffmpeg") is None: + raise RuntimeError( + "ffmpeg not found on PATH. Install via your package manager or " + "download from https://ffmpeg.org/" + ) + + +def pixel_art_video( + base_image, + output_path, + scene="night", + duration=6, + fps=15, + seed=None, + export_gif=False, +): + """Overlay pixel animations onto a base image and encode to MP4. + + Args: + base_image: path to source image (ideally already pixel-art styled) + output_path: path to MP4 output (GIF sibling written if export_gif=True) + scene: key from SCENES (night, urban, storm, snow, fire, ...) + duration: seconds of animation + fps: frames per second (default 15 for retro feel) + seed: optional int for reproducible animation placement + export_gif: also write a GIF alongside the MP4 + + Returns: + (mp4_path, gif_path_or_None) + """ + if scene not in SCENES: + raise ValueError( + f"Unknown scene {scene!r}. Choose from: {sorted(SCENES)}" + ) + _ensure_ffmpeg() + + base = Image.open(base_image).convert("RGB") + W, H = base.size + + rng = random.Random(seed if seed is not None else 42) + layers = [] + for name in SCENES[scene]: + init_fn, draw_fn = _LAYERS[name] + layers.append((draw_fn, init_fn(rng, W, H))) + + n_frames = fps * duration + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="pixelart_frames_") as frames_dir: + for frame_idx in range(n_frames): + canvas = base.copy() + draw = ImageDraw.Draw(canvas) + t = frame_idx / fps + for draw_fn, state in layers: + draw_fn(draw, state, t, W, H) + canvas.save(os.path.join(frames_dir, f"frame_{frame_idx:04d}.png")) + + subprocess.run( + ["ffmpeg", "-y", "-loglevel", "error", + "-framerate", str(fps), + "-i", os.path.join(frames_dir, "frame_%04d.png"), + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", + output_path], + check=True, + ) + + gif_path = None + if export_gif: + gif_path = output_path.rsplit(".", 1)[0] + ".gif" + subprocess.run( + ["ffmpeg", "-y", "-loglevel", "error", + "-framerate", str(fps), + "-i", os.path.join(frames_dir, "frame_%04d.png"), + "-vf", + "scale=320:-1:flags=neighbor,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + "-loop", "0", + gif_path], + check=True, + ) + + return output_path, gif_path + + +def main(): + import argparse + p = argparse.ArgumentParser(description="Overlay pixel animations onto an image → MP4.") + p.add_argument("base_image") + p.add_argument("output") + p.add_argument("--scene", default="night", choices=sorted(SCENES)) + p.add_argument("--duration", type=int, default=6) + p.add_argument("--fps", type=int, default=15) + p.add_argument("--seed", type=int, default=None) + p.add_argument("--gif", action="store_true") + args = p.parse_args() + mp4, gif = pixel_art_video( + args.base_image, args.output, + scene=args.scene, duration=args.duration, + fps=args.fps, seed=args.seed, export_gif=args.gif, + ) + print(f"Wrote {mp4}") + if gif: + print(f"Wrote {gif}") + + +if __name__ == "__main__": + main()