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