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