#!/usr/bin/env python3 """Turn an imagegen eye atlas into coordinate-locked app eye parts.""" from __future__ import annotations import json import shutil from pathlib import Path import cv2 import numpy as np from PIL import Image, ImageDraw HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parents[3] ATLAS = HERE / "imagegen" / "eye-expression-atlas-v1.png" OUT = HERE / "imagegen" / "eye-variants" PARTS = OUT / "parts" APP_PARTS = REPO_ROOT / "apps/web/public/avatar/seoyeon-live2d-psb/parts" CANVAS = (900, 1125) TARGETS = { "joy": { "brow-left": (308, 292, 436, 317), "brow-right": (472, 283, 601, 300), "eye-left": (296, 312, 436, 365), "eye-right": (472, 303, 619, 356), }, "sad": { "brow-left": (308, 294, 436, 322), "brow-right": (472, 286, 601, 308), "eye-left": (296, 306, 436, 392), "eye-right": (472, 297, 619, 377), }, "startled": { "brow-left": (308, 286, 436, 313), "brow-right": (472, 277, 601, 300), "eye-left": (296, 292, 436, 392), "eye-right": (472, 283, 619, 377), }, } ROW_NAMES = ["joy", "sad", "startled"] ROW_PARTS = ["brow-left", "brow-right", "eye-left", "eye-right"] def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: alpha = np.array(im.convert("RGBA").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 chroma_to_alpha(im: Image.Image) -> Image.Image: arr_u8 = np.array(im.convert("RGBA")) rgb_u8 = arr_u8[:, :, :3] hsv = cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2HSV) hue = hsv[:, :, 0] sat = hsv[:, :, 1] val = hsv[:, :, 2] green_bg = (hue >= 42) & (hue <= 92) & (sat > 45) & (val > 18) # Keep a tiny soft edge by making only clearly non-green pixels opaque. alpha = np.where(green_bg, 0, 255).astype(np.uint8) alpha = cv2.medianBlur(alpha, 3) arr = arr_u8.astype(np.float32) rgb = arr[:, :, :3] greenish_edge = (rgb[:, :, 1] > rgb[:, :, 0] + 8) & (rgb[:, :, 1] > rgb[:, :, 2] + 8) rgb[:, :, 1] = np.where(greenish_edge, np.minimum(rgb[:, :, 1], np.maximum(rgb[:, :, 0], rgb[:, :, 2]) + 12), rgb[:, :, 1]) arr[:, :, :3] = rgb arr[:, :, 3] = alpha arr[arr[:, :, 3] < 2, :3] = 0 return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") def component_boxes(alpha_im: Image.Image) -> list[tuple[int, int, int, int]]: alpha = np.array(alpha_im.getchannel("A")) mask = (alpha > 22).astype(np.uint8) num, _labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, 8) boxes: list[tuple[int, int, int, int, int]] = [] for i in range(1, num): x, y, w, h, area = stats[i] if area > 200: boxes.append((int(x), int(y), int(x + w), int(y + h), int(area))) boxes.sort(key=lambda box: (box[1], box[0])) if len(boxes) != 12: raise RuntimeError(f"Expected 12 eye atlas components, got {len(boxes)}: {boxes}") return [(x0, y0, x1, y1) for x0, y0, x1, y1, _area in boxes] def crop_with_padding(im: Image.Image, box: tuple[int, int, int, int], padding: int = 10) -> Image.Image: x0, y0, x1, y1 = box x0 = max(0, x0 - padding) y0 = max(0, y0 - padding) x1 = min(im.width, x1 + padding) y1 = min(im.height, y1 + padding) return im.crop((x0, y0, x1, y1)) def fit_into_canvas(crop: Image.Image, target: tuple[int, int, int, int]) -> Image.Image: target_w = target[2] - target[0] target_h = target[3] - target[1] box = alpha_bbox(crop) source = crop.crop(box) if box else crop scale = min(target_w / source.width, target_h / source.height) size = (max(1, round(source.width * scale)), max(1, round(source.height * scale))) source = source.resize(size, Image.Resampling.LANCZOS) canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0)) x = target[0] + (target_w - source.width) // 2 y = target[1] + (target_h - source.height) // 2 canvas.alpha_composite(source, (x, y)) return canvas def make_contact(entries: list[dict[str, object]]) -> None: tile_w, tile_h = 180, 150 cols = 4 rows = 3 sheet = Image.new("RGBA", (tile_w * cols, tile_h * rows), (30, 39, 36, 255)) draw = ImageDraw.Draw(sheet) for i, entry in enumerate(entries): part = Image.open(PARTS / str(entry["file"])).convert("RGBA") box = alpha_bbox(part) thumb = part.crop(box) if box else part thumb.thumbnail((tile_w - 24, tile_h - 42), Image.Resampling.LANCZOS) x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 y = (i // cols) * tile_h + 10 sheet.alpha_composite(thumb, (x, y)) draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 30), str(entry["id"]), fill=(220, 228, 224, 255)) draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 15), str(entry["alphaBox"]), fill=(150, 166, 160, 255)) sheet.save(OUT / "eye-variants-contact.png", optimize=True) def sync_to_app() -> None: APP_PARTS.mkdir(parents=True, exist_ok=True) for source in PARTS.glob("*.png"): shutil.copy2(source, APP_PARTS / source.name) def main() -> int: if not ATLAS.exists(): raise FileNotFoundError(ATLAS) OUT.mkdir(parents=True, exist_ok=True) PARTS.mkdir(parents=True, exist_ok=True) keyed = chroma_to_alpha(Image.open(ATLAS)) keyed.save(OUT / "eye-expression-atlas-alpha.png", optimize=True) boxes = component_boxes(keyed) entries: list[dict[str, object]] = [] for row_index, variant in enumerate(ROW_NAMES): row_boxes = boxes[row_index * 4 : row_index * 4 + 4] for part_name, source_box in zip(ROW_PARTS, row_boxes): part_id = f"eyegen-{variant}-{part_name}" crop = crop_with_padding(keyed, source_box) canvas = fit_into_canvas(crop, TARGETS[variant][part_name]) path = PARTS / f"{part_id}.png" canvas.save(path, optimize=True) box = alpha_bbox(canvas) entries.append( { "id": part_id, "file": path.name, "sourceBox": list(source_box), "targetBox": list(TARGETS[variant][part_name]), "alphaBox": list(box) if box else None, } ) (OUT / "eye-variants-manifest.json").write_text( json.dumps( { "source": str(ATLAS), "canvas": CANVAS, "variants": ROW_NAMES, "parts": entries, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) make_contact(entries) sync_to_app() print(f"extracted {len(entries)} imagegen eye variant parts") print(f"parts {PARTS}") print(f"app parts {APP_PARTS}") return 0 if __name__ == "__main__": raise SystemExit(main())