| """ |
| 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) |
| |
| |
| 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() |
| 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() |
|
|