"""P1 서연 리노컷 최종 게시 미리보기 — 2단계-B-1a (5). 게시된 WebP(apps/web/public/avatar/v3/p1/*.webp)를 다시 읽어 합성한다(원본 PNG가 아니라 실제로 배포되는 파일을 검증하기 위함). export_rig.py가 남긴 preview/v2/export-rig-report.json의 rig 정보(레이어 x,y,w,h, pivots, palette)를 좌표 소스로 쓴다. 벡터 부위(눈·눈썹·입 등)는 없다 — faceDetail과 grain까지만 포함한 정적 합성이다. 생성물: motion-{cream,cool,dark}.png, face-detail.png, closed-eyes.png, holes-texture.jpg, ghost-check.jpg 실행: /python.exe build_final_previews.py """ from __future__ import annotations import json import sys from pathlib import Path import numpy as np from PIL import Image, ImageDraw SCRIPTS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPTS_DIR)) from build_layers_segmented import composite_over, rotate_rgba, to_u8, translate_rgba # noqa: E402 import build_face_detail as bfd # noqa: E402 ROOT = SCRIPTS_DIR.parent BASE_DIR = ROOT / "base" PREVIEW_V2_DIR = ROOT / "preview" / "v2" MANIFEST_PATH = ROOT / "manifest.json" REPORT_PATH = PREVIEW_V2_DIR / "export-rig-report.json" REPO_ROOT = ROOT.parents[2] PUBLIC_DIR = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "v3" / "p1" GRAIN_OPACITY = 0.35 BG_CREAM = (0xEE, 0xE5, 0xD3) BG_COOL = (0xDC, 0xE0, 0xE2) BG_DARK = (0x3A, 0x3A, 0x3A) FRAMES = [ ("rotate-4deg", {"rotate": -4.0, "tx": 0.0, "ty": 0.0}), ("rotate+4deg", {"rotate": 4.0, "tx": 0.0, "ty": 0.0}), ("up14px", {"rotate": 0.0, "tx": 0.0, "ty": -14.0}), ("down10px", {"rotate": 0.0, "tx": 0.0, "ty": 10.0}), ("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}), ] def load_layer(href_stem: str, x: float, y: float, w: float, h: float, canvas_w: int, canvas_h: int) -> tuple[np.ndarray, np.ndarray]: """게시된 webp를 rig 사각형(x,y,w,h)에 맞춰 리사이즈하고 캔버스 크기로 패딩한다.""" im = Image.open(PUBLIC_DIR / f"{href_stem}.webp").convert("RGBA") tw, th = round(w), round(h) if im.size != (tw, th): im = im.resize((tw, th), Image.LANCZOS) canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) canvas.paste(im, (round(x), round(y))) arr = np.array(canvas).astype(np.float64) return arr[..., :3], arr[..., 3] def polygon_alpha_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray: img = Image.new("L", (w, h), 0) ImageDraw.Draw(img).polygon(points, fill=255) from scipy.ndimage import gaussian_filter return np.clip(gaussian_filter(np.array(img, dtype=np.float64), sigma=2.0), 0, 255) def tile_grain(canvas_w: int, canvas_h: int, size: int) -> np.ndarray: grain_im = Image.open(PUBLIC_DIR / "paper-grain.webp").convert("RGB") if grain_im.size != (size, size): grain_im = grain_im.resize((size, size), Image.LANCZOS) grain = np.array(grain_im).astype(np.float64) ny = -(-canvas_h // size) nx = -(-canvas_w // size) tiled = np.tile(grain, (ny, nx, 1))[:canvas_h, :canvas_w, :] return tiled def apply_grain_multiply(rgb: np.ndarray, grain: np.ndarray, opacity: float) -> np.ndarray: factor = grain / 255.0 multiplied = rgb * factor return np.clip(rgb * (1 - opacity) + multiplied * opacity, 0, 255) def composite_static(body, head, face_detail, hair_front, bg: tuple[int, int, int], canvas_w: int, canvas_h: int) -> np.ndarray: canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64) canvas[..., 0], canvas[..., 1], canvas[..., 2] = bg canvas[..., 3] = 255.0 canvas = composite_over(canvas, to_u8(body[0]), to_u8(body[1])) canvas = composite_over(canvas, to_u8(head[0]), to_u8(head[1])) canvas = composite_over(canvas, to_u8(face_detail[0]), to_u8(face_detail[1])) canvas = composite_over(canvas, to_u8(hair_front[0]), to_u8(hair_front[1])) return to_u8(canvas)[..., :3].astype(np.float64) def main() -> int: report = json.loads(REPORT_PATH.read_text(encoding="utf-8")) rig = report["rig"] manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) cw, ch = rig["canvas"]["w"], rig["canvas"]["h"] neck_pivot = tuple(rig["pivots"]["neck"]) def layer_of(key: str) -> tuple[np.ndarray, np.ndarray]: l = rig["layers"][key] stem = Path(l["href"]).stem return load_layer(stem, l["x"], l["y"], l["w"], l["h"], cw, ch) body = layer_of("body") head = layer_of("head") hair_front = layer_of("hairFront") face_detail_raw = layer_of("faceDetail") face_oval = [tuple(p) for p in rig["faceOval"]] clip = polygon_alpha_mask(face_oval, cw, ch) / 255.0 face_detail = (face_detail_raw[0], face_detail_raw[1] * clip) grain_tile = tile_grain(cw, ch, rig["grain"]["size"]) # ------------------------------------------------------------------ # motion-{cream,cool,dark}.png # ------------------------------------------------------------------ for bg_name, bg in (("cream", BG_CREAM), ("cool", BG_COOL), ("dark", BG_DARK)): frame_ims = [] for name, t in FRAMES: h_rgb, h_a = to_u8(head[0]), to_u8(head[1]) fd_rgb, fd_a = to_u8(face_detail[0]), to_u8(face_detail[1]) hf_rgb, hf_a = to_u8(hair_front[0]), to_u8(hair_front[1]) if t["rotate"] != 0.0: h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], neck_pivot) fd_rgb, fd_a = rotate_rgba(fd_rgb, fd_a, t["rotate"], neck_pivot) hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], neck_pivot) if t["tx"] != 0.0 or t["ty"] != 0.0: h_rgb, h_a = translate_rgba(h_rgb, h_a, t["tx"], t["ty"]) fd_rgb, fd_a = translate_rgba(fd_rgb, fd_a, t["tx"], t["ty"]) hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4) frame_rgb = composite_static( (to_u8(body[0]), to_u8(body[1])), (h_rgb, h_a), (fd_rgb, fd_a), (hf_rgb, hf_a), bg, cw, ch ) frame_rgb = apply_grain_multiply(frame_rgb, grain_tile, GRAIN_OPACITY) im = Image.fromarray(to_u8(frame_rgb), "RGB") d = ImageDraw.Draw(im) label_color = (255, 60, 60) if bg_name != "dark" else (255, 200, 140) d.text((20, 20), name, fill=label_color) frame_ims.append(im) # 세로로 긴 캔버스라 옆으로 5장 나열하면 매우 넓어지므로 절반 크기로 축소해 나열 scale = 0.45 sw, sh = round(cw * scale), round(ch * scale) gap = 10 strip = Image.new("RGB", (sw * len(frame_ims) + gap * (len(frame_ims) - 1), sh), bg) x = 0 for im in frame_ims: strip.paste(im.resize((sw, sh), Image.LANCZOS), (x, 0)) x += sw + gap out_path = PREVIEW_V2_DIR / f"motion-{bg_name}.png" strip.save(out_path) print(f"저장: {out_path}") # ------------------------------------------------------------------ # 제외 영역 재구성(build_face_detail.py와 동일한 함수·상수) — 미리보기가 # 실제 게시물이 쓴 것과 같은 제외 영역 윤곽을 보여주게 한다. # ------------------------------------------------------------------ lm = manifest["landmarks"] front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64) eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"] browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"] mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"] upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"] lum_front = front.mean(axis=2) import scipy.ndimage as ndi from build_layers_segmented import build_padded_faceless yy_full, _ = np.mgrid[0:ch, 0:cw] eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX) eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX) eyeL_ink = bfd.eye_dark_hole(eyeL, lum_front, cw, ch) & eyeL_cap eyeR_ink = bfd.eye_dark_hole(eyeR, lum_front, cw, ch) & eyeR_cap eyeL_grown = eyeL_ink | bfd.grow_directional(eyeL_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeL_ink, dx=-bfd.EYE_OUTER_EXT_PX) eyeR_grown = eyeR_ink | bfd.grow_directional(eyeR_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeR_ink, dx=bfd.EYE_OUTER_EXT_PX) eyeL_core_excl = ndi.binary_dilation(eyeL_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeL_cap eyeR_core_excl = ndi.binary_dilation(eyeR_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeR_cap eyeL_lower_excl = ndi.binary_dilation( bfd.lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX ) eyeR_lower_excl = ndi.binary_dilation( bfd.lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX ) eyeL_excl = eyeL_core_excl | eyeL_lower_excl eyeR_excl = eyeR_core_excl | eyeR_lower_excl browL_ink = bfd.brow_dark_hole(browL, lum_front, cw, ch) browR_ink = bfd.brow_dark_hole(browR, lum_front, cw, ch) browL_band = bfd.eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH) browR_band = bfd.eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH) browL_excl = ndi.binary_dilation(browL_ink | browL_band, iterations=bfd.BROW_FINAL_DILATE_PX) browR_excl = ndi.binary_dilation(browR_ink | browR_band, iterations=bfd.BROW_FINAL_DILATE_PX) mouth_ink = bfd.mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, cw, ch) mouth_grown = ( mouth_ink | bfd.grow_directional(mouth_ink, dx=-bfd.MOUTH_CORNER_EXT_PX) | bfd.grow_directional(mouth_ink, dx=bfd.MOUTH_CORNER_EXT_PX) | bfd.grow_directional(mouth_ink, dy=bfd.MOUTH_SHADOW_EXT_PX) ) mouth_excl = ndi.binary_dilation(mouth_grown, iterations=bfd.MOUTH_FINAL_DILATE_PX) named_holes = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl} hole_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl def mask_outline(mask: np.ndarray) -> np.ndarray: return mask & ~ndi.binary_erosion(mask, iterations=2) # ------------------------------------------------------------------ # holes-texture.jpg: 구멍별로 [F 원본, 메운 결과(face_detail_rgb), # base-front, 제외 영역 윤곽 겹침]을 2배 확대해 나란히 놓는다. # ------------------------------------------------------------------ f_arr = np.array(build_padded_faceless()).astype(np.float64) # 게시된 webp는 알파 bbox로 잘려 있어(bbox 밖은 빈 캔버스) 구멍이 bbox 경계에 # 걸치면 미리보기가 검게 잘린 것처럼 보인다 — 원본 PNG(전체 캔버스, RGB가 # 어디서나 정의됨)를 직접 읽어 이 문제를 피한다. fd_rgb_full = np.array(Image.open(ROOT / "layers" / "v2" / "face-detail.png").convert("RGBA")).astype(np.float64)[..., :3] outline_overlay = front.copy() outline_overlay[mask_outline(hole_mask)] = np.array([40.0, 200.0, 60.0]) rows = [] zoom = 2 hole_pad = 16 for name, m in named_holes.items(): ys, xs = np.where(m) bx0, by0, bx1, by1 = int(xs.min()) - hole_pad, int(ys.min()) - hole_pad, int(xs.max()) + 1 + hole_pad, int(ys.max()) + 1 + hole_pad box = (max(0, bx0), max(0, by0), min(cw, bx1), min(ch, by1)) f_crop = Image.fromarray(to_u8(f_arr), "RGB").crop(box) fill_crop = Image.fromarray(to_u8(fd_rgb_full), "RGB").crop(box) front_crop = Image.fromarray(to_u8(front), "RGB").crop(box) outline_crop = Image.fromarray(to_u8(outline_overlay), "RGB").crop(box) pw2, ph2 = f_crop.size f_crop = f_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST) fill_crop = fill_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST) front_crop = front_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST) outline_crop = outline_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST) row = Image.new("RGB", (pw2 * zoom * 4 + 30, ph2 * zoom + 20), (255, 255, 255)) d = ImageDraw.Draw(row) for i, (label, im) in enumerate([("F 원본", f_crop), ("메운 결과", fill_crop), ("base-front", front_crop), ("제외영역 윤곽", outline_crop)]): row.paste(im, (i * (pw2 * zoom + 10), 20)) d.text((i * (pw2 * zoom + 10), 2), f"{name}: {label}", fill=(0, 0, 0)) rows.append(row) max_w = max(r.width for r in rows) total_h = sum(r.height for r in rows) + 10 * (len(rows) - 1) holes_tex = Image.new("RGB", (max_w, total_h), (255, 255, 255)) y = 0 for r in rows: holes_tex.paste(r, (0, y)) y += r.height + 10 holes_tex_path = PREVIEW_V2_DIR / "holes-texture.jpg" holes_tex.convert("RGB").save(holes_tex_path, "JPEG", quality=90) print(f"저장: {holes_tex_path}") fd_bbox = manifest["layersV2"]["faceDetail"]["bbox"] fx0, fy0, fx1, fy1 = fd_bbox pad = 20 fx0, fy0 = max(0, fx0 - pad), max(0, fy0 - pad) fx1, fy1 = min(cw, fx1 + pad), min(ch, fy1 + pad) # (1) faceDetail 단독(크림 배경 위) fd_on_cream = np.zeros((ch, cw, 3), dtype=np.float64) fd_on_cream[...] = BG_CREAM fd_on_cream = composite_over( np.dstack([fd_on_cream, np.full((ch, cw), 255.0)]), to_u8(face_detail[0]), to_u8(face_detail[1]) )[..., :3] # (2) 구멍 표시(빨강 오버레이) hole_overlay = front.copy() hole_overlay[hole_mask] = hole_overlay[hole_mask] * 0.4 + np.array([230.0, 40.0, 40.0]) * 0.6 # (3) 정지 합성 static_full = composite_static(body, head, face_detail, hair_front, BG_CREAM, cw, ch) static_full = apply_grain_multiply(static_full, grain_tile, GRAIN_OPACITY) # (4) base-front 비교는 front 그대로 panels = [ ("faceDetail 단독", Image.fromarray(to_u8(fd_on_cream), "RGB")), ("구멍 표시", Image.fromarray(to_u8(hole_overlay), "RGB")), ("정지 합성", Image.fromarray(to_u8(static_full), "RGB")), ("base-front", Image.fromarray(to_u8(front), "RGB")), ] crop_box = (int(fx0), int(fy0), int(fx1), int(fy1)) cropped = [im.crop(crop_box) for _, im in panels] pw, ph = cropped[0].size strip = Image.new("RGB", (pw * 4 + 30, ph + 24), (255, 255, 255)) x = 0 for (label, _), im in zip(panels, cropped): strip.paste(im, (x, 24)) d = ImageDraw.Draw(strip) d.text((x, 4), label, fill=(0, 0, 0)) x += pw + 10 out_path = PREVIEW_V2_DIR / "face-detail.png" strip.save(out_path) print(f"저장: {out_path}") # ------------------------------------------------------------------ # closed-eyes.png: 벡터 없이 구멍만 보이는 정지 합성의 눈·입 확대 # ------------------------------------------------------------------ eye_box = (280, 470, 700, 680) mouth_box = (360, 730, 630, 910) eye_crop = Image.fromarray(to_u8(static_full), "RGB").crop(eye_box) mouth_crop = Image.fromarray(to_u8(static_full), "RGB").crop(mouth_box) zoom = 2 eye_crop = eye_crop.resize((eye_crop.width * zoom, eye_crop.height * zoom), Image.LANCZOS) mouth_w = eye_crop.width mouth_h = round(mouth_crop.height * (mouth_w / mouth_crop.width)) mouth_crop = mouth_crop.resize((mouth_w, mouth_h), Image.LANCZOS) out_im = Image.new("RGB", (mouth_w, eye_crop.height + mouth_h + 10), (255, 255, 255)) out_im.paste(eye_crop, (0, 0)) out_im.paste(mouth_crop, (0, eye_crop.height + 10)) out_path = PREVIEW_V2_DIR / "closed-eyes.png" out_im.save(out_path) print(f"저장: {out_path}") # ------------------------------------------------------------------ # ghost-check.jpg: faceDetail만 올린 얼굴(벡터 없음)의 눈·눈썹·입을 한 # 프레임으로 2배 확대 — 옛 잉크선(유령 윤곽)이 남았는지 보는 용도. # ------------------------------------------------------------------ ghost_box = (260, 440, 760, 940) ghost_crop = Image.fromarray(to_u8(static_full), "RGB").crop(ghost_box) ghost_crop = ghost_crop.resize((ghost_crop.width * 2, ghost_crop.height * 2), Image.LANCZOS) out_path = PREVIEW_V2_DIR / "ghost-check.jpg" ghost_crop.convert("RGB").save(out_path, "JPEG", quality=92) print(f"저장: {out_path}") return 0 if __name__ == "__main__": raise SystemExit(main())