vignette/docs/avatar-art/seoyeon/live2d-psd-v2/export_detailed_parts.py
2026-06-28 12:18:20 +09:00

304 lines
11 KiB
Python

#!/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())