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

- 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

@ -40,6 +40,7 @@ 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
from segmentation import build_padded_faceless # noqa: E402 — 새 페르소나 첫 실행에서도 segmentation 단계 전에 패딩본을 만든다
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
BBOX_X_PAD = 25.0
@ -52,6 +53,8 @@ MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
HALFWIDTH_WINDOW_PX = 6.0 # 반두께를 잴 때 각 점 주변 이 폭(열, px) 안의 열을 모아 중앙값을 낸다
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
PEAK_FALLBACK_FRAC = 0.35
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
@ -126,6 +129,23 @@ def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int,
}
def measure_half_width(cw: dict, fit: np.poly1d, x: float, window_px: float = HALFWIDTH_WINDOW_PX) -> float:
"""x 주변 window_px 안 열들의 band 세로 폭(col_yhi-col_ylo) 중앙값을, 그 지점의
중심선 접선 기울기로 중심선 법선 방향 반두께로 환산한다(세로로 자른 폭은 띠가
기울어진 만큼 실제보다 넓게 잡힌다 — cos(기울기각)을 곱해 보정)."""
xs_abs = cw["xs_local"] + cw["x0"]
in_window = (xs_abs >= x - window_px) & (xs_abs <= x + window_px)
ylo = cw["col_ylo"][in_window]
yhi = cw["col_yhi"][in_window]
valid = ~np.isnan(ylo) & ~np.isnan(yhi)
if not valid.any():
return float("nan")
vertical_extent = float(np.median(yhi[valid] - ylo[valid]))
slope = float(fit.deriv()(x))
theta = np.arctan(slope)
return 0.5 * vertical_extent * float(np.cos(theta))
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)
@ -220,6 +240,10 @@ def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: di
n_ok += 1
frac_ok = n_ok / n_total if n_total else 0.0
half_width_inner = measure_half_width(cw, fit, float(inner_x))
half_width_peak = measure_half_width(cw, fit, float(peak_pt_raw[0]))
half_width_outer = measure_half_width(cw, fit, float(outer_x))
return {
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
"colRange": [col_min, col_max], "width": width,
@ -230,6 +254,9 @@ def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: di
"peakFail": bool(peak_fail),
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
"halfWidthInner": round(half_width_inner, 3),
"halfWidthPeak": round(half_width_peak, 3),
"halfWidthOuter": round(half_width_outer, 3),
"band": band, "bandX0": x0, "bandY0": y0,
"fit": fit, "colMin": col_min, "colMax": col_max,
}
@ -284,7 +311,7 @@ def main(persona_dir: Path) -> int:
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)
faceless = np.array(build_padded_faceless(base_dir).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)
@ -311,6 +338,7 @@ def main(persona_dir: Path) -> int:
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'}")
print(f" 반두께(inner/peak/outer) = {result['halfWidthInner']}/{result['halfWidthPeak']}/{result['halfWidthOuter']}")
dropL = resultL["inner"][1] - resultL["peak"][1]
dropR = resultR["inner"][1] - resultR["peak"][1]
@ -361,6 +389,10 @@ def main(persona_dir: Path) -> int:
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
"halfWidthLeft": [resultL["halfWidthInner"], resultL["halfWidthPeak"], resultL["halfWidthOuter"]],
"halfWidthRight": [resultR["halfWidthInner"], resultR["halfWidthPeak"], resultR["halfWidthOuter"]],
"halfWidthNote": "원화 눈썹 잉크 띠 반두께[inner,peak,outer](px, 중심선 법선 방향). export_rig.py가 "
"persona.json browHalfWidthFromArt!=false(기본)면 RigBrow.halfWidth로 내보낸다.",
"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",