126 lines
6.3 KiB
Python
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()
|