""" Image post-processing for the puzzle maker. * Mascot sprite: magenta chroma-key -> autocrop -> downscale -> quantize (same hue/dominance keying as the runner — removes #FF00FF AND the darkened magenta drop shadow Flux likes to add, while sparing real colors). * Puzzle artwork: resize to the canonical 1024x768 board and emit a compact JPEG data-URL (PNG at this size would blow the community-pack budget). * Thumbnails for the gallery cards. """ import base64 import io import numpy as np from PIL import Image PUZZLE_W, PUZZLE_H = 1024, 768 def _open(data): if isinstance(data, (bytes, bytearray)): return Image.open(io.BytesIO(data)) return Image.open(data) def _key_magenta(img, thr=38): """Set alpha=0 wherever a pixel is 'magenta-dominant' (R and B both clearly exceed G). Catches #FF00FF and its purple shadow; leaves real colors alone.""" img = img.convert("RGBA") a = np.asarray(img).astype(np.int16) r, g, b = a[..., 0], a[..., 1], a[..., 2] mag = (r - g > thr) & (b - g > thr) & (np.minimum(r, b) - g > thr) # despill the fringe: pixels slightly magenta (anti-aliased edges) get their # red+blue pulled toward green so there's no pink halo after keying. fringe = (~mag) & (r - g > 8) & (b - g > 8) a[..., 0][fringe] = (r[fringe] + g[fringe]) // 2 a[..., 2][fringe] = (b[fringe] + g[fringe]) // 2 a[..., 3][mag] = 0 return Image.fromarray(a.astype(np.uint8), "RGBA") def _resize_h(img, target_h): w, h = img.size nw = max(1, round(w * target_h / h)) return img.resize((nw, target_h), Image.LANCZOS) def _crunch(img, colors): """Hard-edge the alpha (pixel-art look) and quantize the palette.""" alpha = img.getchannel("A").point(lambda v: 255 if v > 110 else 0) rgb = img.convert("RGB").quantize(colors=colors, method=Image.FASTOCTREE).convert("RGB") out = rgb.convert("RGBA") out.putalpha(alpha) return out def process_mascot(data, target_h=160, colors=32): img = _key_magenta(_open(data)) bbox = img.getchannel("A").getbbox() # crop to the keyed subject only if bbox: img = img.crop(bbox) return _crunch(_resize_h(img, target_h), colors) def process_artwork(data): """Whatever Flux returns -> exactly PUZZLE_W x PUZZLE_H, center-cropped.""" img = _open(data).convert("RGB") w, h = img.size scale = max(PUZZLE_W / w, PUZZLE_H / h) img = img.resize((round(w * scale), round(h * scale)), Image.LANCZOS) w, h = img.size x, y = (w - PUZZLE_W) // 2, (h - PUZZLE_H) // 2 return img.crop((x, y, x + PUZZLE_W, y + PUZZLE_H)) def thumb(img, size=256): t = img.copy().convert("RGB") t.thumbnail((size, size)) return t def to_data_url(img, fmt="PNG"): buf = io.BytesIO() img.save(buf, fmt) return f"data:image/{fmt.lower()};base64," + base64.b64encode(buf.getvalue()).decode() def to_jpeg_data_url(img, quality=85): buf = io.BytesIO() img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True) return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()