"""공통 리노컷 리그 파이프라인 — 단일 진입점. 페르소나 폴더 하나(예: docs/avatar-art/p1-linocut/, persona.json이 있어야 한다)에 대해 1~11단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다 (mediapipe FaceLandmarker+ImageSegmenter를 같은 프로세스에서 같이 쓰면 세그폴트가 나는 것과 같은 이유로, 모든 단계를 서브프로세스로 분리해 두면 한 단계의 문제가 다른 단계·전체 파이프라인을 오염시키지 않는다). 단계 의존 순서(실제 코드 의존성 기준 — 전달받은 작업 설명의 번호는 주제별 묶음이라 실행 순서와 다르다. 특히 final_previews는 export_rig가 쓴 export-rig-report.json을 읽으므로 반드시 export_rig *다음*이다): 1. landmarks — base-front.png 랜드마크 검출, manifest.landmarks 작성 2. brow_centerline — manifest.landmarks.eyebrowLeft/Right를 중심선으로 보정 3. segmentation — layers/{body,head,hairFront}.png(v1) + manifest 기준 섹션 4. layers_v2 — layers/v2/{body,head,hairFront}.png(결함 보정) 5. face_detail — layers/v2/face-detail.png 6. brow_sprite — layers/v2/brow-{left,right}.png + manifest.eyeLineScale(결정문 §8.4.1) 7. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연) 8. lip_texture — layers/v2/lip-{upper,lower,shadow}.png 9. jaw_pieces — layers/v2/jaw-{head,detail}.png 10. export_rig — WebP 게시 + rigs/Rig.ts 생성 11. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기 주의(재실행 순서): landmarks를 혼자 다시 돌리면 manifest.landmarks를 통째로 새로 쓰므로, brow_centerline이 보정한 eyebrowLeft/Right가 사라진다. landmarks만 다시 돌릴 때는 brow_centerline도 같이 다시 돌려야 한다(이 스크립트가 --only로 한 단계만 돌릴 때는 호출하는 쪽이 책임진다 — 자동으로 뒤따라 돌리지 않는다). 실행: /python.exe run_pipeline.py # 전체 실행 /python.exe run_pipeline.py --only face_detail # 한 단계만 /python.exe run_pipeline.py --from lip_texture # 중간부터 /python.exe run_pipeline.py --list # 단계 이름 목록 """ from __future__ import annotations import argparse import json import os import subprocess import sys import time from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPTS_DIR)) from persona_config import load_persona_config # noqa: E402 # (단계 이름, 스크립트 파일명) — 실행 순서 그대로. STAGES: list[tuple[str, str]] = [ ("landmarks", "landmarks.py"), ("brow_centerline", "brow_centerline.py"), ("segmentation", "segmentation.py"), ("layers_v2", "layers_v2.py"), ("face_detail", "face_detail.py"), ("brow_sprite", "brow_sprite.py"), ("paper_grain", "paper_grain.py"), ("lip_texture", "lip_texture.py"), ("jaw_pieces", "jaw_pieces.py"), ("export_rig", "export_rig.py"), ("final_previews", "final_previews.py"), ] STAGE_NAMES = [name for name, _ in STAGES] def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, float]: script_path = SCRIPTS_DIR / script t0 = time.monotonic() # 단계 스크립트 출력은 UTF-8로 주고받는다. Windows 콘솔 기본(cp949)은 em dash 같은 문자를 # 못 그려, 모티프 없는 리그 분기(export_rig) 같은 데서 단계가 UnicodeEncodeError로 죽었다 # (2026-10-01 P7). 부모가 콘솔에 옮겨 쓸 때 못 그리는 문자는 main()에서 대체 문자로 바꾼다. proc = subprocess.run( [sys.executable, "-u", str(script_path), str(persona_dir)], capture_output=True, text=True, encoding="utf-8", errors="replace", env={**os.environ, "PYTHONIOENCODING": "utf-8"}, ) elapsed = time.monotonic() - t0 output = proc.stdout + proc.stderr ok = proc.returncode == 0 return ok, output, elapsed def main() -> int: sys.stdout.reconfigure(errors="replace") sys.stderr.reconfigure(errors="replace") parser = argparse.ArgumentParser(description="공통 리노컷 파이프라인 실행") parser.add_argument("persona_dir", nargs="?", help="페르소나 폴더(예: docs/avatar-art/p1-linocut)") parser.add_argument("--only", choices=STAGE_NAMES, help="이 단계만 실행한다") parser.add_argument("--from", dest="from_stage", choices=STAGE_NAMES, help="이 단계부터 끝까지 실행한다") parser.add_argument("--list", action="store_true", help="단계 이름 목록을 출력하고 끝낸다") args = parser.parse_args() if args.list: for name in STAGE_NAMES: print(name) return 0 if not args.persona_dir: parser.error("persona_dir가 필요하다(--list가 아니면).") persona_dir = Path(args.persona_dir).resolve() cfg = load_persona_config(persona_dir) # persona.json 검증 겸 조기 실패 if args.only: selected = [s for s in STAGES if s[0] == args.only] elif args.from_stage: start = STAGE_NAMES.index(args.from_stage) selected = STAGES[start:] else: selected = STAGES print(f"=== 공통 리노컷 파이프라인: persona={cfg.code} dir={persona_dir} ===") print(f"실행할 단계: {[s[0] for s in selected]}") run_log: list[dict] = [] for name, script in selected: print(f"\n--- [{name}] {script} ---") ok, output, elapsed = run_stage(name, script, persona_dir) print(output.rstrip()) print(f"--- [{name}] {'OK' if ok else '실패'} ({elapsed:.1f}s) ---") run_log.append({"stage": name, "ok": ok, "elapsedSec": round(elapsed, 2)}) if not ok: print(f"\n[중단] 단계 {name}에서 실패했다. 이후 단계는 실행하지 않는다.") _record_run_log(cfg.manifest_path, cfg.code, run_log, completed=False) return 1 _record_run_log(cfg.manifest_path, cfg.code, run_log, completed=True) print(f"\n=== 전체 완료: {len(selected)}개 단계 ===") return 0 def _record_run_log(manifest_path: Path, persona_code: str, run_log: list[dict], completed: bool) -> None: """단계별 실행 기록(성공 여부·소요 시간)을 manifest에 남긴다. 각 단계 자체의 검사 수치(halo%, 평균절대차 등)는 이미 각 단계 스크립트가 manifest의 해당 섹션(layersV2, lipTexture, jaw, ...)에 기록하므로 여기서 중복하지 않는다.""" if not manifest_path.exists(): return manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["pipelineRun"] = { "persona": persona_code, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "completed": completed, "stages": run_log, } manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") if __name__ == "__main__": sys.exit(main())