#!/usr/bin/env python3 """Generate persona-specific raster parts with the project imagegen wrapper. The renderer consumes 900x1125 transparent PNGs. gpt-image-2 generation sizes need 16px multiples, so this runner generates a 1024x1280 chroma-key draft and normalizes the result back to the app canvas. """ from __future__ import annotations import argparse import json import os import platform import shlex import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[3] SOURCE_PARTS = ROOT / "apps/web/public/avatar/seoyeon-live2d-psd-v2/parts" SOURCE_CROPS = ROOT / "docs/avatar-art/personas/source-part-crops" TARGET_CANVAS = (900, 1125) GEN_SIZE = "1024x1280" KEY = (0, 255, 0) WRAPPER = Path.home() / ".codex/imagegen-headless/codex_imagegen.sh" WSL_WRAPPER = "/mnt/c/Users/encep/.codex/imagegen-headless/codex_imagegen.sh" WSL_PYTHON = "/mnt/c/Users/encep/AppData/Local/Python/pythoncore-3.14-64/python.exe" GIT_BASH = Path("C:/Program Files/Git/bin/bash.exe") PERSONAS = { "P4": { "name": "하늘", "summary": "고2 여학생, 학업/시험 불안, 완벽주의, 긴장된 눈매와 깔끔한 교복 느낌", "palette": "soft black hair with cool brown highlights, pale warm skin, muted navy school uniform", }, "P5": { "name": "도윤", "summary": "중3 남학생, 또래관계 갈등과 소외감, 경계심 있고 말수가 적은 표정", "palette": "dark brown short hair, neutral warm skin, subdued gray-blue school jacket", }, "P6": { "name": "하린", "summary": "고3 여학생, 진로갈등과 부모 기대 압박, 차분하지만 흔들리는 표정", "palette": "deep ash brown medium hair, soft warm skin, neat cream and charcoal school styling", }, "P7": { "name": "도현", "summary": "고3 남학생, 입시 번아웃과 무기력, 지친 눈매와 낮은 에너지", "palette": "black slightly messy short hair, low-saturation warm skin, dark school cardigan", }, } CORE_PARTS = [ "face-base", "hair-front", "hair-side-left-1", "hair-side-right-1", "brow-left", "brow-right", "brow-sad-left", "brow-sad-right", "eye-white-left", "eye-white-right", "iris-left", "iris-right", "pupil-left", "pupil-right", "lash-left", "lash-right", "mouth-neutral", "mouth-sad", "tear-left", "tear-right", ] BODY_PARTS = [ "hair-back-left", "hair-back-right", "hair-back-base", "body", "neck", "clavicle", "outfit", "outfit-inner", "outfit-outer", "ribbon", "face", "ear-left", "ear-right", "blush-left", "blush-right", "nose", "eye-left", "eye-right", "highlight-left", "highlight-right", "eyelid-upper-left", "eyelid-upper-right", "eyelid-lower-left", "eyelid-lower-right", "hair-bangs", "hair-side-left-2", "hair-side-right-2", "mouth-open", "mouth-open-small", "mouth-warm", "mouth-tired", "mouth-anxious", "mouth-startled", ] def read_manifest() -> dict: path = ROOT / "docs/avatar-art/seoyeon/live2d-psd-v2/detailed-parts/detailed-parts-manifest.json" return json.loads(path.read_text(encoding="utf-8")) def resolved_alpha_box(part: dict) -> list[int] | None: alpha = part.get("alphaBox") if alpha: return [int(v) for v in alpha] ref = SOURCE_PARTS / f"{part['id']}.png" if not ref.exists(): return None im = Image.open(ref).convert("RGBA") box = alpha_bbox(im) return [int(v) for v in box] if box else None def prompt_for(persona_code: str, part: dict) -> str: persona = PERSONAS[persona_code] alpha = resolved_alpha_box(part) origin = part.get("originPercent") return f"""Use case: stylized-concept Asset type: Live2D-style raster avatar part for Vignette persona {persona_code} {persona["name"]} Primary request: Generate exactly one isolated avatar part, matching the reference part's type and placement. Reference image role: the input image is a tight crop of the source Seoyeon PSD v2 part. Use it for the exact part category, silhouette, edge quality, and local proportions. Subject: {persona["summary"]} Part id: {part["id"]} Final canvas requirement: the delivered project asset must be a 900x1125 transparent PNG. This generation draft is a cropped part source; post-processing will resize the detected part to source alphaBox {alpha} and paste it at originPercent {origin}. Crop constraint: fill the draft with only this one part, with a small clean margin. Do not draw surrounding face, hair, body, sheet cells, labels, or other avatar parts. Style/medium: polished anime Live2D PSD part, clean painted edges, matching Vignette avatar renderer style. Color palette: {persona["palette"]}. Background: perfectly flat solid #00ff00 chroma-key background. Do not use #00ff00 in the part. Constraints: output one part only, not a character sheet, not multiple variants, no full body unless the part id requires it, no text, no watermark, no shadow, no floor, no frame. Preserve the reference part's transparent silhouette logic and visual scale. Avoid: collage, sprite sheet, labels, full character, extra facial features, extra parts outside this one part, gradients in the background. """ def ensure_dirs(persona_code: str) -> tuple[Path, Path, Path]: root = ROOT / f"docs/avatar-art/personas/{persona_code}" raw = root / "generated-raw" final = ROOT / f"apps/web/public/avatar/{persona_code.lower()}-live2d-generated/parts" prompts = root / "prompts" raw.mkdir(parents=True, exist_ok=True) final.mkdir(parents=True, exist_ok=True) prompts.mkdir(parents=True, exist_ok=True) return raw, final, prompts def make_reference_crop(part: dict) -> Path: part_id = part["id"] ref = SOURCE_PARTS / f"{part_id}.png" if not ref.exists(): raise FileNotFoundError(ref) SOURCE_CROPS.mkdir(parents=True, exist_ok=True) out = SOURCE_CROPS / f"{part_id}.png" if out.exists(): return out im = Image.open(ref).convert("RGBA") box = resolved_alpha_box(part) or alpha_bbox(im) if not box: im.save(out, optimize=True) return out x0, y0, x1, y1 = [int(v) for v in box] pad_x = max(8, int((x1 - x0) * 0.28)) pad_y = max(8, int((y1 - y0) * 0.28)) crop_box = ( max(0, x0 - pad_x), max(0, y0 - pad_y), min(im.width, x1 + pad_x), min(im.height, y1 + pad_y), ) crop = im.crop(crop_box) crop.save(out, optimize=True) return out def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: arr = np.array(im.convert("RGBA")) alpha = arr[:, :, 3] ys, xs = np.where(alpha > threshold) if len(xs) == 0: return None return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) def procedural_blush(target_box: list[int] | tuple[int, int, int, int]) -> Image.Image: x0, y0, x1, y1 = [int(v) for v in target_box] layer = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0)) draw = ImageDraw.Draw(layer) w = x1 - x0 h = y1 - y0 inset_x = max(2, int(w * 0.12)) inset_y = max(2, int(h * 0.18)) draw.ellipse( (x0 + inset_x, y0 + inset_y, x1 - inset_x, y1 - inset_y), fill=(238, 142, 158, 74), ) return layer.filter(ImageFilter.GaussianBlur(radius=max(7, int(min(w, h) * 0.15)))) def remove_key_and_resize( src: Path, dst: Path, target_box: list[int] | tuple[int, int, int, int] | None, part_id: str = "", ) -> None: if part_id.startswith("blush-") and target_box: dst.parent.mkdir(parents=True, exist_ok=True) procedural_blush(target_box).save(dst, optimize=True) return im = Image.open(src).convert("RGBA") if im.size != TARGET_CANVAS: im = im.resize(TARGET_CANVAS, Image.Resampling.LANCZOS) arr = np.array(im, dtype=np.uint8) rgb = arr[:, :, :3].astype(np.int16) alpha = arr[:, :, 3].astype(np.int16) r = rgb[:, :, 0] g = rgb[:, :, 1] b = rgb[:, :, 2] max_rb = np.maximum(r, b) green_delta = g - max_rb hard_key = ((g > 80) & (green_delta > 18)) | ((g > 105) & (g > r * 1.16) & (g > b * 1.16)) soft_key = (g > 64) & (green_delta > 5) & ~hard_key arr[hard_key, 3] = 0 if np.any(soft_key): arr[soft_key, 1] = np.clip(max_rb[soft_key] + 3, 0, 255).astype(np.uint8) arr[soft_key, 3] = np.clip(alpha[soft_key] * 0.72, 0, 255).astype(np.uint8) arr[(arr[:, :, 3] > 0) & (arr[:, :, 3] < 18), 3] = 0 im = Image.fromarray(arr, "RGBA") if target_box: src_box = alpha_bbox(im, threshold=12) if src_box: x0, y0, x1, y1 = src_box tx0, ty0, tx1, ty1 = [int(v) for v in target_box] target_w = max(1, tx1 - tx0) target_h = max(1, ty1 - ty0) crop = im.crop((x0, y0, x1, y1)).resize((target_w, target_h), Image.Resampling.LANCZOS) placed = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0)) placed.alpha_composite(crop, (tx0, ty0)) im = placed dst.parent.mkdir(parents=True, exist_ok=True) im.save(dst, optimize=True) def validate_box(path: Path, expected_box: list[int] | tuple[int, int, int, int] | None) -> dict: im = Image.open(path).convert("RGBA") box = alpha_bbox(im, threshold=8) result: dict[str, object] = {"box": list(box) if box else None, "ok": False} if not expected_box or not box: return result expected = [int(v) for v in expected_box] actual = [int(v) for v in box] drift = [actual[i] - expected[i] for i in range(4)] result["expectedBox"] = expected result["drift"] = drift result["ok"] = max(abs(v) for v in drift) <= 2 return result def to_wsl_path(path: Path) -> str: resolved = path.resolve() drive = resolved.drive.rstrip(":").lower() rest = resolved.as_posix()[2:] return f"/mnt/{drive}{rest}" def to_msys_path(path: Path) -> str: resolved = path.resolve() drive = resolved.drive.rstrip(":").lower() rest = resolved.as_posix()[2:] return f"/{drive}{rest}" def run_wrapper(raw: Path, ref: Path, prompt: str, quality: str) -> subprocess.CompletedProcess[str]: if platform.system().lower().startswith("windows"): bash_exe = str(GIT_BASH) if GIT_BASH.exists() else "bash" wrapper = to_msys_path(WRAPPER) if GIT_BASH.exists() else WSL_WRAPPER raw_path = to_msys_path(raw) if GIT_BASH.exists() else to_wsl_path(raw) ref_path = to_msys_path(ref) if GIT_BASH.exists() else to_wsl_path(ref) py_prefix = "" if GIT_BASH.exists() else f"PYTHON={shlex.quote(WSL_PYTHON)} " command = " ".join( [ py_prefix + shlex.quote(wrapper), "--out", shlex.quote(raw_path), "--size", shlex.quote(GEN_SIZE), "--quality", shlex.quote(quality), "-i", shlex.quote(ref_path), "--prompt", shlex.quote(prompt), ] ) return subprocess.run([bash_exe, "-lc", command], cwd=ROOT, text=True, capture_output=True, check=False) args = [ "bash", str(WRAPPER), "--out", str(raw), "--size", GEN_SIZE, "--quality", quality, "-i", str(ref), "--prompt", prompt, ] return subprocess.run(args, cwd=ROOT, text=True, capture_output=True, check=False) def run_one(persona_code: str, part: dict, quality: str, overwrite: bool, reprocess_existing: bool) -> dict: raw_dir, final_dir, prompts_dir = ensure_dirs(persona_code) part_id = part["id"] prompt = prompt_for(persona_code, part) prompt_path = prompts_dir / f"{part_id}.txt" prompt_path.write_text(prompt, encoding="utf-8") ref = make_reference_crop(part) raw = raw_dir / f"{part_id}.png" final = final_dir / f"{part_id}.png" if not ref.exists(): raise FileNotFoundError(ref) target_box = resolved_alpha_box(part) if target_box is None: final.parent.mkdir(parents=True, exist_ok=True) Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0)).save(final, optimize=True) return { "persona": persona_code, "part": part_id, "status": "empty-source", "final": str(final), "boxValidation": {"box": None, "ok": True, "emptySource": True}, } if final.exists() and reprocess_existing and raw.exists(): remove_key_and_resize(raw, final, target_box, part_id) return { "persona": persona_code, "part": part_id, "status": "reprocessed", "raw": str(raw), "final": str(final), "boxValidation": validate_box(final, target_box), } if final.exists() and not overwrite: return { "persona": persona_code, "part": part_id, "status": "exists", "final": str(final), "boxValidation": validate_box(final, target_box), } os.environ["PYTHONUTF8"] = "1" result = run_wrapper(raw, ref, prompt, quality) if result.returncode != 0: return { "persona": persona_code, "part": part_id, "status": "failed", "stdout": result.stdout[-2000:], "stderr": result.stderr[-4000:], } remove_key_and_resize(raw, final, target_box, part_id) return { "persona": persona_code, "part": part_id, "status": "generated", "raw": str(raw), "final": str(final), "boxValidation": validate_box(final, target_box), } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--personas", nargs="+", default=["P4", "P5", "P6", "P7"]) parser.add_argument("--parts", nargs="*", default=[]) parser.add_argument("--tier", choices=["core", "body", "all"], default="core") parser.add_argument("--quality", choices=["low", "medium", "high", "auto"], default="low") parser.add_argument("--concurrency", type=int, default=4) parser.add_argument("--overwrite", action="store_true") parser.add_argument("--reprocess-existing", action="store_true") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() manifest = read_manifest() all_parts = {part["id"]: part for part in manifest["parts"]} part_ids = args.parts if not part_ids: if args.tier == "core": part_ids = CORE_PARTS elif args.tier == "body": part_ids = BODY_PARTS else: part_ids = list(all_parts.keys()) jobs = [] for persona_code in args.personas: if persona_code not in PERSONAS: raise ValueError(f"unknown persona {persona_code}") _, _, prompts_dir = ensure_dirs(persona_code) plan_parts = [] for pid in part_ids: if pid not in all_parts: continue part = dict(all_parts[pid]) part["sourceCrop"] = str(make_reference_crop(all_parts[pid]).relative_to(ROOT)) plan_parts.append(part) plan = { "persona": persona_code, "name": PERSONAS[persona_code]["name"], "sourceArtSet": "seoyeon-live2d-psd-v2", "targetArtSet": f"{persona_code.lower()}-live2d-generated", "canvas": TARGET_CANVAS, "generationSize": GEN_SIZE, "parts": plan_parts, } plan_path = prompts_dir.parent / ("generation-plan-last-run.json" if args.parts else "generation-plan.json") plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") for pid in part_ids: if pid not in all_parts: raise KeyError(pid) prompt = prompt_for(persona_code, all_parts[pid]) (prompts_dir / f"{pid}.txt").write_text(prompt, encoding="utf-8") jobs.append((persona_code, all_parts[pid])) queue_path = ROOT / "docs/avatar-art/personas/generation-queue.jsonl" queue_path.parent.mkdir(parents=True, exist_ok=True) queue_path.write_text( "".join(json.dumps({"persona": p, "part": part["id"]}, ensure_ascii=False) + "\n" for p, part in jobs), encoding="utf-8", ) if args.dry_run: print(f"dry-run jobs={len(jobs)} queue={queue_path}") return 0 results = [] with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as pool: futures = [ pool.submit(run_one, persona, part, args.quality, args.overwrite, args.reprocess_existing) for persona, part in jobs ] for future in as_completed(futures): result = future.result() results.append(result) print(json.dumps(result, ensure_ascii=False), flush=True) summary_path = ROOT / "docs/avatar-art/personas/generation-results.json" summary_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") failed = [item for item in results if item["status"] == "failed"] return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())