vignette/docs/avatar-art/personas/visual_qa.py
2026-06-28 12:18:20 +09:00

192 lines
6.3 KiB
Python

from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[3]
CANVAS = (900, 1125)
PERSONAS = ("P4", "P5", "P6", "P7")
APP_BASE_ORDER = [
"hair-back-base",
"hair-back-left",
"hair-back-right",
"body",
"outfit",
"ear-left",
"ear-right",
"face-base",
"blush-left",
"blush-right",
"eye-white-left",
"eye-white-right",
"iris-left",
"iris-right",
"pupil-left",
"pupil-right",
"lash-left",
"lash-right",
"eyelid-upper-left",
"eyelid-upper-right",
"eyelid-lower-left",
"eyelid-lower-right",
"brow-left",
"brow-right",
"tear-left",
"tear-right",
"nose",
"hair-side-left-1",
"hair-side-right-1",
"hair-side-left-2",
"hair-side-right-2",
"hair-bangs",
"mouth-neutral",
]
SAD_OVERRIDES = {
"brow-left": "brow-sad-left",
"brow-right": "brow-sad-right",
"mouth-neutral": "mouth-sad",
}
def checker(size: tuple[int, int], cell: int = 24) -> Image.Image:
image = Image.new("RGBA", size, (242, 242, 238, 255))
draw = ImageDraw.Draw(image)
for y in range(0, size[1], cell):
for x in range(0, size[0], cell):
if ((x // cell) + (y // cell)) % 2:
draw.rectangle((x, y, x + cell - 1, y + cell - 1), fill=(226, 228, 226, 255))
return image
def parts_dir(persona: str) -> Path:
return ROOT / f"apps/web/public/avatar/{persona.lower()}-live2d-generated/parts"
def load_part(path: Path) -> Image.Image:
image = Image.open(path).convert("RGBA")
if image.size != CANVAS:
resized = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
resized.alpha_composite(image.resize(CANVAS, Image.Resampling.LANCZOS))
return resized
return image
def compose(persona: str, variant: str) -> tuple[Image.Image, list[str]]:
base = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
missing: list[str] = []
directory = parts_dir(persona)
for part in APP_BASE_ORDER:
actual = SAD_OVERRIDES.get(part, part) if variant == "sad" else part
path = directory / f"{actual}.png"
if not path.exists():
missing.append(actual)
continue
base.alpha_composite(load_part(path))
return base, missing
def trim_preview(image: Image.Image) -> Image.Image:
alpha = image.getchannel("A")
box = alpha.getbbox()
if not box:
return image
x0, y0, x1, y1 = box
pad = 32
crop = (
max(0, x0 - pad),
max(0, y0 - pad),
min(image.width, x1 + pad),
min(image.height, y1 + pad),
)
return image.crop(crop)
def save_preview(persona: str, variant: str, out_dir: Path) -> dict:
image, missing = compose(persona, variant)
bg = checker(CANVAS)
bg.alpha_composite(image)
trimmed = trim_preview(bg.convert("RGBA"))
out = out_dir / f"{persona.lower()}-{variant}-app-composite.png"
trimmed.save(out, optimize=True)
return {"persona": persona, "variant": variant, "path": str(out), "missing": missing}
def contact_sheet(persona: str, out_dir: Path) -> dict:
directory = parts_dir(persona)
files = sorted(directory.glob("*.png"))
thumb_w, thumb_h = 180, 225
label_h = 32
cols = 5
rows = max(1, (len(files) + cols - 1) // cols)
sheet = Image.new("RGBA", (cols * thumb_w, rows * (thumb_h + label_h)), (245, 245, 241, 255))
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default()
for index, path in enumerate(files):
row, col = divmod(index, cols)
x = col * thumb_w
y = row * (thumb_h + label_h)
cell = checker((thumb_w, thumb_h), 12)
image = Image.open(path).convert("RGBA")
image.thumbnail((thumb_w - 12, thumb_h - 12), Image.Resampling.LANCZOS)
px = x + (thumb_w - image.width) // 2
py = y + (thumb_h - image.height) // 2
cell.alpha_composite(image, (px - x, py - y))
sheet.alpha_composite(cell, (x, y))
draw.rectangle((x, y + thumb_h, x + thumb_w - 1, y + thumb_h + label_h - 1), fill=(28, 31, 33, 255))
draw.text((x + 6, y + thumb_h + 8), path.stem[:28], fill=(244, 244, 240, 255), font=font)
out = out_dir / f"{persona.lower()}-parts-contact-sheet.png"
sheet.convert("RGB").save(out, optimize=True)
return {"persona": persona, "path": str(out), "parts": len(files)}
def overview(personas: list[str], variant: str, out_dir: Path) -> str:
tile_w, tile_h = 360, 450
label_h = 34
sheet = Image.new("RGB", (tile_w * len(personas), tile_h + label_h), (245, 245, 241))
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default()
for index, persona in enumerate(personas):
path = out_dir / f"{persona.lower()}-{variant}-app-composite.png"
image = Image.open(path).convert("RGBA")
image.thumbnail((tile_w, tile_h), Image.Resampling.LANCZOS)
x = index * tile_w + (tile_w - image.width) // 2
y = (tile_h - image.height) // 2
sheet.paste(image.convert("RGB"), (x, y))
draw.rectangle(
(index * tile_w, tile_h, index * tile_w + tile_w - 1, tile_h + label_h - 1),
fill=(30, 33, 36),
)
draw.text((index * tile_w + 12, tile_h + 10), f"{persona} {variant}", fill=(245, 245, 241), font=font)
out = out_dir / f"all-{variant}-overview.png"
sheet.save(out, optimize=True)
return str(out)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--personas", nargs="+", default=list(PERSONAS))
parser.add_argument("--out", default=str(ROOT / "docs/avatar-art/personas/visual-qa"))
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
summary = {"previews": [], "contactSheets": []}
for persona in args.personas:
for variant in ("neutral", "sad"):
summary["previews"].append(save_preview(persona, variant, out_dir))
summary["contactSheets"].append(contact_sheet(persona, out_dir))
summary["overviews"] = [overview(args.personas, "neutral", out_dir), overview(args.personas, "sad", out_dir)]
summary_path = out_dir / "visual-qa-summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())