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

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