리노컷 자산 파이프라인 공통화와 캐스트 외형 설계

- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
This commit is contained in:
Yun Chan 2026-10-01 16:11:28 +09:00
parent f11e76ff18
commit ecb36d123f
38 changed files with 2393 additions and 990 deletions

View file

@ -0,0 +1,374 @@
"""공통 리노컷 리그 — 눈썹 랜드마크를 잉크 띠 중심선으로 정정.
1차(반려)는 front-F 밝기 차이만으로 마스크를 만들어 눈두덩 그늘 해칭까지
눈썹으로 잡았고, 열별 지역 평활(Savitzky-Golay)이 평평한 눈썹에서 물결치며
peak를 안쪽 끝 열에 찍었다(오케스트레이터 판정, 2단계-B-1d).
이번 버전:
- 마스크 = (front 휘도가 눈썹 bbox 안 Otsu 임계값 이하, 짙은 잉크) AND
(front가 F보다 25 이상 어두움, 머리카락·눈꺼풀 크레이스 등 F에도 있는
구조물 제외). 두 조건의 교집합이라 중간 톤 그늘 해칭은 Otsu 임계값보다
밝아 빠진다.
- 눈썹은 조각도 해칭 때문에 잉크가 여러 조각으로 끊겨 있다(단일 최대
연결성분만 쓰면 절반 가까이 누락됨을 확인). 그래서 형태학적 closing으로
같은 눈썹에 속한 조각을 하나로 묶은 뒤, 그 묶음 영역 안의 "원본"(닫기
전) 마스크 픽셀만 최종 띠로 쓴다(닫기는 성분을 찾는 데만 쓰고 픽셀을
부풀리지 않는다). 그 안에서 면적 30px 미만 잔점은 버린다.
- 중심선은 전역 2차 다항식 하나를 열별 가중 무게중심에 적합한다(지역
평활 대신 — 평평한 눈썹에서 물결을 만들지 않는다). 잔차 2배 표준편차를
넘는 이상치 열을 한 번 제거하고 재적합한다.
- peak는 적합 곡선의 꼭짓점(2차 다항식은 극값이 하나뿐이다)이 바깥 끝
기준 25~60% 구간에 있으면 그 점, 아니면(구간 밖 = 그 구간에서 단조,
또는 구간 안 높이 차 < 3px = 거의 평평) 바깥 끝에서 35% 지점의 곡선
위 점을 쓴다.
전제: landmarks.py가 이미 manifest.landmarks를 채워 두어야 한다.
실행: <venv>/python.exe brow_centerline.py <persona-dir>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from persona_config import load_persona_config # noqa: E402
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
BBOX_X_PAD = 25.0
BBOX_Y_UP = 42.0
BBOX_Y_DOWN = 14.0 # 30이면 눈꺼풀 크레이스(다크서클 경계) 잉크가 섞인다(탐색 확인).
DIFF_THRESH = 25.0 # front가 F보다 이만큼 어두우면 "front에만 있는 것"으로 본다
CLOSING_KERNEL = (7, 11) # (세로, 가로) — 눈썹 해칭 조각을 같은 띠로 묶기 위한 형태학적 닫기
MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보다 작은 잔점은 버린다
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
PEAK_FALLBACK_FRAC = 0.35
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
PEAK_FAIL_EDGE_FRAC = 0.20 # 최종 peak가 양끝 이 비율 안이면 실패
CHECK_A_MIN_FRAC = 0.80
EVIDENCE_MAX_SIDE = 1000
EVIDENCE_ZOOM = 3
def region_bbox_from_points(inner, peak, outer) -> tuple[int, int, int, int]:
xs = [inner[0], peak[0], outer[0]]
ys = [inner[1], peak[1], outer[1]]
x0 = int(round(min(xs) - BBOX_X_PAD))
x1 = int(round(max(xs) + BBOX_X_PAD))
y0 = int(round(min(ys) - BBOX_Y_UP))
y1 = int(round(max(ys) + BBOX_Y_DOWN))
return x0, y0, x1, y1
def build_ink_band(front_lum: np.ndarray, diff: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> tuple[np.ndarray, float]:
"""bbox 안에서 (Otsu 잉크) AND (front가 F보다 어두움) 마스크를 만들고,
형태학적 닫기로 같은 눈썹 조각을 묶은 최대 성분 안의 원본 픽셀만
남긴 뒤 작은 잔점을 버린다. 반환: (band bool 배열(bbox 로컬 좌표), otsu 임계값)."""
region_lum = front_lum[y0:y1, x0:x1]
u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8)
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
ink_mask = region_lum <= otsu_thresh
diff_region = diff[y0:y1, x0:x1]
diff_mask = diff_region >= DIFF_THRESH
combined = ink_mask & diff_mask
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
labeled, n = ndimage.label(closed)
if n == 0:
raise SystemExit("[중단] 눈썹 bbox 안에 잉크 마스크가 전혀 없다.")
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
top_label = int(np.argmax(sizes)) + 1
band = combined & (labeled == top_label)
lbl2, n2 = ndimage.label(band)
if n2 > 0:
sizes2 = ndimage.sum(band, lbl2, range(1, n2 + 1))
keep_labels = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
band = band & np.isin(lbl2, keep_labels)
return band, float(otsu_thresh)
def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int, y0: int) -> dict:
colmask = band.any(axis=0)
cols = np.where(colmask)[0]
col_min, col_max = int(cols.min()), int(cols.max())
xs_local = np.arange(col_min, col_max + 1)
centroid_y = np.full(len(xs_local), np.nan)
col_ylo = np.full(len(xs_local), np.nan)
col_yhi = np.full(len(xs_local), np.nan)
for i, cx in enumerate(xs_local):
colpix = band[:, cx]
if not colpix.any():
continue
yy = np.where(colpix)[0]
w = diff_region[yy, cx]
centroid_y[i] = np.average(yy, weights=w) + y0
col_ylo[i] = yy.min() + y0
col_yhi[i] = yy.max() + y0
return {
"col_min": col_min + x0, "col_max": col_max + x0,
"xs_local": xs_local, "centroid_y": centroid_y,
"col_ylo": col_ylo, "col_yhi": col_yhi, "x0": x0,
}
def fit_centerline(xs_abs: np.ndarray, ys: np.ndarray) -> tuple[np.poly1d, int]:
coef = np.polyfit(xs_abs, ys, 2)
fit = np.poly1d(coef)
resid = ys - fit(xs_abs)
std = resid.std()
outlier = np.abs(resid) > OUTLIER_STD_MULT * std if std > 0 else np.zeros_like(resid, dtype=bool)
n_outliers = int(outlier.sum())
if n_outliers > 0 and (~outlier).sum() >= 3:
coef2 = np.polyfit(xs_abs[~outlier], ys[~outlier], 2)
fit = np.poly1d(coef2)
return fit, n_outliers
def find_peak(fit: np.poly1d, col_min: int, col_max: int, outer_edge_x: float, width: float) -> tuple[tuple[float, float], str]:
a, b, _c = fit.coeffs
xs_sample = np.linspace(col_min, col_max, 400)
t_sample = np.abs(xs_sample - outer_edge_x) / width
in_window = (t_sample >= PEAK_WINDOW[0]) & (t_sample <= PEAK_WINDOW[1])
use_fallback = True
vertex_pt: tuple[float, float] | None = None
if abs(a) > 1e-6:
xv = -b / (2 * a)
if col_min <= xv <= col_max:
tv = abs(xv - outer_edge_x) / width
if PEAK_WINDOW[0] <= tv <= PEAK_WINDOW[1]:
window_ys = fit(xs_sample[in_window])
height_diff = float(window_ys.max() - window_ys.min()) if window_ys.size else 0.0
if height_diff >= PEAK_FLAT_HEIGHT_PX:
vertex_pt = (float(xv), float(fit(xv)))
use_fallback = False
if use_fallback:
# 바깥 끝에서 안쪽으로 35% 지점
if outer_edge_x <= col_min + 1e-6:
fx = col_min + PEAK_FALLBACK_FRAC * width
else:
fx = col_max - PEAK_FALLBACK_FRAC * width
peak_pt = (float(fx), float(fit(fx)))
method = "fallback35"
else:
peak_pt = vertex_pt # type: ignore[assignment]
method = "vertex"
return peak_pt, method
def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: dict) -> dict:
inner_old, peak_old, outer_old = old_pts["inner"], old_pts["peak"], old_pts["outer"]
x0, y0, x1, y1 = region_bbox_from_points(inner_old, peak_old, outer_old)
band, otsu_thresh = build_ink_band(front_lum, diff, x0, y0, x1, y1)
diff_region = diff[y0:y1, x0:x1]
cw = column_weighted_centroid(band, diff_region, x0, y0)
valid = ~np.isnan(cw["centroid_y"])
xs_abs = cw["xs_local"][valid] + x0
ys_valid = cw["centroid_y"][valid]
fit, n_outliers = fit_centerline(xs_abs, ys_valid)
col_min, col_max = cw["col_min"], cw["col_max"]
width = float(col_max - col_min)
inset_px = width * EDGE_INSET_FRAC
left_is_outer = outer_old[0] < inner_old[0]
if left_is_outer:
outer_edge_x = float(col_min)
inner_edge_x = float(col_max)
outer_x = col_min + inset_px
inner_x = col_max - inset_px
else:
outer_edge_x = float(col_max)
inner_edge_x = float(col_min)
outer_x = col_max - inset_px
inner_x = col_min + inset_px
inner_pt = (round(float(inner_x), 2), round(float(fit(inner_x)), 2))
outer_pt = (round(float(outer_x), 2), round(float(fit(outer_x)), 2))
peak_pt_raw, peak_method = find_peak(fit, col_min, col_max, outer_edge_x, width)
peak_pt = (round(peak_pt_raw[0], 2), round(peak_pt_raw[1], 2))
peak_frac = abs(peak_pt_raw[0] - outer_edge_x) / width
peak_fail = peak_frac < PEAK_FAIL_EDGE_FRAC or peak_frac > (1.0 - PEAK_FAIL_EDGE_FRAC)
# 검사(a): 열의 80% 이상에서 적합 곡선 y가 그 열 마스크의 위-아래 끝 사이
n_ok = 0
n_total = int(valid.sum())
for i in range(len(cw["xs_local"])):
if not valid[i]:
continue
cx_abs = cw["xs_local"][i] + x0
fy = float(fit(cx_abs))
if cw["col_ylo"][i] <= fy <= cw["col_yhi"][i]:
n_ok += 1
frac_ok = n_ok / n_total if n_total else 0.0
return {
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
"colRange": [col_min, col_max], "width": width,
"nColumns": n_total, "nOutliersRemoved": n_outliers,
"fitCoeffs": [round(float(c), 8) for c in fit.coeffs],
"inner": inner_pt, "peak": peak_pt, "outer": outer_pt,
"peakMethod": peak_method, "peakFracFromOuterEdge": round(float(peak_frac), 4),
"peakFail": bool(peak_fail),
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
"band": band, "bandX0": x0, "bandY0": y0,
"fit": fit, "colMin": col_min, "colMax": col_max,
}
def draw_evidence_panel(front_img: Image.Image, old_pts: dict, result: dict) -> Image.Image:
x0, y0, x1, y1 = result["bbox"]
pad = 15
box = (max(0, x0 - pad), max(0, y0 - pad), x1 + pad, y1 + pad)
crop = front_img.crop(box).convert("RGB")
crop = crop.resize((crop.width * EVIDENCE_ZOOM, crop.height * EVIDENCE_ZOOM), Image.LANCZOS)
d = ImageDraw.Draw(crop)
ox, oy = box[0], box[1]
def to_panel(px, py):
return ((px - ox) * EVIDENCE_ZOOM, (py - oy) * EVIDENCE_ZOOM)
# 마스크 윤곽선(노랑)
band_u8 = (result["band"].astype(np.uint8)) * 255
contours, _ = cv2.findContours(band_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
bx0, by0 = result["bandX0"], result["bandY0"]
for cnt in contours:
pts = [to_panel(p[0][0] + bx0, p[0][1] + by0) for p in cnt]
if len(pts) >= 2:
d.line(pts + [pts[0]], fill=(230, 200, 0), width=2)
# 적합 중심선(청록)
fit = result["fit"]
xs_line = np.linspace(result["colMin"], result["colMax"], 200)
ys_line = fit(xs_line)
line_pts = [to_panel(x, y) for x, y in zip(xs_line, ys_line)]
d.line(line_pts, fill=(0, 200, 200), width=2)
for key in ("inner", "peak", "outer"):
ox_pt, oy_pt = old_pts[key]
px, py = to_panel(ox_pt, oy_pt)
d.ellipse([px - 5, py - 5, px + 5, py + 5], outline=(230, 30, 30), width=2)
nx_pt, ny_pt = result[key]
px2, py2 = to_panel(nx_pt, ny_pt)
d.ellipse([px2 - 5, py2 - 5, px2 + 5, py2 + 5], fill=(0, 220, 220))
return crop
def main(persona_dir: Path) -> int:
cfg = load_persona_config(persona_dir)
base_dir = cfg.base_dir
preview_v2_dir = cfg.preview_v2_dir
manifest_path = cfg.manifest_path
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
lm = manifest["landmarks"]
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
faceless = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
front_lum = front.mean(axis=2)
faceless_lum = faceless.mean(axis=2)
diff = np.clip(faceless_lum - front_lum, 0.0, None)
# 재실행 멱등성: 이 스크립트가 landmarks.eyebrowLeft/Right를 덮어쓰므로,
# 진짜 mediapipe 원본은 browCenterline.oldPoints에 한 번 고정해 두고
# 재실행 시 거기서 읽는다(자기가 쓴 결과를 다시 원본으로 오인하지 않는다).
prev_old_points = manifest.get("browCenterline", {}).get("oldPoints")
if prev_old_points is not None:
browL_old = prev_old_points["browLeft"]
browR_old = prev_old_points["browRight"]
else:
browL_old = lm["eyebrowLeft"]
browR_old = lm["eyebrowRight"]
resultL = process_brow("browLeft", front_lum, diff, browL_old)
resultR = process_brow("browRight", front_lum, diff, browR_old)
for result, old_pts in ((resultL, browL_old), (resultR, browR_old)):
print(f"=== {result['name']} ===")
print(f" bbox={result['bbox']} otsuThresh={result['otsuThresh']} colRange={result['colRange']}")
print(f" fit={result['fitCoeffs']} outliers제거={result['nOutliersRemoved']}/{result['nColumns']}")
print(f" old inner={old_pts['inner']} peak={old_pts['peak']} outer={old_pts['outer']}")
print(f" new inner={result['inner']} peak={result['peak']}({result['peakMethod']}) outer={result['outer']}")
print(f" 검사(a) 열 포함 비율 = {result['checkA_fracColumnsFitWithinMask']} (기준>=0.80) {'OK' if result['checkA_pass'] else '[실패]'}")
print(f" peak 위치비율(바깥끝기준) = {result['peakFracFromOuterEdge']} {'[실패:양끝20% 안]' if result['peakFail'] else 'OK'}")
dropL = resultL["inner"][1] - resultL["peak"][1]
dropR = resultR["inner"][1] - resultR["peak"][1]
print(f"검사(b) browLeft inner.y-peak.y = {dropL:.2f} (예상 0~20)")
print(f"검사(b) browRight inner.y-peak.y = {dropR:.2f} (예상 0~20)")
inner_y_symmetry = abs(resultL["inner"][1] - resultR["inner"][1])
peak_y_symmetry = abs(resultL["peak"][1] - resultR["peak"][1])
print(f"검사(d) 좌우 inner.y 차 = {inner_y_symmetry:.2f}")
print(f"검사(d) 좌우 peak.y 차 = {peak_y_symmetry:.2f}")
print(f"검사(c) browLeft peak 위치비율 = {resultL['peakFracFromOuterEdge']} ({resultL['peakMethod']})")
print(f"검사(c) browRight peak 위치비율 = {resultR['peakFracFromOuterEdge']} ({resultR['peakMethod']})")
front_img = Image.open(base_dir / "base-front.png").convert("RGB")
panelL = draw_evidence_panel(front_img, browL_old, resultL)
panelR = draw_evidence_panel(front_img, browR_old, resultR)
gap = 20
combined = Image.new("RGB", (panelL.width + panelR.width + gap, max(panelL.height, panelR.height)), (255, 255, 255))
combined.paste(panelL, (0, 0))
combined.paste(panelR, (panelL.width + gap, 0))
scale = min(1.0, EVIDENCE_MAX_SIDE / max(combined.size))
if scale < 1.0:
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
preview_v2_dir.mkdir(parents=True, exist_ok=True)
evidence_path = preview_v2_dir / "brow-centerline.jpg"
combined.convert("RGB").save(evidence_path, "JPEG", quality=90)
print(f"저장: {evidence_path}")
lm["eyebrowLeft"] = {"inner": list(resultL["inner"]), "peak": list(resultL["peak"]), "outer": list(resultL["outer"])}
lm["eyebrowRight"] = {"inner": list(resultR["inner"]), "peak": list(resultR["peak"]), "outer": list(resultR["outer"])}
def strip_for_json(r: dict) -> dict:
return {k: v for k, v in r.items() if k not in ("band", "fit")}
manifest["browCenterline"] = {
"method": "Otsu(front lum in bbox) AND diff(F-front)>=25, morphological closing to merge hatching, "
"single global degree-2 polyfit on column-weighted centroid with one-pass outlier removal",
"diffThresh": DIFF_THRESH,
"bboxPad": {"x": BBOX_X_PAD, "yUp": BBOX_Y_UP, "yDown": BBOX_Y_DOWN},
"closingKernel": list(CLOSING_KERNEL),
"minComponentAreaPx": MIN_COMPONENT_AREA,
"outlierStdMult": OUTLIER_STD_MULT,
"edgeInsetFrac": EDGE_INSET_FRAC,
"peakWindow": list(PEAK_WINDOW),
"peakFallbackFrac": PEAK_FALLBACK_FRAC,
"peakFlatHeightPx": PEAK_FLAT_HEIGHT_PX,
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
"checkB_innerMinusPeakY": {"browLeft": round(dropL, 2), "browRight": round(dropR, 2)},
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
"evidenceImage": "preview/v2/brow-centerline.jpg",
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest.json 갱신: {manifest_path}")
return 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1])))