vignette/docs/avatar-art/seoyeon/live2d-psb/export_detailed_parts.py
2026-06-27 11:20:24 +09:00

517 lines
20 KiB
Python

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