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