Compare commits
3 commits
9a22d1647b
...
6e3123091a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e3123091a | ||
|
|
ded312acdc | ||
|
|
98577cf4ae |
9
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, "조금 안정된 것 같아요.");
|
||||
|
|
|
|||
BIN
apps/web/public/avatar/v3/p2/body.webp
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/web/public/avatar/v3/p2/brow-left.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/v3/p2/brow-right.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
apps/web/public/avatar/v3/p2/face-detail.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/web/public/avatar/v3/p2/hair-front.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/web/public/avatar/v3/p2/head.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/web/public/avatar/v3/p2/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/avatar/v3/p2/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/public/avatar/v3/p2/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p2/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p2/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/avatar/v3/p2/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
apps/web/public/avatar/v3/p3/body.webp
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
apps/web/public/avatar/v3/p3/brow-left.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web/public/avatar/v3/p3/brow-right.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web/public/avatar/v3/p3/face-detail.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/web/public/avatar/v3/p3/hair-front.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
apps/web/public/avatar/v3/p3/head.webp
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
apps/web/public/avatar/v3/p3/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/web/public/avatar/v3/p3/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/web/public/avatar/v3/p3/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/avatar/v3/p3/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/web/public/avatar/v3/p3/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p3/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 980 B |
BIN
apps/web/public/avatar/v3/p4/body.webp
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
apps/web/public/avatar/v3/p4/brow-left.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
apps/web/public/avatar/v3/p4/brow-right.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/web/public/avatar/v3/p4/face-detail.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/avatar/v3/p4/hair-front.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/web/public/avatar/v3/p4/head.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/web/public/avatar/v3/p4/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/web/public/avatar/v3/p4/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
apps/web/public/avatar/v3/p4/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/web/public/avatar/v3/p4/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/web/public/avatar/v3/p4/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p4/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/web/public/avatar/v3/p5/body.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/web/public/avatar/v3/p5/brow-left.webp
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
apps/web/public/avatar/v3/p5/brow-right.webp
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/web/public/avatar/v3/p5/face-detail.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/web/public/avatar/v3/p5/hair-front.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/v3/p5/head.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/web/public/avatar/v3/p5/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/web/public/avatar/v3/p5/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/web/public/avatar/v3/p5/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/avatar/v3/p5/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/web/public/avatar/v3/p5/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/avatar/v3/p5/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/web/public/avatar/v3/p6/body.webp
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
apps/web/public/avatar/v3/p6/brow-left.webp
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/web/public/avatar/v3/p6/brow-right.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
apps/web/public/avatar/v3/p6/face-detail.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/web/public/avatar/v3/p6/hair-front.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/web/public/avatar/v3/p6/head.webp
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
apps/web/public/avatar/v3/p6/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/avatar/v3/p6/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web/public/avatar/v3/p6/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p6/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p6/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p6/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/web/public/avatar/v3/p7/body.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
apps/web/public/avatar/v3/p7/brow-left.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/web/public/avatar/v3/p7/brow-right.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
apps/web/public/avatar/v3/p7/face-detail.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/web/public/avatar/v3/p7/hair-front.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/web/public/avatar/v3/p7/head.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/web/public/avatar/v3/p7/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/v3/p7/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/avatar/v3/p7/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p7/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/avatar/v3/p7/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/avatar/v3/p7/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 980 B |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/* ── 보조 주름 ─────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
86
apps/web/src/components/avatar/v3/rigs/p2Rig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
86
apps/web/src/components/avatar/v3/rigs/p3Rig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
86
apps/web/src/components/avatar/v3/rigs/p4Rig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
86
apps/web/src/components/avatar/v3/rigs/p5Rig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
86
apps/web/src/components/avatar/v3/rigs/p6Rig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
86
apps/web/src/components/avatar/v3/rigs/p7Rig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 검증. |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
394
docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs
Normal 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); });
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
635
docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py
Normal 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])))
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
249
docs/avatar-art/linocut-pipeline/scripts/normalize_base.py
Normal 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:]))
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
327
docs/avatar-art/linocut-pipeline/scripts/register_edit.py
Normal 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:]))
|
||||
|
|
@ -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="이 단계만 실행한다")
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||