Compare commits

...

3 commits

Author SHA1 Message Date
Yun Chan
6e3123091a 리노컷 캐스트 확장 결정문과 기준 상태판 동기화 2026-10-01 22:39:08 +09:00
Yun Chan
ded312acdc P2~P7 리노컷 캐스트 원화·리그와 세션 활성화
- 추천 원화(P2 b·P3 v2b·P4 v2b·P5 v2a·P6 v2b·P7 v2b)로 얼굴 없는 기본형·몸 원본 codex 편집, 정렬·정규화 후 공통 파이프라인 실행
- 게시 자산 apps/web/public/avatar/v3/p2~p7, 리그 rigs/p2Rig~p7Rig
- 세션 레지스트리 P1~P7 전원 활성, Lab은 생성된 리그 자동 수집
- P3·P6 기본 눈 감김을 줄여 중립 렌더를 원화 눈매에 맞춤
- 표정 E2E를 v3 렌더러 기대로 갱신(검사 의도 유지)
2026-10-01 22:38:45 +09:00
Yun Chan
98577cf4ae 리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움)
- faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor
- brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측
- 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기
- 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조)
- P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
2026-10-01 22:38:27 +09:00
279 changed files with 21050 additions and 119 deletions

9
.gitignore vendored
View file

@ -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/

View file

@ -53,26 +53,35 @@ test.describe("avatar expression lab", () => {
);
await expect(panel.locator('[data-expression-card="true"]')).toHaveCount(28);
const cardRenderers = panel.locator(
'.axl__card .vg-avatar [data-avatar-renderer="linocut"]',
);
await expect(cardRenderers).toHaveCount(28);
await expect
.poll(
async () =>
cardRenderers.evaluateAll(
(els) => els.filter((el) => el.getAttribute("data-load-state") === "ready").length,
),
{ timeout: 20_000 },
)
.toBe(28);
const sectionMetrics = await panel.evaluate((el) => {
const avatars = Array.from(el.querySelectorAll<HTMLElement>(".axl__card .vg-avatar"));
const invalid = avatars
.map((avatar) => {
const neck = avatar.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]');
const svg = avatar.querySelector("svg");
const renderer = avatar.querySelector<HTMLElement>('[data-avatar-renderer="linocut"]');
const svg = avatar.querySelector(".linocut-avatar");
return {
affect: avatar.getAttribute("data-affect"),
primitives: avatar.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: neck?.getBoundingClientRect().toJSON(),
loadState: renderer?.getAttribute("data-load-state"),
svgBox: svg?.getBoundingClientRect().toJSON(),
};
})
.filter((item) => {
return (
item.primitives < 12 ||
!item.neckBox ||
item.neckBox.width <= 8 ||
item.neckBox.height <= 14 ||
item.loadState !== "ready" ||
!item.svgBox ||
item.svgBox.width <= 0 ||
item.svgBox.height <= 0

View file

@ -197,6 +197,10 @@ test.describe("persona avatar expression rig", () => {
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute("data-affect", persona.expectedExpression);
const v3 = avatar.locator('[data-avatar-renderer="linocut"]');
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
await expect(v3.locator(".linocut-avatar")).toBeVisible();
const metrics = await avatar.evaluate((el) => ({
expressionCount: Number(el.getAttribute("data-expression-count")),
live2dSchema: el.getAttribute("data-live2d-schema"),
@ -207,10 +211,7 @@ test.describe("persona avatar expression rig", () => {
live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")),
renderMode: el.getAttribute("data-render-mode"),
rasterCount: el.querySelectorAll(".vg-raster").length,
primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: el.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]')?.getBoundingClientRect().toJSON(),
svgBox: el.querySelector("svg")?.getBoundingClientRect().toJSON(),
v3Box: el.querySelector(".linocut-avatar")?.getBoundingClientRect().toJSON(),
}));
expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20);
expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1");
@ -225,10 +226,8 @@ test.describe("persona avatar expression rig", () => {
expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20);
expect(metrics.renderMode, `${persona.code} render mode`).toBe("svg");
expect(metrics.rasterCount, `${persona.code} raster rig should be disabled`).toBe(0);
expect(metrics.primitiveCount, `${persona.code} SVG primitives`).toBeGreaterThan(10);
expect(metrics.neckBox?.width, `${persona.code} SVG neck`).toBeGreaterThan(0);
expect(metrics.svgBox?.width, `${persona.code} SVG rig`).toBeGreaterThan(0);
expect(metrics.svgBox?.height, `${persona.code} SVG rig`).toBeGreaterThan(0);
expect(metrics.v3Box?.width, `${persona.code} linocut SVG`).toBeGreaterThan(0);
expect(metrics.v3Box?.height, `${persona.code} linocut SVG`).toBeGreaterThan(0);
}
});
@ -261,9 +260,10 @@ test.describe("persona avatar expression rig", () => {
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-render-mode", "svg");
const activeV3 = activeAvatar.locator('[data-avatar-renderer="linocut"]');
await expect(activeV3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
await expect(activeV3.locator(".linocut-avatar")).toBeVisible();
await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-model", "vignette-p4-live2d");
@ -316,9 +316,10 @@ test.describe("persona avatar expression rig", () => {
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toHaveAttribute("data-render-mode", "svg");
const activeV3 = activeAvatar.locator('[data-avatar-renderer="linocut"]');
await expect(activeV3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
await expect(activeV3.locator(".linocut-avatar")).toBeVisible();
await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await setLearnerUtterance(page, "조금 안정된 것 같아요.");

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

View file

@ -88,9 +88,11 @@ const P2 = demeanor({
speech: { articulation: 0.85, syllablesPerSec: 6.4, gesture: 0.9, gazeAvertProb: 0.5 },
});
/* P3 지우 — 28세 미혼모, 역할 부담, 따뜻하지만 지친 톤. */
/* P3 지우 — 28세 미혼모, 역할 부담, 따뜻하지만 지친 톤.
눈 감김(eyeOpen)은 −0.15에서 −0.05로 줄였다(2026-10-01): 리노컷 원화가 지친 눈매를 이미
그리고 있어 기본 자세로 더 감기면 원화보다 졸린 눈이 된다(결정문 §8.4.1 "중립 = 원화"). */
const P3 = demeanor({
basePose: { eyeOpen: -0.15, eyeSmile: 0.08, shoulderRaise: -0.15 },
basePose: { eyeOpen: -0.05, eyeSmile: 0.08, shoulderRaise: -0.15 },
expressivity: 0.9,
blinkIntervalMs: [2200, 4600],
blinkDurationMs: 140,
@ -143,9 +145,10 @@ const P5 = demeanor({
speech: { articulation: 0.65, syllablesPerSec: 5.8, gesture: 0.6, gazeAvertProb: 0.6 },
});
/* P6 하린 — 고3, 진로갈등, 똑부러지지만 지친 톤. */
/* P6 하린 — 고3, 진로갈등, 똑부러지지만 지친 톤.
눈 감김 기본 자세(−0.08)는 뺐다(2026-10-01, P3과 같은 이유 — 원화의 다크서클이 지침을 맡는다). */
const P6 = demeanor({
basePose: { eyeOpen: -0.08 },
basePose: {},
expressivity: 0.9,
blinkIntervalMs: [2000, 4200],
blinkDurationMs: 120,

View file

@ -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({
/>
<g filter={texture ? `url(#${inkFilterId})` : undefined}>
{/* 눈썹(결정문 §8.4.1): rig.landmarks.browLeft/Right.sprite가 있으면
원화 눈썹 스프라이트를 세로 BROW_STRIP_COUNT개 띠로 나눠 그린다
(벡터 몸통·털 획은 그리지 않는다 — 안쪽 올림 그늘은 아래에서
그대로 그린다). 없으면 기존 벡터 경로를 그린다. */}
{!rig.landmarks.browLeft.sprite ? (
<>
<path data-part="brow-left" ref={setRef(refs.current, "brow-left")} fill={pal.ink} opacity={0.6} />
<path ref={setRef(refs.current, "brow-left-hair")} className="linocut-avatar__brow-hair" stroke={pal.ink} />
</>
) : (
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 (
<g key={`bl${i}`}>
<clipPath id={stripClipId} clipPathUnits="userSpaceOnUse">
<rect x={rect.x} y={rect.y} width={rect.w} height={rect.h} />
</clipPath>
<image
ref={setRef(refs.current, `brow-left-strip-${i}`)}
href={sprite.href}
x={sprite.x}
y={sprite.y}
width={sprite.w}
height={sprite.h}
preserveAspectRatio="none"
clipPath={`url(#${stripClipId})`}
/>
</g>
);
})
)}
{!rig.landmarks.browRight.sprite ? (
<>
<path data-part="brow-right" ref={setRef(refs.current, "brow-right")} fill={pal.ink} opacity={0.6} />
<path ref={setRef(refs.current, "brow-right-hair")} className="linocut-avatar__brow-hair" stroke={pal.ink} />
</>
) : (
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 (
<g key={`br${i}`}>
<clipPath id={stripClipId} clipPathUnits="userSpaceOnUse">
<rect x={rect.x} y={rect.y} width={rect.w} height={rect.h} />
</clipPath>
<image
ref={setRef(refs.current, `brow-right-strip-${i}`)}
href={sprite.href}
x={sprite.x}
y={sprite.y}
width={sprite.w}
height={sprite.h}
preserveAspectRatio="none"
clipPath={`url(#${stripClipId})`}
/>
</g>
);
})
)}
<path data-part="glabella" ref={setRef(refs.current, "glabella")} stroke={pal.ink} className="linocut-avatar__wrinkle" />
<path data-part="forehead-inner" ref={setRef(refs.current, "forehead-inner")} fill={pal.ink} className="linocut-avatar__wrinkle-ribbon" />

View file

@ -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<readonly [number, number]> = [
[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와 같은 패턴. 채널과 무관해 렌더러가 마운트 시 한 번만
계산해 <clipPath>에 쓴다. */
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<readonly [number, number]> = 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 };
}
/* ── 보조 주름 ─────────────────────────────────────────────────────── */

View file

@ -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 {

View file

@ -1,16 +1,50 @@
/* =====================================================================
아바타 v3 리그 레지스트리 — 결정문 §8.5 "분기". 페르소나 코드 → 리노컷
리그. 지금은 P1만 있고 나머지는 null(기존 SVG로 남는다). 빌드 플래그
VITE_AVATAR_V3=0 이면 항상 null(기본은 켜짐).
리그. 세션용(linocutRigFor)은 오케스트레이터가 표정 시트를 판정한 페르소나만
켠다 — 2026-10-01 P1~P7 전원(결정문 §8.4.1). 목록에 없으면 null(기존 SVG).
빌드 플래그 VITE_AVATAR_V3=0 이면 항상 null(기본은 켜짐).
===================================================================== */
import type { LinocutRig } from "../linocutRig";
import { P1_LINOCUT_RIG } from "./p1Rig";
import { P2_LINOCUT_RIG } from "./p2Rig";
import { P3_LINOCUT_RIG } from "./p3Rig";
import { P4_LINOCUT_RIG } from "./p4Rig";
import { P5_LINOCUT_RIG } from "./p5Rig";
import { P6_LINOCUT_RIG } from "./p6Rig";
import { P7_LINOCUT_RIG } from "./p7Rig";
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_LINOCUT_RIG };
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = {
P1: P1_LINOCUT_RIG,
P2: P2_LINOCUT_RIG,
P3: P3_LINOCUT_RIG,
P4: P4_LINOCUT_RIG,
P5: P5_LINOCUT_RIG,
P6: P6_LINOCUT_RIG,
P7: P7_LINOCUT_RIG,
};
export function linocutRigFor(code: string | null | undefined): LinocutRig | null {
if (import.meta.env.VITE_AVATAR_V3 === "0") return null;
if (!code) return null;
return LINOCUT_RIGS[code.toUpperCase()] ?? null;
}
/* Lab(AvatarLab.tsx)용 — 공통 파이프라인(scripts/export_rig.py)이 만드는 ./pN Rig.ts를
전부 모아 rig.persona로 색인한다. 세션 화면(linocutRigFor)과는 무관하다 —
persona.json이 아직 없는 pNRig.ts가 생기는 즉시 Lab 선택지에 나타나지만,
세션에는 영향이 없다(세션은 위 LINOCUT_RIGS 목록만 본다). */
const labRigModules = import.meta.glob("./p*Rig.ts", { eager: true }) as Record<string, Record<string, unknown>>;
export function labLinocutRigs(): Partial<Record<string, LinocutRig>> {
const out: Partial<Record<string, LinocutRig>> = {};
for (const mod of Object.values(labRigModules)) {
for (const exported of Object.values(mod)) {
const rig = exported as Partial<LinocutRig> | null;
if (rig && typeof rig === "object" && rig.schemaVersion === "vignette.avatar.v3.rig.v1" && typeof rig.persona === "string") {
out[rig.persona.toUpperCase()] = rig as LinocutRig;
}
}
}
return out;
}

View file

@ -0,0 +1,86 @@
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P2_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P2",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p2/body.webp", x: 0, y: 751, w: 1005, h: 815 },
head: { href: "/avatar/v3/p2/head.webp", x: 164, y: 84, w: 667, h: 877 },
hairFront: { href: "/avatar/v3/p2/hair-front.webp", x: 240, y: 398, w: 498, h: 460 },
faceDetail: { href: "/avatar/v3/p2/face-detail.webp", x: 274, y: 405, w: 449, h: 550 },
},
grain: { href: "/avatar/v3/p2/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p2/lip-upper.webp", x: 404, y: 772, w: 182, h: 51 },
lower: { href: "/avatar/v3/p2/lip-lower.webp", x: 404, y: 813, w: 181, h: 48 },
shadow: { href: "/avatar/v3/p2/lip-shadow.webp", x: 414, y: 817, w: 155, h: 52 },
},
jaw: {
head: { href: "/avatar/v3/p2/jaw-head.webp", x: 246, y: 658, w: 506, h: 333 },
detail: { href: "/avatar/v3/p2/jaw-detail.webp", x: 246, y: 658, w: 506, h: 333 },
},
pivots: {
neck: [496.4, 1021.2],
body: [502, 1566],
face: [492, 658.9],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [181.6, 364.6, 630.6, 630.6],
},
faceOval: [[492.2, 398.4], [558, 399.3], [614.2, 407.7], [665.7, 426.4], [699, 456.1], [719.9, 494.6], [731.3, 536], [736, 586.9], [734.6, 635.4], [730.5, 685.8], [721.5, 739], [704.8, 795.1], [682.2, 840.4], [657.5, 873.5], [627.1, 903.3], [601, 924.5], [572.8, 943.3], [539.3, 957.3], [496.4, 961.4], [453.8, 957.3], [421, 943.3], [393.2, 924.7], [367.4, 904.2], [338.1, 874.9], [315, 842], [293.7, 797.6], [277.1, 741.8], [267, 688.9], [261.6, 639.6], [257.9, 591.7], [260.7, 541.3], [270.6, 499.5], [290.1, 460.8], [321.9, 430.6], [371.3, 410.5], [426.6, 401]],
landmarks: {
eyeLeft: {
inner: [436.9, 589.7],
outer: [345.9, 578.6],
upperLidTop: [382.5, 563.5],
lowerLidBottom: [387, 595.5],
iris: { center: [391, 576], radius: 20 },
lineScale: 2.5,
},
eyeRight: {
inner: [553.4, 588.3],
outer: [643.7, 575.1],
upperLidTop: [606.6, 560.8],
lowerLidBottom: [603.5, 593],
iris: { center: [598.5, 573.5], radius: 20.5 },
lineScale: 2.5,
},
browLeft: { inner: [459.2, 529.9], peak: [359.4, 517.4], outer: [307.8, 526.2], halfWidth: [12.8, 16.5, 2.4], sprite: { href: "/avatar/v3/p2/brow-left.webp", x: 307, y: 500, w: 158, h: 55 } },
browRight: { inner: [522.2, 526.9], peak: [630.8, 512.7], outer: [686.8, 520.9], halfWidth: [15.3, 12.2, 6.8], sprite: { href: "/avatar/v3/p2/brow-right.webp", x: 515, y: 492, w: 176, h: 57 } },
noseTip: [494.7, 718.4],
mouthCornerLeft: [417.4, 818.3],
mouthCornerRight: [572.3, 816.9],
upperLipTop: [494.6, 787.6],
lowerLipBottom: [494.1, 843.9],
mouthCenter: [494.6, 812.3],
chinTip: [496.4, 950.7],
},
palette: {
ink: "#1E1E1D",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#9D7253",
lipLower: "#A87857",
mouthLine: "#242220",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -0,0 +1,86 @@
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P3_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P3",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p3/body.webp", x: 0, y: 848, w: 1005, h: 718 },
head: { href: "/avatar/v3/p3/head.webp", x: 132, y: 156, w: 742, h: 888 },
hairFront: { href: "/avatar/v3/p3/hair-front.webp", x: 237, y: 398, w: 540, h: 535 },
faceDetail: { href: "/avatar/v3/p3/face-detail.webp", x: 279, y: 397, w: 443, h: 542 },
},
grain: { href: "/avatar/v3/p3/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p3/lip-upper.webp", x: 424, y: 756, w: 173, h: 51 },
lower: { href: "/avatar/v3/p3/lip-lower.webp", x: 423, y: 792, w: 171, h: 54 },
shadow: { href: "/avatar/v3/p3/lip-shadow.webp", x: 433, y: 791, w: 148, h: 63 },
},
jaw: {
head: { href: "/avatar/v3/p3/jaw-head.webp", x: 243, y: 644, w: 512, h: 332 },
detail: { href: "/avatar/v3/p3/jaw-detail.webp", x: 243, y: 644, w: 512, h: 332 },
},
pivots: {
neck: [518, 1005],
body: [502, 1566],
face: [492, 652],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [185.9, 356.9, 621.9, 621.9],
},
faceOval: [[475.8, 393.7], [540.7, 390.2], [596.5, 395.8], [649.6, 413.2], [685.4, 442.2], [709.3, 480.3], [724.7, 521.2], [733.9, 571.8], [736.4, 619.7], [736.4, 669.4], [731, 722.5], [718.2, 777.9], [698.5, 822.4], [674.5, 854.4], [643.8, 883.5], [618.4, 904.1], [591.4, 922.9], [559.9, 938.4], [519, 945.4], [477.9, 944.3], [445.2, 933.5], [416.7, 919.2], [389.2, 903.2], [355.4, 879.7], [328.1, 851.8], [302.7, 811.3], [282.3, 758], [269.2, 706.1], [262.4, 657.2], [257.2, 610.1], [258.2, 560], [266.7, 517.2], [283.8, 476], [312.7, 441.9], [359, 416], [411.7, 401.4]],
landmarks: {
eyeLeft: {
inner: [435.1, 593.8],
outer: [346.3, 589.4],
upperLidTop: [382.5, 570.1],
lowerLidBottom: [386.7, 602.2],
iris: { center: [389, 580], radius: 20 },
lineScale: 1,
},
eyeRight: {
inner: [552.1, 583.6],
outer: [640.8, 564.7],
upperLidTop: [600.8, 552.1],
lowerLidBottom: [601, 583.9],
iris: { center: [593.5, 558.5], radius: 19.5 },
lineScale: 1,
},
browLeft: { inner: [438, 513.2], peak: [354.9, 519.8], outer: [312, 530.8], halfWidth: [7, 8.3, 3.8], sprite: { href: "/avatar/v3/p3/brow-left.webp", x: 314, y: 502, w: 129, h: 38 } },
browRight: { inner: [521.3, 505.4], peak: [609.3, 496.5], outer: [654.7, 501], halfWidth: [3.9, 7, 2], sprite: { href: "/avatar/v3/p3/brow-right.webp", x: 517, y: 486, w: 142, h: 32 } },
noseTip: [499.6, 704.4],
mouthCornerLeft: [432.6, 803.4],
mouthCornerRight: [583.6, 792.2],
upperLipTop: [505.2, 769.8],
lowerLipBottom: [509.4, 832.4],
mouthCenter: [507.2, 796.4],
chinTip: [518.3, 935.2],
},
palette: {
ink: "#242322",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#B4835D",
lipLower: "#BD8A62",
mouthLine: "#76553F",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -0,0 +1,86 @@
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P4_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P4",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p4/body.webp", x: 0, y: 436, w: 1005, h: 1130 },
head: { href: "/avatar/v3/p4/head.webp", x: 161, y: 97, w: 661, h: 924 },
hairFront: { href: "/avatar/v3/p4/hair-front.webp", x: 234, y: 405, w: 534, h: 485 },
faceDetail: { href: "/avatar/v3/p4/face-detail.webp", x: 266, y: 394, w: 470, h: 551 },
},
grain: { href: "/avatar/v3/p4/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p4/lip-upper.webp", x: 410, y: 777, w: 179, h: 46 },
lower: { href: "/avatar/v3/p4/lip-lower.webp", x: 410, y: 816, w: 178, h: 51 },
shadow: { href: "/avatar/v3/p4/lip-shadow.webp", x: 420, y: 814, w: 159, h: 61 },
},
jaw: {
head: { href: "/avatar/v3/p4/jaw-head.webp", x: 250, y: 668, w: 504, h: 313 },
detail: { href: "/avatar/v3/p4/jaw-detail.webp", x: 250, y: 668, w: 504, h: 313 },
},
pivots: {
neck: [502.7, 1011.1],
body: [502, 1566],
face: [496, 655],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [184.9, 354.2, 631.2, 631.2],
},
faceOval: [[495.7, 388], [569.6, 388.6], [631.4, 396.4], [687.2, 414.1], [721.3, 442.7], [739.9, 479.9], [748.2, 519.6], [748.9, 568.1], [743.7, 615], [735.8, 663.9], [723.6, 715.1], [704.8, 769.9], [681.9, 816.1], [657.7, 851.5], [628.5, 883.7], [603.9, 907.3], [576.9, 929.1], [544.6, 946.1], [502.7, 951.6], [461.8, 946.3], [430.4, 929.7], [403.7, 908.3], [378.7, 885.5], [348.8, 854], [324, 819.1], [300.1, 773.9], [280.7, 719], [267.6, 667.8], [259.3, 619.2], [252.7, 572.5], [252.2, 524.3], [258.8, 484.1], [275.1, 446.8], [306.7, 417.8], [360.2, 398.8], [421.5, 390.1]],
landmarks: {
eyeLeft: {
inner: [430.6, 594.2],
outer: [337.6, 576.7],
upperLidTop: [375.9, 563.9],
lowerLidBottom: [380.9, 598.5],
iris: { center: [385, 576], radius: 20 },
lineScale: 2.5,
},
eyeRight: {
inner: [566.4, 593.4],
outer: [659.2, 572.9],
upperLidTop: [621.4, 561.7],
lowerLidBottom: [616.8, 596.6],
iris: { center: [611.5, 574.5], radius: 18.5 },
lineScale: 2.5,
},
browLeft: { inner: [449.4, 524.6], peak: [353.2, 508.4], outer: [303.6, 507.6], halfWidth: [6.8, 6.5, 2.5], sprite: { href: "/avatar/v3/p4/brow-left.webp", x: 299, y: 499, w: 156, h: 36 } },
browRight: { inner: [540.9, 523.7], peak: [642.6, 502.3], outer: [695.1, 500.2], halfWidth: [2.4, 6.7, 1.5], sprite: { href: "/avatar/v3/p4/brow-right.webp", x: 536, y: 491, w: 164, h: 42 } },
noseTip: [498.6, 728.2],
mouthCornerLeft: [426.2, 819.4],
mouthCornerRight: [571.5, 818.6],
upperLipTop: [497.9, 791.6],
lowerLipBottom: [498.7, 852.3],
mouthCenter: [498.4, 815.7],
chinTip: [502.7, 940.6],
},
palette: {
ink: "#22211F",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#B48460",
lipLower: "#C08E69",
mouthLine: "#332B26",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -0,0 +1,86 @@
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P5_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P5",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p5/body.webp", x: 0, y: 696, w: 1005, h: 870 },
head: { href: "/avatar/v3/p5/head.webp", x: 144, y: 118, w: 688, h: 846 },
hairFront: { href: "/avatar/v3/p5/hair-front.webp", x: 231, y: 401, w: 513, h: 436 },
faceDetail: { href: "/avatar/v3/p5/face-detail.webp", x: 262, y: 410, w: 455, h: 523 },
},
grain: { href: "/avatar/v3/p5/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p5/lip-upper.webp", x: 423, y: 749, w: 124, h: 54 },
lower: { href: "/avatar/v3/p5/lip-lower.webp", x: 423, y: 791, w: 136, h: 46 },
shadow: { href: "/avatar/v3/p5/lip-shadow.webp", x: 433, y: 797, w: 116, h: 48 },
},
jaw: {
head: { href: "/avatar/v3/p5/jaw-head.webp", x: 236, y: 633, w: 512, h: 337 },
detail: { href: "/avatar/v3/p5/jaw-detail.webp", x: 236, y: 633, w: 512, h: 337 },
},
pivots: {
neck: [488, 1001],
body: [502, 1566],
face: [490, 659],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [191.6, 370.8, 601.3, 601.3],
},
faceOval: [[496.1, 403], [564, 405], [621.1, 414.5], [672.8, 434.4], [705.1, 464.2], [723.6, 501.7], [732.1, 541], [732.9, 589.2], [728.9, 634.4], [723.7, 681], [714.5, 730.1], [698.1, 782.2], [675.7, 823.6], [650.1, 853], [619, 879.9], [592.9, 900], [565.2, 919.2], [531.9, 934.8], [488.2, 939.9], [444.8, 934.7], [411.7, 918.9], [384.1, 899.3], [358.4, 879], [328.1, 851.8], [303.5, 822.2], [281.3, 781.3], [265.8, 729], [257.4, 680], [253.9, 634], [251.6, 589.2], [255.3, 541.7], [265.7, 502], [286.1, 464.7], [319.6, 435], [371.3, 414.7], [428.2, 405.3]],
landmarks: {
eyeLeft: {
inner: [431.6, 589],
outer: [343.8, 578.5],
upperLidTop: [381.9, 566.8],
lowerLidBottom: [383, 594],
iris: { center: [387.5, 574.5], radius: 18.5 },
lineScale: 2.5,
},
eyeRight: {
inner: [553.9, 588.2],
outer: [641.8, 576.3],
upperLidTop: [605.4, 564.5],
lowerLidBottom: [602.9, 593],
iris: { center: [594, 573], radius: 18 },
lineScale: 2.5,
},
browLeft: { inner: [454.2, 526.5], peak: [355, 512.3], outer: [303.8, 521.4], halfWidth: [7.1, 13, 3.4], sprite: { href: "/avatar/v3/p5/brow-left.webp", x: 299, y: 495, w: 161, h: 48 } },
browRight: { inner: [524.9, 526.2], peak: [625.3, 510.7], outer: [677.1, 518.6], halfWidth: [2.1, 13, 1.9], sprite: { href: "/avatar/v3/p5/brow-right.webp", x: 520, y: 495, w: 162, h: 44 } },
noseTip: [493.1, 693.9],
mouthCornerLeft: [418.9, 801.4],
mouthCornerRight: [557.3, 801.8],
upperLipTop: [490.9, 759.4],
lowerLipBottom: [489.5, 828.3],
mouthCenter: [490.7, 791],
chinTip: [488.3, 929.7],
},
palette: {
ink: "#222221",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#B1845F",
lipLower: "#CC926C",
mouthLine: "#A5694F",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -0,0 +1,86 @@
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P6_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P6",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p6/body.webp", x: 0, y: 844, w: 1005, h: 722 },
head: { href: "/avatar/v3/p6/head.webp", x: 37, y: 83, w: 887, h: 1063 },
hairFront: { href: "/avatar/v3/p6/hair-front.webp", x: 257, y: 399, w: 463, h: 529 },
faceDetail: { href: "/avatar/v3/p6/face-detail.webp", x: 272, y: 404, w: 439, h: 529 },
},
grain: { href: "/avatar/v3/p6/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p6/lip-upper.webp", x: 420, y: 765, w: 158, h: 49 },
lower: { href: "/avatar/v3/p6/lip-lower.webp", x: 419, y: 804, w: 160, h: 48 },
shadow: { href: "/avatar/v3/p6/lip-shadow.webp", x: 429, y: 807, w: 140, h: 53 },
},
jaw: {
head: { href: "/avatar/v3/p6/jaw-head.webp", x: 251, y: 656, w: 482, h: 315 },
detail: { href: "/avatar/v3/p6/jaw-detail.webp", x: 251, y: 656, w: 482, h: 315 },
},
pivots: {
neck: [506, 1001],
body: [502, 1566],
face: [488, 661],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [184.6, 365, 608.6, 608.6],
},
faceOval: [[480.8, 398.4], [546.8, 397.6], [602, 404.4], [651.9, 421], [683.6, 447.7], [702.8, 483.5], [713.5, 522.5], [717.9, 570.3], [717, 615.8], [714.2, 663.3], [706.6, 713.5], [692.8, 766.8], [673.8, 811.4], [651.7, 845], [623.5, 875.2], [600.1, 897], [575.1, 917.5], [545.4, 934.4], [506.6, 941], [467.6, 937.9], [437.3, 924], [410.8, 905.8], [385.5, 886.6], [354, 859], [328.2, 826.9], [304.3, 783.8], [285.2, 730.7], [272.8, 680.6], [265.7, 633.6], [260, 588.5], [260, 540.9], [267.4, 500.8], [283.6, 463.8], [312.3, 434.4], [359.6, 413.5], [414.2, 402.7]],
landmarks: {
eyeLeft: {
inner: [432.4, 593.7],
outer: [341.4, 583.9],
upperLidTop: [378.1, 566],
lowerLidBottom: [383.4, 600.1],
iris: { center: [386.5, 577.5], radius: 20.5 },
lineScale: 1,
},
eyeRight: {
inner: [548.9, 589.6],
outer: [639.8, 570.8],
upperLidTop: [601.4, 555.9],
lowerLidBottom: [598.4, 591.8],
iris: { center: [595, 568], radius: 20 },
lineScale: 1,
},
browLeft: { inner: [451.9, 530.3], peak: [367.6, 517.4], outer: [324.1, 521.5], halfWidth: [15.7, 11, 7.9], sprite: { href: "/avatar/v3/p6/brow-left.webp", x: 323, y: 504, w: 134, h: 49 } },
browRight: { inner: [518.3, 525.4], peak: [607, 510.3], outer: [652.7, 515.6], halfWidth: [5.7, 25.5, 4.9], sprite: { href: "/avatar/v3/p6/brow-right.webp", x: 514, y: 492, w: 137, h: 57 } },
noseTip: [495.6, 716.5],
mouthCornerLeft: [423.8, 811],
mouthCornerRight: [575.1, 805.1],
upperLipTop: [498.5, 778],
lowerLipBottom: [501.5, 841.2],
mouthCenter: [499.8, 803.5],
chinTip: [506.1, 930.6],
},
palette: {
ink: "#242322",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#A27659",
lipLower: "#A47459",
mouthLine: "#2C2625",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -0,0 +1,86 @@
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P7_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P7",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p7/body.webp", x: 0, y: 725, w: 1005, h: 841 },
head: { href: "/avatar/v3/p7/head.webp", x: 174, y: 151, w: 648, h: 892 },
hairFront: { href: "/avatar/v3/p7/hair-front.webp", x: 220, y: 413, w: 535, h: 423 },
faceDetail: { href: "/avatar/v3/p7/face-detail.webp", x: 276, y: 423, w: 425, h: 513 },
},
grain: { href: "/avatar/v3/p7/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p7/lip-upper.webp", x: 406, y: 763, w: 170, h: 50 },
lower: { href: "/avatar/v3/p7/lip-lower.webp", x: 408, y: 801, w: 164, h: 49 },
shadow: { href: "/avatar/v3/p7/lip-shadow.webp", x: 428, y: 807, w: 126, h: 51 },
},
jaw: {
head: { href: "/avatar/v3/p7/jaw-head.webp", x: 251, y: 652, w: 478, h: 321 },
detail: { href: "/avatar/v3/p7/jaw-detail.webp", x: 251, y: 652, w: 478, h: 321 },
},
pivots: {
neck: [492, 1002],
body: [502, 1566],
face: [489, 660],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [193.3, 384.2, 590, 590],
},
faceOval: [[490.5, 415.8], [555.3, 417.3], [609.5, 425.4], [658.6, 442.5], [688.8, 469], [706, 502.9], [714.3, 539.4], [716.2, 584.5], [712.6, 628.1], [706.8, 673.7], [696.8, 722.2], [681.3, 774.6], [662, 818.1], [641.5, 850.8], [615.8, 880.4], [592.8, 902], [566.9, 921.9], [534.8, 937.4], [491.9, 942.6], [449, 938.5], [416.4, 923.9], [389.5, 904.8], [365.4, 883.9], [338.9, 854.2], [317.7, 821], [298, 777], [282.2, 723.6], [271.5, 674.4], [265.1, 628.9], [260.4, 585.3], [262.1, 540.3], [270.9, 503.5], [289.1, 469.4], [320.3, 442.7], [369.9, 425.3], [425, 417.4]],
landmarks: {
eyeLeft: {
inner: [433.4, 588.7],
outer: [345.6, 578.8],
upperLidTop: [381.3, 566.3],
lowerLidBottom: [387.8, 592.8],
iris: { center: [387.5, 574.5], radius: 16.5 },
lineScale: 2.5,
},
eyeRight: {
inner: [550.7, 589.9],
outer: [637.7, 578.5],
upperLidTop: [601.5, 566.1],
lowerLidBottom: [596.3, 593.2],
iris: { center: [593.5, 572.5], radius: 16.5 },
lineScale: 2.5,
},
browLeft: { inner: [455.8, 527], peak: [369, 521.2], outer: [324.2, 527.5], halfWidth: [5.4, 8, 3.4], sprite: { href: "/avatar/v3/p7/brow-left.webp", x: 324, y: 509, w: 137, h: 29 } },
browRight: { inner: [526.5, 526.8], peak: [619.5, 519.8], outer: [667.5, 527], halfWidth: [6.3, 8, 2.9], sprite: { href: "/avatar/v3/p7/brow-right.webp", x: 521, y: 509, w: 150, h: 28 } },
noseTip: [493.5, 712.5],
mouthCornerLeft: [422.6, 806.5],
mouthCornerRight: [558.9, 808.4],
upperLipTop: [493.8, 774],
lowerLipBottom: [492.3, 835],
mouthCenter: [493.2, 800.3],
chinTip: [491.8, 932.4],
},
palette: {
ink: "#202122",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#BA9069",
lipLower: "#C4936D",
mouthLine: "#322F2B",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -8,7 +8,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import DebugFace from "../components/avatar/v3/DebugFace";
import LinocutAvatar from "../components/avatar/v3/LinocutAvatar";
import LinocutMotif from "../components/avatar/v3/LinocutMotif";
import { P1_LINOCUT_RIG } from "../components/avatar/v3/rigs/p1Rig";
import { labLinocutRigs } from "../components/avatar/v3/rigs";
import type { LinocutRig } from "../components/avatar/v3/linocutRig";
import { CHANNEL_IDS } from "../components/avatar/engine/channels";
import { createAvatarEngine, type AvatarEngine, type DebugSnapshot } from "../components/avatar/engine/engine";
@ -30,8 +30,10 @@ const PERSONA_OPTIONS: Array<{ code: string; label: string }> = [
{ code: "P7", label: "P7 도현" },
];
/** 리노컷 리그가 있는 페르소나만. 없는 페르소나는 debug 렌더러를 강제한다. */
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_LINOCUT_RIG };
/** 리노컷 리그가 있는 페르소나만. 없는 페르소나는 debug 렌더러를 강제한다.
공통 파이프라인이 만든 pNRig.ts를 전부 모은 값이라, 새 페르소나가 빌드되는
즉시(재빌드 없이) 이 Lab에서 보인다 — v3/rigs/index.ts labLinocutRigs() 참고. */
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = labLinocutRigs();
type RendererMode = "linocut" | "debug";
type FeedbackMode = "immersive" | "ambient" | "coached";
@ -80,11 +82,19 @@ function seedFromQuery(): number {
return Number.isFinite(parsed) ? parsed : 1;
}
/** ?persona=P3 같은 쿼리로 처음 고를 페르소나를 정한다(캡처 도구용). 모르는 코드거나
없으면 기본 P1. */
function personaFromQuery(): string {
const raw = new URLSearchParams(window.location.search).get("persona");
const upper = raw?.toUpperCase() ?? "";
return PERSONA_OPTIONS.some((p) => p.code === upper) ? upper : "P1";
}
export default function AvatarLab() {
const seed = useMemo(seedFromQuery, []);
const clock = useRef<Clock>(createClock()).current;
const [personaCode, setPersonaCode] = useState("P1");
const [personaCode, setPersonaCode] = useState(personaFromQuery);
const [avatarState, setAvatarState] = useState<AvatarState>("idle");
const [openness, setOpenness] = useState(0.4);
const [reducedMotion, setReducedMotion] = useState(false);

View file

@ -27,7 +27,7 @@
| ADMIN-OPENROUTER-001 | 새 사용자 OAuth finish 성공을 관측한다. | API/web 복구·실제 PostgreSQL rollback·배포/보존 census·공개 관리자 UI를 수용했다. OpenRouter UI 상태는 미연결이고 health의 Jev `live_verified=false`는 live 검증이 아니며, 새 OAuth finish 성공 관측 전까지 진행 중이다. [운영 영수증](./ops/evidence/admin-openrouter-recovery-2026-09-22.json)을 따른다. |
| JEV-001 | 충분한 한국어 독립 평가와 전체 지연 반복 비교를 마친다. | 운영 API, 합성·인증 브라우저 3턴 runtime과 DB 감정 영속화는 수용했지만 품질 승격은 보류한다. API legacy baseline 1191 passed·1 skipped·0 failed, gateway 82 passed는 구현 회귀 증거이며 품질·성능 우위 증거가 아니다. 위 품질 게이트를 닫은 뒤 전체 사용자 흐름 완료 여부를 판단하며, 최신 probe·수용/반려 근거는 [Jev 결정문](./decisions/jev-client-affect.md)을 따른다. |
| JEV-002 | v2 평가·표현 프로토콜을 운영에 올리고 실제 대사 영향을 관측한다. | 로컬 구현·실 Jev 16/16·실 DB RLS 15항목·실 스택 레이아웃을 수용했다. 운영은 migration 24를 먼저 적용한 뒤 API·web을 배포한다(스키마 없이 배포하면 readiness가 기동을 막는다). 로컬 `claude_cli` 생성 엔진 시간 초과로 v2 지시가 실제 대사에 미치는 영향은 미관측이다. 보정 관찰(`a_understood` 문구, `a_sore_spot` 과다 선택)은 전문가 검토 대상이다. [v2 프로토콜](./decisions/jev-client-affect-v2.md) |
| AVATAR-V3 | 가상 내담자 아바타 v3(표현 엔진·반응 모션·리노컷 리그)를 P1부터 세션에 연결한다. | 1단계 엔진·`/dev/avatar-lab` 수용(check:avatar-presets 통과·E2E 6 passed), 2단계-A P1 리노컷 레이어 수용. 2단계-B-1b 렌더러 기능 계약·B-1c 모티프 수용. 진행: 2단계-B-1d 표정 판독성 개정(입술 결 래스터, 눈썹 중심선 랜드마크, 이마 주름, 작은 크기 선 보정)과 발화층 립싱크(한글 비짐 + 오디오 선분석, 결정문 §5.4 — 구현·검증됨), 발화 동반층 §5.5·하관 띠 변형(턱·코·볼)·원화 픽셀 입술 구현, 2단계-B-1d 수용(소유자 시각 확인 대기). 2단계-B-2 세션 연결 수용·운영 반영(master `613bcb60`, 공개 웹 Pages `dd96c2d5`, 2026-10-01). 남음: 운영 화면 소유자 시각 확인, 3단계 API `avatar_cue`·지문 어휘 가이드, 4단계 7명 외형 SSOT. [결정문](./decisions/avatar-expression-engine-v3.md) |
| AVATAR-V3 | 가상 내담자 아바타 v3(표현 엔진·반응 모션·리노컷 리그)를 P1부터 세션에 연결한다. | 1단계 엔진·`/dev/avatar-lab` 수용(check:avatar-presets 통과·E2E 6 passed), 2단계-A P1 리노컷 레이어 수용. 2단계-B-1b 렌더러 기능 계약·B-1c 모티프 수용. 진행: 2단계-B-1d 표정 판독성 개정(입술 결 래스터, 눈썹 중심선 랜드마크, 이마 주름, 작은 크기 선 보정)과 발화층 립싱크(한글 비짐 + 오디오 선분석, 결정문 §5.4 — 구현·검증됨), 발화 동반층 §5.5·하관 띠 변형(턱·코·볼)·원화 픽셀 입술 구현, 2단계-B-1d 수용(소유자 시각 확인 대기). 2단계-B-2 세션 연결 수용·운영 반영(master `613bcb60`, 공개 웹 Pages `dd96c2d5`, 2026-10-01). P2~P7 리노컷 캐스트 리그 수용·세션 활성화(2026-10-01, 결정문 §8.4.1, 공개 웹 미반영). 남음: P2~P7 공개 웹 반영, 운영 화면 소유자 시각 확인, 흰자 색 원화 실측·`data-render-mode` v3 반영·공황 창백 오버레이 사각 경계(후속 다듬기), 3단계 API `avatar_cue`·지문 어휘 가이드, 4단계 7명 외형 SSOT. [결정문](./decisions/avatar-expression-engine-v3.md) |
| USER-LATENCY (JEV-001) | 실제 경로에서 text display·voice onset·완료 준비를 분리 측정한다. | 안전 검사를 유지한 최적화 뒤 반복 비교로 사용자 체감 지연을 평가한다. 현재 CUA 관측상한과 소수 audit은 속도 개선 증거가 아니다. |
| G6-PRODUCER-CONFLICT | 기존 submission id와 content hash를 읽기 전용으로 대조한다. | `SupervisionResearchConflictError`의 동일 id 재실행 idempotency 충돌을 해소하고 해당 테스트와 실제 cycle 성공을 확인한다. Jev·세션 경로와 분리된 기존 운영 충돌이며, 이번 배포 회귀로 단정하지 않는다. |
| VNET-001 | `vnet.18ka.net` 공개 전환의 외부 설정을 마친다. | DNS, Cloudflare zone 권한, Google redirect URI가 모두 준비된 뒤 live 검증. |

View file

@ -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/<publicSlug>/`) + `rigs/<rigFileName>` 생성
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/<publicSlug>/`) + `rigs/<rigFileName>` 생성
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
<venv>/python.exe register_edit.py <ref.png> <faceless-edit.png> <faceless-aligned.png> \
--mode faceless --report <faceless-report.json>
<venv>/python.exe register_edit.py <ref.png> <body-edit.png> <body-aligned.png> \
--mode body --report <body-report.json>
```
콘솔·리포트 JSON에 정렬 품질 지표(변환 배율·회전·이동, 마스크 안 Canny 에지
챔퍼 거리 중앙값·p90, faceless는 얼굴 윤곽 띠 챔퍼와 눈·눈썹·입 상자 잉크 비율)를
낸다 — **판정은 하지 않는다**(수용 기준은 출력만, 사람/오케스트레이터가 본다).
2. `normalize_base.py` — 위 세 장(원래 ref, 정렬된 faceless, 정렬된 body)에 **같은**
아핀 변환 하나(등방 배율 + 평행이동, 회전 없음)를 적용해 캔버스로 옮긴다. 배율·
이동은 P1 `base-front.png`의 눈 중심·눈 사이 거리·눈-턱 거리 비율에 맞춘다
(P1과 머리 크기 비율이 같아지도록).
```bash
<venv>/python.exe normalize_base.py <persona-dir> \
--front <ref.png> --faceless <faceless-aligned.png> --body <body-aligned.png>
```
출력: `<persona-dir>/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장: `<persona-dir>/base/base-front.png`(정면 기본형)과
`base-faceless.png`(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다.
후보 원화 크기·구도가 P1과 다르면(캐스트 확장이 보통 그렇다) 바로 두지 말고
위 "캐스트 확장 0단계" 도구(`register_edit.py` → `normalize_base.py`)로 캔버스에
맞춰 만든다.
2. `<persona-dir>/raw/body.png`: body 레이어가 head_mask로 가려지는 목 상단 영역을
메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬).
메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬). 역시 캐스트
확장이면 위 도구가 만든다.
3. `<persona-dir>/persona.json`: 위 스키마대로 작성한다. 특히 `styleFrame`·
`paletteSamples`·`pivots`는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).
4. (선택) `<persona-dir>/motif/`: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가
@ -147,3 +309,45 @@ python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
그 다음 `run_pipeline.py <persona-dir>`를 실행하고, 출력된 검사 수치(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 <CODE> <outDir> [--base http://localhost:5173]
```
- `<CODE>`: `P1`처럼 대문자 페르소나 코드. `apps/web/src/components/avatar/v3/rigs/<code>Rig.ts`의
`<CODE>_LINOCUT_RIG` export와 `docs/avatar-art/<code>-linocut/base/base-front.png`를 읽는다.
- Lab에 `?persona=<CODE>` 쿼리(`v3/rigs/index.ts` `labLinocutRigs()` + `AvatarLab.tsx`
`?persona=` 지원, 세션 화면과 무관)로 들어가 그 페르소나를 바로 고른다.
- `--base`가 가리키는 dev server가 이미 떠 있어야 한다(이 스크립트는 서버를
띄우거나 끄지 않는다). 기본값 `http://localhost:5173`.
산출(`<outDir>`에 JPG, 모두 긴 변 ≤1000px — 개별 캡처 PNG는 `<outDir>/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급 비결정성 때문에 스캔 때와
다른 비짐이 잡힐 수 있음을 실측으로 확인했다 — 항상 한 패스로 찍는다).
`<outDir>/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).

View file

@ -0,0 +1,394 @@
// 공통 리노컷 리그 — 캐스트 QA 캡처 도구(T7). scratch/linocut-review/capture_out4.mjs의
// 방식(apps/web의 playwright, auth/me 모킹, Lab 조작, 일시정지 뒤 스크린샷)을 그대로
// 따른다. 페르소나 코드 하나를 받아 AvatarLab(/dev/avatar-lab?persona=<CODE>)을 조작해
// 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 <CODE> <outDir> [--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 <CODE> <outDir> [--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 `<div style="display:grid;grid-template-columns:repeat(${cols},${cellPx}px);gap:6px;">${cells
.map((c) => `<div style="width:${cellPx}px;"><img src="${toDataUri(c.src)}" width="${cellPx}" style="display:block;border:1px solid #ccc;" /><div style="font:11px sans-serif;text-align:center;height:${labelH}px;overflow:hidden;">${c.label}</div></div>`)
.join("")}</div>`;
}
async function buildMontage(browser, filePath, htmlBody) {
// fullPage 스크린샷은 실제 콘텐츠 크기로 찍힌다(뷰포트보다 작아도 줄지 않고, 커도
// 안 잘린다 — 실측 확인됨). 뷰포트를 넉넉하게 어림잡으면 그 어림값만큼 빈 여백이
// 그대로 찍혀 긴 변 1000px 기준을 넘길 수 있어, 작게 고정해 항상 실제 콘텐츠
// 크기로 나오게 한다.
const page = await browser.newPage({ viewport: { width: 60, height: 60 } });
const html = `<!doctype html><html><body style="margin:0;padding:12px;background:#fff;font-family:sans-serif;">${htmlBody}</body></html>`;
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 = `<div style="display:flex;gap:20px;align-items:flex-start;">
<div style="width:${PANEL_W}px;"><img src="${toDataUri(neutralPortraitPath)}" width="${PANEL_W}" style="display:block;border:1px solid #ccc;" /><div style="font:12px sans-serif;text-align:center;margin-top:4px;">neutral 렌더(portrait)</div></div>
<div style="width:${PANEL_W}px;"><img src="${toDataUri(baseFrontPath)}" width="${PANEL_W}" style="display:block;border:1px solid #ccc;" /><div style="font:12px sans-serif;text-align:center;margin-top:4px;">base-front.png(원화)</div></div>
</div>`;
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); });

View file

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

View file

@ -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보다 뒤에 둔다.
실행: <venv>/python.exe brow_sprite.py <persona-dir>
"""
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를 포함하는 연속 어두운(값<thresh) 구간의
(시작, 끝) 인덱스. center_idx 자체가 어둡지 않으면 None."""
n = len(values)
if center_idx < 0 or 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])))

View file

@ -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

View file

@ -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,6 +477,17 @@ 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))
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)],

View file

@ -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 <persona-dir> --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:]))

View file

@ -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,
)

View file

@ -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 <ref.png> <edited.png> <out.png> --mode faceless|body [--report <json>]
"""
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:]))

View file

@ -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/<code>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/<code>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="이 단계만 실행한다")

View file

@ -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)",

View file

@ -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
}
}
}

Some files were not shown because too many files have changed in this diff Show more