vignette/docs/avatar-art/linocut-pipeline/scripts/normalize_base.py
Yun Chan 98577cf4ae 리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움)
- faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor
- brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측
- 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기
- 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조)
- P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
2026-10-01 22:38:27 +09:00

249 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""공통 리노컷 리그 — 캐스트 확장 0단계: 원화 3장을 캔버스로 정규화.
입력 front/faceless/body는 모두 같은 좌표계(후보 원화) 위에 있다고 전제한다
(T1 register_edit.py가 faceless·body를 원화 좌표계로 이미 정렬해 둔 출력을
그대로 받는다). 셋 모두에 **같은 아핀 변환 하나**(등방 배율 + 평행이동,
회전 없음)를 적용해 캔버스 1005x1566(결정문 §8.4)으로 옮긴다.
기준은 P1 base-front.png의 랜드마크다(눈 중심 두 점의 중점, 두 눈 사이 거리,
턱끝) — 머리 크기·위치가 P1과 같은 비율이 되게 새 원화의 배율·이동을 定한다.
mediapipe FaceLandmarker 인덱스: 눈A=(33,133) 중점, 눈B=(263,362) 중점, 턱=152
(landmarks.py의 EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다).
배율: s_head = (P1 눈-턱 거리/원화 눈-턱 거리 + P1 눈 사이 거리/원화 눈 사이
거리) / 2. s = max(s_head, 캔버스폭/원화폭) — 원화가 머리 비율상 더 작게
배율돼도 캔버스 폭은 항상 덮어야 한다.
이동: 두 눈 중점을 P1 두 눈 중점에 맞춘다. 가로 오프셋은 배율된 원화가 캔버스
폭을 꼭 덮도록 [캔버스폭 − 원화폭·s, 0] 구간으로 자른다(좌우 빈틈 방지).
세로 오프셋은 자르지 않는다 — 위/아래로 남는 빈 곳은 각 이미지 종류에 맞게
채운다(front·faceless: 원본 가장자리 띠 반사 패딩, body: #00ff00).
사용: normalize_base.py <persona-dir> --front <원화> --faceless <정렬된 faceless> --body <정렬된 body>
"""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
SCRIPTS_DIR = Path(__file__).resolve().parent
CANVAS_W = 1005
CANVAS_H = 1566
TOP_PAPER_BAND_PX = 8 # 위쪽 채움 색을 뽑을 원화 맨 위 행 수(머리카락은 14행부터 나온다)
P1_REFERENCE_RELATIVE = Path("docs/avatar-art/p1-linocut/base/base-front.png")
# mediapipe FaceLandmarker 인덱스(landmarks.py EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다).
EYE_A_IDX = (33, 133)
EYE_B_IDX = (263, 362)
CHIN_IDX = 152
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
"""FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, 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 midpoint(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
def dist(a: tuple[float, float], b: tuple[float, float]) -> float:
return float(np.hypot(a[0] - b[0], a[1] - b[1]))
def measure_head(points: list[tuple[float, float]]) -> dict:
eye_a = midpoint(points[EYE_A_IDX[0]], points[EYE_A_IDX[1]])
eye_b = midpoint(points[EYE_B_IDX[0]], points[EYE_B_IDX[1]])
eyes_center = midpoint(eye_a, eye_b)
chin = points[CHIN_IDX]
return {
"eyeA": eye_a, "eyeB": eye_b, "eyesCenter": eyes_center, "chin": chin,
"eyeDistance": dist(eye_a, eye_b),
"eyeChinDistance": dist(eyes_center, chin),
}
def measure_report(head: dict) -> dict:
return {
"eyesCenter": [round(head["eyesCenter"][0], 2), round(head["eyesCenter"][1], 2)],
"chin": [round(head["chin"][0], 2), round(head["chin"][1], 2)],
"eyeDistance": round(head["eyeDistance"], 3),
"eyeChinDistance": round(head["eyeChinDistance"], 3),
}
def clamp(v: float, lo: float, hi: float) -> float:
return max(lo, min(hi, v))
def sha256_of(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="원화 3장(front/faceless/body)을 캔버스 1005x1566으로 정규화한다.")
parser.add_argument("persona_dir", help="페르소나 폴더(예: docs/avatar-art/p2-linocut)")
parser.add_argument("--front", required=True, help="후보 원화(정면 기본형)")
parser.add_argument("--faceless", required=True, help="원화 좌표계로 정렬된 얼굴 없는 기본형(T1 출력)")
parser.add_argument("--body", required=True, help="원화 좌표계로 정렬된 body 편집본(T1 출력)")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
persona_dir = Path(args.persona_dir).resolve()
front_path = Path(args.front).resolve()
faceless_path = Path(args.faceless).resolve()
body_path = Path(args.body).resolve()
# persona_dir 위치와 무관하게 이 스크립트 파일 기준으로 repo root를 고정한다
# (persona.json이 아직 없을 수 있는 단계라 persona_config.py의 persona_dir 기준
# repo_root 계산에 의존하지 않는다): scripts/ -> linocut-pipeline -> avatar-art -> docs -> repo root.
repo_root = SCRIPTS_DIR.parents[3]
ref_path = repo_root / P1_REFERENCE_RELATIVE
if not ref_path.exists():
raise SystemExit(f"[중단] P1 기준 이미지가 없다: {ref_path}")
print(f"=== 기준(P1) 랜드마크: {ref_path} ===")
ref_head = measure_head(detect_face_landmarks(ref_path))
print(
f" eyesCenter={ref_head['eyesCenter']} eyeDistance={ref_head['eyeDistance']:.2f} "
f"eyeChinDistance={ref_head['eyeChinDistance']:.2f}"
)
print(f"=== 입력 원화 랜드마크: {front_path} ===")
front_head = measure_head(detect_face_landmarks(front_path))
print(
f" eyesCenter={front_head['eyesCenter']} eyeDistance={front_head['eyeDistance']:.2f} "
f"eyeChinDistance={front_head['eyeChinDistance']:.2f}"
)
front_img = Image.open(front_path).convert("RGB")
front_w, front_h = front_img.size
s_head = (
ref_head["eyeChinDistance"] / front_head["eyeChinDistance"]
+ ref_head["eyeDistance"] / front_head["eyeDistance"]
) / 2.0
s_width = CANVAS_W / front_w
s = max(s_head, s_width)
print(f"s_head={s_head:.5f} s_width(캔버스폭/원화폭)={s_width:.5f} -> s={s:.5f}")
offset_x_natural = ref_head["eyesCenter"][0] - front_head["eyesCenter"][0] * s
offset_x_lo, offset_x_hi = CANVAS_W - front_w * s, 0.0
offset_x = clamp(offset_x_natural, offset_x_lo, offset_x_hi)
offset_y = ref_head["eyesCenter"][1] - front_head["eyesCenter"][1] * s
print(f"offset_x natural={offset_x_natural:.3f} clamp=[{offset_x_lo:.3f}, {offset_x_hi:.3f}] -> {offset_x:.3f}")
print(f"offset_y={offset_y:.3f} (클램프 없음)")
covered_x0 = clamp(offset_x, 0.0, CANVAS_W)
covered_x1 = clamp(offset_x + front_w * s, 0.0, CANVAS_W)
covered_y0 = clamp(offset_y, 0.0, CANVAS_H)
covered_y1 = clamp(offset_y + front_h * s, 0.0, CANVAS_H)
print(
f"덮인 범위(반사/채움 아닌 원화 픽셀): x=[{covered_x0:.1f}, {covered_x1:.1f}] "
f"y=[{covered_y0:.1f}, {covered_y1:.1f}] (캔버스 {CANVAS_W}x{CANVAS_H})"
)
matrix = np.array([[s, 0.0, offset_x], [0.0, s, offset_y]], dtype=np.float64)
def warp(src_path: Path, border_mode: int, border_value: tuple[int, int, int] | None) -> np.ndarray:
arr = np.array(Image.open(src_path).convert("RGB"))
kwargs: dict = {"flags": cv2.INTER_LANCZOS4, "borderMode": border_mode}
if border_value is not None:
kwargs["borderValue"] = border_value
return cv2.warpAffine(arr, matrix, (CANVAS_W, CANVAS_H), **kwargs)
# front·faceless: 빈 곳은 원본 가장자리 띠 반사(BORDER_REFLECT_101 — 가장자리 픽셀을
# 복제하지 않고 그 안쪽부터 비추어, 복제판(T3 build_padded_faceless류)보다 이음매가
# 덜 드러난다; 위쪽은 아래에서 종이색으로 덮는다). body: 빈 곳은 크로마키 배경(#00ff00)으로 채운다.
front_out = warp(front_path, cv2.BORDER_REFLECT_101, None)
faceless_out = warp(faceless_path, cv2.BORDER_REFLECT_101, None)
body_out = warp(body_path, cv2.BORDER_CONSTANT, (0, 255, 0))
# 위쪽 빈 곳만은 반사하지 않고 원화 위 가장자리의 종이색으로 채운다. 머리카락이 원화 위
# 끝 가까이(14~42px)까지 오는 후보는 반사하면 머리 위에 뒤집힌 머리카락이 생긴다
# (2026-10-01 P3·P5 판정). 배경은 분할에서 레이어 밖으로 빠지므로 평평한 색이어도 화면에
# 드러나지 않는다. 아래쪽은 옷이 이어지는 편이 나아 반사를 유지한다.
top_rows = int(np.ceil(covered_y0))
if top_rows > 0:
for out, src_path in ((front_out, front_path), (faceless_out, faceless_path)):
band = np.array(Image.open(src_path).convert("RGB"))[:TOP_PAPER_BAND_PX].reshape(-1, 3)
paper = band[band.mean(axis=1) > 150]
out[:top_rows] = np.median(paper if len(paper) else band, axis=0).astype(np.uint8)
base_dir = persona_dir / "base"
raw_dir = persona_dir / "raw"
base_dir.mkdir(parents=True, exist_ok=True)
raw_dir.mkdir(parents=True, exist_ok=True)
front_out_path = base_dir / "base-front.png"
faceless_out_path = base_dir / "base-faceless.png"
body_out_path = raw_dir / "body.png"
Image.fromarray(front_out, "RGB").save(front_out_path)
Image.fromarray(faceless_out, "RGB").save(faceless_out_path)
Image.fromarray(body_out, "RGB").save(body_out_path)
print(f"저장: {front_out_path}")
print(f"저장: {faceless_out_path}")
print(f"저장: {body_out_path}")
report = {
"canvas": {"w": CANVAS_W, "h": CANVAS_H},
"reference": {"image": str(ref_path.relative_to(repo_root)), **measure_report(ref_head)},
"input": {
"front": str(front_path),
"faceless": str(faceless_path),
"body": str(body_path),
"frontSize": [front_w, front_h],
**measure_report(front_head),
},
"transform": {
"sHead": round(s_head, 6),
"sWidth": round(s_width, 6),
"s": round(s, 6),
"rotationDeg": 0.0,
"offsetXNatural": round(offset_x_natural, 3),
"offsetXClampRange": [round(offset_x_lo, 3), round(offset_x_hi, 3)],
"offsetX": round(offset_x, 3),
"offsetY": round(offset_y, 3),
},
"coveredRange": {
"x": [round(covered_x0, 1), round(covered_x1, 1)],
"y": [round(covered_y0, 1), round(covered_y1, 1)],
},
"inputSha256": {
"front": sha256_of(front_path),
"faceless": sha256_of(faceless_path),
"body": sha256_of(body_path),
},
}
report_path = base_dir / "normalize.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"저장: {report_path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))