vignette/docs/avatar-art/linocut-pipeline/scripts/landmarks.py
Yun Chan ecb36d123f 리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
2026-10-01 16:11:28 +09:00

237 lines
8.6 KiB
Python

"""공통 리노컷 리그 — base-front.png 얼굴 랜드마크 검출.
mediapipe FaceLandmarker로 manifest.json의 landmarks 섹션을 채우고
preview/landmarks.png를 만든다.
좌표계: 화면(이미지) 기준 left/right. "left"는 이미지의 왼쪽(작은 x), "right"는
이미지의 오른쪽(큰 x)이다. 인물 해부학적 좌/우가 아니다.
주의(재실행 순서): 이 스크립트는 manifest.landmarks를 통째로 다시 쓴다.
brow_centerline.py가 그중 eyebrowLeft/Right를 더 정확한 중심선으로 보정하므로,
landmarks.py를 brow_centerline.py보다 "나중에" 다시 돌리면 그 보정이 사라진다.
단계를 하나만 다시 돌릴 때는 이 순서를 지켜야 한다(run_pipeline.py가 기본
순서로는 지켜 주지만, --only로 landmarks만 돌리면 뒤이어 brow_centerline도
다시 돌려야 한다).
실행: <venv>/python.exe landmarks.py <persona-dir>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from PIL import Image, ImageDraw, ImageFont
SCRIPTS_DIR = Path(__file__).resolve().parent
MODEL_PATH = SCRIPTS_DIR / "_models" / "face_landmarker.task"
sys.path.insert(0, str(SCRIPTS_DIR))
from persona_config import load_persona_config # noqa: E402
RIGHT_EYEBROW_IDX = [46, 53, 52, 65, 55, 70, 63, 105, 66, 107]
LEFT_EYEBROW_IDX = [276, 283, 282, 295, 285, 300, 293, 334, 296, 336]
EYE_A_IDX = {"outer": 33, "inner": 133, "top": 159, "bottom": 145}
EYE_B_IDX = {"inner": 362, "outer": 263, "top": 386, "bottom": 374}
NOSE_TIP_IDX = 1
CHIN_IDX = 152
MOUTH_CORNER_A_IDX = 61
MOUTH_CORNER_B_IDX = 291
UPPER_LIP_TOP_IDX = 0
LOWER_LIP_BOTTOM_IDX = 17
FACE_EDGE_A_IDX = 234
FACE_EDGE_B_IDX = 454
IRIS_A = {"center": 468, "ring": [469, 470, 471, 472]}
IRIS_B = {"center": 473, "ring": [474, 475, 476, 477]}
def detect(base_front: Path, preview_path: Path) -> dict | None:
if not MODEL_PATH.exists():
return None
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_PATH))
options = vision.FaceLandmarkerOptions(
base_options=base_options,
running_mode=vision.RunningMode.IMAGE,
num_faces=1,
)
im = Image.open(base_front).convert("RGB")
w, h = im.size
arr = np.array(im)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
with vision.FaceLandmarker.create_from_options(options) as landmarker:
result = landmarker.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0]
pts = [(p.x * w, p.y * h) for p in lm]
print(f"검출된 랜드마크 개수: {len(pts)}")
def pt(idx: int) -> list[float]:
x, y = pts[idx]
return [round(x, 2), round(y, 2)]
def screen_label(idx_a: int, idx_b: int) -> tuple[int, int]:
"""두 인덱스를 화면 기준 left(작은 x)/right(큰 x)로 정렬해 반환."""
xa = pts[idx_a][0]
xb = pts[idx_b][0]
return (idx_a, idx_b) if xa < xb else (idx_b, idx_a)
left_eye_idx, right_eye_idx = screen_label(EYE_A_IDX["outer"], EYE_B_IDX["outer"])
eyeA_is_left = left_eye_idx == EYE_A_IDX["outer"]
eyeL = EYE_A_IDX if eyeA_is_left else EYE_B_IDX
eyeR = EYE_B_IDX if eyeA_is_left else EYE_A_IDX
irisL, irisR = (IRIS_A, IRIS_B) if eyeA_is_left else (IRIS_B, IRIS_A)
def iris_stats(iris: dict) -> dict:
cx, cy = pts[iris["center"]]
radii = [
float(np.hypot(pts[i][0] - cx, pts[i][1] - cy)) for i in iris["ring"]
]
return {"center": [round(cx, 2), round(cy, 2)], "radius": round(float(np.mean(radii)), 2)}
browL_first_x = pts[RIGHT_EYEBROW_IDX[0]][0]
browB_first_x = pts[LEFT_EYEBROW_IDX[0]][0]
browSetL, browSetR = (
(RIGHT_EYEBROW_IDX, LEFT_EYEBROW_IDX)
if browL_first_x < browB_first_x
else (LEFT_EYEBROW_IDX, RIGHT_EYEBROW_IDX)
)
def brow_stats(idx_set: list[int], face_cx: float) -> dict:
xs = [pts[i][0] for i in idx_set]
ys = [pts[i][1] for i in idx_set]
peak_i = idx_set[int(np.argmin(ys))]
inner_i = min(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
outer_i = max(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
return {
"inner": pt(inner_i),
"peak": pt(peak_i),
"outer": pt(outer_i),
}
face_cx = pts[NOSE_TIP_IDX][0]
mouthL_idx, mouthR_idx = screen_label(MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX)
faceEdgeL_idx, faceEdgeR_idx = screen_label(FACE_EDGE_A_IDX, FACE_EDGE_B_IDX)
landmarks = {
"coordSystem": "screen (image pixel: x=0 좌측, y=0 상단; left=작은 x, right=큰 x; 인물 해부학적 좌우 아님)",
"eyeLeft": {
"innerCorner": pt(eyeL["inner"]),
"outerCorner": pt(eyeL["outer"]),
"upperLidTop": pt(eyeL["top"]),
"lowerLidBottom": pt(eyeL["bottom"]),
"iris": iris_stats(irisL),
},
"eyeRight": {
"innerCorner": pt(eyeR["inner"]),
"outerCorner": pt(eyeR["outer"]),
"upperLidTop": pt(eyeR["top"]),
"lowerLidBottom": pt(eyeR["bottom"]),
"iris": iris_stats(irisR),
},
"eyebrowLeft": brow_stats(browSetL, face_cx),
"eyebrowRight": brow_stats(browSetR, face_cx),
"noseTip": pt(NOSE_TIP_IDX),
"mouthCornerLeft": pt(mouthL_idx),
"mouthCornerRight": pt(mouthR_idx),
"upperLipTopCenter": pt(UPPER_LIP_TOP_IDX),
"lowerLipBottomCenter": pt(LOWER_LIP_BOTTOM_IDX),
"chinTip": pt(CHIN_IDX),
"faceWidthAtEyeLevelLeft": pt(faceEdgeL_idx),
"faceWidthAtEyeLevelRight": pt(faceEdgeR_idx),
}
draw_preview(im, landmarks, preview_path)
return landmarks
def draw_preview(im: Image.Image, landmarks: dict, preview_path: Path) -> None:
canvas = im.convert("RGB").copy()
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype("arial.ttf", 13)
except Exception:
font = ImageFont.load_default()
GROUP_COLORS = {
"eyeLeft": (220, 0, 0),
"eyeRight": (0, 120, 220),
"eyebrowLeft": (180, 0, 180),
"eyebrowRight": (0, 150, 80),
"noseTip": (255, 140, 0),
"mouthCornerLeft": (200, 0, 100),
"mouthCornerRight": (0, 100, 200),
"upperLipTopCenter": (150, 100, 0),
"lowerLipBottomCenter": (0, 150, 150),
"chinTip": (100, 60, 0),
"faceWidthAtEyeLevelLeft": (120, 120, 120),
"faceWidthAtEyeLevelRight": (120, 120, 120),
}
def dot(xy: list[float], label: str, color=(255, 0, 0)) -> None:
x, y = xy
r = 5
draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=2)
draw.text((x + 7, y - 7), label, fill=color, font=font)
flat = []
for group, val in landmarks.items():
if group == "coordSystem":
continue
if isinstance(val, list):
flat.append((val, group))
elif isinstance(val, dict):
for k, v in val.items():
if isinstance(v, list):
flat.append((v, f"{group}.{k}"))
elif isinstance(v, dict) and "center" in v:
flat.append((v["center"], f"{group}.iris.center"))
for xy, label in flat:
group = label.split(".")[0]
dot(xy, label, GROUP_COLORS.get(group, (255, 0, 0)))
canvas.save(preview_path)
print(f"landmarks 미리보기 저장: {preview_path}")
def main(persona_dir: Path) -> int:
cfg = load_persona_config(persona_dir)
base_front = cfg.base_dir / "base-front.png"
manifest_path = cfg.manifest_path
preview_path = cfg.preview_dir / "landmarks.png"
cfg.preview_dir.mkdir(parents=True, exist_ok=True)
manifest = {}
if manifest_path.exists():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
result = detect(base_front, preview_path)
if result is None:
manifest["landmarks"] = None
manifest["landmarkDetector"] = None
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("[검출 실패] mediapipe FaceLandmarker가 얼굴을 찾지 못했다. manifest.landmarks=null로 기록.")
return 1
manifest["landmarks"] = result
manifest["landmarkDetector"] = f"mediapipe FaceLandmarker (tasks) {mp.__version__}, model=face_landmarker(float16, v1)"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("manifest.json landmarks 섹션 기록 완료")
return 0
if __name__ == "__main__":
raise SystemExit(main(Path(sys.argv[1])))