리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측

- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
Yun Chan 2026-10-01 22:38:27 +09:00
parent 9a22d1647b
commit 98577cf4ae
18 changed files with 2524 additions and 85 deletions

View file

@ -0,0 +1,249 @@
"""공통 리노컷 리그 — 캐스트 확장 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:]))