192 lines
6.3 KiB
Python
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())
|