#!/usr/bin/env python3 """Export app-ready Live2D-style parts from the v2 PSD. This reuses the PSB detailed-parts helpers but keeps the v2 PSD as a distinct art set so previous assets remain auditable. """ from __future__ import annotations import importlib.util import json import shutil from pathlib import Path from typing import Sequence import numpy as np from PIL import Image, ImageDraw from psd_tools import PSDImage from scipy import ndimage as ndi HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parents[3] BASE_SCRIPT = HERE.parent / "live2d-psb" / "export_detailed_parts.py" PSD = HERE / "source-latudi-female-v2.psd" OUT = HERE / "detailed-parts" PARTS = OUT / "parts" APP_PARTS = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "seoyeon-live2d-psd-v2" / "parts" def load_base_module(): spec = importlib.util.spec_from_file_location("seoyeon_psb_export", BASE_SCRIPT) if spec is None or spec.loader is None: raise RuntimeError(f"cannot load {BASE_SCRIPT}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module.HERE = HERE module.REPO_ROOT = REPO_ROOT module.PSB = PSD module.OUT = OUT module.PARTS = PARTS module.APP_PARTS = APP_PARTS return module B = load_base_module() N = dict(B.N) N.update( { "cry_brow_left": "좌 울상 눈썹 ", "cry_brow_right": "우 울상 눈썹", "closed_eye_left": "좌 감은눈", "closed_eye_right": "우 감은눈", "tears": "눈물", "tear_left": "좌 눈물", "tear_right": "우 눈물", "cry_mouth": "우는 입", } ) NEUTRAL_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", ] SAD_LAYER_ORDER = [ *NEUTRAL_LAYER_ORDER[:24], "brow-sad-left", "brow-sad-right", "tear-left", "tear-right", "nose", "hair-side-left-1", "hair-side-right-1", "hair-side-left-2", "hair-side-right-2", "hair-bangs", "mouth-sad", ] def visible(psd: PSDImage, root: Sequence[str], *, hidden: Sequence[Sequence[str]] = ()) -> Image.Image: return B.composite_with_visibility(psd, root, visible_paths=[root], hidden_paths=hidden) def enhance_tears(image: Image.Image) -> Image.Image: arr = np.array(image.convert("RGBA")).astype(np.float32) alpha = arr[:, :, 3] spread = ndi.maximum_filter(alpha, size=3) alpha = np.maximum(alpha, spread * 0.72) alpha = np.clip(alpha * 1.32, 0, 255) mask = alpha > 4 cool = np.array([226, 241, 255], dtype=np.float32) arr[mask, :3] = arr[mask, :3] * 0.34 + cool * 0.66 arr[:, :, 3] = alpha arr[arr[:, :, 3] < 1, :3] = 0 return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") def save_enhanced_part(entries: list[dict[str, object]], part_id: str, source: str, image: Image.Image) -> Image.Image: normalized = B.normalize(image) if image.size == B.SOURCE_CANVAS else image.convert("RGBA") normalized = enhance_tears(normalized) path = PARTS / f"{part_id}.png" normalized.save(path, optimize=True) box = B.alpha_bbox(normalized) entries.append( { "id": part_id, "source": source, "file": path.name, "alphaBox": list(box) if box else None, "originPercent": B.origin_for(part_id, box), } ) return normalized def make_preview(name: str, order: Sequence[str]) -> None: preview = Image.new("RGBA", B.TARGET_CANVAS, (30, 39, 36, 255)) transparent = B.blank(B.TARGET_CANVAS) for part_id in order: layer = Image.open(PARTS / f"{part_id}.png").convert("RGBA") preview.alpha_composite(layer) transparent.alpha_composite(layer) preview.save(OUT / f"preview-{name}.png", optimize=True) transparent.save(OUT / f"preview-{name}-transparent.png", optimize=True) if name == "neutral": preview.save(OUT / "preview-detailed-parts.png", optimize=True) transparent.save(OUT / "preview-detailed-parts-transparent.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 PSD.exists(): raise FileNotFoundError(PSD) OUT.mkdir(parents=True, exist_ok=True) PARTS.mkdir(parents=True, exist_ok=True) psd = PSDImage.open(PSD) 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"]]), ("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"]]), ] for part_id, source, path in simple_parts: B.save_part(entries, part_id, source, B.composite_path(psd, path)) B.save_part(entries, "brow-left", "hidden neutral left brow", visible(psd, [N["eye_left"], N["brow"]])) B.save_part(entries, "brow-right", "hidden neutral right brow", visible(psd, [N["eye_right"], N["brow_copy"]])) B.save_part(entries, "brow-sad-left", "v2 crying left brow", visible(psd, [N["cry_brow_left"]])) B.save_part(entries, "brow-sad-right", "v2 crying right brow", visible(psd, [N["cry_brow_right"]])) save_enhanced_part(entries, "tear-left", "v2 left tear layer", visible(psd, [N["tears"], N["tear_left"]])) save_enhanced_part(entries, "tear-right", "v2 right tear layer", visible(psd, [N["tears"], N["tear_right"]])) hair_back_base = B.composite_many( [ B.composite_path(psd, [N["back_extra_left"]]), B.composite_path(psd, [N["back_extra_right"]]), ] ) B.save_part(entries, "hair-back-base", "top extra back hair layers", hair_back_base) neutral_mouth = B.save_part( entries, "mouth-neutral", "hidden smile mouth group", visible(psd, [N["mouth"], N["smile"]], hidden=[[N["mouth"], N["cry_mouth"]]]), ) sad_mouth = B.save_part( entries, "mouth-sad", "v2 crying mouth group", visible(psd, [N["mouth"], N["cry_mouth"]], hidden=[[N["mouth"], N["smile"]]]), ) 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 = B.save_part( entries, "mouth-open", "hidden open mouth groups", B.composite_with_visibility( psd, [N["mouth"]], visible_paths=mouth_open_groups, hidden_paths=[[N["mouth"], N["smile"]], [N["mouth"], N["cry_mouth"]]], ), ) B.save_part(entries, "mouth-warm", "derived from neutral mouth bbox", B.paste_scaled(neutral_mouth, 1.12, 1.02, offset_y=-1)) B.save_part(entries, "mouth-tired", "derived from sad mouth bbox", B.paste_scaled(sad_mouth, 0.9, 0.82, offset_y=0)) B.save_part(entries, "mouth-anxious", "derived from sad mouth bbox", B.paste_scaled(sad_mouth, 0.82, 0.86, offset_y=0)) B.save_part(entries, "mouth-startled", "derived from mouth-open bbox", B.paste_scaled(mouth_open, 0.68, 0.72, offset_y=-1)) B.save_part(entries, "mouth-open-small", "derived from mouth-open bbox", B.paste_scaled(mouth_open, 0.86, 0.62, offset_y=-1)) make_preview("neutral", NEUTRAL_LAYER_ORDER) make_preview("sad", SAD_LAYER_ORDER) B.make_contact(entries) B.make_imagegen_harness(entries) (OUT / "detailed-parts-manifest.json").write_text( json.dumps( { "source": str(PSD), "sourceCanvas": B.SOURCE_CANVAS, "targetCanvas": B.TARGET_CANVAS, "cropBox": B.CROP_BOX, "neutralLayerOrder": NEUTRAL_LAYER_ORDER, "sadLayerOrder": SAD_LAYER_ORDER, "parts": entries, }, 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}") return 0 if __name__ == "__main__": raise SystemExit(main())