리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
This commit is contained in:
parent
f11e76ff18
commit
ecb36d123f
38 changed files with 2393 additions and 990 deletions
237
docs/avatar-art/linocut-pipeline/scripts/landmarks.py
Normal file
237
docs/avatar-art/linocut-pipeline/scripts/landmarks.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""공통 리노컷 리그 — 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])))
|
||||
Loading…
Add table
Add a link
Reference in a new issue