Files
aetherbound-guild/tools/art/process_sticker.py
T

114 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Turn a magenta-keyed generated image into a runtime sticker/actor PNG.
Pipeline (the recipe accepted with the M69 character pass):
1. chroma-key the plain magenta backdrop (#FF00FF) with a soft matte;
2. despill magenta fringes toward the red/blue average;
3. trim to the content bounding box;
4. scale so the content height is `--content` of the canvas;
5. paste centred horizontally with a small bottom margin on a square canvas.
Deterministic, offline, PIL only.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image, ImageChops
def backdrop_color(image: Image.Image) -> tuple[int, int, int]:
"""Median-ish backdrop estimate from the four corners beyond the matte."""
width, height = image.size
probe = max(4, min(width, height) // 32)
samples = []
for box in [(0, 0, probe, probe), (width - probe, 0, width, probe),
(0, height - probe, probe, height), (width - probe, height - probe, width, height)]:
region = image.convert("RGB").crop(box)
colors = region.getcolors(region.width * region.height) or []
if colors:
samples.append(max(colors)[1])
if not samples:
return (255, 0, 255)
channels = list(zip(*samples))
return tuple(sorted(channel)[len(channel) // 2] for channel in channels)
def key_magenta(image: Image.Image, low: int = 30, feather: int = 22) -> Image.Image:
rgba = image.convert("RGBA")
key_r, key_g, key_b = backdrop_color(image)
r, g, b = rgba.split()[:3]
dr = ImageChops.difference(r, Image.new("L", rgba.size, key_r))
dg = ImageChops.difference(g, Image.new("L", rgba.size, key_g))
db = ImageChops.difference(b, Image.new("L", rgba.size, key_b))
distance = ImageChops.lighter(ImageChops.lighter(dr, dg), db)
alpha = distance.point(lambda v: 0 if v < low else (255 if v > low + feather else int((v - low) * 255 / feather)))
rgba.putalpha(alpha)
# despill: magenta fringes have green below the red/blue average
pixels = rgba.load()
width, height = rgba.size
for y in range(height):
for x in range(width):
pr, pg, pb, pa = pixels[x, y]
if pa == 0:
continue
# magenta-ish fringe: green dips below the red/blue average
floor = min(pr, pb)
if pg < floor:
pixels[x, y] = (pr, floor, pb, pa)
return rgba
def normalize(image: Image.Image, size: int, content: float, bottom: float) -> Image.Image:
bbox = image.getchannel("A").point(lambda v: 255 if v > 10 else 0).getbbox()
if bbox is None:
return Image.new("RGBA", (size, size), (0, 0, 0, 0))
cropped = image.crop(bbox)
target_h = max(1, int(size * content))
scale = target_h / cropped.height
target_w = max(1, int(round(cropped.width * scale)))
resized = cropped.resize((target_w, target_h), Image.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
x = (size - target_w) // 2
y = size - target_h - int(size * bottom)
canvas.paste(resized, (x, max(0, y)), resized)
return canvas
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("inputs", nargs="+")
parser.add_argument("--out-dir", required=True)
parser.add_argument("--size", type=int, default=512)
parser.add_argument("--content", type=float, default=0.90)
parser.add_argument("--bottom", type=float, default=0.03)
parser.add_argument("--report", default=None)
args = parser.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
records = []
for raw in args.inputs:
source = Path(raw)
image = Image.open(source)
keyed = key_magenta(image)
final = normalize(keyed, args.size, args.content, args.bottom)
target = out_dir / f"{source.stem}.png"
final.save(target)
records.append({
"source": str(source),
"output": str(target),
"source_size": list(image.size),
"output_size": [args.size, args.size],
"content_fraction": args.content,
})
print(f"OK {source.name} {image.size} -> {target.name}")
if args.report:
Path(args.report).write_text(json.dumps({"items": records}, ensure_ascii=False, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())