P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
parent
85bd079d18
commit
00169533d8
83 changed files with 7843 additions and 0 deletions
361
docs/avatar-art/p1-linocut/scripts/build_layers.py
Normal file
361
docs/avatar-art/p1-linocut/scripts/build_layers.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue