- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
237 lines
8.6 KiB
Python
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])))
|