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

140 lines
5.8 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")
SHEET = ROOT / "Characters_Build_Docs" / "LeeSori_Profile" / "03_Assets" / "Reference" / "sori_sheet.png"
APP_ASSETS = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App" / "Assets"
PUPPET = APP_ASSETS / "Characters" / "Puppets" / "LeeSoriV2"
IMAGES = PUPPET / "Images"
PREVIEW = APP_ASSETS / "Characters" / "Live2DPreview" / "leesori.png"
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
def clean_alpha(im: Image.Image) -> Image.Image:
im = im.convert("RGBA")
r, g, b, a = im.split()
# Remove very dark residual antialias pixels from the black sheet background.
pixels = im.load()
for y in range(im.height):
for x in range(im.width):
rr, gg, bb, aa = pixels[x, y]
if aa == 0:
continue
if max(rr, gg, bb) < 18:
pixels[x, y] = (0, 0, 0, 0)
elif max(rr, gg, bb) < 44 and aa < 255:
pixels[x, y] = (rr, gg, bb, max(0, aa - 80))
return im
def crop_front_pose(sheet: Image.Image) -> Image.Image:
# The new sheet's front full-body pose is the leftmost character.
crop = sheet.crop((30, 30, 365, 1038))
crop = clean_alpha(crop)
crop = crop.crop(alpha_bbox(crop))
margin = 36
out = Image.new("RGBA", (crop.width + margin * 2, crop.height + margin * 2), (0, 0, 0, 0))
out.alpha_composite(crop, (margin, margin))
return out
def mask_from_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 3.5) -> Image.Image:
w, h = size
pts = [(int(x * w), int(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 make_part(src: Image.Image, name: str, points: list[tuple[float, float]], blur: float = 3.5) -> None:
part = Image.new("RGBA", src.size, (0, 0, 0, 0))
mask = mask_from_polygon(src.size, points, blur)
part.alpha_composite(src)
alpha = Image.composite(part.getchannel("A"), Image.new("L", src.size, 0), mask)
part.putalpha(alpha)
part.save(IMAGES / name)
def composite_black(parts: list[str], dest: Path, width: int = 390, height: int = 600) -> None:
canvas = Image.new("RGBA", (width, height), (16, 16, 18, 255))
src_size = Image.open(IMAGES / parts[0]).size
scale = min(width * 0.83 / src_size[0], height * 1.08 / src_size[1])
target = (int(src_size[0] * scale), int(src_size[1] * scale))
x = (width - target[0]) // 2 + 4
y = int(height - target[1] + 78)
for part_name in parts:
part = Image.open(IMAGES / part_name).convert("RGBA").resize(target, Image.Resampling.LANCZOS)
canvas.alpha_composite(part, (x, y))
canvas.convert("RGB").save(dest)
def main() -> None:
IMAGES.mkdir(parents=True, exist_ok=True)
sheet = Image.open(SHEET).convert("RGBA")
source_path = PUPPET / "leesori_v2_source.png"
if source_path.exists():
src = Image.open(source_path).convert("RGBA")
else:
src = crop_front_pose(sheet)
src.save(source_path)
w, h = src.size
# Full source is retained underneath; overlays are intentionally separated
# enough for visible idle motion while staying close to the sheet artwork.
src.save(IMAGES / "leesori_v2_v3_base.png")
# LeeSoriV2 uses the full base for torso/waist. A moving chest overlay
# creates green/crop-hem ghosting around the exposed waist, so the active
# rig below omits chest and keeps only head/right-arm motion.
make_part(src, "leesori_v2_v3_arm_r.png", [(0.65, 0.24), (0.86, 0.24), (0.84, 0.49), (0.69, 0.49), (0.61, 0.34)], blur=2.0)
make_part(src, "leesori_v2_v3_hand_r.png", [(0.70, 0.47), (0.88, 0.47), (0.87, 0.61), (0.67, 0.60)], blur=1.5)
make_part(src, "leesori_v2_v3_head.png", [(0.19, 0.00), (0.81, 0.00), (0.82, 0.27), (0.65, 0.36), (0.35, 0.36), (0.18, 0.27)])
rig = {
"name": "LeeSoriV2",
"status": "new_sheet_overlap_puppet_v4_static_torso_clean_waist",
"canvas": {"width": w, "height": h},
"imageBase": "./Images/",
"note": "New LeeSori sheet-based overlap puppet. Torso, waist, lower body, and pocketed left hand stay in the base image to avoid doubled outlines, green waist ghosting, and awkward hand motion.",
"bones": [
{"name": "base", "parent": None, "pivot": [w * 0.50, h * 0.62], "z": 0, "image": "leesori_v2_v3_base.png"},
{"name": "upperarm_r", "parent": "base", "pivot": [w * 0.74, h * 0.28], "z": 4, "image": "leesori_v2_v3_arm_r.png"},
{"name": "hand_r", "parent": "upperarm_r", "pivot": [w * 0.78, h * 0.55], "z": 8, "image": "leesori_v2_v3_hand_r.png"},
{"name": "head", "parent": "base", "pivot": [w * 0.50, h * 0.20], "z": 10, "image": "leesori_v2_v3_head.png"},
],
}
(PUPPET / "rig.json").write_text(json.dumps(rig, ensure_ascii=False, indent=2), encoding="utf-8")
preview = Image.new("RGBA", (512, 768), (0, 0, 0, 0))
scale = min(512 * 0.76 / w, 768 * 1.02 / h)
target = (int(w * scale), int(h * scale))
resized = src.resize(target, Image.Resampling.LANCZOS)
preview.alpha_composite(resized, ((512 - target[0]) // 2, 768 - target[1] + 44))
preview.save(PREVIEW)
parts = [
"leesori_v2_v3_base.png",
"leesori_v2_v3_arm_r.png",
"leesori_v2_v3_hand_r.png",
"leesori_v2_v3_head.png",
]
composite_black(parts, PUPPET / "qa_view_390x600_upper_bias.png")
black = Image.new("RGBA", src.size, (16, 16, 18, 255))
black.alpha_composite(src)
black.convert("RGB").save(PUPPET / "qa_source_black.png")
if __name__ == "__main__":
main()