리노컷 자산 파이프라인 공통화와 캐스트 외형 설계

- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
This commit is contained in:
Yun Chan 2026-10-01 16:11:28 +09:00
parent f11e76ff18
commit ecb36d123f
38 changed files with 2393 additions and 990 deletions

View file

@ -0,0 +1,344 @@
"""공통 리노컷 리그 — 최종 게시 미리보기.
게시된 WebP(apps/web/public/avatar/v3/<publicSlug>/*.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
전제: export_rig.py가 먼저 게시를 끝내야 한다(이 스크립트가 export-rig-report.json과
게시된 webp를 읽는다) — 파이프라인에서 가장 마지막에 돌린다.
실행: <venv>/python.exe final_previews.py <persona-dir>
"""
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 segmentation import composite_over, rotate_rgba, to_u8, translate_rgba, build_padded_faceless # noqa: E402
import face_detail as bfd # noqa: E402
from persona_config import load_persona_config # noqa: E402
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(public_dir: Path, 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(public_dir: Path, 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(persona_dir: Path) -> int:
cfg = load_persona_config(persona_dir)
base_dir = cfg.base_dir
layers_v2_dir = cfg.layers_v2_dir
preview_v2_dir = cfg.preview_v2_dir
manifest_path = cfg.manifest_path
public_dir = cfg.public_dir
report_path = preview_v2_dir / "export-rig-report.json"
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(public_dir, 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(public_dir, 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}")
# ------------------------------------------------------------------
# 제외 영역 재구성(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
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(base_dir)).astype(np.float64)
# 게시된 webp는 알파 bbox로 잘려 있어(bbox 밖은 빈 캔버스) 구멍이 bbox 경계에
# 걸치면 미리보기가 검게 잘린 것처럼 보인다 — 원본 PNG(전체 캔버스, RGB가
# 어디서나 정의됨)를 직접 읽어 이 문제를 피한다.
fd_rgb_full = np.array(Image.open(layers_v2_dir / "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: 벡터 없이 구멍만 보이는 정지 합성의 눈·입 확대
# (머리 영역 중심 기준 상대 좌표 — 결과물 회귀 대상이 아닌 미리보기 전용)
# ------------------------------------------------------------------
eyeL_out, eyeR_out = eyeL["outerCorner"], eyeR["outerCorner"]
eye_cx = (eyeL_out[0] + eyeR_out[0]) / 2.0
eye_box = (int(eye_cx - 210), int(browL["peak"][1] - 40), int(eye_cx + 210), int(eyeL["lowerLidBottom"][1] + 100))
mouth_cx = (mcL[0] + mcR[0]) / 2.0
mouth_box = (int(mouth_cx - 135), int(upLip[1] - 43), int(mouth_cx + 135), int(loLip[1] + 78))
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 = (int(eye_cx - 240), int(browL["peak"][1] - 70), int(eye_cx + 260), int(loLip[1] + 110))
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(Path(sys.argv[1])))