tools/art_prep.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Turn a generated or supplied illustration into a shippable theme figure.
Keys out the flat background, crops to what is actually drawn, scales it to
something a corner watermark can use, and strips the metadata. What comes back
from an image model is a megabyte of 1408x768 with the figure floating in the
middle of a magenta field; what belongs in the wheel is about 40KB of exactly
the character.
python3 tools/art_prep.py in.png out.png [--key ff00ff] [--height 560]
Needs Pillow, which the panel does not: this runs once when art arrives, and
the thing it produces is a plain PNG. Nothing at runtime imports it.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
try:
from PIL import Image
except ImportError: # pragma: no cover - a developer tool, not the product
sys.exit("art_prep needs Pillow: pip install Pillow")
# How far a pixel may sit from the key colour and still count as background.
# Generous, because a JPEG-ish encode leaves a halo of near-key pixels around
# every edge and a tight threshold keeps them as a magenta fringe.
TOLERANCE = 60
# Below this alpha a pixel is background outright. Above it, the pixel keeps
# its colour but gets pulled away from the key, which is what stops a magenta
# rim appearing wherever the figure was antialiased against it.
EDGE = 200
def key_out(im: Image.Image, key: tuple[int, int, int]) -> Image.Image:
im = im.convert("RGBA")
out = []
kr, kg, kb = key
for r, g, b, a in im.get_flattened_data():
near = max(abs(r - kr), abs(g - kg), abs(b - kb))
if near <= TOLERANCE:
out.append((r, g, b, 0))
elif near <= TOLERANCE * 3:
# An edge pixel: keep it, but unmix the key out of it so the
# figure does not ship wearing a pink outline.
f = (near - TOLERANCE) / (TOLERANCE * 2)
out.append((
min(255, int(r - kr * (1 - f) * 0.55)),
min(255, int(g - kg * (1 - f) * 0.55)),
min(255, int(b - kb * (1 - f) * 0.55)),
int(255 * f),
))
else:
out.append((r, g, b, a))
im.putdata(out)
return im
def prep(src: Path, dst: Path, key: tuple[int, int, int], height: int,
colors: int) -> None:
im = key_out(Image.open(src), key)
box = im.getbbox()
if box:
im = im.crop(box)
if im.height > height:
im = im.resize(
(max(1, round(im.width * height / im.height)), height), Image.LANCZOS
)
# Quantised on purpose. These are cel-shaded illustrations: flat fills,
# hard edges, a handful of tones per region, which is exactly the case a
# palette handles without showing. Full colour came out at 2.2MB across
# the seven, against about 400KB here for a picture that is drawn at nine
# percent opacity behind somebody's terminal output. `colors` is the one
# knob worth touching if a future drawing does have a gradient in it.
flat = Image.new("RGBA", im.size)
flat.putdata(list(im.get_flattened_data()))
alpha = flat.getchannel("A")
small = flat.convert("RGB").quantize(colors=colors, method=Image.MEDIANCUT, dither=Image.NONE)
small = small.convert("RGBA")
small.putalpha(alpha)
small.save(dst, "PNG", optimize=True)
print(f" {dst.name:<16} {im.width}x{im.height} {dst.stat().st_size // 1024} KB")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("src", type=Path)
ap.add_argument("dst", type=Path)
ap.add_argument("--key", default="ff00ff", help="background colour to remove")
ap.add_argument("--height", type=int, default=420)
ap.add_argument("--colors", type=int, default=96)
args = ap.parse_args()
raw = args.key.lstrip("#")
prep(args.src, args.dst, tuple(int(raw[i:i + 2], 16) for i in (0, 2, 4)),
args.height, args.colors)
return 0
if __name__ == "__main__":
raise SystemExit(main())