diff --git a/.gitignore b/.gitignore index 0dcac88..2ce1318 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,12 @@ infra/.tmp-*.b64 # 아바타 v3 아트 파이프라인: 내려받는 MediaPipe 모델과 다시 만들 수 있는 진단 PNG docs/avatar-art/*/scripts/_models/ docs/avatar-art/*/preview/**/*.png + +# 리노컷 캐스트 원화 작업 중간물 — 원화 후보·codex 편집 원본·정렬본·패딩본·분할 레이어는 로컬에만 둔다. +# 저장소에는 파이프라인 입력(정규화된 base-front·base-faceless·raw/body)과 설정·측정·증거 JPG만 남긴다 +# (P1은 이 규칙 이전에 레이어까지 추적됐고 그대로 둔다). +docs/avatar-art/p[2-9]-linocut/base/candidates/ +docs/avatar-art/p[2-9]-linocut/base/source-front.png +docs/avatar-art/p[2-9]-linocut/base/base-faceless-padded.png +docs/avatar-art/p[2-9]-linocut/base/edits/*.png +docs/avatar-art/p[2-9]-linocut/layers/ diff --git a/apps/web/src/components/avatar/v3/LinocutAvatar.tsx b/apps/web/src/components/avatar/v3/LinocutAvatar.tsx index 49cbcc2..4f91148 100644 --- a/apps/web/src/components/avatar/v3/LinocutAvatar.tsx +++ b/apps/web/src/components/avatar/v3/LinocutAvatar.tsx @@ -16,6 +16,8 @@ import { shrunkFaceOvalPointsAttr, lipStripClipRect, LIP_STRIP_COUNT, + browSpriteStripClipRect, + BROW_STRIP_COUNT, jawWarpStripRects, jawWarpStripClipPolygons, jawWarpHeadHoleClipD, @@ -75,9 +77,13 @@ function applyEye(refs: Refs, side: "left" | "right", eye: LinocutFrame["eyeLeft if (crease) { crease.setAttribute("d", eye.creaseD); crease.setAttribute("opacity", String(eye.creaseOpacity)); + crease.setAttribute("stroke-width", String(eye.creaseStrokeWidth)); } const lowerLid = refs[`${p}-lower-lid`] as SVGPathElement | null; - if (lowerLid) lowerLid.setAttribute("d", eye.lowerLidD); + if (lowerLid) { + lowerLid.setAttribute("d", eye.lowerLidD); + lowerLid.setAttribute("stroke-width", String(eye.lowerLidStrokeWidth)); + } const shadow = refs[`${p}-shadow`] as SVGPathElement | null; if (shadow) shadow.setAttribute("d", eye.shadowD); @@ -208,6 +214,16 @@ function applyFrame(refs: Refs, frame: LinocutFrame): void { browRightShadow.setAttribute("cy", String(frame.browRight.shadowCy)); browRightShadow.setAttribute("opacity", String(frame.browRight.shadowOpacity)); } + /* 눈썹 스프라이트 띠 변형(결정문 §8.4.1): sprite가 없는 리그는 spriteStrips가 + 빈 배열이라 ref도 없어(JSX가 아예 안 그린다) 아래는 그냥 건너뛴다. */ + for (let i = 0; i < BROW_STRIP_COUNT; i++) { + const stripLeft = refs[`brow-left-strip-${i}`] as SVGImageElement | null; + const sl = frame.browLeft.spriteStrips[i]; + if (stripLeft && sl) stripLeft.setAttribute("transform", `translate(${sl.dx} ${sl.dy})`); + const stripRight = refs[`brow-right-strip-${i}`] as SVGImageElement | null; + const sr = frame.browRight.spriteStrips[i]; + if (stripRight && sr) stripRight.setAttribute("transform", `translate(${sr.dx} ${sr.dy})`); + } const glabella = refs.glabella as SVGPathElement | null; if (glabella) { @@ -394,6 +410,8 @@ export default function LinocutAvatar({ if (rig.lipTexture) hrefs.push(rig.lipTexture.upper.href, rig.lipTexture.lower.href); if (rig.lipTexture?.shadow) hrefs.push(rig.lipTexture.shadow.href); if (rig.jaw) hrefs.push(rig.jaw.head.href, rig.jaw.detail.href); + if (rig.landmarks.browLeft.sprite) hrefs.push(rig.landmarks.browLeft.sprite.href); + if (rig.landmarks.browRight.sprite) hrefs.push(rig.landmarks.browRight.sprite.href); return hrefs; }, [rig, texture]); @@ -865,10 +883,68 @@ export default function LinocutAvatar({ /> - - - - + {/* 눈썹(결정문 §8.4.1): rig.landmarks.browLeft/Right.sprite가 있으면 + 원화 눈썹 스프라이트를 세로 BROW_STRIP_COUNT개 띠로 나눠 그린다 + (벡터 몸통·털 획은 그리지 않는다 — 안쪽 올림 그늘은 아래에서 + 그대로 그린다). 없으면 기존 벡터 경로를 그린다. */} + {!rig.landmarks.browLeft.sprite ? ( + <> + + + + ) : ( + Array.from({ length: BROW_STRIP_COUNT }, (_, i) => { + const sprite = rig.landmarks.browLeft.sprite!; + const rect = browSpriteStripClipRect(sprite, i); + const stripClipId = `${uid}-brow-left-strip-clip-${i}`; + return ( + + + + + + + ); + }) + )} + {!rig.landmarks.browRight.sprite ? ( + <> + + + + ) : ( + Array.from({ length: BROW_STRIP_COUNT }, (_, i) => { + const sprite = rig.landmarks.browRight.sprite!; + const rect = browSpriteStripClipRect(sprite, i); + const stripClipId = `${uid}-brow-right-strip-clip-${i}`; + return ( + + + + + + + ); + }) + )} diff --git a/apps/web/src/components/avatar/v3/linocutGeometry.ts b/apps/web/src/components/avatar/v3/linocutGeometry.ts index 4f0b037..54c71ef 100644 --- a/apps/web/src/components/avatar/v3/linocutGeometry.ts +++ b/apps/web/src/components/avatar/v3/linocutGeometry.ts @@ -364,6 +364,11 @@ export interface EyeGeometry { highlightCx: number; highlightCy: number; highlightRadius: number; + /** 쌍꺼풀 주름(creaseD) stroke-width = sizeScale × lineScale × 1.2(기존 CSS 고정값). + 홍채·흰자에는 lineScale을 곱하지 않는다(결정문 §8.4.1). */ + creaseStrokeWidth: number; + /** 아랫눈꺼풀(lowerLidD) stroke-width = sizeScale × lineScale × 1.2. */ + lowerLidStrokeWidth: number; radialStrokesD: string; tearPoolD: string; tearPoolOpacity: number; @@ -390,6 +395,16 @@ interface EyeChannels { const CRESCENT_SAMPLES = 14; const CREASE_SAMPLES = 8; const LOWER_LID_SAMPLES = 10; +/** 쌍꺼풀·아랫눈꺼풀 stroke-width 기준값(기존 CSS 고정값 1.2와 같다, linocut-avatar.css + .linocut-avatar__crease/.lower-lid). eye.lineScale이 없으면(기본 1) 전과 같다. */ +const CREASE_LOWER_LID_BASE_STROKE_WIDTH = 1.2; + +/** 윗눈꺼풀 리본 두께 기준식(t_base(s), s는 O→I 곡선 매개변수 0~1). lineScale=1일 때 + curveTop에서 안쪽(눈 틈 쪽)으로 더하는 두께이자, lineScale>1일 때 바깥쪽으로 추가로 + 더하는 두께의 단위이기도 하다(T2, 아래 buildEye 참고). */ +function lidThicknessAt(s: number): number { + return 1.5 + 4 * (1 - smoothstep(0.45, 1, s)); +} function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScale: number): EyeGeometry { const O = toPoint(eye.outer); @@ -398,6 +413,9 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal const D = toPoint(eye.lowerLidBottom); const C = toPoint(eye.iris.center); const r = eye.iris.radius; + /* 원화 윗눈꺼풀 잉크 두께 ÷ P1 두께(결정문 §8.4.1) — 눈꺼풀(아래 t)·쌍꺼풀·아랫눈꺼풀 + 굵기에만 곱한다. 홍채·흰자(irisRadius·irisRingWidth 등)에는 곱하지 않는다. */ + const lineScale = eye.lineScale ?? 1; /* 크게 뜬 눈(eyeOpen>1) 증폭: 그대로 두면 놀람이 잘 안 읽힌다(1차 반려 "표정 간 차이가 작다"와 연결). e는 eyeOpen<=1이면 그대로, 넘으면 1.8배 증폭(최대 1.5)한다. */ @@ -424,11 +442,21 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal const outlineD = `${pathQ(O, upperControl, I)} Q ${lowerControl[0]} ${lowerControl[1]} ${O[0]} ${O[1]} Z`; - /* 윗눈꺼풀 선: s=0(바깥 O)→1(안쪽 I), t(s) = 1.5 + 4·(1 − smoothstep(0.45,1,s)). */ - const crescentTop = sampleQuad(O, upperControl, I, 0, 1, CRESCENT_SAMPLES); - const crescentBottom = crescentTop.map((p, i) => { + /* 윗눈꺼풀 선: s=0(바깥 O)→1(안쪽 I), t_base(s) = lidThicknessAt(s). + 2차 보정(T2, 2단계-B-2a 반려 — 안쪽 경계가 홍채 위를 덮었다): lineScale로 늘어나는 + 두께 (lineScale−1)·t_base는 바깥쪽(위, curveTop 기준 음의 y)으로만 더한다. 안쪽 + 경계(crescentBottom, 눈 틈·홍채 쪽)는 lineScale과 무관하게 항상 curveTop + t_base다 + (= lineScale=1일 때의 자리와 같다). lineScale=1이면 extra=0이라 crescentTop=curveTop, + crescentBottom은 전과 바이트 단위로 같다. */ + const curveTop = sampleQuad(O, upperControl, I, 0, 1, CRESCENT_SAMPLES); + const crescentTop = curveTop.map((p, i) => { const s = i / (CRESCENT_SAMPLES - 1); - const t = sizeScale * (1.5 + 4 * (1 - smoothstep(0.45, 1, s))); + const extra = sizeScale * (lineScale - 1) * lidThicknessAt(s); + return [p[0], p[1] - extra] as Point; + }); + const crescentBottom = curveTop.map((p, i) => { + const s = i / (CRESCENT_SAMPLES - 1); + const t = sizeScale * lidThicknessAt(s); return [p[0], p[1] + t] as Point; }); const tailDir = (() => { @@ -447,11 +475,20 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal "Z", ].join(" "); - /* 쌍꺼풀 주름: s 0.15~0.85, 윗곡선 위로 9 + 5·max(0, openU−1)만큼, 불투명도 0.6 기준. */ + /* 쌍꺼풀 주름: s 0.15~0.85, 윗곡선 위로 9 + 5·max(0, openU−1)만큼. lineScale>1이면 + 윗눈꺼풀 리본이 바깥쪽으로 그만큼 두꺼워지므로(위 crescentTop), 주름도 같은 양만큼 + 더 위로 비켜 겹치지 않게 한다(T2). */ const creaseOffset = 9 + 5 * Math.max(0, openU - 1); - const creasePts = sampleQuad(O, upperControl, I, 0.15, 0.85, CREASE_SAMPLES).map((p) => [p[0], p[1] - creaseOffset] as Point); + const creasePts = Array.from({ length: CREASE_SAMPLES }, (_, i) => { + const t = lerp(0.15, 0.85, i / (CREASE_SAMPLES - 1)); + const p = quadPointAt(O, upperControl, I, t); + const extra = sizeScale * (lineScale - 1) * lidThicknessAt(t); + return [p[0], p[1] - creaseOffset - extra] as Point; + }); const creaseD = creasePts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); const creaseOpacity = (openU < 0.35 ? clamp(openU / 0.35) : 1) * 0.6; + const creaseStrokeWidth = sizeScale * lineScale * CREASE_LOWER_LID_BASE_STROKE_WIDTH; + const lowerLidStrokeWidth = sizeScale * lineScale * CREASE_LOWER_LID_BASE_STROKE_WIDTH; /* 아랫눈꺼풀: s 0~0.8 가는 선. */ const lowerPts = sampleQuad(O, lowerControl, I, 0, 0.8, LOWER_LID_SAMPLES); @@ -523,6 +560,8 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal highlightCx, highlightCy, highlightRadius: 0.13 * r, + creaseStrokeWidth, + lowerLidStrokeWidth, radialStrokesD: radialSegments.join(" "), tearPoolD, tearPoolOpacity: pool, @@ -549,6 +588,11 @@ export interface BrowGeometry { shadowCx: number; shadowCy: number; shadowOpacity: number; + /** 눈썹 스프라이트 띠 변형(결정문 §8.4.1, rig.landmarks.browLeft/Right.sprite가 + 있는 리그 한정). 없으면 빈 배열 — 렌더러가 기존 벡터 몸통·털 획(bodyD· + hairStrokesD)을 그린다. 있으면 렌더러가 이 배열로 스프라이트 띠를 옮기고 + 벡터 몸통·털 획은 그리지 않는다(안쪽 올림 그늘은 그대로 그린다). */ + spriteStrips: BrowStripMatrix[]; } interface BrowChannels { @@ -564,14 +608,26 @@ const BROW_STOPS: Array = [ [1, 1.0], ]; -function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", sizeScale: number): BrowGeometry { +interface BrowDisplacement { + innerDy: number; + peakDy: number; + outerDy: number; + innerDx: number; + innerP: Point; + peakP: Point; + outerP: Point; +} + +/** browAngle a는 부호에 따라 다른 계수를 쓴다(AU1 안쪽 올림은 바깥이 따라 오르지 않는다, + §8.4 2단계-B-1d 개정): a>0이면 안쪽 −60a·꼭짓점 −4a·바깥 +18a, a<0(AU4)이면 + 안쪽 −50a·꼭짓점 −12a·바깥 +14a. buildBrow(벡터 몸통·털 획)와 + computeBrowSpriteStripTransforms(스프라이트 띠) 둘 다 이 변위를 쓴다(§8.4.1 — + "같은 계수로 구한" 요구, 중복 계산 아님). */ +function computeBrowDisplacement(brow: RigBrow, ch: BrowChannels, side: "left" | "right"): BrowDisplacement { const inner = toPoint(brow.inner); const peak = toPoint(brow.peak); const outer = toPoint(brow.outer); - /* browAngle a는 부호에 따라 다른 계수를 쓴다(AU1 안쪽 올림은 바깥이 따라 오르지 않는다, - §8.4 2단계-B-1d 개정): a>0이면 안쪽 −60a·꼭짓점 −4a·바깥 +18a, a<0(AU4)이면 - 안쪽 −50a·꼭짓점 −12a·바깥 +14a. */ const dyAll = -ch.y * 45; const a = ch.angle; const innerCoef = a > 0 ? -60 : -50; @@ -582,13 +638,91 @@ function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", size const peakDy = dyAll + peakCoef * a - ch.form * 10; const innerDx = side === "left" ? -ch.x * 16 : ch.x * 16; - const innerP: Point = [inner[0] + innerDx, inner[1] + innerDy]; - const peakP: Point = [peak[0], peak[1] + peakDy]; - const outerP: Point = [outer[0], outer[1] + outerDy]; + return { + innerDy, + peakDy, + outerDy, + innerDx, + innerP: [inner[0] + innerDx, inner[1] + innerDy], + peakP: [peak[0], peak[1] + peakDy], + outerP: [outer[0], outer[1] + outerDy], + }; +} + +export const BROW_STRIP_COUNT = 16; + +export interface BrowStripMatrix { + dx: number; + dy: number; +} + +/** 세 점 (x0,y0)(x1,y1)(x2,y2)를 "정확히" 지나는 2차 다항식 y=f(x)의 x에서의 + 값(라그랑주 보간). quadControlThrough(베지어 제어점)는 t=0.5가 mid의 x를 + 지난다는 보장이 없어(x 간격이 고르지 않으면 어긋난다) 눈썹처럼 inner·peak·outer + 간격이 비대칭인 랜드마크에는 안 맞는다. */ +function quadLagrangeAt(p0: Point, p1: Point, p2: Point, x: number): number { + const [x0, y0] = p0; + const [x1, y1] = p1; + const [x2, y2] = p2; + const l0 = ((x - x1) * (x - x2)) / ((x0 - x1) * (x0 - x2)); + const l1 = ((x - x0) * (x - x2)) / ((x1 - x0) * (x1 - x2)); + const l2 = ((x - x0) * (x - x1)) / ((x2 - x0) * (x2 - x1)); + return y0 * l0 + y1 * l1 + y2 * l2; +} + +/** 눈썹 스프라이트 띠 변형(결정문 §8.4.1): 원화 눈썹 스프라이트를 세로 + BROW_STRIP_COUNT개 띠로 나눠 computeBrowDisplacement와 같은 계수로 구한 + inner·peak·outer 변위만큼만 옮긴다(스케일 없음 — 원화 픽셀을 늘리면 잉크 + 굵기가 왜곡된다). 띠 i의 중심 x에서: dy(x)는 (inner.x,innerDy)·(peak.x,peakDy)· + (outer.x,outerDy)를 지나는 2차 보간(quadLagrangeAt), dx는 innerDx × + (안쪽→바깥 선형 가중 1→0)이다. brow.sprite가 없으면 null(렌더러가 기존 벡터 + 경로를 쓴다). */ +export function computeBrowSpriteStripTransforms( + brow: RigBrow, + ch: BrowChannels, + side: "left" | "right", + stripCount: number = BROW_STRIP_COUNT, +): BrowStripMatrix[] | null { + if (!brow.sprite) return null; + const disp = computeBrowDisplacement(brow, ch, side); + const innerX = brow.inner[0]; + const outerX = brow.outer[0]; + const span = outerX - innerX || 1; + const p0: Point = [innerX, disp.innerDy]; + const p1: Point = [brow.peak[0], disp.peakDy]; + const p2: Point = [outerX, disp.outerDy]; + + const sprite = brow.sprite; + const stripW = sprite.w / stripCount; + const out: BrowStripMatrix[] = []; + for (let i = 0; i < stripCount; i++) { + const cx = sprite.x + (i + 0.5) * stripW; + const weight = clamp(1 - (cx - innerX) / span, 0, 1); + out.push({ dx: disp.innerDx * weight, dy: quadLagrangeAt(p0, p1, p2, cx) }); + } + return out; +} + +/** 눈썹 스프라이트 띠 clip 사각형(스프라이트 폭 기준 등분, 이웃과 1px 겹침) — + lipStripClipRect와 같은 패턴. 채널과 무관해 렌더러가 마운트 시 한 번만 + 계산해 에 쓴다. */ +export function browSpriteStripClipRect(sprite: RigLayer, index: number, stripCount: number = BROW_STRIP_COUNT) { + const stripW = sprite.w / stripCount; + return { x: sprite.x + index * stripW - 0.5, y: sprite.y, w: stripW + 1, h: sprite.h }; +} + +function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", sizeScale: number): BrowGeometry { + const disp = computeBrowDisplacement(brow, ch, side); + const { innerP, peakP, outerP } = disp; /* "세 점을 지나는 곡선(12점 이상 표본)", 꺾인 직선 금지(1차 반려 사유). 작은 화면 - 보정(§8.4 2단계-B-1d)으로 반두께·털 획 길이·굵기에 sizeScale을 곱한다. */ - const scaledStops = BROW_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); + 보정(§8.4 2단계-B-1d)으로 반두께·털 획 길이·굵기에 sizeScale을 곱한다. + rig에 원화 실측 반두께(halfWidth)가 있으면 그것을, 없으면 P1 설계값(BROW_STOPS)을 + 쓴다(캐스트 확장, 결정문 §8.4). */ + const stops: Array = brow.halfWidth + ? [[0, brow.halfWidth[0]], [0.5, brow.halfWidth[1]], [1, brow.halfWidth[2]]] + : BROW_STOPS; + const scaledStops = stops.map(([t, hw]) => [t, hw * sizeScale] as const); const { samples, halfWidths } = curveRibbonData([innerP, peakP, outerP], scaledStops, 6); const bodyD = polylineRibbon(samples, halfWidths); const seedBase = side === "left" ? 0 : 100; @@ -596,12 +730,17 @@ function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", size const hairStrokeWidth = 1.3 * sizeScale; /* 안쪽 올림 그늘(§8.4): a>0.3이면 눈썹 머리 위에 흐린 잉크 그늘(가우시안 흐림은 - 렌더러의 SVG 필터가 맡는다, 여기서는 위치·불투명도만). */ - const shadowOpacity = 0.18 * clamp((a - 0.3) / 0.7); + 렌더러의 SVG 필터가 맡는다, 여기서는 위치·불투명도만). 스프라이트가 있는 리그도 + 이 그늘은 그대로 그린다(§8.4.1 — 벡터 몸통·털 획만 스프라이트로 대체한다). */ + const shadowOpacity = 0.18 * clamp((ch.angle - 0.3) / 0.7); const shadowCx = innerP[0]; const shadowCy = innerP[1] - 18; - return { bodyD, hairStrokesD, hairStrokeWidth, innerP, peakP, shadowCx, shadowCy, shadowOpacity }; + /* 눈썹 스프라이트 띠 변형(§8.4.1): sprite가 없으면 빈 배열 — 렌더러가 위 + bodyD·hairStrokesD(벡터 경로)를 그대로 그린다. */ + const spriteStrips = computeBrowSpriteStripTransforms(brow, ch, side) ?? []; + + return { bodyD, hairStrokesD, hairStrokeWidth, innerP, peakP, shadowCx, shadowCy, shadowOpacity, spriteStrips }; } /* ── 보조 주름 ─────────────────────────────────────────────────────── */ diff --git a/apps/web/src/components/avatar/v3/linocutRig.ts b/apps/web/src/components/avatar/v3/linocutRig.ts index 885a144..85dae68 100644 --- a/apps/web/src/components/avatar/v3/linocutRig.ts +++ b/apps/web/src/components/avatar/v3/linocutRig.ts @@ -28,6 +28,9 @@ export interface RigEye { upperLidTop: RigPoint; lowerLidBottom: RigPoint; iris: { center: RigPoint; radius: number }; + /** 눈꺼풀·쌍꺼풀·아랫눈꺼풀 선 굵기 배율 — 원화 윗눈꺼풀 잉크 두께 ÷ P1 두께(결정문 §8.4.1). + 굵은 판화 선 캐릭터의 벡터 눈이 가늘고 흐려지는 것을 막는다. 없으면 1. */ + lineScale?: number; } /** 눈썹 잉크 띠의 **중심선** 위 세 점(결정문 §8.4). 띠의 윗가장자리·아래 모서리를 찍으면 @@ -36,6 +39,13 @@ export interface RigBrow { inner: RigPoint; peak: RigPoint; outer: RigPoint; + /** 원화 잉크 띠의 반두께(px) — [안쪽, 꼭짓점, 바깥]. 캐스트마다 눈썹 굵기가 달라(남성·성인은 + 더 굵다) 원화에서 잰 값을 쓴다. 없으면 P1 설계값 [4.5, 3.2, 1.0](결정문 §8.4 캐스트 확장). */ + halfWidth?: readonly [number, number, number]; + /** 원화 눈썹 잉크 스프라이트(결정문 §8.4.1). 알파는 잉크 농도(피부는 투명)이고 앞머리 가닥은 + 빠져 있다. 있으면 렌더러는 벡터 눈썹 몸통·털 획 대신 이 픽셀을 세로 띠로 나눠, 각 띠를 + inner·peak·outer 변위를 잇는 곡선 값만큼 옮긴다(입술 결과 같은 "중립 렌더 = 원화" 원칙). */ + sprite?: RigLayer; } export interface RigLandmarks { diff --git a/docs/avatar-art/linocut-pipeline/README.md b/docs/avatar-art/linocut-pipeline/README.md index 531606a..688735c 100644 --- a/docs/avatar-art/linocut-pipeline/README.md +++ b/docs/avatar-art/linocut-pipeline/README.md @@ -16,11 +16,15 @@ 3. `segmentation.py` — `layers/{body,head,hairFront}.png`(v1) + manifest 기준 섹션 4. `layers_v2.py` — `layers/v2/{body,head,hairFront}.png`(턱 밑 띠·잔머리 halo 보정) 5. `face_detail.py` — `layers/v2/face-detail.png` -6. `paper_grain.py` — `layers/v2/paper-grain.png`(다른 단계와 독립, 순서 유연) -7. `lip_texture.py` — `layers/v2/lip-{upper,lower,shadow}.png` -8. `jaw_pieces.py` — `layers/v2/jaw-{head,detail}.png` -9. `export_rig.py` — WebP 게시(`apps/web/public/avatar/v3//`) + `rigs/` 생성 -10. `final_previews.py` — 게시된 WebP로 모션·눈/입 확대 미리보기 +6. `brow_sprite.py` — `layers/v2/brow-{left,right}.png`(원화 눈썹 스프라이트) + + `manifest.eyeLineScale`(눈꺼풀 선 굵기 배율) + `manifest.iris`(홍채 실측, 플래그와 + 무관하게 항상 기록). 아래 "`browSpriteFromArt`/`eyeLineScaleFromArt`/ + `irisFromArt`" 절 참고. +7. `paper_grain.py` — `layers/v2/paper-grain.png`(다른 단계와 독립, 순서 유연) +8. `lip_texture.py` — `layers/v2/lip-{upper,lower,shadow}.png` +9. `jaw_pieces.py` — `layers/v2/jaw-{head,detail}.png` +10. `export_rig.py` — WebP 게시(`apps/web/public/avatar/v3//`) + `rigs/` 생성 +11. `final_previews.py` — 게시된 WebP로 모션·눈/입 확대 미리보기 이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 `final_previews`는 `export_rig`가 쓴 `export-rig-report.json`을 읽으므로 반드시 `export_rig` *다음*이어야 @@ -79,7 +83,17 @@ "faceDetail": { // 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다). - "moles": [{ "center": [661.3, 627.9], "radius": 20.0 }] + "moles": [{ "center": [661.3, 627.9], "radius": 20.0 }], + // "features"(기본)면 눈·눈썹·입 주변 타원만 faceDetail(원화 픽셀) 범위다. "face"면 + // 얼굴 오벌 전체(moles 포함)로 넓어진다 — 캐스트 확장(새 페르소나)은 "face"를 쓴다. + // 자세한 설계는 아래 "faceDetail.region" 절 참고. + "region": "face", + // region="face"일 때만 쓴다. 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px, 기본 8) — + // 원화 윤곽 잉크선이 faceDetail 범위 밖에 남도록 원화를 보고 정한다. + "faceInsetPx": 8, + // true면 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의 색을 주변 원화 + // 피부에 맞춰 평행이동한다(기본 false). 아래 "faceDetail.matchFacelessColor" 절 참고. + "matchFacelessColor": true }, // 렌더러 회전/스케일 중심점(결정문 §8.4). 원화를 보고 목·몸통·얼굴 중심을 정한다. @@ -120,6 +134,150 @@ P1의 기존 `face-detail.png`·`jaw-detail.png`·해당 webp·`p1Rig.ts`는 눈 그 고정본과 바이트 단위로 같아지게 한다. **새 페르소나는 이 필드를 넣지 않는다** — 처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다. +### `browHalfWidthFromArt` — 눈썹 굵기를 원화에서 잴지 여부(기본 true, 캐스트 확장) + +```jsonc +{ + "browHalfWidthFromArt": false +} +``` + +렌더러(`v3/linocutGeometry.ts` buildBrow)의 눈썹 반두께는 기본으로 P1 설계값 +(`BROW_STOPS` = [4.5, 3.2, 1.0])이 아니라 **원화에서 실측한 값**을 쓴다 +(`brow_centerline.py`가 눈썹 잉크 띠의 중심선 법선 방향 반두께를 안쪽·꼭짓점·바깥 +세 점에서 재 `manifest.browCenterline.halfWidth{Left,Right}`에 남기고, +`export_rig.py`가 `browHalfWidthFromArt`가 `false`가 아니면(필드 생략 시 기본 true) +그 값을 `rig.landmarks.browLeft/Right.halfWidth`로 내보낸다). 굵은 눈썹 캐릭터(성인· +남성 등)가 P1 고정 굵기로 그려져 원화와 달라지는 문제를 캐스트 확장에서 없앤다. + +P1은 `false`로 둔다 — 기존 `p1Rig.ts`가 2단계-B에서 소유자 검수를 거쳐 고정됐으므로 +(§8.4 눈썹 항목) 재실행해도 바뀌면 안 된다. 측정값 자체는 `browHalfWidthFromArt` +설정과 무관하게 항상 manifest에 남는다(참고용). **새 페르소나는 이 필드를 생략한다** +(기본 true로 원화 실측값을 쓴다). + +### `browSpriteFromArt`/`eyeLineScaleFromArt`/`irisFromArt` — 눈썹 스프라이트·눈꺼풀 굵기·홍채(모두 기본 true) + +```jsonc +{ + "browSpriteFromArt": false, + "eyeLineScaleFromArt": false, + "irisFromArt": false +} +``` + +굵은 판화 눈썹 캐릭터는 벡터 눈썹(P1 설계 굵기에 맞춘 리본·털 획)이 "갈색빛 회색 +막대"로 그려진다(결정문 §8.4.1). 입술과 같은 "중립 렌더 = 원화" 원칙을 적용한다: + +- `browSpriteFromArt`(기본 true): `brow_sprite.py`가 눈썹 중심선(`manifest.landmarks. + eyebrowLeft/Right` + `browCenterline.halfWidth{Left,Right}`)을 따라 띠를 만들고, + 그 안의 원화 잉크(Otsu+diff 연결성분, `brow_centerline.py`와 같은 기준)에서 머리카락 + (`layers/v2/hairFront.png` 알파 — 분할 카테고리 자체가 아니다. 이 화풍에서는 + mediapipe 머리카락 분할이 두꺼운 눈썹 잉크 자체를 머리카락으로 오탐해, 분할 결과를 + 그대로 빼면 눈썹 잉크 태반이 사라진다. 실제로 화면에서 눈썹 위에 겹쳐 그려질 + hairFront 알파로 기준을 좁혀야 "앞머리 가닥이 눈썹과 같이 움직이면 안 된다"는 + 의도를 과하게 지우지 않고 구현할 수 있다)를 뺀 자리만 `brow-{left,right}.png`로 + 뗀다. `export_rig.py`가 입술 결과 같은 무손실(exact) WebP로 게시하고 + `rig.landmarks.browLeft/Right.sprite`를 채운다 — 렌더러(`linocutGeometry.ts` + `computeBrowSpriteStripTransforms`)가 벡터 몸통·털 획 대신 이 스프라이트를 세로 + 16띠로 나눠 옮긴다(안쪽 올림 그늘은 그대로 벡터로 그린다). +- `eyeLineScaleFromArt`(기본 true): `brow_sprite.py`가 눈 폭 가운데 60% 열마다 + `upperLidTop.y-12`~홍채 중심 y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값을 + 재, P1 기준값(2.0px, 상수로 고정 — 근거는 `brow_sprite.py` 주석 참고)으로 나눈 + 비를 `[1.0, 2.5]`로 clamp한 값을 `manifest.eyeLineScale`에 남긴다. `export_rig.py`가 + `rig.landmarks.eyeLeft/Right.lineScale`로 내보내고, 렌더러가 눈꺼풀·쌍꺼풀· + 아랫눈꺼풀 굵기에 곱한다(홍채·흰자는 그대로). + +- `irisFromArt`(기본 true): mediapipe iris 랜드마크(468~477)는 실제 사람 눈 비례로 + 학습돼, 판화 잉크로 그려진 홍채보다 반지름을 크게(또는 중심을 다르게) 잡을 수 있다 + (2차 보정 T3, P7 실측: 가로 반지름 기준 리그 대비 약 11~15% 작다). `brow_sprite.py`가 + (browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상) 원화에서 홍채 가로 반지름· + 중심을 재 `manifest.iris`에 남기고, `export_rig.py`가 `irisFromArt`가 `false`가 + 아니면 `rig.landmarks.eyeLeft/Right.iris`를 이 실측값으로 채운다(세로 경계는 윗눈꺼풀 + 잉크와 홍채가 같은 톤으로 이어져 가를 수 없어, 가로 반지름 + 아래쪽 경계로만 + 중심y를 역산한다 — 자세한 방법은 `brow_sprite.py` `measure_iris` 참고). + +P1은 셋 다 `false`다 — 이미 고정 벡터 눈썹·굵기·홍채 위치로 빌드·검수됐고, +`eyeLineScaleFromArt`의 경우 P1 자체가 lineScale의 기준값이라 바뀌면 안 된다. +**새 페르소나는 이 필드들을 생략한다**(기본 true로 원화 스프라이트·실측 굵기·실측 +홍채를 쓴다). + +### `faceDetail.region` — faceDetail이 그려질 범위(기본 "features", 캐스트 확장 "face") + +`face_detail.py`가 눈·눈썹·입 "제외 영역"(이목구비 잉크선) 밖에서 원화(base-front) 픽셀을 +쓰는 범위를 정한다. 레이어 픽셀은 항상 base-faceless(F)에서 가져오므로(README 상단 +단계 설명 참고), 이 범위 밖은 F뿐이라 — 다크서클·볼 해칭·팔자 주름·턱 그늘 같은 +원화 고유의 판화 해칭이 전혀 남지 않는다. + +- `"features"`(기본, P1 그대로): 눈·입 주변 타원(눈두덩~다크서클, 입가 음영)에서만 + 원화 픽셀을 쓴다. 나머지 얼굴 피부는 F(매끈한 기본형)다. +- `"face"`(캐스트 확장): 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은 + 36점 루프·1.04 배율) 전체에서 원화 픽셀을 쓴다. moles 타원은 그대로 합쳐진다. + - 오벌을 그대로 쓰면 원화 얼굴 윤곽 잉크선(jaw_pieces.py 기준 오벌 경계 ±18px + 안팎)이 영역 안에 들어와, F 기준으로 이미 그려진 head 레이어의 윤곽선과 겹쳐 + 두 겹 턱선(머리 이동 시차)이 생긴다 — `faceInsetPx`(기본 8)만큼 오벌을 안쪽으로 + 줄여(거리변환 기준, 방사형 스케일이 아니다) 이 잉크선을 영역 밖에 둔다. 페더는 + 다른 영역과 같은 `REGION_FEATHER_PX`(12px, sigma 6)를 쓴다. 새 페르소나마다 + 원화 윤곽선 위치가 다르므로, 원화를 보고 `faceInsetPx`를 정해 보고해야 한다. + - 머리카락 알파 0 규칙(머리카락 위에는 faceDetail을 그리지 않는다)은 그대로이되, + `"face"`에서는 머리카락 마스크를 **원화(front)와 faceless(F) 분할 결과의 + 합집합**으로 만든다. 둘의 앞머리 가닥 위치가 달라(codex 편집본 시차) 한쪽 + 분할만 쓰면 다른 쪽 잔머리가 마스크 밖에 남아 유령 가닥이 생기기 때문이다. + `"features"`는 기존처럼 F 분할만 쓴다(P1 회귀 보존). + +manifest.layersV2.faceDetail.region/faceInsetPx에 남는다. + +### `faceDetail.matchFacelessColor` — 제외 영역 F 색을 원화 피부에 맞추기(기본 false) + +`true`면 제외 영역(눈·눈썹·입) 안을 채우는 F 픽셀의 색을 주변 원화 피부에 맞춘다. +영역(eyeLeft/eyeRight/browLeft/browRight/mouth)마다 그 영역 바깥 `RING_PX`(12px) +고리에서 어두운 잉크(원화 명도<110) 픽셀을 뺀 나머지로 원화·F 평균 RGB 차(ΔRGB)를 +구하고, 그 영역 전체의 F 픽셀을 ΔRGB만큼 그대로 평행이동(가산)한다 — 보간·블러· +패치 합성은 하지 않는다(과거 P1에서 그 방식이 회색 얼룩으로 반려된 적이 있다). +영역별 ΔRGB는 콘솔과 `manifest.layersV2.faceDetail.matchFacelessColorDeltaRgb`에 +남는다. P1은 `false`(기존 결과 그대로)다. + +## 캐스트 확장 0단계 — 원화 3장을 준비하는 공통 도구 + +새 페르소나의 후보 원화는 크기·구도가 P1과 다르다(결정문 §8.2 생성 규칙은 그대로 +따르되, 캔버스에 맞추는 과정이 필요하다). 아래 두 도구가 "사람이 고른 후보 원화 + +codex 편집본 2장"을 파이프라인이 기대하는 `base/base-front.png`· +`base/base-faceless.png`·`raw/body.png`(모두 캔버스 1005x1566)로 만든다. 순서대로 +쓴다: + +1. `register_edit.py` — codex가 만든 얼굴 없는 기본형·body 편집본을 ECC 아핀 + 정렬(`cv2.findTransformECC`, `MOTION_AFFINE`)로 후보 원화(ref) 좌표계에 맞춘다. + edited가 ref와 크기가 달라도 된다(먼저 ref 크기로 리사이즈한다). + + ```bash + /python.exe register_edit.py \ + --mode faceless --report + /python.exe register_edit.py \ + --mode body --report + ``` + + 콘솔·리포트 JSON에 정렬 품질 지표(변환 배율·회전·이동, 마스크 안 Canny 에지 + 챔퍼 거리 중앙값·p90, faceless는 얼굴 윤곽 띠 챔퍼와 눈·눈썹·입 상자 잉크 비율)를 + 낸다 — **판정은 하지 않는다**(수용 기준은 출력만, 사람/오케스트레이터가 본다). + +2. `normalize_base.py` — 위 세 장(원래 ref, 정렬된 faceless, 정렬된 body)에 **같은** + 아핀 변환 하나(등방 배율 + 평행이동, 회전 없음)를 적용해 캔버스로 옮긴다. 배율· + 이동은 P1 `base-front.png`의 눈 중심·눈 사이 거리·눈-턱 거리 비율에 맞춘다 + (P1과 머리 크기 비율이 같아지도록). + + ```bash + /python.exe normalize_base.py \ + --front --faceless --body + ``` + + 출력: `/base/base-front.png`·`base/base-faceless.png`(1005x1566 + RGB, 위아래 빈 곳은 원본 가장자리 띠 반사 패딩)·`raw/body.png`(1005x1566, + 빈 곳은 `#00ff00`)·`base/normalize.json`(배율·오프셋·덮인 범위·입력 파일 + sha256). + +이 둘은 `persona.json`이 아직 없어도 돈다(두 스크립트 다 페르소나 폴더 경로만 +받고 `load_persona_config`에 의존하지 않는다) — persona.json은 이 산출물을 보고 +그다음에 쓴다(아래 "새 페르소나를 추가할 때" 3번). + ## 모델 파일(저장소에 없음) `scripts/_models/`에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, `.gitignore`의 @@ -137,8 +295,12 @@ python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다. 1. 원화 2장: `/base/base-front.png`(정면 기본형)과 `base-faceless.png`(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다. + 후보 원화 크기·구도가 P1과 다르면(캐스트 확장이 보통 그렇다) 바로 두지 말고 + 위 "캐스트 확장 0단계" 도구(`register_edit.py` → `normalize_base.py`)로 캔버스에 + 맞춰 만든다. 2. `/raw/body.png`: body 레이어가 head_mask로 가려지는 목 상단 영역을 - 메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬). + 메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬). 역시 캐스트 + 확장이면 위 도구가 만든다. 3. `/persona.json`: 위 스키마대로 작성한다. 특히 `styleFrame`· `paletteSamples`·`pivots`는 원화를 눈으로 보고 정해야 한다(자동 유도 불가). 4. (선택) `/motif/`: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가 @@ -147,3 +309,45 @@ python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다. 그 다음 `run_pipeline.py `를 실행하고, 출력된 검사 수치(halo%, 평균절대차 등 — 각 단계 스크립트가 콘솔에 찍고 manifest.json에도 남긴다)를 기준치와 비교해 판정한다. + +## QA 캡처(`qa/capture_cast_sheet.mjs`) + +리그가 게시된(`run_pipeline.py`가 `export_rig.py`까지 끝낸) 페르소나를 Lab +(`/dev/avatar-lab`)에서 조작해 캐스트 시트 5장을 만든다. + +```bash +node qa/capture_cast_sheet.mjs [--base http://localhost:5173] +``` + +- ``: `P1`처럼 대문자 페르소나 코드. `apps/web/src/components/avatar/v3/rigs/Rig.ts`의 + `_LINOCUT_RIG` export와 `docs/avatar-art/-linocut/base/base-front.png`를 읽는다. +- Lab에 `?persona=` 쿼리(`v3/rigs/index.ts` `labLinocutRigs()` + `AvatarLab.tsx` + `?persona=` 지원, 세션 화면과 무관)로 들어가 그 페르소나를 바로 고른다. +- `--base`가 가리키는 dev server가 이미 떠 있어야 한다(이 스크립트는 서버를 + 띄우거나 끄지 않는다). 기본값 `http://localhost:5173`. + +산출(``에 JPG, 모두 긴 변 ≤1000px — 개별 캡처 PNG는 `/frames/`): + +- `sheet-expr-bust.jpg` / `sheet-expr-face.jpg`: 16표정(강도 1) × bust/face 크롭, + 4×4 격자. +- `sheet-viseme.jpg`: 비짐 9종(X·M·A·EO·O·U·EU·I·E), 입 주변 확대, 3×3 격자. Lab에 + 채널을 직접 주입하는 고정 기능이 없어(엔진 값을 바꾸지 않는 이 패킷 범위에서는 + 추가하지 않는다) 비짐 9종이 모두 나오는 문장을 재생하며 50ms 간격으로 + (시각·비짐·스크린샷)을 한 패스에 모아 두고, 각 비짐이 가장 오래 유지된 구간의 + 중앙 표본을 그 비짐의 대표 프레임으로 쓴다(스캔과 재생을 두 패스로 나눠 같은 + 시각을 다시 재생하면 클릭~엔진 시계 리셋 사이 수십ms급 비결정성 때문에 스캔 때와 + 다른 비짐이 잡힐 수 있음을 실측으로 확인했다 — 항상 한 패스로 찍는다). + `/viseme-timeline.json`에 전체 표본을 남긴다. +- `neutral-vs-art.jpg`: neutral 렌더(portrait 크롭)와 `base/base-front.png`를 + 나란히 둔 것 — 리그 피벗·배율이 원화와 맞는지 한눈에 비교한다. +- `face-art-vs-render.jpg`: 고해상도 얼굴 대조(2차 보정 T4b). `rig.crops.face` + 영역을 원화는 500px, 렌더(neutral·grief·joy·angry)는 760px로 키워 2줄 3칸으로 + 나란히 두고 최종 긴 변 ≤1000px로 다시 줄인다 — 눈·눈썹처럼 작은 차이가 bust + 전체 비교에서는 묻히는 부위를 본다. + +**깜빡임/유휴 시선 흔들림 회피**(`screenshotEyeStable`, 2차 보정 T4a): `neutral-vs-art.jpg`· +`face-art-vs-render.jpg`의 정적 비교 스크린샷은 Reduced motion을 다시 켠 뒤(viseme +구간에서 꺼 둔 채로 남는다 — 유휴층 breath·blink·시선 흔들림이 채널에 섞여 든다) +700ms 간격 3연사 중 eye-left outline(흰자 바탕) bbox 높이가 가장 큰 장을 쓴다 — +neutral-vs-art가 깜빡임 중에 찍힌 사례(P2)와, 원화 대비 렌더 홍채 아래 흰 띠가 +유휴 시선 흔들림(gazeY) 중에만 나타나는 것을 실측으로 확인했다(2026-10-01). diff --git a/docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs b/docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs new file mode 100644 index 0000000..6e7243d --- /dev/null +++ b/docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs @@ -0,0 +1,394 @@ +// 공통 리노컷 리그 — 캐스트 QA 캡처 도구(T7). scratch/linocut-review/capture_out4.mjs의 +// 방식(apps/web의 playwright, auth/me 모킹, Lab 조작, 일시정지 뒤 스크린샷)을 그대로 +// 따른다. 페르소나 코드 하나를 받아 AvatarLab(/dev/avatar-lab?persona=)을 조작해 +// 5장을 만든다: sheet-expr-bust.jpg, sheet-expr-face.jpg, sheet-viseme.jpg, +// neutral-vs-art.jpg, face-art-vs-render.jpg. +// +// 비짐 9종은 Lab에 고정 기능이 없어(엔진 채널을 직접 주입할 UI가 없다) 발화 재생 중 +// 프레임을 쓴다: 비짐 글자를 담는 텍스트를 재생하면서 매 프레임을 처음부터 다시 +// 재생해 정확한 오프셋만큼 기다리는 방식(capture_speech_strip*.mjs 패턴)으로 (t, +// 비짐) 타임라인을 훑고, 각 목표 비짐이 가장 오래 유지된 구간의 중앙 지점을 그 +// 비짐의 대표 프레임으로 쓴다. +// +// 사용: node capture_cast_sheet.mjs [--base http://localhost:5173] +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.join(__dirname, "../../../../"); +const require = createRequire(path.join(REPO, "apps/web/package.json")); +const { chromium } = require("playwright"); +const esbuild = require("esbuild"); +const Module = require("node:module"); + +const argv = process.argv.slice(2); +const CODE = argv[0]; +const outDirArg = argv[1]; +if (!CODE || !outDirArg) { + console.error("사용: node capture_cast_sheet.mjs [--base http://localhost:5173]"); + process.exit(1); +} +const baseIdx = argv.indexOf("--base"); +const BASE_URL = baseIdx !== -1 ? argv[baseIdx + 1] : "http://localhost:5173"; +const OUT = path.resolve(outDirArg); +const FRAMES = path.join(OUT, "frames"); +mkdirSync(OUT, { recursive: true }); +mkdirSync(FRAMES, { recursive: true }); + +const SLUG = CODE.toLowerCase(); +const CODE_UPPER = CODE.toUpperCase(); + +/* ── 16표정(사용자 지정) ── */ +const EXPR16 = [ + "neutral", "calm", "warm", "joy", "sad", "grief", "lonely", "anxious", + "panic", "startled", "ashamed", "angry", "skeptical", "confused", "tired", "bored", +]; + +/* ── 비짐 9종(결정문 §5.4) — 목표 open/wide/round로 가장 또렷이 드러나는 한글 음절 1개씩. + 밥/맘은 초성·종성이 모두 양순음(M)이라 M 비짐 구간이 더 길게 유지된다. ── */ +const VISEME_WORDS = ["밥", "너", "소", "두", "그", "미", "네"]; +const VISEME_TEXT = VISEME_WORDS.join(" "); +const VISEME_TARGETS = ["M", "A", "EO", "O", "U", "EU", "I", "E"]; // X는 재생 전 초기 상태에서 따로 캡처 +const VISEME_SCAN_END_MS = 2800; +const VISEME_SCAN_STEP_MS = 50; +const VISEME_CELL_TARGET_W = 300; // 최종 비짐 크롭 1장의 캔버스 단위 목표 너비(px) + +/* ── 깜빡임/유휴 시선 흔들림 회피(T4a) — neutral-vs-art가 깜빡임 중에 찍힌 사례가 + 있었다(P2). reducedMotion=true면 유휴층(breath·blink·시선 흔들림)이 꺼져 채널이 + 표정 목표값에 그대로 스냅되지만(엔진 §idle layer), 안전망으로 700ms 간격 3연사 중 + eye-left outline bbox 높이가 가장 큰 장(=눈이 가장 크게 뜬 상태)을 고른다. ── */ +const EYE_STABILITY_SHOTS = 3; +const EYE_STABILITY_INTERVAL_MS = 700; +const FACE_COMPARE_RENDER_PX = 760; // T4b: 렌더 얼굴 크롭 캡처 해상도 +const FACE_COMPARE_ART_PX = 500; // T4b: 원화 얼굴 크롭 캡처 해상도 +const FACE_COMPARE_MAX_LONG_SIDE = 1000; + +function toDataUri(filePath) { + return `data:image/png;base64,${readFileSync(filePath).toString("base64")}`; +} +function loadTs(file) { + const code = esbuild.transformSync(readFileSync(file, "utf8"), { loader: "ts", format: "cjs" }).code; + const m = new Module(file); + m.filename = file; + m.paths = Module._nodeModulePaths(path.dirname(file)); + m._compile(code, file); + return m.exports; +} +async function mockAuth(page) { + await page.route("**/api/auth/me", (route) => + route.fulfill({ + status: 200, contentType: "application/json", + body: JSON.stringify({ + user_id: "cast-sheet", email: "cast-sheet@hs.ac.kr", display_name: "CastSheet", + role: "learner", cohort_ids: [], onboarding_completed_at: Math.floor(Date.now() / 1000), + }), + }), + ); +} +async function waitReady(page) { + await page.waitForFunction(() => { + const svg = document.querySelector('.al__linocut-main svg[data-render-mode="linocut-v3"]'); + return svg && svg.getAttribute("data-load-state") === "ready"; + }, { timeout: 10000 }); +} +async function setReducedMotion(page, on) { + const el = page.locator('[data-testid="reduced-motion-toggle"]'); + if ((await el.isChecked()) !== on) await el.click(); +} +async function setExpression(page, id) { + await page.locator(`[data-testid="expression-${id}"]`).click(); +} +async function setIntensity(page, value) { + await page.locator(".al__intensity input[type=range]").evaluate((el, v) => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; + setter.call(el, String(v)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }, value); +} +async function resizeAndScreenshot(locator, wPx, hPx, filePath) { + const prev = await locator.evaluate((el) => ({ w: el.style.width, h: el.style.height })); + await locator.evaluate((el, size) => { el.style.width = `${size.w}px`; el.style.height = `${size.h}px`; }, { w: wPx, h: hPx }); + await locator.waitFor(); + await locator.screenshot({ path: filePath }); + await locator.evaluate((el, p) => { el.style.width = p.w; el.style.height = p.h; }, prev); +} +function labelRow(cells, cellPx, cols, labelH = 22) { + return `
${cells + .map((c) => `
${c.label}
`) + .join("")}
`; +} +async function buildMontage(browser, filePath, htmlBody) { + // fullPage 스크린샷은 실제 콘텐츠 크기로 찍힌다(뷰포트보다 작아도 줄지 않고, 커도 + // 안 잘린다 — 실측 확인됨). 뷰포트를 넉넉하게 어림잡으면 그 어림값만큼 빈 여백이 + // 그대로 찍혀 긴 변 1000px 기준을 넘길 수 있어, 작게 고정해 항상 실제 콘텐츠 + // 크기로 나오게 한다. + const page = await browser.newPage({ viewport: { width: 60, height: 60 } }); + const html = `${htmlBody}`; + await page.setContent(html); + await page.waitForTimeout(300); + await page.screenshot({ path: filePath, type: "jpeg", quality: 88, fullPage: true }); + await page.close(); +} +async function cropScale(page, srcPath, rect, scale, destPath) { + const uri = toDataUri(srcPath); + const outUri = await page.evaluate(async ({ src, rect, scale }) => { + const img = await new Promise((resolve, reject) => { + const im = new Image(); + im.onload = () => resolve(im); + im.onerror = reject; + im.src = src; + }); + const c = document.createElement("canvas"); + c.width = Math.max(1, Math.round(rect.w * scale)); + c.height = Math.max(1, Math.round(rect.h * scale)); + const ctx = c.getContext("2d"); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h, 0, 0, c.width, c.height); + return c.toDataURL("image/png"); + }, { src: uri, rect, scale }); + writeFileSync(destPath, Buffer.from(outUri.split(",")[1], "base64")); +} +async function scaleDownToMaxLongSide(browser, filePath, maxSide) { + const page = await browser.newPage({ viewport: { width: 60, height: 60 } }); + const uri = toDataUri(filePath); + const outUri = await page.evaluate(async ({ src, maxSide }) => { + const img = await new Promise((resolve, reject) => { + const im = new Image(); + im.onload = () => resolve(im); + im.onerror = reject; + im.src = src; + }); + const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); + const c = document.createElement("canvas"); + c.width = Math.max(1, Math.round(img.width * scale)); + c.height = Math.max(1, Math.round(img.height * scale)); + const ctx = c.getContext("2d"); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img, 0, 0, c.width, c.height); + return c.toDataURL("image/jpeg", 0.9); + }, { src: uri, maxSide }); + writeFileSync(filePath, Buffer.from(outUri.split(",")[1], "base64")); + await page.close(); +} +/** 깜빡임(또는 유휴 시선 흔들림) 중 캡처되는 것을 피한다(T4a). 같은 locator를 + EYE_STABILITY_INTERVAL_MS 간격으로 EYE_STABILITY_SHOTS번 찍어, eye-left outline + (흰자 바탕) bbox 높이가 가장 큰 장을 destPath에 남긴다. */ +async function screenshotEyeStable(page, locator, wPx, hPx, destPath) { + const candidates = []; + for (let i = 0; i < EYE_STABILITY_SHOTS; i++) { + if (i > 0) await page.waitForTimeout(EYE_STABILITY_INTERVAL_MS); + const tmpPath = path.join(FRAMES, `${path.basename(destPath, path.extname(destPath))}-stab${i}.png`); + await resizeAndScreenshot(locator, wPx, hPx, tmpPath); + const h = await page.evaluate(() => { + const outline = document.querySelector('[data-part="eye-left"]')?.children?.[0]; + return outline ? outline.getBBox().height : 0; + }); + candidates.push({ h, tmpPath }); + } + const best = candidates.reduce((a, b) => (b.h > a.h ? b : a)); + copyFileSync(best.tmpPath, destPath); + return destPath; +} + +/** 같은 비짐이 유지된 가장 긴 구간에서 중앙 표본을 고른다(전환 중 프레임을 피한다). + 표본 자체(스크린샷 포함)를 반환한다 — 나중에 같은 t로 재생을 다시 틀어 캡처하면 + 클릭~엔진 시계 리셋 사이의 수십ms급 비결정성 때문에 스캔 때와 다른 비짐이 잡힐 + 수 있다(실측 확인됨). 그래서 스캔 패스에서 이미 찍어 둔 스크린샷을 그대로 쓴다. */ +function pickRepresentativeSample(samples, viseme) { + let bestLen = 0; + let bestMid = null; + let i = 0; + while (i < samples.length) { + if (samples[i].viseme !== viseme) { i++; continue; } + let j = i; + while (j < samples.length && samples[j].viseme === viseme) j++; + const runLen = j - i; + if (runLen > bestLen) { + bestLen = runLen; + bestMid = samples[Math.floor((i + j - 1) / 2)]; + } + i = j; + } + return bestMid; +} + +async function main() { + const rigPath = path.join(REPO, `apps/web/src/components/avatar/v3/rigs/${SLUG}Rig.ts`); + const rigExportName = `${CODE_UPPER}_LINOCUT_RIG`; + const rigModule = loadTs(rigPath); + const rig = rigModule[rigExportName]; + if (!rig) throw new Error(`${rigPath}에 ${rigExportName} export가 없다.`); + const baseFrontPath = path.join(REPO, `docs/avatar-art/${SLUG}-linocut/base/base-front.png`); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1400, height: 1100 } }); + await mockAuth(page); + await page.goto(`${BASE_URL}/dev/avatar-lab?seed=1&persona=${CODE_UPPER}`); + await page.waitForSelector('[data-avatar-lab="true"]'); + await waitReady(page); + + const bustCircle = page.locator(".al__preview-cell--bust-circle"); + const face96 = page.locator(".al__preview-cell--face-96"); + const portrait = page.locator(".al__preview-cell--portrait"); + const visemeCell = page.locator('[data-testid="speech-shape-viseme"]'); + + /* ── 1,2) sheet-expr-bust.jpg / sheet-expr-face.jpg: 16표정, 강도1, 4x4 격자 ── */ + await setReducedMotion(page, true); + const CELL_PX = 215; + const exprShots = []; + for (const id of EXPR16) { + await setExpression(page, id); + await setIntensity(page, 1); + await page.waitForTimeout(500); + const bustPath = path.join(FRAMES, `expr-bust-${id}.png`); + const facePath = path.join(FRAMES, `expr-face-${id}.png`); + await resizeAndScreenshot(bustCircle, CELL_PX, CELL_PX, bustPath); + await resizeAndScreenshot(face96, CELL_PX, CELL_PX, facePath); + exprShots.push({ id, bustPath, facePath }); + console.log(`[expr] ${id}@1 캡처 완료`); + } + { + const cells = exprShots.map((s) => ({ src: s.bustPath, label: s.id })); + const rows = []; + for (let i = 0; i < cells.length; i += 4) rows.push(labelRow(cells.slice(i, i + 4), CELL_PX, 4)); + await buildMontage(browser, path.join(OUT, "sheet-expr-bust.jpg"), rows.join("")); + console.log("sheet-expr-bust.jpg 완료"); + } + { + const cells = exprShots.map((s) => ({ src: s.facePath, label: s.id })); + const rows = []; + for (let i = 0; i < cells.length; i += 4) rows.push(labelRow(cells.slice(i, i + 4), CELL_PX, 4)); + await buildMontage(browser, path.join(OUT, "sheet-expr-face.jpg"), rows.join("")); + console.log("sheet-expr-face.jpg 완료"); + } + + /* ── 3) sheet-viseme.jpg: 비짐 9종, 입 주변 확대, Lab에 고정 기능이 없어 발화 재생 중 + 프레임을 쓴다(패킷 비목표 — 엔진 값 변경 금지라 임시 harness도 만들지 않는다). ── */ + const [fcx, fcy, fcw] = rig.crops.face; + const RENDER_PX = 1203; + const faceScale = RENDER_PX / fcw; + const L = rig.landmarks; + const mxs = [L.mouthCornerLeft[0], L.mouthCornerRight[0]]; + const mys = [L.upperLipTop[1], L.lowerLipBottom[1]]; + const mw = mxs[1] - mxs[0]; + const mh = mys[1] - mys[0]; + const mouthRect = { x: mxs[0] - mw * 0.25, y: mys[0] - mh * 0.6, w: mw * 1.5, h: mh * 2.2 }; + const mouthRenderRect = { x: (mouthRect.x - fcx) * faceScale, y: (mouthRect.y - fcy) * faceScale, w: mouthRect.w * faceScale, h: mouthRect.h * faceScale }; + const cropStageScale = (VISEME_CELL_TARGET_W / mouthRect.w) / faceScale; + + async function captureMouthZoom(destPath) { + const bigPath = path.join(FRAMES, "viseme-big-tmp.png"); + await resizeAndScreenshot(face96, RENDER_PX, RENDER_PX, bigPath); + await cropScale(page, bigPath, mouthRenderRect, cropStageScale, destPath); + } + + await setReducedMotion(page, false); + await setExpression(page, "neutral"); + await setIntensity(page, 0.3); + await page.waitForTimeout(300); + + const visemeShots = []; + // X(쉼) — 아직 재생을 누르기 전 초기 휴지 상태. + { + const shotPath = path.join(FRAMES, "viseme-X.png"); + await captureMouthZoom(shotPath); + visemeShots.push({ id: "X", src: shotPath }); + console.log("[viseme] X(재생 전 초기 상태) 캡처 완료"); + } + + await page.locator('[data-testid="speech-text"]').fill(VISEME_TEXT); + // 한 패스로 (t, 비짐, 스크린샷)을 같이 모은다 — 읻고 나중에 같은 t로 재생을 다시 + // 틀어 캡처하면(2패스) 클릭~엔진 시계 리셋 사이 비결정성 때문에 스캔 때와 다른 + // 비짐이 잡히는 것을 실측으로 확인했다(capture_speech_strip*.mjs와 같은 1패스 방식). + const samples = []; + for (let t = 0; t <= VISEME_SCAN_END_MS; t += VISEME_SCAN_STEP_MS) { + await page.locator('[data-testid="play-speech"]').click(); + await page.waitForTimeout(t); + const viseme = await visemeCell.innerText().catch(() => "?"); + const shotPath = path.join(FRAMES, `viseme-scan-t${String(t).padStart(5, "0")}.png`); + await captureMouthZoom(shotPath); + samples.push({ t, viseme, shotPath }); + } + writeFileSync( + path.join(OUT, "viseme-timeline.json"), + JSON.stringify(samples.map(({ t, viseme, shotPath }) => ({ t, viseme, shotPath })), null, 1), + ); + console.log(`[viseme] 타임라인 ${samples.length}개 표본 수집(스크린샷 포함) 완료`); + + for (const target of VISEME_TARGETS) { + const best = pickRepresentativeSample(samples, target); + if (best === null) { + console.log(`[viseme] ${target} — 타임라인에서 찾지 못함(건너뜀)`); + continue; + } + visemeShots.push({ id: target, src: best.shotPath, t: best.t }); + console.log(`[viseme] ${target} t=${best.t}ms 표본 채택`); + } + + { + const cells = visemeShots.map((s) => ({ src: s.src, label: s.id })); + const rows = []; + const cellPx = VISEME_CELL_TARGET_W; + for (let i = 0; i < cells.length; i += 3) rows.push(labelRow(cells.slice(i, i + 3), cellPx, 3)); + await buildMontage(browser, path.join(OUT, "sheet-viseme.jpg"), rows.join("")); + console.log("sheet-viseme.jpg 완료"); + } + + /* ── 4) neutral-vs-art.jpg: neutral 렌더(portrait)와 base-front.png를 나란히(T4a: + reducedMotion을 다시 켜 유휴층을 끄고, 700ms 3연사로 깜빡임/시선 흔들림을 피한다 — + viseme 구간에서 reducedMotion이 꺼진 채로 남아 neutral-vs-art가 유휴 애니메이션 + 중에 찍힌 사례가 있었다, P2). ── */ + await setReducedMotion(page, true); + await setExpression(page, "neutral"); + await setIntensity(page, 1); + await page.waitForTimeout(400); + const PANEL_W = 420; + const PANEL_H = Math.round((PANEL_W * rig.canvas.h) / rig.canvas.w); + const neutralPortraitPath = path.join(FRAMES, "neutral-portrait.png"); + await screenshotEyeStable(page, portrait, PANEL_W, PANEL_H, neutralPortraitPath); + { + const html = `
+
neutral 렌더(portrait)
+
base-front.png(원화)
+
`; + await buildMontage(browser, path.join(OUT, "neutral-vs-art.jpg"), html); + console.log("neutral-vs-art.jpg 완료"); + } + + /* ── 5) face-art-vs-render.jpg: 고해상도 얼굴 대조(T4b) — bust 얼굴 크롭(rig.crops.face)을 + 렌더 760px·원화 500px로 키워 neutral·grief·joy·angry와 나란히 둔다(2줄 3칸, + 최종 긴 변 <=1000px로 다시 줄인다). ── */ + { + const fch = rig.crops.face[3]; + const artFaceCropPath = path.join(FRAMES, "face-art-crop.png"); + await cropScale(page, baseFrontPath, { x: fcx, y: fcy, w: fcw, h: fch }, FACE_COMPARE_ART_PX / fcw, artFaceCropPath); + + const FACE_COMPARE_EXPR = ["neutral", "grief", "joy", "angry"]; + const faceCompareCells = [{ src: artFaceCropPath, label: "원화(base-front)" }]; + for (const id of FACE_COMPARE_EXPR) { + await setExpression(page, id); + await setIntensity(page, 1); + await page.waitForTimeout(400); + const shotPath = path.join(FRAMES, `face-art-vs-render-${id}.png`); + await screenshotEyeStable(page, face96, FACE_COMPARE_RENDER_PX, FACE_COMPARE_RENDER_PX, shotPath); + faceCompareCells.push({ src: shotPath, label: `렌더: ${id}` }); + } + + const rows = []; + for (let i = 0; i < faceCompareCells.length; i += 3) { + rows.push(labelRow(faceCompareCells.slice(i, i + 3), FACE_COMPARE_RENDER_PX, 3)); + } + const outPath = path.join(OUT, "face-art-vs-render.jpg"); + await buildMontage(browser, outPath, rows.join("")); + await scaleDownToMaxLongSide(browser, outPath, FACE_COMPARE_MAX_LONG_SIDE); + console.log("face-art-vs-render.jpg 완료"); + } + + await browser.close(); + console.log(`\n모두 완료. 산출물: ${OUT}`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py b/docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py index 6ed2c32..7b427bf 100644 --- a/docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py +++ b/docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py @@ -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", diff --git a/docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py b/docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py new file mode 100644 index 0000000..43ded66 --- /dev/null +++ b/docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py @@ -0,0 +1,635 @@ +"""공통 리노컷 리그 — 원화 눈썹 스프라이트 + 눈꺼풀 선 굵기 배율 생성(결정문 §8.4.1, +작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기"). + +배경(오케스트레이터 판정): P7 시범 렌더에서 벡터 눈썹(P1의 가늘고 옅은 눈썹에 맞춘 +굵기)이 P7의 굵은 판화 눈썹을 "갈색빛 회색 막대"로 그렸다. 입술에서 받아들여진 +"중립 렌더 = 원화" 원칙을 눈썹에도 적용한다 — 원화 눈썹 잉크를 그대로 스프라이트로 +떼어 렌더러가 세로 띠로 나눠 휘게 한다. 눈은 감고 뜨기 때문에 벡터를 유지하되, +원화에서 잰 윗눈꺼풀 잉크 두께 배율(lineScale)을 선 굵기에 곱한다. + +persona.json browSpriteFromArt/eyeLineScaleFromArt(둘 다 기본 true)가 false인 +페르소나는 해당 산출을 건너뛴다(P1: 이미 고정 벡터 눈썹·굵기로 빌드·검수됐고, +lineScale의 기준값 자체이기도 하다 — 바뀌면 안 된다). + +눈썹 영역: +- manifest의 눈썹 중심선(brow_centerline.py 결과: landmarks.eyebrowLeft/Right + inner·peak·outer, browCenterline.halfWidthLeft/Right)을 따라 띠를 만든다. + 반높이는 max(halfWidth)+8, inner·outer 바깥으로 6px 연장한다. +- 띠 안에서 원화(base-front) 잉크와 연결된 성분만 남긴다(Otsu(front 휘도) AND + diff(F-front)>=25, 형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취한다 — + brow_centerline.build_ink_band와 같은 기준). +- 머리카락은 뺀다. 기준은 layers/v2/hairFront.png(이미 게시되는 그 레이어)의 + 알파>128(1px 팽창, 오케스트레이터 판정값)다 — 분할 카테고리(category=1) 자체를 + 쓰면 이 화풍(두꺼운 판화 눈썹이 앞머리와 맞닿아 있다)에서 세그멘터가 눈썹 + 잉크 자체를 "머리카락"으로 광범위 오탐한다(P7 1차 실측: 분할 합집합을 그대로 + 빼면 눈썹 잉크의 54~82%가 사라짐). hairFront는 렌더 z-순서에서 눈썹 스프라이트 + "위"에 그려지므로, 그 알파 아래 픽셀을 스프라이트에서 빼도 중립 렌더는 원화와 + 같고 이중 가닥(겹쳐 보임)도 생기지 않는다 — "앞머리 가닥이 눈썹과 같이 + 움직이면 안 된다"는 의도를 분할 카테고리보다 정확히 구현한다(오케스트레이터 + 실측: 눈썹 띠 안 잉크 중 hairFront 알파>128 아래에 있는 비율은 좌 47% + (1392/2953), 우 19%(442/2329) — 분할 카테고리 기준 손실 54~82%보다 훨씬 적다). + +알파: a = clamp((L_skin-L)/(L_skin-L_ink), 0, 1) × (region 경계 1.5px 페더, RAMP_PX). +L_skin은 띠 안 faceless 휘도 중앙값, L_ink는 띠 안에서 "잉크로 분류된 픽셀"(머리카락 +제외 후 region, band_ink_mask 결과) 휘도의 중앙값이다 — 그보다 어두운 픽셀은 a=1이 +된다(2차 보정, 작업 패킷 "눈썹 스프라이트·눈꺼풀 굵기 2차 보정": 띠 전체 휘도의 +5백분위를 쓰면 극단적으로 어두운 꼬리값이 L_ink가 돼, 잉크 픽셀 대부분이 a<1로 +옅게 그려졌다). 페더도 4px→1.5px로 줄였다 — 눈썹 해칭 획 자체가 가늘어(경계거리 +평균 2.28px) 4px 페더로는 L_ink를 낮춰도 대부분 페더 중간값에 걸려 옅었다(RAMP_PX +주석 참고, 블러·형태학 닫기는 쓰지 않아 결은 그대로다). + +색: RGB = clip((front - (1-a)*skin)/a), skin은 띠 안 faceless 평균색. a<0.05면 +잉크색(L_ink population의 front RGB 중앙값)으로 대체한다(0 나눗셈 폭주 방지). + +눈꺼풀 굵기: 눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채 중심 y 사이의 첫 +연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다. P1 기준값(아래 상수)으로 나눈 +비를 [1.0, 2.5]로 clamp한 값이 lineScale이다. + +전제: landmarks.py·brow_centerline.py·layers_v2.py·face_detail.py가 먼저 돌아서 +manifest.landmarks(+browCenterline)와 layers/v2/{body,head,hairFront,face-detail}.png를 +만들어 두어야 한다. export_rig.py보다 앞, face_detail.py보다 뒤에 둔다. + +실행: /python.exe brow_sprite.py +""" +from __future__ import annotations + +import json +import math +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 +from segmentation import build_padded_faceless, to_u8, composite_over, CREAM_BG # noqa: E402 +from lip_texture import crop_to_alpha_bbox # noqa: E402 — 알파 bbox로 잘라 RGBA PNG를 내는 공용 유틸(입술 결과 동일 패턴) + +# --- 눈썹 영역(띠) 상수 ------------------------------------------------------- +BAND_EXTRA_HALF_HEIGHT_PX = 8.0 # 반높이 = max(halfWidth) + 이 값 +BAND_END_EXTEND_PX = 6.0 # inner·outer 바깥 연장 +BAND_BBOX_PAD_PX = 10 # 닫기 연산이 띠 경계에서 잘리지 않게 주는 여유 + +# brow_centerline.py의 잉크 판정 기준과 같다(같은 원화 눈썹 잉크를 다시 찾는 +# 것이므로 같은 임계값을 쓴다) — DIFF_THRESH·CLOSING_KERNEL·MIN_COMPONENT_AREA. +DIFF_THRESH = 25.0 +CLOSING_KERNEL = (7, 11) +MIN_COMPONENT_AREA = 30.0 + +HAIR_DILATE_ITER = 1 # 머리카락(hairFront 알파) 마스크 팽창(오케스트레이터 판정값) +HAIR_ALPHA_THRESH = 128.0 # layers/v2/hairFront.png 알파가 이보다 크면 "머리카락이 덮는다"로 본다(오케스트레이터 판정값) + +RAMP_PX = 1.5 # region(잉크, 머리카락 제외) 경계 알파 페더(px) +# 2차 보정: 눈썹 해칭은 획 자체가 가늘다(P7 실측: region 안 경계거리변환(distance_transform_edt) +# 평균 2.28px, dist>=4px인 픽셀 14~17%뿐) — 기존 4px 페더로는 L_ink를 아무리 낮춰도(=어둡게 +# 잡아도) 거의 모든 획이 페더 중간값에 걸려 옅게 그려졌다(T1 목표 미달의 실제 원인). 1.5px로 +# 줄이면 블러·형태학 닫기 없이(결 보존) 경계 anti-alias만 남기고 목표(알파>0 영역 평균절대차 +# <=10)를 만족한다(P7 실측: 좌 7.83 우 4.99, RAMP_PX=4일 때는 좌 42.75 우 41.77이었다). +MIN_ALPHA_FOR_UNMIX = 0.05 # a<0.05면 피부색 역산(unmix) 대신 잉크색을 쓴다(0 나눗셈 폭주 방지) + +# --- 눈꺼풀 선 굵기(lineScale) 상수 ------------------------------------------ +UPPER_LID_WINDOW_ABOVE_PX = 12.0 # 측정 창 위쪽 = upperLidTop.y - 이 값 +UPPER_LID_DARK_LUM_THRESH = 80.0 +UPPER_LID_COLUMN_MIDDLE_FRAC = 0.6 # 눈 폭 가운데 60%(양옆 20%씩 제외) +LINE_SCALE_MIN = 1.0 +LINE_SCALE_MAX = 2.5 + +# P1 기준값(윗눈꺼풀 잉크 두께, px) — measure_upper_lid_thickness를 P1 +# manifest.landmarks.eyeLeft/eyeRight + base/base-front.png에 적용한 값이다 +# (둘 다 median=2.000px, 표본 열 57/58개 — 실측 근거, 2026-10-01). P1은 +# "다시 빌드하지 않는다"는 결정문 지시로 고정돼 있어 매번 다시 재는 대신 +# 상수로 고정한다(패킷 지시: "P1 값은... 상수로 고정하되 근거를 주석으로 단다"). +P1_UPPER_LID_THICKNESS_PX = 2.0 + +# --- 홍채 실측(2차 보정 T3) 상수 --------------------------------------------- +# mediapipe iris 랜드마크(468~477)는 실제 사람 눈 비례로 학습돼, 판화 잉크로 +# 그려진 홍채(원화 실측)보다 반지름을 크게 잡을 수 있다(P7 실측: 가로 반지름 +# 기준 리그 대비 약 11~15% 작다, 2026-10-01). lineScale·browSprite와 무관하게 +# 항상 측정해 manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 항상 기록" +# 원칙) — export_rig.py가 persona.json irisFromArt(기본 true)일 때만 이 값을 쓴다. +IRIS_DARK_LUM_THRESH = 100.0 # 홍채 잉크 실측 휘도(28~40대) vs 흰자(150 이상) 중간, 여유를 둔 임계값 +IRIS_SCAN_BAND_PX = 4 # 중심 둘레 ±이 값(px)의 행/열을 스캔한다 + + +# --- 눈썹 띠(band) 기하 ------------------------------------------------------- + + +def _quad_point(p0: tuple[float, float], c: tuple[float, float], p2: tuple[float, float], t: float) -> tuple[float, float]: + mt = 1.0 - t + return (mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p2[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p2[1]) + + +def _quad_control(p0: tuple[float, float], mid: tuple[float, float], p2: tuple[float, float]) -> tuple[float, float]: + return (2 * mid[0] - 0.5 * (p0[0] + p2[0]), 2 * mid[1] - 0.5 * (p0[1] + p2[1])) + + +def _normalize(dx: float, dy: float) -> tuple[float, float]: + length = math.hypot(dx, dy) or 1.0 + return dx / length, dy / length + + +def _seg_normal(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]: + dx, dy = b[0] - a[0], b[1] - a[1] + length = math.hypot(dx, dy) or 1.0 + return -dy / length, dx / length + + +def brow_band_polygon( + inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float], + half_height: float, extend_px: float, samples: int = 40, +) -> list[tuple[float, float]]: + """눈썹 중심선(inner→peak→outer 2차 베지어, linocutGeometry.ts buildBrow의 + 곡선과 같은 방식)을 따라 반높이 half_height인 리본 폴리곤을 만든다(곡선 표본 + + 법선 오프셋, linocutGeometry.ts polylineRibbon과 같은 기법). 양끝은 접선 + 방향으로 extend_px만큼 늘린다(inner·outer 바깥 연장).""" + control = _quad_control(inner, peak, outer) + pts = [_quad_point(inner, control, outer, i / (samples - 1)) for i in range(samples)] + t0 = _normalize(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) + t1 = _normalize(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]) + ext_inner = (pts[0][0] + t0[0] * extend_px, pts[0][1] + t0[1] * extend_px) + ext_outer = (pts[-1][0] + t1[0] * extend_px, pts[-1][1] + t1[1] * extend_px) + pts = [ext_inner] + pts + [ext_outer] + + n = len(pts) + normals = [] + for i in range(n): + a = _seg_normal(pts[i - 1], pts[i]) if i > 0 else _seg_normal(pts[i], pts[i + 1]) + b = _seg_normal(pts[i], pts[i + 1]) if i < n - 1 else _seg_normal(pts[i - 1], pts[i]) + normals.append(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)) + + top = [(p[0] + nx * half_height, p[1] + ny * half_height) for p, (nx, ny) in zip(pts, normals)] + bot = [(p[0] - nx * half_height, p[1] - ny * half_height) for p, (nx, ny) in zip(pts, normals)] + return top + list(reversed(bot)) + + +def polygon_bool_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray: + img = Image.new("L", (w, h), 0) + ImageDraw.Draw(img).polygon(points, fill=255) + return np.array(img) > 127 + + +def mask_bbox(mask: np.ndarray, pad: int, w: int, h: int) -> tuple[int, int, int, int]: + ys, xs = np.where(mask) + if len(xs) == 0: + raise SystemExit("[중단] 눈썹 띠 마스크가 비어 있다.") + x0 = max(0, int(xs.min()) - pad) + y0 = max(0, int(ys.min()) - pad) + x1 = min(w, int(xs.max()) + 1 + pad) + y1 = min(h, int(ys.max()) + 1 + pad) + return x0, y0, x1, y1 + + +# --- 띠 안 잉크 연결성분 ------------------------------------------------------ + + +def band_ink_mask(front_lum: np.ndarray, diff: np.ndarray, band_mask: np.ndarray, bbox: tuple[int, int, int, int]) -> tuple[np.ndarray, float]: + """brow_centerline.build_ink_band와 같은 기준(Otsu(front 휘도) AND diff>=25, + 형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취함)을, 축 정렬 bbox 대신 눈썹 + 띠 폴리곤(band_mask) 안으로 제한해 적용한다.""" + x0, y0, x1, y1 = bbox + 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_mask = diff[y0:y1, x0:x1] >= DIFF_THRESH + band_region = band_mask[y0:y1, x0:x1] + combined = ink_mask & diff_mask & band_region + + closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL)) + labeled, n = ndimage.label(closed) + if n == 0: + raise SystemExit("[중단] 눈썹 띠 안에 잉크 마스크가 전혀 없다.") + sizes = ndimage.sum(closed, labeled, range(1, n + 1)) + top_label = int(np.argmax(sizes)) + 1 + band_ink_local = combined & (labeled == top_label) + + lbl2, n2 = ndimage.label(band_ink_local) + if n2 > 0: + sizes2 = ndimage.sum(band_ink_local, lbl2, range(1, n2 + 1)) + keep = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA] + band_ink_local = band_ink_local & np.isin(lbl2, keep) + + full = np.zeros(front_lum.shape, dtype=bool) + full[y0:y1, x0:x1] = band_ink_local + return full, float(otsu_thresh) + + +# --- 눈썹 스프라이트 한 쪽 ---------------------------------------------------- + + +def build_one_brow_sprite( + inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float], + half_width: tuple[float, float, float], + front_rgb: np.ndarray, faceless_rgb: np.ndarray, front_lum: np.ndarray, faceless_lum: np.ndarray, + diff: np.ndarray, hair_mask: np.ndarray, canvas_w: int, canvas_h: int, side_label: str, +) -> dict: + half_height = max(half_width) + BAND_EXTRA_HALF_HEIGHT_PX + band_points = brow_band_polygon(inner, peak, outer, half_height, BAND_END_EXTEND_PX) + band_mask = polygon_bool_mask(band_points, canvas_w, canvas_h) + bbox = mask_bbox(band_mask, BAND_BBOX_PAD_PX, canvas_w, canvas_h) + + ink_mask_raw, otsu_thresh = band_ink_mask(front_lum, diff, band_mask, bbox) + hair_overlap_px = int((ink_mask_raw & hair_mask).sum()) + region = ink_mask_raw & ~hair_mask + if not region.any(): + raise SystemExit(f"[중단] {side_label} 눈썹 영역이 비었다(머리카락 제거 후 잉크 픽셀 0).") + + band_lum_faceless = faceless_lum[band_mask] + l_skin = float(np.median(band_lum_faceless)) + # 2차 보정: 띠 전체 휘도의 5백분위(극단적으로 어두운 꼬리값) 대신, 실제로 잉크로 + # 분류된 픽셀(region, 머리카락 제거 후)의 휘도 중앙값을 쓴다 — 그보다 어두운 + # 잉크 픽셀은 a=1이 된다(목표: 눈썹 알파>0 영역 평균절대차 <=10). + l_ink = float(np.median(front_lum[region])) + denom = (l_skin - l_ink) if abs(l_skin - l_ink) > 1e-6 else 1e-6 + + dist_in = ndimage.distance_transform_edt(region) + feather = np.clip(dist_in / RAMP_PX, 0.0, 1.0) + density = np.clip((l_skin - front_lum) / denom, 0.0, 1.0) + alpha_frac = np.where(region, density * feather, 0.0) + alpha = np.rint(alpha_frac * 255.0).astype(np.uint8) + + skin_rgb = faceless_rgb[band_mask].mean(axis=0) + ink_sample_mask = band_mask & (front_lum <= l_ink) + ink_fallback_rgb = ( + np.median(front_rgb[ink_sample_mask], axis=0) if ink_sample_mask.any() else np.array([0.0, 0.0, 0.0]) + ) + + a3 = alpha_frac[..., None] + unmixed = (front_rgb - (1.0 - a3) * skin_rgb.reshape(1, 1, 3)) / np.maximum(a3, 1e-6) + unmixed = np.clip(unmixed, 0.0, 255.0) + rgb = np.where(a3 >= MIN_ALPHA_FOR_UNMIX, unmixed, ink_fallback_rgb.reshape(1, 1, 3)) + + print( + f" [{side_label}] 띠 반높이={half_height:.2f}px otsu={otsu_thresh:.1f} " + f"L_skin={l_skin:.2f} L_ink={l_ink:.2f} 영역픽셀={int(region.sum())} " + f"앞머리겹침픽셀={hair_overlap_px}" + ) + + return { + "alpha": alpha, "rgb": rgb, "region": region, "bandMask": band_mask, "bbox": bbox, + "halfHeight": half_height, "otsuThresh": otsu_thresh, "lSkin": l_skin, "lInk": l_ink, + "hairOverlapPx": hair_overlap_px, "regionPixelCount": int(region.sum()), + } + + +# --- 중립 합성 검사(기존 레이어 위에 스프라이트를 얹어 원화와 비교) ------------ + + +def _load_rgba(path: Path) -> tuple[np.ndarray, np.ndarray]: + arr = np.array(Image.open(path).convert("RGBA")).astype(np.float64) + return arr[..., :3], arr[..., 3] + + +def composite_with_sprites(layers_v2_dir: Path, canvas_w: int, canvas_h: int, sprites: dict[str, dict]) -> np.ndarray: + """body→head→face-detail(이미 눈썹 자리를 faceless로 메워 둔 상태) 위에 + 눈썹 스프라이트를 얹고, 그 위에 hairFront를 덮는다 — LinocutAvatar.tsx의 + 그리는 순서(브로우는 face 그룹 안, hairFront보다 먼저)와 같다.""" + canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64) + canvas[..., 0] = CREAM_BG[0] + canvas[..., 1] = CREAM_BG[1] + canvas[..., 2] = CREAM_BG[2] + canvas[..., 3] = 255.0 + for stem in ("body", "head", "face-detail"): + p = layers_v2_dir / f"{stem}.png" + if p.exists(): + rgb, a = _load_rgba(p) + canvas = composite_over(canvas, to_u8(rgb), to_u8(a)) + for data in sprites.values(): + canvas = composite_over(canvas, to_u8(data["rgb"]), data["alpha"]) + hf_path = layers_v2_dir / "hairFront.png" + if hf_path.exists(): + hf_rgb, hf_a = _load_rgba(hf_path) + canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a)) + return canvas[..., :3] + + +def _save_evidence(front_rgb: np.ndarray, sprites: dict[str, dict], composite_rgb: np.ndarray, preview_v2_dir: Path) -> None: + """옆 쪽마다 원화 | 중립 합성(스프라이트 포함) | 스프라이트(체커 배경) 3열, + 3배 확대(lip_texture.py _save_evidence와 같은 방식).""" + rows = [] + for side_label, data in sprites.items(): + x0, y0, x1, y1 = data["bbox"] + pad = 10 + bx0, by0 = max(0, x0 - pad), max(0, y0 - pad) + bx1, by1 = x1 + pad, y1 + pad + + def zoom(arr: np.ndarray) -> Image.Image: + crop = arr[by0:by1, bx0:bx1] + im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8)) + return im.resize((im.width * 3, im.height * 3), Image.LANCZOS) + + panel_orig = zoom(front_rgb).convert("RGB") + panel_composite = zoom(composite_rgb).convert("RGB") + + alpha = data["alpha"] + rgb = data["rgb"] + crop_rgb = rgb[by0:by1, bx0:bx1] + crop_a = (alpha[by0:by1, bx0:bx1].astype(np.float64) / 255.0)[..., None] + yy, xx = np.indices(crop_a.shape[:2]) + checker = ((yy // 6 + xx // 6) % 2) * 60 + 180 + checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64) + sprite_view = crop_rgb * crop_a + checker3 * (1 - crop_a) + panel_sprite = Image.fromarray(np.clip(sprite_view, 0, 255).astype(np.uint8)).resize( + (panel_orig.width, panel_orig.height), Image.NEAREST + ) + + gap = 12 + row = Image.new("RGB", (panel_orig.width * 3 + gap * 2, panel_orig.height), (255, 255, 255)) + row.paste(panel_orig, (0, 0)) + row.paste(panel_composite, (panel_orig.width + gap, 0)) + row.paste(panel_sprite, (panel_orig.width * 2 + gap * 2, 0)) + rows.append(row) + + gap_y = 12 + max_w = max(r.width for r in rows) + total_h = sum(r.height for r in rows) + gap_y * (len(rows) - 1) + combined = Image.new("RGB", (max_w, total_h), (255, 255, 255)) + y = 0 + for row in rows: + combined.paste(row, (0, y)) + y += row.height + gap_y + scale = min(1.0, 1400 / 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) + out_path = preview_v2_dir / "brow-sprite.jpg" + combined.save(out_path, "JPEG", quality=92) + print(f"저장: {out_path}") + + +# --- 눈꺼풀 선 굵기(lineScale) ------------------------------------------------- + + +def measure_upper_lid_thickness(eye: dict, front_lum: np.ndarray) -> tuple[float, int]: + """눈 폭(innerCorner~outerCorner) 가운데 60% 열마다, upperLidTop.y-12에서 + 홍채 중심 y 사이의 첫 연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다.""" + inner_x = eye["innerCorner"][0] + outer_x = eye["outerCorner"][0] + x_lo, x_hi = sorted([inner_x, outer_x]) + width = x_hi - x_lo + margin = (1.0 - UPPER_LID_COLUMN_MIDDLE_FRAC) / 2.0 * width + col_lo = x_lo + margin + col_hi = x_hi - margin + y_top = eye["upperLidTop"][1] - UPPER_LID_WINDOW_ABOVE_PX + y_bottom = eye["iris"]["center"][1] + + lengths: list[int] = [] + for cx in range(int(round(col_lo)), int(round(col_hi)) + 1): + col = front_lum[int(round(y_top)):int(round(y_bottom)), cx] + dark = col < UPPER_LID_DARK_LUM_THRESH + idx = np.where(dark)[0] + if len(idx) == 0: + continue + start = int(idx[0]) + length = 1 + i = start + while i + 1 < len(dark) and dark[i + 1]: + length += 1 + i += 1 + lengths.append(length) + if not lengths: + return float("nan"), 0 + return float(np.median(lengths)), len(lengths) + + +# --- 홍채 실측(T3) ------------------------------------------------------------- + + +def _contiguous_dark_run(values: np.ndarray, center_idx: int, thresh: float) -> tuple[int, int] | None: + """1차원 휘도 배열에서 center_idx를 포함하는 연속 어두운(값= n or values[center_idx] >= thresh: + return None + s = center_idx + while s - 1 >= 0 and values[s - 1] < thresh: + s -= 1 + e = center_idx + while e + 1 < n and values[e + 1] < thresh: + e += 1 + return s, e + + +def measure_iris(eye: dict, front_lum: np.ndarray, side_label: str) -> dict: + """홍채(잉크 원판)의 실측 중심·반지름. 세로축은 윗눈꺼풀 잉크와 홍채가 같은 + 톤으로 이어져(판화 특성상 경계가 없다) 못 가르므로, 가로축(중심 y 부근 행마다 + 중심 x를 포함하는 연속 어두운 구간)만으로 반지름·중심 x를 재고, 중심 x 부근 + 열마다 중심 y를 포함하는 연속 어두운 구간의 아래쪽 끝(중앙값)에서 그 가로 + 반지름을 빼 중심 y를 역산한다(원이라고 가정).""" + cx, cy = eye["iris"]["center"] + cxi, cyi = int(round(cx)), int(round(cy)) + + lefts: list[int] = [] + rights: list[int] = [] + for dy in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1): + row = front_lum[cyi + dy, :] + seg = _contiguous_dark_run(row, cxi, IRIS_DARK_LUM_THRESH) + if seg is not None: + lefts.append(seg[0]) + rights.append(seg[1]) + if not lefts: + raise SystemExit(f"[중단] {side_label} 홍채 가로 실측 — 중심 부근 행에서 어두운 구간을 찾지 못했다.") + left = float(np.median(lefts)) + right = float(np.median(rights)) + radius = (right - left) / 2.0 + measured_cx = (right + left) / 2.0 + + bottoms: list[int] = [] + for dx in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1): + col = front_lum[:, cxi + dx] + seg = _contiguous_dark_run(col, cyi, IRIS_DARK_LUM_THRESH) + if seg is not None: + bottoms.append(seg[1]) + if not bottoms: + raise SystemExit(f"[중단] {side_label} 홍채 세로 실측 — 중심 부근 열에서 어두운 구간을 찾지 못했다.") + bottom = float(np.median(bottoms)) + measured_cy = bottom - radius + + rig_radius = float(eye["iris"]["radius"]) + print( + f" [{side_label}] 홍채 실측: center=({measured_cx:.1f},{measured_cy:.1f}) radius={radius:.2f} " + f"(리그 center=({cx:.1f},{cy:.1f}) radius={rig_radius:.2f}, 차이 radius={radius - rig_radius:+.2f}, " + f"표본 가로 {len(lefts)}행/세로 {len(bottoms)}열)" + ) + return { + "measuredCenter": [round(measured_cx, 2), round(measured_cy, 2)], + "measuredRadius": round(radius, 2), + "rigCenter": [round(cx, 2), round(cy, 2)], + "rigRadius": round(rig_radius, 2), + "nRowsHorizontal": len(lefts), + "nColsVertical": len(bottoms), + } + + +def main(persona_dir: Path) -> int: + cfg = load_persona_config(persona_dir) + base_dir = cfg.base_dir + layers_v2_dir = cfg.layers_v2_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"] + canvas_w = manifest["canvas"]["w"] + canvas_h = manifest["canvas"]["h"] + + front_rgb = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64) + faceless_rgb = np.array(build_padded_faceless(base_dir)).astype(np.float64) + front_lum = front_rgb.mean(axis=2) + faceless_lum = faceless_rgb.mean(axis=2) + diff = np.clip(faceless_lum - front_lum, 0.0, None) + + browsprite_report: dict | None = None + if cfg.brow_sprite_from_art: + print("=== 눈썹 스프라이트 ===") + bc = manifest.get("browCenterline") + if bc is None: + raise SystemExit("[중단] manifest.browCenterline이 없다 — brow_centerline.py를 먼저 실행하라.") + half_width_by_side = {"left": bc["halfWidthLeft"], "right": bc["halfWidthRight"]} + eyebrow_by_side = {"left": lm["eyebrowLeft"], "right": lm["eyebrowRight"]} + + hairfront_path = layers_v2_dir / "hairFront.png" + if not hairfront_path.exists(): + raise SystemExit(f"[중단] {hairfront_path}가 없다 — layers_v2.py를 먼저 실행하라.") + hairfront_alpha = np.array(Image.open(hairfront_path).convert("RGBA"))[..., 3].astype(np.float64) + if hairfront_alpha.shape != (canvas_h, canvas_w): + raise SystemExit( + f"[중단] layers/v2/hairFront.png 크기({hairfront_alpha.shape[1]}x{hairfront_alpha.shape[0]})가 " + f"캔버스({canvas_w}x{canvas_h})와 다르다." + ) + hair_mask = ndimage.binary_dilation(hairfront_alpha > HAIR_ALPHA_THRESH, iterations=HAIR_DILATE_ITER) + print(f"머리카락 마스크(hairFront 알파>{HAIR_ALPHA_THRESH}, {HAIR_DILATE_ITER}px 팽창) 픽셀 = {int(hair_mask.sum())}") + + sprites: dict[str, dict] = {} + for side in ("left", "right"): + eb = eyebrow_by_side[side] + sprites[side] = build_one_brow_sprite( + tuple(eb["inner"]), tuple(eb["peak"]), tuple(eb["outer"]), + tuple(half_width_by_side[side]), + front_rgb, faceless_rgb, front_lum, faceless_lum, diff, hair_mask, + canvas_w, canvas_h, side, + ) + + composite_rgb = composite_with_sprites(layers_v2_dir, canvas_w, canvas_h, sprites) + diffs: dict[str, float] = {} + for side, data in sprites.items(): + d = np.abs(composite_rgb - front_rgb).mean(axis=2) + # 2차 보정: 띠 전체가 아니라 스프라이트 알파>0 영역(region)만 잰다 — 알파=0인 + # 띠 가장자리 피부 픽셀(원래도 거의 차이가 없다)이 섞여 들어가 잉크 알파가 + # 옅은 문제를 가리던 것을 바로잡는다(목표 <=10). + diffs[side] = float(d[data["region"]].mean()) + print(f" 검사 [{side}] 중립 합성(스프라이트 포함) vs 원화, 눈썹 알파>0 영역 평균절대차 = {diffs[side]:.3f}") + + _save_evidence(front_rgb, sprites, composite_rgb, preview_v2_dir) + + layers_v2_dir.mkdir(parents=True, exist_ok=True) + side_reports: dict[str, dict] = {} + canvas_origin: dict[str, list[float]] = {} + for side, data in sprites.items(): + png_arr, bbox = crop_to_alpha_bbox(data["alpha"], to_u8(data["rgb"])) + out_path = layers_v2_dir / f"brow-{side}.png" + Image.fromarray(png_arr, "RGBA").save(out_path) + file_size = out_path.stat().st_size + canvas_origin[side] = [float(bbox[0]), float(bbox[1])] + print(f"저장: {out_path} bbox(캔버스 절대좌표)={bbox} size={png_arr.shape[1]}x{png_arr.shape[0]} ({file_size} bytes)") + side_reports[side] = { + "bboxCanvas": list(bbox), + "halfHeightPx": round(data["halfHeight"], 3), + "otsuThresh": data["otsuThresh"], + "lSkin": round(data["lSkin"], 3), + "lInk": round(data["lInk"], 3), + "regionPixelCount": data["regionPixelCount"], + "hairOverlapPixelCount": data["hairOverlapPx"], + "neutralCompositeMeanAbsDiffAlphaRegion": round(diffs[side], 3), + "pngFileSizeBytes": file_size, + } + + browsprite_report = { + "enabled": True, + "bandExtraHalfHeightPx": BAND_EXTRA_HALF_HEIGHT_PX, + "bandEndExtendPx": BAND_END_EXTEND_PX, + "diffThresh": DIFF_THRESH, + "closingKernel": list(CLOSING_KERNEL), + "minComponentAreaPx": MIN_COMPONENT_AREA, + "hairSource": "layers/v2/hairFront.png alpha(게시되는 레이어 그 자체, 분할 카테고리 아님)", + "hairAlphaThresh": HAIR_ALPHA_THRESH, + "hairDilateIterations": HAIR_DILATE_ITER, + "rampPx": RAMP_PX, + "inkLumMethod": "median(ink-classified pixels in band, region after hair removal)", + "minAlphaForUnmix": MIN_ALPHA_FOR_UNMIX, + "hairMaskPixelCount": int(hair_mask.sum()), + "left": side_reports["left"], + "right": side_reports["right"], + "canvasOrigin": canvas_origin, + "evidenceImage": "preview/v2/brow-sprite.jpg", + } + else: + print("browSpriteFromArt=false — 눈썹 스프라이트를 건너뛴다.") + browsprite_report = {"enabled": False} + + eye_line_scale_report: dict | None = None + if cfg.eye_line_scale_from_art: + print("=== 눈꺼풀 선 굵기(lineScale) ===") + side_reports = {} + for side, key in (("left", "eyeLeft"), ("right", "eyeRight")): + eye = lm[key] + measured_px, n_cols = measure_upper_lid_thickness(eye, front_lum) + if n_cols == 0: + raise SystemExit(f"[중단] {key} 윗눈꺼풀 두께 측정 열이 하나도 없다(모든 열에서 lum<80 구간을 못 찾음).") + ratio = measured_px / P1_UPPER_LID_THICKNESS_PX + line_scale = float(np.clip(ratio, LINE_SCALE_MIN, LINE_SCALE_MAX)) + print( + f" [{key}] 측정={measured_px:.3f}px(열 {n_cols}개) / P1기준={P1_UPPER_LID_THICKNESS_PX}px " + f"= 비율 {ratio:.3f} -> lineScale={line_scale:.3f}" + + (" [clamp 적용됨]" if not (LINE_SCALE_MIN <= ratio <= LINE_SCALE_MAX) else "") + ) + side_reports[side] = { + "measuredPx": round(measured_px, 3), "nColumns": n_cols, + "ratio": round(ratio, 3), "lineScale": round(line_scale, 3), + } + eye_line_scale_report = { + "enabled": True, + "method": "눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채중심y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값", + "windowAbovePx": UPPER_LID_WINDOW_ABOVE_PX, + "darkLumThresh": UPPER_LID_DARK_LUM_THRESH, + "columnMiddleFrac": UPPER_LID_COLUMN_MIDDLE_FRAC, + "p1BaselinePx": P1_UPPER_LID_THICKNESS_PX, + "lineScaleClampRange": [LINE_SCALE_MIN, LINE_SCALE_MAX], + "left": side_reports["left"], + "right": side_reports["right"], + } + else: + print("eyeLineScaleFromArt=false — 눈꺼풀 lineScale을 건너뛴다.") + eye_line_scale_report = {"enabled": False} + + # 홍채 실측(T3)은 browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상 재 + # manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 설정과 무관하게 항상 + # 기록" 원칙). export_rig.py가 persona.json irisFromArt(기본 true)일 때만 쓴다. + print("=== 홍채 실측(T3) ===") + iris_report = { + "method": ( + "가로: 중심 y 부근 상하 각 4행에서 중심 x를 포함하는 연속 어두운(lum<100) 구간의 " + "중앙값으로 반지름·중심x. 세로: 중심 x 부근 좌우 각 4열에서 중심 y를 포함하는 연속 " + "어두운 구간의 아래쪽 끝 중앙값에서 가로 반지름을 빼 중심y(세로축은 윗눈꺼풀 잉크와 " + "홍채가 같은 톤으로 이어져 위쪽 경계를 가를 수 없다)." + ), + "darkLumThresh": IRIS_DARK_LUM_THRESH, + "scanBandPx": IRIS_SCAN_BAND_PX, + "left": measure_iris(lm["eyeLeft"], front_lum, "eyeLeft"), + "right": measure_iris(lm["eyeRight"], front_lum, "eyeRight"), + } + + manifest["browSprite"] = browsprite_report + manifest["eyeLineScale"] = eye_line_scale_report + manifest["iris"] = iris_report + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"manifest.json 갱신(browSprite, eyeLineScale, iris): {manifest_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(Path(sys.argv[1]))) diff --git a/docs/avatar-art/linocut-pipeline/scripts/export_rig.py b/docs/avatar-art/linocut-pipeline/scripts/export_rig.py index be168cd..5f9647e 100644 --- a/docs/avatar-art/linocut-pipeline/scripts/export_rig.py +++ b/docs/avatar-art/linocut-pipeline/scripts/export_rig.py @@ -59,6 +59,15 @@ LIP_SPECS = [ ] LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 그늘 조각 분리로 3장 +# --- 눈썹 스프라이트(결정문 §8.4.1, 작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기") -- + +BROW_SPECS = [ + # (rig key, 파일 stem, public href stem) + ("left", "brow-left", "brow-left"), + ("right", "brow-right", "brow-right"), +] +BROW_SIZE_BUDGET_BYTES = 60 * 1024 + # --- 턱 조각(결정문 §8.4 하관 띠 변형) --------------------------------------- JAW_SPECS = [ @@ -307,6 +316,123 @@ def publish_lip_texture(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report +def publish_brow_sprite(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]: + """layers/v2/brow-{left,right}.png(brow_sprite.py 산출)를 알파 bbox로 잘라 + WebP로 게시한다(결정문 §8.4.1). 소스가 없으면 (None, None) — + browSpriteFromArt=false인 리그(P1)는 RigBrow.sprite 없이 벡터 눈썹을 그대로 + 쓴다(렌더러 대체 경로). + + PNG는 이미 알파 bbox로 꽉 차게 잘려 있어(brow_sprite.py crop_to_alpha_bbox) + crop_to_bbox는 사실상 no-op이고, 캔버스 위치는 manifest["browSprite"] + ["canvasOrigin"]에서 읽는다(publish_lip_texture와 같은 패턴). 인코딩은 입술 + 스프라이트와 같은 무손실(exact) 우선 — 알파=0 영역도 원화 RGB를 그대로 + 담고 있으므로(brow_sprite.py의 피부색 역산·잉크색 대체) exact=True로 + 버려지지 않게 한다.""" + canvas_origin = manifest.get("browSprite", {}).get("canvasOrigin") + if canvas_origin is None: + return None, None + + sources: dict[str, tuple[Path, str]] = {} + for key, stem, href_stem in BROW_SPECS: + p = layers_v2_dir / f"{stem}.png" + if not p.exists(): + return None, None + sources[key] = (p, href_stem) + + cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {} + for key, (path, href_stem) in sources.items(): + im = Image.open(path) + cropped_im, local_bbox = crop_to_bbox(im) + lx0, ly0 = canvas_origin[key] + abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3]) + cropped[key] = (cropped_im, abs_bbox, href_stem) + + BROW_DIFF_TARGET = 2.0 + + def encode_all(quality: int) -> dict[str, bytes]: + out: dict[str, bytes] = {} + for key, (im, _bbox, href_stem) in cropped.items(): + out[href_stem] = encode_webp(im.convert("RGBA"), quality) + return out + + def encode_all_lossless() -> dict[str, bytes]: + out: dict[str, bytes] = {} + for key, (im, _bbox, href_stem) in cropped.items(): + buf = io.BytesIO() + im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True) + out[href_stem] = buf.getvalue() + return out + + def webp_vs_png_diff(encoded: dict[str, bytes]) -> dict[str, float]: + diffs: dict[str, float] = {} + for key, (im, _bbox, href_stem) in cropped.items(): + src_arr = np.array(im.convert("RGBA")).astype(np.float64) + dec_arr = np.array(Image.open(io.BytesIO(encoded[href_stem])).convert("RGBA")).astype(np.float64) + diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3) + return diffs + + lossless_encoded = encode_all_lossless() + lossless_total = sum(len(b) for b in lossless_encoded.values()) + if lossless_total <= BROW_SIZE_BUDGET_BYTES: + chosen, chosen_quality = lossless_encoded, "lossless" + else: + candidates: list[tuple[int, dict[str, bytes], int, dict[str, float]]] = [] + chosen, chosen_quality = None, None + for q in (100, 99, 98, 97, 96, 95): + encoded = encode_all(q) + total = sum(len(b) for b in encoded.values()) + diffs = webp_vs_png_diff(encoded) + candidates.append((q, encoded, total, diffs)) + if total <= BROW_SIZE_BUDGET_BYTES and max(diffs.values()) <= BROW_DIFF_TARGET: + chosen, chosen_quality = encoded, q + break + if chosen is None: + within_budget = [c for c in candidates if c[2] <= BROW_SIZE_BUDGET_BYTES] + pool = within_budget if within_budget else candidates + best = min(pool, key=lambda c: max(c[3].values())) + chosen_quality, chosen, _total, _diffs = best[0], best[1], best[2], best[3] + print( + f" [경고] quality 95~100 중 예산({BROW_SIZE_BUDGET_BYTES}B) AND 평균차<={BROW_DIFF_TARGET} " + f"동시 만족 없음 — quality={chosen_quality}(평균차 최소) 채택" + ) + assert chosen is not None and chosen_quality is not None + + public_dir = cfg.public_dir + public_dir.mkdir(parents=True, exist_ok=True) + file_sizes: dict[str, int] = {} + for href_stem, data in chosen.items(): + out_path = public_dir / f"{href_stem}.webp" + out_path.write_bytes(data) + file_sizes[href_stem] = len(data) + total_bytes = sum(file_sizes.values()) + print(f"눈썹 스프라이트 인코딩: quality={chosen_quality} (무손실 시도 시 합계 {lossless_total} bytes)") + for href_stem, size in file_sizes.items(): + print(f" {href_stem}.webp = {size} bytes") + print( + f"눈썹 스프라이트 합계 = {total_bytes} bytes (예산 {BROW_SIZE_BUDGET_BYTES} bytes) " + f"{'OK' if total_bytes <= BROW_SIZE_BUDGET_BYTES else '[초과]'}" + ) + + webp_vs_png = webp_vs_png_diff(chosen) + for href_stem in file_sizes: + key = next(k for k, (_im, _bbox, hs) in cropped.items() if hs == href_stem) + print(f" 검사 [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}") + + brow_field = {} + for key, (_im, bbox, href_stem) in cropped.items(): + x0, y0, w, h = bbox + brow_field[key] = { + "href": f"{cfg.public_href_prefix}/{href_stem}.webp", + "x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1), + } + publish_report = { + "quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes, + "budgetBytes": BROW_SIZE_BUDGET_BYTES, + "webpVsPngMeanAbsDiff": webp_vs_png, + } + return brow_field, publish_report + + # --- 턱 조각 게시(결정문 §8.4 하관 띠 변형) ---------------------------------- @@ -764,29 +890,49 @@ def compute_mouth_center_from_manifest(manifest: dict) -> tuple[float, float]: # --- landmarks 매핑(manifest 값을 이름만 바꿔 그대로 사용) ----------------- -def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict: +def build_landmarks( + cfg: PersonaConfig, manifest: dict, mouth_center: tuple[float, float], brow_sprite_field: dict | None +) -> dict: lm = manifest["landmarks"] + half_widths = manifest.get("browCenterline", {}) if cfg.brow_half_width_from_art else {} + eye_line_scale = manifest.get("eyeLineScale", {}) if cfg.eye_line_scale_from_art else {} + iris_measured = manifest.get("iris", {}) if cfg.iris_from_art else {} def pt(p) -> list[float]: return [round(p[0], 1), round(p[1], 1)] - def eye(e) -> dict: - return { + def eye(e, line_scale_key: str) -> dict: + iris_m = iris_measured.get(line_scale_key) + if iris_m is not None: + iris_field = {"center": pt(iris_m["measuredCenter"]), "radius": round(float(iris_m["measuredRadius"]), 1)} + else: + iris_field = {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)} + d = { "inner": pt(e["innerCorner"]), "outer": pt(e["outerCorner"]), "upperLidTop": pt(e["upperLidTop"]), "lowerLidBottom": pt(e["lowerLidBottom"]), - "iris": {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)}, + "iris": iris_field, } + line_scale = eye_line_scale.get(line_scale_key, {}).get("lineScale") + if line_scale is not None: + d["lineScale"] = round(float(line_scale), 3) + return d - def brow(b) -> dict: - return {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])} + def brow(b, half_width_key: str, sprite_key: str) -> dict: + d = {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])} + hw = half_widths.get(half_width_key) + if hw is not None: + d["halfWidth"] = [round(float(v), 2) for v in hw] + if brow_sprite_field is not None and brow_sprite_field.get(sprite_key) is not None: + d["sprite"] = brow_sprite_field[sprite_key] + return d return { - "eyeLeft": eye(lm["eyeLeft"]), - "eyeRight": eye(lm["eyeRight"]), - "browLeft": brow(lm["eyebrowLeft"]), - "browRight": brow(lm["eyebrowRight"]), + "eyeLeft": eye(lm["eyeLeft"], "left"), + "eyeRight": eye(lm["eyeRight"], "right"), + "browLeft": brow(lm["eyebrowLeft"], "halfWidthLeft", "left"), + "browRight": brow(lm["eyebrowRight"], "halfWidthRight", "right"), "noseTip": pt(lm["noseTip"]), "mouthCornerLeft": pt(lm["mouthCornerLeft"]), "mouthCornerRight": pt(lm["mouthCornerRight"]), @@ -937,11 +1083,22 @@ def render_rig_ts(cfg: PersonaConfig, rig: dict) -> str: lines.append(f' upperLidTop: {ts_point(e["upperLidTop"])},') lines.append(f' lowerLidBottom: {ts_point(e["lowerLidBottom"])},') lines.append(f' iris: {{ center: {ts_point(e["iris"]["center"])}, radius: {ts_num(e["iris"]["radius"])} }},') + if e.get("lineScale") is not None: + lines.append(f' lineScale: {ts_num(e["lineScale"])},') lines.append(" },") def emit_brow(name, b): + hw = b.get("halfWidth") + hw_str = f", halfWidth: [{ts_num(hw[0])}, {ts_num(hw[1])}, {ts_num(hw[2])}]" if hw else "" + sprite = b.get("sprite") + sprite_str = "" + if sprite: + sprite_str = ( + f', sprite: {{ href: "{sprite["href"]}", x: {ts_num(sprite["x"])}, y: {ts_num(sprite["y"])}, ' + f'w: {ts_num(sprite["w"])}, h: {ts_num(sprite["h"])} }}' + ) lines.append( - f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])} }},' + f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])}{hw_str}{sprite_str} }},' ) emit_eye("eyeLeft", lmk["eyeLeft"]) @@ -1001,7 +1158,12 @@ def main(persona_dir: Path) -> int: for k, v in palette.items(): print(f" {k} = {v}") - landmarks = build_landmarks(manifest, mouth_center) + print("=== 눈썹 스프라이트 게시 ===") + brow_sprite_field, brow_sprite_publish_report = publish_brow_sprite(cfg, manifest, layers_v2_dir) + if brow_sprite_field is None: + print(" brow-left.png/brow-right.png 없음(생략) — 벡터 눈썹을 그대로 쓰는 리그") + + landmarks = build_landmarks(cfg, manifest, mouth_center, brow_sprite_field) face_crop = compute_face_crop(face_oval) print(f"crops.face = {[round(v,1) for v in face_crop]}") @@ -1083,6 +1245,7 @@ def main(persona_dir: Path) -> int: "lipTexturePublish": lip_publish_report, "jawPublish": jaw_publish_report, "motifPublish": motif_publish_report, + "browSpritePublish": brow_sprite_publish_report, "rig": rig, } report_path = cfg.preview_v2_dir / "export-rig-report.json" @@ -1097,8 +1260,14 @@ def main(persona_dir: Path) -> int: if jaw_publish_report is not None and "vsPublishedLayerAbsDiff" in jaw_publish_report: manifest.setdefault("jaw", {})["check1VsPublishedLayerAbsDiff"] = jaw_publish_report["vsPublishedLayerAbsDiff"] manifest["motifPublish"] = motif_publish_report + manifest["browSpritePublish"] = brow_sprite_publish_report + if brow_sprite_publish_report is not None and "webpVsPngMeanAbsDiff" in brow_sprite_publish_report: + manifest.setdefault("browSprite", {})["checkWebpVsPngMeanAbsDiff"] = brow_sprite_publish_report["webpVsPngMeanAbsDiff"] manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish): {manifest_path.relative_to(cfg.repo_root)}") + print( + f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish, browSpritePublish): " + f"{manifest_path.relative_to(cfg.repo_root)}" + ) return 0 diff --git a/docs/avatar-art/linocut-pipeline/scripts/face_detail.py b/docs/avatar-art/linocut-pipeline/scripts/face_detail.py index cc83fbe..8c8d17c 100644 --- a/docs/avatar-art/linocut-pipeline/scripts/face_detail.py +++ b/docs/avatar-art/linocut-pipeline/scripts/face_detail.py @@ -39,6 +39,7 @@ SCRIPTS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPTS_DIR)) from segmentation import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over, CREAM_BG # noqa: E402 from persona_config import load_persona_config # noqa: E402 +from export_rig import detect_face_landmarks, compute_face_oval # noqa: E402 — region="face"용 얼굴 오벌(캐스트 확장) # --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) -- REGION_FEATHER_PX = 12.0 @@ -99,6 +100,9 @@ CHECKE_THRESH = 1.5 GENERIC_PATCH_SIZE = 48 GENERIC_PATCH_MARGIN = 40 +# --- matchFacelessColor(faceDetail.matchFacelessColor) 상수 ----------------- +MATCH_RING_LUM_THRESH = 110.0 # 고리 표본에서 이보다 어두운(잉크) front 픽셀은 뺀다 + def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray: yy, xx = np.mgrid[0:h, 0:w].astype(np.float64) @@ -108,6 +112,26 @@ def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, fe return np.clip(alpha, 0, 255) +def face_oval_region_alpha( + base_front_path: Path, inset_px: float, w: int, h: int, feather: float = REGION_FEATHER_PX +) -> tuple[np.ndarray, np.ndarray, int, int]: + """base-front.png 랜드마크로 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은 + 36점 루프·1.04 배율)을 구해 inset_px만큼 안쪽으로 줄인 뒤 feather px로 페더링한다. + 원화 윤곽 잉크선은 오벌 경계 근방(jaw_pieces.py OVAL_OUTSET_PX=18px 안팎)에 있으므로, + inset_px를 충분히 크게 잡아야 이 잉크선이 반환 영역(알파>0) 밖에 남는다 — 호출자가 + persona.json faceDetail.faceInsetPx로 값을 정하고 근거를 보고해야 한다. + 반환: (알파, 원본 오벌 불리언 마스크, 오벌 픽셀 수, inset 후 픽셀 수).""" + points = detect_face_landmarks(base_front_path) + oval_pts = compute_face_oval(points) + oval_img = Image.new("L", (w, h), 0) + ImageDraw.Draw(oval_img).polygon(oval_pts, fill=255) + oval_mask = np.array(oval_img) > 127 + dist_in = distance_transform_edt(oval_mask) + inset_mask = oval_mask & (dist_in >= inset_px) + alpha = np.clip(gaussian_filter(inset_mask.astype(np.float64) * 255.0, sigma=feather / 2.0), 0, 255) + return alpha, oval_mask, int(oval_mask.sum()), int(inset_mask.sum()) + + def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray: img = Image.new("L", (w, h), 0) d = ImageDraw.Draw(img) @@ -453,7 +477,18 @@ def main(persona_dir: Path) -> int: mcx, mcy = mole["center"] r = float(mole["radius"]) a_mole = np.maximum(a_mole, ellipse_alpha(mcx, mcy, r, r, w, h, REGION_FEATHER_PX)) - region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole)) + + if cfg.face_detail_region == "face": + a_face, oval_mask, oval_px, inset_px_count = face_oval_region_alpha( + base_dir / "base-front.png", cfg.face_inset_px, w, h + ) + region_alpha = np.maximum(a_face, a_mole) + print( + f"faceDetail.region=face: faceInsetPx={cfg.face_inset_px} " + f"오벌픽셀={oval_px} inset후픽셀={inset_px_count}" + ) + else: + region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole)) # ------------------------------------------------------------------ # 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창 @@ -521,7 +556,20 @@ def main(persona_dir: Path) -> int: # 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다 # ------------------------------------------------------------------ category_mask = run_segmentation(base_dir / "base-faceless-padded.png") - hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX) + hair_mask_raw = category_mask == 1 + if cfg.face_detail_region == "face": + # region="face"는 얼굴 피부 전체에 원화(front) 픽셀을 노출하므로, front와 + # faceless의 앞머리 가닥 위치가 달라(codex 편집본 시차) faceless 분할 하나만 + # 쓰면 front 쪽 잔머리가 머리카락 마스크 밖으로 나와 유령 가닥이 생긴다 — + # 두 그림의 분할 합집합으로 덮는다. + front_category_mask = run_segmentation(base_dir / "base-front.png") + hair_mask_front_raw = front_category_mask == 1 + hair_mask_raw = hair_mask_raw | hair_mask_front_raw + print( + f"머리카락 분할(합집합): faceless={int((category_mask == 1).sum())}px " + f"front={int(hair_mask_front_raw.sum())}px union={int(hair_mask_raw.sum())}px" + ) + hair_mask = binary_dilation(hair_mask_raw, iterations=HAIR_DILATE_PX) print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}") # ------------------------------------------------------------------ @@ -535,6 +583,31 @@ def main(persona_dir: Path) -> int: for r in stamp_report: print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}") + # ------------------------------------------------------------------ + # matchFacelessColor: 제외 영역(눈·눈썹·입) 안의 F 색을 주변 원화 피부에 맞춘다. + # 영역마다 RING_PX 바깥 고리(어두운 잉크 제외)에서 front·F 평균 RGB 차를 구해 + # 그 영역 전체의 F를 그 차이만큼 평행이동한다 — 보간·블러·패치 합성은 하지 않는다. + # ------------------------------------------------------------------ + match_color_report: dict[str, list[float]] = {} + if cfg.match_faceless_color: + print("=== matchFacelessColor: 영역별 ΔRGB(front - F, 안쪽 고리 표본) ===") + deltas: dict[str, np.ndarray] = {} + for name, region_mask in named_excl.items(): + ring = binary_dilation(region_mask, iterations=RING_PX) & ~exclusion_mask & ~hair_mask + sample_mask = ring & (lum_front >= MATCH_RING_LUM_THRESH) + if not sample_mask.any(): + deltas[name] = np.zeros(3) + print(f" {name}: 고리 표본 없음 — 보정 생략") + continue + front_mean = front[sample_mask].mean(axis=0) + f_mean = f_clean[sample_mask].mean(axis=0) + deltas[name] = front_mean - f_mean + d = deltas[name] + print(f" {name}: ΔRGB=({d[0]:+.2f},{d[1]:+.2f},{d[2]:+.2f}) 표본={int(sample_mask.sum())}px") + for name, region_mask in named_excl.items(): + f_clean[region_mask] = np.clip(f_clean[region_mask] + deltas[name], 0, 255) + match_color_report = {name: [round(float(c), 2) for c in d] for name, d in deltas.items()} + # ------------------------------------------------------------------ # 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간 # 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는 @@ -714,6 +787,10 @@ def main(persona_dir: Path) -> int: manifest.setdefault("layersV2", {}) manifest["layersV2"]["faceDetail"] = { "browLandmarksSource": brow_source, + "region": cfg.face_detail_region, + "faceInsetPx": cfg.face_inset_px if cfg.face_detail_region == "face" else None, + "matchFacelessColor": cfg.match_faceless_color, + "matchFacelessColorDeltaRgb": match_color_report, "regions": { "eyeLeft": [round(bx0, 1), round(by0, 1), round(bx1, 1), round(by1, 1)], "eyeRight": [round(rx0, 1), round(ry0, 1), round(rx1, 1), round(ry1, 1)], diff --git a/docs/avatar-art/linocut-pipeline/scripts/normalize_base.py b/docs/avatar-art/linocut-pipeline/scripts/normalize_base.py new file mode 100644 index 0000000..8d03598 --- /dev/null +++ b/docs/avatar-art/linocut-pipeline/scripts/normalize_base.py @@ -0,0 +1,249 @@ +"""공통 리노컷 리그 — 캐스트 확장 0단계: 원화 3장을 캔버스로 정규화. + +입력 front/faceless/body는 모두 같은 좌표계(후보 원화) 위에 있다고 전제한다 +(T1 register_edit.py가 faceless·body를 원화 좌표계로 이미 정렬해 둔 출력을 +그대로 받는다). 셋 모두에 **같은 아핀 변환 하나**(등방 배율 + 평행이동, +회전 없음)를 적용해 캔버스 1005x1566(결정문 §8.4)으로 옮긴다. + +기준은 P1 base-front.png의 랜드마크다(눈 중심 두 점의 중점, 두 눈 사이 거리, +턱끝) — 머리 크기·위치가 P1과 같은 비율이 되게 새 원화의 배율·이동을 定한다. +mediapipe FaceLandmarker 인덱스: 눈A=(33,133) 중점, 눈B=(263,362) 중점, 턱=152 +(landmarks.py의 EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다). + +배율: s_head = (P1 눈-턱 거리/원화 눈-턱 거리 + P1 눈 사이 거리/원화 눈 사이 +거리) / 2. s = max(s_head, 캔버스폭/원화폭) — 원화가 머리 비율상 더 작게 +배율돼도 캔버스 폭은 항상 덮어야 한다. + +이동: 두 눈 중점을 P1 두 눈 중점에 맞춘다. 가로 오프셋은 배율된 원화가 캔버스 +폭을 꼭 덮도록 [캔버스폭 − 원화폭·s, 0] 구간으로 자른다(좌우 빈틈 방지). +세로 오프셋은 자르지 않는다 — 위/아래로 남는 빈 곳은 각 이미지 종류에 맞게 +채운다(front·faceless: 원본 가장자리 띠 반사 패딩, body: #00ff00). + +사용: normalize_base.py --front <원화> --faceless <정렬된 faceless> --body <정렬된 body> +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image + +SCRIPTS_DIR = Path(__file__).resolve().parent + +CANVAS_W = 1005 +CANVAS_H = 1566 +TOP_PAPER_BAND_PX = 8 # 위쪽 채움 색을 뽑을 원화 맨 위 행 수(머리카락은 14행부터 나온다) +P1_REFERENCE_RELATIVE = Path("docs/avatar-art/p1-linocut/base/base-front.png") + +# mediapipe FaceLandmarker 인덱스(landmarks.py EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다). +EYE_A_IDX = (33, 133) +EYE_B_IDX = (263, 362) +CHIN_IDX = 152 + + +def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]: + """FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유).""" + with tempfile.TemporaryDirectory() as td: + out_json = Path(td) / "landmarks.json" + proc = subprocess.run( + [sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)], + capture_output=True, text=True, + ) + print(proc.stdout.strip()) + if proc.returncode != 0 or not out_json.exists(): + raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}") + data = json.loads(out_json.read_text(encoding="utf-8")) + if not data.get("ok"): + raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.") + return [(p[0], p[1]) for p in data["points"]] + + +def midpoint(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]: + return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) + + +def dist(a: tuple[float, float], b: tuple[float, float]) -> float: + return float(np.hypot(a[0] - b[0], a[1] - b[1])) + + +def measure_head(points: list[tuple[float, float]]) -> dict: + eye_a = midpoint(points[EYE_A_IDX[0]], points[EYE_A_IDX[1]]) + eye_b = midpoint(points[EYE_B_IDX[0]], points[EYE_B_IDX[1]]) + eyes_center = midpoint(eye_a, eye_b) + chin = points[CHIN_IDX] + return { + "eyeA": eye_a, "eyeB": eye_b, "eyesCenter": eyes_center, "chin": chin, + "eyeDistance": dist(eye_a, eye_b), + "eyeChinDistance": dist(eyes_center, chin), + } + + +def measure_report(head: dict) -> dict: + return { + "eyesCenter": [round(head["eyesCenter"][0], 2), round(head["eyesCenter"][1], 2)], + "chin": [round(head["chin"][0], 2), round(head["chin"][1], 2)], + "eyeDistance": round(head["eyeDistance"], 3), + "eyeChinDistance": round(head["eyeChinDistance"], 3), + } + + +def clamp(v: float, lo: float, hi: float) -> float: + return max(lo, min(hi, v)) + + +def sha256_of(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="원화 3장(front/faceless/body)을 캔버스 1005x1566으로 정규화한다.") + parser.add_argument("persona_dir", help="페르소나 폴더(예: docs/avatar-art/p2-linocut)") + parser.add_argument("--front", required=True, help="후보 원화(정면 기본형)") + parser.add_argument("--faceless", required=True, help="원화 좌표계로 정렬된 얼굴 없는 기본형(T1 출력)") + parser.add_argument("--body", required=True, help="원화 좌표계로 정렬된 body 편집본(T1 출력)") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + persona_dir = Path(args.persona_dir).resolve() + front_path = Path(args.front).resolve() + faceless_path = Path(args.faceless).resolve() + body_path = Path(args.body).resolve() + + # persona_dir 위치와 무관하게 이 스크립트 파일 기준으로 repo root를 고정한다 + # (persona.json이 아직 없을 수 있는 단계라 persona_config.py의 persona_dir 기준 + # repo_root 계산에 의존하지 않는다): scripts/ -> linocut-pipeline -> avatar-art -> docs -> repo root. + repo_root = SCRIPTS_DIR.parents[3] + ref_path = repo_root / P1_REFERENCE_RELATIVE + if not ref_path.exists(): + raise SystemExit(f"[중단] P1 기준 이미지가 없다: {ref_path}") + + print(f"=== 기준(P1) 랜드마크: {ref_path} ===") + ref_head = measure_head(detect_face_landmarks(ref_path)) + print( + f" eyesCenter={ref_head['eyesCenter']} eyeDistance={ref_head['eyeDistance']:.2f} " + f"eyeChinDistance={ref_head['eyeChinDistance']:.2f}" + ) + + print(f"=== 입력 원화 랜드마크: {front_path} ===") + front_head = measure_head(detect_face_landmarks(front_path)) + print( + f" eyesCenter={front_head['eyesCenter']} eyeDistance={front_head['eyeDistance']:.2f} " + f"eyeChinDistance={front_head['eyeChinDistance']:.2f}" + ) + + front_img = Image.open(front_path).convert("RGB") + front_w, front_h = front_img.size + + s_head = ( + ref_head["eyeChinDistance"] / front_head["eyeChinDistance"] + + ref_head["eyeDistance"] / front_head["eyeDistance"] + ) / 2.0 + s_width = CANVAS_W / front_w + s = max(s_head, s_width) + print(f"s_head={s_head:.5f} s_width(캔버스폭/원화폭)={s_width:.5f} -> s={s:.5f}") + + offset_x_natural = ref_head["eyesCenter"][0] - front_head["eyesCenter"][0] * s + offset_x_lo, offset_x_hi = CANVAS_W - front_w * s, 0.0 + offset_x = clamp(offset_x_natural, offset_x_lo, offset_x_hi) + offset_y = ref_head["eyesCenter"][1] - front_head["eyesCenter"][1] * s + print(f"offset_x natural={offset_x_natural:.3f} clamp=[{offset_x_lo:.3f}, {offset_x_hi:.3f}] -> {offset_x:.3f}") + print(f"offset_y={offset_y:.3f} (클램프 없음)") + + covered_x0 = clamp(offset_x, 0.0, CANVAS_W) + covered_x1 = clamp(offset_x + front_w * s, 0.0, CANVAS_W) + covered_y0 = clamp(offset_y, 0.0, CANVAS_H) + covered_y1 = clamp(offset_y + front_h * s, 0.0, CANVAS_H) + print( + f"덮인 범위(반사/채움 아닌 원화 픽셀): x=[{covered_x0:.1f}, {covered_x1:.1f}] " + f"y=[{covered_y0:.1f}, {covered_y1:.1f}] (캔버스 {CANVAS_W}x{CANVAS_H})" + ) + + matrix = np.array([[s, 0.0, offset_x], [0.0, s, offset_y]], dtype=np.float64) + + def warp(src_path: Path, border_mode: int, border_value: tuple[int, int, int] | None) -> np.ndarray: + arr = np.array(Image.open(src_path).convert("RGB")) + kwargs: dict = {"flags": cv2.INTER_LANCZOS4, "borderMode": border_mode} + if border_value is not None: + kwargs["borderValue"] = border_value + return cv2.warpAffine(arr, matrix, (CANVAS_W, CANVAS_H), **kwargs) + + # front·faceless: 빈 곳은 원본 가장자리 띠 반사(BORDER_REFLECT_101 — 가장자리 픽셀을 + # 복제하지 않고 그 안쪽부터 비추어, 복제판(T3 build_padded_faceless류)보다 이음매가 + # 덜 드러난다; 위쪽은 아래에서 종이색으로 덮는다). body: 빈 곳은 크로마키 배경(#00ff00)으로 채운다. + front_out = warp(front_path, cv2.BORDER_REFLECT_101, None) + faceless_out = warp(faceless_path, cv2.BORDER_REFLECT_101, None) + body_out = warp(body_path, cv2.BORDER_CONSTANT, (0, 255, 0)) + + # 위쪽 빈 곳만은 반사하지 않고 원화 위 가장자리의 종이색으로 채운다. 머리카락이 원화 위 + # 끝 가까이(14~42px)까지 오는 후보는 반사하면 머리 위에 뒤집힌 머리카락이 생긴다 + # (2026-10-01 P3·P5 판정). 배경은 분할에서 레이어 밖으로 빠지므로 평평한 색이어도 화면에 + # 드러나지 않는다. 아래쪽은 옷이 이어지는 편이 나아 반사를 유지한다. + top_rows = int(np.ceil(covered_y0)) + if top_rows > 0: + for out, src_path in ((front_out, front_path), (faceless_out, faceless_path)): + band = np.array(Image.open(src_path).convert("RGB"))[:TOP_PAPER_BAND_PX].reshape(-1, 3) + paper = band[band.mean(axis=1) > 150] + out[:top_rows] = np.median(paper if len(paper) else band, axis=0).astype(np.uint8) + + base_dir = persona_dir / "base" + raw_dir = persona_dir / "raw" + base_dir.mkdir(parents=True, exist_ok=True) + raw_dir.mkdir(parents=True, exist_ok=True) + + front_out_path = base_dir / "base-front.png" + faceless_out_path = base_dir / "base-faceless.png" + body_out_path = raw_dir / "body.png" + Image.fromarray(front_out, "RGB").save(front_out_path) + Image.fromarray(faceless_out, "RGB").save(faceless_out_path) + Image.fromarray(body_out, "RGB").save(body_out_path) + print(f"저장: {front_out_path}") + print(f"저장: {faceless_out_path}") + print(f"저장: {body_out_path}") + + report = { + "canvas": {"w": CANVAS_W, "h": CANVAS_H}, + "reference": {"image": str(ref_path.relative_to(repo_root)), **measure_report(ref_head)}, + "input": { + "front": str(front_path), + "faceless": str(faceless_path), + "body": str(body_path), + "frontSize": [front_w, front_h], + **measure_report(front_head), + }, + "transform": { + "sHead": round(s_head, 6), + "sWidth": round(s_width, 6), + "s": round(s, 6), + "rotationDeg": 0.0, + "offsetXNatural": round(offset_x_natural, 3), + "offsetXClampRange": [round(offset_x_lo, 3), round(offset_x_hi, 3)], + "offsetX": round(offset_x, 3), + "offsetY": round(offset_y, 3), + }, + "coveredRange": { + "x": [round(covered_x0, 1), round(covered_x1, 1)], + "y": [round(covered_y0, 1), round(covered_y1, 1)], + }, + "inputSha256": { + "front": sha256_of(front_path), + "faceless": sha256_of(faceless_path), + "body": sha256_of(body_path), + }, + } + report_path = base_dir / "normalize.json" + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"저장: {report_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/docs/avatar-art/linocut-pipeline/scripts/persona_config.py b/docs/avatar-art/linocut-pipeline/scripts/persona_config.py index 1af04eb..f8fb563 100644 --- a/docs/avatar-art/linocut-pipeline/scripts/persona_config.py +++ b/docs/avatar-art/linocut-pipeline/scripts/persona_config.py @@ -37,6 +37,7 @@ DEFAULT_BACKDROP = { "energy": "#E2E0D0", } DEFAULT_BUST_Y_OFFSET = 40.0 +DEFAULT_FACE_INSET_PX = 8.0 @dataclass @@ -70,6 +71,32 @@ class PersonaConfig: # 단위 회귀가 성립한다. {"browLeft": {...}, "browRight": {...}} 형태(각 # inner/peak/outer), manifest.landmarks.eyebrowLeft/Right와 같은 스키마. brow_landmarks_override: dict | None + # false면 export_rig.py가 RigBrow.halfWidth를 쓰지 않는다(렌더러가 linocutGeometry.ts + # BROW_STOPS 고정값을 쓴다). 기본 true — 원화에서 잰 눈썹 반두께(manifest.browCenterline. + # halfWidth{Left,Right})를 쓴다. P1은 이미 고정 굵기로 빌드·검수됐으므로 false로 둔다. + brow_half_width_from_art: bool + # false면 brow_sprite.py가 눈썹 스프라이트(layers/v2/brow-{left,right}.png)를 + # 만들지 않는다(결정문 §8.4.1). 기본 true. P1은 이미 고정 벡터 눈썹으로 + # 빌드·검수됐으므로 false로 둔다. + brow_sprite_from_art: bool + # false면 brow_sprite.py가 눈꺼풀 선 굵기 배율(RigEye.lineScale)을 재지 않는다 + # (결정문 §8.4.1). 기본 true. P1은 lineScale의 기준값 자체(P1_UPPER_LID_THICKNESS_PX)라 + # false로 둔다. + eye_line_scale_from_art: bool + # false면 export_rig.py가 RigEye.iris에 manifest.landmarks(mediapipe)의 값을 그대로 + # 쓴다. 기본 true — brow_sprite.py가 원화에서 실측한 홍채(manifest.iris)를 쓴다 + # (2차 보정 T3: mediapipe iris 랜드마크는 실제 눈 비례로 학습돼 판화 잉크 홍채보다 + # 크게 잡는 경향이 있다). P1은 이미 고정 벡터 렌더로 빌드·검수됐으므로 false로 둔다. + iris_from_art: bool + # faceDetail.region: "features"(기본, 눈·입 주변 타원만) | "face"(얼굴 오벌 전체 — + # 캐스트 확장, 얼굴 피부 해칭 보존). face_detail.py만 쓴다. + face_detail_region: str + # region="face"일 때 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px) — 원화 윤곽 잉크선이 + # 이 영역에 들어오지 않게 하는 여유. region="features"면 쓰이지 않는다. + face_inset_px: float + # true면 face_detail.py가 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의 + # 색을 주변 원화 피부에 맞춰 평행이동한다(보간/블러 없음). 기본 false(P1 그대로). + match_faceless_color: bool def require_pivots(self) -> dict: if self.pivots is None: @@ -183,6 +210,18 @@ def load_persona_config(persona_dir: Path) -> PersonaConfig: face_detail = data.get("faceDetail", {}) moles = face_detail.get("moles", []) brow_landmarks_override = face_detail.get("browLandmarksOverride") + brow_half_width_from_art = data.get("browHalfWidthFromArt", True) + brow_sprite_from_art = data.get("browSpriteFromArt", True) + eye_line_scale_from_art = data.get("eyeLineScaleFromArt", True) + iris_from_art = data.get("irisFromArt", True) + face_detail_region = face_detail.get("region", "features") + if face_detail_region not in ("features", "face"): + raise SystemExit( + f"[중단] {persona_dir.name}/persona.json의 faceDetail.region={face_detail_region!r}는 " + "지원하지 않는다 — \"features\" 또는 \"face\"만 쓴다." + ) + face_inset_px = float(face_detail.get("faceInsetPx", DEFAULT_FACE_INSET_PX)) + match_faceless_color = bool(face_detail.get("matchFacelessColor", False)) pivots = data.get("pivots") crops = data.get("crops", {}) @@ -211,4 +250,11 @@ def load_persona_config(persona_dir: Path) -> PersonaConfig: palette_fixed=palette_fixed, backdrop=backdrop, brow_landmarks_override=brow_landmarks_override, + brow_half_width_from_art=bool(brow_half_width_from_art), + brow_sprite_from_art=bool(brow_sprite_from_art), + eye_line_scale_from_art=bool(eye_line_scale_from_art), + iris_from_art=bool(iris_from_art), + face_detail_region=face_detail_region, + face_inset_px=face_inset_px, + match_faceless_color=match_faceless_color, ) diff --git a/docs/avatar-art/linocut-pipeline/scripts/register_edit.py b/docs/avatar-art/linocut-pipeline/scripts/register_edit.py new file mode 100644 index 0000000..8ac0acc --- /dev/null +++ b/docs/avatar-art/linocut-pipeline/scripts/register_edit.py @@ -0,0 +1,327 @@ +"""공통 리노컷 리그 — 캐스트 확장 0단계: codex 편집본을 원화 좌표계로 정렬. + +ref는 편집의 참조가 된 원본(후보 원화, normalize_base.py 이전 — 아직 캔버스로 +옮기지 않은 원래 크기)이고, edited는 그 ref를 참조해 codex가 편집한 결과다 +(얼굴 없는 기본형 또는 body 재생성). 크기가 다를 수 있다. + +정렬: edited를 ref 크기로 리사이즈한 뒤, cv2.findTransformECC(MOTION_AFFINE)로 +회색조·가우시안 흐림(gaussFiltSize) 위에서 ref 좌표계에 정렬한다. ECC가 보는 +영역은 모드별 마스크로 제한한다(전체 이미지를 다 보면 얼굴 생김새 차이·크로마키 +배경이 정렬을 왜곡한다): + +- faceless: 머리카락·옷·얼굴 윤곽처럼 두 이미지에서 모양이 같아야 하는 곳만 + 본다 — 랜드마크(ref 기준)로 잡은 눈·눈썹·입 상자를 25px 넓혀 뺀 뒤, 평평한 + 배경(지역 표준편차가 작은 곳)도 뺀다. +- body: 턱끝 +20px 아래 행 중 edited(리사이즈본)의 비초록(크로마키 전경) 픽셀만 + 본다 — 그 위는 얼굴이라 body 재생성과 무관하고, 초록 배경은 정렬 정보가 없다. + +출력은 ref 크기로 맞춘 정렬된 편집본 1장이다. ECC로 못 채우는 테두리는 +faceless면 테두리 복제(BORDER_REPLICATE), body면 크로마키 배경(#00ff00)이다. + +사용: register_edit.py --mode faceless|body [--report ] +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image +from scipy.ndimage import distance_transform_edt + +SCRIPTS_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPTS_DIR)) +from segmentation import chroma_key # noqa: E402 — 크로마키 전경/배경 판정 재사용(같은 임계치로 일관성 유지) + +# --- mediapipe FaceLandmarker 인덱스(landmarks.py/segmentation.py와 같다) --- +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} +MOUTH_CORNER_A_IDX = 61 +MOUTH_CORNER_B_IDX = 291 +UPPER_LIP_TOP_IDX = 0 +LOWER_LIP_BOTTOM_IDX = 17 +CHIN_IDX = 152 +FACE_OVAL_LOOP = [ + 10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379, + 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127, + 162, 21, 54, 103, 67, 109, +] +FACE_OVAL_SCALE = 1.04 + +# --- ECC --- +ECC_EXCLUDE_PAD_PX = 25.0 # 눈·눈썹·입 상자를 이만큼 넓혀 ECC 마스크에서 뺀다 +ECC_CHIN_MARGIN_PX = 20.0 # body: 턱끝 + 이만큼 아래부터 본다 +FLAT_BG_WINDOW = 9 # 평평한 배경 판정용 지역 표준편차 창(px) +FLAT_BG_STD_THRESH = 6.0 # 이 미만이면 "평평"(정렬 정보 없음)으로 보고 뺀다 +GREEN_ALPHA_THRESH = 127 # chroma_key 알파 임계치(이상이면 비초록 전경) +ECC_MIN_MASK_PX = 400 # 이보다 적으면 ECC가 의미 있게 수렴하지 않는다 +ECC_GAUSS_FILT_SIZE = 7 # findTransformECC 내부 가우시안 흐림 창(홀수) +ECC_MAX_ITER = 5000 +ECC_EPS = 1e-7 + +# --- 지표 --- +CANNY_BLUR_SIGMA = 1.0 +CANNY_LOW, CANNY_HIGH = 40, 120 +FACE_OVAL_BAND_PX = 12.0 # 얼굴 윤곽 띠 폭(±px) +INK_LUM_THRESH = 70.0 # "어두운 잉크" 휘도 임계치 + + +def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]: + """FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유).""" + with tempfile.TemporaryDirectory() as td: + out_json = Path(td) / "landmarks.json" + proc = subprocess.run( + [sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)], + capture_output=True, text=True, + ) + print(proc.stdout.strip()) + if proc.returncode != 0 or not out_json.exists(): + raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}") + data = json.loads(out_json.read_text(encoding="utf-8")) + if not data.get("ok"): + raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.") + return [(p[0], p[1]) for p in data["points"]] + + +def bbox_of(points: list[tuple[float, float]], idxs: list[int]) -> tuple[float, float, float, float]: + xs = [points[i][0] for i in idxs] + ys = [points[i][1] for i in idxs] + return (min(xs), min(ys), max(xs), max(ys)) + + +def expand_box(box: tuple[float, float, float, float], pad: float, w: int, h: int) -> tuple[int, int, int, int]: + x0, y0, x1, y1 = box + return ( + max(0, int(np.floor(x0 - pad))), max(0, int(np.floor(y0 - pad))), + min(w, int(np.ceil(x1 + pad))), min(h, int(np.ceil(y1 + pad))), + ) + + +def build_face_boxes(points: list[tuple[float, float]]) -> dict[str, tuple[float, float, float, float]]: + """눈(좌/우)·눈썹(좌/우)·입 상자(원화 좌표, 확장 전).""" + mouth_idx = [MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX, UPPER_LIP_TOP_IDX, LOWER_LIP_BOTTOM_IDX] + return { + "eyeA": bbox_of(points, list(EYE_A_IDX.values())), + "eyeB": bbox_of(points, list(EYE_B_IDX.values())), + "browA": bbox_of(points, RIGHT_EYEBROW_IDX), + "browB": bbox_of(points, LEFT_EYEBROW_IDX), + "mouth": bbox_of(points, mouth_idx), + } + + +def flat_background_mask(gray_u8: np.ndarray) -> np.ndarray: + """지역(FLAT_BG_WINDOW) 표준편차가 FLAT_BG_STD_THRESH 미만인 곳 — 평평한 배경(정렬 + 정보가 없는 영역)으로 보고 ECC 포함 마스크에서 뺀다.""" + g = gray_u8.astype(np.float32) + mean = cv2.boxFilter(g, -1, (FLAT_BG_WINDOW, FLAT_BG_WINDOW)) + sqmean = cv2.boxFilter(g * g, -1, (FLAT_BG_WINDOW, FLAT_BG_WINDOW)) + std = np.sqrt(np.clip(sqmean - mean * mean, 0.0, None)) + return std < FLAT_BG_STD_THRESH + + +def face_oval_band_mask(points: list[tuple[float, float]], w: int, h: int, band_px: float) -> np.ndarray: + """랜드마크 face oval(결정문 §8.2, segmentation.py/export_rig.py와 같은 루프·배율) + 경계선에서 band_px 이내인 픽셀.""" + from PIL import ImageDraw + + loop_pts = [points[i] for i in FACE_OVAL_LOOP] + cx = float(np.mean([p[0] for p in loop_pts])) + cy = float(np.mean([p[1] for p in loop_pts])) + scaled = [((x - cx) * FACE_OVAL_SCALE + cx, (y - cy) * FACE_OVAL_SCALE + cy) for x, y in loop_pts] + outline_img = Image.new("L", (w, h), 0) + ImageDraw.Draw(outline_img).polygon(scaled, outline=255, width=1) + boundary = np.array(outline_img) > 0 + dist_to_boundary = distance_transform_edt(~boundary) + return dist_to_boundary <= band_px + + +def chamfer_stats(ref_edges: np.ndarray, aligned_edges: np.ndarray, region_mask: np.ndarray) -> dict: + """aligned_edges 중 region_mask 안에 있는 에지 픽셀들이 ref_edges의 가장 가까운 + 에지까지 떨어진 거리(챔퍼)의 중앙값·p90. ref_edges 쪽 거리장은 호출자가 한 번 + 계산해 재사용하도록 이미 distance_transform_edt를 적용한 배열을 받는다는 점에 + 주의 — 이 함수는 edge 불boolean 배열 두 개를 받아 내부에서 거리장을 계산한다.""" + dist_to_ref_edge = distance_transform_edt(~ref_edges) + sample_mask = aligned_edges & region_mask + n = int(sample_mask.sum()) + if n == 0: + return {"median": None, "p90": None, "nSamples": 0} + d = dist_to_ref_edge[sample_mask] + return {"median": round(float(np.median(d)), 3), "p90": round(float(np.percentile(d, 90)), 3), "nSamples": n} + + +def ink_ratio(gray_u8: np.ndarray, box: tuple[int, int, int, int]) -> float: + x0, y0, x1, y1 = box + region = gray_u8[y0:y1, x0:x1].astype(np.float64) + if region.size == 0: + return float("nan") + return float((region < INK_LUM_THRESH).mean()) + + +def decompose_affine(warp_matrix: np.ndarray) -> dict: + a, b, tx = warp_matrix[0] + c, d, ty = warp_matrix[1] + det = a * d - b * c + scale = float(np.sqrt(abs(det))) + rotation_deg = float(np.degrees(np.arctan2(c, a))) + return { + "scaleSqrtDet": round(scale, 5), + "rotationDeg": round(rotation_deg, 3), + "translateX": round(float(tx), 3), + "translateY": round(float(ty), 3), + "matrix": [[round(float(v), 6) for v in row] for row in warp_matrix.tolist()], + "direction": "ref(template) 좌표 -> edited(resized, input) 좌표. 정렬된 출력은 이 행렬의 역변환(WARP_INVERSE_MAP)으로 만든다.", + } + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="codex 편집본을 ECC 아핀 정렬로 원화(ref) 좌표계에 맞춘다.") + parser.add_argument("ref", help="편집의 참조가 된 원본(원화)") + parser.add_argument("edited", help="codex 편집본(크기가 ref와 다를 수 있다)") + parser.add_argument("out", help="정렬된 출력(ref 크기)") + parser.add_argument("--mode", required=True, choices=["faceless", "body"]) + parser.add_argument("--report", help="지표 JSON 저장 경로(생략 가능, 콘솔에는 항상 출력)") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + ref_path = Path(args.ref).resolve() + edited_path = Path(args.edited).resolve() + out_path = Path(args.out).resolve() + mode = args.mode + + ref_img = Image.open(ref_path).convert("RGB") + w, h = ref_img.size + ref_arr = np.array(ref_img) + ref_gray = cv2.cvtColor(ref_arr, cv2.COLOR_RGB2GRAY) + + edited_img = Image.open(edited_path).convert("RGB") + ew, eh = edited_img.size + print(f"ref 크기={w}x{h} edited 원본 크기={ew}x{eh} (리사이즈 후 ref 크기로 정렬)") + edited_resized = np.array(edited_img.resize((w, h), Image.LANCZOS)) + edited_gray = cv2.cvtColor(edited_resized, cv2.COLOR_RGB2GRAY) + + print("=== ref 랜드마크 검출 ===") + ref_points = detect_face_landmarks(ref_path) + chin_xy = ref_points[CHIN_IDX] + print(f"턱끝(152)={chin_xy}") + + boxes_report: dict[str, list[int]] = {} + if mode == "faceless": + boxes = build_face_boxes(ref_points) + exclude = np.zeros((h, w), dtype=bool) + for name, box in boxes.items(): + x0, y0, x1, y1 = expand_box(box, ECC_EXCLUDE_PAD_PX, w, h) + boxes_report[name] = [x0, y0, x1, y1] + exclude[y0:y1, x0:x1] = True + flat_bg = flat_background_mask(ref_gray) + include = ~exclude & ~flat_bg + print(f"제외 상자(25px 확장): {boxes_report}") + print(f"평평한 배경 제외 픽셀: {int(flat_bg.sum())} ({flat_bg.mean()*100:.1f}%)") + else: + _, edited_alpha = chroma_key(edited_resized) + nongreen = edited_alpha > GREEN_ALPHA_THRESH + chin_line = chin_xy[1] + ECC_CHIN_MARGIN_PX + below_chin = np.zeros((h, w), dtype=bool) + below_chin[int(np.ceil(chin_line)):, :] = True + include = below_chin & nongreen + print(f"턱끝+{ECC_CHIN_MARGIN_PX:.0f}px={chin_line:.1f} 아래 비초록 픽셀: {int(include.sum())}") + + n_include = int(include.sum()) + print(f"ECC 포함 마스크 픽셀 수: {n_include} / {w * h} ({n_include / (w * h) * 100:.2f}%)") + if n_include < ECC_MIN_MASK_PX: + raise SystemExit(f"[중단] ECC 포함 마스크 픽셀이 너무 적다({n_include} < {ECC_MIN_MASK_PX}).") + include_u8 = (include.astype(np.uint8)) * 255 + + warp0 = np.eye(2, 3, dtype=np.float32) + criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, ECC_MAX_ITER, ECC_EPS) + try: + cc, warp_matrix = cv2.findTransformECC( + ref_gray.astype(np.float32), edited_gray.astype(np.float32), + warp0, cv2.MOTION_AFFINE, criteria, include_u8, ECC_GAUSS_FILT_SIZE, + ) + except cv2.error as exc: + raise SystemExit(f"[중단] ECC 정렬이 수렴하지 않았다: {exc}") + print(f"ECC 상관계수={cc:.5f}") + print(f"warp_matrix=\n{warp_matrix}") + + transform_report = decompose_affine(warp_matrix) + print( + f"변환: 배율(sqrt det)={transform_report['scaleSqrtDet']} 회전={transform_report['rotationDeg']}deg " + f"이동=({transform_report['translateX']}, {transform_report['translateY']})" + ) + + border_mode = cv2.BORDER_REPLICATE if mode == "faceless" else cv2.BORDER_CONSTANT + border_value = None if mode == "faceless" else (0, 255, 0) + warp_kwargs: dict = {"flags": cv2.INTER_LANCZOS4 | cv2.WARP_INVERSE_MAP, "borderMode": border_mode} + if border_value is not None: + warp_kwargs["borderValue"] = border_value + aligned = cv2.warpAffine(edited_resized, warp_matrix, (w, h), **warp_kwargs) + + out_path.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(aligned, "RGB").save(out_path) + print(f"저장: {out_path}") + + aligned_gray = cv2.cvtColor(aligned, cv2.COLOR_RGB2GRAY) + ref_edges = cv2.Canny(cv2.GaussianBlur(ref_gray, (0, 0), CANNY_BLUR_SIGMA), CANNY_LOW, CANNY_HIGH) > 0 + aligned_edges = cv2.Canny(cv2.GaussianBlur(aligned_gray, (0, 0), CANNY_BLUR_SIGMA), CANNY_LOW, CANNY_HIGH) > 0 + + mask_chamfer = chamfer_stats(ref_edges, aligned_edges, include) + print( + f"마스크 안 Canny 챔퍼: 중앙값={mask_chamfer['median']} p90={mask_chamfer['p90']} " + f"(n={mask_chamfer['nSamples']})" + + (f" 기준(중앙값<=1.0,p90<=2.5) {'OK' if mask_chamfer['median'] is not None and mask_chamfer['median'] <= 1.0 and mask_chamfer['p90'] <= 2.5 else '[미달]'}" + if mode == "faceless" else + f" 기준(중앙값<=1.5,p90<=3.0) {'OK' if mask_chamfer['median'] is not None and mask_chamfer['median'] <= 1.5 and mask_chamfer['p90'] <= 3.0 else '[미달]'}") + ) + + report: dict = { + "mode": mode, + "ref": str(ref_path), + "edited": str(edited_path), + "out": str(out_path), + "refSize": [w, h], + "editedOriginalSize": [ew, eh], + "eccCorrelation": round(float(cc), 5), + "transform": transform_report, + "eccIncludeMaskPixels": n_include, + "chamferInMask": mask_chamfer, + } + + if mode == "faceless": + oval_band = face_oval_band_mask(ref_points, w, h, FACE_OVAL_BAND_PX) + oval_chamfer = chamfer_stats(ref_edges, aligned_edges, oval_band) + print( + f"얼굴 윤곽 띠(±{FACE_OVAL_BAND_PX:.0f}px) Canny 챔퍼: 중앙값={oval_chamfer['median']} " + f"p90={oval_chamfer['p90']} (n={oval_chamfer['nSamples']}) 기준(p90<=2.0) " + f"{'OK' if oval_chamfer['p90'] is not None and oval_chamfer['p90'] <= 2.0 else '[미달]'}" + ) + report["faceOvalBandChamfer"] = oval_chamfer + + ink_report: dict[str, dict] = {} + for name, box in boxes_report.items(): + ref_ratio = ink_ratio(ref_gray, tuple(box)) + edited_ratio = ink_ratio(aligned_gray, tuple(box)) + ink_report[name] = {"box": box, "refInkRatio": round(ref_ratio, 4), "editedInkRatio": round(edited_ratio, 4)} + print(f" 잉크 비율(lum<{INK_LUM_THRESH:.0f}) [{name}] ref={ref_ratio:.4f} edited={edited_ratio:.4f}") + report["inkRatioByBox"] = ink_report + report["eccExcludeBoxes"] = boxes_report + + if args.report: + report_path = Path(args.report).resolve() + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"저장: {report_path}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py b/docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py index 5fa8cc6..a3fb687 100644 --- a/docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py +++ b/docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py @@ -1,7 +1,7 @@ """공통 리노컷 리그 파이프라인 — 단일 진입점. 페르소나 폴더 하나(예: docs/avatar-art/p1-linocut/, persona.json이 있어야 한다)에 -대해 1~8단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다 +대해 1~11단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다 (mediapipe FaceLandmarker+ImageSegmenter를 같은 프로세스에서 같이 쓰면 세그폴트가 나는 것과 같은 이유로, 모든 단계를 서브프로세스로 분리해 두면 한 단계의 문제가 다른 단계·전체 파이프라인을 오염시키지 않는다). @@ -15,11 +15,12 @@ export-rig-report.json을 읽으므로 반드시 export_rig *다음*이다): 3. segmentation — layers/{body,head,hairFront}.png(v1) + manifest 기준 섹션 4. layers_v2 — layers/v2/{body,head,hairFront}.png(결함 보정) 5. face_detail — layers/v2/face-detail.png - 6. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연) - 7. lip_texture — layers/v2/lip-{upper,lower,shadow}.png - 8. jaw_pieces — layers/v2/jaw-{head,detail}.png - 9. export_rig — WebP 게시 + rigs/Rig.ts 생성 - 10. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기 + 6. brow_sprite — layers/v2/brow-{left,right}.png + manifest.eyeLineScale(결정문 §8.4.1) + 7. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연) + 8. lip_texture — layers/v2/lip-{upper,lower,shadow}.png + 9. jaw_pieces — layers/v2/jaw-{head,detail}.png + 10. export_rig — WebP 게시 + rigs/Rig.ts 생성 + 11. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기 주의(재실행 순서): landmarks를 혼자 다시 돌리면 manifest.landmarks를 통째로 새로 쓰므로, brow_centerline이 보정한 eyebrowLeft/Right가 사라진다. landmarks만 @@ -36,6 +37,7 @@ from __future__ import annotations import argparse import json +import os import subprocess import sys import time @@ -52,6 +54,7 @@ STAGES: list[tuple[str, str]] = [ ("segmentation", "segmentation.py"), ("layers_v2", "layers_v2.py"), ("face_detail", "face_detail.py"), + ("brow_sprite", "brow_sprite.py"), ("paper_grain", "paper_grain.py"), ("lip_texture", "lip_texture.py"), ("jaw_pieces", "jaw_pieces.py"), @@ -64,9 +67,13 @@ STAGE_NAMES = [name for name, _ in STAGES] def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, float]: script_path = SCRIPTS_DIR / script t0 = time.monotonic() + # 단계 스크립트 출력은 UTF-8로 주고받는다. Windows 콘솔 기본(cp949)은 em dash 같은 문자를 + # 못 그려, 모티프 없는 리그 분기(export_rig) 같은 데서 단계가 UnicodeEncodeError로 죽었다 + # (2026-10-01 P7). 부모가 콘솔에 옮겨 쓸 때 못 그리는 문자는 main()에서 대체 문자로 바꾼다. proc = subprocess.run( [sys.executable, "-u", str(script_path), str(persona_dir)], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", errors="replace", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, ) elapsed = time.monotonic() - t0 output = proc.stdout + proc.stderr @@ -75,6 +82,8 @@ def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, flo def main() -> int: + sys.stdout.reconfigure(errors="replace") + sys.stderr.reconfigure(errors="replace") parser = argparse.ArgumentParser(description="공통 리노컷 파이프라인 실행") parser.add_argument("persona_dir", nargs="?", help="페르소나 폴더(예: docs/avatar-art/p1-linocut)") parser.add_argument("--only", choices=STAGE_NAMES, help="이 단계만 실행한다") diff --git a/docs/avatar-art/linocut-pipeline/scripts/segmentation.py b/docs/avatar-art/linocut-pipeline/scripts/segmentation.py index 0095d88..15046af 100644 --- a/docs/avatar-art/linocut-pipeline/scripts/segmentation.py +++ b/docs/avatar-art/linocut-pipeline/scripts/segmentation.py @@ -112,8 +112,18 @@ def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def build_padded_faceless(base_dir: Path) -> Image.Image: bf = Image.open(base_dir / "base-faceless.png").convert("RGB") + front_h = Image.open(base_dir / "base-front.png").size[1] arr = np.array(bf) - padded = np.concatenate([arr, arr[-1:, :, :]], axis=0) + deficit = front_h - arr.shape[0] + if deficit < 0: + raise SystemExit( + f"[중단] base-faceless.png 높이({arr.shape[0]})가 base-front.png 높이({front_h})보다 크다." + ) + if deficit > 0: + pad_rows = np.repeat(arr[-1:, :, :], deficit, axis=0) + padded = np.concatenate([arr, pad_rows], axis=0) + else: + padded = arr out = Image.fromarray(padded, "RGB") out.save(base_dir / "base-faceless-padded.png") return out @@ -233,10 +243,12 @@ def main(persona_dir: Path) -> int: layers_dir.mkdir(parents=True, exist_ok=True) preview_dir.mkdir(parents=True, exist_ok=True) + orig_faceless_h = Image.open(base_dir / "base-faceless.png").size[1] f_img = build_padded_faceless(base_dir) f_arr = np.array(f_img) h, w, _ = f_arr.shape - print(f"F(패딩된 base-faceless) 크기: {w}x{h}") + pad_rows = h - orig_faceless_h + print(f"F(패딩된 base-faceless) 크기: {w}x{h} (원본 {w}x{orig_faceless_h}, 패딩 {pad_rows}행)") front_pts = detect_face_landmarks(base_dir / "base-front.png") chin_y = front_pts[152][1] @@ -442,8 +454,8 @@ def main(persona_dir: Path) -> int: "front": "base/base-front.png", "faceless": "base/base-faceless.png", "facelessPadded": "base/base-faceless-padded.png", - "facelessSize": [w, h - 1], - "padNote": f"base-faceless.png 마지막 행을 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.", + "facelessSize": [w, orig_faceless_h], + "padNote": f"base-faceless.png 마지막 행을 {pad_rows}번 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.", } manifest["segmenter"] = { "model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)", diff --git a/docs/avatar-art/p1-linocut/manifest.json b/docs/avatar-art/p1-linocut/manifest.json index 6bcba08..a85ca63 100644 --- a/docs/avatar-art/p1-linocut/manifest.json +++ b/docs/avatar-art/p1-linocut/manifest.json @@ -13,7 +13,7 @@ 1005, 1565 ], - "padNote": "base-faceless.png 마지막 행을 복제해 1005x1566(base-front.png 크기)으로 패딩한 것이 F다." + "padNote": "base-faceless.png 마지막 행을 1번 복제해 1005x1566(base-front.png 크기)으로 패딩한 것이 F다." }, "layers": [ { @@ -424,6 +424,10 @@ ], "faceDetail": { "browLandmarksSource": "persona.json faceDetail.browLandmarksOverride", + "region": "features", + "faceInsetPx": null, + "matchFacelessColor": false, + "matchFacelessColorDeltaRgb": {}, "regions": { "eyeLeft": [ 309.1, @@ -1439,6 +1443,9 @@ "peakFail": false, "checkA_fracColumnsFitWithinMask": 0.9247, "checkA_pass": true, + "halfWidthInner": 2.389, + "halfWidthPeak": 8.999, + "halfWidthOuter": 3.963, "bandX0": 274, "bandY0": 456, "colMin": 309, @@ -1482,12 +1489,26 @@ "peakFail": false, "checkA_fracColumnsFitWithinMask": 0.9202, "checkA_pass": true, + "halfWidthInner": 5.84, + "halfWidthPeak": 9.499, + "halfWidthOuter": 2.984, "bandX0": 506, "bandY0": 454, "colMin": 513, "colMax": 688 } }, + "halfWidthLeft": [ + 2.389, + 8.999, + 3.963 + ], + "halfWidthRight": [ + 5.84, + 9.499, + 2.984 + ], + "halfWidthNote": "원화 눈썹 잉크 띠 반두께[inner,peak,outer](px, 중심선 법선 방향). export_rig.py가 persona.json browHalfWidthFromArt!=false(기본)면 RigBrow.halfWidth로 내보낸다.", "checkB_innerMinusPeakY": { "browLeft": 14.62, "browRight": 13.47 @@ -1721,59 +1742,84 @@ }, "pipelineRun": { "persona": "P1", - "timestamp": "2026-10-01T11:04:58", + "timestamp": "2026-10-01T21:46:24", "completed": true, "stages": [ - { - "stage": "landmarks", - "ok": true, - "elapsedSec": 1.24 - }, - { - "stage": "brow_centerline", - "ok": true, - "elapsedSec": 0.64 - }, - { - "stage": "segmentation", - "ok": true, - "elapsedSec": 7.84 - }, - { - "stage": "layers_v2", - "ok": true, - "elapsedSec": 12.39 - }, { "stage": "face_detail", "ok": true, - "elapsedSec": 12.25 + "elapsedSec": 15.41 + }, + { + "stage": "brow_sprite", + "ok": true, + "elapsedSec": 1.05 }, { "stage": "paper_grain", "ok": true, - "elapsedSec": 1.91 + "elapsedSec": 2.45 }, { "stage": "lip_texture", "ok": true, - "elapsedSec": 1.92 + "elapsedSec": 2.45 }, { "stage": "jaw_pieces", "ok": true, - "elapsedSec": 1.89 + "elapsedSec": 2.17 }, { "stage": "export_rig", "ok": true, - "elapsedSec": 21.12 + "elapsedSec": 22.95 }, { "stage": "final_previews", "ok": true, - "elapsedSec": 16.83 + "elapsedSec": 19.59 } ] + }, + "browSprite": { + "enabled": false + }, + "eyeLineScale": { + "enabled": false + }, + "browSpritePublish": null, + "iris": { + "method": "가로: 중심 y 부근 상하 각 4행에서 중심 x를 포함하는 연속 어두운(lum<100) 구간의 중앙값으로 반지름·중심x. 세로: 중심 x 부근 좌우 각 4열에서 중심 y를 포함하는 연속 어두운 구간의 아래쪽 끝 중앙값에서 가로 반지름을 빼 중심y(세로축은 윗눈꺼풀 잉크와 홍채가 같은 톤으로 이어져 위쪽 경계를 가를 수 없다).", + "darkLumThresh": 100.0, + "scanBandPx": 4, + "left": { + "measuredCenter": [ + 395.0, + 567.0 + ], + "measuredRadius": 30.0, + "rigCenter": [ + 387.33, + 579.15 + ], + "rigRadius": 21.43, + "nRowsHorizontal": 9, + "nColsVertical": 9 + }, + "right": { + "measuredCenter": [ + 600.0, + 573.0 + ], + "measuredRadius": 22.0, + "rigCenter": [ + 601.93, + 576.21 + ], + "rigRadius": 21.28, + "nRowsHorizontal": 9, + "nColsVertical": 9 + } } } \ No newline at end of file diff --git a/docs/avatar-art/p1-linocut/persona.json b/docs/avatar-art/p1-linocut/persona.json index 1d67318..8ee341d 100644 --- a/docs/avatar-art/p1-linocut/persona.json +++ b/docs/avatar-art/p1-linocut/persona.json @@ -35,6 +35,10 @@ "browRight": { "inner": [531.24, 535.37], "peak": [628.82, 496.05], "outer": [687.99, 522.25] } } }, + "browHalfWidthFromArt": false, + "browSpriteFromArt": false, + "eyeLineScaleFromArt": false, + "irisFromArt": false, "pivots": { "neck": [500, 990], "body": [502, 1566], diff --git a/docs/avatar-art/p1-linocut/preview/v2/export-rig-report.json b/docs/avatar-art/p1-linocut/preview/v2/export-rig-report.json index c35ad66..05d5072 100644 --- a/docs/avatar-art/p1-linocut/preview/v2/export-rig-report.json +++ b/docs/avatar-art/p1-linocut/preview/v2/export-rig-report.json @@ -267,6 +267,7 @@ }, "haloThreshold": 2.0 }, + "browSpritePublish": null, "rig": { "schemaVersion": "vignette.avatar.v3.rig.v1", "persona": "P1",