114 lines
4.4 KiB
Python
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())
|