"""공통 리노컷 리그 — 캐스트 확장 0단계: 원화 3장을 캔버스로 정규화. 입력 front/faceless/body는 모두 같은 좌표계(후보 원화) 위에 있다고 전제한다 (T1 register_edit.py가 faceless·body를 원화 좌표계로 이미 정렬해 둔 출력을 그대로 받는다). 셋 모두에 **같은 아핀 변환 하나**(등방 배율 + 평행이동, 회전 없음)를 적용해 캔버스 1005x1566(결정문 §8.4)으로 옮긴다. 기준은 P1 base-front.png의 랜드마크다(눈 중심 두 점의 중점, 두 눈 사이 거리, 턱끝) — 머리 크기·위치가 P1과 같은 비율이 되게 새 원화의 배율·이동을 定한다. mediapipe FaceLandmarker 인덱스: 눈A=(33,133) 중점, 눈B=(263,362) 중점, 턱=152 (landmarks.py의 EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다). 배율: s_head = (P1 눈-턱 거리/원화 눈-턱 거리 + P1 눈 사이 거리/원화 눈 사이 거리) / 2. s = max(s_head, 캔버스폭/원화폭) — 원화가 머리 비율상 더 작게 배율돼도 캔버스 폭은 항상 덮어야 한다. 이동: 두 눈 중점을 P1 두 눈 중점에 맞춘다. 가로 오프셋은 배율된 원화가 캔버스 폭을 꼭 덮도록 [캔버스폭 − 원화폭·s, 0] 구간으로 자른다(좌우 빈틈 방지). 세로 오프셋은 자르지 않는다 — 위/아래로 남는 빈 곳은 각 이미지 종류에 맞게 채운다(front·faceless: 원본 가장자리 띠 반사 패딩, body: #00ff00). 사용: normalize_base.py --front <원화> --faceless <정렬된 faceless> --body <정렬된 body> """ from __future__ import annotations import argparse import hashlib import json import subprocess import sys import tempfile from pathlib import Path import cv2 import numpy as np from PIL import Image SCRIPTS_DIR = Path(__file__).resolve().parent CANVAS_W = 1005 CANVAS_H = 1566 TOP_PAPER_BAND_PX = 8 # 위쪽 채움 색을 뽑을 원화 맨 위 행 수(머리카락은 14행부터 나온다) P1_REFERENCE_RELATIVE = Path("docs/avatar-art/p1-linocut/base/base-front.png") # mediapipe FaceLandmarker 인덱스(landmarks.py EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다). EYE_A_IDX = (33, 133) EYE_B_IDX = (263, 362) CHIN_IDX = 152 def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]: """FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유).""" with tempfile.TemporaryDirectory() as td: out_json = Path(td) / "landmarks.json" proc = subprocess.run( [sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)], capture_output=True, text=True, ) print(proc.stdout.strip()) if proc.returncode != 0 or not out_json.exists(): raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}") data = json.loads(out_json.read_text(encoding="utf-8")) if not data.get("ok"): raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.") return [(p[0], p[1]) for p in data["points"]] def midpoint(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]: return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) def dist(a: tuple[float, float], b: tuple[float, float]) -> float: return float(np.hypot(a[0] - b[0], a[1] - b[1])) def measure_head(points: list[tuple[float, float]]) -> dict: eye_a = midpoint(points[EYE_A_IDX[0]], points[EYE_A_IDX[1]]) eye_b = midpoint(points[EYE_B_IDX[0]], points[EYE_B_IDX[1]]) eyes_center = midpoint(eye_a, eye_b) chin = points[CHIN_IDX] return { "eyeA": eye_a, "eyeB": eye_b, "eyesCenter": eyes_center, "chin": chin, "eyeDistance": dist(eye_a, eye_b), "eyeChinDistance": dist(eyes_center, chin), } def measure_report(head: dict) -> dict: return { "eyesCenter": [round(head["eyesCenter"][0], 2), round(head["eyesCenter"][1], 2)], "chin": [round(head["chin"][0], 2), round(head["chin"][1], 2)], "eyeDistance": round(head["eyeDistance"], 3), "eyeChinDistance": round(head["eyeChinDistance"], 3), } def clamp(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) def sha256_of(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="원화 3장(front/faceless/body)을 캔버스 1005x1566으로 정규화한다.") parser.add_argument("persona_dir", help="페르소나 폴더(예: docs/avatar-art/p2-linocut)") parser.add_argument("--front", required=True, help="후보 원화(정면 기본형)") parser.add_argument("--faceless", required=True, help="원화 좌표계로 정렬된 얼굴 없는 기본형(T1 출력)") parser.add_argument("--body", required=True, help="원화 좌표계로 정렬된 body 편집본(T1 출력)") return parser.parse_args(argv) def main(argv: list[str]) -> int: args = parse_args(argv) persona_dir = Path(args.persona_dir).resolve() front_path = Path(args.front).resolve() faceless_path = Path(args.faceless).resolve() body_path = Path(args.body).resolve() # persona_dir 위치와 무관하게 이 스크립트 파일 기준으로 repo root를 고정한다 # (persona.json이 아직 없을 수 있는 단계라 persona_config.py의 persona_dir 기준 # repo_root 계산에 의존하지 않는다): scripts/ -> linocut-pipeline -> avatar-art -> docs -> repo root. repo_root = SCRIPTS_DIR.parents[3] ref_path = repo_root / P1_REFERENCE_RELATIVE if not ref_path.exists(): raise SystemExit(f"[중단] P1 기준 이미지가 없다: {ref_path}") print(f"=== 기준(P1) 랜드마크: {ref_path} ===") ref_head = measure_head(detect_face_landmarks(ref_path)) print( f" eyesCenter={ref_head['eyesCenter']} eyeDistance={ref_head['eyeDistance']:.2f} " f"eyeChinDistance={ref_head['eyeChinDistance']:.2f}" ) print(f"=== 입력 원화 랜드마크: {front_path} ===") front_head = measure_head(detect_face_landmarks(front_path)) print( f" eyesCenter={front_head['eyesCenter']} eyeDistance={front_head['eyeDistance']:.2f} " f"eyeChinDistance={front_head['eyeChinDistance']:.2f}" ) front_img = Image.open(front_path).convert("RGB") front_w, front_h = front_img.size s_head = ( ref_head["eyeChinDistance"] / front_head["eyeChinDistance"] + ref_head["eyeDistance"] / front_head["eyeDistance"] ) / 2.0 s_width = CANVAS_W / front_w s = max(s_head, s_width) print(f"s_head={s_head:.5f} s_width(캔버스폭/원화폭)={s_width:.5f} -> s={s:.5f}") offset_x_natural = ref_head["eyesCenter"][0] - front_head["eyesCenter"][0] * s offset_x_lo, offset_x_hi = CANVAS_W - front_w * s, 0.0 offset_x = clamp(offset_x_natural, offset_x_lo, offset_x_hi) offset_y = ref_head["eyesCenter"][1] - front_head["eyesCenter"][1] * s print(f"offset_x natural={offset_x_natural:.3f} clamp=[{offset_x_lo:.3f}, {offset_x_hi:.3f}] -> {offset_x:.3f}") print(f"offset_y={offset_y:.3f} (클램프 없음)") covered_x0 = clamp(offset_x, 0.0, CANVAS_W) covered_x1 = clamp(offset_x + front_w * s, 0.0, CANVAS_W) covered_y0 = clamp(offset_y, 0.0, CANVAS_H) covered_y1 = clamp(offset_y + front_h * s, 0.0, CANVAS_H) print( f"덮인 범위(반사/채움 아닌 원화 픽셀): x=[{covered_x0:.1f}, {covered_x1:.1f}] " f"y=[{covered_y0:.1f}, {covered_y1:.1f}] (캔버스 {CANVAS_W}x{CANVAS_H})" ) matrix = np.array([[s, 0.0, offset_x], [0.0, s, offset_y]], dtype=np.float64) def warp(src_path: Path, border_mode: int, border_value: tuple[int, int, int] | None) -> np.ndarray: arr = np.array(Image.open(src_path).convert("RGB")) kwargs: dict = {"flags": cv2.INTER_LANCZOS4, "borderMode": border_mode} if border_value is not None: kwargs["borderValue"] = border_value return cv2.warpAffine(arr, matrix, (CANVAS_W, CANVAS_H), **kwargs) # front·faceless: 빈 곳은 원본 가장자리 띠 반사(BORDER_REFLECT_101 — 가장자리 픽셀을 # 복제하지 않고 그 안쪽부터 비추어, 복제판(T3 build_padded_faceless류)보다 이음매가 # 덜 드러난다; 위쪽은 아래에서 종이색으로 덮는다). body: 빈 곳은 크로마키 배경(#00ff00)으로 채운다. front_out = warp(front_path, cv2.BORDER_REFLECT_101, None) faceless_out = warp(faceless_path, cv2.BORDER_REFLECT_101, None) body_out = warp(body_path, cv2.BORDER_CONSTANT, (0, 255, 0)) # 위쪽 빈 곳만은 반사하지 않고 원화 위 가장자리의 종이색으로 채운다. 머리카락이 원화 위 # 끝 가까이(14~42px)까지 오는 후보는 반사하면 머리 위에 뒤집힌 머리카락이 생긴다 # (2026-10-01 P3·P5 판정). 배경은 분할에서 레이어 밖으로 빠지므로 평평한 색이어도 화면에 # 드러나지 않는다. 아래쪽은 옷이 이어지는 편이 나아 반사를 유지한다. top_rows = int(np.ceil(covered_y0)) if top_rows > 0: for out, src_path in ((front_out, front_path), (faceless_out, faceless_path)): band = np.array(Image.open(src_path).convert("RGB"))[:TOP_PAPER_BAND_PX].reshape(-1, 3) paper = band[band.mean(axis=1) > 150] out[:top_rows] = np.median(paper if len(paper) else band, axis=0).astype(np.uint8) base_dir = persona_dir / "base" raw_dir = persona_dir / "raw" base_dir.mkdir(parents=True, exist_ok=True) raw_dir.mkdir(parents=True, exist_ok=True) front_out_path = base_dir / "base-front.png" faceless_out_path = base_dir / "base-faceless.png" body_out_path = raw_dir / "body.png" Image.fromarray(front_out, "RGB").save(front_out_path) Image.fromarray(faceless_out, "RGB").save(faceless_out_path) Image.fromarray(body_out, "RGB").save(body_out_path) print(f"저장: {front_out_path}") print(f"저장: {faceless_out_path}") print(f"저장: {body_out_path}") report = { "canvas": {"w": CANVAS_W, "h": CANVAS_H}, "reference": {"image": str(ref_path.relative_to(repo_root)), **measure_report(ref_head)}, "input": { "front": str(front_path), "faceless": str(faceless_path), "body": str(body_path), "frontSize": [front_w, front_h], **measure_report(front_head), }, "transform": { "sHead": round(s_head, 6), "sWidth": round(s_width, 6), "s": round(s, 6), "rotationDeg": 0.0, "offsetXNatural": round(offset_x_natural, 3), "offsetXClampRange": [round(offset_x_lo, 3), round(offset_x_hi, 3)], "offsetX": round(offset_x, 3), "offsetY": round(offset_y, 3), }, "coveredRange": { "x": [round(covered_x0, 1), round(covered_x1, 1)], "y": [round(covered_y0, 1), round(covered_y1, 1)], }, "inputSha256": { "front": sha256_of(front_path), "faceless": sha256_of(faceless_path), "body": sha256_of(body_path), }, } report_path = base_dir / "normalize.json" report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(f"저장: {report_path}") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))