304 lines
11 KiB
Python
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())
|