P1 서연 리노컷 아트 원본과 자산 파이프라인

- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
Yun Chan 2026-10-01 09:58:15 +09:00
parent 85bd079d18
commit 00169533d8
83 changed files with 7843 additions and 0 deletions

View file

@ -0,0 +1,361 @@
"""P1 서연 리노컷 리그 덩어리 레이어(body/head/hairFront) 빌드 스크립트.
raw/*.png (초록 배경 위 codex exec 생성본) -> 크로마키 -> 기준 이미지 위상상관 정렬
-> layers/*.png(투명 PNG) + preview/*.png + manifest.json(layers 섹션).
실행: <venv>/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())