#!/usr/bin/env python3 """Export detailed app-ready Live2D-style raster parts from the source PSB. All exported PNGs keep the same normalized 900x1125 canvas. That makes every part usable by the renderer without per-image CSS sizing hacks, and gives image-generation follow-up work a stable coordinate harness. """ from __future__ import annotations import json import math import shutil from collections.abc import Iterable, Sequence from pathlib import Path import numpy as np from PIL import Image, ImageDraw from psd_tools import PSDImage HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parents[3] PSB = Path( "C:/Users/encep/OneDrive/" "\ubb38\uc11c/\uce74\uce74\uc624\ud1a1 \ubc1b\uc740 \ud30c\uc77c/" "\ub77c\ud22c\ub514 \uc5ec\uce90.psb" ) OUT = HERE / "detailed-parts" PARTS = OUT / "parts" APP_PARTS = REPO_ROOT / "apps/web/public/avatar/seoyeon-live2d-psb/parts" SOURCE_CANVAS = (1080, 1920) TARGET_CANVAS = (900, 1125) CROP_BOX = (0, 240, 1080, 1590) def k(name: str) -> str: """Keep the source file ASCII while addressing Korean PSB layer names.""" return name.encode("utf-8").decode("unicode_escape") N = { "paper": k("\\uc6a9\\uc9c0"), "back_hair": k("\\ub4b7\\uba38\\ub9ac"), "back_hair_left": k("\\uc88c \\ub4b7\\uba38\\ub9ac"), "back_hair_right": k("\\uc6b0 \\ub4b7\\uba38\\ub9ac"), "body": k("\\ubab8"), "neck": k("\\ubaa9"), "clavicle": k("\\uc1c4\\uace8"), "outfit": k("\\uc758\\uc0c1"), "inner_outfit": k("\\uc548\\uc637"), "outer_outfit": k("\\uac89\\uc637"), "ribbon": k("\\ub9ac\\ubcf8"), "face": k("\\uc5bc\\uad74"), "ear_left": k("\\uadc0 \\uc88c"), "ear_right": k("\\uadc0\\uc6b0"), "face_base": k("\\uc5bc\\uad74 \\ubca0\\uc774\\uc2a4"), "blush_left": k("\\uc88c \\ud64d\\uc870"), "blush_right": k("\\uc6b0 \\ud64d\\uc870"), "nose": k("\\ucf54"), "eye_left": k("\\uc88c \\ub208"), "eye_right": k("\\uc6b0 \\ub208"), "white": k("\\ud770 \\uc790"), "white_copy": k("\\ud770 \\uc790 \\ubcf5\\uc0ac"), "iris": k("\\ud64d\\ucc44"), "iris_copy": k("\\ud64d\\ucc44 \\ubcf5\\uc0ac"), "pupil": k("\\ub3d9\\uacf5"), "pupil_copy": k("\\ub3d9\\uacf5 \\ubcf5\\uc0ac"), "highlight": k("\\ub208\\ub3d9\\uc790 \\ud558\\uc774\\ub77c\\uc774\\ud2b8"), "highlight_copy": k("\\ub208\\ub3d9\\uc790 \\ud558\\uc774\\ub77c\\uc774\\ud2b8 \\ubcf5\\uc0ac"), "lash": k("\\uc18d\\ub208\\uc379"), "lash_copy": k("\\uc18d\\ub208\\uc379 \\ubcf5\\uc0ac"), "upper_lid": k("\\uc717 \\ub208\\uaebc\\ud480"), "upper_lid_copy": k("\\uc717 \\ub208\\uaebc\\ud480 \\ubcf5\\uc0ac"), "lower_lid": k("\\uc544\\ub7ab \\ub208\\uaebc\\ud480"), "lower_lid_copy": k("\\uc544\\ub7ab \\ub208\\uaebc\\ud480 \\ubcf5\\uc0ac"), "brow": k("\\ub208\\uc379"), "brow_copy": k("\\ub208\\uc379 \\ubcf5\\uc0ac"), "front_hair": k("\\uba38\\ub9ac\\uce74\\ub77d"), "bangs": k("\\uc55e\\uba38\\ub9ac"), "side_left_2": k("\\uc88c \\uc606\\uba38\\ub9ac2"), "side_right_2": k("\\uc6b0 \\uc606\\uba38\\ub9ac 2"), "side_right_1": k("\\uc6b0 \\uc606\\uba38\\ub9ac 1"), "side_left_1": k("\\uc88c \\uc606\\uba38\\ub9ac 1"), "mouth": k("\\uc785"), "mouth_inner": k("\\uc785\\uc548"), "upper_teeth": k("\\uc717\\ub2c8"), "lower_side_teeth": k("\\uc544\\ub798\\uc606\\ub2c8"), "tongue": k("\\ud600"), "lower_teeth": k("\\uc544\\ub7ab\\ub2c8"), "upper_lip": k("\\uc785\\uc220 \\uc704"), "lower_lip": k("\\uc785 \\uc220 \\uc544\\ub798"), "smile": k("\\ubbf8\\uc18c"), "back_extra_left": k("\\ub808\\uc774\\uc5b4 110 \\ubcf5\\uc0ac"), "back_extra_right": k("\\ub808\\uc774\\uc5b4 110"), } LAYER_ORDER = [ "hair-back-base", "hair-back-left", "hair-back-right", "body", "outfit", "ear-left", "ear-right", "face-base", "blush-left", "blush-right", "eye-white-left", "eye-white-right", "iris-left", "iris-right", "pupil-left", "pupil-right", "highlight-left", "highlight-right", "lash-left", "lash-right", "eyelid-upper-left", "eyelid-upper-right", "eyelid-lower-left", "eyelid-lower-right", "brow-left", "brow-right", "nose", "hair-side-left-1", "hair-side-right-1", "hair-side-left-2", "hair-side-right-2", "hair-bangs", "mouth-neutral", ] 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 resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image: arr = np.array(im.convert("RGBA")).astype(np.float32) alpha = arr[:, :, 3:4] / 255.0 premultiplied = arr.copy() premultiplied[:, :, :3] *= alpha resized = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGBA").resize( size, Image.Resampling.LANCZOS, ) out = np.array(resized).astype(np.float32) out_alpha = out[:, :, 3:4] / 255.0 out[:, :, :3] = np.where(out_alpha > 0.001, out[:, :, :3] / np.maximum(out_alpha, 0.001), 0) out[out[:, :, 3] < 1, :3] = 0 return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA") def normalize(full_canvas: Image.Image) -> Image.Image: return resize_rgba(full_canvas.crop(CROP_BOX), TARGET_CANVAS) def blank(size: tuple[int, int] = SOURCE_CANVAS) -> Image.Image: return Image.new("RGBA", size, (0, 0, 0, 0)) def find_path(root, path: Sequence[str]): current = root for name in path: for child in current: if child.name == name: current = child break else: rendered = " / ".join(item.encode("unicode_escape").decode() for item in path) raise KeyError(f"Missing PSB layer path: {rendered}") return current def composite_layer(layer) -> Image.Image: im = layer.composite(viewport=(0, 0, SOURCE_CANVAS[0], SOURCE_CANVAS[1]), force=True) return im.convert("RGBA") if im else blank() def composite_path(psd: PSDImage, path: Sequence[str]) -> Image.Image: return composite_layer(find_path(psd, path)) def composite_many(images: Iterable[Image.Image]) -> Image.Image: canvas = blank() for image in images: canvas.alpha_composite(image.convert("RGBA")) return canvas def composite_with_visibility( psd: PSDImage, root_path: Sequence[str], visible_paths: Sequence[Sequence[str]], hidden_paths: Sequence[Sequence[str]] = (), ) -> Image.Image: changed = [] try: for path in visible_paths: layer = find_path(psd, path) changed.append((layer, layer.visible)) layer.visible = True for path in hidden_paths: layer = find_path(psd, path) changed.append((layer, layer.visible)) layer.visible = False return composite_path(psd, root_path) finally: for layer, visible in reversed(changed): layer.visible = visible def dominant_color(im: Image.Image) -> tuple[int, int, int, int]: arr = np.array(im.convert("RGBA")) mask = arr[:, :, 3] > 24 if not np.any(mask): return (130, 72, 72, 255) rgb = np.median(arr[mask][:, :3], axis=0) alpha = np.percentile(arr[mask][:, 3], 85) return (int(rgb[0]), int(rgb[1]), int(rgb[2]), int(max(alpha, 220))) def paste_scaled( source: Image.Image, scale_x: float, scale_y: float, offset_y: int = 0, rotate: float = 0, ) -> Image.Image: source = source.convert("RGBA") box = alpha_bbox(source) if box is None: return blank(TARGET_CANVAS) crop = source.crop(box) new_size = ( max(1, int(round(crop.width * scale_x))), max(1, int(round(crop.height * scale_y))), ) crop = crop.resize(new_size, Image.Resampling.LANCZOS) if rotate: crop = crop.rotate(rotate, resample=Image.Resampling.BICUBIC, expand=True) cx = (box[0] + box[2]) // 2 cy = (box[1] + box[3]) // 2 + offset_y out = blank(TARGET_CANVAS) out.alpha_composite(crop, (cx - crop.width // 2, cy - crop.height // 2)) return out def make_curve_mouth(reference: Image.Image, curve: float, width_scale: float = 1.0) -> Image.Image: ref = reference.convert("RGBA") box = alpha_bbox(ref) out = blank(TARGET_CANVAS) if box is None: return out color = dominant_color(ref) cx = (box[0] + box[2]) / 2 cy = (box[1] + box[3]) / 2 + 1 width = max(38, (box[2] - box[0]) * width_scale) height = max(7, (box[3] - box[1]) * 0.48) points = [] for i in range(24): t = i / 23 x = cx - width / 2 + width * t y = cy + math.sin(t * math.pi) * curve * height points.append((x, y)) draw = ImageDraw.Draw(out) shadow = (max(0, color[0] - 36), max(0, color[1] - 26), max(0, color[2] - 20), 92) draw.line([(x, y + 1.4) for x, y in points], fill=shadow, width=4, joint="curve") draw.line(points, fill=color, width=3, joint="curve") return out.filter(Image.Resampling.BICUBIC) if False else out def origin_for(part_id: str, box: tuple[int, int, int, int] | None) -> dict[str, float]: if not box: return {"x": 50.0, "y": 50.0} x0, y0, x1, y1 = box cx = (x0 + x1) / 2 / TARGET_CANVAS[0] * 100 if part_id.startswith("hair-back"): y = (y0 + (y1 - y0) * 0.08) / TARGET_CANVAS[1] * 100 elif part_id.startswith("hair-side"): y = (y0 + (y1 - y0) * 0.12) / TARGET_CANVAS[1] * 100 elif part_id == "hair-bangs": y = (y0 + (y1 - y0) * 0.08) / TARGET_CANVAS[1] * 100 elif part_id.startswith("brow"): y = (y0 + y1) / 2 / TARGET_CANVAS[1] * 100 else: y = (y0 + y1) / 2 / TARGET_CANVAS[1] * 100 return {"x": round(cx, 2), "y": round(y, 2)} def save_part(entries: list[dict[str, object]], part_id: str, source: str, image: Image.Image) -> Image.Image: normalized = normalize(image) if image.size == SOURCE_CANVAS else image.convert("RGBA") path = PARTS / f"{part_id}.png" normalized.save(path, optimize=True) box = alpha_bbox(normalized) entries.append( { "id": part_id, "source": source, "file": path.name, "alphaBox": list(box) if box else None, "originPercent": origin_for(part_id, box), } ) return normalized def draw_label(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str) -> None: x, y = xy draw.rectangle((x - 3, y - 2, x + len(text) * 6 + 4, y + 12), fill=(22, 29, 27, 210)) draw.text((x, y), text, fill=(230, 236, 232, 255)) def make_contact(entries: list[dict[str, object]]) -> None: tile_w, tile_h = 180, 180 cols = 5 rows = math.ceil(len(entries) / cols) sheet = Image.new("RGBA", (tile_w * cols, tile_h * rows), (30, 39, 36, 255)) draw = ImageDraw.Draw(sheet) for i, entry in enumerate(entries): layer = Image.open(PARTS / str(entry["file"])).convert("RGBA") box = alpha_bbox(layer) thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box) thumb.thumbnail((tile_w - 22, tile_h - 44), Image.Resampling.LANCZOS) x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 y = (i // cols) * tile_h + 12 sheet.alpha_composite(thumb, (x, y)) draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 28), str(entry["id"]), fill=(210, 222, 216, 255)) draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 14), str(entry["alphaBox"]), fill=(150, 166, 160, 255)) sheet.save(OUT / "detailed-parts-contact.png", optimize=True) def make_preview(layer_order: Sequence[str]) -> None: preview = Image.new("RGBA", TARGET_CANVAS, (30, 39, 36, 255)) transparent = blank(TARGET_CANVAS) for part_id in layer_order: im = Image.open(PARTS / f"{part_id}.png").convert("RGBA") preview.alpha_composite(im) transparent.alpha_composite(im) preview.save(OUT / "preview-detailed-parts.png", optimize=True) transparent.save(OUT / "preview-detailed-parts-transparent.png", optimize=True) def make_imagegen_harness(entries: list[dict[str, object]]) -> None: base = Image.open(OUT / "preview-detailed-parts.png").convert("RGBA") draw = ImageDraw.Draw(base) focus = { "hair-back-left", "hair-back-right", "hair-side-left-1", "hair-side-right-1", "hair-side-left-2", "hair-side-right-2", "hair-bangs", "eye-white-left", "eye-white-right", "iris-left", "iris-right", "brow-left", "brow-right", "mouth-neutral", "mouth-open", } for entry in entries: if entry["id"] not in focus or not entry["alphaBox"]: continue x0, y0, x1, y1 = entry["alphaBox"] color = (120, 223, 177, 210) if str(entry["id"]).startswith("hair") else (255, 214, 92, 220) if str(entry["id"]).startswith("mouth"): color = (255, 128, 128, 220) draw.rectangle((x0, y0, x1, y1), outline=color, width=2) origin = entry["originPercent"] ox = int(float(origin["x"]) / 100 * TARGET_CANVAS[0]) oy = int(float(origin["y"]) / 100 * TARGET_CANVAS[1]) draw.ellipse((ox - 4, oy - 4, ox + 4, oy + 4), fill=color) draw_label(draw, (x0, max(0, y0 - 14)), str(entry["id"])) base.save(OUT / "imagegen-coordinate-harness.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 PSB.exists(): raise FileNotFoundError(PSB) OUT.mkdir(parents=True, exist_ok=True) PARTS.mkdir(parents=True, exist_ok=True) psd = PSDImage.open(PSB) entries: list[dict[str, object]] = [] simple_parts: list[tuple[str, str, Sequence[str]]] = [ ("hair-back-left", "back hair left group", [N["back_hair"], N["back_hair_left"]]), ("hair-back-right", "back hair right group", [N["back_hair"], N["back_hair_right"]]), ("body", "body group", [N["body"]]), ("neck", "neck group", [N["body"], N["neck"]]), ("clavicle", "clavicle group", [N["body"], N["clavicle"]]), ("outfit", "outfit group", [N["outfit"]]), ("outfit-inner", "inner outfit group", [N["outfit"], N["inner_outfit"]]), ("outfit-outer", "outer outfit group", [N["outfit"], N["outer_outfit"]]), ("ribbon", "ribbon group", [N["outfit"], N["ribbon"]]), ("face", "face group", [N["face"]]), ("face-base", "face base group", [N["face"], N["face_base"]]), ("ear-left", "left ear group", [N["face"], N["ear_left"]]), ("ear-right", "right ear group", [N["face"], N["ear_right"]]), ("blush-left", "left blush group", [N["face"], N["blush_left"]]), ("blush-right", "right blush group", [N["face"], N["blush_right"]]), ("nose", "nose group", [N["face"], N["nose"]]), ("eye-left", "left eye group", [N["eye_left"]]), ("eye-right", "right eye group", [N["eye_right"]]), ("eye-white-left", "left eye white group", [N["eye_left"], N["white"]]), ("eye-white-right", "right eye white group", [N["eye_right"], N["white_copy"]]), ("iris-left", "left iris group", [N["eye_left"], N["iris"]]), ("iris-right", "right iris group", [N["eye_right"], N["iris_copy"]]), ("pupil-left", "left pupil group", [N["eye_left"], N["pupil"]]), ("pupil-right", "right pupil group", [N["eye_right"], N["pupil_copy"]]), ("highlight-left", "left eye highlight group", [N["eye_left"], N["highlight"]]), ("highlight-right", "right eye highlight group", [N["eye_right"], N["highlight_copy"]]), ("lash-left", "left lash group", [N["eye_left"], N["lash"]]), ("lash-right", "right lash group", [N["eye_right"], N["lash_copy"]]), ("eyelid-upper-left", "left upper eyelid group", [N["eye_left"], N["upper_lid"]]), ("eyelid-upper-right", "right upper eyelid group", [N["eye_right"], N["upper_lid_copy"]]), ("eyelid-lower-left", "left lower eyelid group", [N["eye_left"], N["lower_lid"]]), ("eyelid-lower-right", "right lower eyelid group", [N["eye_right"], N["lower_lid_copy"]]), ("brow-left", "left brow group", [N["eye_left"], N["brow"]]), ("brow-right", "right brow group", [N["eye_right"], N["brow_copy"]]), ("hair-front", "front hair group", [N["front_hair"]]), ("hair-bangs", "bangs group", [N["front_hair"], N["bangs"]]), ("hair-side-left-1", "left side hair 1 group", [N["front_hair"], N["side_left_1"]]), ("hair-side-left-2", "left side hair 2 group", [N["front_hair"], N["side_left_2"]]), ("hair-side-right-1", "right side hair 1 group", [N["front_hair"], N["side_right_1"]]), ("hair-side-right-2", "right side hair 2 group", [N["front_hair"], N["side_right_2"]]), ("mouth-neutral", "mouth smile group", [N["mouth"], N["smile"]]), ] for part_id, source, path in simple_parts: save_part(entries, part_id, source, composite_path(psd, path)) hair_back_base = composite_many( [ composite_path(psd, [N["back_extra_left"]]), composite_path(psd, [N["back_extra_right"]]), ] ) save_part(entries, "hair-back-base", "top extra back hair layers", hair_back_base) mouth_open_groups = [ [N["mouth"], N["mouth_inner"]], [N["mouth"], N["upper_teeth"]], [N["mouth"], N["lower_side_teeth"]], [N["mouth"], N["tongue"]], [N["mouth"], N["lower_teeth"]], [N["mouth"], N["upper_lip"]], [N["mouth"], N["lower_lip"]], ] mouth_open = save_part( entries, "mouth-open", "hidden open mouth groups", composite_with_visibility( psd, [N["mouth"]], visible_paths=mouth_open_groups, hidden_paths=[[N["mouth"], N["smile"]]], ), ) mouth_neutral = Image.open(PARTS / "mouth-neutral.png").convert("RGBA") save_part(entries, "mouth-warm", "derived from mouth-neutral bbox", paste_scaled(mouth_neutral, 1.12, 1.02, offset_y=-1)) save_part(entries, "mouth-tired", "derived from mouth-neutral bbox", make_curve_mouth(mouth_neutral, curve=0.15, width_scale=0.86)) save_part(entries, "mouth-sad", "derived from mouth-neutral bbox", make_curve_mouth(mouth_neutral, curve=-1.0, width_scale=0.9)) save_part(entries, "mouth-anxious", "derived from mouth-neutral bbox", paste_scaled(mouth_neutral, 0.76, 0.82, offset_y=0)) save_part(entries, "mouth-startled", "derived from mouth-open bbox", paste_scaled(mouth_open, 0.68, 0.72, offset_y=-1)) save_part(entries, "mouth-open-small", "derived from mouth-open bbox", paste_scaled(mouth_open, 0.86, 0.62, offset_y=-1)) make_preview(LAYER_ORDER) make_contact(entries) make_imagegen_harness(entries) (OUT / "detailed-parts-manifest.json").write_text( json.dumps( { "source": str(PSB), "sourceCanvas": SOURCE_CANVAS, "targetCanvas": TARGET_CANVAS, "cropBox": CROP_BOX, "layerOrder": LAYER_ORDER, "parts": entries, "imageGenerationHarness": { "canvas": TARGET_CANVAS, "rule": "Generate each replacement part on this exact canvas, transparent outside the part bbox, matching alphaBox and originPercent.", "file": "imagegen-coordinate-harness.png", }, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) sync_to_app() print(f"exported {len(entries)} detailed parts") print(f"parts {PARTS}") print(f"app parts {APP_PARTS}") print(f"harness {OUT / 'imagegen-coordinate-harness.png'}") return 0 if __name__ == "__main__": raise SystemExit(main())