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()