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,411 @@
"""P1 서연 리노컷 리그 — 분할 기반 재작업 (2단계-A 반려 후 설계).
레이어 픽셀은 항상 base-faceless.png(패딩판 F)에서 가져온다. mediapipe
ImageSegmenter(selfie_multiclass_256x256)로 F를 분할해 head/hairFront/body
마스크를 만들고, body는 head 마스크가 덮는 영역 중 기존에 수용된 재생성
raw/body.png(크로마키 결과, offset 0,0 확인됨)의 알파>0 부분만 가려진 영역
채움으로 사용한다. imagegen 추가 호출 없음.
실행: <venv>/python.exe build_layers_segmented.py
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import binary_dilation, distance_transform_edt, 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"
SCRIPTS_DIR = Path(__file__).resolve().parent
MODEL_SEG = SCRIPTS_DIR / "_models" / "selfie_multiclass_256x256.tflite"
MODEL_FACE = SCRIPTS_DIR / "_models" / "face_landmarker.task"
MEDIAPIPE_VERSION = "1.0.1" # pip show mediapipe로 확인(런타임 import 생략 — 동일 프로세스 세그폴트 회피)
CREAM_BG = (0xEE, 0xE5, 0xD3)
FEATHER_PX = 1.0 # 레이어 자체 경계 페더
FILL_FEATHER_PX = 4.0 # body 채움 이음매 페더
HAIR_EDGE_DILATE_PX = 6
HAIR_EDGE_LUM_THRESH = 110
CHIN_MARGIN = 8
FACE_OVAL_SCALE = 1.04
GREEN_RESIDUE_MARGIN = 30
# mediapipe FaceMesh FACE_OVAL 연결(468 캐노니컬 토폴로지)을 순서대로 이은 폐곡선.
FACE_OVAL_LOOP = [
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379,
378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127,
162, 21, 54, 103, 67, 109,
]
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers import chroma_key # noqa: E402 (기존 크로마키 함수 재사용)
def build_padded_faceless() -> Image.Image:
bf = Image.open(BASE_DIR / "base-faceless.png").convert("RGB")
arr = np.array(bf)
padded = np.concatenate([arr, arr[-1:, :, :]], axis=0)
out = Image.fromarray(padded, "RGB")
out.save(BASE_DIR / "base-faceless-padded.png")
return out
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
"""FaceLandmarker를 별도 프로세스로 실행한다.
같은 프로세스에서 ImageSegmenter와 함께 호출하면 세그폴트(exit 139)가
재현확인됐다(scripts/_run_face_landmarks.py, _run_segmentation.py 분리 사유)."""
with tempfile.TemporaryDirectory() as td:
out_json = Path(td) / "landmarks.json"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_json.exists():
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
data = json.loads(out_json.read_text(encoding="utf-8"))
if not data.get("ok"):
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
return [(p[0], p[1]) for p in data["points"]]
def run_segmentation(image_path: Path) -> np.ndarray:
"""ImageSegmenter를 별도 프로세스로 실행한다(세그폴트 회피, 위 설명 참고)."""
if not MODEL_SEG.exists():
raise SystemExit(f"[중단] 분할 모델이 없다: {MODEL_SEG}")
with tempfile.TemporaryDirectory() as td:
out_npy = Path(td) / "category_mask.npy"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_segmentation.py"), str(image_path), str(out_npy)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_npy.exists():
raise SystemExit(f"[중단] ImageSegmenter 서브프로세스 실패.\n{proc.stderr}")
category_mask = np.load(out_npy)
return category_mask
def refine_hair_edge(hair_mask: np.ndarray, rgb_arr: np.ndarray) -> np.ndarray:
dil = binary_dilation(hair_mask, iterations=HAIR_EDGE_DILATE_PX)
band = dil & ~hair_mask
lum = rgb_arr.astype(np.float64).mean(axis=2)
add = band & (lum < HAIR_EDGE_LUM_THRESH)
return hair_mask | add
def polygon_mask(points: list[tuple[float, float]], w: int, h: int, scale: float = 1.0) -> tuple[np.ndarray, list[tuple[float, float]]]:
cx = float(np.mean([p[0] for p in points]))
cy = float(np.mean([p[1] for p in points]))
scaled = [((x - cx) * scale + cx, (y - cy) * scale + cy) for x, y in points]
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(scaled, fill=255)
return np.array(img) > 127, scaled
def feather_bool_mask(mask: np.ndarray, px: float = FEATHER_PX) -> np.ndarray:
a = mask.astype(np.float64) * 255.0
a = gaussian_filter(a, sigma=px / 1.6)
return np.clip(a, 0, 255)
def to_u8(x: np.ndarray) -> np.ndarray:
"""float 배열을 반올림해 uint8로 캐스팅한다(truncation으로 255가 254 되는 것 방지)."""
return np.clip(np.round(x), 0, 255).astype(np.uint8)
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 rotate_rgba(rgb: np.ndarray, alpha: np.ndarray, angle_deg: float, pivot: tuple[float, float]) -> tuple[np.ndarray, np.ndarray]:
im = Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA")
rot = im.rotate(angle_deg, resample=Image.BICUBIC, center=pivot, fillcolor=(0, 0, 0, 0))
out = np.array(rot)
return out[..., :3], out[..., 3]
def translate_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: float, dy: float) -> tuple[np.ndarray, np.ndarray]:
im = Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA")
out = Image.new("RGBA", im.size, (0, 0, 0, 0))
out.paste(im, (round(dx), round(dy)))
arr = np.array(out)
return arr[..., :3], arr[..., 3]
def main() -> int:
LAYERS_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
f_img = build_padded_faceless()
f_arr = np.array(f_img)
h, w, _ = f_arr.shape
print(f"F(패딩된 base-faceless) 크기: {w}x{h}")
front_pts = detect_face_landmarks(BASE_DIR / "base-front.png")
chin_y = front_pts[152][1]
chin_xy = front_pts[152]
face_oval_mask, face_oval_poly = polygon_mask(
[front_pts[i] for i in FACE_OVAL_LOOP], w, h, scale=FACE_OVAL_SCALE
)
print(f"턱끝(152) 좌표: {chin_xy}, chinY+{CHIN_MARGIN}={chin_y + CHIN_MARGIN:.1f}")
category_mask = run_segmentation(BASE_DIR / "base-faceless-padded.png")
cat_counts = {int(k): int(v) for k, v in zip(*np.unique(category_mask, return_counts=True))}
print(f"분할 카테고리 픽셀 수(F 전체 {w*h}): {cat_counts}")
bg_mask = category_mask == 0
hair_mask_raw = category_mask == 1
body_skin_mask = category_mask == 2
face_skin_mask = category_mask == 3
clothes_mask = category_mask == 4
others_mask = category_mask == 5
hair_mask = refine_hair_edge(hair_mask_raw, f_arr)
hair_edge_added = int((hair_mask & ~hair_mask_raw).sum())
print(f"머리카락 가장자리 보강으로 추가된 픽셀: {hair_edge_added}")
yy = np.arange(h)[:, None] * np.ones((1, w))
chin_line = chin_y + CHIN_MARGIN
head_mask = hair_mask | face_skin_mask | (body_skin_mask & (yy < chin_line))
hairfront_mask = hair_mask & face_oval_mask
body_base_mask = clothes_mask | others_mask | (body_skin_mask & (yy >= chin_line))
print(
f"head_mask={int(head_mask.sum())} hairfront_mask={int(hairfront_mask.sum())} "
f"body_base_mask={int(body_base_mask.sum())} 배경={int(bg_mask.sum())}"
)
# --- head / hairFront: F 픽셀을 각 마스크로 잘라 1px 페더 ---
head_alpha = feather_bool_mask(head_mask, FEATHER_PX)
hairfront_alpha = feather_bool_mask(hairfront_mask, FEATHER_PX)
head_rgb = f_arr.copy()
hairfront_rgb = f_arr.copy()
# --- body: F 기반 기본 + 수용된 재생성 raw/body.png 채움(4px 페더) ---
body_base_alpha = feather_bool_mask(body_base_mask, FEATHER_PX)
body_base_rgb = f_arr.copy()
raw_body = np.array(Image.open(RAW_DIR / "body.png").convert("RGB"))
if raw_body.shape[:2] != (h, w):
raise SystemExit(f"[중단] raw/body.png 크기 {raw_body.shape[:2][::-1]}가 캔버스 {w}x{h}와 다르다.")
regen_body_rgb, regen_body_alpha = chroma_key(raw_body)
fill_target = head_mask & (regen_body_alpha > 0)
fill_target_px = int(fill_target.sum())
print(f"body 채움 대상(head_mask ∩ 재생성 알파>0) 픽셀 수: {fill_target_px}")
dist_out = distance_transform_edt(~fill_target)
fill_weight = np.clip(1.0 - dist_out / FILL_FEATHER_PX, 0.0, 1.0)
fill_weight = gaussian_filter(fill_weight, sigma=FILL_FEATHER_PX / 2.35)
fill_weight = np.clip(fill_weight, 0.0, 1.0)
base_a = body_base_alpha / 255.0
fill_a = (regen_body_alpha.astype(np.float64) / 255.0) * fill_weight
out_a = base_a + fill_a * (1 - base_a)
eps = 1e-6
body_final_rgb = (
body_base_rgb.astype(np.float64) * base_a[..., None]
+ regen_body_rgb.astype(np.float64) * (fill_a * (1 - base_a))[..., None]
) / np.clip(out_a[..., None], eps, None)
body_final_rgb = to_u8(body_final_rgb)
body_final_alpha = to_u8(out_a * 255.0)
layers_out = {
"head": (to_u8(head_rgb), to_u8(head_alpha)),
"hairFront": (to_u8(hairfront_rgb), to_u8(hairfront_alpha)),
"body": (body_final_rgb, body_final_alpha),
}
layers_report = []
for layer_id in ("body", "head", "hairFront"):
rgb, alpha = layers_out[layer_id]
out_path = LAYERS_DIR / f"{layer_id}.png"
Image.fromarray(np.dstack([rgb, alpha]), "RGBA").save(out_path)
bbox = alpha_bbox(alpha)
opaque = int((alpha == 255).sum())
residue = green_residue_count(rgb, alpha)
entry = {
"id": layer_id,
"file": f"layers/{layer_id}.png",
"source": "base-faceless-masked+regenerated-fill" if layer_id == "body" else "base-faceless-masked",
"alphaBBox": bbox,
"opaquePixels": opaque,
"greenResidue": residue,
}
if layer_id == "body":
entry["fillTargetPixels"] = fill_target_px
entry["fillSourceRaw"] = "raw/body.png (rejected 재생성이 아니라 수용된 body.png의 크로마키 결과; head/hairFront와 달리 body 재생성은 승인됨)"
layers_report.append(entry)
print(f"[{layer_id}] bbox={bbox} opaque={opaque} greenResidue={residue}")
# --- 정지 합성 vs F ---
canvas_rgba = np.zeros((h, 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 = layers_out[layer_id]
canvas_rgba = composite_over(canvas_rgba, rgb, alpha)
composite = to_u8(canvas_rgba)
Image.fromarray(composite, "RGBA").save(PREVIEW_DIR / "composite-faceless.png")
composite_rgb = composite[..., :3].astype(np.float64)
f_rgb = f_arr.astype(np.float64)
full_diff = np.abs(composite_rgb - f_rgb).mean(axis=2)
mean_abs_full = float(full_diff.mean())
mean_abs_face = float(full_diff[head_mask].mean()) if head_mask.any() else None
from scipy.ndimage import binary_erosion
dil = binary_dilation(hair_mask, iterations=6)
ero = binary_erosion(hair_mask, iterations=6)
hair_outline_band = dil & ~ero
mean_abs_hair_outline = float(full_diff[hair_outline_band].mean()) if hair_outline_band.any() else None
print(
f"합성 vs F 평균절대차: 전체={mean_abs_full:.3f} 얼굴(head_mask)={mean_abs_face:.3f} "
f"머리윤곽밴드={mean_abs_hair_outline:.3f}"
)
if mean_abs_full >= 3.0:
bg_diff = float(full_diff[bg_mask].mean())
fg_diff = float(full_diff[~bg_mask].mean())
print(
f"[경고] 전체 평균절대차 {mean_abs_full:.3f} >= 3.0. 원인 분해: "
f"배경(카테고리0) 평균절대차={bg_diff:.3f}(전체의 {bg_mask.mean()*100:.1f}%), "
f"전경 평균절대차={fg_diff:.3f}"
)
side = Image.new("RGB", (w * 2 + 20, h), CREAM_BG)
side.paste(Image.fromarray(f_arr), (0, 0))
side.paste(Image.fromarray(composite[..., :3]), (w + 20, 0))
side.save(PREVIEW_DIR / "compare.png")
# --- masks.png ---
colors = {0: (0, 0, 0), 1: (255, 0, 0), 2: (0, 255, 0), 3: (0, 120, 255), 4: (255, 255, 0), 5: (255, 0, 255)}
overlay = np.zeros((h, w, 3), dtype=np.uint8)
for k, c in colors.items():
overlay[category_mask == k] = c
blend = to_u8(f_arr.astype(np.float64) * 0.55 + overlay.astype(np.float64) * 0.45)
masks_img = Image.fromarray(blend)
d = ImageDraw.Draw(masks_img)
d.polygon(face_oval_poly, outline=(255, 255, 255), width=3)
masks_img.save(PREVIEW_DIR / "masks.png")
# --- motion-test.png ---
pivot = chin_xy
transforms = [
("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}),
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
]
frames = []
body_rgb0, body_a0 = layers_out["body"]
head_rgb0, head_a0 = layers_out["head"]
hf_rgb0, hf_a0 = layers_out["hairFront"]
for name, t in transforms:
h_rgb, h_a = head_rgb0, head_a0
hf_rgb, hf_a = hf_rgb0, hf_a0
if t["rotate"] != 0.0:
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], pivot)
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], 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"])
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
frame = np.zeros((h, w, 4), dtype=np.float64)
frame[..., 0] = CREAM_BG[0]
frame[..., 1] = CREAM_BG[1]
frame[..., 2] = CREAM_BG[2]
frame[..., 3] = 255.0
frame = composite_over(frame, body_rgb0, body_a0)
frame = composite_over(frame, h_rgb, h_a)
frame = composite_over(frame, hf_rgb, hf_a)
frames.append((name, Image.fromarray(to_u8(frame), "RGBA").convert("RGB")))
gap = 12
strip = Image.new("RGB", (w * 4 + gap * 3, h), CREAM_BG)
x = 0
for name, fr in frames:
strip.paste(fr, (x, 0))
d2 = ImageDraw.Draw(strip)
d2.text((x + 10, 10), name, fill=(255, 0, 0))
x += w + gap
strip.save(PREVIEW_DIR / "motion-test.png")
# --- manifest 갱신 ---
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": w, "h": h}
manifest["base"] = {
"front": "base/base-front.png",
"faceless": "base/base-faceless.png",
"facelessPadded": "base/base-faceless-padded.png",
"facelessSize": [1005, 1565],
"padNote": "base-faceless.png 마지막 행을 복제해 1005x1566(base-front.png 크기)으로 패딩한 것이 F다.",
}
manifest["segmenter"] = {
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",
"categories": {"0": "background", "1": "hair", "2": "bodySkin", "3": "faceSkin", "4": "clothes", "5": "others"},
"categoryPixelCounts": cat_counts,
"hairEdgeRefine": {"dilatePx": HAIR_EDGE_DILATE_PX, "lumThreshold": HAIR_EDGE_LUM_THRESH, "addedPixels": hair_edge_added},
}
manifest["chinLine"] = {"landmarkIndex": 152, "xy": [round(chin_xy[0], 2), round(chin_xy[1], 2)], "marginPx": CHIN_MARGIN, "cutY": round(chin_line, 2)}
manifest["faceOval"] = {"landmarkLoop": FACE_OVAL_LOOP, "scale": FACE_OVAL_SCALE, "sourceImage": "base/base-front.png"}
manifest["layers"] = layers_report
manifest["rejectedRawEdits"] = {
"head": {"file": "raw/head.png", "status": "rejected", "reason": "얼굴 폭·턱선·귀·머리숱이 base-faceless와 달라짐(2단계-A 1차 반려 사유)"},
"hairFront": {"file": "raw/hair-front.png", "status": "rejected", "reason": "노란 하이라이트 획 등 기준에 없던 색상 아티팩트, 형태 변형(2단계-A 1차 반려 사유)"},
"body": {"file": "raw/body.png", "status": "accepted-as-fill-source", "reason": "정렬 (0,0), 형태 변형 없음 — head_mask 채움 전용 소스로 재사용"},
}
manifest["composite"] = {
"meanAbsDiff": {"full": mean_abs_full, "face": mean_abs_face, "hairOutline": mean_abs_hair_outline},
"target": {"full": 3.0, "pass": bool(mean_abs_full < 3.0)},
"backgroundNote": "카테고리0(배경)은 종이결 텍스처 노이즈(표준편차 ~29/채널)를 포함해 평탄한 크림(#EEE5D3)과 자체적으로 평균절대차 ~7 차이가 난다.",
}
manifest["landmarkDetector"] = manifest.get(
"landmarkDetector", f"mediapipe FaceLandmarker (tasks) {MEDIAPIPE_VERSION}, model=face_landmarker(float16, v1)"
)
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())