- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
338 lines
16 KiB
Python
338 lines
16 KiB
Python
"""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
|
|
|
|
실행: <venv>/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())
|