"""P1 서연 리노컷 리그 덩어리 레이어(body/head/hairFront) 빌드 스크립트. raw/*.png (초록 배경 위 codex exec 생성본) -> 크로마키 -> 기준 이미지 위상상관 정렬 -> layers/*.png(투명 PNG) + preview/*.png + manifest.json(layers 섹션). 실행: /python.exe build_layers.py """ from __future__ import annotations import json import sys from pathlib import Path import numpy as np from PIL import Image from scipy.ndimage import gaussian_filter ROOT = Path(__file__).resolve().parents[1] BASE_DIR = ROOT / "base" RAW_DIR = ROOT / "raw" LAYERS_DIR = ROOT / "layers" PREVIEW_DIR = ROOT / "preview" MANIFEST_PATH = ROOT / "manifest.json" CREAM_BG = (0xEE, 0xE5, 0xD3) # AGENTS.md §4.2 알파 정제 임계치 ALPHA_LO, ALPHA_HI = 35, 205 FEATHER_SIGMA = 0.6 # ~1px 페더 # 크로마키(HSV 기반) 튜닝값. #00ff00 배경 기준. HUE_TARGET_DEG = 120.0 HUE_WINDOW_DEG = 40.0 SAT_LO, SAT_HI = 0.15, 0.5 VAL_LO, VAL_HI = 0.15, 0.5 GREEN_RESIDUE_MARGIN = 30 # G > R+margin && G > B+margin def rgb_to_hsv_np(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] maxc = np.max(rgb, axis=-1) minc = np.min(rgb, axis=-1) v = maxc delta = maxc - minc s = np.where(maxc > 0, delta / np.where(maxc == 0, 1, maxc), 0.0) safe_delta = np.where(delta == 0, 1, delta) rc = (maxc - r) / safe_delta gc = (maxc - g) / safe_delta bc = (maxc - b) / safe_delta h = np.zeros_like(maxc) h = np.where(maxc == r, (bc - gc), h) h = np.where(maxc == g, 2.0 + rc - bc, h) h = np.where(maxc == b, 4.0 + gc - rc, h) h = (h / 6.0) % 1.0 h = np.where(delta == 0, 0.0, h) return h, s, v def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """RGB(0-255) 배열을 받아 (despill된 RGB uint8, 정제된 알파 uint8)를 반환.""" rgb = rgb_u8.astype(np.float64) / 255.0 h, s, v = rgb_to_hsv_np(rgb) hue_deg = h * 360.0 hue_dist = np.abs(hue_deg - HUE_TARGET_DEG) hue_dist = np.minimum(hue_dist, 360.0 - hue_dist) hue_component = np.clip(1.0 - hue_dist / HUE_WINDOW_DEG, 0.0, 1.0) sat_component = np.clip((s - SAT_LO) / (SAT_HI - SAT_LO), 0.0, 1.0) val_component = np.clip((v - VAL_LO) / (VAL_HI - VAL_LO), 0.0, 1.0) green_score = hue_component * sat_component * val_component alpha_raw = (1.0 - green_score) * 255.0 alpha_refined = np.clip((alpha_raw - ALPHA_LO) * 255.0 / (ALPHA_HI - ALPHA_LO), 0, 255) alpha_feathered = gaussian_filter(alpha_refined, sigma=FEATHER_SIGMA) alpha_feathered = np.clip(alpha_feathered, 0, 255) r = rgb_u8[..., 0].astype(np.float64) g = rgb_u8[..., 1].astype(np.float64) b = rgb_u8[..., 2].astype(np.float64) g_despill = np.minimum(g, np.maximum(r, b)) despilled_rgb = np.stack([r, g_despill, b], axis=-1) return despilled_rgb.astype(np.uint8), alpha_feathered.astype(np.uint8) def load_and_normalize(path: Path, canvas_size: tuple[int, int]) -> tuple[np.ndarray, dict]: im = Image.open(path).convert("RGB") src_w, src_h = im.size tgt_w, tgt_h = canvas_size report = {"srcSize": [src_w, src_h], "targetSize": [tgt_w, tgt_h], "resized": False} if (src_w, src_h) != (tgt_w, tgt_h): src_ratio = src_w / src_h tgt_ratio = tgt_w / tgt_h ratio_diff_pct = abs(src_ratio - tgt_ratio) / tgt_ratio * 100.0 report["srcRatio"] = src_ratio report["targetRatio"] = tgt_ratio report["ratioDiffPct"] = ratio_diff_pct if ratio_diff_pct > 1.0: raise SystemExit( f"[중단] {path.name}: 종횡비 차이 {ratio_diff_pct:.3f}% > 1% " f"(src={src_w}x{src_h}, target={tgt_w}x{tgt_h}) — 보고 후 정지." ) im = im.resize((tgt_w, tgt_h), Image.LANCZOS) report["resized"] = True return np.array(im), report def phase_correlate(mask_a: np.ndarray, mask_b: np.ndarray) -> tuple[int, int]: """mask_a를 mask_b에 맞추기 위한 정수 (dx, dy) 오프셋을 반환한다. mask_a를 (dy行, dx열)만큼 이동시키면 mask_b와 정렬된다.""" a = mask_a.astype(np.float64) b = mask_b.astype(np.float64) fa = np.fft.fft2(a) fb = np.fft.fft2(b) cross = fa * np.conj(fb) denom = np.abs(cross) denom[denom == 0] = 1e-12 r = np.fft.ifft2(cross / denom) r = np.abs(r) peak = np.unravel_index(np.argmax(r), r.shape) dy, dx = peak h, w = a.shape if dy > h // 2: dy -= h if dx > w // 2: dx -= w # 교차 위상 스펙트럼 peak는 -d(이동량)에서 나타난다(이산 이동 정리) — 부호 반전해 반환. return int(-dx), int(-dy) def shift_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]: h, w = alpha.shape out_rgb = np.zeros_like(rgb) out_alpha = np.zeros_like(alpha) src_x0, src_x1 = max(0, -dx), min(w, w - dx) src_y0, src_y1 = max(0, -dy), min(h, h - dy) dst_x0, dst_x1 = max(0, dx), min(w, w + dx) dst_y0, dst_y1 = max(0, dy), min(h, h + dy) out_rgb[dst_y0:dst_y1, dst_x0:dst_x1] = rgb[src_y0:src_y1, src_x0:src_x1] out_alpha[dst_y0:dst_y1, dst_x0:dst_x1] = alpha[src_y0:src_y1, src_x0:src_x1] return out_rgb, out_alpha def pad_to_canvas(mask: np.ndarray, canvas_w: int, canvas_h: int) -> np.ndarray: src_h, src_w = mask.shape if (src_w, src_h) == (canvas_w, canvas_h): return mask out = np.zeros((canvas_h, canvas_w), dtype=mask.dtype) h = min(src_h, canvas_h) w = min(src_w, canvas_w) out[:h, :w] = mask[:h, :w] return out def build_body_ref_mask(base_front_rgb: np.ndarray) -> np.ndarray: h, w, _ = base_front_rgb.shape lum = base_front_rgb.astype(np.float64).mean(axis=2) y0 = int(0.62 * h) mask = np.zeros((h, w), dtype=bool) mask[y0:, :] = lum[y0:, :] < 90 return mask def build_head_ref_mask(base_faceless_rgb: np.ndarray) -> np.ndarray: h, w, _ = base_faceless_rgb.shape bg = np.array([233.0, 226.0, 207.0]) diff = np.sqrt(((base_faceless_rgb.astype(np.float64) - bg) ** 2).sum(axis=2)) y1 = int(0.735 * h) mask = np.zeros((h, w), dtype=bool) mask[:y1, :] = diff[:y1, :] > 25 return mask def build_hair_front_ref_mask(base_front_rgb: np.ndarray) -> np.ndarray: h, w, _ = base_front_rgb.shape lum = base_front_rgb.astype(np.float64).mean(axis=2) yy, xx = np.mgrid[0:h, 0:w] cx, cy = w * 0.5, h * 0.365 rx, ry = w * 0.30, h * 0.34 oval = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2 <= 1.0 dark = lum < 90 return oval & dark def alpha_bbox(alpha: np.ndarray, threshold: int = 1) -> list[int] | None: 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 green_residue_count(rgb: np.ndarray, alpha: np.ndarray) -> int: r = rgb[..., 0].astype(np.int32) g = rgb[..., 1].astype(np.int32) b = rgb[..., 2].astype(np.int32) mask = (alpha > 0) & (g > r + GREEN_RESIDUE_MARGIN) & (g > b + GREEN_RESIDUE_MARGIN) return int(mask.sum()) def composite_over(base_rgba: np.ndarray, layer_rgb: np.ndarray, layer_alpha: np.ndarray) -> np.ndarray: out = base_rgba.astype(np.float64).copy() a = (layer_alpha.astype(np.float64) / 255.0)[..., None] out[..., :3] = layer_rgb.astype(np.float64) * a + out[..., :3] * (1 - a) out[..., 3] = 255.0 * a[..., 0] + out[..., 3] * (1 - a[..., 0]) return out def main() -> int: LAYERS_DIR.mkdir(parents=True, exist_ok=True) PREVIEW_DIR.mkdir(parents=True, exist_ok=True) base_front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")) base_faceless = np.array(Image.open(BASE_DIR / "base-faceless.png").convert("RGB")) canvas_h, canvas_w = base_front.shape[0], base_front.shape[1] canvas_size = (canvas_w, canvas_h) print(f"기준 캔버스: {canvas_w}x{canvas_h}") ref_masks = { "body": build_body_ref_mask(base_front), "head": pad_to_canvas(build_head_ref_mask(base_faceless), canvas_w, canvas_h), "hairFront": build_hair_front_ref_mask(base_front), } for k, m in ref_masks.items(): Image.fromarray((m * 255).astype(np.uint8)).save(PREVIEW_DIR / f"refmask-{k}.png") layer_specs = [ ("body", "body.png"), ("head", "head.png"), ("hairFront", "hair-front.png"), ] layers_report = [] results = {} for layer_id, filename in layer_specs: raw_path = RAW_DIR / filename rgb, norm_report = load_and_normalize(raw_path, canvas_size) despilled_rgb, alpha = chroma_key(rgb) layer_mask = alpha > 127 ref_mask = ref_masks[layer_id] dx0, dy0 = phase_correlate(layer_mask, ref_mask) shifted_rgb, shifted_alpha = shift_rgba(despilled_rgb, alpha, dx0, dy0) shifted_mask = shifted_alpha > 127 dx1, dy1 = phase_correlate(shifted_mask, ref_mask) residual_exceeds = abs(dx1) > 1 or abs(dy1) > 1 out = np.dstack([shifted_rgb, shifted_alpha]).astype(np.uint8) out_path = LAYERS_DIR / f"{layer_id}.png" Image.fromarray(out, "RGBA").save(out_path) bbox = alpha_bbox(shifted_alpha) opaque_pixels = int((shifted_alpha == 255).sum()) green_residue = green_residue_count(shifted_rgb, shifted_alpha) entry = { "id": layer_id, "file": f"layers/{layer_id}.png", "sourceRaw": f"raw/{filename}", "rawNormalize": norm_report, "alphaBBox": bbox, "opaquePixels": opaque_pixels, "greenResidue": green_residue, "alignOffsetBefore": [dx0, dy0], "alignOffsetAfter": [dx1, dy1], "residualExceeds1px": residual_exceeds, } layers_report.append(entry) results[layer_id] = (shifted_rgb, shifted_alpha) print( f"[{layer_id}] normalize={norm_report} offsetBefore=({dx0},{dy0}) " f"offsetAfter=({dx1},{dy1}) bbox={bbox} opaque={opaque_pixels} " f"greenResidue={green_residue}" ) if residual_exceeds: print(f" [경고] {layer_id} 잔여 오프셋이 ±1px를 초과했다: ({dx1},{dy1})") canvas_rgba = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64) canvas_rgba[..., 0] = CREAM_BG[0] canvas_rgba[..., 1] = CREAM_BG[1] canvas_rgba[..., 2] = CREAM_BG[2] canvas_rgba[..., 3] = 255.0 for layer_id in ("body", "head", "hairFront"): rgb, alpha = results[layer_id] canvas_rgba = composite_over(canvas_rgba, rgb, alpha) composite = canvas_rgba.astype(np.uint8) composite_img = Image.fromarray(composite, "RGBA") composite_img.save(PREVIEW_DIR / "composite-faceless.png") base_faceless_img = Image.open(BASE_DIR / "base-faceless.png").convert("RGBA") bf_w, bf_h = base_faceless_img.size cmp_w, cmp_h = composite_img.size diff_w, diff_h = min(bf_w, cmp_w), min(bf_h, cmp_h) size_note = None if (bf_w, bf_h) != (cmp_w, cmp_h): size_note = ( f"base-faceless.png({bf_w}x{bf_h})와 composite({cmp_w}x{cmp_h}) 크기가 달라 " f"좌상단 기준 {diff_w}x{diff_h} 교차 영역만 비교했다." ) print(f"[안내] {size_note}") composite_arr = np.array(composite_img.convert("RGB"))[0:diff_h, 0:diff_w].astype(np.float64) base_arr = np.array(base_faceless_img.convert("RGB"))[0:diff_h, 0:diff_w].astype(np.float64) full_diff = np.abs(composite_arr - base_arr).mean(axis=2) mean_abs_diff_full = float(full_diff.mean()) head_ref = ref_masks["head"][0:diff_h, 0:diff_w] mean_abs_diff_face = float(full_diff[head_ref].mean()) if head_ref.any() else None hair_edge = ref_masks["hairFront"][0:diff_h, 0:diff_w] from scipy.ndimage import binary_dilation, binary_erosion dilated = binary_dilation(hair_edge, iterations=6) eroded = binary_erosion(hair_edge, iterations=6) hair_outline_band = dilated & ~eroded mean_abs_diff_hair_outline = ( float(full_diff[hair_outline_band].mean()) if hair_outline_band.any() else None ) side_by_side = Image.new("RGB", (diff_w * 2 + 20, diff_h), CREAM_BG) side_by_side.paste(base_faceless_img.convert("RGB").crop((0, 0, diff_w, diff_h)), (0, 0)) side_by_side.paste(composite_img.convert("RGB").crop((0, 0, diff_w, diff_h)), (diff_w + 20, 0)) side_by_side.save(PREVIEW_DIR / "compare.png") print( f"합성 차이: 전체={mean_abs_diff_full:.3f} 얼굴영역={mean_abs_diff_face} " f"머리윤곽영역={mean_abs_diff_hair_outline}" ) manifest = {} if MANIFEST_PATH.exists(): manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) manifest["schemaVersion"] = "vignette.avatar.v3.layers.v1" manifest["persona"] = "P1" manifest["canvas"] = {"w": canvas_w, "h": canvas_h} manifest["base"] = { "front": "base/base-front.png", "faceless": "base/base-faceless.png", "facelessSize": list(base_faceless_img.size), } manifest["layers"] = layers_report manifest["composite"] = { "meanAbsDiff": { "full": mean_abs_diff_full, "face": mean_abs_diff_face, "hairOutline": mean_abs_diff_hair_outline, }, "sizeNote": size_note, } MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print(f"manifest.json 저장: {MANIFEST_PATH}") return 0 if __name__ == "__main__": sys.exit(main())