#!/usr/bin/env python3 """Build a stable raster-parts rig for Seoyeon. The generated character sheet contains useful loose parts, but recomposing those parts directly is fragile because each item is drawn at a different scale. This script uses the approved assembled bust as the coordinate authority, creates a faceless base from it, then adds small aligned raster parts for the eyes, brows, nose, mouth, blink, and hair-sway overlays. Output files are full-canvas 900x1125 PNG layers so the React renderer can stack them without per-image layout math. """ from __future__ import annotations from pathlib import Path from typing import Iterable, Literal import numpy as np from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[3] SHEET = ROOT / "docs/avatar-art/seoyeon/character-sheet-v1.png" PUB = ROOT / "apps/web/public/avatar/seoyeon" NEUTRAL = PUB / "neutral.png" OUT = PUB / "parts" PREVIEW = ROOT / "docs/avatar-art/seoyeon/character-sheet-rig-preview-v2.png" LEGACY_PREVIEW = ROOT / "docs/avatar-art/seoyeon/character-sheet-recompose-preview.png" CANVAS = (900, 1125) Box = tuple[int, int, int, int] ShapeKind = Literal["ellipse", "round", "rect"] def alpha_bbox(im: Image.Image, threshold: int = 10) -> Box | None: alpha = np.array(im.getchannel("A")) ys, xs = np.where(alpha > threshold) if len(xs) == 0: return None return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) def median_skin(im: Image.Image, box: Box) -> tuple[int, int, int, int]: arr = np.array(im.crop(box).convert("RGBA")) rgb = arr[:, :, :3].astype(np.float32) alpha = arr[:, :, 3] > 180 luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 chroma = rgb.max(axis=2) - rgb.min(axis=2) # Keep face skin, reject hair/linework/shirt. mask = alpha & (luma > 145) & (chroma > 8) if int(mask.sum()) < 20: mask = alpha color = np.median(rgb[mask], axis=0) return int(color[0]), int(color[1]), int(color[2]), 255 def draw_shape(mask: Image.Image, box: Box, kind: ShapeKind, radius: int = 14) -> None: draw = ImageDraw.Draw(mask) if kind == "ellipse": draw.ellipse(box, fill=255) elif kind == "round": draw.rounded_rectangle(box, radius=radius, fill=255) else: draw.rectangle(box, fill=255) def shape_mask( size: tuple[int, int], shapes: Iterable[tuple[Box, ShapeKind, int]], feather: float = 0, ) -> Image.Image: mask = Image.new("L", size, 0) for box, kind, radius in shapes: draw_shape(mask, box, kind, radius) if feather > 0: mask = mask.filter(ImageFilter.GaussianBlur(feather)) return mask def erase_features(neutral: Image.Image) -> Image.Image: base = neutral.convert("RGBA") patches = [ ((318, 292, 410, 320), (418, 360, 476, 392), "round", 5), ((490, 292, 582, 320), (418, 360, 476, 392), "round", 5), ((306, 314, 412, 372), (418, 360, 476, 392), "ellipse", 5), ((488, 314, 594, 372), (418, 360, 476, 392), "ellipse", 5), ((412, 382, 490, 430), (392, 360, 512, 410), "ellipse", 5), ((382, 414, 518, 466), (388, 382, 512, 428), "ellipse", 6), ] for erase_box, sample_box, kind, feather in patches: color = median_skin(base, sample_box) fill = Image.new("RGBA", CANVAS, color) mask = Image.new("L", CANVAS, 0) draw_shape(mask, erase_box, kind, 18) mask = mask.filter(ImageFilter.GaussianBlur(feather)) base = Image.composite(fill, base, mask) return base def save_image(name: str, im: Image.Image) -> Image.Image: out = OUT / f"{name}.png" im.save(out, optimize=True) print(f"{name:18} bbox={alpha_bbox(im)}") return im def save_masked_neutral( neutral: Image.Image, name: str, shapes: Iterable[tuple[Box, ShapeKind, int]], *, subtract_shapes: Iterable[tuple[Box, ShapeKind, int]] = (), feather: float = 0, mode: Literal["patch", "dark"] = "patch", dark_hi: float = 175, dark_lo: float = 80, ) -> Image.Image: src = neutral.convert("RGBA") mask_im = shape_mask(CANVAS, shapes, feather=0) if subtract_shapes: erase_im = shape_mask(CANVAS, subtract_shapes, feather=0) mask_arr = np.array(mask_im) mask_arr[np.array(erase_im) > 0] = 0 mask_im = Image.fromarray(mask_arr, "L") if feather > 0: mask_im = mask_im.filter(ImageFilter.GaussianBlur(feather)) mask = np.array(mask_im).astype(np.float32) / 255.0 arr = np.array(src).astype(np.float32) alpha = arr[:, :, 3] * mask if mode == "dark": rgb = arr[:, :, :3] luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 dark = np.clip((dark_hi - luma) / max(1.0, dark_hi - dark_lo), 0, 1) alpha *= dark arr[:, :, 3] = alpha arr[alpha < 1, :3] = 0 return save_image(name, Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA")) def remove_sheet_bg(im: Image.Image, *, lo: float = 18.0, hi: float = 62.0) -> Image.Image: arr = np.array(im.convert("RGBA")).astype(np.float32) rgb = arr[:, :, :3] border = np.concatenate( [ rgb[:5, :, :].reshape(-1, 3), rgb[-5:, :, :].reshape(-1, 3), rgb[:, :5, :].reshape(-1, 3), rgb[:, -5:, :].reshape(-1, 3), ], axis=0, ) bg = np.median(border, axis=0) dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2)) dist_white = np.sqrt(((255.0 - rgb) ** 2).sum(axis=2)) alpha_bg = np.clip((dist_bg - lo) * 255.0 / max(1.0, hi - lo), 0, 255) alpha_white = np.clip((dist_white - 15.0) * 255.0 / 50.0, 0, 255) alpha = np.minimum(alpha_bg, alpha_white) luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 chroma = rgb.max(axis=2) - rgb.min(axis=2) keep = (luma < 205) | (chroma > 16) alpha = np.where(keep & (alpha > 35), np.maximum(alpha, 230), alpha) arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3]) arr[arr[:, :, 3] < 1, :3] = 0 return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") def save_sheet_part( sheet: Image.Image, name: str, src_box: Box, dst_box: Box, *, matte_lo: float = 14.0, matte_hi: float = 54.0, ) -> Image.Image: part = remove_sheet_bg(sheet.crop(src_box), lo=matte_lo, hi=matte_hi) dst_w = dst_box[2] - dst_box[0] dst_h = dst_box[3] - dst_box[1] part = part.resize((dst_w, dst_h), Image.Resampling.LANCZOS) canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0)) canvas.alpha_composite(part, (dst_box[0], dst_box[1])) return save_image(name, canvas) def duplicate(src: str, dst: str) -> None: im = Image.open(OUT / f"{src}.png").convert("RGBA") save_image(dst, im) def make_preview(parts: dict[str, Image.Image]) -> Image.Image: bg = Image.new("RGBA", CANVAS, (30, 39, 36, 255)) order = [ "base-faceless", "hair-left", "hair-right", "hair-bangs", "brow-neutral", "eyes-neutral", "nose-neutral", "mouth-neutral", ] for name in order: layer = parts.get(name) if layer is None: layer = Image.open(OUT / f"{name}.png").convert("RGBA") bg.alpha_composite(layer) # Add small comparison swatches for blink and mouth variants without touching # the app-visible neutral render. swatch = Image.new("RGBA", (360, 210), (30, 39, 36, 255)) for i, name in enumerate(["eyelid-closed", "mouth-sad", "mouth-warm", "mouth-open"]): layer = Image.open(OUT / f"{name}.png").convert("RGBA") crop_box = alpha_bbox(layer) or (0, 0, 1, 1) crop = layer.crop(crop_box) crop.thumbnail((155, 85), Image.Resampling.LANCZOS) x = 18 + (i % 2) * 174 y = 18 + (i // 2) * 98 swatch.alpha_composite(crop, (x, y)) bg.alpha_composite(swatch, (20, 895)) return bg def main() -> int: OUT.mkdir(parents=True, exist_ok=True) neutral = Image.open(NEUTRAL).convert("RGBA") sheet = Image.open(SHEET).convert("RGBA") if neutral.size != CANVAS: raise ValueError(f"Expected {CANVAS}, got {neutral.size} for {NEUTRAL}") parts: dict[str, Image.Image] = {} parts["base-faceless"] = save_image("base-faceless", erase_features(neutral)) # Compatibility/static layers used by older manifests or future experiments. parts["face-faceless"] = save_masked_neutral( parts["base-faceless"], "face-faceless", [((250, 205, 650, 535), "ellipse", 40)], feather=3, ) parts["forehead"] = save_masked_neutral( parts["base-faceless"], "forehead", [((340, 228, 560, 315), "round", 22)], feather=4, ) parts["neck"] = save_masked_neutral( neutral, "neck", [((352, 470, 548, 618), "round", 28)], feather=2, ) parts["shoulders"] = save_masked_neutral( neutral, "shoulders", [((160, 545, 740, 820), "round", 28)], feather=2, ) parts["hair-back"] = save_masked_neutral( neutral, "hair-back", [((170, 80, 730, 520), "round", 80)], subtract_shapes=[ ((262, 210, 638, 540), "ellipse", 72), ((292, 280, 608, 388), "round", 34), ], feather=2, mode="dark", dark_hi=172, dark_lo=70, ) parts["hair-bangs"] = save_masked_neutral( neutral, "hair-bangs", [((326, 120, 574, 310), "round", 44)], subtract_shapes=[((292, 282, 608, 386), "round", 34)], feather=1.5, mode="dark", dark_hi=178, dark_lo=75, ) parts["hair-left"] = save_masked_neutral( neutral, "hair-left", [ ((168, 155, 318, 522), "round", 54), ((242, 430, 392, 522), "round", 42), ], subtract_shapes=[ ((270, 228, 430, 538), "ellipse", 48), ((292, 282, 425, 390), "round", 32), ], feather=1.5, mode="dark", dark_hi=178, dark_lo=75, ) parts["hair-right"] = save_masked_neutral( neutral, "hair-right", [ ((582, 155, 732, 522), "round", 54), ((508, 430, 658, 522), "round", 42), ], subtract_shapes=[ ((470, 228, 630, 538), "ellipse", 48), ((475, 282, 608, 390), "round", 32), ], feather=1.5, mode="dark", dark_hi=178, dark_lo=75, ) # Ear files are kept as small static compatibility layers; the faceless base # already contains the ears in their approved position. parts["ear-left"] = save_masked_neutral( neutral, "ear-left", [((285, 318, 337, 392), "ellipse", 20)], feather=2, ) parts["ear-right"] = save_masked_neutral( neutral, "ear-right", [((563, 318, 615, 392), "ellipse", 20)], feather=2, ) parts["brow-neutral"] = save_sheet_part( sheet, "brow-neutral", (600, 424, 848, 482), (315, 272, 591, 310), matte_lo=10, matte_hi=42, ) parts["eyes-neutral"] = save_sheet_part( sheet, "eyes-neutral", (594, 458, 856, 548), (311, 314, 599, 376), matte_lo=10, matte_hi=44, ) parts["nose-neutral"] = save_masked_neutral( neutral, "nose-neutral", [((414, 385, 488, 426), "ellipse", 18)], feather=2, ) parts["mouth-neutral"] = save_masked_neutral( neutral, "mouth-neutral", [((382, 414, 518, 468), "ellipse", 20)], feather=2, ) parts["eyelid-closed"] = save_sheet_part( sheet, "eyelid-closed", (592, 552, 856, 620), (306, 315, 594, 369), matte_lo=11, matte_hi=46, ) parts["mouth-sad"] = save_sheet_part( sheet, "mouth-sad", (888, 520, 1016, 586), (397, 414, 531, 468), matte_lo=10, matte_hi=44, ) parts["mouth-warm"] = save_sheet_part( sheet, "mouth-warm", (888, 596, 1016, 662), (397, 414, 531, 468), matte_lo=10, matte_hi=44, ) parts["mouth-open"] = save_sheet_part( sheet, "mouth-open", (888, 672, 1016, 752), (394, 407, 534, 475), matte_lo=10, matte_hi=44, ) for variant in ["sad", "tired", "anxious", "warm", "startled"]: duplicate("brow-neutral", f"brow-{variant}") duplicate("eyes-neutral", f"eyes-{variant}") duplicate("mouth-sad", "mouth-tired") duplicate("mouth-sad", "mouth-anxious") duplicate("mouth-open", "mouth-startled") preview = make_preview(parts) preview.save(PREVIEW, optimize=True) preview.save(LEGACY_PREVIEW, optimize=True) print(f"preview {PREVIEW}") print(f"legacy-preview {LEGACY_PREVIEW}") return 0 if __name__ == "__main__": raise SystemExit(main())