Files
Dansori_Characters/tmp/imagegen/build_isabel_profile_parts.py
2026-07-04 10:34:46 +09:00

296 lines
10 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[2]
PROFILE = ROOT / "Isabel_Profile"
OUT_DIR = PROFILE / "03_Assets" / "Parts" / "Images"
TMP_DIR = ROOT / "tmp" / "imagegen"
BODY_SRC = PROFILE / "03_Assets" / "Library" / "CoarseParts" / "Club" / "isabel_body_club_apose.png"
HEAD_SRC = PROFILE / "03_Assets" / "Library" / "Heads" / "isabel_head_wave.png"
W, H = 520, 900
CENTER_X = 260
PARTS_Z = [
"foot_r",
"foot_l",
"shin_r",
"shin_l",
"thigh_r",
"thigh_l",
"pelvis",
"upperarm_r",
"forearm_r",
"hand_r",
"chest",
"neck",
"head",
"upperarm_l",
"forearm_l",
"hand_l",
]
def load_rgba(path: Path) -> Image.Image:
return Image.open(path).convert("RGBA")
def crop_alpha(img: Image.Image) -> Image.Image:
bbox = img.getbbox()
if not bbox:
return img
return img.crop(bbox)
def paste_scaled(src: Image.Image, scale: float, x: int, y: int) -> Image.Image:
src = crop_alpha(src)
rw = max(1, round(src.width * scale))
rh = max(1, round(src.height * scale))
resized = src.resize((rw, rh), Image.Resampling.LANCZOS)
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
layer.alpha_composite(resized, (x, y))
return layer
def make_layers() -> tuple[Image.Image, Image.Image, Image.Image]:
body_src = load_rgba(BODY_SRC)
head_src = load_rgba(HEAD_SRC)
body_crop = crop_alpha(body_src)
body_scale = 0.493
body_w = round(body_crop.width * body_scale)
body_h = round(body_crop.height * body_scale)
body_x = round((W - body_w) / 2)
body_y = 165
body = paste_scaled(body_src, body_scale, body_x, body_y)
head_crop = crop_alpha(head_src)
head_scale = 0.172
head_w = round(head_crop.width * head_scale)
head_h = round(head_crop.height * head_scale)
head_x = round(CENTER_X - head_w / 2)
head_y = 5
head = paste_scaled(head_src, head_scale, head_x, head_y)
master = Image.new("RGBA", (W, H), (0, 0, 0, 0))
master.alpha_composite(body)
master.alpha_composite(head)
return master, body, head
def ellipse_mask(xx: np.ndarray, yy: np.ndarray, cx: float, cy: float, rx: float, ry: float) -> np.ndarray:
return ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2 <= 1.0
def segment_mask(
xx: np.ndarray,
yy: np.ndarray,
x1: float,
y1: float,
x2: float,
y2: float,
radius: float,
) -> np.ndarray:
px = xx.astype(np.float32)
py = yy.astype(np.float32)
vx = x2 - x1
vy = y2 - y1
denom = vx * vx + vy * vy
t = ((px - x1) * vx + (py - y1) * vy) / denom
t = np.clip(t, 0.0, 1.0)
cx = x1 + t * vx
cy = y1 + t * vy
return (px - cx) ** 2 + (py - cy) ** 2 <= radius * radius
def body_part_masks(alpha: np.ndarray) -> dict[str, np.ndarray]:
yy, xx = np.indices(alpha.shape)
subject = alpha > 0
masks: dict[str, np.ndarray] = {name: np.zeros(alpha.shape, dtype=bool) for name in PARTS_Z}
# Hands and arms use limb-center masks, not broad side boxes, to avoid stealing dress pixels.
masks["hand_r"] = subject & ellipse_mask(xx, yy, 104, 462, 35, 45)
masks["hand_l"] = subject & ellipse_mask(xx, yy, 416, 462, 35, 45)
masks["upperarm_r"] = subject & segment_mask(xx, yy, 176, 210, 143, 345, 32)
masks["forearm_r"] = subject & segment_mask(xx, yy, 143, 345, 108, 435, 31)
masks["upperarm_l"] = subject & segment_mask(xx, yy, 344, 210, 377, 345, 32)
masks["forearm_l"] = subject & segment_mask(xx, yy, 377, 345, 412, 435, 31)
# Feet and legs.
masks["foot_r"] = subject & (xx < CENTER_X) & (yy >= 795)
masks["foot_l"] = subject & (xx >= CENTER_X) & (yy >= 795)
masks["shin_r"] = subject & (xx < CENTER_X) & (yy >= 645) & (yy < 815) & ~masks["foot_r"]
masks["shin_l"] = subject & (xx >= CENTER_X) & (yy >= 645) & (yy < 815) & ~masks["foot_l"]
masks["thigh_r"] = subject & (xx < CENTER_X) & (yy >= 505) & (yy < 660) & ~masks["shin_r"] & ~masks["foot_r"]
masks["thigh_l"] = subject & (xx >= CENTER_X) & (yy >= 505) & (yy < 660) & ~masks["shin_l"] & ~masks["foot_l"]
# Torso/neck. Keep the dress/pelvis in front of upper legs.
masks["neck"] = subject & ellipse_mask(xx, yy, 260, 178, 34, 50)
masks["chest"] = subject & (xx >= 150) & (xx <= 370) & (yy >= 185) & (yy < 425) & ~masks["neck"]
masks["pelvis"] = subject & (xx >= 165) & (xx <= 370) & (yy >= 395) & (yy < 545)
priority = [
"hand_r",
"hand_l",
"forearm_r",
"forearm_l",
"upperarm_r",
"upperarm_l",
"neck",
"chest",
"pelvis",
"foot_r",
"foot_l",
"shin_r",
"shin_l",
"thigh_r",
"thigh_l",
]
assigned = np.zeros(alpha.shape, dtype=bool)
for name in priority:
masks[name] &= ~assigned
assigned |= masks[name]
# Any remaining body pixels are assigned by nearest practical region.
remaining = subject & ~assigned
masks["forearm_r"] |= remaining & (xx < 155) & (yy < 430)
masks["hand_r"] |= remaining & (xx < 155) & (yy >= 430) & (yy < 535)
masks["forearm_l"] |= remaining & (xx > 365) & (yy < 430)
masks["hand_l"] |= remaining & (xx > 365) & (yy >= 430) & (yy < 535)
assigned |= masks["forearm_r"] | masks["hand_r"] | masks["forearm_l"] | masks["hand_l"]
remaining = subject & ~assigned
masks["chest"] |= remaining & (yy < 405)
assigned |= masks["chest"]
remaining = subject & ~assigned
masks["pelvis"] |= remaining & (yy < 535)
assigned |= masks["pelvis"]
remaining = subject & ~assigned
masks["thigh_r"] |= remaining & (xx < CENTER_X) & (yy < 655)
masks["thigh_l"] |= remaining & (xx >= CENTER_X) & (yy < 655)
assigned |= masks["thigh_r"] | masks["thigh_l"]
remaining = subject & ~assigned
masks["shin_r"] |= remaining & (xx < CENTER_X) & (yy < 805)
masks["shin_l"] |= remaining & (xx >= CENTER_X) & (yy < 805)
assigned |= masks["shin_r"] | masks["shin_l"]
remaining = subject & ~assigned
masks["foot_r"] |= remaining & (xx < CENTER_X)
masks["foot_l"] |= remaining & (xx >= CENTER_X)
return masks
def apply_mask(layer: Image.Image, mask: np.ndarray) -> Image.Image:
arr = np.array(layer)
arr[~mask] = 0
return Image.fromarray(arr, "RGBA")
def save_outputs() -> dict[str, object]:
OUT_DIR.mkdir(parents=True, exist_ok=True)
TMP_DIR.mkdir(parents=True, exist_ok=True)
_, body, head = make_layers()
body_alpha = np.array(body.getchannel("A"))
head_alpha = np.array(head.getchannel("A"))
masks = body_part_masks(body_alpha)
yy, xx = np.indices(head_alpha.shape)
masks["head"] = (head_alpha > 0) & ~((yy > 170) & (xx > 188) & (xx < 332))
saved = []
for name in PARTS_Z:
src = head if name == "head" else body
part = apply_mask(src, masks[name])
out_name = f"isabel_part_{name}.png"
part.save(OUT_DIR / out_name)
saved.append(out_name)
recomposed = Image.new("RGBA", (W, H), (0, 0, 0, 0))
for name in PARTS_Z:
recomposed.alpha_composite(load_rgba(OUT_DIR / f"isabel_part_{name}.png"))
recomposed.save(TMP_DIR / "isabel_profile_parts_recomposed.png")
recomposed.save(OUT_DIR / "isabel_part_master_apose.png")
saved = ["isabel_part_master_apose.png"] + saved
make_contact_sheet([OUT_DIR / n for n in saved], TMP_DIR / "isabel_profile_parts_contact.png")
report = validate(saved, recomposed, recomposed)
(TMP_DIR / "isabel_profile_parts_report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
return report
def validate(names: list[str], recomposed: Image.Image, master: Image.Image) -> dict[str, object]:
files = []
ok = True
for name in names:
img = load_rgba(OUT_DIR / name)
corners = [
img.getpixel((0, 0))[3],
img.getpixel((W - 1, 0))[3],
img.getpixel((0, H - 1))[3],
img.getpixel((W - 1, H - 1))[3],
]
file_ok = img.size == (W, H) and img.mode == "RGBA" and all(a == 0 for a in corners) and img.getbbox() is not None
ok = ok and file_ok
files.append({"name": name, "size": img.size, "bbox": img.getbbox(), "cornerAlpha": corners, "ok": file_ok})
ma = np.array(master)
ra = np.array(recomposed)
diff = np.abs(ma.astype(np.int16) - ra.astype(np.int16))
changed_pixels = int(np.any(diff > 2, axis=2).sum())
return {
"ok": ok,
"outputDir": str(OUT_DIR),
"total": len(names),
"files": files,
"recomposeDiffPixelsGt2": changed_pixels,
"contactSheet": str(TMP_DIR / "isabel_profile_parts_contact.png"),
}
def make_contact_sheet(paths: list[Path], out: Path) -> None:
thumb_w, thumb_h = 170, 220
label_h, pad = 28, 10
cols = 4
rows = (len(paths) + cols - 1) // cols
sheet = Image.new("RGBA", (cols * (thumb_w + pad) + pad, rows * (thumb_h + label_h + pad) + pad), (30, 32, 36, 255))
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default()
for i, path in enumerate(paths):
x = pad + (i % cols) * (thumb_w + pad)
y = pad + (i // cols) * (thumb_h + label_h + pad)
bg = Image.new("RGBA", (thumb_w, thumb_h), (255, 255, 255, 255))
bgd = ImageDraw.Draw(bg)
s = 14
for yy in range(0, thumb_h, s):
for xx in range(0, thumb_w, s):
if ((xx // s) + (yy // s)) % 2 == 0:
bgd.rectangle([xx, yy, xx + s - 1, yy + s - 1], fill=(214, 218, 226, 255))
img = load_rgba(path)
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
img.thumbnail((thumb_w - 8, thumb_h - 8), Image.Resampling.LANCZOS)
sheet.alpha_composite(bg, (x, y))
sheet.alpha_composite(img, (x + (thumb_w - img.width) // 2, y + (thumb_h - img.height) // 2))
draw.rectangle([x, y, x + thumb_w - 1, y + thumb_h - 1], outline=(90, 96, 108, 255))
label = path.name
if len(label) > 27:
label = label[:26] + "..."
draw.text((x + 3, y + thumb_h + 6), label, fill=(235, 238, 245, 255), font=font)
sheet.save(out)
if __name__ == "__main__":
result = save_outputs()
print(json.dumps(result, ensure_ascii=False, indent=2))