mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(pixel-art): add hardware palettes and video animation (#12725)
Expand the pixel-art skill from 2 presets (arcade, snes) to 14 presets with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, Apple II, MS Paint, CRT mono), plus a procedural video overlay pipeline. Ported from Synero/pixel-art-studio (MIT). Full attribution in ATTRIBUTION.md. What's in: - scripts/palettes.py — 28 named RGB palettes (hardware + artistic) - scripts/pixel_art.py — 14 presets, named palette support, CLI - scripts/pixel_art_video.py — 12 animation scenes (stars, rain, fireflies, snow, embers, lightning, etc.) → MP4/GIF via ffmpeg - references/palettes.md — palette catalog - SKILL.md — clarify-tool workflow (offer style, then optional scene) What's out (intentional): - Wu's quantizer (PIL's built-in quantize suffices) - Sobel edge-aware downsample (scipy dep not worth it) - Atkinson/Bayer dither (would need numpy reimpl) - Pollinations text-to-image (Hermes uses image_generate instead) Video pipeline uses subprocess.run with check=True (replaces os.system) and tempfile.TemporaryDirectory (replaces manual cleanup).
This commit is contained in:
parent
abfc1847b7
commit
d40a828a8b
7 changed files with 955 additions and 131 deletions
54
skills/creative/pixel-art/ATTRIBUTION.md
Normal file
54
skills/creative/pixel-art/ATTRIBUTION.md
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
49
skills/creative/pixel-art/references/palettes.md
Normal file
49
skills/creative/pixel-art/references/palettes.md
Normal 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 |
|
||||||
0
skills/creative/pixel-art/scripts/__init__.py
Normal file
0
skills/creative/pixel-art/scripts/__init__.py
Normal file
167
skills/creative/pixel-art/scripts/palettes.py
Normal file
167
skills/creative/pixel-art/scripts/palettes.py
Normal 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
|
||||||
162
skills/creative/pixel-art/scripts/pixel_art.py
Normal file
162
skills/creative/pixel-art/scripts/pixel_art.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Pixel art converter — Floyd-Steinberg dithering with preset or named palette.
|
||||||
|
|
||||||
|
Named hardware palettes (NES, GameBoy, PICO-8, C64, etc.) ported from
|
||||||
|
pixel-art-studio (MIT) — see ATTRIBUTION.md.
|
||||||
|
|
||||||
|
Usage (import):
|
||||||
|
from pixel_art import pixel_art
|
||||||
|
pixel_art("in.png", "out.png", preset="arcade")
|
||||||
|
pixel_art("in.png", "out.png", preset="nes")
|
||||||
|
pixel_art("in.png", "out.png", palette="PICO_8", block=6)
|
||||||
|
|
||||||
|
Usage (CLI):
|
||||||
|
python pixel_art.py in.png out.png --preset nes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image, ImageEnhance, ImageOps
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .palettes import PALETTES, build_palette_image
|
||||||
|
except ImportError:
|
||||||
|
from palettes import PALETTES, build_palette_image
|
||||||
|
|
||||||
|
|
||||||
|
PRESETS = {
|
||||||
|
# ── Original presets (adaptive palette) ─────────────────────────────
|
||||||
|
"arcade": {
|
||||||
|
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 5, "block": 8, "palette": 16,
|
||||||
|
},
|
||||||
|
"snes": {
|
||||||
|
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 4, "palette": 32,
|
||||||
|
},
|
||||||
|
# ── Hardware-accurate presets (named palette) ───────────────────────
|
||||||
|
"nes": {
|
||||||
|
"contrast": 1.5, "color": 1.4, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 8, "palette": "NES",
|
||||||
|
},
|
||||||
|
"gameboy": {
|
||||||
|
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_ORIGINAL",
|
||||||
|
},
|
||||||
|
"gameboy_pocket": {
|
||||||
|
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_POCKET",
|
||||||
|
},
|
||||||
|
"pico8": {
|
||||||
|
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 6, "palette": "PICO_8",
|
||||||
|
},
|
||||||
|
"c64": {
|
||||||
|
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 8, "palette": "C64",
|
||||||
|
},
|
||||||
|
"apple2": {
|
||||||
|
"contrast": 1.8, "color": 1.4, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 5, "block": 10, "palette": "APPLE_II_HI",
|
||||||
|
},
|
||||||
|
"teletext": {
|
||||||
|
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 5, "block": 10, "palette": "TELETEXT",
|
||||||
|
},
|
||||||
|
"mspaint": {
|
||||||
|
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 6, "block": 8, "palette": "MICROSOFT_WINDOWS_PAINT",
|
||||||
|
},
|
||||||
|
"mono_green": {
|
||||||
|
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 5, "block": 6, "palette": "MONO_GREEN",
|
||||||
|
},
|
||||||
|
"mono_amber": {
|
||||||
|
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 5, "block": 6, "palette": "MONO_AMBER",
|
||||||
|
},
|
||||||
|
# ── Artistic palette presets ────────────────────────────────────────
|
||||||
|
"neon": {
|
||||||
|
"contrast": 1.8, "color": 1.6, "sharpness": 1.2,
|
||||||
|
"posterize_bits": 5, "block": 6, "palette": "NEON_CYBER",
|
||||||
|
},
|
||||||
|
"pastel": {
|
||||||
|
"contrast": 1.2, "color": 1.3, "sharpness": 1.1,
|
||||||
|
"posterize_bits": 6, "block": 6, "palette": "PASTEL_DREAM",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pixel_art(input_path, output_path, preset="arcade", **overrides):
|
||||||
|
"""Convert an image to retro pixel art.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: path to source image
|
||||||
|
output_path: path to save the resulting PNG
|
||||||
|
preset: one of PRESETS (arcade, snes, nes, gameboy, pico8, c64, ...)
|
||||||
|
**overrides: optionally override any preset field. In particular:
|
||||||
|
palette: int (adaptive N colors) OR str (named palette from PALETTES)
|
||||||
|
block: int pixel block size
|
||||||
|
contrast / color / sharpness / posterize_bits: numeric enhancers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resulting PIL.Image.
|
||||||
|
"""
|
||||||
|
if preset not in PRESETS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown preset {preset!r}. Choose from: {sorted(PRESETS)}"
|
||||||
|
)
|
||||||
|
cfg = {**PRESETS[preset], **overrides}
|
||||||
|
|
||||||
|
img = Image.open(input_path).convert("RGB")
|
||||||
|
|
||||||
|
img = ImageEnhance.Contrast(img).enhance(cfg["contrast"])
|
||||||
|
img = ImageEnhance.Color(img).enhance(cfg["color"])
|
||||||
|
img = ImageEnhance.Sharpness(img).enhance(cfg["sharpness"])
|
||||||
|
img = ImageOps.posterize(img, cfg["posterize_bits"])
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
block = cfg["block"]
|
||||||
|
small = img.resize(
|
||||||
|
(max(1, w // block), max(1, h // block)),
|
||||||
|
Image.NEAREST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quantize AFTER downscale so Floyd-Steinberg aligns with final pixel grid.
|
||||||
|
pal = cfg["palette"]
|
||||||
|
if isinstance(pal, str):
|
||||||
|
# Named hardware/artistic palette
|
||||||
|
pal_img = build_palette_image(pal)
|
||||||
|
quantized = small.quantize(palette=pal_img, dither=Image.FLOYDSTEINBERG)
|
||||||
|
else:
|
||||||
|
# Adaptive N-color palette (original behavior)
|
||||||
|
quantized = small.quantize(colors=int(pal), dither=Image.FLOYDSTEINBERG)
|
||||||
|
|
||||||
|
result = quantized.resize((w, h), Image.NEAREST)
|
||||||
|
result.save(output_path, "PNG")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser(description="Convert image to pixel art.")
|
||||||
|
p.add_argument("input")
|
||||||
|
p.add_argument("output")
|
||||||
|
p.add_argument("--preset", default="arcade", choices=sorted(PRESETS))
|
||||||
|
p.add_argument("--palette", default=None,
|
||||||
|
help=f"Override palette: int or name from {sorted(PALETTES)}")
|
||||||
|
p.add_argument("--block", type=int, default=None)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
overrides = {}
|
||||||
|
if args.palette is not None:
|
||||||
|
try:
|
||||||
|
overrides["palette"] = int(args.palette)
|
||||||
|
except ValueError:
|
||||||
|
overrides["palette"] = args.palette
|
||||||
|
if args.block is not None:
|
||||||
|
overrides["block"] = args.block
|
||||||
|
|
||||||
|
pixel_art(args.input, args.output, preset=args.preset, **overrides)
|
||||||
|
print(f"Wrote {args.output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
345
skills/creative/pixel-art/scripts/pixel_art_video.py
Normal file
345
skills/creative/pixel-art/scripts/pixel_art_video.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue