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

@ -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

View file

@ -1,170 +1,217 @@
--- ---
name: pixel-art 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. 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: 1.2.0 version: 2.0.0
author: dodo-reach author: dodo-reach
license: MIT license: MIT
metadata: metadata:
hermes: hermes:
tags: [creative, pixel-art, arcade, snes, retro, image] tags: [creative, pixel-art, arcade, snes, nes, gameboy, retro, image, video]
category: creative category: creative
credits:
- "Hardware palettes and animation loops ported from Synero/pixel-art-studio (MIT) — https://github.com/Synero/pixel-art-studio"
--- ---
# Pixel Art # 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. Two scripts ship with this skill:
- `snes` — 32-color palette, 4px blocks. Cleaner 16-bit console look with more detail retention.
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 ## When to Use
- User wants retro pixel art from a source image - User wants retro pixel art from a source image
- Posters, album covers, social posts, sprites, characters, backgrounds - User asks for NES / Game Boy / PICO-8 / C64 / arcade / SNES styling
- Subject can tolerate aggressive simplification (arcade) or benefits from retained detail (snes) - 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 | Before generating, confirm the style with the user. Different presets produce
|--------|---------|-------|----------| very different outputs and regenerating is costly.
| `arcade` | 16 colors | 8px | Posters, hero images, bold covers, simple subjects |
| `snes` | 32 colors | 4px | Characters, sprites, detailed illustrations, photos |
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. Default menu when the user's intent is unclear:
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
```python ```python
from PIL import Image, ImageEnhance, ImageOps clarify(
question="Which pixel-art style do you want?",
PRESETS = { choices=[
"arcade": { "arcade — bold, chunky 80s cabinet feel (16 colors, 8px)",
"contrast": 1.8, "nes — Nintendo 8-bit hardware palette (54 colors, 8px)",
"color": 1.5, "gameboy — 4-shade green Game Boy DMG",
"sharpness": 1.2, "snes — cleaner 16-bit look (32 colors, 4px)",
"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,
) )
``` ```
## 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 ### Step 3 — Generate
- 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.
## 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 | Preset | Era | Palette | Block | Best for |
- The image shows clear square pixel blocks at the preset's block size |--------|-----|---------|-------|----------|
- Dithering is visible in gradients | `arcade` | 80s arcade | adaptive 16 | 8px | Bold posters, hero art |
- The palette is limited to approximately the preset's color count | `snes` | 16-bit | adaptive 32 | 4px | Characters, detailed scenes |
- The overall look matches the targeted era (arcade or SNES) | `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 ## Dependencies
- Python 3 - Python 3.9+
- Pillow - Pillow (`pip install Pillow`)
- ffmpeg on PATH (only needed for video — Hermes installs package this)
```bash ## Pitfalls
pip install Pillow
``` - 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.

View file

@ -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 |

View file

@ -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

View 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()

View file

@ -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()