from __future__ import annotations from collections import deque from pathlib import Path import numpy as np from PIL import Image, ImageFilter ROOT = Path(__file__).resolve().parents[1] REFERENCE = ROOT / "03_Assets" / "Reference" PARTS = ROOT / "03_Assets" / "Parts" / "Images" RAW = REFERENCE / "sori_generated_apose_raw.png" SRC_W, SRC_H = 520, 900 def load_source() -> Image.Image: return Image.open(RAW).convert("RGBA") def checker_background_mask(img: Image.Image) -> np.ndarray: arr = np.array(img.convert("RGBA")) rgb = arr[:, :, :3].astype(np.int16) alpha = arr[:, :, 3] light_gray = (rgb.min(axis=2) > 218) & ((rgb.max(axis=2) - rgb.min(axis=2)) < 18) transparent = alpha < 8 candidate = light_gray | transparent h, w = candidate.shape seen = np.zeros_like(candidate, dtype=bool) q: deque[tuple[int, int]] = deque() for x in range(w): if candidate[0, x]: q.append((x, 0)) if candidate[h - 1, x]: q.append((x, h - 1)) for y in range(h): if candidate[y, 0]: q.append((0, y)) if candidate[y, w - 1]: q.append((w - 1, y)) while q: x, y = q.popleft() if seen[y, x] or not candidate[y, x]: continue seen[y, x] = True if x > 0: q.append((x - 1, y)) if x < w - 1: q.append((x + 1, y)) if y > 0: q.append((x, y - 1)) if y < h - 1: q.append((x, y + 1)) return seen def remove_checker_background(img: Image.Image) -> Image.Image: bg = checker_background_mask(img) alpha = Image.fromarray((~bg).astype(np.uint8) * 255, "L") alpha = alpha.filter(ImageFilter.GaussianBlur(0.65)).point(lambda p: 0 if p < 28 else min(255, int(p * 1.18))) out = img.copy() out.putalpha(alpha) return out def normalize_canvas(img: Image.Image) -> Image.Image: bbox = img.getbbox() if bbox is None: raise RuntimeError("source image has no visible pixels") crop = img.crop(bbox) ratio = min(430 / crop.width, 850 / crop.height) resized = crop.resize((round(crop.width * ratio), round(crop.height * ratio)), Image.Resampling.LANCZOS) canvas = Image.new("RGBA", (SRC_W, SRC_H), (0, 0, 0, 0)) canvas.alpha_composite(resized, ((SRC_W - resized.width) // 2, 24)) return canvas def save_part(master: Image.Image, name: str, box: tuple[int, int, int, int]) -> None: mask = Image.new("L", master.size, 0) part_alpha = master.getchannel("A").crop(box) mask.paste(part_alpha, box) out = master.copy() out.putalpha(mask.filter(ImageFilter.GaussianBlur(0.25))) out.save(PARTS / name) def main() -> None: PARTS.mkdir(parents=True, exist_ok=True) master = normalize_canvas(remove_checker_background(load_source())) master.save(PARTS / "sori_part_master_apose.png") # Coarse Live2D source parts. Boxes are in the normalized 520x900 master coordinate system. boxes = { "sori_part_head.png": (135, 8, 385, 255), "sori_part_neck.png": (215, 220, 305, 295), "sori_part_chest.png": (112, 212, 408, 435), "sori_part_pelvis.png": (145, 385, 375, 505), "sori_part_upperarm_l.png": (342, 250, 465, 452), "sori_part_forearm_l.png": (370, 360, 500, 535), "sori_part_hand_l.png": (382, 428, 520, 590), "sori_part_upperarm_r.png": (55, 250, 178, 452), "sori_part_forearm_r.png": (20, 360, 150, 535), "sori_part_hand_r.png": (0, 428, 138, 590), "sori_part_thigh_l.png": (260, 438, 385, 675), "sori_part_thigh_r.png": (135, 438, 260, 675), "sori_part_shin_l.png": (262, 640, 370, 825), "sori_part_shin_r.png": (150, 640, 258, 825), "sori_part_foot_l.png": (258, 780, 375, 900), "sori_part_foot_r.png": (145, 780, 262, 900), } for name, box in boxes.items(): save_part(master, name, box) print(PARTS / "sori_part_master_apose.png") if __name__ == "__main__": main()