Files
Dansori_Characters/tools/build_leesori_solo3_dance_puppet.py
2026-07-04 10:34:46 +09:00

126 lines
6.3 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(r"D:\Work_AI\Dansori\Characters_Build_Docs")
DANCE = ROOT / "LeeSori_Live2D" / "03_Assets" / "Dance" / "SoloDance3"
SRC = DANCE / "pose01_dance_ready.png"
RIG_DIR = DANCE / "Rig"
IMAGES = RIG_DIR / "Images"
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
def prepare_source() -> Image.Image:
src = Image.open(SRC).convert("RGBA")
src = src.crop(alpha_bbox(src))
target_h = 1320
target_w = int(round(src.width * target_h / src.height))
src = src.resize((target_w, target_h), Image.Resampling.LANCZOS)
margin_x = 96
margin_y = 64
out = Image.new("RGBA", (target_w + margin_x * 2, target_h + margin_y * 2), (0, 0, 0, 0))
out.alpha_composite(src, (margin_x, margin_y))
return out
def mask_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 0.8) -> Image.Image:
w, h = size
pts = [(int(round(x * w)), int(round(y * h))) for x, y in points]
mask = Image.new("L", size, 0)
ImageDraw.Draw(mask).polygon(pts, fill=255)
if blur:
mask = mask.filter(ImageFilter.GaussianBlur(blur))
return mask
def save_masked(src: Image.Image, name: str, points: list[tuple[float, float]], blur: float = 0.8) -> None:
mask = mask_polygon(src.size, points, blur)
part = src.copy()
alpha = Image.composite(part.getchannel("A"), Image.new("L", src.size, 0), mask)
part.putalpha(alpha)
part.save(IMAGES / name)
def save_empty(size: tuple[int, int], name: str) -> None:
Image.new("RGBA", size, (0, 0, 0, 0)).save(IMAGES / name)
def composite(parts: list[str], dest: Path) -> None:
sample = Image.open(IMAGES / parts[0]).convert("RGBA")
canvas = Image.new("RGBA", sample.size, (18, 18, 20, 255))
for part_name in parts:
canvas.alpha_composite(Image.open(IMAGES / part_name).convert("RGBA"))
canvas.convert("RGB").save(dest)
def main() -> None:
IMAGES.mkdir(parents=True, exist_ok=True)
src = prepare_source()
w, h = src.size
src.save(RIG_DIR / "leesori_solo3_source.png")
save_empty(src.size, "solo3_base.png")
# Broad overlapping masks are intentional. They avoid visible cracks during CSS rotation.
save_masked(src, "solo3_head.png", [(0.31, 0.02), (0.69, 0.02), (0.72, 0.22), (0.61, 0.29), (0.39, 0.29), (0.28, 0.22)], 0.5)
save_masked(src, "solo3_chest.png", [(0.24, 0.20), (0.76, 0.20), (0.72, 0.48), (0.28, 0.48)], 0.5)
save_masked(src, "solo3_pelvis.png", [(0.28, 0.43), (0.72, 0.43), (0.74, 0.63), (0.26, 0.63)], 0.7)
save_masked(src, "solo3_upperarm_l.png", [(0.11, 0.25), (0.33, 0.23), (0.34, 0.45), (0.16, 0.51), (0.08, 0.40)], 1.0)
save_masked(src, "solo3_forearm_l.png", [(0.08, 0.43), (0.28, 0.39), (0.24, 0.61), (0.02, 0.64)], 1.0)
save_masked(src, "solo3_hand_l.png", [(0.00, 0.58), (0.21, 0.55), (0.23, 0.69), (0.02, 0.72)], 0.7)
save_masked(src, "solo3_upperarm_r.png", [(0.67, 0.23), (0.89, 0.25), (0.92, 0.41), (0.84, 0.51), (0.66, 0.45)], 1.0)
save_masked(src, "solo3_forearm_r.png", [(0.72, 0.39), (0.92, 0.43), (0.98, 0.64), (0.76, 0.61)], 1.0)
save_masked(src, "solo3_hand_r.png", [(0.78, 0.55), (1.00, 0.58), (0.98, 0.72), (0.77, 0.69)], 0.7)
save_masked(src, "solo3_thigh_l.png", [(0.28, 0.55), (0.50, 0.54), (0.49, 0.76), (0.29, 0.78)], 0.8)
save_masked(src, "solo3_shin_l.png", [(0.29, 0.73), (0.49, 0.72), (0.48, 0.93), (0.31, 0.94)], 0.8)
save_masked(src, "solo3_foot_l.png", [(0.27, 0.90), (0.50, 0.90), (0.49, 1.00), (0.25, 1.00)], 0.6)
save_masked(src, "solo3_thigh_r.png", [(0.48, 0.54), (0.74, 0.55), (0.81, 0.78), (0.56, 0.77)], 0.8)
save_masked(src, "solo3_shin_r.png", [(0.57, 0.72), (0.82, 0.73), (0.86, 0.94), (0.65, 0.94)], 0.8)
save_masked(src, "solo3_foot_r.png", [(0.64, 0.90), (0.88, 0.90), (0.90, 1.00), (0.62, 1.00)], 0.6)
bones = [
{"name": "base", "parent": None, "pivot": [w * 0.50, h * 0.58], "z": 0, "image": "solo3_base.png"},
{"name": "pelvis", "parent": "base", "pivot": [w * 0.50, h * 0.48], "z": 2, "image": "solo3_pelvis.png"},
{"name": "chest", "parent": "base", "pivot": [w * 0.50, h * 0.31], "z": 4, "image": "solo3_chest.png"},
{"name": "upperarm_l", "parent": "chest", "pivot": [w * 0.30, h * 0.27], "z": 5, "image": "solo3_upperarm_l.png"},
{"name": "forearm_l", "parent": "upperarm_l", "pivot": [w * 0.18, h * 0.43], "z": 6, "image": "solo3_forearm_l.png"},
{"name": "hand_l", "parent": "forearm_l", "pivot": [w * 0.10, h * 0.60], "z": 7, "image": "solo3_hand_l.png"},
{"name": "upperarm_r", "parent": "chest", "pivot": [w * 0.70, h * 0.27], "z": 5, "image": "solo3_upperarm_r.png"},
{"name": "forearm_r", "parent": "upperarm_r", "pivot": [w * 0.82, h * 0.43], "z": 6, "image": "solo3_forearm_r.png"},
{"name": "hand_r", "parent": "forearm_r", "pivot": [w * 0.90, h * 0.60], "z": 7, "image": "solo3_hand_r.png"},
{"name": "thigh_l", "parent": "pelvis", "pivot": [w * 0.40, h * 0.56], "z": 2, "image": "solo3_thigh_l.png"},
{"name": "shin_l", "parent": "thigh_l", "pivot": [w * 0.39, h * 0.74], "z": 2, "image": "solo3_shin_l.png"},
{"name": "foot_l", "parent": "shin_l", "pivot": [w * 0.39, h * 0.92], "z": 2, "image": "solo3_foot_l.png"},
{"name": "thigh_r", "parent": "pelvis", "pivot": [w * 0.60, h * 0.56], "z": 2, "image": "solo3_thigh_r.png"},
{"name": "shin_r", "parent": "thigh_r", "pivot": [w * 0.61, h * 0.74], "z": 2, "image": "solo3_shin_r.png"},
{"name": "foot_r", "parent": "shin_r", "pivot": [w * 0.61, h * 0.92], "z": 2, "image": "solo3_foot_r.png"},
{"name": "head", "parent": "chest", "pivot": [w * 0.50, h * 0.16], "z": 10, "image": "solo3_head.png"},
]
rig = {
"name": "LeeSoriDance",
"status": "leesori_solo3_dance_pose_parts_2026_07_04",
"canvas": {"width": w, "height": h},
"imageBase": "./Images/",
"note": "Solo Dance 3 inspired pose-part rig. Parts are broad overlapping masks for the CSS Live2DHost dance loop.",
"bones": bones,
}
(RIG_DIR / "rig.json").write_text(json.dumps(rig, ensure_ascii=False, indent=2), encoding="utf-8")
composite([bone["image"] for bone in bones], RIG_DIR / "qa_parts_composite.png")
if __name__ == "__main__":
main()