517 lines
20 KiB
Python
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())
|