Compare commits
2 commits
f11e76ff18
...
613bcb603e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613bcb603e | ||
|
|
ecb36d123f |
52 changed files with 3393 additions and 1172 deletions
|
|
@ -96,6 +96,9 @@ src/
|
|||
- UI 프리미티브는 `components/ui` 배럴에서 가져온다. props/타입이 안정 계약이다.
|
||||
- `ClientAvatar` props(`persona`/`state`/`affect`/`analyser`)는 확정 인터페이스.
|
||||
avatar 에이전트는 이 파일 내부 SVG/모션만 고도화하고 시그니처는 유지.
|
||||
리노컷 리그가 있는 페르소나(`components/avatar/v3/rigs`, 지금은 P1)는 내부에서 v3로 그리며,
|
||||
선택 prop `openness`·`surfaceIntensity`·`speech`(TTS 발화 구동)를 더 받는다(결정문
|
||||
`docs/decisions/avatar-expression-engine-v3.md` §8.5). 빌드 플래그 `VITE_AVATAR_V3=0`이면 모두 기존 SVG.
|
||||
- 세션 데이터는 `lib/api.ts`의 `sessionApi`(start/turn/end/stream) 사용.
|
||||
SSE 토큰 수신은 `openSessionStream(sessionId, { onToken, onDone, ... })`.
|
||||
- 페이지는 `default export`. `AppShell`로 감싸면 톱바/네비/역할 accent가 자동 적용.
|
||||
|
|
|
|||
|
|
@ -337,15 +337,16 @@ test.describe("persona avatar expression rig", () => {
|
|||
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
|
||||
});
|
||||
|
||||
test("uses the original SVG parameter rig for P1 Seoyeon", async ({ page }) => {
|
||||
test("uses the v3 linocut avatar for P1 Seoyeon", async ({ page }) => {
|
||||
await page.goto("/learn/session/P1");
|
||||
|
||||
const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first();
|
||||
await expect(avatar).toBeVisible();
|
||||
await expect(avatar).toHaveAttribute("data-render-mode", "svg");
|
||||
await expect(avatar).toHaveAttribute("data-affect", "sad");
|
||||
await expect(avatar.locator(".vg-raster")).toHaveCount(0);
|
||||
await expect(avatar.locator(".vg-avatar__svg")).toBeVisible();
|
||||
await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible();
|
||||
|
||||
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();
|
||||
await expect(v3).toHaveAttribute("data-viseme", "X");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
300
apps/web/e2e/avatar-session-speech.spec.ts
Normal file
300
apps/web/e2e/avatar-session-speech.spec.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
/* =====================================================================
|
||||
avatar-session-speech.spec.ts — P1 세션 TTS 재생 ↔ v3(리노컷) 아바타
|
||||
발화 연결 E2E(결정문 §8.5 "세션 연결" 테스트 계약).
|
||||
|
||||
목적: TTS 응답을 테스트 안에서 만든 WAV(유성 구간이 있는 톤버스트
|
||||
여러 개)로 모킹해, 재생 중 v3 래퍼의 data-speech-source="audio"·
|
||||
data-viseme 순환을 확인하고, 재생이 끝나면 발화 속성이 정리되는지
|
||||
본다. TTS 실패(500)면 data-speech-source="text"로 텍스트 타이밍
|
||||
발화로 떨어지는지도 본다.
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx playTtsAudio/speakTextClientTurn,
|
||||
apps/web/src/components/avatar/v3/ClientAvatarV3.tsx.
|
||||
주의: 모든 API는 route fixture로 모킹한다(실제 AI 엔진·TTS 제공자 없음).
|
||||
헤드리스 자동재생 정책 때문에 이 파일만 autoplay-policy를 느슨하게
|
||||
연다(세션 범위, 다른 스펙에 영향 없음).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Locator, type Page, type Route } from "@playwright/test";
|
||||
|
||||
test.use({ launchOptions: { args: ["--autoplay-policy=no-user-gesture-required"] } });
|
||||
|
||||
const SESSION_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const LEARNER_TEXT = "요즘 많이 힘들었어요. 어떤 마음이 가장 크게 남아 있나요?";
|
||||
const CLIENT_REPLY = "그냥요. 잠을 잘 못 자요. 아무 것도 하고 싶지 않아요.";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function sseTurnBody(tokens: string[], done: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const token of tokens) lines.push("event: token", `data: ${token}`, "");
|
||||
lines.push("event: done", `data: ${JSON.stringify(done)}`, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function fulfillTurn(route: Route, tokens: string[], done: Record<string, unknown>) {
|
||||
await route.fulfill({ status: 200, contentType: "text/event-stream", body: sseTurnBody(tokens, done) });
|
||||
}
|
||||
|
||||
function writeAsciiString(view: DataView, offset: number, text: string): void {
|
||||
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* 유성 구간이 있는 톤버스트 여러 개로 테스트용 WAV를 만든다(16비트 PCM 모노).
|
||||
* decodeAudioData가 바로 디코드할 수 있다. 무음 구간(0)과 사인파 구간을 번갈아
|
||||
* 넣어 speechEnvelope.ts의 유성 구간 검출(포락선 > max(0.02, 0.12·P95))이
|
||||
* 서로 다른 구간 여러 개를 집어내게 한다.
|
||||
*/
|
||||
function buildToneBurstWav(): Buffer {
|
||||
const sampleRate = 16000;
|
||||
const amplitude = 0.7;
|
||||
const segments: Array<{ freq: number; durMs: number }> = [
|
||||
{ freq: 0, durMs: 150 },
|
||||
{ freq: 220, durMs: 320 },
|
||||
{ freq: 0, durMs: 180 },
|
||||
{ freq: 420, durMs: 280 },
|
||||
{ freq: 0, durMs: 180 },
|
||||
{ freq: 320, durMs: 300 },
|
||||
{ freq: 0, durMs: 220 },
|
||||
];
|
||||
|
||||
const samples: number[] = [];
|
||||
for (const seg of segments) {
|
||||
const count = Math.round((seg.durMs / 1000) * sampleRate);
|
||||
for (let i = 0; i < count; i++) {
|
||||
samples.push(seg.freq > 0 ? amplitude * Math.sin((2 * Math.PI * seg.freq * i) / sampleRate) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
const dataLength = samples.length * 2;
|
||||
const buffer = Buffer.alloc(44 + dataLength);
|
||||
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
|
||||
writeAsciiString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeAsciiString(view, 8, "WAVE");
|
||||
writeAsciiString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true); // byte rate(mono·16비트)
|
||||
view.setUint16(32, 2, true); // block align
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeAsciiString(view, 36, "data");
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function routeSessionScreen(page: Page) {
|
||||
const startedAt = new Date(Date.now() - 60_000);
|
||||
|
||||
// catch-all을 먼저 등록한다 — Playwright는 나중에 등록한 route가 이긴다.
|
||||
await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this fixture" }, 404)));
|
||||
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-0avatarspeech",
|
||||
email: "avatar-speech.learner@hs.ac.kr",
|
||||
display_name: "학습자",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: Math.floor(Date.now() / 1000),
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
nickname: "학습자",
|
||||
self_introduction: "아바타 v3 발화 연결 E2E 검증용 학습자입니다.",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
pilot_id: "phase3-pilot-draft",
|
||||
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
||||
measures: [],
|
||||
complete_pre_count: 0,
|
||||
complete_post_count: 0,
|
||||
updated_at: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/personas", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute([
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "서연(가명) · 고2 · 우울/자살사고",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic", "cbt"],
|
||||
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
|
||||
presenting_summary: "우울감과 자살사고 위험",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: "e2e fixture" })),
|
||||
);
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{
|
||||
session_id: SESSION_ID,
|
||||
case_id: "avatar-speech-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.3,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "avatar-speech-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "서연",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.3,
|
||||
started_at: startedAt.toISOString(),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// 회기 전 자기점검(pre)은 이미 원장에 잠긴 상태로 제공한다 — 이 스펙의 대상이 아니다.
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill(jsonRoute({ pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "awaiting_agents" }, 202));
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
checkpoint: "pre",
|
||||
status: "ready",
|
||||
learner_locked_at: startedAt.toISOString(),
|
||||
revealed_at: startedAt.toISOString(),
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
||||
route.fulfill(jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [] })),
|
||||
);
|
||||
}
|
||||
|
||||
function doneEvent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
session_id: SESSION_ID,
|
||||
stage: "라포",
|
||||
effective_openness: 0.42,
|
||||
turn_seq: 1,
|
||||
safety_flagged: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendLearnerTurn(page: Page) {
|
||||
const input = page.getByLabel("학습자 발화 입력");
|
||||
await input.fill(LEARNER_TEXT);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
}
|
||||
|
||||
/** 재생 중 data-viseme가 거친 서로 다른 값의 집합(관찰 상한 5초, 50ms 간격 표본). */
|
||||
async function collectDistinctVisemes(locator: Locator, minDistinct: number): Promise<Set<string>> {
|
||||
const seen = new Set<string>();
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const viseme = await locator.getAttribute("data-viseme");
|
||||
if (viseme) seen.add(viseme);
|
||||
if (seen.size >= minDistinct) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
test.describe("P1 세션 — v3 아바타 TTS 발화 연결", () => {
|
||||
test("TTS 오디오 재생 중 립싱크가 돌고, 끝나면 발화 속성이 정리된다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await page.route("**/api/voice/speech", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "audio/wav", body: buildToneBurstWav() }),
|
||||
);
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent()));
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']");
|
||||
await expect(v3).toHaveAttribute("data-speech-source", "audio", { timeout: 15_000 });
|
||||
|
||||
const visemes = await collectDistinctVisemes(v3, 2);
|
||||
expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await expect(v3).not.toHaveAttribute("data-speech-source", "audio", { timeout: 10_000 });
|
||||
await expect(v3).toHaveAttribute("data-viseme", "X", { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("TTS 실패(500)면 텍스트 타이밍 발화로 떨어진다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await page.route("**/api/voice/speech", (route) => route.fulfill(jsonRoute({ detail: "tts failure fixture" }, 500)));
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent()));
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']");
|
||||
await expect(v3).toHaveAttribute("data-speech-source", "text", { timeout: 15_000 });
|
||||
|
||||
const visemes = await collectDistinctVisemes(v3, 2);
|
||||
expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -666,6 +666,62 @@ test.describe("learner session full-screen layout", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// P1 서연은 v3(리노컷) 아바타로 그린다(결정문 §8.5). 위 "dense viewport" 스윕을 P1로도
|
||||
// 돌려 v3 래퍼(절대 위치·원형 클립)가 기존 SVG와 같은 레이아웃 게이트를 통과하는지 본다.
|
||||
test("keeps critical session controls visible across dense viewport sizes for the v3 avatar (P1)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsLearner(page);
|
||||
|
||||
const viewports = [
|
||||
{ width: 1366, height: 768 },
|
||||
{ width: 1180, height: 768 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 881, height: 768 },
|
||||
{ width: 820, height: 1180 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 320, height: 568 },
|
||||
];
|
||||
|
||||
await page.setViewportSize(viewports[0]);
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await completeAlliancePreCheckpoint(page);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
await expectNoDocumentOverflow(page);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectSessionControlsInsideViewport(page);
|
||||
await expectNoVisibleSessionPanelOverlap(page);
|
||||
await expectMainControlsUnclipped(page);
|
||||
await expectSessionPageHeightToMatchViewport(page);
|
||||
await expectActiveSessionUsableLayout(page);
|
||||
|
||||
/* 320×568 저높이 폰은 session.css가 아바타 오브(.sx-orb-wrap)를 숨긴다(기존 SVG도 같다).
|
||||
보일 때는 v3 래퍼가 스테이지 원 자리를 정확히 차지해야 한다. */
|
||||
const orbShown = !(viewport.width === 320 && viewport.height === 568);
|
||||
const stage = page.locator(".sx-page--active .vg-avatar__stage").first();
|
||||
if (orbShown) {
|
||||
await expect(stage).toBeVisible();
|
||||
const v3 = stage.locator('[data-avatar-renderer="linocut"]');
|
||||
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
|
||||
const [stageBox, v3Box] = await Promise.all([stage.boundingBox(), v3.boundingBox()]);
|
||||
expect(stageBox, "아바타 스테이지 좌표").not.toBeNull();
|
||||
expect(v3Box, "v3 래퍼 좌표").not.toBeNull();
|
||||
for (const key of ["x", "y", "width", "height"] as const) {
|
||||
expect(Math.abs(stageBox![key] - v3Box![key]), `v3 래퍼 ${key}`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
} else {
|
||||
await expect(page.locator(".sx-page--active .sx-orb-wrap")).toBeHidden();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("does not leave an unsaved local transcript when a text turn is rejected", async ({ page }) => {
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import { Brows } from "./Brows";
|
|||
import { Mouth } from "./Mouth";
|
||||
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
|
||||
import { useExpressionTransition } from "./useExpressionTransition";
|
||||
import ClientAvatarV3, { type AvatarSpeech } from "./v3/ClientAvatarV3";
|
||||
import { linocutRigFor } from "./v3/rigs";
|
||||
import "./client-avatar.css";
|
||||
|
||||
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
|
||||
|
|
@ -48,6 +50,7 @@ export {
|
|||
type AvatarPersona,
|
||||
} from "./persona";
|
||||
export type { Live2DExpressionMotion, Live2DPersonaModel } from "./live2dModel";
|
||||
export type { AvatarSpeech } from "./v3/ClientAvatarV3";
|
||||
|
||||
export interface ClientAvatarProps {
|
||||
persona: AvatarPersona;
|
||||
|
|
@ -65,6 +68,15 @@ export interface ClientAvatarProps {
|
|||
* null/미지정이면 speaking 동안 차분한 의사 발화 모션.
|
||||
*/
|
||||
speakingProgress?: number | null;
|
||||
/**
|
||||
* 개방도 0~1(세션 effective_openness). v3(리노컷) 리그가 있는 페르소나에서만 쓴다.
|
||||
* 미지정이면 rapport로 대신한다(결정문 §8.5).
|
||||
*/
|
||||
openness?: number;
|
||||
/** 겉표정 강도 0~1(performance.ts surfaceIntensityFor). 미지정이면 0.5. v3 전용. */
|
||||
surfaceIntensity?: number;
|
||||
/** TTS 발화 구동(결정문 §8.5). v3 리그가 있는 페르소나에서만 립싱크·발화 동반층을 돈다. */
|
||||
speech?: AvatarSpeech | null;
|
||||
/** px 지름 (기본 220 — §5.2 아바타 220px) */
|
||||
size?: number;
|
||||
className?: string;
|
||||
|
|
@ -215,6 +227,9 @@ export function ClientAvatar({
|
|||
analyser = null,
|
||||
rapport = 0,
|
||||
speakingProgress = null,
|
||||
openness,
|
||||
surfaceIntensity,
|
||||
speech = null,
|
||||
size = 220,
|
||||
className,
|
||||
animated = true,
|
||||
|
|
@ -222,7 +237,29 @@ export function ClientAvatar({
|
|||
showMeta = true,
|
||||
}: ClientAvatarProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const motionEnabled = animated && !reduced;
|
||||
|
||||
/* v3(리노컷) 분기(결정문 §8.5) — persona.code로 판정(지금은 P1만). 리그가 있으면
|
||||
그림 영역을 ClientAvatarV3로 그린다. 로드가 끝날 때까지 기존 SVG를 그대로 보이고,
|
||||
ready가 되면 300ms 불투명도 전환으로 v3를 위에 올린 뒤 기존 SVG를 언마운트해
|
||||
rAF를 멈춘다. error면 v3를 언마운트하고 기존 SVG로 남는다(§8.4 로드 실패 규칙). */
|
||||
const rig = useMemo(() => linocutRigFor(persona.code), [persona.code]);
|
||||
const [v3LoadState, setV3LoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [legacyMounted, setLegacyMounted] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setV3LoadState("loading");
|
||||
setLegacyMounted(true);
|
||||
}, [rig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (v3LoadState !== "ready") return;
|
||||
const transitionMs = reduced ? 0 : 300;
|
||||
const timer = window.setTimeout(() => setLegacyMounted(false), transitionMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [v3LoadState, reduced]);
|
||||
|
||||
const v3Active = rig !== null && v3LoadState !== "error";
|
||||
const motionEnabled = animated && !reduced && legacyMounted;
|
||||
|
||||
// 외형 안전값
|
||||
const skin = persona.skinTone;
|
||||
|
|
@ -275,6 +312,10 @@ export function ClientAvatar({
|
|||
const shoulderRotate = params.shoulderTurn * 0.4;
|
||||
const mouthOpen = Math.max(mouth, params.mouthOpen);
|
||||
|
||||
// v3 전용 보정값(결정문 §8.5): 없으면 openness는 rapport로, 강도는 0.5로 둔다.
|
||||
const v3Openness = openness ?? rapport ?? 0.5;
|
||||
const v3SurfaceIntensity = surfaceIntensity ?? 0.5;
|
||||
|
||||
return (
|
||||
<figure
|
||||
className={"vg-avatar" + (className ? " " + className : "")}
|
||||
|
|
@ -307,66 +348,84 @@ export function ClientAvatar({
|
|||
) : null}
|
||||
|
||||
<div className="vg-avatar__stage" style={{ height: size }}>
|
||||
{/* 호흡하는 광배 */}
|
||||
<AuraLayer
|
||||
hue={params.auraHue}
|
||||
opacity={params.auraOpacity}
|
||||
saturation={age.auraSaturation}
|
||||
state={state}
|
||||
mouth={mouth}
|
||||
reduced={reduced}
|
||||
/>
|
||||
{legacyMounted ? (
|
||||
<>
|
||||
{/* 호흡하는 광배 */}
|
||||
<AuraLayer
|
||||
hue={params.auraHue}
|
||||
opacity={params.auraOpacity}
|
||||
saturation={age.auraSaturation}
|
||||
state={state}
|
||||
mouth={mouth}
|
||||
reduced={reduced}
|
||||
/>
|
||||
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
||||
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
||||
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
||||
|
||||
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
||||
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
||||
<NeckBridge skin={skin} />
|
||||
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
||||
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
||||
<NeckBridge skin={skin} />
|
||||
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
|
||||
<HairFront style={hairStyle} color={hairColor} />
|
||||
<HairFront style={hairStyle} color={hairColor} />
|
||||
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows
|
||||
browTilt={params.browTilt}
|
||||
browLift={params.browLift}
|
||||
browPinch={params.browPinch}
|
||||
color={hairColor}
|
||||
/>
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyeOpen={params.eyeOpen}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
pupilScale={params.pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth
|
||||
open={mouthOpen}
|
||||
curve={params.mouthCurve}
|
||||
width={params.mouthWidth}
|
||||
tension={params.mouthTension}
|
||||
color="#9B5B52"
|
||||
/>
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows
|
||||
browTilt={params.browTilt}
|
||||
browLift={params.browLift}
|
||||
browPinch={params.browPinch}
|
||||
color={hairColor}
|
||||
/>
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyeOpen={params.eyeOpen}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
pupilScale={params.pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth
|
||||
open={mouthOpen}
|
||||
curve={params.mouthCurve}
|
||||
width={params.mouthWidth}
|
||||
tension={params.mouthTension}
|
||||
color="#9B5B52"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{rig && v3Active ? (
|
||||
<ClientAvatarV3
|
||||
rig={rig}
|
||||
code={persona.code}
|
||||
state={state}
|
||||
affect={affect}
|
||||
openness={v3Openness}
|
||||
surfaceIntensity={v3SurfaceIntensity}
|
||||
speech={speech}
|
||||
running={animated}
|
||||
reducedMotion={reduced}
|
||||
onLoadStateChange={setV3LoadState}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 페르소나 메타 + 상태 텍스트 */}
|
||||
|
|
|
|||
153
apps/web/src/components/avatar/engine/speechDriver.ts
Normal file
153
apps/web/src/components/avatar/engine/speechDriver.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화 구동 공용 모듈 — 결정문 §8.5 "세션 연결". AvatarLab의
|
||||
runSpeechTimeline / playSpeech / playSpeechWithAudioFile / endSpeech가
|
||||
하던 엔진 구동 부분(buildPerformance → buildSpeechTimeline[+포락선]
|
||||
→ buildCoSpeechPlan → playPerformance → 매 프레임 표본)을 여기로 옮겨
|
||||
Lab과 v3 래퍼(ClientAvatarV3)가 함께 쓴다. 화면 표시(ref 갱신)는 onFrame
|
||||
콜백으로 호출자에 남긴다. 발화 종료 시 상태 복귀(Lab은 listening, 래퍼는
|
||||
prop state)도 호출자가 onEnd에서 정한다 — 이 모듈은 setSpeechShape(null)·
|
||||
setSpeechMotion(null)과 rAF 해제까지만 한다.
|
||||
===================================================================== */
|
||||
|
||||
import type { AvatarExpression } from "../persona";
|
||||
import type { AvatarEngine } from "./engine";
|
||||
import type { SpeechStyle } from "./demeanorDefaults";
|
||||
import { buildPerformance, type PerformanceCue } from "./performance";
|
||||
import {
|
||||
buildSpeechTimeline,
|
||||
currentViseme,
|
||||
sampleSpeech,
|
||||
type PhraseKind,
|
||||
type SpeechShape,
|
||||
type SpeechTimeline,
|
||||
type VisemeId,
|
||||
} from "./lipsync";
|
||||
import { buildCoSpeechPlan, isStressPulseActive, sampleCoSpeech } from "./coSpeech";
|
||||
import { computeEnvelope, type SpeechEnvelope } from "./speechEnvelope";
|
||||
|
||||
export interface SpeechDriverAudio {
|
||||
buffer: AudioBuffer;
|
||||
context: BaseAudioContext;
|
||||
/** source.start(when)의 when(초, context 시계). */
|
||||
startAt: number;
|
||||
}
|
||||
|
||||
export interface SpeechFrameSample {
|
||||
viseme: VisemeId;
|
||||
shape: SpeechShape;
|
||||
phraseKind: PhraseKind | null;
|
||||
stressed: boolean;
|
||||
}
|
||||
|
||||
export interface StartSpeechParams {
|
||||
engine: AvatarEngine;
|
||||
/** 괄호 지문을 포함한 발화 원문. */
|
||||
text: string;
|
||||
expression: AvatarExpression;
|
||||
intensity: number;
|
||||
openness: number;
|
||||
seed: number;
|
||||
speech: SpeechStyle;
|
||||
/** 엔진 시계(engine.evaluate에 넘기는 시계와 같은 시계). */
|
||||
now: () => number;
|
||||
/** 있으면 오디오 선분석 정렬 경로. 없으면 텍스트 타이밍(speech.syllablesPerSec). */
|
||||
audio?: SpeechDriverAudio;
|
||||
/** 매 프레임 표본 결과(화면 표시용). React state 갱신은 호출자 책임(ref로 받는다). */
|
||||
onFrame?: (sample: SpeechFrameSample) => void;
|
||||
/** 타임라인이 끝까지 재생됐을 때(stop() 호출로 끝난 경우에는 부르지 않는다). */
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
export interface SpeechHandle {
|
||||
stop(): void;
|
||||
/** 표시·디버그용 — buildPerformance가 파싱한 cue와 미대응 지문. */
|
||||
cues: PerformanceCue[];
|
||||
unmatched: string[];
|
||||
}
|
||||
|
||||
/** tMs 시점까지 시작한 가장 최근 구의 종류. 구 사이 휴지 중에는 그 직전 구를 보인다. */
|
||||
function currentPhraseKind(timeline: SpeechTimeline, localMs: number): PhraseKind | null {
|
||||
let kind: PhraseKind | null = null;
|
||||
for (const ph of timeline.phrases) {
|
||||
if (ph.startMs > localMs) break;
|
||||
kind = ph.kind;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발화 하나를 시작한다. 오디오가 있으면 포락선을 선분석해 오디오 시계로 표본하고,
|
||||
* 없으면 텍스트 타이밍으로 엔진 시계에서 300ms 뒤 시작한다(결정문 §5.4·§8.5).
|
||||
* 지문은 playPerformance로 함께 재생한다. 반환된 stop()은 호출자가 발화를 바꾸거나
|
||||
* 끝낼 때 부른다(speechShape·speechMotion을 null로 되돌리고 rAF를 해제, 상태 복귀는 않는다).
|
||||
*/
|
||||
export function startSpeech(params: StartSpeechParams): SpeechHandle {
|
||||
const { engine, text, expression, intensity, openness, seed, speech, now, audio, onFrame, onEnd } = params;
|
||||
const nowMs = now();
|
||||
const { performance: perf, unmatched } = buildPerformance({ text, expression, intensity, openness, seed });
|
||||
|
||||
let envelope: SpeechEnvelope | undefined;
|
||||
let timeline: SpeechTimeline;
|
||||
let speechStartMs: number;
|
||||
let sampleClock: () => number;
|
||||
|
||||
if (audio) {
|
||||
const channels: Float32Array[] = [];
|
||||
for (let c = 0; c < audio.buffer.numberOfChannels; c++) channels.push(audio.buffer.getChannelData(c));
|
||||
envelope = computeEnvelope(channels, audio.buffer.sampleRate);
|
||||
timeline = buildSpeechTimeline({ text, syllablesPerSec: speech.syllablesPerSec, envelope });
|
||||
speechStartMs = nowMs + (audio.startAt - audio.context.currentTime) * 1000;
|
||||
/* 오디오 시계 기준(초 → ms, §8.5 "매 프레임 표본 시각은 (context.currentTime − startAt)·1000"). */
|
||||
sampleClock = () => (audio.context.currentTime - audio.startAt) * 1000 + speechStartMs;
|
||||
} else {
|
||||
timeline = buildSpeechTimeline({ text, syllablesPerSec: speech.syllablesPerSec });
|
||||
speechStartMs = nowMs + 300;
|
||||
sampleClock = now;
|
||||
}
|
||||
|
||||
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
|
||||
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
|
||||
|
||||
let rafId = 0;
|
||||
let prevLocalMs = -Infinity;
|
||||
const endMs = speechStartMs + timeline.totalDurationMs;
|
||||
|
||||
function stop(): void {
|
||||
cancelAnimationFrame(rafId);
|
||||
engine.setSpeechShape(null);
|
||||
engine.setSpeechMotion(null);
|
||||
}
|
||||
|
||||
const step = () => {
|
||||
const t = sampleClock();
|
||||
if (t < speechStartMs) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
if (t >= endMs) {
|
||||
stop();
|
||||
onEnd?.();
|
||||
return;
|
||||
}
|
||||
const localMs = t - speechStartMs;
|
||||
const shape = sampleSpeech(timeline, localMs, speech.articulation);
|
||||
engine.setSpeechShape(shape);
|
||||
|
||||
const sample = sampleCoSpeech(coSpeechPlan, localMs, prevLocalMs, envelope);
|
||||
engine.setSpeechMotion(sample.delta);
|
||||
if (sample.blinkNow) engine.requestSpeechBlink(now());
|
||||
|
||||
onFrame?.({
|
||||
viseme: currentViseme(timeline, localMs),
|
||||
shape,
|
||||
phraseKind: currentPhraseKind(timeline, localMs),
|
||||
stressed: isStressPulseActive(coSpeechPlan, localMs),
|
||||
});
|
||||
|
||||
prevLocalMs = localMs;
|
||||
rafId = requestAnimationFrame(step);
|
||||
};
|
||||
rafId = requestAnimationFrame(step);
|
||||
|
||||
return { stop, cues: perf.cues, unmatched };
|
||||
}
|
||||
210
apps/web/src/components/avatar/v3/ClientAvatarV3.tsx
Normal file
210
apps/web/src/components/avatar/v3/ClientAvatarV3.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/* =====================================================================
|
||||
ClientAvatarV3 — 리노컷 리그가 있는 페르소나를 위한 v3 그림 영역 래퍼.
|
||||
결정문 §8.5 "세션 연결". ClientAvatar가 그림 영역만 이 컴포넌트로 바꿔
|
||||
그린다(루트 data-*·캡션·메타는 ClientAvatar가 그대로 유지한다).
|
||||
|
||||
엔진은 이 컴포넌트가 소유한다(persona.code로 demeanor·시드를 만든다).
|
||||
시계는 모듈 수준 상수 함수(engineNowMs)를 LinocutAvatar에 안정 참조로
|
||||
넘긴다 — 인라인 화살표를 넘기면 리렌더마다 rAF가 다시 시작된다.
|
||||
크롭(bust/face)은 이 래퍼의 실측 폭(ResizeObserver)으로 정한다 —
|
||||
session.css의 !important 규칙이 실제 렌더 크기를 덮어쓰기 때문에 size
|
||||
prop은 쓰지 않는다(§8.5).
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AvatarExpression, AvatarState } from "../persona";
|
||||
import { createAvatarEngine } from "../engine/engine";
|
||||
import { demeanorFor } from "../engine/demeanorDefaults";
|
||||
import { hashString } from "../engine/rng";
|
||||
import { startSpeech, type SpeechDriverAudio } from "../engine/speechDriver";
|
||||
import type { VisemeId } from "../engine/lipsync";
|
||||
import LinocutAvatar from "./LinocutAvatar";
|
||||
import { observeWidth } from "./observeWidth";
|
||||
import type { LinocutRig, RigCrop } from "./linocutRig";
|
||||
import "./client-avatar-v3.css";
|
||||
|
||||
/** ClientAvatar의 speech? prop 타입 — ClientAvatar 모듈에서 재노출한다. */
|
||||
export interface AvatarSpeech {
|
||||
id: string;
|
||||
/** 괄호 지문을 포함한 답변 원문. */
|
||||
text: string;
|
||||
/** 있으면 오디오 선분석 경로. startAt은 source.start(when)의 when(초, context 시계). */
|
||||
audio?: SpeechDriverAudio;
|
||||
}
|
||||
|
||||
export interface ClientAvatarV3Props {
|
||||
rig: LinocutRig;
|
||||
code: string | null | undefined;
|
||||
state: AvatarState;
|
||||
affect: AvatarExpression;
|
||||
/** 호출부가 openness ?? rapport ?? 0.5 로 보정해 넘긴다. */
|
||||
openness: number;
|
||||
/** 호출부가 surfaceIntensity ?? 0.5 로 보정해 넘긴다. */
|
||||
surfaceIntensity: number;
|
||||
speech?: AvatarSpeech | null;
|
||||
/** false면 rAF를 멈추고 정지한 한 프레임만 그린다(시작 전 화면·썸네일). */
|
||||
running: boolean;
|
||||
reducedMotion: boolean;
|
||||
className?: string;
|
||||
onLoadStateChange?: (state: "loading" | "ready" | "error") => void;
|
||||
}
|
||||
|
||||
const CROP_BUST_MIN_WIDTH = 120;
|
||||
|
||||
/** 엔진 시계는 performance.now()다. 안정 참조로 LinocutAvatar의 nowMs에 넘긴다
|
||||
(인라인 화살표를 넘기면 리렌더마다 rAF가 다시 시작된다, §8.5). */
|
||||
function engineNowMs(): number {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
export default function ClientAvatarV3({
|
||||
rig,
|
||||
code,
|
||||
state,
|
||||
affect,
|
||||
openness,
|
||||
surfaceIntensity,
|
||||
speech = null,
|
||||
running,
|
||||
reducedMotion,
|
||||
className,
|
||||
onLoadStateChange,
|
||||
}: ClientAvatarV3Props) {
|
||||
const demeanor = useMemo(() => demeanorFor(code), [code]);
|
||||
const seed = useMemo(() => hashString(code ?? ""), [code]);
|
||||
const engine = useMemo(
|
||||
() => createAvatarEngine({ demeanor, seed, reducedMotion }),
|
||||
[demeanor, seed, reducedMotion],
|
||||
);
|
||||
|
||||
const stateRef = useRef(state);
|
||||
const speakingRef = useRef(false);
|
||||
|
||||
/* 엔진이 바뀌면(페르소나·reduced motion 전환) 지금 값으로 한 번에 다시 맞춘다
|
||||
(AvatarLab.tsx의 같은 패턴). */
|
||||
useEffect(() => {
|
||||
engine.setState(speakingRef.current ? "speaking" : state, engineNowMs());
|
||||
engine.setOpenness(openness);
|
||||
engine.setSurface(affect, surfaceIntensity);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
if (!speakingRef.current) engine.setState(state, engineNowMs());
|
||||
}, [engine, state]);
|
||||
|
||||
useEffect(() => {
|
||||
engine.setOpenness(openness);
|
||||
}, [engine, openness]);
|
||||
|
||||
useEffect(() => {
|
||||
engine.setSurface(affect, surfaceIntensity);
|
||||
}, [engine, affect, surfaceIntensity]);
|
||||
|
||||
/* 래퍼 박스 루트 — 크롭 판정(ResizeObserver)과 발화 data-* 속성 쓰기가 함께 쓴다. */
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
/* 크롭(§8.5): 래퍼 박스의 실측 폭. 120px 이상이면 bust, 미만이면 face다. */
|
||||
const [crop, setCrop] = useState<RigCrop>("face");
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
return observeWidth(el, (widthPx) => setCrop(widthPx >= CROP_BUST_MIN_WIDTH ? "bust" : "face"));
|
||||
}, []);
|
||||
|
||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
function handleLoadStateChange(next: "loading" | "ready" | "error"): void {
|
||||
setLoadState(next);
|
||||
onLoadStateChange?.(next);
|
||||
}
|
||||
|
||||
/* 발화(§8.5·§5.4·§5.5): id가 바뀌면 이전 발화를 멈추고 새 발화를 시작한다. 발화가
|
||||
진행 중이면 엔진 상태를 "speaking"으로 유지하고, 끝나면 그때의 prop state로
|
||||
되돌린다. 프레임마다 바뀌는 data-viseme은 React state가 아니라 ref로 쓴다. */
|
||||
const speechHandleRef = useRef<ReturnType<typeof startSpeech> | null>(null);
|
||||
const activeSpeechIdRef = useRef<string | null>(null);
|
||||
|
||||
function writeSpeechAttrs(source: "audio" | "text" | null, viseme: VisemeId): void {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
if (source) {
|
||||
el.setAttribute("data-speech-source", source);
|
||||
el.setAttribute("data-viseme", viseme);
|
||||
} else {
|
||||
el.removeAttribute("data-speech-source");
|
||||
el.setAttribute("data-viseme", "X");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!speech) {
|
||||
if (activeSpeechIdRef.current !== null) {
|
||||
speechHandleRef.current?.stop();
|
||||
speechHandleRef.current = null;
|
||||
activeSpeechIdRef.current = null;
|
||||
speakingRef.current = false;
|
||||
engine.setState(stateRef.current, engineNowMs());
|
||||
writeSpeechAttrs(null, "X");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (speech.id === activeSpeechIdRef.current) return;
|
||||
|
||||
speechHandleRef.current?.stop();
|
||||
activeSpeechIdRef.current = speech.id;
|
||||
speakingRef.current = true;
|
||||
engine.setState("speaking", engineNowMs());
|
||||
const source: "audio" | "text" = speech.audio ? "audio" : "text";
|
||||
writeSpeechAttrs(source, "X");
|
||||
|
||||
speechHandleRef.current = startSpeech({
|
||||
engine,
|
||||
text: speech.text,
|
||||
expression: affect,
|
||||
intensity: surfaceIntensity,
|
||||
openness,
|
||||
seed,
|
||||
speech: demeanor.speech,
|
||||
now: engineNowMs,
|
||||
audio: speech.audio,
|
||||
onFrame: ({ viseme }) => writeSpeechAttrs(source, viseme),
|
||||
onEnd: () => {
|
||||
speakingRef.current = false;
|
||||
speechHandleRef.current = null;
|
||||
activeSpeechIdRef.current = null;
|
||||
engine.setState(stateRef.current, engineNowMs());
|
||||
writeSpeechAttrs(null, "X");
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [speech]);
|
||||
|
||||
/* 언마운트 시 발화 rAF를 해제한다(페르소나 전환 등으로 이 래퍼 자체가 사라질 때). */
|
||||
useEffect(() => {
|
||||
return () => speechHandleRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const transitionMs = reducedMotion ? 0 : 300;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={"vg-avatar-v3" + (className ? " " + className : "")}
|
||||
data-avatar-renderer="linocut"
|
||||
data-load-state={loadState}
|
||||
data-viseme="X"
|
||||
style={{ opacity: loadState === "ready" ? 1 : 0, transitionDuration: `${transitionMs}ms` }}
|
||||
>
|
||||
<LinocutAvatar
|
||||
rig={rig}
|
||||
engine={engine}
|
||||
running={running}
|
||||
nowMs={engineNowMs}
|
||||
crop={crop}
|
||||
backdropExpression={affect}
|
||||
onLoadStateChange={handleLoadStateChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type LinocutFrame,
|
||||
type JawWarpLayer,
|
||||
} from "./linocutGeometry";
|
||||
import { observeWidth } from "./observeWidth";
|
||||
import "./linocut-avatar.css";
|
||||
|
||||
export interface LinocutAvatarProps {
|
||||
|
|
@ -442,25 +443,23 @@ export default function LinocutAvatar({
|
|||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
const viewBoxWidth = rig.crops[crop][2];
|
||||
const update = (widthPx: number) => {
|
||||
if (widthPx <= 0) return;
|
||||
return observeWidth(el, (widthPx) => {
|
||||
const k = widthPx / viewBoxWidth;
|
||||
sizeScaleRef.current = Math.min(2, Math.max(1, 0.3 / k));
|
||||
};
|
||||
update(el.getBoundingClientRect().width);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
||||
update(w);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [rig, crop]);
|
||||
|
||||
/* running=false(§8.5 세션 연결 — 시작 전 화면·썸네일)여도 정지한 한 프레임은 계산해
|
||||
그린다. 그렇지 않으면 벡터 부위(눈·눈썹·입)가 비어 보인다. 마운트할 때, loadState가
|
||||
ready가 될 때, engine·rig·crop이 바뀔 때 다시 계산한다(crop·loadState를 deps에
|
||||
넣는다). running=true면 그대로 rAF 루프를 돈다. */
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const clock = nowMs ?? (() => performance.now());
|
||||
if (!running) {
|
||||
const v = engine.evaluate(clock());
|
||||
applyFrame(refs.current, computeLinocutFrame(rig, v, sizeScaleRef.current));
|
||||
return;
|
||||
}
|
||||
const loop = () => {
|
||||
const v = engine.evaluate(clock());
|
||||
const frame = computeLinocutFrame(rig, v, sizeScaleRef.current);
|
||||
|
|
@ -469,7 +468,7 @@ export default function LinocutAvatar({
|
|||
};
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [engine, running, nowMs, rig]);
|
||||
}, [engine, running, nowMs, rig, crop, loadState]);
|
||||
|
||||
const viewBox = rig.crops[crop].join(" ");
|
||||
const faceClipId = `${uid}-face-clip`;
|
||||
|
|
|
|||
20
apps/web/src/components/avatar/v3/client-avatar-v3.css
Normal file
20
apps/web/src/components/avatar/v3/client-avatar-v3.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/* =====================================================================
|
||||
client-avatar-v3.css — ClientAvatarV3 래퍼 스타일. 결정문 §8.5 "화면".
|
||||
|
||||
원형 오브 안(.vg-avatar__stage, position:relative)에 꽉 차게 올라가
|
||||
기존 SVG와 정확히 같은 자리를 차지한다(inset:0) — session.css의
|
||||
!important 크기 규칙이 스테이지 쪽에 이미 걸려 있어 이 규칙만으로
|
||||
모든 브레이크포인트에서 기존 SVG와 같은 크기가 된다. 원형이 아닌 곳에
|
||||
이 컴포넌트를 쓰면 border-radius는 그대로 두되 바깥에서 inset을
|
||||
덮어써야 한다(지금은 호출부가 모두 원형 오브다).
|
||||
===================================================================== */
|
||||
|
||||
.vg-avatar-v3 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
15
apps/web/src/components/avatar/v3/observeWidth.ts
Normal file
15
apps/web/src/components/avatar/v3/observeWidth.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** 요소의 렌더 폭(px)을 지금 한 번, 이후 크기가 바뀔 때마다 onWidth로 알린다(프레임마다 아님).
|
||||
폭이 0 이하인 측정(아직 배치 전)은 건너뛴다. 반환 함수로 관찰을 끝낸다. */
|
||||
export function observeWidth(el: Element, onWidth: (widthPx: number) => void): () => void {
|
||||
const report = (widthPx: number) => {
|
||||
if (widthPx > 0) onWidth(widthPx);
|
||||
};
|
||||
report(el.getBoundingClientRect().width);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
report(entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
16
apps/web/src/components/avatar/v3/rigs/index.ts
Normal file
16
apps/web/src/components/avatar/v3/rigs/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 리그 레지스트리 — 결정문 §8.5 "분기". 페르소나 코드 → 리노컷
|
||||
리그. 지금은 P1만 있고 나머지는 null(기존 SVG로 남는다). 빌드 플래그
|
||||
VITE_AVATAR_V3=0 이면 항상 null(기본은 켜짐).
|
||||
===================================================================== */
|
||||
|
||||
import type { LinocutRig } from "../linocutRig";
|
||||
import { P1_LINOCUT_RIG } from "./p1Rig";
|
||||
|
||||
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_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;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
|
||||
/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
|
||||
import type { LinocutRig } from "../linocutRig";
|
||||
|
||||
export const P1_LINOCUT_RIG: LinocutRig = {
|
||||
|
|
|
|||
|
|
@ -14,18 +14,9 @@ import { CHANNEL_IDS } from "../components/avatar/engine/channels";
|
|||
import { createAvatarEngine, type AvatarEngine, type DebugSnapshot } from "../components/avatar/engine/engine";
|
||||
import { REACTION_CLIPS, REACTION_CLIP_IDS, type ReactionClipId } from "../components/avatar/engine/clipCatalog";
|
||||
import { demeanorFor } from "../components/avatar/engine/demeanorDefaults";
|
||||
import { buildPerformance, type Performance, type PerformanceCue } from "../components/avatar/engine/performance";
|
||||
import {
|
||||
buildSpeechTimeline,
|
||||
currentViseme,
|
||||
sampleSpeech,
|
||||
type PhraseKind,
|
||||
type SpeechShape,
|
||||
type SpeechTimeline,
|
||||
type VisemeId,
|
||||
} from "../components/avatar/engine/lipsync";
|
||||
import { buildCoSpeechPlan, isStressPulseActive, sampleCoSpeech, type CoSpeechPlan } from "../components/avatar/engine/coSpeech";
|
||||
import { computeEnvelope, type SpeechEnvelope } from "../components/avatar/engine/speechEnvelope";
|
||||
import type { Performance, PerformanceCue } from "../components/avatar/engine/performance";
|
||||
import type { PhraseKind, SpeechShape, VisemeId } from "../components/avatar/engine/lipsync";
|
||||
import { startSpeech, type SpeechHandle } from "../components/avatar/engine/speechDriver";
|
||||
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression, type AvatarState } from "../components/avatar/persona";
|
||||
import "./avatar-lab.css";
|
||||
|
||||
|
|
@ -83,16 +74,6 @@ function createClock(): Clock {
|
|||
};
|
||||
}
|
||||
|
||||
/** 표시용: localMs 시점까지 시작한 가장 최근 구의 종류. 구 사이 휴지 중에는 그 직전 구를 보인다. */
|
||||
function currentPhraseKind(timeline: SpeechTimeline, localMs: number): PhraseKind | null {
|
||||
let kind: PhraseKind | null = null;
|
||||
for (const ph of timeline.phrases) {
|
||||
if (ph.startMs > localMs) break;
|
||||
kind = ph.kind;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
function seedFromQuery(): number {
|
||||
const raw = new URLSearchParams(window.location.search).get("seed");
|
||||
const parsed = raw === null ? NaN : Number(raw);
|
||||
|
|
@ -132,7 +113,7 @@ export default function AvatarLab() {
|
|||
const [unmatched, setUnmatched] = useState<string[]>([]);
|
||||
const [snapshot, setSnapshot] = useState<DebugSnapshot | null>(null);
|
||||
|
||||
const speechRafRef = useRef<number>(0);
|
||||
const speechHandleRef = useRef<SpeechHandle | null>(null);
|
||||
const meterRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||
|
||||
/* 발화층(립싱크) — 원시 SpeechShape·비짐 표시용. 채널 미터와 달리 매 프레임 갱신하지
|
||||
|
|
@ -213,7 +194,7 @@ export default function AvatarLab() {
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(speechRafRef.current);
|
||||
speechHandleRef.current?.stop();
|
||||
try {
|
||||
audioSourceRef.current?.stop();
|
||||
} catch {
|
||||
|
|
@ -247,76 +228,38 @@ export default function AvatarLab() {
|
|||
coSpeechDisplayRef.current = { phraseKind: null, stressed: false };
|
||||
}
|
||||
|
||||
/** timeline을 nowFn() 시계로 매 프레임 표본해 engine.setSpeechShape·setSpeechMotion에 흘려보낸다.
|
||||
coSpeechPlan이 있으면 발화 동반층(§5.5)도 같은 프레임에 표본한다. */
|
||||
function runSpeechTimeline(
|
||||
timeline: SpeechTimeline,
|
||||
speechStartMs: number,
|
||||
nowFn: () => number,
|
||||
articulation: number,
|
||||
coSpeechPlan: CoSpeechPlan | null,
|
||||
envelope?: SpeechEnvelope,
|
||||
): void {
|
||||
cancelAnimationFrame(speechRafRef.current);
|
||||
const endMs = speechStartMs + timeline.totalDurationMs;
|
||||
let prevLocalMs = -Infinity;
|
||||
const step = () => {
|
||||
const t = nowFn();
|
||||
if (t < speechStartMs) {
|
||||
speechRafRef.current = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
if (t >= endMs) {
|
||||
endSpeech(clock.now());
|
||||
return;
|
||||
}
|
||||
const localMs = t - speechStartMs;
|
||||
const shape = sampleSpeech(timeline, localMs, articulation);
|
||||
engine.setSpeechShape(shape);
|
||||
speechDisplayRef.current = { viseme: currentViseme(timeline, localMs), shape };
|
||||
|
||||
if (coSpeechPlan) {
|
||||
const sample = sampleCoSpeech(coSpeechPlan, localMs, prevLocalMs, envelope);
|
||||
engine.setSpeechMotion(sample.delta);
|
||||
if (sample.blinkNow) engine.requestSpeechBlink(clock.now());
|
||||
coSpeechDisplayRef.current = {
|
||||
phraseKind: currentPhraseKind(timeline, localMs),
|
||||
stressed: isStressPulseActive(coSpeechPlan, localMs),
|
||||
};
|
||||
}
|
||||
prevLocalMs = localMs;
|
||||
|
||||
speechRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
speechRafRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function playSpeech(): void {
|
||||
speechHandleRef.current?.stop();
|
||||
const nowMs = clock.now();
|
||||
const { performance: perf, unmatched: um } = buildPerformance({
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
const handle = startSpeech({
|
||||
engine,
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
speech,
|
||||
now: clock.now,
|
||||
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
|
||||
speechDisplayRef.current = { viseme, shape };
|
||||
coSpeechDisplayRef.current = { phraseKind, stressed };
|
||||
},
|
||||
onEnd: () => endSpeech(clock.now()),
|
||||
});
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec });
|
||||
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
|
||||
const speechStartMs = nowMs + 300;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
|
||||
setParsedCues(perf.cues);
|
||||
setUnmatched(um);
|
||||
runSpeechTimeline(timeline, speechStartMs, clock.now, speech.articulation, coSpeechPlan);
|
||||
speechHandleRef.current = handle;
|
||||
setParsedCues(handle.cues);
|
||||
setUnmatched(handle.unmatched);
|
||||
}
|
||||
|
||||
function handleAudioFileChange(e: ChangeEvent<HTMLInputElement>): void {
|
||||
setAudioFileName(e.target.files?.[0]?.name ?? null);
|
||||
}
|
||||
|
||||
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 포락선을 만들고, 오디오 시계로 표본한다. */
|
||||
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 speechDriver에 넘긴다(포락선 선분석·
|
||||
오디오 시계 표본은 startSpeech 안에서 한다). */
|
||||
async function playSpeechWithAudioFile(): Promise<void> {
|
||||
const file = audioFileInputRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
|
|
@ -330,21 +273,6 @@ export default function AvatarLab() {
|
|||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
|
||||
const channels: Float32Array[] = [];
|
||||
for (let c = 0; c < audioBuffer.numberOfChannels; c++) channels.push(audioBuffer.getChannelData(c));
|
||||
const envelope = computeEnvelope(channels, audioBuffer.sampleRate);
|
||||
|
||||
const nowMs = clock.now();
|
||||
const { performance: perf, unmatched: um } = buildPerformance({
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
});
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec, envelope });
|
||||
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
|
||||
|
||||
try {
|
||||
audioSourceRef.current?.stop();
|
||||
|
|
@ -356,19 +284,33 @@ export default function AvatarLab() {
|
|||
source.connect(ctx.destination);
|
||||
audioSourceRef.current = source;
|
||||
|
||||
const speechStartMs = nowMs + 300;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
|
||||
setParsedCues(perf.cues);
|
||||
setUnmatched(um);
|
||||
|
||||
const startAtCtx = ctx.currentTime + 0.3;
|
||||
source.start(startAtCtx);
|
||||
/* 오디오 시계 기준(초 → ms). Lab의 clock(performance.now() 기반)과는 별개 시계다 —
|
||||
재생 시작을 같은 300ms로 맞췄지만 독립 시계라 아주 긴 발화에서는 드리프트가 있을 수 있다. */
|
||||
const nowFn = () => (ctx.currentTime - startAtCtx) * 1000 + speechStartMs;
|
||||
runSpeechTimeline(timeline, speechStartMs, nowFn, speech.articulation, coSpeechPlan, envelope);
|
||||
|
||||
speechHandleRef.current?.stop();
|
||||
const nowMs = clock.now();
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
const handle = startSpeech({
|
||||
engine,
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
speech,
|
||||
now: clock.now,
|
||||
audio: { buffer: audioBuffer, context: ctx, startAt: startAtCtx },
|
||||
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
|
||||
speechDisplayRef.current = { viseme, shape };
|
||||
coSpeechDisplayRef.current = { phraseKind, stressed };
|
||||
},
|
||||
onEnd: () => endSpeech(clock.now()),
|
||||
});
|
||||
speechHandleRef.current = handle;
|
||||
setParsedCues(handle.cues);
|
||||
setUnmatched(handle.unmatched);
|
||||
}
|
||||
|
||||
function playLeakTest(): void {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import {
|
|||
ClientAvatar,
|
||||
expressionLabelFor,
|
||||
} from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarState, AvatarAffect } from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarAffect, AvatarSpeech, AvatarState } from "../components/avatar/ClientAvatar";
|
||||
import { surfaceIntensityFor } from "../components/avatar/engine/performance";
|
||||
import { Kicker, Button, Icon, surfaceClassName } from "../components/ui";
|
||||
import { InnerReactionCard } from "../components/inner-reaction/InnerReactionCard";
|
||||
import {
|
||||
|
|
@ -268,6 +269,9 @@ export default function Session() {
|
|||
const [voiceConsentError, setVoiceConsentError] = useState<string | null>(null);
|
||||
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
|
||||
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
|
||||
// v3(리노컷) 아바타 발화 구동(결정문 §8.5). TTS 재생 시작 시점에 세팅하고, 정지·실패·
|
||||
// 종료 경로에서 null로 되돌린다.
|
||||
const [avatarSpeech, setAvatarSpeech] = useState<AvatarSpeech | null>(null);
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const [ending, setEnding] = useState(false);
|
||||
const endCancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -346,6 +350,9 @@ export default function Session() {
|
|||
const ttsPlaybackActiveRef = useRef(false);
|
||||
const ttsRequestAbortRef = useRef<AbortController | null>(null);
|
||||
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
|
||||
// v3 아바타 발화 텍스트(결정문 §8.5 2g): 텍스트 모드는 speakTextClientTurn 직전,
|
||||
// 음성 모드는 reply 이벤트에서 담는다. 발화를 한 번 내보내면 비운다.
|
||||
const pendingSpeechTextRef = useRef<string | null>(null);
|
||||
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
|
||||
const pendingVoiceLearnerTextRef = useRef<string>("");
|
||||
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -419,6 +426,7 @@ export default function Session() {
|
|||
ttsPlaybackCleanupRef.current = null;
|
||||
if (cleanup) cleanup();
|
||||
setVoiceAnalyser(null);
|
||||
setAvatarSpeech(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1284,6 +1292,15 @@ export default function Session() {
|
|||
}
|
||||
}, [acceptConsent, consentChecked, pushSignal]);
|
||||
|
||||
// TTS를 받지 못했거나 재생하지 못한 경로(결정문 §8.5 2g): 발화 텍스트 ref에 남은
|
||||
// 텍스트가 있으면 텍스트 타이밍 발화로 v3 아바타를 말하게 한다. 내보내면 ref를 비운다.
|
||||
const speakPendingTextFallback = useCallback(() => {
|
||||
const text = pendingSpeechTextRef.current;
|
||||
pendingSpeechTextRef.current = null;
|
||||
if (!text) return;
|
||||
setAvatarSpeech({ id: randomUuid(), text });
|
||||
}, []);
|
||||
|
||||
const speakTextClientTurn = useCallback(
|
||||
async (sessionId: string, turnSeq: number) => {
|
||||
const requestId = ttsPlaybackRequestRef.current + 1;
|
||||
|
|
@ -1311,13 +1328,14 @@ export default function Session() {
|
|||
setVoiceStatus("degraded");
|
||||
setVoiceDetail("내담자 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
|
||||
pushSignal("warn", "AI 음성 재생 실패");
|
||||
speakPendingTextFallback();
|
||||
} finally {
|
||||
if (ttsRequestAbortRef.current === abortController) {
|
||||
ttsRequestAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[pushSignal],
|
||||
[pushSignal, speakPendingTextFallback],
|
||||
);
|
||||
|
||||
const appendServerClientReply = useCallback(
|
||||
|
|
@ -1477,6 +1495,7 @@ export default function Session() {
|
|||
setAvatarState("listening");
|
||||
if (!conversationStopped && !qualityRetryable) {
|
||||
if (clientReply && typeof done.turn_seq === "number") {
|
||||
pendingSpeechTextRef.current = clientReply;
|
||||
void speakTextClientTurn(liveSessionId, done.turn_seq);
|
||||
}
|
||||
void requestLiveCoach({
|
||||
|
|
@ -1613,12 +1632,15 @@ export default function Session() {
|
|||
ttsPlaybackActiveRef.current = false;
|
||||
ttsPlaybackCleanupRef.current = null;
|
||||
setVoiceAnalyser(null);
|
||||
setAvatarSpeech(null);
|
||||
setAvatarState("listening");
|
||||
setVoiceStatus("idle");
|
||||
setVoiceDetail("응답이 끝났습니다. 마이크를 다시 켜 발화하세요.");
|
||||
closeVoiceSocket();
|
||||
};
|
||||
|
||||
// TTS를 받았지만 재생하지 못한 경로(결정문 §8.5 2g): 발화 텍스트가 있으면
|
||||
// 텍스트 타이밍 발화로 v3 아바타를 대신 말하게 한다.
|
||||
const failPlayback = (detail: string, status: VoiceStatus = "error") => {
|
||||
ttsPlaybackActiveRef.current = false;
|
||||
ttsPlaybackCleanupRef.current = null;
|
||||
|
|
@ -1627,6 +1649,7 @@ export default function Session() {
|
|||
setVoiceStatus(status);
|
||||
setVoiceDetail(detail);
|
||||
closeVoiceSocket();
|
||||
speakPendingTextFallback();
|
||||
};
|
||||
|
||||
const ctx = ensureVoiceAudioContext();
|
||||
|
|
@ -1677,7 +1700,13 @@ export default function Session() {
|
|||
setAvatarState("speaking");
|
||||
setVoiceStatus("speaking");
|
||||
setVoiceDetail(`${clientName} 음성을 재생 중입니다.`);
|
||||
source.start();
|
||||
const startAt = ctx.currentTime + 0.08;
|
||||
source.start(startAt);
|
||||
const speechText = pendingSpeechTextRef.current;
|
||||
pendingSpeechTextRef.current = null;
|
||||
if (speechText) {
|
||||
setAvatarSpeech({ id: randomUuid(), text: speechText, audio: { buffer: decoded, context: ctx, startAt } });
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
setVoiceAnalyser(null);
|
||||
|
|
@ -1711,11 +1740,14 @@ export default function Session() {
|
|||
|
||||
try {
|
||||
await audio.play();
|
||||
const speechText = pendingSpeechTextRef.current;
|
||||
pendingSpeechTextRef.current = null;
|
||||
if (speechText) setAvatarSpeech({ id: randomUuid(), text: speechText });
|
||||
} catch {
|
||||
cleanupElement();
|
||||
failPlayback("브라우저가 자동 재생을 막았습니다. 자막 응답은 화면에 남겼습니다.", "degraded");
|
||||
}
|
||||
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]);
|
||||
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, speakPendingTextFallback, stopTtsPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
playTtsAudioRef.current = playTtsAudio;
|
||||
|
|
@ -1947,6 +1979,8 @@ export default function Session() {
|
|||
|
||||
if (payload.type === "reply") {
|
||||
replyReceived = true;
|
||||
// 음성 모드 발화 텍스트(결정문 §8.5 2g): reply 이벤트의 payload.text를 담는다.
|
||||
pendingSpeechTextRef.current = payload.text ?? null;
|
||||
setClientReplyPending(false);
|
||||
if (payload.stage) setStage(payload.stage);
|
||||
if (typeof payload.effective_openness === "number") {
|
||||
|
|
@ -3562,6 +3596,14 @@ export default function Session() {
|
|||
affect={avatarAffect}
|
||||
analyser={voiceAnalyser}
|
||||
rapport={meters.rapport}
|
||||
openness={openness}
|
||||
surfaceIntensity={surfaceIntensityFor({
|
||||
expression: avatarAffect,
|
||||
openness,
|
||||
safety: Boolean(safety),
|
||||
paused,
|
||||
})}
|
||||
speech={avatarSpeech}
|
||||
size={220}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
2
apps/web/src/vite-env.d.ts
vendored
2
apps/web/src/vite-env.d.ts
vendored
|
|
@ -3,6 +3,8 @@
|
|||
interface ImportMetaEnv {
|
||||
/** API 베이스 URL. 기본 "/api" (vite proxy / nginx 가 백엔드로 라우팅). */
|
||||
readonly VITE_API_BASE?: string;
|
||||
/** "0"이면 아바타 v3(리노컷)를 끄고 항상 기존 SVG를 쓴다. 기본(미설정)은 켜짐. */
|
||||
readonly VITE_AVATAR_V3?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
5
docs/avatar-art/linocut-cast-prompts/P2-front.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P2-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean man in his early thirties, an anxious office worker. A lean, slightly gaunt angular face with tension in the jaw and a faint vertical crease between his eyebrows; tired but alert eyes with light shadows under them. Short, neatly cut black hair with a side part, a little flattened as if he keeps running his hand through it, and one small cowlick sticking up at the crown. Clean-shaven with a faint stubble shadow. He wears a pale blue office shirt with the top button undone and no tie, under a dark slate-grey V-neck knit sweater, and a plain company ID lanyard cord around his neck with the badge tucked inside the sweater so only the cord shows. His shoulders are stiff and slightly raised.
|
||||
|
||||
He must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P3-front-v2.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P3-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean woman in her late twenties, a single mother of a two-year-old, warm but exhausted. Facial structure: a round, full-cheeked face with a soft short chin; gently down-turned eyes with clear double eyelids; low, softly arched eyebrows; a small rounded nose; a full lower lip. She clearly looks like an adult in her late twenties, not a teenager, with faint lines beside her nose and tired skin; heavy lower eyelids and shadows under the eyes, slightly dry lips, no makeup. Hair: dark brown, pulled back from her face into a low, messy bun that is clearly visible at the back of her head, with loose strands falling around her ears and jaw (not over her eyes or eyebrows). Clothing: a slightly pilled ochre-mustard knit sweater, a little stretched at one shoulder, over a cream t-shirt, with a small faded milk stain near the shoulder. Her shoulders are slightly dropped.
|
||||
|
||||
She must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P3-front.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P3-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean woman in her late twenties, a single mother of a two-year-old, warm but exhausted. A kind, soft face, visibly tired: heavy lower eyelids, faint shadows under the eyes, slightly dry lips, no makeup. Dark brown hair pulled back into a low, messy bun, with loose strands falling around her face and ears (not over her eyes or eyebrows). She wears a slightly pilled ochre-mustard knit sweater, a little stretched at one shoulder, over a cream t-shirt, with a small faded milk stain near the shoulder of the sweater. Her shoulders are slightly dropped.
|
||||
|
||||
She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P4-front-v2.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P4-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school girl of seventeen, a diligent top student and class president, tense and perfectionistic. Facial structure: a narrow heart-shaped face with a small pointed chin and high cheekbones; long almond-shaped eyes with thin double eyelids, slightly upturned at the outer corners; thin, straight, neatly groomed eyebrows; a narrow straight nose; a small mouth with thin lips pressed tight. Slightly too-alert eyes with faint shadows of sleeplessness. Hair: long straight black hair pulled smoothly straight back from her face and tied in a low ponytail at the nape (the ponytail mostly hidden behind her neck), forehead completely uncovered with no bangs and no hair over the forehead, only a few short baby hairs at her temples. Clothing: a navy crewneck sweater over a white collared shirt with the collar arranged neatly, and a yellow pencil tucked behind her right ear. Very upright posture with stiff, slightly raised shoulders.
|
||||
|
||||
She must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P4-front.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P4-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school girl of seventeen, a diligent top student and class president, tense and perfectionistic. A neat, carefully groomed look; slightly too-alert eyes with faint shadows of sleeplessness under them and a tense mouth. Long straight black hair tied in a low ponytail at the nape of her neck, a few short baby hairs escaping at her temples, no bangs so her forehead and eyebrows are fully visible. She wears a navy crewneck sweater over a white collared shirt with the collar arranged neatly, and a pencil tucked behind her right ear. Very upright posture with stiff, slightly raised shoulders.
|
||||
|
||||
She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P5-front-v2.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P5-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean middle-school boy of fifteen, slim and still childlike, guarded and watchful. Facial structure: a boyish face with a soft rounded jaw and some baby fat in the cheeks; a short, broad button nose; small narrow monolid eyes; thick, low, straight eyebrows; a small mouth with a fuller lower lip; ears that stick out slightly. A wary, observant look, and two or three small adolescent pimples on his forehead and chin. Hair: short dark brown hair, untidy and unstyled, soft and a little flat, with one cowlick. Clothing: an oversized plain black zip-up track jacket with no stripes and no logos, zipped halfway over a grey t-shirt, with a pair of white wired earphones hanging loose around his neck. His shoulders are slightly hunched.
|
||||
|
||||
He must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P5-front.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P5-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean middle-school boy of fifteen, slim and still a little childlike, guarded and watchful. Short dark brown hair, untidy and unstyled, with a cowlick. Straight, narrow eyes with low upper eyelids and no eyelash emphasis; a wary, observant look. Two or three small adolescent pimples on his forehead and chin. He wears an oversized black zip-up track jacket zipped halfway over a grey t-shirt, with a pair of wired earphones hanging loose around his neck. His shoulders are slightly hunched.
|
||||
|
||||
He must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P6-front-v2.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P6-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior girl of eighteen, intelligent and articulate but worn out, torn between what her parents want and what she loves. Facial structure: a long oval face with a defined jaw; deep-set eyes with natural double eyelids under a slightly heavy brow ridge; thick, straight, dark eyebrows that are her most noticeable feature; a long straight nose with a high bridge; a wide mouth with a thin upper lip. Thoughtful eyes with shadows of sleepless nights, slightly chapped lips. Hair: medium-length dark brown hair with a soft natural wave; the top half is gathered back and held with a large plain dark hair claw clip that is clearly visible at the crown of her head, and the lower half falls in waves to her shoulders, a few strands loose beside her face (not over her eyes or eyebrows). Clothing: an oversized off-white cable-knit cardigan over a grey-and-white striped shirt, with a faint smudge of charcoal drawing dust on the edge of the cardigan collar. Her shoulders are drawn slightly inward.
|
||||
|
||||
She must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P6-front.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P6-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior girl of eighteen, intelligent and articulate but worn out, torn between what her parents want and what she loves. A thoughtful face with shadows of sleepless nights under her eyes and slightly chapped lips. Medium-length dark brown hair with a soft natural wave, the top half loosely held back with a plain hair claw clip (no decoration), a few strands falling loose beside her face (not over her eyes or eyebrows). She wears an oversized off-white cable-knit cardigan over a grey-and-white striped shirt, with a faint smudge of charcoal drawing dust on the edge of the cardigan collar. Her shoulders are drawn slightly inward.
|
||||
|
||||
She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P7-front-v2.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P7-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior boy of eighteen, tall and lanky, visibly burnt out. Facial structure: a long, angular face with a strong jaw and sharp cheekbones; long narrow eyes with heavy, drooping monolid upper eyelids; thin straight eyebrows set low; a long nose with a prominent bridge; thin lips; a visible Adam's apple. He looks older and more gaunt than a middle-school boy. Dark circles under his eyes, a pale tired complexion, and a faint shadow of an unshaven upper lip. Hair: short black hair flattened on one side and sticking up at the back as if he just got out of bed, with an uneven side part. Clothing: a rumpled charcoal-grey crewneck sweatshirt with the wrinkled white collar of a school shirt showing at the neck. Slumped shoulders. Not a delinquent: an ordinary, exhausted student.
|
||||
|
||||
He must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P7-front.txt
Normal file
5
docs/avatar-art/linocut-cast-prompts/P7-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior boy of eighteen, tall and lanky, visibly burnt out. Short black hair flattened on one side and sticking up at the back as if he just got out of bed, with an uneven side part. Heavy drooping upper eyelids, dark circles under his eyes, a pale, slightly sallow complexion, and a faint shadow of an unshaven upper lip. He wears a rumpled charcoal-grey crewneck sweatshirt with the wrinkled white collar of a school shirt showing at the neck. Slumped shoulders. Not a delinquent: an ordinary, exhausted student.
|
||||
|
||||
He must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
BIN
docs/avatar-art/linocut-cast-prompts/style-ref.png
Normal file
BIN
docs/avatar-art/linocut-cast-prompts/style-ref.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 930 KiB |
74
docs/avatar-art/linocut-cast.md
Normal file
74
docs/avatar-art/linocut-cast.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# 리노컷 아바타 캐스트 설계 (P1~P7)
|
||||
|
||||
아바타 v3 리노컷 리그([결정문](../decisions/avatar-expression-engine-v3.md) §8)의 7명 외형·상징 설계다. 오케스트레이터가 캐릭터 카드(`apps/api/app/services/persona.py` SEED_PERSONAS, `data/personas/P4~P7.json`)와 성향(`apps/web/src/components/avatar/engine/demeanorDefaults.ts`)에서 끌어냈다. 4단계에서 캐릭터 카드 `avatar.look` 블록(§8.3)으로 옮긴다.
|
||||
|
||||
## 공통 원칙
|
||||
|
||||
- **한 판화가의 연작**처럼 화풍을 통일한다. 검은 남색 조각선, 크림 종이, 잉크 두 색(슬레이트 블루 `#53626C`, 황토 `#D0A362`)이다. 캐릭터는 얼굴·체형·헤어·의상과 **사람다운 디테일 하나**로 구분한다. 7월 래스터가 반려된 이유 중 하나가 "같은 얼굴에 머리만 다름"이었다.
|
||||
- 실제 한국인 한 사람처럼 그린다. 애니·웹툰·아이돌풍, 큰 눈, 속눈썹 강조, 미화를 금지한다. 교복을 캐릭터 기호로 쓰지 않는다(소유자 반려 사유: 애니풍 교복).
|
||||
- **리그 제약**
|
||||
- 정면, 입 다묾, 정면 응시, 어깨 수평, 머리·목·어깨만 그리고 손은 프레임 밖이다.
|
||||
- 눈·눈썹·입이 머리카락·손·소품에 가리지 않는다. 안경은 쓰지 않는다(벡터 눈 위에 테가 와야 하는데 head 레이어에 있으면 눈 아래로 깔린다).
|
||||
- 피부에 상처·자국을 그리지 않는다(자해 연상 금지).
|
||||
- 구도·크기·종이는 P1 정면 원화(`p1-linocut/base/base-front.png`)를 화풍·구도 참조로 쓴다. 같은 머리 크기·위치여야 같은 렌더러 변환 수치가 통한다.
|
||||
- 상징 모티프는 P1 봉오리와 같은 문법이다. 한 대상의 네 상태 — 닫힘·반쯤·활짝(개방도 0 → 1)과 처짐(부정 겉표정) — 를 같은 크기·같은 밑동으로 그린다. 날씨 5종은 P1 스프라이트를 모두가 함께 쓴다.
|
||||
|
||||
## 혼동 쌍 구분
|
||||
|
||||
| 쌍 | 공통점 | 구분 |
|
||||
|---|---|---|
|
||||
| P4 하늘 / P6 하린 | 상위권 여고생, 부모 기대 | P4: 낮게 묶은 긴 생머리·남색 스웨터·귀에 꽂은 연필·꼿꼿함 / P6: 반쯤 올려 집게핀으로 고정한 웨이브 머리·오프화이트 케이블 카디건·줄무늬 셔츠·목깃의 목탄 가루·움츠림 |
|
||||
| P5 도윤 / P7 도현 | 무덤덤한 남학생, 반존대 | P5: 15세 앳된 얼굴·검은 트랙 재킷·목에 건 유선 이어폰·경계하는 눈 / P7: 18세 마르고 긴 체형·눌린 뒷머리·회색 맨투맨 위로 구겨진 흰 셔츠 깃·무거운 눈꺼풀 |
|
||||
| P3 지우 / P6 하린 | 니트 상의 | P3: 황토색 니트 스웨터·낮은 올림머리 / P6: 오프화이트 카디건 |
|
||||
|
||||
## 캐릭터별
|
||||
|
||||
### P1 서연 (기존)
|
||||
- 고2 여학생, 우울·자퇴 고민. 어깨 길이의 흐트러진 흑갈색 머리를 한쪽 귀 뒤로 넘김, 왼눈 밑 점, 잠 못 잔 그늘, 바랜 차콜 후드에 흰 티, 처진 어깨
|
||||
- 상징: 꽃봉오리(닫힘·반쯤·활짝·고개 숙임)
|
||||
|
||||
### P2 민재
|
||||
- 32세 남성 직장인, 범불안·신체화, 완벽주의·점검, 장황하고 빠른 말, 어깨가 올라가 있다.
|
||||
- 외형: 마르고 각진 얼굴, 턱의 긴장, 미간의 옅은 세로 주름, 지쳤지만 경계하는 눈과 옅은 눈 밑 그늘. 짧게 정돈한 검은 옆가르마 머리는 손으로 자꾸 쓸어 눌렸고 정수리에 작은 뻗침이 있다. 면도한 턱에 옅은 수염 그늘.
|
||||
- 의상: 맨 위 단추를 푼 연청색 셔츠(넥타이 없음) 위 진회색 브이넥 니트, 니트 안으로 넣은 **사원증 목줄**(끈만 보임). 어깨가 굳어 살짝 올라감.
|
||||
- 상징: **회중시계** — 닫힘: 뚜껑이 닫히고 줄이 촘촘히 감김 / 반쯤: 뚜껑이 조금 열려 문자판이 보임 / 활짝: 뚜껑이 활짝 열려 차분한 문자판 / 처짐: 닫힌 시계가 엉킨 줄 끝에 매달려 늘어짐. 통제·점검·시간 압박.
|
||||
|
||||
### P3 지우
|
||||
- 28세 여성 미혼모(2세 아이), 소진·외로움, 따뜻하지만 지친 톤, 혼자 짊어진다.
|
||||
- 외형: 부드럽고 따뜻한 인상, 눈에 띄게 지친 얼굴 — 무거운 아래 눈꺼풀, 눈 밑 그늘, 마른 입술, 화장기 없음. 흑갈색 머리를 낮게 대충 틀어 올리고 잔머리가 얼굴과 귀 옆으로 흘러내린다.
|
||||
- 의상: 보풀 핀 **황토색 니트 스웨터**(어깨 쪽이 조금 늘어남) 안에 크림색 티셔츠, **어깨 근처의 옅은 우유 얼룩**. 어깨가 조금 처짐.
|
||||
- 상징: **촛불** — 닫힘: 아주 작고 낮은 불꽃 / 반쯤: 조금 자란 불꽃 / 활짝: 안정된 따뜻한 불꽃과 둥근 빛무리 / 처짐: 꺼질 듯 흔들리는 불꽃과 가는 연기, 흘러내린 촛농. 남을 위해 태우는 사람.
|
||||
|
||||
### P4 하늘
|
||||
- 고2 여학생, 시험불안·완벽주의, 반장, 또박또박·자기검열, 어깨를 올리고 눈을 약간 크게 뜬다.
|
||||
- 외형: 단정하게 꾸민 얼굴, 지나치게 깨어 있는 눈과 옅은 수면 부족 그늘, 긴장한 입. 긴 검은 생머리를 목덜미에서 낮게 묶었고, 관자놀이에 짧은 잔머리 몇 가닥이 삐져나온다. 앞머리 없음(눈썹이 다 보임).
|
||||
- 의상: 흰 깃 셔츠 위 남색 크루넥 스웨터, 깃을 반듯하게 정리, **오른쪽 귀에 꽂은 연필**. 아주 꼿꼿한 자세에 어깨가 굳음.
|
||||
- 상징: **연** — 닫힘: 연이 접혀 땅에 놓이고 실이 얼레에 꽉 감김 / 반쯤: 연이 조금 떠오르고 실이 팽팽함 / 활짝: 느슨하게 굽은 실과 꼬리를 달고 높이 남 / 처짐: 앙상한 가지에 걸려 늘어지고 꼬리가 엉킴.
|
||||
|
||||
### P5 도윤
|
||||
- 중3 남학생, 또래 무리에서 빠짐(단톡방), 관찰력·그림 취미, 무던한 척 눈치 봄, 시선을 옆으로, 입을 크게 안 벌린다.
|
||||
- 외형: 마르고 아직 앳된 15세 얼굴. 손대지 않은 흑갈색 짧은 머리, 뻗친 가마. 낮은 윗눈꺼풀의 가늘고 곧은 눈(속눈썹 강조 없음), 경계하며 살피는 눈빛. 이마와 턱에 작은 여드름 두어 개.
|
||||
- 의상: 반쯤 잠근 **큰 검은 집업 트랙 재킷** 안에 회색 티셔츠, **목에 느슨하게 건 유선 이어폰**. 어깨를 살짝 웅크리고 고개를 조금 숙임.
|
||||
- 상징: **참새** — 닫힘: 깃털을 부풀리고 머리를 깃에 묻음 / 반쯤: 고개를 들고 옆을 봄 / 활짝: 부리를 벌려 지저귀며 한쪽 날개를 조금 듦 / 처짐: 머리를 숙이고 깃털이 헝클어진 채 웅크림. 모두 짧은 맨 가지 위.
|
||||
|
||||
### P6 하린
|
||||
- 고3 여학생, 의대와 미술 사이의 진로 갈등, 몰래 미술 실기, 개방성 최고, 똑부러지지만 지친 톤, 손톱을 만지작거린다.
|
||||
- 외형: 총명하고 말을 잘할 것 같지만 지친 얼굴, 생각이 많은 눈과 밤샘의 그늘, 조금 튼 입술. 자연스러운 웨이브의 중단발 흑갈색 머리를 윗부분만 장식 없는 집게핀으로 느슨하게 올렸고 몇 가닥이 흘러내린다.
|
||||
- 의상: 큰 **오프화이트 케이블 카디건** 안에 회색·흰색 줄무늬 셔츠, **카디건 깃 가장자리에 옅게 묻은 목탄 가루**. 어깨를 조금 안으로 모음.
|
||||
- 상징: **고치와 나비** — 닫힘: 가지에 매달린 고치 / 반쯤: 고치가 갈라지고 구겨진 날개가 나옴 / 활짝: 황토·슬레이트 날개를 편 나비 / 처짐: 힘없이 늘어져 말라 가는 고치.
|
||||
|
||||
### P7 도현
|
||||
- 고3 남학생, 입시 번아웃·무쾌감, 폰과 잠으로 도피, 무덤덤·자조, 눈을 가장 많이 내리고 고개를 숙이며 가장 작게 웅얼거린다.
|
||||
- 외형: 키가 크고 마른 체형, 눈에 띄게 지친 얼굴. 한쪽이 눌리고 뒷머리가 뻗친 짧은 검은 머리(방금 일어난 듯), 고르지 않은 옆가르마. 무겁게 처진 윗눈꺼풀, 짙은 눈 밑 그늘, 창백하고 누르스름한 안색, 깎지 않은 옅은 콧수염 그늘. 불량학생이 아니라 평범한 지친 학생.
|
||||
- 의상: 구겨진 **차콜 회색 맨투맨** 목 위로 구겨진 흰 셔츠 깃. 어깨가 처짐.
|
||||
- 상징: **달팽이** — 닫힘: 껍데기 속에 완전히 숨음 / 반쯤: 머리를 조금 내밂 / 활짝: 더듬이를 세우고 앞으로 미끄러지며 옅은 자국을 남김 / 처짐: 숨은 채 껍데기가 옆으로 기울어짐.
|
||||
|
||||
## 생성 프롬프트 (정면 원화 후보)
|
||||
|
||||
codex `image_gen`, 1024×1536 high, 참조 이미지 = `docs/avatar-art/p1-linocut/base/base-front.png`. 프롬프트 = 머리말 + [인물] + [공통 제약] + [구도·화풍].
|
||||
|
||||
- 머리말(호출 지시): "Call the built-in image_gen tool immediately, using the attached image only as the style and framing reference. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as <파일명>.png:"
|
||||
- [공통 제약]: "He/She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin."
|
||||
- [구도·화풍]: "Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text."
|
||||
|
||||
[인물] 문단(영문)은 생성 패킷에 있다(`linocut-cast-prompts/P*.txt`).
|
||||
149
docs/avatar-art/linocut-pipeline/README.md
Normal file
149
docs/avatar-art/linocut-pipeline/README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# 공통 리노컷 아바타 파이프라인
|
||||
|
||||
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/avatar-expression-engine-v3.md)
|
||||
§8.2 리그 원칙)의 자산 파이프라인이다. P1~P7 등 모든 페르소나가 이 공통 스크립트를 공유하고,
|
||||
페르소나별 차이는 각 페르소나 폴더(예: `../p1-linocut/`)의 `persona.json`에서만 읽는다.
|
||||
|
||||
결과물(레이어 픽셀, 알파, 게시된 WebP, 리그 TS)은 페르소나별 폴더 구조나 상수 위치가
|
||||
바뀌었다는 이유로 달라지지 않는다 — 알고리즘은 건드리지 않았다(P1 회귀 검증: 아래 참고).
|
||||
|
||||
## 단계와 의존 순서
|
||||
|
||||
`run_pipeline.py`가 아래 순서로 각 단계를 독립 프로세스로 실행한다(괄호는 산출물):
|
||||
|
||||
1. `landmarks.py` — base-front.png 랜드마크 검출 (`manifest.landmarks`)
|
||||
2. `brow_centerline.py` — `manifest.landmarks.eyebrowLeft/Right`를 잉크 띠 중심선으로 보정
|
||||
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로 모션·눈/입 확대 미리보기
|
||||
|
||||
이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 `final_previews`는
|
||||
`export_rig`가 쓴 `export-rig-report.json`을 읽으므로 반드시 `export_rig` *다음*이어야
|
||||
한다. `face_detail`·`jaw_pieces`는 `manifest.landmarks`(1~2단계가 채움)와
|
||||
`layers/v2/{head,hairFront,body}.png`(3~4단계)가 먼저 있어야 한다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 전체 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir>
|
||||
|
||||
# 한 단계만 다시 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail
|
||||
|
||||
# 중간부터 끝까지
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture
|
||||
|
||||
# 단계 이름 목록
|
||||
<venv>/python.exe run_pipeline.py --list
|
||||
```
|
||||
|
||||
각 단계 스크립트는 `python <script>.py <persona-dir>`로 단독 실행도 된다(디버깅용).
|
||||
|
||||
**주의**: `landmarks.py`만 혼자 다시 돌리면 `manifest.landmarks`를 통째로 새로 써서
|
||||
`brow_centerline.py`가 보정한 눈썹 중심선이 사라진다. `--only landmarks`를 쓴 뒤에는
|
||||
`--only brow_centerline`도 반드시 같이 돌려야 한다(자동으로 뒤따라 돌지 않는다).
|
||||
|
||||
## persona.json 스키마
|
||||
|
||||
페르소나 폴더(예: `../p1-linocut/persona.json`)에 둔다. 랜드마크·분할로 계산 가능한
|
||||
값은 각 단계 스크립트가 직접 계산하므로 여기 없다 — 원화마다 달라지고 유도할 수 없는
|
||||
값(참조 이미지를 보고 사람이 고른 점·상자)만 이 파일에 둔다.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"code": "P1", // 필수. manifest.persona, rig.persona
|
||||
"publicSlug": "p1", // 생략 시 code.lower(). apps/web/public/avatar/v3/<publicSlug>/
|
||||
"rigFileName": "p1Rig.ts", // 생략 시 "<publicSlug>Rig.ts"
|
||||
"rigExportName": "P1_LINOCUT_RIG", // 생략 시 "<CODE>_LINOCUT_RIG"
|
||||
|
||||
// 모티프 팔레트(motifPetal/motifLeaf) 표본을 뽑을 스타일 참조 이미지(persona.json 기준 상대경로).
|
||||
// export_rig.py의 팔레트 계산에서만 쓴다 — 모티프 스프라이트 자체는 이번 파이프라인
|
||||
// 범위 밖이다(오케스트레이터가 따로 설계).
|
||||
"styleFrame": "../art-direction-v3/p1/r2-b-linocut.png",
|
||||
|
||||
"paletteSamples": {
|
||||
// 머리카락 어두운 덩어리 표본(ink 팔레트색). base-front.png 픽셀 기준 상자.
|
||||
"ink": { "box": [280, 100, 720, 350], "lumThreshold": 55 },
|
||||
// 눈 흰자/홍채/홍채테 고정 설계값(결정문 §8.2 "고정값" 원칙) — 생략하면 공통 기본값 사용.
|
||||
"eyeOverride": { "sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F" },
|
||||
// styleFrame에서 꽃잎(ochre)·잎/구름(blue) 색을 뽑을 상자들. kind는 "ochre" 또는 "blue".
|
||||
"motifPetalBoxes": [{ "label": "sun", "kind": "ochre", "box": [1230, 10, 1536, 210] }],
|
||||
"motifLeafBoxes": [{ "label": "cloudLeft", "kind": "blue", "box": [20, 20, 380, 190] }]
|
||||
},
|
||||
|
||||
"faceDetail": {
|
||||
// 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다).
|
||||
"moles": [{ "center": [661.3, 627.9], "radius": 20.0 }]
|
||||
},
|
||||
|
||||
// 렌더러 회전/스케일 중심점(결정문 §8.4). 원화를 보고 목·몸통·얼굴 중심을 정한다.
|
||||
"pivots": { "neck": [500, 990], "body": [502, 1566], "face": [490, 660] },
|
||||
|
||||
// bust 크롭은 정사각형(변 = 캔버스 폭)이고 위쪽 오프셋만 여기서 정한다.
|
||||
"crops": { "bustYOffset": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
`eyeOverride`·`paletteFixed`(mouthInner/teeth/blush/tear/pallor/paper)·`backdrop`(겉표정
|
||||
그룹별 배경색)은 모든 페르소나가 공유하는 기본값이 있다(`persona_config.py`의
|
||||
`DEFAULT_*`) — 캐릭터마다 다르게 할 필요가 있을 때만 persona.json에 적어 덮어쓴다.
|
||||
|
||||
`styleFrame`·`paletteSamples.ink`·`paletteSamples.motifPetalBoxes`/`motifLeafBoxes`·
|
||||
`pivots`는 **필수**다(`export_rig.py`가 해당 값을 쓰는 시점에 없으면 어떤 필드를
|
||||
채워야 하는지 알려주며 멈춘다).
|
||||
|
||||
### `faceDetail.browLandmarksOverride` — P1 전용 호환 장치, 새 페르소나는 쓰지 않는다
|
||||
|
||||
```jsonc
|
||||
"faceDetail": {
|
||||
"browLandmarksOverride": {
|
||||
"browLeft": { "inner": [..], "peak": [..], "outer": [..] },
|
||||
"browRight": { "inner": [..], "peak": [..], "outer": [..] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
있으면 `face_detail.py`가 눈썹 제외 영역·눈 영역 y0 계산에 `manifest.landmarks`의
|
||||
현재(중심선 보정) 눈썹 좌표 대신 이 값을 쓴다. 다른 랜드마크(눈·입·코·턱)는 그대로
|
||||
현재 값을 쓴다 — 눈썹만 바꾼다.
|
||||
|
||||
P1의 기존 `face-detail.png`·`jaw-detail.png`·해당 webp·`p1Rig.ts`는 눈썹 중심선 보정
|
||||
(`manifest.browCenterline`) **이전** 좌표(`browCenterline.oldPoints`)로 빌드된 뒤
|
||||
"다시 빌드하지 않는다"는 오케스트레이터 지시로 고정됐다(소유자도 그 결과를 검수했다).
|
||||
그래서 P1 persona.json에는 `browCenterline.oldPoints`와 같은 값을 넣어 재실행 결과가
|
||||
그 고정본과 바이트 단위로 같아지게 한다. **새 페르소나는 이 필드를 넣지 않는다** —
|
||||
처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.
|
||||
|
||||
## 모델 파일(저장소에 없음)
|
||||
|
||||
`scripts/_models/`에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, `.gitignore`의
|
||||
`docs/avatar-art/*/scripts/_models/` 패턴에 그대로 맞는다).
|
||||
|
||||
- `face_landmarker.task`: https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
|
||||
- `selfie_multiclass_256x256.tflite`: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite
|
||||
|
||||
python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
|
||||
|
||||
## 새 페르소나를 추가할 때 사람이 해야 하는 일
|
||||
|
||||
이 파이프라인은 자동으로 원화를 만들지 않는다. 사람(또는 다른 워커)이 먼저 준비해야
|
||||
하는 것:
|
||||
|
||||
1. 원화 2장: `<persona-dir>/base/base-front.png`(정면 기본형)과
|
||||
`base-faceless.png`(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다.
|
||||
2. `<persona-dir>/raw/body.png`: body 레이어가 head_mask로 가려지는 목 상단 영역을
|
||||
메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬).
|
||||
3. `<persona-dir>/persona.json`: 위 스키마대로 작성한다. 특히 `styleFrame`·
|
||||
`paletteSamples`·`pivots`는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).
|
||||
4. (선택) `<persona-dir>/motif/`: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가
|
||||
따로 설계한다. 없으면 `export_rig.py`가 모티프 없는 리그를 만든다(rig.motif 생략).
|
||||
5. `scripts/_models/`에 모델 파일이 없으면 받아 둔다(위 링크, 한 번만).
|
||||
|
||||
그 다음 `run_pipeline.py <persona-dir>`를 실행하고, 출력된 검사 수치(halo%, 평균절대차
|
||||
등 — 각 단계 스크립트가 콘솔에 찍고 manifest.json에도 남긴다)를 기준치와 비교해 판정한다.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""P1 서연 눈썹 랜드마크를 잉크 띠 중심선으로 정정 — 2단계-B-1d-A(재작업).
|
||||
"""공통 리노컷 리그 — 눈썹 랜드마크를 잉크 띠 중심선으로 정정.
|
||||
|
||||
1차(반려)는 front-F 밝기 차이만으로 마스크를 만들어 눈두덩 그늘 해칭까지
|
||||
눈썹으로 잡았고, 열별 지역 평활(Savitzky-Golay)이 평평한 눈썹에서 물결치며
|
||||
|
|
@ -22,7 +22,9 @@ peak를 안쪽 끝 열에 찍었다(오케스트레이터 판정, 2단계-B-1d).
|
|||
또는 구간 안 높이 차 < 3px = 거의 평평) 바깥 끝에서 35% 지점의 곡선
|
||||
위 점을 쓴다.
|
||||
|
||||
실행: <venv>/python.exe build_brow_centerline.py
|
||||
전제: landmarks.py가 이미 manifest.landmarks를 채워 두어야 한다.
|
||||
|
||||
실행: <venv>/python.exe brow_centerline.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -36,10 +38,8 @@ from PIL import Image, ImageDraw
|
|||
from scipy import ndimage
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
|
||||
BBOX_X_PAD = 25.0
|
||||
|
|
@ -274,12 +274,17 @@ def draw_evidence_panel(front_img: Image.Image, old_pts: dict, result: dict) ->
|
|||
return crop
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
lm = manifest["landmarks"]
|
||||
|
||||
front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
faceless = np.array(Image.open(BASE_DIR / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
faceless = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
front_lum = front.mean(axis=2)
|
||||
faceless_lum = faceless.mean(axis=2)
|
||||
diff = np.clip(faceless_lum - front_lum, 0.0, None)
|
||||
|
|
@ -320,7 +325,7 @@ def main() -> int:
|
|||
print(f"검사(c) browLeft peak 위치비율 = {resultL['peakFracFromOuterEdge']} ({resultL['peakMethod']})")
|
||||
print(f"검사(c) browRight peak 위치비율 = {resultR['peakFracFromOuterEdge']} ({resultR['peakMethod']})")
|
||||
|
||||
front_img = Image.open(BASE_DIR / "base-front.png").convert("RGB")
|
||||
front_img = Image.open(base_dir / "base-front.png").convert("RGB")
|
||||
panelL = draw_evidence_panel(front_img, browL_old, resultL)
|
||||
panelR = draw_evidence_panel(front_img, browR_old, resultR)
|
||||
gap = 20
|
||||
|
|
@ -330,8 +335,8 @@ def main() -> int:
|
|||
scale = min(1.0, EVIDENCE_MAX_SIDE / max(combined.size))
|
||||
if scale < 1.0:
|
||||
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
|
||||
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
evidence_path = PREVIEW_V2_DIR / "brow-centerline.jpg"
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
evidence_path = preview_v2_dir / "brow-centerline.jpg"
|
||||
combined.convert("RGB").save(evidence_path, "JPEG", quality=90)
|
||||
print(f"저장: {evidence_path}")
|
||||
|
||||
|
|
@ -360,10 +365,10 @@ def main() -> int:
|
|||
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
|
||||
"evidenceImage": "preview/v2/brow-centerline.jpg",
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
"""P1 서연 리노컷 리그 게시 스크립트 (2단계-B-1a).
|
||||
"""공통 리노컷 리그 게시 스크립트(2단계-B-1a, 결정문 §8.2).
|
||||
|
||||
layers/{v2 있으면 v2 우선, 없으면 layers/*}의 PNG를 알파 bbox로 잘라 WebP로
|
||||
apps/web/public/avatar/v3/p1/에 게시하고, apps/web/src/components/avatar/v3/rigs/p1Rig.ts를
|
||||
생성한다. 같은 입력이면 같은 결과가 나오도록 결정적으로 만든다(팔레트·faceOval 계산 방식 고정).
|
||||
apps/web/public/avatar/v3/<publicSlug>/에 게시하고,
|
||||
apps/web/src/components/avatar/v3/rigs/<rigFileName>을 생성한다. 같은 입력이면
|
||||
같은 결과가 나오도록 결정적으로 만든다(팔레트·faceOval 계산 방식 고정).
|
||||
|
||||
같은 스크립트를 (0) 선행 게시(faceDetail·grain 없음)와 (5) 최종 게시(둘 다 있음) 양쪽에 쓴다.
|
||||
faceDetail/grain 소스 파일이 없으면 해당 필드를 rig에서 생략한다.
|
||||
같은 스크립트를 선행 게시(faceDetail·grain 없음)와 최종 게시(둘 다 있음) 양쪽에
|
||||
쓴다. faceDetail/grain 소스 파일이 없으면 해당 필드를 rig에서 생략한다.
|
||||
|
||||
실행: <venv>/python.exe export_rig.py
|
||||
전제: layers_v2.py·face_detail.py·paper_grain.py·lip_texture.py·jaw_pieces.py가
|
||||
먼저 돌아서 layers/v2/*.png와 manifest.landmarks/lipTexture/jaw를 만들어 두어야
|
||||
한다.
|
||||
|
||||
실행: <venv>/python.exe export_rig.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -22,29 +27,15 @@ from pathlib import Path
|
|||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from build_layers_v2 import halo_metric # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE_DIR = ROOT / "base"
|
||||
LAYERS_DIR = ROOT / "layers"
|
||||
LAYERS_V2_DIR = ROOT / "layers" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from layers_v2 import halo_metric # noqa: E402
|
||||
from persona_config import PersonaConfig, load_persona_config # noqa: E402
|
||||
|
||||
MOTIF_ROOT = ROOT / "motif"
|
||||
MOTIF_SPRITES_DIR = MOTIF_ROOT / "sprites"
|
||||
MOTIF_MANIFEST_PATH = MOTIF_ROOT / "manifest.json"
|
||||
BUD_STATES = ["closed", "half", "open", "droop"]
|
||||
WEATHER_GROUPS = ["positive", "negative", "defensive", "cognitive", "energy"]
|
||||
MOTIF_SIZE_BUDGET_BYTES = 160 * 1024
|
||||
|
||||
REPO_ROOT = ROOT.parents[2]
|
||||
PUBLIC_DIR = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "v3" / "p1"
|
||||
MOTIF_PUBLIC_DIR = PUBLIC_DIR / "motif"
|
||||
RIG_TS_PATH = REPO_ROOT / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / "p1Rig.ts"
|
||||
|
||||
STYLE_FRAME_PATH = ROOT.parents[0] / "art-direction-v3" / "p1" / "r2-b-linocut.png"
|
||||
|
||||
SIZE_BUDGET_BYTES = 600 * 1024
|
||||
|
||||
# --- 레이어 소스 해석: v2가 있으면 v2, 없으면 기존 layers/ ------------------
|
||||
|
|
@ -58,7 +49,7 @@ LAYER_SPECS = [
|
|||
]
|
||||
GRAIN_SPEC = ("paper-grain", "paper-grain")
|
||||
|
||||
# --- 입술 결 스프라이트(결정문 §8.4 입, 2단계-B-1d-A2) -----------------------
|
||||
# --- 입술 결 스프라이트(결정문 §8.4 입) -------------------------------------
|
||||
|
||||
LIP_SPECS = [
|
||||
# (rig key, 파일 stem, public href stem)
|
||||
|
|
@ -66,23 +57,23 @@ LIP_SPECS = [
|
|||
("lower", "lip-lower", "lip-lower"),
|
||||
("shadow", "lip-shadow", "lip-shadow"),
|
||||
]
|
||||
LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 패킷 A5 크기 상한(그늘 조각 분리로 3장, A4의 60KB에서 상향)
|
||||
LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 그늘 조각 분리로 3장
|
||||
|
||||
# --- 턱 조각(결정문 §8.4 하관 띠 변형, 작업 패킷 A5) --------------------------
|
||||
# --- 턱 조각(결정문 §8.4 하관 띠 변형) ---------------------------------------
|
||||
|
||||
JAW_SPECS = [
|
||||
# (rig key, 파일 stem, public href stem)
|
||||
("head", "jaw-head", "jaw-head"),
|
||||
("detail", "jaw-detail", "jaw-detail"),
|
||||
]
|
||||
JAW_SIZE_BUDGET_BYTES = 200 * 1024 # 패킷 A5 크기 상한
|
||||
JAW_SIZE_BUDGET_BYTES = 200 * 1024
|
||||
|
||||
|
||||
def resolve_layer_source(stem: str, optional: bool) -> Path | None:
|
||||
v2_path = LAYERS_V2_DIR / f"{stem}.png"
|
||||
def resolve_layer_source(layers_dir: Path, layers_v2_dir: Path, stem: str, optional: bool) -> Path | None:
|
||||
v2_path = layers_v2_dir / f"{stem}.png"
|
||||
if v2_path.exists():
|
||||
return v2_path
|
||||
legacy_path = LAYERS_DIR / f"{stem}.png"
|
||||
legacy_path = layers_dir / f"{stem}.png"
|
||||
if legacy_path.exists():
|
||||
return legacy_path
|
||||
if optional:
|
||||
|
|
@ -90,9 +81,9 @@ def resolve_layer_source(stem: str, optional: bool) -> Path | None:
|
|||
raise SystemExit(f"[중단] 레이어 소스가 없다: {v2_path} 또는 {legacy_path}")
|
||||
|
||||
|
||||
def resolve_grain_source() -> Path | None:
|
||||
def resolve_grain_source(layers_v2_dir: Path) -> Path | None:
|
||||
stem, _ = GRAIN_SPEC
|
||||
p = LAYERS_V2_DIR / f"{stem}.png"
|
||||
p = layers_v2_dir / f"{stem}.png"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
|
|
@ -116,16 +107,15 @@ def crop_to_bbox(im: Image.Image) -> tuple[Image.Image, tuple[int, int, int, int
|
|||
|
||||
|
||||
def encode_webp(im: Image.Image, quality: int) -> bytes:
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, "WEBP", quality=quality, alpha_quality=100, method=6)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def publish_layers(sources: dict[str, tuple[Path, str]], grain_source: Path | None) -> tuple[dict, dict | None, dict]:
|
||||
def publish_layers(cfg: PersonaConfig, sources: dict[str, tuple[Path, str]], grain_source: Path | None) -> tuple[dict, dict | None, dict]:
|
||||
"""sources: rig key -> (원본 PNG 경로, href stem). 반환: (layers 리포트, grain 리포트, 파일크기 dict)"""
|
||||
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
public_dir = cfg.public_dir
|
||||
public_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {}
|
||||
for key, (path, href_stem) in sources.items():
|
||||
|
|
@ -170,7 +160,7 @@ def publish_layers(sources: dict[str, tuple[Path, str]], grain_source: Path | No
|
|||
|
||||
file_sizes: dict[str, int] = {}
|
||||
for href_stem, data in chosen.items():
|
||||
out_path = PUBLIC_DIR / f"{href_stem}.webp"
|
||||
out_path = public_dir / f"{href_stem}.webp"
|
||||
out_path.write_bytes(data)
|
||||
file_sizes[href_stem] = len(data)
|
||||
|
||||
|
|
@ -178,7 +168,7 @@ def publish_layers(sources: dict[str, tuple[Path, str]], grain_source: Path | No
|
|||
for key, (_im, bbox, href_stem) in cropped.items():
|
||||
x0, y0, w, h = bbox
|
||||
layers_report[key] = {
|
||||
"href": f"/avatar/v3/p1/{href_stem}.webp",
|
||||
"href": f"{cfg.public_href_prefix}/{href_stem}.webp",
|
||||
"x": round(x0, 1),
|
||||
"y": round(y0, 1),
|
||||
"w": round(w, 1),
|
||||
|
|
@ -187,31 +177,31 @@ def publish_layers(sources: dict[str, tuple[Path, str]], grain_source: Path | No
|
|||
|
||||
grain_report = None
|
||||
if grain_im is not None:
|
||||
grain_report = {"href": f"/avatar/v3/p1/{GRAIN_SPEC[1]}.webp", "size": 256}
|
||||
grain_report = {"href": f"{cfg.public_href_prefix}/{GRAIN_SPEC[1]}.webp", "size": 256}
|
||||
|
||||
return layers_report, grain_report, {"fileSizes": file_sizes, "encodeMeta": chosen_meta}
|
||||
|
||||
|
||||
# --- 입술 결 스프라이트 게시(결정문 §8.4 입, 2단계-B-1d-A2) -----------------
|
||||
# --- 입술 결 스프라이트 게시(결정문 §8.4 입) --------------------------------
|
||||
|
||||
|
||||
def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
||||
"""layers/v2/lip-{upper,lower}.png(build_lip_texture.py 산출)를 알파
|
||||
def publish_lip_texture(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
|
||||
"""layers/v2/lip-{upper,lower,shadow}.png(lip_texture.py 산출)를 알파
|
||||
bbox로 잘라 WebP로 게시한다. 소스가 없으면 (None, None)을 반환해
|
||||
lipTexture 없는 리그도 여전히 만들 수 있게 한다(렌더러는 palette 단색
|
||||
으로 대체). 두 파일 합계 예산은 60KB(패킷 A4 크기 상한).
|
||||
으로 대체). 세 파일 합계 예산은 80KB.
|
||||
|
||||
PNG 자체는 이미 알파 bbox로 꽉 차게 잘려 있어(build_lip_texture.py) 이 함수의
|
||||
PNG 자체는 이미 알파 bbox로 꽉 차게 잘려 있어(lip_texture.py) 이 함수의
|
||||
crop_to_bbox는 사실상 no-op이고, 캔버스 위치 정보를 담고 있지 않다 —
|
||||
RigLayer.x/y(절대 캔버스 좌표)는 manifest["lipTexture"]["canvasOrigin"](작업
|
||||
캔버스 좌표를 build_lip_texture.py가 절대 좌표로 환산해 기록한 값)에서 읽는다."""
|
||||
캔버스 좌표를 lip_texture.py가 절대 좌표로 환산해 기록한 값)에서 읽는다."""
|
||||
canvas_origin = manifest.get("lipTexture", {}).get("canvasOrigin")
|
||||
if canvas_origin is None:
|
||||
raise SystemExit("[중단] manifest.lipTexture.canvasOrigin이 없다 — build_lip_texture.py를 먼저 실행하라.")
|
||||
raise SystemExit("[중단] manifest.lipTexture.canvasOrigin이 없다 — lip_texture.py를 먼저 실행하라.")
|
||||
|
||||
sources: dict[str, tuple[Path, str]] = {}
|
||||
for key, stem, href_stem in LIP_SPECS:
|
||||
p = LAYERS_V2_DIR / f"{stem}.png"
|
||||
p = layers_v2_dir / f"{stem}.png"
|
||||
if not p.exists():
|
||||
return None, None
|
||||
sources[key] = (p, href_stem)
|
||||
|
|
@ -224,9 +214,7 @@ def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
|||
abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3])
|
||||
cropped[key] = (cropped_im, abs_bbox, href_stem)
|
||||
|
||||
import io
|
||||
|
||||
LIP_DIFF4_TARGET = 2.0 # 검사(4) 목표(입 결은 표정의 중심이라 품질을 우선한다, 오케스트레이터 지시)
|
||||
LIP_DIFF4_TARGET = 2.0 # 검사(4) 목표(입 결은 표정의 중심이라 품질을 우선한다)
|
||||
|
||||
def encode_all(quality: int) -> dict[str, bytes]:
|
||||
out: dict[str, bytes] = {}
|
||||
|
|
@ -240,7 +228,7 @@ def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
|||
buf = io.BytesIO()
|
||||
# exact=True: libwebp 기본값(0)은 완전 투명(알파=0) 영역의 RGB를 압축률을 위해
|
||||
# 바꿔도 되는 것으로 보고 버린다 — "무손실"이 알파>0 영역에만 적용된다. 알파=0
|
||||
# 영역도 원화 RGB를 그대로 담고 있으므로(build_lip_texture.py) exact=True로 강제한다.
|
||||
# 영역도 원화 RGB를 그대로 담고 있으므로(lip_texture.py) exact=True로 강제한다.
|
||||
im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
out[href_stem] = buf.getvalue()
|
||||
return out
|
||||
|
|
@ -253,7 +241,7 @@ def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
|||
diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3)
|
||||
return diffs
|
||||
|
||||
# 1순위: 무손실. 두 장 합계가 예산(60KB) 안이면 무손실을 쓴다.
|
||||
# 1순위: 무손실. 세 장 합계가 예산 안이면 무손실을 쓴다.
|
||||
lossless_encoded = encode_all_lossless()
|
||||
lossless_total = sum(len(b) for b in lossless_encoded.values())
|
||||
if lossless_total <= LIP_SIZE_BUDGET_BYTES:
|
||||
|
|
@ -282,10 +270,11 @@ def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
|||
)
|
||||
assert chosen is not None and chosen_quality is not None
|
||||
|
||||
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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 = public_dir / f"{href_stem}.webp"
|
||||
out_path.write_bytes(data)
|
||||
file_sizes[href_stem] = len(data)
|
||||
total_bytes = sum(file_sizes.values())
|
||||
|
|
@ -307,7 +296,7 @@ def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
|||
for key, (_im, bbox, href_stem) in cropped.items():
|
||||
x0, y0, w, h = bbox
|
||||
lip_field[key] = {
|
||||
"href": f"/avatar/v3/p1/{href_stem}.webp",
|
||||
"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 = {
|
||||
|
|
@ -318,26 +307,24 @@ def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
|
|||
return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report
|
||||
|
||||
|
||||
# --- 턱 조각 게시(결정문 §8.4 하관 띠 변형, 작업 패킷 A5) -------------------
|
||||
# --- 턱 조각 게시(결정문 §8.4 하관 띠 변형) ----------------------------------
|
||||
|
||||
|
||||
JAW_LAYER_KEY_FOR = {"head": "head", "detail": "faceDetail"} # jaw rig key -> layers_report 키
|
||||
JAW_DIFF_TARGET = 2.0 # 오케스트레이터 지시: 평균 절대차 목표
|
||||
JAW_DIFF_TARGET = 2.0
|
||||
|
||||
|
||||
def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -> tuple[dict | None, dict | None]:
|
||||
"""layers/v2/jaw-{head,detail}.png(build_jaw_pieces.py 산출, 원본 레이어 픽셀을
|
||||
def publish_jaw_pieces(cfg: PersonaConfig, manifest: dict, layers_report: dict, encode_meta: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
|
||||
"""layers/v2/jaw-{head,detail}.png(jaw_pieces.py 산출, 원본 레이어 픽셀을
|
||||
그대로 잘라낸 것)를 게시한다.
|
||||
|
||||
A5 1차 시도(무손실 WebP)는 오케스트레이터 판정으로 반려됐다: 화면에 실제로
|
||||
그려지는 원본은 게시된 `head.webp`·`face-detail.webp`이고 **그 둘 다 이미
|
||||
손실 압축**이라, 턱 조각만 무손실로 게시하면 PNG와는 같아도 화면의 원본과는
|
||||
달라 이음매가 드러난다. 그래서 턱 조각을 **같은 레이어가 쓴 것과 같은 인코딩
|
||||
설정(quality·alpha_quality·method, publish_layers의 encode_meta)**으로 다시
|
||||
인코딩한다. 픽셀 동일성 기준도 PNG가 아니라 **게시된 head.webp/face-detail.webp를
|
||||
같은 캔버스 영역으로 잘라 디코드한 값**으로 바꾼다(둘 다 화면 표시 크기로
|
||||
맞춘 뒤 비교 — encode_meta.scale!=1이면 원본 alpha-bbox 크기로 늘려 브라우저가
|
||||
표시할 모습을 재현한다)."""
|
||||
턱 조각을 **같은 레이어가 쓴 것과 같은 인코딩 설정**(quality·alpha_quality·
|
||||
method, publish_layers의 encode_meta)으로 다시 인코딩한다 — 화면에 실제로
|
||||
그려지는 원본은 게시된 head.webp·face-detail.webp이고 그 둘 다 이미 손실
|
||||
압축이라, 턱 조각만 무손실로 게시하면 PNG와는 같아도 화면의 원본과는 달라
|
||||
이음매가 드러난다. 픽셀 동일성 기준도 PNG가 아니라 게시된
|
||||
head.webp/face-detail.webp를 같은 캔버스 영역으로 잘라 디코드한 값으로
|
||||
비교한다."""
|
||||
jaw_manifest = manifest.get("jaw")
|
||||
if jaw_manifest is None:
|
||||
return None, None
|
||||
|
|
@ -347,7 +334,7 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
|
||||
sources: dict[str, tuple[Path, str]] = {}
|
||||
for key, stem, href_stem in JAW_SPECS:
|
||||
p = LAYERS_V2_DIR / f"{stem}.png"
|
||||
p = layers_v2_dir / f"{stem}.png"
|
||||
if not p.exists():
|
||||
return None, None
|
||||
sources[key] = (p, href_stem)
|
||||
|
|
@ -355,7 +342,8 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
quality = encode_meta["quality"]
|
||||
scale = encode_meta["scale"]
|
||||
|
||||
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
public_dir = cfg.public_dir
|
||||
public_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_sizes: dict[str, int] = {}
|
||||
compare_report: dict[str, dict] = {}
|
||||
for key, (path, href_stem) in sources.items():
|
||||
|
|
@ -368,7 +356,7 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
sw, sh = max(1, round(w * scale)), max(1, round(h * scale))
|
||||
work = im.resize((sw, sh), Image.LANCZOS)
|
||||
data = encode_webp(work, quality)
|
||||
out_path = PUBLIC_DIR / f"{href_stem}.webp"
|
||||
out_path = public_dir / f"{href_stem}.webp"
|
||||
out_path.write_bytes(data)
|
||||
file_sizes[href_stem] = len(data)
|
||||
|
||||
|
|
@ -384,7 +372,7 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
layer_info = layers_report[layer_key]
|
||||
layer_cx0, layer_cy0 = int(round(layer_info["x"])), int(round(layer_info["y"]))
|
||||
layer_w, layer_h = int(round(layer_info["w"])), int(round(layer_info["h"]))
|
||||
layer_webp_path = PUBLIC_DIR / Path(layer_info["href"]).name
|
||||
layer_webp_path = public_dir / Path(layer_info["href"]).name
|
||||
layer_decoded = Image.open(layer_webp_path).convert("RGBA")
|
||||
if layer_decoded.size != (layer_w, layer_h):
|
||||
layer_decoded = layer_decoded.resize((layer_w, layer_h), Image.LANCZOS)
|
||||
|
|
@ -402,10 +390,8 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
]
|
||||
|
||||
# 알파=0인 자리는 화면에 전혀 그려지지 않으므로 그 자리의 RGB 값은 "화면에
|
||||
# 보이는 차이"와 무관하다(원본 크롭·게시 레이어 각각 인코더가 자유롭게 다른
|
||||
# 값을 남길 수 있다 — 실측: 이 값을 그냥 빼면 평균차가 19.7까지 치솟는데,
|
||||
# 전부 두 쪽 다 알파 0인 자리의 RGB 잡음이었다). 알파를 곱한(premultiplied)
|
||||
# 색으로 비교해야 "실제로 그려지는 결과"의 차이가 된다.
|
||||
# 보이는 차이"와 무관하다 — 알파를 곱한(premultiplied) 색으로 비교해야
|
||||
# "실제로 그려지는 결과"의 차이가 된다.
|
||||
def _premultiplied(arr: np.ndarray) -> np.ndarray:
|
||||
return arr[..., :3] * (arr[..., 3:4] / 255.0)
|
||||
|
||||
|
|
@ -423,15 +409,6 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
"maxAbsDiffAlpha": max_diff_alpha,
|
||||
"meetsTarget": mean_diff <= JAW_DIFF_TARGET,
|
||||
}
|
||||
if key == "head" and not compare_report[key]["meetsTarget"]:
|
||||
compare_report[key]["orchestratorAcceptanceNote"] = (
|
||||
"평균차 3.13(목표 2.0 초과) 수용(오케스트레이터, 2026-10-01). 근거: "
|
||||
"최대차가 난 자리(왼쪽 위 머리카락)는 띠 clip(얼굴 윤곽 18px 바깥 "
|
||||
"다각형) 밖이라 렌더러가 그리지 않는다. 띠가 켜지는 동안에도 윗경계(y_n) "
|
||||
"부근은 변위 f(y)가 0에 가까워 이 조각의 위쪽 여백이 눈에 띄게 움직이지 "
|
||||
"않는다. quality를 올려도 이미 게시된 head.webp 자체의 압축 오차만큼은 "
|
||||
"남아 이득이 없다(비교 대상 자체가 손실 압축본)."
|
||||
)
|
||||
|
||||
total_bytes = sum(file_sizes.values())
|
||||
print(f"턱 조각 인코딩: quality={quality} scale={scale}(head·faceDetail 레이어와 동일 설정)")
|
||||
|
|
@ -443,14 +420,14 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
)
|
||||
for key, href_stem in (("head", "jaw-head"), ("detail", "jaw-detail")):
|
||||
r = compare_report[key]
|
||||
print(f" 검사(1, A5 재게시) [{href_stem}] 게시된 {r['comparedAgainstPublishedLayer']}(같은 영역, 알파 곱한 색) 대비 "
|
||||
print(f" 검사(1) [{href_stem}] 게시된 {r['comparedAgainstPublishedLayer']}(같은 영역, 알파 곱한 색) 대비 "
|
||||
f"평균차={r['meanAbsDiffPremultipliedRgb']} 최대차={r['maxAbsDiffPremultipliedRgb']} "
|
||||
f"(알파 평균차={r['meanAbsDiffAlpha']} 최대차={r['maxAbsDiffAlpha']}) "
|
||||
f"(목표 평균<={JAW_DIFF_TARGET}) {'OK' if r['meetsTarget'] else '[미달]'}")
|
||||
|
||||
jaw_field = {
|
||||
"head": {"href": "/avatar/v3/p1/jaw-head.webp", "x": x0, "y": y0, "w": w, "h": h},
|
||||
"detail": {"href": "/avatar/v3/p1/jaw-detail.webp", "x": x0, "y": y0, "w": w, "h": h},
|
||||
"head": {"href": f"{cfg.public_href_prefix}/jaw-head.webp", "x": x0, "y": y0, "w": w, "h": h},
|
||||
"detail": {"href": f"{cfg.public_href_prefix}/jaw-detail.webp", "x": x0, "y": y0, "w": w, "h": h},
|
||||
}
|
||||
publish_report = {
|
||||
"quality": quality, "scale": scale,
|
||||
|
|
@ -464,12 +441,21 @@ def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -
|
|||
# --- 모티프 스프라이트 게시(꽃봉오리·날씨) ---------------------------------
|
||||
|
||||
|
||||
def publish_motif() -> tuple[dict, dict]:
|
||||
def publish_motif(cfg: PersonaConfig) -> tuple[dict | None, dict | None]:
|
||||
"""motif/sprites/*.png를 WebP로 게시하고(알파 bbox 크롭 없음 — 스프라이트
|
||||
캔버스 그대로), 좌표는 motif/manifest.json에서 읽어 하드코딩하지 않는다.
|
||||
반환: (rig의 motif 필드, manifest.json 기록용 리포트)."""
|
||||
motif_manifest = json.loads(MOTIF_MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
MOTIF_PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
반환: (rig의 motif 필드, manifest.json 기록용 리포트). motif/manifest.json이
|
||||
없으면 (None, None) — 이 라운드는 모티프 원화 제작이 범위 밖인 페르소나가
|
||||
있을 수 있다(오케스트레이터가 모티프를 따로 설계한다)."""
|
||||
motif_root = cfg.root / "motif"
|
||||
motif_manifest_path = motif_root / "manifest.json"
|
||||
if not motif_manifest_path.exists():
|
||||
return None, None
|
||||
motif_sprites_dir = motif_root / "sprites"
|
||||
motif_public_dir = cfg.public_dir / "motif"
|
||||
|
||||
motif_manifest = json.loads(motif_manifest_path.read_text(encoding="utf-8"))
|
||||
motif_public_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bud_canvas = motif_manifest["bud"]["canvas"]
|
||||
bud_base = motif_manifest["bud"]["base_anchor_px"]
|
||||
|
|
@ -484,9 +470,9 @@ def publish_motif() -> tuple[dict, dict]:
|
|||
|
||||
sources: dict[str, Path] = {}
|
||||
for state in BUD_STATES:
|
||||
sources[f"bud-{state}"] = MOTIF_SPRITES_DIR / f"bud-{state}.png"
|
||||
sources[f"bud-{state}"] = motif_sprites_dir / f"bud-{state}.png"
|
||||
for name in WEATHER_GROUPS:
|
||||
sources[f"weather-{name}"] = MOTIF_SPRITES_DIR / f"weather-{name}.png"
|
||||
sources[f"weather-{name}"] = motif_sprites_dir / f"weather-{name}.png"
|
||||
for key, p in sources.items():
|
||||
if not p.exists():
|
||||
raise SystemExit(f"[중단] 모티프 스프라이트가 없다: {p}")
|
||||
|
|
@ -511,7 +497,7 @@ def publish_motif() -> tuple[dict, dict]:
|
|||
|
||||
file_sizes: dict[str, int] = {}
|
||||
for key, data in chosen.items():
|
||||
out_path = MOTIF_PUBLIC_DIR / f"{key}.webp"
|
||||
out_path = motif_public_dir / f"{key}.webp"
|
||||
out_path.write_bytes(data)
|
||||
file_sizes[key] = len(data)
|
||||
total_bytes = sum(file_sizes.values())
|
||||
|
|
@ -527,7 +513,7 @@ def publish_motif() -> tuple[dict, dict]:
|
|||
weather_paper = np.array(motif_manifest["weather"]["raw_sheet"]["paper_color_rgb"], dtype=np.float64)
|
||||
halo_report: dict[str, float] = {}
|
||||
for key in images:
|
||||
webp_path = MOTIF_PUBLIC_DIR / f"{key}.webp"
|
||||
webp_path = motif_public_dir / f"{key}.webp"
|
||||
arr = np.array(Image.open(webp_path).convert("RGBA")).astype(np.float64)
|
||||
rgb, alpha = arr[..., :3], arr[..., 3]
|
||||
paper = bud_paper if key.startswith("bud") else weather_paper
|
||||
|
|
@ -535,18 +521,19 @@ def publish_motif() -> tuple[dict, dict]:
|
|||
halo_report[key] = hm
|
||||
print(f" halo({key}) = {hm:.3f}% (기준 <=2%) {'OK' if hm <= 2.0 else '[초과]'}")
|
||||
|
||||
prefix = cfg.public_href_prefix
|
||||
motif_field = {
|
||||
"bud": {
|
||||
"canvas": {"w": bud_canvas[0], "h": bud_canvas[1]},
|
||||
"base": [round(bud_base[0], 2), round(bud_base[1], 2)],
|
||||
"closed": "/avatar/v3/p1/motif/bud-closed.webp",
|
||||
"half": "/avatar/v3/p1/motif/bud-half.webp",
|
||||
"open": "/avatar/v3/p1/motif/bud-open.webp",
|
||||
"droop": "/avatar/v3/p1/motif/bud-droop.webp",
|
||||
"closed": f"{prefix}/motif/bud-closed.webp",
|
||||
"half": f"{prefix}/motif/bud-half.webp",
|
||||
"open": f"{prefix}/motif/bud-open.webp",
|
||||
"droop": f"{prefix}/motif/bud-droop.webp",
|
||||
},
|
||||
"weather": {
|
||||
"canvas": {"w": weather_canvas[0], "h": weather_canvas[1]},
|
||||
"sprites": {name: f"/avatar/v3/p1/motif/weather-{name}.webp" for name in WEATHER_GROUPS},
|
||||
"sprites": {name: f"{prefix}/motif/weather-{name}.webp" for name in WEATHER_GROUPS},
|
||||
},
|
||||
}
|
||||
publish_report = {
|
||||
|
|
@ -608,7 +595,7 @@ def hexc(rgb: np.ndarray) -> str:
|
|||
return "#%02X%02X%02X" % tuple(int(round(c)) for c in np.clip(rgb, 0, 255))
|
||||
|
||||
|
||||
def compute_palette(base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, dict]:
|
||||
def compute_palette(cfg: PersonaConfig, base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, dict]:
|
||||
im = base_front_rgb.astype(np.float64)
|
||||
lm = manifest["landmarks"]
|
||||
samples_report: dict = {}
|
||||
|
|
@ -645,31 +632,38 @@ def compute_palette(base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, d
|
|||
|
||||
eL = eye_samples(lm["eyeLeft"])
|
||||
eR = eye_samples(lm["eyeRight"])
|
||||
# sclera/iris/irisRing 측정값은 참고용으로만 기록한다. base-front의 흰자는 조각
|
||||
# 해칭 그늘 때문에 밝은 픽셀이 거의 없고(명도>140 픽셀 4개뿐, 표본 중앙값
|
||||
# #A5917A), irisRing 측정값(#3E3028)이 iris 측정값보다 밝아 테두리가 안 보인다.
|
||||
# 벡터 눈은 그늘을 별도로 그리므로(윗눈꺼풀 그늘 띠) 이 3개는 표본이 아니라
|
||||
# 오케스트레이터가 정한 고정 설계값을 쓴다(2단계-B-1a 팔레트 정정 지시).
|
||||
sclera_measured = (eL["sclera"] + eR["sclera"]) / 2.0
|
||||
iris_measured = (eL["iris"] + eR["iris"]) / 2.0
|
||||
iris_ring_measured = (eL["irisRing"] + eR["irisRing"]) / 2.0
|
||||
sclera = np.array([0xD8, 0xCE, 0xBD], dtype=np.float64)
|
||||
iris = np.array([0x4F, 0x3B, 0x2C], dtype=np.float64)
|
||||
iris_ring = np.array([0x1E, 0x1F, 0x1F], dtype=np.float64)
|
||||
# sclera/iris/irisRing은 참고용 측정값으로만 기록한다. base-front의 흰자·홍채는
|
||||
# 조각 해칭 그늘 때문에 실측이 불안정하고, 벡터 눈은 그늘을 별도로 그리므로
|
||||
# persona.json의 paletteSamples.eyeOverride(고정 설계값, 결정문 §8.2 "고정값"
|
||||
# 원칙)를 쓴다.
|
||||
sclera_measured = (eL["sclera"] + eR["sclera"]) / 2.0 if eL["sclera"] is not None and eR["sclera"] is not None else None
|
||||
iris_measured = (eL["iris"] + eR["iris"]) / 2.0 if eL["iris"] is not None and eR["iris"] is not None else None
|
||||
iris_ring_measured = (
|
||||
(eL["irisRing"] + eR["irisRing"]) / 2.0 if eL["irisRing"] is not None and eR["irisRing"] is not None else None
|
||||
)
|
||||
sclera = _hex_to_rgb(cfg.eye_override["sclera"])
|
||||
iris = _hex_to_rgb(cfg.eye_override["iris"])
|
||||
iris_ring = _hex_to_rgb(cfg.eye_override["irisRing"])
|
||||
samples_report["eyeLeft"] = {k: (hexc(v) if isinstance(v, np.ndarray) else v) for k, v in eL.items()}
|
||||
samples_report["eyeRight"] = {k: (hexc(v) if isinstance(v, np.ndarray) else v) for k, v in eR.items()}
|
||||
samples_report["scleraIrisIrisRingFixedOverride"] = {
|
||||
"reason": "고정값(오케스트레이터 결정), 측정 참고값: sclera 중앙값 #A5917A, 홍채 중간 링 #3E3028",
|
||||
"measured": {"sclera": hexc(sclera_measured), "iris": hexc(iris_measured), "irisRing": hexc(iris_ring_measured)},
|
||||
"reason": "persona.json paletteSamples.eyeOverride(고정 설계값)",
|
||||
"measured": {
|
||||
"sclera": hexc(sclera_measured) if sclera_measured is not None else None,
|
||||
"iris": hexc(iris_measured) if iris_measured is not None else None,
|
||||
"irisRing": hexc(iris_ring_measured) if iris_ring_measured is not None else None,
|
||||
},
|
||||
"fixed": {"sclera": hexc(sclera), "iris": hexc(iris), "irisRing": hexc(iris_ring)},
|
||||
}
|
||||
|
||||
# ink: 머리카락 덩어리 안 어두운 픽셀(hair 영역 bbox, lum<55)
|
||||
hair_region = im[100:350, 280:720]
|
||||
# ink: 머리카락 덩어리 안 어두운 픽셀(persona.json paletteSamples.ink, lum<threshold)
|
||||
ink_box, ink_lum_thresh = cfg.require_ink_sample()
|
||||
ix0, iy0, ix1, iy1 = [int(round(v)) for v in ink_box]
|
||||
hair_region = im[iy0:iy1, ix0:ix1]
|
||||
hair_lum = hair_region.mean(axis=2)
|
||||
dark_px = hair_region[hair_lum < 55]
|
||||
dark_px = hair_region[hair_lum < ink_lum_thresh]
|
||||
ink = np.median(dark_px, axis=0)
|
||||
samples_report["ink"] = {"box": [280, 100, 720, 350], "lumThreshold": 55, "n": int(len(dark_px)), "hex": hexc(ink)}
|
||||
samples_report["ink"] = {"box": [ix0, iy0, ix1, iy1], "lumThreshold": ink_lum_thresh, "n": int(len(dark_px)), "hex": hexc(ink)}
|
||||
|
||||
# 입술: mouthCorner/upperLipTop/lowerLipBottom bbox를 mouthCenter y로 위/아래 분리
|
||||
mc = compute_mouth_center_from_manifest(manifest)
|
||||
|
|
@ -688,42 +682,47 @@ def compute_palette(base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, d
|
|||
samples_report["lipLower"] = {"box": [x0, int(mc[1] + 2), x1, int(lb[1] + 3)], "hex": hexc(lip_lower)}
|
||||
samples_report["mouthLine"] = {"box": [x0 + 20, int(mc[1]) - 2, x1 - 20, int(mc[1]) + 3], "hex": hexc(mouth_line)}
|
||||
|
||||
# 모티프: 스타일 프레임 r2-b-linocut.png에서 해/튤립(ochre), 구름/잎(slate blue) 표본
|
||||
style = np.array(Image.open(STYLE_FRAME_PATH).convert("RGB")).astype(np.float64)
|
||||
# 모티프: persona.json에 정의된 스타일 참조 이미지에서 꽃잎(ochre)·잎/구름(blue) 표본
|
||||
style_frame_path = cfg.require_style_frame()
|
||||
petal_boxes, leaf_boxes = cfg.require_motif_boxes()
|
||||
style = np.array(Image.open(style_frame_path).convert("RGB")).astype(np.float64)
|
||||
|
||||
def box_median(x0, y0, x1, y1, kind):
|
||||
box = style[y0:y1, x0:x1].reshape(-1, 3)
|
||||
r, g, b = box[:, 0], box[:, 1], box[:, 2]
|
||||
lum = box.mean(axis=1)
|
||||
def box_median(box, kind):
|
||||
x0b, y0b, x1b, y1b = [int(round(v)) for v in box]
|
||||
region = style[y0b:y1b, x0b:x1b].reshape(-1, 3)
|
||||
r, g, b = region[:, 0], region[:, 1], region[:, 2]
|
||||
lum = region.mean(axis=1)
|
||||
if kind == "ochre":
|
||||
mask = (r > 150) & (r - b > 50) & (r - g > 15)
|
||||
else:
|
||||
mask = (b > r) & (lum > 60) & (lum < 190)
|
||||
sel = box[mask]
|
||||
return np.median(sel, axis=0) if len(sel) else None, len(sel)
|
||||
sel = region[mask]
|
||||
return (np.median(sel, axis=0) if len(sel) else None), len(sel)
|
||||
|
||||
sun, n_sun = box_median(1230, 10, 1536, 210, "ochre")
|
||||
tulip, n_tulip = box_median(1030, 280, 1170, 560, "ochre")
|
||||
motif_petal = (sun + tulip) / 2.0
|
||||
petal_samples = [(spec["label"], *box_median(spec["box"], spec["kind"]), spec["box"]) for spec in petal_boxes]
|
||||
petal_values = [v for _label, v, _n, _box in petal_samples if v is not None]
|
||||
if not petal_values:
|
||||
raise SystemExit("[중단] motifPetalBoxes 표본에서 ochre 픽셀을 하나도 찾지 못했다.")
|
||||
motif_petal = np.mean(petal_values, axis=0)
|
||||
|
||||
cloud1, n_c1 = box_median(20, 20, 380, 190, "blue")
|
||||
raincloud, n_rc = box_median(520, 10, 930, 230, "blue")
|
||||
wilted, n_wf = box_median(520, 280, 650, 580, "blue")
|
||||
bud, n_bud = box_median(10, 290, 110, 570, "blue")
|
||||
motif_leaf = (cloud1 + raincloud + wilted + bud) / 4.0
|
||||
leaf_samples = [(spec["label"], *box_median(spec["box"], spec["kind"]), spec["box"]) for spec in leaf_boxes]
|
||||
leaf_values = [v for _label, v, _n, _box in leaf_samples if v is not None]
|
||||
if not leaf_values:
|
||||
raise SystemExit("[중단] motifLeafBoxes 표본에서 blue 픽셀을 하나도 찾지 못했다.")
|
||||
motif_leaf = np.mean(leaf_values, axis=0)
|
||||
|
||||
try:
|
||||
style_frame_display = str(style_frame_path.relative_to(cfg.repo_root))
|
||||
except ValueError:
|
||||
style_frame_display = str(style_frame_path)
|
||||
samples_report["motifPetal"] = {
|
||||
"sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png",
|
||||
"sun": {"box": [1230, 10, 1536, 210], "n": n_sun, "hex": hexc(sun)},
|
||||
"tulip": {"box": [1030, 280, 1170, 560], "n": n_tulip, "hex": hexc(tulip)},
|
||||
"sourceImage": style_frame_display,
|
||||
"samples": {label: {"box": list(box), "n": n, "hex": hexc(v) if v is not None else None} for label, v, n, box in petal_samples},
|
||||
"hex": hexc(motif_petal),
|
||||
}
|
||||
samples_report["motifLeaf"] = {
|
||||
"sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png",
|
||||
"cloudLeft": {"box": [20, 20, 380, 190], "n": n_c1, "hex": hexc(cloud1)},
|
||||
"raincloud": {"box": [520, 10, 930, 230], "n": n_rc, "hex": hexc(raincloud)},
|
||||
"wiltedFlowerLeaf": {"box": [520, 280, 650, 580], "n": n_wf, "hex": hexc(wilted)},
|
||||
"closedBudLeaf": {"box": [10, 290, 110, 570], "n": n_bud, "hex": hexc(bud)},
|
||||
"sourceImage": style_frame_display,
|
||||
"samples": {label: {"box": list(box), "n": n, "hex": hexc(v) if v is not None else None} for label, v, n, box in leaf_samples},
|
||||
"hex": hexc(motif_leaf),
|
||||
}
|
||||
|
||||
|
|
@ -735,18 +734,23 @@ def compute_palette(base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, d
|
|||
"lipUpper": hexc(lip_upper),
|
||||
"lipLower": hexc(lip_lower),
|
||||
"mouthLine": hexc(mouth_line),
|
||||
"mouthInner": "#3B201B",
|
||||
"teeth": "#E9E0CF",
|
||||
"blush": "#C0624A",
|
||||
"tear": "#EEE5D3",
|
||||
"pallor": "#9AA3A6",
|
||||
"paper": "#EEE5D3",
|
||||
"mouthInner": cfg.palette_fixed["mouthInner"],
|
||||
"teeth": cfg.palette_fixed["teeth"],
|
||||
"blush": cfg.palette_fixed["blush"],
|
||||
"tear": cfg.palette_fixed["tear"],
|
||||
"pallor": cfg.palette_fixed["pallor"],
|
||||
"paper": cfg.palette_fixed["paper"],
|
||||
"motifPetal": hexc(motif_petal),
|
||||
"motifLeaf": hexc(motif_leaf),
|
||||
}
|
||||
return palette, samples_report
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_str: str) -> np.ndarray:
|
||||
hex_str = hex_str.lstrip("#")
|
||||
return np.array([int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)], dtype=np.float64)
|
||||
|
||||
|
||||
_MOUTH_CENTER_CACHE: tuple[float, float] | None = None
|
||||
|
||||
|
||||
|
|
@ -793,7 +797,7 @@ def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict:
|
|||
}
|
||||
|
||||
|
||||
# --- crops.face 계산 --------------------------------------------------------
|
||||
# --- crops.face/bust 계산 ----------------------------------------------------
|
||||
|
||||
|
||||
def compute_face_crop(face_oval: list[tuple[float, float]]) -> tuple[float, float, float, float]:
|
||||
|
|
@ -808,6 +812,14 @@ def compute_face_crop(face_oval: list[tuple[float, float]]) -> tuple[float, floa
|
|||
return (cx - side / 2.0, cy - side / 2.0, side, side)
|
||||
|
||||
|
||||
def compute_bust_crop(cfg: PersonaConfig, canvas_w: int) -> tuple[float, float, float, float]:
|
||||
"""정사각형 크롭, 변 길이는 캔버스 폭(인물이 캔버스 폭에 거의 꽉 차는 흉상
|
||||
구도라는 전제). 위쪽 오프셋만 persona.json(crops.bustYOffset)에서 읽는다
|
||||
(원화마다 인물이 캔버스 안에서 수직으로 얼마나 올려/내려 잡혔는지가
|
||||
달라 랜드마크로 유도하기보다 사람이 보고 정하는 편이 안전하다)."""
|
||||
return (0.0, cfg.bust_crop_y_offset, float(canvas_w), float(canvas_w))
|
||||
|
||||
|
||||
# --- TypeScript 파일 렌더링 --------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -822,12 +834,12 @@ def ts_point(p) -> str:
|
|||
return f"[{ts_num(p[0])}, {ts_num(p[1])}]"
|
||||
|
||||
|
||||
def render_rig_ts(rig: dict) -> str:
|
||||
def render_rig_ts(cfg: PersonaConfig, rig: dict) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */")
|
||||
lines.append("/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */")
|
||||
lines.append('import type { LinocutRig } from "../linocutRig";')
|
||||
lines.append("")
|
||||
lines.append("export const P1_LINOCUT_RIG: LinocutRig = {")
|
||||
lines.append(f"export const {cfg.rig_export_name}: LinocutRig = {{")
|
||||
lines.append(f' schemaVersion: "{rig["schemaVersion"]}",')
|
||||
lines.append(f' persona: "{rig["persona"]}",')
|
||||
lines.append(f' canvas: {{ w: {rig["canvas"]["w"]}, h: {rig["canvas"]["h"]} }},')
|
||||
|
|
@ -963,12 +975,18 @@ def render_rig_ts(rig: dict) -> str:
|
|||
# --- main -------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
base_front_rgb = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB"))
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
layers_dir = cfg.layers_dir
|
||||
layers_v2_dir = cfg.layers_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
base_front_rgb = np.array(Image.open(base_dir / "base-front.png").convert("RGB"))
|
||||
|
||||
print("=== 얼굴 랜드마크 검출(base-front.png) ===")
|
||||
points = detect_face_landmarks(BASE_DIR / "base-front.png")
|
||||
points = detect_face_landmarks(base_dir / "base-front.png")
|
||||
face_oval = compute_face_oval(points)
|
||||
mouth_center = compute_mouth_center(points)
|
||||
global _MOUTH_CENTER_CACHE
|
||||
|
|
@ -979,7 +997,7 @@ def main() -> int:
|
|||
print(f"faceOval bbox = [{fx0:.1f}, {fy0:.1f}, {fx1:.1f}, {fy1:.1f}] (w={fx1-fx0:.1f} h={fy1-fy0:.1f})")
|
||||
|
||||
print("=== 팔레트 표본 ===")
|
||||
palette, palette_report = compute_palette(base_front_rgb, manifest)
|
||||
palette, palette_report = compute_palette(cfg, base_front_rgb, manifest)
|
||||
for k, v in palette.items():
|
||||
print(f" {k} = {v}")
|
||||
|
||||
|
|
@ -990,21 +1008,21 @@ def main() -> int:
|
|||
print("=== 레이어 게시 ===")
|
||||
layer_sources: dict[str, tuple[Path, str]] = {}
|
||||
for key, stem, href_stem, optional in LAYER_SPECS:
|
||||
src = resolve_layer_source(stem, optional)
|
||||
src = resolve_layer_source(layers_dir, layers_v2_dir, stem, optional)
|
||||
if src is not None:
|
||||
layer_sources[key] = (src, href_stem)
|
||||
print(f" {key} <- {src.relative_to(REPO_ROOT)}")
|
||||
print(f" {key} <- {src.relative_to(cfg.repo_root)}")
|
||||
elif not optional:
|
||||
raise SystemExit(f"[중단] 필수 레이어 {key} 소스가 없다.")
|
||||
else:
|
||||
print(f" {key} 없음(생략)")
|
||||
grain_source = resolve_grain_source()
|
||||
grain_source = resolve_grain_source(layers_v2_dir)
|
||||
if grain_source is not None:
|
||||
print(f" grain <- {grain_source.relative_to(REPO_ROOT)}")
|
||||
print(f" grain <- {grain_source.relative_to(cfg.repo_root)}")
|
||||
else:
|
||||
print(" grain 없음(생략)")
|
||||
|
||||
layers_report, grain_report, publish_meta = publish_layers(layer_sources, grain_source)
|
||||
layers_report, grain_report, publish_meta = publish_layers(cfg, layer_sources, grain_source)
|
||||
print(f"인코딩: quality={publish_meta['encodeMeta']['quality']} scale={publish_meta['encodeMeta']['scale']}")
|
||||
total = 0
|
||||
for name, size in publish_meta["fileSizes"].items():
|
||||
|
|
@ -1013,48 +1031,46 @@ def main() -> int:
|
|||
print(f"합계 = {total} bytes (예산 {SIZE_BUDGET_BYTES} bytes) {'OK' if total <= SIZE_BUDGET_BYTES else '[초과]'}")
|
||||
|
||||
print("=== 입술 결 게시 ===")
|
||||
lip_texture_field, lip_publish_report = publish_lip_texture(manifest)
|
||||
lip_texture_field, lip_publish_report = publish_lip_texture(cfg, manifest, layers_v2_dir)
|
||||
if lip_texture_field is None:
|
||||
print(" lip-upper.png/lip-lower.png/lip-shadow.png 없음(생략) — lipTexture 없는 리그")
|
||||
|
||||
print("=== 턱 조각 게시 ===")
|
||||
jaw_field, jaw_publish_report = publish_jaw_pieces(manifest, layers_report, publish_meta["encodeMeta"])
|
||||
jaw_field, jaw_publish_report = publish_jaw_pieces(cfg, manifest, layers_report, publish_meta["encodeMeta"], layers_v2_dir)
|
||||
if jaw_field is None:
|
||||
print(" jaw-head.png/jaw-detail.png 없음(생략) — jaw 없는 리그")
|
||||
|
||||
print("=== 모티프 게시 ===")
|
||||
motif_field, motif_publish_report = publish_motif()
|
||||
motif_field, motif_publish_report = publish_motif(cfg)
|
||||
if motif_field is None:
|
||||
print(" motif/manifest.json 없음(생략) — 모티프 없는 리그")
|
||||
|
||||
pivots = cfg.require_pivots()
|
||||
rig = {
|
||||
"schemaVersion": "vignette.avatar.v3.rig.v1",
|
||||
"persona": "P1",
|
||||
"persona": cfg.code,
|
||||
"canvas": {"w": manifest["canvas"]["w"], "h": manifest["canvas"]["h"]},
|
||||
"layers": layers_report,
|
||||
"grain": grain_report,
|
||||
"lipTexture": lip_texture_field,
|
||||
"jaw": jaw_field,
|
||||
"motif": motif_field,
|
||||
"pivots": {"neck": [500, 990], "body": [502, 1566], "face": [490, 660]},
|
||||
"pivots": {"neck": list(pivots["neck"]), "body": list(pivots["body"]), "face": list(pivots["face"])},
|
||||
"crops": {
|
||||
"portrait": [0, 0, manifest["canvas"]["w"], manifest["canvas"]["h"]],
|
||||
"bust": [0, 40, 1005, 1005],
|
||||
"bust": list(compute_bust_crop(cfg, manifest["canvas"]["w"])),
|
||||
"face": face_crop,
|
||||
},
|
||||
"faceOval": [(round(x, 1), round(y, 1)) for x, y in face_oval],
|
||||
"landmarks": landmarks,
|
||||
"palette": palette,
|
||||
"backdrop": {
|
||||
"cognitive": "#ECE3D1",
|
||||
"positive": "#F1DEC2",
|
||||
"negative": "#DCE0E2",
|
||||
"defensive": "#E6DAD3",
|
||||
"energy": "#E2E0D0",
|
||||
},
|
||||
"backdrop": dict(cfg.backdrop),
|
||||
}
|
||||
|
||||
RIG_TS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
RIG_TS_PATH.write_text(render_rig_ts(rig), encoding="utf-8", newline="\n")
|
||||
print(f"저장: {RIG_TS_PATH.relative_to(REPO_ROOT)}")
|
||||
rig_ts_path = cfg.rig_ts_path
|
||||
rig_ts_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rig_ts_path.write_text(render_rig_ts(cfg, rig), encoding="utf-8", newline="\n")
|
||||
print(f"저장: {rig_ts_path.relative_to(cfg.repo_root)}")
|
||||
|
||||
report = {
|
||||
"faceOvalBBox": [round(fx0, 1), round(fy0, 1), round(fx1, 1), round(fy1, 1)],
|
||||
|
|
@ -1069,10 +1085,10 @@ def main() -> int:
|
|||
"motifPublish": motif_publish_report,
|
||||
"rig": rig,
|
||||
}
|
||||
report_path = ROOT / "preview" / "v2" / "export-rig-report.json"
|
||||
report_path = cfg.preview_v2_dir / "export-rig-report.json"
|
||||
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.relative_to(REPO_ROOT)}")
|
||||
print(f"저장: {report_path.relative_to(cfg.repo_root)}")
|
||||
|
||||
manifest["lipTexturePublish"] = lip_publish_report
|
||||
if lip_publish_report is not None and "webpVsPngMeanAbsDiff" in lip_publish_report:
|
||||
|
|
@ -1081,10 +1097,10 @@ def main() -> 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_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(REPO_ROOT)}")
|
||||
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)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""P1 서연 faceDetail 레이어 생성 — 2단계-B-1a (3), 결정문 §8.2.
|
||||
"""공통 리노컷 리그 — faceDetail 레이어 생성(결정문 §8.2).
|
||||
|
||||
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
|
||||
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
|
||||
|
|
@ -13,7 +13,14 @@ F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으
|
|||
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
|
||||
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
|
||||
|
||||
실행: <venv>/python.exe build_face_detail.py
|
||||
점(mole)처럼 원화에만 있고 랜드마크로 유도할 수 없는 반점은 persona.json의
|
||||
faceDetail.moles(원점+반경 목록)에서 읽는다. 목록이 비면 점 영역을 그리지
|
||||
않는다(모든 캐릭터가 점이 있는 것은 아니다).
|
||||
|
||||
전제: layers_v2.py가 이미 layers/v2/{body,head,hairFront}.png를 만들어 두어야
|
||||
한다(정지 합성 검사에 쓴다).
|
||||
|
||||
실행: <venv>/python.exe face_detail.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -30,19 +37,11 @@ from scipy.ndimage import (
|
|||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from build_layers_segmented import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over # noqa: E402
|
||||
from build_layers_v2 import CREAM_BG # noqa: E402
|
||||
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
LAYERS_V2_DIR = ROOT / "layers" / "v2"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
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
|
||||
|
||||
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
|
||||
REGION_FEATHER_PX = 12.0
|
||||
MOLE_CENTER = (661.3, 627.9)
|
||||
MOLE_RADIUS = 20.0
|
||||
|
||||
# --- 제외 영역(구멍) 상수: 이목구비 잉크선을 덮는 좁은 범위 ------------------
|
||||
EYE_INK_MARGIN = 32.0
|
||||
|
|
@ -384,22 +383,37 @@ def find_clean_patch(
|
|||
return int(idx[1] + sx0), int(idx[0] + sy0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
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"]
|
||||
|
||||
front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
f_img = build_padded_faceless()
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
f_img = build_padded_faceless(base_dir)
|
||||
f_arr = np.array(f_img).astype(np.float64)
|
||||
h, w, _ = f_arr.shape
|
||||
lum_front = front.mean(axis=2)
|
||||
lum_f_raw = f_arr.mean(axis=2)
|
||||
|
||||
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
if cfg.brow_landmarks_override is not None:
|
||||
# P1 전용 호환 장치: 눈썹 제외 영역·eyeRegion y0은 눈썹 중심선 보정 이전
|
||||
# 좌표로 고정 빌드된 채 "다시 빌드하지 않는다"는 지시로 승인됐다(persona.json
|
||||
# 참고). 다른 모든 랜드마크(눈·입·코·턱)는 그대로 현재 값을 쓴다.
|
||||
browL, browR = cfg.brow_landmarks_override["browLeft"], cfg.brow_landmarks_override["browRight"]
|
||||
brow_source = "persona.json faceDetail.browLandmarksOverride"
|
||||
else:
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
brow_source = "manifest.landmarks.eyebrowLeft/Right"
|
||||
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
|
||||
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
|
||||
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
|
||||
print(f"눈썹 좌표 출처: {brow_source}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 영역(region): faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영·점)
|
||||
|
|
@ -434,7 +448,11 @@ def main() -> int:
|
|||
a_eyeL = ellipse_alpha(eyeL_cx, eyeL_cy, eyeL_rx, eyeL_ry, w, h, REGION_FEATHER_PX)
|
||||
a_eyeR = ellipse_alpha(eyeR_cx, eyeR_cy, eyeR_rx, eyeR_ry, w, h, REGION_FEATHER_PX)
|
||||
a_mouth = ellipse_alpha(mouth_cx, mouth_cy, mouth_rx, mouth_ry, w, h, REGION_FEATHER_PX)
|
||||
a_mole = ellipse_alpha(MOLE_CENTER[0], MOLE_CENTER[1], MOLE_RADIUS, MOLE_RADIUS, w, h, REGION_FEATHER_PX)
|
||||
a_mole = np.zeros((h, w), dtype=np.float64)
|
||||
for mole in cfg.moles:
|
||||
mcx, mcy = mole["center"]
|
||||
r = float(mole["radius"])
|
||||
a_mole = np.maximum(a_mole, ellipse_alpha(mcx, mcy, r, r, w, h, REGION_FEATHER_PX))
|
||||
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -502,7 +520,7 @@ def main() -> int:
|
|||
# ------------------------------------------------------------------
|
||||
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
|
||||
# ------------------------------------------------------------------
|
||||
category_mask = run_segmentation(BASE_DIR / "base-faceless-padded.png")
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
|
||||
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
|
||||
|
||||
|
|
@ -545,8 +563,8 @@ def main() -> int:
|
|||
# ------------------------------------------------------------------
|
||||
# 저장
|
||||
# ------------------------------------------------------------------
|
||||
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_path = LAYERS_V2_DIR / "face-detail.png"
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = layers_v2_dir / "face-detail.png"
|
||||
Image.fromarray(np.dstack([to_u8(face_detail_rgb), to_u8(face_detail_alpha)]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(to_u8(face_detail_alpha))
|
||||
print(f"저장: {out_path} bbox={bbox}")
|
||||
|
|
@ -558,9 +576,9 @@ def main() -> int:
|
|||
arr = np.array(Image.open(p).convert("RGBA")).astype(np.float64)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
body_rgb, body_a = load_rgba(LAYERS_V2_DIR / "body.png")
|
||||
head_rgb, head_a = load_rgba(LAYERS_V2_DIR / "head.png")
|
||||
hf_rgb, hf_a = load_rgba(LAYERS_V2_DIR / "hairFront.png")
|
||||
body_rgb, body_a = load_rgba(layers_v2_dir / "body.png")
|
||||
head_rgb, head_a = load_rgba(layers_v2_dir / "head.png")
|
||||
hf_rgb, hf_a = load_rgba(layers_v2_dir / "hairFront.png")
|
||||
|
||||
canvas = np.zeros((h, w, 4), dtype=np.float64)
|
||||
canvas[..., 0] = CREAM_BG[0]; canvas[..., 1] = CREAM_BG[1]; canvas[..., 2] = CREAM_BG[2]; canvas[..., 3] = 255.0
|
||||
|
|
@ -605,7 +623,7 @@ def main() -> int:
|
|||
pad = 30
|
||||
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
cx1, cy1 = min(w, int(xs.max()) + pad), min(h, int(ys.max()) + pad)
|
||||
diag_path = PREVIEW_V2_DIR / "checkC-violations.png"
|
||||
diag_path = preview_v2_dir / "checkC-violations.png"
|
||||
Image.fromarray(to_u8(overlay)).crop((cx0, cy0, cx1, cy1)).save(diag_path)
|
||||
print(f" [checkC 미달] 위반 위치 진단 이미지 저장: {diag_path}")
|
||||
|
||||
|
|
@ -687,19 +705,20 @@ def main() -> int:
|
|||
band = dark_circle_band_mask(eye, w, h)
|
||||
composite_mean = float(lum_composite[band].mean())
|
||||
front_mean = float(lum_front[band].mean())
|
||||
diff = abs(composite_mean - front_mean)
|
||||
passed = diff <= DARK_CIRCLE_MATCH_TOLERANCE
|
||||
diff2 = abs(composite_mean - front_mean)
|
||||
passed = diff2 <= DARK_CIRCLE_MATCH_TOLERANCE
|
||||
all_pass_dc = all_pass_dc and passed
|
||||
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff, "pass": passed}
|
||||
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
|
||||
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff2, "pass": passed}
|
||||
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff2:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
|
||||
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"]["faceDetail"] = {
|
||||
"browLandmarksSource": brow_source,
|
||||
"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)],
|
||||
"mouth": [round(mouth_x0, 1), round(mouth_y0, 1), round(mouth_x1, 1), round(mouth_y1, 1)],
|
||||
"cheekMole": {"center": [round(MOLE_CENTER[0], 1), round(MOLE_CENTER[1], 1)], "radius": MOLE_RADIUS},
|
||||
"moles": [{"center": [round(m["center"][0], 1), round(m["center"][1], 1)], "radius": m["radius"]} for m in cfg.moles],
|
||||
},
|
||||
"exclusionPixels": {
|
||||
"eyeLeft": int(eyeL_excl.sum()), "eyeRight": int(eyeR_excl.sum()),
|
||||
|
|
@ -725,16 +744,15 @@ def main() -> int:
|
|||
"checkC_violationPixels": n_violations,
|
||||
"checkD_texturePreservation": checkD,
|
||||
"checkD_allPass": all_pass_d,
|
||||
"checkD_mouthException": "mouth 0.550은 가공 없는 원본 F 픽셀 그대로라 원본 특성으로 수용(오케스트레이터 판정).",
|
||||
"checkE_ghostOutline": checkE,
|
||||
"checkE_allPass": all_pass_e,
|
||||
"checkDarkCircle": checkDarkCircle,
|
||||
"checkDarkCircle_allPass": all_pass_dc,
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
"""P1 서연 리노컷 최종 게시 미리보기 — 2단계-B-1a (5).
|
||||
"""공통 리노컷 리그 — 최종 게시 미리보기.
|
||||
|
||||
게시된 WebP(apps/web/public/avatar/v3/p1/*.webp)를 다시 읽어 합성한다(원본 PNG가
|
||||
아니라 실제로 배포되는 파일을 검증하기 위함). export_rig.py가 남긴
|
||||
게시된 WebP(apps/web/public/avatar/v3/<publicSlug>/*.webp)를 다시 읽어 합성한다
|
||||
(원본 PNG가 아니라 실제로 배포되는 파일을 검증하기 위함). export_rig.py가 남긴
|
||||
preview/v2/export-rig-report.json의 rig 정보(레이어 x,y,w,h, pivots, palette)를
|
||||
좌표 소스로 쓴다. 벡터 부위(눈·눈썹·입 등)는 없다 — faceDetail과 grain까지만
|
||||
포함한 정적 합성이다.
|
||||
|
||||
생성물: motion-{cream,cool,dark}.png, face-detail.png, closed-eyes.png, holes-texture.jpg, ghost-check.jpg
|
||||
|
||||
실행: <venv>/python.exe build_final_previews.py
|
||||
전제: export_rig.py가 먼저 게시를 끝내야 한다(이 스크립트가 export-rig-report.json과
|
||||
게시된 webp를 읽는다) — 파이프라인에서 가장 마지막에 돌린다.
|
||||
|
||||
실행: <venv>/python.exe final_previews.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -21,17 +24,9 @@ from PIL import Image, ImageDraw
|
|||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from build_layers_segmented import composite_over, rotate_rgba, to_u8, translate_rgba # noqa: E402
|
||||
import build_face_detail as bfd # noqa: E402
|
||||
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
REPORT_PATH = PREVIEW_V2_DIR / "export-rig-report.json"
|
||||
|
||||
REPO_ROOT = ROOT.parents[2]
|
||||
PUBLIC_DIR = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "v3" / "p1"
|
||||
from segmentation import composite_over, rotate_rgba, to_u8, translate_rgba, build_padded_faceless # noqa: E402
|
||||
import face_detail as bfd # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
GRAIN_OPACITY = 0.35
|
||||
|
||||
|
|
@ -48,9 +43,9 @@ FRAMES = [
|
|||
]
|
||||
|
||||
|
||||
def load_layer(href_stem: str, x: float, y: float, w: float, h: float, canvas_w: int, canvas_h: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
def load_layer(public_dir: Path, href_stem: str, x: float, y: float, w: float, h: float, canvas_w: int, canvas_h: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""게시된 webp를 rig 사각형(x,y,w,h)에 맞춰 리사이즈하고 캔버스 크기로 패딩한다."""
|
||||
im = Image.open(PUBLIC_DIR / f"{href_stem}.webp").convert("RGBA")
|
||||
im = Image.open(public_dir / f"{href_stem}.webp").convert("RGBA")
|
||||
tw, th = round(w), round(h)
|
||||
if im.size != (tw, th):
|
||||
im = im.resize((tw, th), Image.LANCZOS)
|
||||
|
|
@ -67,8 +62,8 @@ def polygon_alpha_mask(points: list[tuple[float, float]], w: int, h: int) -> np.
|
|||
return np.clip(gaussian_filter(np.array(img, dtype=np.float64), sigma=2.0), 0, 255)
|
||||
|
||||
|
||||
def tile_grain(canvas_w: int, canvas_h: int, size: int) -> np.ndarray:
|
||||
grain_im = Image.open(PUBLIC_DIR / "paper-grain.webp").convert("RGB")
|
||||
def tile_grain(public_dir: Path, canvas_w: int, canvas_h: int, size: int) -> np.ndarray:
|
||||
grain_im = Image.open(public_dir / "paper-grain.webp").convert("RGB")
|
||||
if grain_im.size != (size, size):
|
||||
grain_im = grain_im.resize((size, size), Image.LANCZOS)
|
||||
grain = np.array(grain_im).astype(np.float64)
|
||||
|
|
@ -95,17 +90,25 @@ def composite_static(body, head, face_detail, hair_front, bg: tuple[int, int, in
|
|||
return to_u8(canvas)[..., :3].astype(np.float64)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
report = json.loads(REPORT_PATH.read_text(encoding="utf-8"))
|
||||
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
|
||||
public_dir = cfg.public_dir
|
||||
|
||||
report_path = preview_v2_dir / "export-rig-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
rig = report["rig"]
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
cw, ch = rig["canvas"]["w"], rig["canvas"]["h"]
|
||||
neck_pivot = tuple(rig["pivots"]["neck"])
|
||||
|
||||
def layer_of(key: str) -> tuple[np.ndarray, np.ndarray]:
|
||||
l = rig["layers"][key]
|
||||
stem = Path(l["href"]).stem
|
||||
return load_layer(stem, l["x"], l["y"], l["w"], l["h"], cw, ch)
|
||||
return load_layer(public_dir, stem, l["x"], l["y"], l["w"], l["h"], cw, ch)
|
||||
|
||||
body = layer_of("body")
|
||||
head = layer_of("head")
|
||||
|
|
@ -116,7 +119,7 @@ def main() -> int:
|
|||
clip = polygon_alpha_mask(face_oval, cw, ch) / 255.0
|
||||
face_detail = (face_detail_raw[0], face_detail_raw[1] * clip)
|
||||
|
||||
grain_tile = tile_grain(cw, ch, rig["grain"]["size"])
|
||||
grain_tile = tile_grain(public_dir, cw, ch, rig["grain"]["size"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# motion-{cream,cool,dark}.png
|
||||
|
|
@ -154,23 +157,22 @@ def main() -> int:
|
|||
for im in frame_ims:
|
||||
strip.paste(im.resize((sw, sh), Image.LANCZOS), (x, 0))
|
||||
x += sw + gap
|
||||
out_path = PREVIEW_V2_DIR / f"motion-{bg_name}.png"
|
||||
out_path = preview_v2_dir / f"motion-{bg_name}.png"
|
||||
strip.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 제외 영역 재구성(build_face_detail.py와 동일한 함수·상수) — 미리보기가
|
||||
# 제외 영역 재구성(face_detail.py와 동일한 함수·상수) — 미리보기가
|
||||
# 실제 게시물이 쓴 것과 같은 제외 영역 윤곽을 보여주게 한다.
|
||||
# ------------------------------------------------------------------
|
||||
lm = manifest["landmarks"]
|
||||
front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
|
||||
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
|
||||
lum_front = front.mean(axis=2)
|
||||
import scipy.ndimage as ndi
|
||||
from build_layers_segmented import build_padded_faceless
|
||||
|
||||
yy_full, _ = np.mgrid[0:ch, 0:cw]
|
||||
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
|
||||
|
|
@ -216,11 +218,11 @@ def main() -> int:
|
|||
# holes-texture.jpg: 구멍별로 [F 원본, 메운 결과(face_detail_rgb),
|
||||
# base-front, 제외 영역 윤곽 겹침]을 2배 확대해 나란히 놓는다.
|
||||
# ------------------------------------------------------------------
|
||||
f_arr = np.array(build_padded_faceless()).astype(np.float64)
|
||||
f_arr = np.array(build_padded_faceless(base_dir)).astype(np.float64)
|
||||
# 게시된 webp는 알파 bbox로 잘려 있어(bbox 밖은 빈 캔버스) 구멍이 bbox 경계에
|
||||
# 걸치면 미리보기가 검게 잘린 것처럼 보인다 — 원본 PNG(전체 캔버스, RGB가
|
||||
# 어디서나 정의됨)를 직접 읽어 이 문제를 피한다.
|
||||
fd_rgb_full = np.array(Image.open(ROOT / "layers" / "v2" / "face-detail.png").convert("RGBA")).astype(np.float64)[..., :3]
|
||||
fd_rgb_full = np.array(Image.open(layers_v2_dir / "face-detail.png").convert("RGBA")).astype(np.float64)[..., :3]
|
||||
|
||||
outline_overlay = front.copy()
|
||||
outline_overlay[mask_outline(hole_mask)] = np.array([40.0, 200.0, 60.0])
|
||||
|
|
@ -254,7 +256,7 @@ def main() -> int:
|
|||
for r in rows:
|
||||
holes_tex.paste(r, (0, y))
|
||||
y += r.height + 10
|
||||
holes_tex_path = PREVIEW_V2_DIR / "holes-texture.jpg"
|
||||
holes_tex_path = preview_v2_dir / "holes-texture.jpg"
|
||||
holes_tex.convert("RGB").save(holes_tex_path, "JPEG", quality=90)
|
||||
print(f"저장: {holes_tex_path}")
|
||||
|
||||
|
|
@ -297,15 +299,19 @@ def main() -> int:
|
|||
d = ImageDraw.Draw(strip)
|
||||
d.text((x, 4), label, fill=(0, 0, 0))
|
||||
x += pw + 10
|
||||
out_path = PREVIEW_V2_DIR / "face-detail.png"
|
||||
out_path = preview_v2_dir / "face-detail.png"
|
||||
strip.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# closed-eyes.png: 벡터 없이 구멍만 보이는 정지 합성의 눈·입 확대
|
||||
# (머리 영역 중심 기준 상대 좌표 — 결과물 회귀 대상이 아닌 미리보기 전용)
|
||||
# ------------------------------------------------------------------
|
||||
eye_box = (280, 470, 700, 680)
|
||||
mouth_box = (360, 730, 630, 910)
|
||||
eyeL_out, eyeR_out = eyeL["outerCorner"], eyeR["outerCorner"]
|
||||
eye_cx = (eyeL_out[0] + eyeR_out[0]) / 2.0
|
||||
eye_box = (int(eye_cx - 210), int(browL["peak"][1] - 40), int(eye_cx + 210), int(eyeL["lowerLidBottom"][1] + 100))
|
||||
mouth_cx = (mcL[0] + mcR[0]) / 2.0
|
||||
mouth_box = (int(mouth_cx - 135), int(upLip[1] - 43), int(mouth_cx + 135), int(loLip[1] + 78))
|
||||
eye_crop = Image.fromarray(to_u8(static_full), "RGB").crop(eye_box)
|
||||
mouth_crop = Image.fromarray(to_u8(static_full), "RGB").crop(mouth_box)
|
||||
zoom = 2
|
||||
|
|
@ -316,7 +322,7 @@ def main() -> int:
|
|||
out_im = Image.new("RGB", (mouth_w, eye_crop.height + mouth_h + 10), (255, 255, 255))
|
||||
out_im.paste(eye_crop, (0, 0))
|
||||
out_im.paste(mouth_crop, (0, eye_crop.height + 10))
|
||||
out_path = PREVIEW_V2_DIR / "closed-eyes.png"
|
||||
out_path = preview_v2_dir / "closed-eyes.png"
|
||||
out_im.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
|
@ -324,10 +330,10 @@ def main() -> int:
|
|||
# ghost-check.jpg: faceDetail만 올린 얼굴(벡터 없음)의 눈·눈썹·입을 한
|
||||
# 프레임으로 2배 확대 — 옛 잉크선(유령 윤곽)이 남았는지 보는 용도.
|
||||
# ------------------------------------------------------------------
|
||||
ghost_box = (260, 440, 760, 940)
|
||||
ghost_box = (int(eye_cx - 240), int(browL["peak"][1] - 70), int(eye_cx + 260), int(loLip[1] + 110))
|
||||
ghost_crop = Image.fromarray(to_u8(static_full), "RGB").crop(ghost_box)
|
||||
ghost_crop = ghost_crop.resize((ghost_crop.width * 2, ghost_crop.height * 2), Image.LANCZOS)
|
||||
out_path = PREVIEW_V2_DIR / "ghost-check.jpg"
|
||||
out_path = preview_v2_dir / "ghost-check.jpg"
|
||||
ghost_crop.convert("RGB").save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
|
@ -335,4 +341,4 @@ def main() -> int:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,31 +1,24 @@
|
|||
"""P1 서연 턱 조각(jaw-head·jaw-detail) 생성 — 2단계-B, 결정문 §8.4 하관 띠 변형
|
||||
(작업 패킷 A5).
|
||||
"""공통 리노컷 리그 — 턱 조각(jaw-head·jaw-detail) 생성(결정문 §8.4 하관 띠 변형).
|
||||
|
||||
하관 띠 변형(렌더러 v3)은 ㅏ·ㅗ 등에서 턱·아랫입술·콧방울을 세로 띠로 나눠 옮기는
|
||||
변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 1005×1566 head·faceDetail
|
||||
이미지를 매 프레임 다시 그리면 비용이 크므로(out5 실측: long task 14/7.3s), 변형이
|
||||
실제로 필요한 사각형만 미리 잘라 둔다.
|
||||
변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 head·faceDetail 이미지를
|
||||
매 프레임 다시 그리면 비용이 크므로, 변형이 실제로 필요한 사각형만 미리 잘라 둔다.
|
||||
|
||||
영역(결정문 §8.4, 작업 패킷 A5):
|
||||
영역:
|
||||
- y: noseTip.y − 50 − 10 부터 chinTip.y + 40 까지.
|
||||
- x: 얼굴 윤곽(faceOval, p1Rig.ts에 이미 게시된 값과 같은 36점 루프)을 바깥으로
|
||||
18px 넓힌 다각형의, 이 y 범위 안에서의 가로 범위(렌더러가 각 띠를 그 다각형으로
|
||||
clip하므로, 이 y 범위를 벗어난 곳의 폭은 필요 없다 — 재는 방식은 아래 참고).
|
||||
- 변형·메우기는 하지 않는다. head·face-detail의 **원본 픽셀·알파를 그대로** 잘라
|
||||
layers/v2/jaw-{head,detail}.png로 저장한다. 게시본은 무손실 WebP(exact=True)다
|
||||
(export_rig.py의 publish_jaw_pieces).
|
||||
- x: 얼굴 윤곽(faceOval, export_rig.py가 계산하는 36점 루프)을 바깥으로 18px 넓힌
|
||||
다각형의, 이 y 범위 안에서의 가로 범위.
|
||||
|
||||
가로 범위 측정: 얼굴 윤곽 다각형을 캔버스 크기 마스크로 채우고(cv2.fillPoly) 유클리드
|
||||
거리 변환으로 18px 확장한 뒤(build_lip_texture.py의 DILATE_PX와 같은 기법), y 범위로
|
||||
제한한 행들에서 열이 하나라도 켜진 범위를 x0~x1로 쓴다. (참고: 이 y 범위 안에서는
|
||||
다각형 경계가 거의 수직에 가까워, 단순히 "y 범위 안 원본 다각형의 x0/x1에 18을 더/
|
||||
빼는" 결과와 사실상 같다 — 두 방식을 대조해 확인했다.)
|
||||
변형·메우기는 하지 않는다. head·face-detail의 **원본 픽셀·알파를 그대로** 잘라
|
||||
layers/v2/jaw-{head,detail}.png로 저장한다.
|
||||
|
||||
실행: <venv>/python.exe build_jaw_pieces.py
|
||||
전제: layers_v2.py, face_detail.py가 먼저 layers/v2/{head,face-detail}.png를
|
||||
만들어 두어야 한다.
|
||||
|
||||
실행: <venv>/python.exe jaw_pieces.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
|
|
@ -39,26 +32,27 @@ from scipy import ndimage
|
|||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from export_rig import detect_face_landmarks, compute_face_oval # noqa: E402
|
||||
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
LAYERS_V2_DIR = ROOT / "layers" / "v2"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
NOSE_TO_TOP_PX = 50.0 + 10.0 # y0 = noseTip.y - (이 값)
|
||||
CHIN_TO_BOTTOM_PX = 40.0 # y1 = chinTip.y + (이 값)
|
||||
OVAL_OUTSET_PX = 18.0 # 얼굴 윤곽을 바깥으로 넓히는 폭
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
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"]
|
||||
nose_tip = tuple(lm["noseTip"])
|
||||
chin_tip = tuple(lm["chinTip"])
|
||||
print(f"noseTip={nose_tip} chinTip={chin_tip}")
|
||||
|
||||
points = detect_face_landmarks(BASE_DIR / "base-front.png")
|
||||
points = detect_face_landmarks(base_dir / "base-front.png")
|
||||
face_oval = compute_face_oval(points)
|
||||
|
||||
canvas_w, canvas_h = manifest["canvas"]["w"], manifest["canvas"]["h"]
|
||||
|
|
@ -96,18 +90,18 @@ def main() -> int:
|
|||
print(f"대조(단순 폭 확장) = [{naive_x0}, {naive_x1}) vs 거리변환 [{x0i}, {x1i}) "
|
||||
f"차이=({x0i-naive_x0},{x1i-naive_x1})")
|
||||
|
||||
head_full = np.array(Image.open(LAYERS_V2_DIR / "head.png").convert("RGBA"))
|
||||
detail_full = np.array(Image.open(LAYERS_V2_DIR / "face-detail.png").convert("RGBA"))
|
||||
head_full = np.array(Image.open(layers_v2_dir / "head.png").convert("RGBA"))
|
||||
detail_full = np.array(Image.open(layers_v2_dir / "face-detail.png").convert("RGBA"))
|
||||
if head_full.shape[:2] != (canvas_h, canvas_w) or detail_full.shape[:2] != (canvas_h, canvas_w):
|
||||
raise SystemExit(
|
||||
f"[중단] head/face-detail 크기가 캔버스와 다르다: head={head_full.shape[:2]} "
|
||||
f"detail={detail_full.shape[:2]} canvas=({canvas_h},{canvas_w})"
|
||||
)
|
||||
|
||||
pieces = {"head": (head_full, LAYERS_V2_DIR / "jaw-head.png"),
|
||||
"detail": (detail_full, LAYERS_V2_DIR / "jaw-detail.png")}
|
||||
pieces = {"head": (head_full, layers_v2_dir / "jaw-head.png"),
|
||||
"detail": (detail_full, layers_v2_dir / "jaw-detail.png")}
|
||||
|
||||
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
checks: dict = {}
|
||||
for key, (src, out_path) in pieces.items():
|
||||
crop = src[y0i:y1i, x0i:x1i].copy()
|
||||
|
|
@ -123,10 +117,10 @@ def main() -> int:
|
|||
"pngFileSize": out_path.stat().st_size,
|
||||
}
|
||||
|
||||
_save_evidence(pieces, x0i, y0i, x1i, y1i)
|
||||
_save_evidence(pieces, x0i, y0i, x1i, y1i, preview_v2_dir)
|
||||
|
||||
manifest["jaw"] = {
|
||||
"designVersion": "A5 (작업 패킷 A5, 결정문 §8.4 하관 띠 변형 — 턱 조각 원본)",
|
||||
"designVersion": "A5(결정문 §8.4 하관 띠 변형 — 턱 조각 원본)",
|
||||
"noseTip": list(nose_tip),
|
||||
"chinTip": list(chin_tip),
|
||||
"yFormula": "noseTip.y - 50 - 10 .. chinTip.y + 40",
|
||||
|
|
@ -139,12 +133,12 @@ def main() -> int:
|
|||
"checks": checks,
|
||||
"evidenceImage": "preview/v2/jaw-pieces.jpg",
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def _save_evidence(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int) -> None:
|
||||
def _save_evidence(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int, preview_v2_dir: Path) -> None:
|
||||
"""jaw-head | jaw-detail(체커 배경), 원본 배율."""
|
||||
panels = []
|
||||
for key in ("head", "detail"):
|
||||
|
|
@ -166,11 +160,11 @@ def _save_evidence(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int) -> None
|
|||
for p in panels:
|
||||
combined.paste(p, (x, 0))
|
||||
x += p.width + gap
|
||||
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_path = PREVIEW_V2_DIR / "jaw-pieces.jpg"
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = preview_v2_dir / "jaw-pieces.jpg"
|
||||
combined.save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
"""base-front.png에서 mediapipe FaceLandmarker로 얼굴 랜드마크를 검출해
|
||||
manifest.json의 landmarks 섹션을 채우고 preview/landmarks.png를 만든다.
|
||||
"""공통 리노컷 리그 — base-front.png 얼굴 랜드마크 검출.
|
||||
|
||||
mediapipe FaceLandmarker로 manifest.json의 landmarks 섹션을 채우고
|
||||
preview/landmarks.png를 만든다.
|
||||
|
||||
좌표계: 화면(이미지) 기준 left/right. "left"는 이미지의 왼쪽(작은 x), "right"는
|
||||
이미지의 오른쪽(큰 x)이다. 인물 해부학적 좌/우가 아니다.
|
||||
|
||||
실행: <venv>/python.exe detect_landmarks.py
|
||||
주의(재실행 순서): 이 스크립트는 manifest.landmarks를 통째로 다시 쓴다.
|
||||
brow_centerline.py가 그중 eyebrowLeft/Right를 더 정확한 중심선으로 보정하므로,
|
||||
landmarks.py를 brow_centerline.py보다 "나중에" 다시 돌리면 그 보정이 사라진다.
|
||||
단계를 하나만 다시 돌릴 때는 이 순서를 지켜야 한다(run_pipeline.py가 기본
|
||||
순서로는 지켜 주지만, --only로 landmarks만 돌리면 뒤이어 brow_centerline도
|
||||
다시 돌려야 한다).
|
||||
|
||||
실행: <venv>/python.exe landmarks.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mediapipe as mp
|
||||
|
|
@ -17,11 +27,11 @@ from mediapipe.tasks import python as mp_python
|
|||
from mediapipe.tasks.python import vision
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE_FRONT = ROOT / "base" / "base-front.png"
|
||||
MODEL_PATH = Path(__file__).resolve().parent / "_models" / "face_landmarker.task"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
PREVIEW_PATH = ROOT / "preview" / "landmarks.png"
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
MODEL_PATH = SCRIPTS_DIR / "_models" / "face_landmarker.task"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
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]
|
||||
|
|
@ -42,7 +52,7 @@ IRIS_A = {"center": 468, "ring": [469, 470, 471, 472]}
|
|||
IRIS_B = {"center": 473, "ring": [474, 475, 476, 477]}
|
||||
|
||||
|
||||
def detect() -> dict | None:
|
||||
def detect(base_front: Path, preview_path: Path) -> dict | None:
|
||||
if not MODEL_PATH.exists():
|
||||
return None
|
||||
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_PATH))
|
||||
|
|
@ -51,7 +61,7 @@ def detect() -> dict | None:
|
|||
running_mode=vision.RunningMode.IMAGE,
|
||||
num_faces=1,
|
||||
)
|
||||
im = Image.open(BASE_FRONT).convert("RGB")
|
||||
im = Image.open(base_front).convert("RGB")
|
||||
w, h = im.size
|
||||
arr = np.array(im)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
|
||||
|
|
@ -143,11 +153,11 @@ def detect() -> dict | None:
|
|||
"faceWidthAtEyeLevelRight": pt(faceEdgeR_idx),
|
||||
}
|
||||
|
||||
draw_preview(im, landmarks)
|
||||
draw_preview(im, landmarks, preview_path)
|
||||
return landmarks
|
||||
|
||||
|
||||
def draw_preview(im: Image.Image, landmarks: dict) -> None:
|
||||
def draw_preview(im: Image.Image, landmarks: dict, preview_path: Path) -> None:
|
||||
canvas = im.convert("RGB").copy()
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
try:
|
||||
|
|
@ -193,29 +203,35 @@ def draw_preview(im: Image.Image, landmarks: dict) -> None:
|
|||
group = label.split(".")[0]
|
||||
dot(xy, label, GROUP_COLORS.get(group, (255, 0, 0)))
|
||||
|
||||
canvas.save(PREVIEW_PATH)
|
||||
print(f"landmarks 미리보기 저장: {PREVIEW_PATH}")
|
||||
canvas.save(preview_path)
|
||||
print(f"landmarks 미리보기 저장: {preview_path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_front = cfg.base_dir / "base-front.png"
|
||||
manifest_path = cfg.manifest_path
|
||||
preview_path = cfg.preview_dir / "landmarks.png"
|
||||
cfg.preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = {}
|
||||
if MANIFEST_PATH.exists():
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
result = detect()
|
||||
result = detect(base_front, preview_path)
|
||||
if result is None:
|
||||
manifest["landmarks"] = None
|
||||
manifest["landmarkDetector"] = None
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print("[검출 실패] mediapipe FaceLandmarker가 얼굴을 찾지 못했다. manifest.landmarks=null로 기록.")
|
||||
return 1
|
||||
|
||||
manifest["landmarks"] = result
|
||||
manifest["landmarkDetector"] = f"mediapipe FaceLandmarker (tasks) {mp.__version__}, model=face_landmarker(float16, v1)"
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print("manifest.json landmarks 섹션 기록 완료")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""P1 서연 리노컷 v2 레이어 보정 — 2단계-B-1a (1)(2).
|
||||
"""공통 리노컷 리그 — v2 레이어 보정(2단계-B-1a (1)(2)).
|
||||
|
||||
기존 layers/{body,head,hairFront}.png(2단계-A 산출물)을 다시 만들지 않고,
|
||||
segmentation.py가 만든 layers/{body,head,hairFront}.png(v1)을 다시 만들지 않고,
|
||||
같은 분할·랜드마크 계산을 재실행해 다음 두 결함만 보정한 layers/v2/*.png를 만든다.
|
||||
|
||||
(1) body 턱 밑 밝은 띠: head_mask가 덮는 목 상단 영역(regen raw/body.png 채움)의
|
||||
|
|
@ -17,7 +17,15 @@
|
|||
또한 모든 레이어의 부분 알파 경계 픽셀 전반에 일반 unpremultiply(halo 제거)를
|
||||
적용해 배경색이 섞여 나오지 않게 한다.
|
||||
|
||||
실행: <venv>/python.exe build_layers_v2.py
|
||||
(2c) hairFront 턱선 오분류 조각 제거: 입을 크게 벌리면 head의 턱선과 별개로
|
||||
hairFront에도 같은 턱선 모양 잉크가 겹쳐 보이던 결함의 원인은 머리카락
|
||||
분할이 입 중심 아래 짙은 턱선 그림자를 머리카락으로 잘못 분류한 것이다.
|
||||
mediapipe FaceLandmarker의 입술 안쪽 중심(13/14) y 아래, faceOval 경계
|
||||
14px 이내에서 페더 간극(<=3px)으로 이어지는 성분을 하나로 묶어 폭/높이
|
||||
비율을 본다. 턱선을 따라 가로로 긴(비율>1) 성분만 지우고, 세로로 늘어진
|
||||
실제 머리카락 가닥(비율<=1)은 남긴다.
|
||||
|
||||
실행: <venv>/python.exe layers_v2.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -33,22 +41,13 @@ from scipy.ndimage import (
|
|||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from build_layers_segmented import ( # noqa: E402
|
||||
from segmentation import ( # noqa: E402
|
||||
build_padded_faceless, detect_face_landmarks, run_segmentation, refine_hair_edge,
|
||||
polygon_mask, feather_bool_mask, chroma_key, to_u8, alpha_bbox, green_residue_count,
|
||||
composite_over, rotate_rgba, translate_rgba,
|
||||
FACE_OVAL_LOOP, FACE_OVAL_SCALE, CHIN_MARGIN, FEATHER_PX, FILL_FEATHER_PX,
|
||||
FACE_OVAL_LOOP, FACE_OVAL_SCALE, CHIN_MARGIN, FEATHER_PX, FILL_FEATHER_PX, CREAM_BG,
|
||||
)
|
||||
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
RAW_DIR = ROOT / "raw"
|
||||
LAYERS_DIR = ROOT / "layers"
|
||||
LAYERS_V2_DIR = ROOT / "layers" / "v2"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
|
||||
CREAM_BG = np.array([0xEE, 0xE5, 0xD3], dtype=np.float64)
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
HAIR_EDGE_BAND_OUT_PX = 40
|
||||
HAIR_EDGE_BAND_IN_PX = 6
|
||||
|
|
@ -59,13 +58,6 @@ MIN_FRAGMENT_AREA = 60
|
|||
RNG_SEED = 20260930
|
||||
|
||||
# --- (2c) hairFront 턱선 오분류 조각 제거 -----------------------------------
|
||||
# 입을 크게 벌리면 head의 턱선과 별개로 hairFront에도 같은 턱선 모양 잉크가 겹쳐
|
||||
# 보이던 결함의 원인: 머리카락 분할이 입 중심 아래 짙은 턱선 그림자를 머리카락으로
|
||||
# 잘못 분류했고, 그 조각이 face_oval 안쪽이라 hairfront_mask에 들어갔다. mediapipe
|
||||
# FaceLandmarker의 입술 안쪽 중심(13/14) y 아래, faceOval 경계 14px 이내에서 페더
|
||||
# 간극(<=3px)으로 이어지는 성분을 하나로 묶어 폭/높이 비율을 본다. 턱선을 따라
|
||||
# 가로로 긴(비율>1) 성분만 지우고, 세로로 늘어진 실제 머리카락 가닥(비율<=1)은
|
||||
# 남긴다.
|
||||
MOUTH_CENTER_INNER_UPPER_IDX = 13
|
||||
MOUTH_CENTER_INNER_LOWER_IDX = 14
|
||||
JAW_INK_DIST_TO_OVAL_PX = 14.0
|
||||
|
|
@ -79,6 +71,12 @@ PAPER_LUM_THRESH = 195.0
|
|||
PAPER_SAT_THRESH = 40.0
|
||||
HAIR_PAPER_OPAQUE_ALPHA = 0.95
|
||||
|
||||
# 머리카락 경계·틈 보정에서 알파가 거의 0인 자리를 채우는 잉크 근접 폴백색.
|
||||
# export_rig.py 팔레트 ink 산출과 같은 "머리카락 어두운 덩어리" 개념이지만,
|
||||
# 여기서는 팔레트 표본(사람이 고른 상자)을 쓰지 않고 고정값을 쓴다 — 이 자리는
|
||||
# 알파가 3/255 미만이라 화면에 거의 안 보이고, 전부 unpremultiply가 지배한다.
|
||||
INK_FALLBACK_RGB = np.array([30.0, 31.0, 31.0]) # #1E1F1F
|
||||
|
||||
|
||||
def smoothstep(t: np.ndarray) -> np.ndarray:
|
||||
t = np.clip(t, 0.0, 1.0)
|
||||
|
|
@ -113,7 +111,7 @@ def recompute_hair_edge_band(
|
|||
out_alpha[band] = alpha_new_band[band]
|
||||
|
||||
a01 = out_alpha.astype(np.float64) / 255.0
|
||||
unpre = unpremultiply(rgb, a01, CREAM_BG)
|
||||
unpre = unpremultiply(rgb, a01, CREAM_BG_ARR)
|
||||
# 아주 어두운(순수 잉크에 가까운) 픽셀은 원본 유지, 나머지 band는 unpremultiply 결과 사용.
|
||||
out_rgb[band] = unpre[band]
|
||||
# ink 근접 폴백: 알파가 거의 0인 자리는 안 보이므로 잉크색으로 채워 안정화.
|
||||
|
|
@ -151,7 +149,7 @@ def remove_small_fragments(alpha: np.ndarray, min_area: int = MIN_FRAGMENT_AREA)
|
|||
|
||||
def is_paper_color(rgb: np.ndarray) -> np.ndarray:
|
||||
"""#EEE5D3과 RGB 거리 40 이내, 또는 명도>195이면서 채도(max-min)<40인 픽셀."""
|
||||
dist = np.sqrt(((rgb - CREAM_BG[None, None, :]) ** 2).sum(axis=2))
|
||||
dist = np.sqrt(((rgb - CREAM_BG_ARR[None, None, :]) ** 2).sum(axis=2))
|
||||
lum = rgb.mean(axis=2)
|
||||
sat = rgb.max(axis=2) - rgb.min(axis=2)
|
||||
return (dist <= PAPER_COLOR_DIST) | ((lum > PAPER_LUM_THRESH) & (sat < PAPER_SAT_THRESH))
|
||||
|
|
@ -174,7 +172,7 @@ def fix_hair_paper_gaps(rgb: np.ndarray, alpha: np.ndarray, region: np.ndarray)
|
|||
out_alpha[target] = alpha_new[target]
|
||||
|
||||
a01_new = out_alpha.astype(np.float64) / 255.0
|
||||
unpre = unpremultiply(rgb, a01_new, CREAM_BG)
|
||||
unpre = unpremultiply(rgb, a01_new, CREAM_BG_ARR)
|
||||
out_rgb = rgb.copy()
|
||||
out_rgb[target] = unpre[target]
|
||||
|
||||
|
|
@ -261,17 +259,19 @@ def general_halo_cleanup(
|
|||
edge = edge & ~exclude
|
||||
if not edge.any():
|
||||
return rgb, alpha
|
||||
unpre = unpremultiply(rgb, a01, CREAM_BG)
|
||||
unpre = unpremultiply(rgb, a01, CREAM_BG_ARR)
|
||||
out_rgb = rgb.copy()
|
||||
out_rgb[edge] = unpre[edge]
|
||||
dist = np.sqrt(((unpre - CREAM_BG[None, None, :]) ** 2).sum(axis=2))
|
||||
dist = np.sqrt(((unpre - CREAM_BG_ARR[None, None, :]) ** 2).sum(axis=2))
|
||||
spurious = edge & (a01 < 0.5) & (dist <= 30)
|
||||
out_alpha = alpha.copy()
|
||||
out_alpha[spurious] = 0
|
||||
return out_rgb, out_alpha
|
||||
|
||||
|
||||
def halo_metric(rgb: np.ndarray, alpha: np.ndarray, paper: np.ndarray = CREAM_BG) -> float:
|
||||
def halo_metric(rgb: np.ndarray, alpha: np.ndarray, paper: np.ndarray = None) -> float:
|
||||
if paper is None:
|
||||
paper = CREAM_BG_ARR
|
||||
a01 = alpha.astype(np.float64) / 255.0
|
||||
edge = (a01 > 0.05) & (a01 < 0.95)
|
||||
n_edge = int(edge.sum())
|
||||
|
|
@ -295,18 +295,29 @@ def bg_leak_metric(alpha: np.ndarray, category_mask: np.ndarray, mask: np.ndarra
|
|||
return float((alpha[bg_far].astype(np.float64) / 255.0).mean())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CREAM_BG_ARR = np.array(CREAM_BG, dtype=np.float64)
|
||||
|
||||
print("=== 기준 데이터 재계산(2단계-A와 동일 알고리즘) ===")
|
||||
f_img = build_padded_faceless()
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
raw_dir = cfg.raw_dir
|
||||
layers_dir = cfg.layers_dir
|
||||
layers_v2_dir = cfg.layers_v2_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("=== 기준 데이터 재계산(segmentation.py와 동일 알고리즘) ===")
|
||||
f_img = build_padded_faceless(base_dir)
|
||||
f_arr = np.array(f_img).astype(np.float64)
|
||||
h, w, _ = f_arr.shape
|
||||
|
||||
front_pts = detect_face_landmarks(BASE_DIR / "base-front.png")
|
||||
chin_y = front_pts[152][1]
|
||||
front_pts = detect_face_landmarks(base_dir / "base-front.png")
|
||||
chin_xy = front_pts[152]
|
||||
chin_y = chin_xy[1]
|
||||
face_oval_mask, face_oval_poly = polygon_mask(
|
||||
[front_pts[i] for i in FACE_OVAL_LOOP], w, h, scale=FACE_OVAL_SCALE
|
||||
)
|
||||
|
|
@ -317,7 +328,7 @@ def main() -> int:
|
|||
face_oval_dist_out = distance_transform_edt(~face_oval_mask)
|
||||
face_oval_boundary_dist = np.where(face_oval_mask, face_oval_dist_in, face_oval_dist_out)
|
||||
|
||||
category_mask = run_segmentation(BASE_DIR / "base-faceless-padded.png")
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
bg_mask = category_mask == 0
|
||||
hair_mask_raw = category_mask == 1
|
||||
body_skin_mask = category_mask == 2
|
||||
|
|
@ -337,7 +348,7 @@ def main() -> int:
|
|||
# (어깨 오른쪽 가장자리, 후드 왼쪽 밑단). 흰 티셔츠처럼 진짜 옷인데 밝은 영역과
|
||||
# 구분하기 위해, "배경(category0)과 실제로 이어져 있는" 밝은 덩어리만 배경으로
|
||||
# 되돌린다(흰 티셔츠는 어두운 후드에 둘러싸여 배경과 안 이어져 있어 보존된다).
|
||||
paper_dist = np.sqrt(((f_arr - CREAM_BG[None, None, :]) ** 2).sum(axis=2))
|
||||
paper_dist = np.sqrt(((f_arr - CREAM_BG_ARR[None, None, :]) ** 2).sum(axis=2))
|
||||
paperlike = paper_dist < 20
|
||||
seed_bg = category_mask == 0
|
||||
candidate = body_base_mask_raw & paperlike
|
||||
|
|
@ -347,7 +358,7 @@ def main() -> int:
|
|||
body_base_mask = body_base_mask_raw & ~reclassify_to_bg
|
||||
print(f"배경 오분류 보정: clothes/others -> background {int(reclassify_to_bg.sum())}px 재분류")
|
||||
|
||||
ink_rgb = np.array([30.0, 31.0, 31.0]) # #1E1F1F, export_rig.py 팔레트 ink와 동일 방식으로 산출된 값
|
||||
ink_rgb = INK_FALLBACK_RGB
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# (1) body 턱 밑 밝은 띠 보정
|
||||
|
|
@ -356,7 +367,7 @@ def main() -> int:
|
|||
body_base_alpha = feather_bool_mask(body_base_mask, FEATHER_PX)
|
||||
body_base_rgb = f_arr.copy()
|
||||
|
||||
raw_body = np.array(Image.open(RAW_DIR / "body.png").convert("RGB"))
|
||||
raw_body = np.array(Image.open(raw_dir / "body.png").convert("RGB"))
|
||||
regen_body_rgb, regen_body_alpha = chroma_key(raw_body)
|
||||
|
||||
fill_target = head_mask & (regen_body_alpha > 0)
|
||||
|
|
@ -489,7 +500,7 @@ def main() -> int:
|
|||
layers_report = []
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
out_path = LAYERS_V2_DIR / f"{layer_id}.png"
|
||||
out_path = layers_v2_dir / f"{layer_id}.png"
|
||||
Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(to_u8(alpha))
|
||||
opaque = int((to_u8(alpha) == 255).sum())
|
||||
|
|
@ -536,7 +547,7 @@ def main() -> int:
|
|||
# 미리보기: chin-band 전/후, hair-edge 전/후
|
||||
# ------------------------------------------------------------------
|
||||
def load_v1(name):
|
||||
return np.array(Image.open(LAYERS_DIR / f"{name}.png").convert("RGBA")).astype(np.float64)
|
||||
return np.array(Image.open(layers_dir / f"{name}.png").convert("RGBA")).astype(np.float64)
|
||||
|
||||
def composite(bg, layers):
|
||||
h2, w2, _ = layers[0][0].shape
|
||||
|
|
@ -557,14 +568,17 @@ def main() -> int:
|
|||
hf = layers_out["hairFront"] if use_v2_hf else (hf_v1[..., :3], hf_v1[..., 3])
|
||||
return [b, hh, hf]
|
||||
|
||||
comp_before = composite(CREAM_BG, pair_layers(False, False, False))
|
||||
comp_after = composite(CREAM_BG, pair_layers(True, True, True))
|
||||
comp_before = composite(CREAM_BG_ARR, pair_layers(False, False, False))
|
||||
comp_after = composite(CREAM_BG_ARR, pair_layers(True, True, True))
|
||||
comp_after_dark = composite(np.array([58.0, 58.0, 58.0]), pair_layers(True, True, True))
|
||||
comp_before_dark = composite(np.array([58.0, 58.0, 58.0]), pair_layers(False, False, False))
|
||||
|
||||
# chin-band는 정지 상태가 아니라 up14px + rotate-4deg(neck pivot)에서 목이 드러날 때
|
||||
# 비교해야 결함(과 보정)이 보인다 — 원래 결함도 이 모션에서만 보였다(motion-test.png).
|
||||
neck_pivot = (500.0, 990.0)
|
||||
# neck pivot은 턱끝(152) 좌표 바로 아래를 쓴다(이 미리보기 전용 모션 테스트는
|
||||
# 결과물 회귀 대상이 아니라 1px 단위 정밀도가 필요 없다 — 실제 렌더러 pivot은
|
||||
# persona.json의 pivots.neck이고 export_rig.py가 그 값을 그대로 rig에 싣는다).
|
||||
neck_pivot = (float(chin_xy[0]), float(chin_line) + 70.45)
|
||||
|
||||
def motion_composite(bg, body_layer, head_layer, hf_layer, rotate_deg, ty):
|
||||
b_rgb, b_a = body_layer
|
||||
|
|
@ -581,12 +595,17 @@ def main() -> int:
|
|||
body_before, head_before, hf_before = (body_v1[..., :3], body_v1[..., 3]), (head_v1[..., :3], head_v1[..., 3]), (hf_v1[..., :3], hf_v1[..., 3])
|
||||
body_after = layers_out["body"]; head_after = layers_out["head"]; hf_after = layers_out["hairFront"]
|
||||
|
||||
chin_before_up = motion_composite(CREAM_BG, body_before, head_before, hf_before, 0.0, -14.0)
|
||||
chin_after_up = motion_composite(CREAM_BG, body_after, head_after, hf_after, 0.0, -14.0)
|
||||
chin_before_rot = motion_composite(CREAM_BG, body_before, head_before, hf_before, -4.0, 0.0)
|
||||
chin_after_rot = motion_composite(CREAM_BG, body_after, head_after, hf_after, -4.0, 0.0)
|
||||
chin_before_up = motion_composite(CREAM_BG_ARR, body_before, head_before, hf_before, 0.0, -14.0)
|
||||
chin_after_up = motion_composite(CREAM_BG_ARR, body_after, head_after, hf_after, 0.0, -14.0)
|
||||
chin_before_rot = motion_composite(CREAM_BG_ARR, body_before, head_before, hf_before, -4.0, 0.0)
|
||||
chin_after_rot = motion_composite(CREAM_BG_ARR, body_after, head_after, hf_after, -4.0, 0.0)
|
||||
|
||||
box = (100, 750, 950, 1150)
|
||||
# 미리보기 크롭 상자는 머리 bbox(head_mask) 중심을 기준으로 둔다(캔버스 크기가
|
||||
# 달라져도 머리 근처를 보여준다 — 이 상자는 증거 이미지 전용이라 결과물 회귀
|
||||
# 대상이 아니다).
|
||||
hys, hxs = np.where(head_mask)
|
||||
hcx, hcy = int(hxs.mean()), int(hys.mean())
|
||||
box = (max(0, hcx - 425), max(0, hcy + 30), min(w, hcx + 425), min(h, hcy + 430))
|
||||
crops = [
|
||||
Image.fromarray(chin_before_up).crop(box), Image.fromarray(chin_after_up).crop(box),
|
||||
Image.fromarray(chin_before_rot).crop(box), Image.fromarray(chin_after_rot).crop(box),
|
||||
|
|
@ -596,21 +615,21 @@ def main() -> int:
|
|||
x = 0
|
||||
for im in crops:
|
||||
chin_strip.paste(im, (x, 0)); x += cw + 10
|
||||
chin_strip.save(PREVIEW_V2_DIR / "chin-band.png")
|
||||
chin_strip.save(preview_v2_dir / "chin-band.png")
|
||||
print("chin-band.png: [up14 전, up14 후, rotate-4 전, rotate-4 후]")
|
||||
|
||||
hair_crop_before = Image.fromarray(comp_before_dark).crop((100, 150, 400, 500))
|
||||
hair_crop_after = Image.fromarray(comp_after_dark).crop((100, 150, 400, 500))
|
||||
hair_crop_before_r = Image.fromarray(comp_before_dark).crop((680, 150, 980, 500))
|
||||
hair_crop_after_r = Image.fromarray(comp_after_dark).crop((680, 150, 980, 500))
|
||||
hair_crop_before_r = Image.fromarray(comp_before_dark).crop((max(0, w - 325), 150, w - 25, 500))
|
||||
hair_crop_after_r = Image.fromarray(comp_after_dark).crop((max(0, w - 325), 150, w - 25, 500))
|
||||
hw, hh_ = hair_crop_before.size
|
||||
hair_strip = Image.new("RGB", (hw * 4 + 30, hh_), (255, 255, 255))
|
||||
x = 0
|
||||
for im in (hair_crop_before, hair_crop_after, hair_crop_before_r, hair_crop_after_r):
|
||||
hair_strip.paste(im, (x, 0)); x += hw + 10
|
||||
hair_strip.save(PREVIEW_V2_DIR / "hair-edge.png")
|
||||
hair_strip.save(preview_v2_dir / "hair-edge.png")
|
||||
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"] = {
|
||||
"chinBandFix": {
|
||||
|
|
@ -641,10 +660,10 @@ def main() -> int:
|
|||
"metrics": metrics,
|
||||
"layers": layers_report,
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,46 +1,25 @@
|
|||
"""P1 서연 입술 결 스프라이트 생성 — 2단계-B-1d-A5(작업 패킷 A5, §8.4 2026-10-01 개정 반영).
|
||||
"""공통 리노컷 리그 — 입술 결 스프라이트 생성(결정문 §8.4, 작업 패킷 A5).
|
||||
|
||||
원칙(결정문 §8.4 개정): **중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면
|
||||
원화 픽셀이 변형된다.** 입술 모양은 스프라이트 알파가 정하고, 렌더러는 스프라이트를
|
||||
세로 띠로 변형만 한다. A2'(half 분할 + 6px 최근접 복제 확장)는 렌더러가 벡터 입술
|
||||
윤곽으로 결을 잘라내자 윗입술이 뾰족해지고 입꼬리가 바늘처럼 튀어나오고 아랫입술
|
||||
그늘이 성긴 빗금이 되는 문제를 낳았다 — 원인은 확장 영역이 좁고(6px) 입꼬리 홈·
|
||||
입 선 전체·아랫입술 그늘이 벡터 윤곽 안에 다 들어가지 못한 것.
|
||||
세로 띠로 변형만 한다.
|
||||
|
||||
A3 재설계:
|
||||
- 스프라이트 영역 R = 입술 색 마스크를 10px 넓힌 영역. 마스크 계산 자체(Lab a채널
|
||||
설계(A5):
|
||||
- 스프라이트 영역 R = 입술 색 마스크를 10px 넓힌 영역. 마스크 계산(Lab a채널
|
||||
Otsu AND front가 faceless보다 어두움, 캡 bbox 안에서 계산, 구멍 메움·작은 조각
|
||||
제거)는 그대로.
|
||||
제거).
|
||||
- R 안은 **원화 픽셀 그대로**다(복제·반사·메우기·블러 금지). 알파만 R 경계 안쪽
|
||||
4px에서 0→255 선형 램프, 그 안은 255(거리 변환으로 계산).
|
||||
- 중립 입 선 곡선 Cu0(mouthCornerLeft·mouthCenter·mouthCornerRight 2차 곡선)로
|
||||
R을 위/아래로 나눈다(윗 스프라이트 y<=Cu0(x), 아랫 스프라이트 y>Cu0(x)). 이
|
||||
분할은 R의 외곽 알파 램프와 무관하므로(램프는 R 전체 기준 거리 변환 한 번으로만
|
||||
계산하고 분할선은 그 값을 그대로 나눠 쓸 뿐) 나누는 경계에는 알파 램프가 없다 —
|
||||
두 장을 원래 자리에 도로 놓으면 램프 없이 딱 맞붙어 원화가 된다.
|
||||
Cu0의 x 범위(mouthCornerLeft.x~mouthCornerRight.x) 밖은 양끝 접선으로 연장한다.
|
||||
R을 위/아래로 나눈다. 윗 스프라이트는 분할선 아래로 2px 겹쳐 담아(A4) 이음매를
|
||||
없앤다.
|
||||
- 아랫입술 아래 그늘은 별도 조각(`lip-shadow`)으로 뗀다(A5): 아랫입술 마스크
|
||||
아래 경계에서 18px 띠, 아래·옆 6px 램프, 위는 아랫입술 스프라이트 아래로 6px
|
||||
겹친다.
|
||||
|
||||
A4 수정(2026-10-01, 렌더 비교 반려): 화면 배율로 재샘플링되면서 두 스프라이트가
|
||||
Cu0에서 딱 맞닿는 이음매가 가는 밝은 실금으로 보였다(경계 알파가 재샘플링으로
|
||||
반투명해져 밑 피부가 비침). 윗 스프라이트를 y<=Cu0(x)+2(분할선 아래 2px)까지
|
||||
겹쳐 담아 이음매를 없앤다 — 겹침 2px는 같은 원화 픽셀·같은 알파이고, 렌더러가
|
||||
아랫 스프라이트를 먼저 그리고 윗 스프라이트를 그 위에 덮어 그리므로(§8.4) 중립
|
||||
합성 결과는 바뀌지 않는다. 아랫 스프라이트·알파 램프 규칙은 그대로.
|
||||
전제: landmarks.py가 manifest.landmarks를 채워 두어야 한다.
|
||||
|
||||
A5 재설계(작업 패킷 A5, 2026-10-01): 아랫입술 스프라이트에 붙어 있던 아래 18px
|
||||
그늘 띠를 뺀다 — out5 판정(§8.4): 턱 변위 띠와 함께 옮기지 않으면 ㅗ에서 입이
|
||||
좁아질 때 그늘이 W/W0로 같이 압축돼 짙은 직사각형이 됐다. 턱 피부 그늘은 입이
|
||||
좁아져도 줄지 않아야 한다. 그늘은 별도 조각 `lip-shadow`로 뗀다:
|
||||
- 아랫입술 마스크(combined_work & d>0) 아래 경계에서 열마다 18px 띠, 원화 픽셀
|
||||
그대로(복제·메우기 없음, 없는 열은 생략).
|
||||
- 알파: 아래·옆(열이 끊기는 곳)은 6px 선형 램프. 위쪽은 마스크 경계보다 6px 위까지
|
||||
겹쳐 담고(아랫 스프라이트와 같은 원화 픽셀 원본이므로 겹침 알파 값과 무관하게
|
||||
두 장을 겹쳐 그려도 원화와 같다 — A4의 2px 분할선 겹침과 같은 원리), 그 6px
|
||||
안에서 4px 램프를 쓴다(겹침 나머지 2px는 이미 마스크 깊숙한 안쪽이라 풀 알파).
|
||||
- 중립에서 (F + 아랫 스프라이트 + 그늘 조각 + 윗 스프라이트)를 §8.4 그리는 순서
|
||||
그대로(아랫 → 그늘 → 윗) 합성하면 원화와 같아야 한다.
|
||||
|
||||
실행: <venv>/python.exe build_lip_texture.py
|
||||
실행: <venv>/python.exe lip_texture.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -56,12 +35,7 @@ from scipy import ndimage
|
|||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from export_rig import detect_face_landmarks, compute_mouth_center # noqa: E402
|
||||
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
LAYERS_V2_DIR = ROOT / "layers" / "v2"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
DIFF_THRESH = 20.0
|
||||
MIN_COMPONENT_AREA = 30.0
|
||||
|
|
@ -80,8 +54,6 @@ EVIDENCE_MAX_SIDE = 1400
|
|||
EVIDENCE_ZOOM = 3
|
||||
EVIDENCE_PAD = 8
|
||||
|
||||
LM: dict = {}
|
||||
|
||||
|
||||
def curve_fit(lc, mc, rc) -> np.poly1d:
|
||||
coef = np.polyfit([lc[0], mc[0], rc[0]], [lc[1], mc[1], rc[1]], 2)
|
||||
|
|
@ -109,17 +81,23 @@ def crop_to_alpha_bbox(alpha: np.ndarray, rgb: np.ndarray) -> tuple[np.ndarray,
|
|||
return out, (x0, y0, x1 - x0, y1 - y0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
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"]
|
||||
|
||||
global LM
|
||||
LM: dict = {}
|
||||
LM["Lc"] = tuple(lm["mouthCornerLeft"])
|
||||
LM["Rc"] = tuple(lm["mouthCornerRight"])
|
||||
LM["T"] = tuple(lm["upperLipTopCenter"])
|
||||
LM["B"] = tuple(lm["lowerLipBottomCenter"])
|
||||
|
||||
points = detect_face_landmarks(BASE_DIR / "base-front.png")
|
||||
points = detect_face_landmarks(base_dir / "base-front.png")
|
||||
mouth_center = compute_mouth_center(points)
|
||||
LM["Mc"] = mouth_center
|
||||
print(f"mouthCenter(재계산) = {mouth_center}")
|
||||
|
|
@ -142,15 +120,15 @@ def main() -> int:
|
|||
wx1 = icx1 + int(WORKING_MARGIN)
|
||||
wy1 = icy1 + int(WORKING_MARGIN)
|
||||
|
||||
front_full = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB"))
|
||||
faceless_full = np.array(Image.open(BASE_DIR / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
front_full = np.array(Image.open(base_dir / "base-front.png").convert("RGB"))
|
||||
faceless_full = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
front_work = front_full[wy0:wy1, wx0:wx1]
|
||||
faceless_work = faceless_full[wy0:wy1, wx0:wx1]
|
||||
front_lum_full = front_full.astype(np.float64).mean(axis=2)
|
||||
faceless_lum_full = faceless_full.mean(axis=2)
|
||||
diff_full = np.clip(faceless_lum_full - front_lum_full, 0.0, None)
|
||||
|
||||
# --- 입술 색+diff 마스크: 캡 bbox 안에서만 계산, 작업 캔버스에 배치(마스크 계산은 A2'와 동일) ---
|
||||
# --- 입술 색+diff 마스크: 캡 bbox 안에서만 계산, 작업 캔버스에 배치(마스크 계산은 기존과 동일) ---
|
||||
cap_rgb = front_full[icy0:icy1, icx0:icx1]
|
||||
lab = cv2.cvtColor(cap_rgb, cv2.COLOR_RGB2LAB)
|
||||
a_chan = lab[..., 1].astype(np.float64)
|
||||
|
|
@ -178,7 +156,7 @@ def main() -> int:
|
|||
yy_abs = rows_local + wy0
|
||||
d = yy_abs - cu0_y[None, :] # d<=0: 윗입술 쪽, d>0: 아랫입술 쪽
|
||||
|
||||
# --- R = 입술 색 마스크를 10px 넓힌 영역(윗/아랫 스프라이트 전용, A5부터 그늘 띠 제외) ---
|
||||
# --- R = 입술 색 마스크를 10px 넓힌 영역(윗/아랫 스프라이트 전용, 그늘 띠 제외) ---
|
||||
dist_out = ndimage.distance_transform_edt(~combined_work)
|
||||
region = combined_work | (dist_out <= DILATE_PX)
|
||||
print(f"R(확장, 윗/아랫 스프라이트) 픽셀 = {int(region.sum())}")
|
||||
|
|
@ -212,7 +190,7 @@ def main() -> int:
|
|||
f"겹침 알파 불일치 최대차={overlap_alpha_diff} 틈(불일치) 픽셀수={int(coverage_mismatch.sum())} "
|
||||
f"{'OK' if split_ok else '[실패]'}")
|
||||
|
||||
# --- 그늘 조각(lip-shadow, A5): 아랫입술 마스크(combined_work & d>0) 아래 경계에서
|
||||
# --- 그늘 조각(lip-shadow): 아랫입술 마스크(combined_work & d>0) 아래 경계에서
|
||||
# 열마다 18px 띠. 위쪽은 마스크 경계보다 6px 위까지 겹쳐 담고, 그 6px 안에서만 4px
|
||||
# 램프를 쓴다. 아래·옆(열이 끊기는 곳)은 6px 램프다. ---
|
||||
lower_lip_mask = combined_work & (d > 0)
|
||||
|
|
@ -251,14 +229,14 @@ def main() -> int:
|
|||
"shadow": {"alpha": alpha_shadow, "rgb": front_work, "mask": shadow_region},
|
||||
}
|
||||
|
||||
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
checks: dict = {}
|
||||
file_sizes_png = {}
|
||||
canvas_origin: dict[str, list[float]] = {}
|
||||
for half, key in (("upper", "lip-upper"), ("lower", "lip-lower"), ("shadow", "lip-shadow")):
|
||||
s = sprites[half]
|
||||
png_arr, bbox = crop_to_alpha_bbox(s["alpha"], s["rgb"])
|
||||
out_path = LAYERS_V2_DIR / f"{key}.png"
|
||||
out_path = layers_v2_dir / f"{key}.png"
|
||||
Image.fromarray(png_arr, "RGBA").save(out_path)
|
||||
file_sizes_png[key] = out_path.stat().st_size
|
||||
canvas_origin[half] = [round(wx0 + bbox[0], 1), round(wy0 + bbox[1], 1)]
|
||||
|
|
@ -302,16 +280,16 @@ def main() -> int:
|
|||
|
||||
cap_sl = (slice(icy0 - wy0, icy1 - wy0), slice(icx0 - wx0, icx1 - wx0))
|
||||
eff_alpha_cap = eff_alpha[cap_sl]
|
||||
diff_cap = np.abs(composite[cap_sl] - front_f64[cap_sl])
|
||||
diff_cap_arr = np.abs(composite[cap_sl] - front_f64[cap_sl])
|
||||
full_op_cap = eff_alpha_cap >= 254.999
|
||||
ramp_cap = (eff_alpha_cap > 0) & (eff_alpha_cap < 254.999)
|
||||
max_diff_full_op_cap = float(diff_cap[full_op_cap].max()) if full_op_cap.any() else 0.0
|
||||
mean_diff_ramp_cap = float(diff_cap[ramp_cap].mean()) if ramp_cap.any() else 0.0
|
||||
mean_diff_all_cap = float(diff_cap.mean())
|
||||
max_diff_full_op_cap = float(diff_cap_arr[full_op_cap].max()) if full_op_cap.any() else 0.0
|
||||
mean_diff_ramp_cap = float(diff_cap_arr[ramp_cap].mean()) if ramp_cap.any() else 0.0
|
||||
mean_diff_all_cap = float(diff_cap_arr.mean())
|
||||
print(f"검사(3) 중립 합성(아랫->그늘->윗) 대 base-front 대비 알파255 최대차={max_diff_full_op_cap:.3f} "
|
||||
f"램프 평균차={mean_diff_ramp_cap:.3f} 입bbox 전체 평균차={mean_diff_all_cap:.3f}")
|
||||
|
||||
# --- 검사(패킷 A5 목표 5-3) 그늘 조각과 아랫 스프라이트의 겹침 영역: (F+아랫+그늘) 합성이 원화와 같다 ---
|
||||
# --- 검사(그늘 조각과 아랫 스프라이트의 겹침 영역): (F+아랫+그늘) 합성이 원화와 같다 ---
|
||||
overlap_shadow_lower = (alpha_shadow > 0) & (alpha_lower > 0)
|
||||
diff_overlap_arr = np.abs(step_shadow - front_f64).max(axis=2)
|
||||
diff_overlap = diff_overlap_arr[overlap_shadow_lower]
|
||||
|
|
@ -325,10 +303,10 @@ def main() -> int:
|
|||
total_png = sum(file_sizes_png.values())
|
||||
print(f"PNG 파일 크기 합계(참고, WebP 아님) = {total_png} bytes")
|
||||
|
||||
_save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow)
|
||||
_save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow, preview_v2_dir)
|
||||
|
||||
manifest["lipTexture"] = {
|
||||
"designVersion": "A5 (2단계-B-1d-A5, 작업 패킷 A5 — 아랫입술 그늘 띠를 lip-shadow 조각으로 분리)",
|
||||
"designVersion": "A5(아랫입술 그늘 띠를 lip-shadow 조각으로 분리)",
|
||||
"landmarks": {"mouthCornerLeft": list(lc), "mouthCornerRight": list(rc),
|
||||
"upperLipTop": list(t), "lowerLipBottom": list(b), "mouthCenter": list(mc)},
|
||||
"capBBox": [round(cap_x0, 2), round(cap_y0, 2), round(cap_x1, 2), round(cap_y1, 2)],
|
||||
|
|
@ -353,16 +331,6 @@ def main() -> int:
|
|||
"shadowLowerOverlapMaxDiff": max_diff_overlap,
|
||||
"shadowLowerOverlapDiffGt1PixelCount": diff_overlap_gt1_count,
|
||||
"shadowLowerOverlapOk": shadow_lower_overlap_ok,
|
||||
"shadowLowerOverlapNote": (
|
||||
"국소 실패(입꼬리 첨점 부근, diff>1인 픽셀 수 참고). 원인: 두 입꼬리 근처에서 "
|
||||
"원화 입술 색 마스크가 하이라이트로 끊겨 윗/아랫 두 블롭으로 갈라지고, "
|
||||
"기존 Cu0 부호 분할(A3/A4부터의 로직, 이번 작업에서 변경하지 않음)이 그 "
|
||||
"다리 부분을 '윗'으로 분류해 alpha_lower=0이 되는 지점이 생긴다. 그 자리는 "
|
||||
"그늘 조각의 옆(열 끊김) 램프 구간과도 겹쳐 그늘 쪽 알파도 완전 불투명이 "
|
||||
"아니라서 등식이 깨진다. preview/v2/lip-texture.jpg의 원화|중립합성 패널을 "
|
||||
"3배 확대로 육안 확인한 결과 이음매는 보이지 않는다(오케스트레이터 육안 "
|
||||
"무해 판단, 2026-10-01)."
|
||||
),
|
||||
},
|
||||
"check3NeutralComposite": {
|
||||
"fullOpacityMaxDiff": round(max_diff_full_op_cap, 3),
|
||||
|
|
@ -373,12 +341,12 @@ def main() -> int:
|
|||
"canvasOrigin": canvas_origin,
|
||||
"evidenceImage": "preview/v2/lip-texture.jpg",
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow) -> None:
|
||||
def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow, preview_v2_dir: Path) -> None:
|
||||
"""원화 입 | 중립 합성 | 윗 스프라이트(체커) | 아랫 스프라이트(체커) | 그늘 조각(체커), 3배 확대.
|
||||
|
||||
bbox는 세 스프라이트 알파>0 영역의 합집합(+pad)에서 구한다 — 10px 확장·그늘
|
||||
|
|
@ -442,11 +410,11 @@ def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, compos
|
|||
scale = min(1.0, EVIDENCE_MAX_SIDE / max(combined.size))
|
||||
if scale < 1.0:
|
||||
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
|
||||
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_path = PREVIEW_V2_DIR / "lip-texture.jpg"
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = preview_v2_dir / "lip-texture.jpg"
|
||||
combined.save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""P1 서연 종이결 타일 생성 — 2단계-B-1a (4).
|
||||
"""공통 리노컷 리그 — 종이결 타일 생성.
|
||||
|
||||
base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서
|
||||
256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다.
|
||||
|
|
@ -6,11 +6,15 @@ np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤
|
|||
블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서
|
||||
서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다.
|
||||
|
||||
실행: <venv>/python.exe build_paper_grain.py
|
||||
base/base-front.png만 있으면 되고, 다른 단계와 순서 의존성이 없다(원하는
|
||||
때에 다시 돌려도 된다).
|
||||
|
||||
실행: <venv>/python.exe paper_grain.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -18,15 +22,9 @@ from PIL import Image
|
|||
from scipy.ndimage import distance_transform_edt, gaussian_filter, label
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
import sys # noqa: E402
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from build_layers_segmented import run_segmentation # noqa: E402
|
||||
|
||||
ROOT = SCRIPTS_DIR.parent
|
||||
BASE_DIR = ROOT / "base"
|
||||
LAYERS_V2_DIR = ROOT / "layers" / "v2"
|
||||
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
from segmentation import run_segmentation # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
TILE_SIZE = 256
|
||||
PERSON_MARGIN_PX = 20
|
||||
|
|
@ -64,11 +62,17 @@ def find_tile_origin(eligible: np.ndarray, size: int) -> tuple[int, int, float]:
|
|||
return y0, x0, frac
|
||||
|
||||
|
||||
def main() -> int:
|
||||
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
|
||||
front_path = BASE_DIR / "base-front.png"
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
front_path = base_dir / "base-front.png"
|
||||
front = np.array(Image.open(front_path).convert("RGB")).astype(np.float64)
|
||||
h, w, _ = front.shape
|
||||
|
||||
|
|
@ -76,10 +80,10 @@ def main() -> int:
|
|||
bg_mask = category_mask == 0
|
||||
|
||||
# 배경(category0) 자체가 종이결 텍스처 노이즈 때문에 분류기가 곳곳에 좁쌀만한
|
||||
# 오분류 반점을 흩뿌려 놓는다(연결요소가 15개로 쪼개짐). 그 반점 하나하나에서
|
||||
# 오분류 반점을 흩뿌려 놓는다(연결요소가 여러 개로 쪼개짐). 그 반점 하나하나에서
|
||||
# 20px씩 침식하면 실제 인물과 무관한 곳까지 배경 후보가 사라진다. 대신
|
||||
# "인물(가장 큰 전경 연결요소) 경계에서 20px 이상"만 걸러 낸다 — 반점은 실제
|
||||
# 얼굴 이 아니라 채도 낮은 종이 위의 분류 잡음이므로 그 자리 원본 픽셀도 그냥
|
||||
# 얼굴이 아니라 채도 낮은 종이 위의 분류 잡음이므로 그 자리 원본 픽셀도 그냥
|
||||
# 종이 질감이다.
|
||||
fg_mask = category_mask != 0
|
||||
labeled_fg, _ = label(fg_mask)
|
||||
|
|
@ -128,7 +132,7 @@ def main() -> int:
|
|||
tile = rolled * (1 - seam_w[..., None]) + blurred * seam_w[..., None]
|
||||
tile = np.clip(tile, 0, 255)
|
||||
|
||||
out_path = LAYERS_V2_DIR / "paper-grain.png"
|
||||
out_path = layers_v2_dir / "paper-grain.png"
|
||||
Image.fromarray(tile.round().astype(np.uint8), "RGB").save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
|
@ -150,10 +154,11 @@ def main() -> int:
|
|||
for j in range(3):
|
||||
for i in range(3):
|
||||
tiled.paste(tile_im, (i * TILE_SIZE, j * TILE_SIZE))
|
||||
tiled.save(PREVIEW_V2_DIR / "paper-grain-tiled.png")
|
||||
print(f"저장: {PREVIEW_V2_DIR / 'paper-grain-tiled.png'}")
|
||||
tiled.save(preview_v2_dir / "paper-grain-tiled.png")
|
||||
print(f"저장: {preview_v2_dir / 'paper-grain-tiled.png'}")
|
||||
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"]["paperGrain"] = {
|
||||
"sourceImage": "base/base-front.png",
|
||||
"sourceOrigin": [x0, y0],
|
||||
|
|
@ -168,10 +173,10 @@ def main() -> int:
|
|||
"finalLumStd": float(final_lum.std()),
|
||||
"seamContinuity": {"leftRightDiff": lr_diff, "topBottomDiff": tb_diff},
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
214
docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Normal file
214
docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""공통 리노컷 파이프라인의 페르소나별 설정 로더.
|
||||
|
||||
페르소나 폴더(예: docs/avatar-art/p1-linocut/)의 persona.json을 읽어
|
||||
PersonaConfig로 만든다. 파이프라인 스크립트들은 이 모듈을 통해서만 페르소나별
|
||||
차이(게시 경로·rig 내보내기 이름·팔레트 표본 상자·점 위치·피벗 등)를 얻는다.
|
||||
|
||||
persona.json에 없는 선택 필드는 모두 기본값(P1에서 쓰던 값과 같은 보수적 기본값)으로
|
||||
채운다 — 랜드마크로 유도할 수 없는 "원화 고유" 상수(점 위치, 팔레트 표본 상자,
|
||||
피벗 등)만 이 설정에 둔다. 랜드마크·분할로 계산 가능한 것은 각 단계 스크립트가
|
||||
그대로 계산한다(이 모듈이 대신하지 않는다).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# 모든 페르소나가 같은 종이·잉크 계열(리노컷 판화)이라 공유하는 기본값.
|
||||
# persona.json이 생략하면 이 값을 쓴다. 종이색(#EEE5D3)은 내부 합성·마스크 계산에
|
||||
# 쓰는 고정 상수라 각 단계 스크립트에 그대로 둔다(모든 페르소나가 같은 종이이고,
|
||||
# persona.json에서 조정할 축이 아니다 — 캐릭터별로 달라지는 "배경색"은 UI에 쓰이는
|
||||
# rig.backdrop이며 그건 persona.json의 backdrop 필드로 다룬다).
|
||||
DEFAULT_EYE_OVERRIDE = {"sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F"}
|
||||
DEFAULT_PALETTE_FIXED = {
|
||||
"mouthInner": "#3B201B",
|
||||
"teeth": "#E9E0CF",
|
||||
"blush": "#C0624A",
|
||||
"tear": "#EEE5D3",
|
||||
"pallor": "#9AA3A6",
|
||||
"paper": "#EEE5D3",
|
||||
}
|
||||
DEFAULT_BACKDROP = {
|
||||
"cognitive": "#ECE3D1",
|
||||
"positive": "#F1DEC2",
|
||||
"negative": "#DCE0E2",
|
||||
"defensive": "#E6DAD3",
|
||||
"energy": "#E2E0D0",
|
||||
}
|
||||
DEFAULT_BUST_Y_OFFSET = 40.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonaConfig:
|
||||
persona_dir: Path
|
||||
code: str
|
||||
public_slug: str
|
||||
rig_file_name: str
|
||||
rig_export_name: str
|
||||
# 아래 필드들은 원화마다 달라지고 랜드마크로 유도할 수 없는(참조 이미지를 보고
|
||||
# 사람이 고른) 값이라 persona.json에 없으면 None/빈 값으로 두고, 실제로 그
|
||||
# 값이 필요한 단계 스크립트가 쓰는 시점에 어떤 필드를 채워야 하는지 에러로
|
||||
# 알린다(로드 시점에 한꺼번에 요구하지 않는다 — 단계별로 독립 실행 가능해야 함).
|
||||
style_frame_path: Path | None
|
||||
palette_ink_box: list[float] | None
|
||||
palette_ink_lum_threshold: float | None
|
||||
eye_override: dict
|
||||
motif_petal_boxes: list[dict]
|
||||
motif_leaf_boxes: list[dict]
|
||||
moles: list[dict]
|
||||
pivots: dict | None
|
||||
bust_crop_y_offset: float
|
||||
palette_fixed: dict
|
||||
backdrop: dict
|
||||
# P1 전용 호환 장치(새 페르소나는 쓰지 않는다). face_detail.py가 눈썹 제외 영역·
|
||||
# eyeRegion y0을 계산할 때 쓰는 눈썹 좌표를 manifest.landmarks(중심선 보정본)
|
||||
# 대신 이 값으로 바꿔 쓴다. P1의 기존 face-detail.png·jaw-detail.png·
|
||||
# face-detail.webp·jaw-detail.webp·p1Rig.ts는 눈썹 중심선 보정(browCenterline)
|
||||
# 이전 좌표(manifest.browCenterline.oldPoints)로 빌드된 뒤 "다시 빌드하지
|
||||
# 않는다"는 오케스트레이터 지시로 고정됐다 — 그 시점 좌표를 재현해야 바이트
|
||||
# 단위 회귀가 성립한다. {"browLeft": {...}, "browRight": {...}} 형태(각
|
||||
# inner/peak/outer), manifest.landmarks.eyebrowLeft/Right와 같은 스키마.
|
||||
brow_landmarks_override: dict | None
|
||||
|
||||
def require_pivots(self) -> dict:
|
||||
if self.pivots is None:
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json에 pivots(neck/body/face)가 없다 — "
|
||||
"원화를 보고 목·몸통·얼굴 회전 중심점을 정해 채워야 한다."
|
||||
)
|
||||
return self.pivots
|
||||
|
||||
def require_ink_sample(self) -> tuple[list[float], float]:
|
||||
if self.palette_ink_box is None or self.palette_ink_lum_threshold is None:
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.ink(box/lumThreshold)가 "
|
||||
"없다 — base-front.png에서 머리카락 어두운 덩어리를 담는 상자를 정해야 한다."
|
||||
)
|
||||
return self.palette_ink_box, self.palette_ink_lum_threshold
|
||||
|
||||
def require_style_frame(self) -> Path:
|
||||
if self.style_frame_path is None or not self.style_frame_path.exists():
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json의 styleFrame이 없거나 파일이 없다 — "
|
||||
"모티프 팔레트(motifPetal/motifLeaf)를 뽑을 스타일 참조 이미지가 필요하다."
|
||||
)
|
||||
return self.style_frame_path
|
||||
|
||||
def require_motif_boxes(self) -> tuple[list[dict], list[dict]]:
|
||||
if not self.motif_petal_boxes or not self.motif_leaf_boxes:
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.motifPetalBoxes/"
|
||||
"motifLeafBoxes가 없다 — styleFrame에서 꽃잎(ochre)·잎/구름(blue) 표본 상자를 정해야 한다."
|
||||
)
|
||||
return self.motif_petal_boxes, self.motif_leaf_boxes
|
||||
|
||||
# --- 공통 경로(모든 단계 스크립트가 쓰는 폴더 레이아웃, 페르소나 폴더 안에서 고정) ---
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
return self.persona_dir
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self.persona_dir / "base"
|
||||
|
||||
@property
|
||||
def raw_dir(self) -> Path:
|
||||
return self.persona_dir / "raw"
|
||||
|
||||
@property
|
||||
def layers_dir(self) -> Path:
|
||||
return self.persona_dir / "layers"
|
||||
|
||||
@property
|
||||
def layers_v2_dir(self) -> Path:
|
||||
return self.persona_dir / "layers" / "v2"
|
||||
|
||||
@property
|
||||
def preview_dir(self) -> Path:
|
||||
return self.persona_dir / "preview"
|
||||
|
||||
@property
|
||||
def preview_v2_dir(self) -> Path:
|
||||
return self.persona_dir / "preview" / "v2"
|
||||
|
||||
@property
|
||||
def manifest_path(self) -> Path:
|
||||
return self.persona_dir / "manifest.json"
|
||||
|
||||
# --- 저장소 안 게시 경로(apps/web) ---
|
||||
@property
|
||||
def repo_root(self) -> Path:
|
||||
# docs/avatar-art/<persona>/ -> parents[0]=avatar-art, [1]=docs, [2]=저장소 루트
|
||||
return self.persona_dir.parents[2]
|
||||
|
||||
@property
|
||||
def public_dir(self) -> Path:
|
||||
return self.repo_root / "apps" / "web" / "public" / "avatar" / "v3" / self.public_slug
|
||||
|
||||
@property
|
||||
def public_href_prefix(self) -> str:
|
||||
return f"/avatar/v3/{self.public_slug}"
|
||||
|
||||
@property
|
||||
def rig_ts_path(self) -> Path:
|
||||
return (
|
||||
self.repo_root / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / self.rig_file_name
|
||||
)
|
||||
|
||||
|
||||
def load_persona_config(persona_dir: Path) -> PersonaConfig:
|
||||
persona_dir = Path(persona_dir).resolve()
|
||||
cfg_path = persona_dir / "persona.json"
|
||||
if not cfg_path.exists():
|
||||
raise SystemExit(f"[중단] 페르소나 설정이 없다: {cfg_path}")
|
||||
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
|
||||
code = data["code"]
|
||||
public_slug = data.get("publicSlug", code.lower())
|
||||
rig_file_name = data.get("rigFileName", f"{public_slug}Rig.ts")
|
||||
rig_export_name = data.get("rigExportName", f"{code}_LINOCUT_RIG")
|
||||
|
||||
style_frame = data.get("styleFrame")
|
||||
style_frame_path = (persona_dir / style_frame).resolve() if style_frame else None
|
||||
|
||||
palette_samples = data.get("paletteSamples", {})
|
||||
ink = palette_samples.get("ink")
|
||||
palette_ink_box = ink.get("box") if ink else None
|
||||
palette_ink_lum_threshold = ink.get("lumThreshold") if ink else None
|
||||
eye_override = palette_samples.get("eyeOverride", DEFAULT_EYE_OVERRIDE)
|
||||
motif_petal_boxes = palette_samples.get("motifPetalBoxes", [])
|
||||
motif_leaf_boxes = palette_samples.get("motifLeafBoxes", [])
|
||||
|
||||
face_detail = data.get("faceDetail", {})
|
||||
moles = face_detail.get("moles", [])
|
||||
brow_landmarks_override = face_detail.get("browLandmarksOverride")
|
||||
|
||||
pivots = data.get("pivots")
|
||||
crops = data.get("crops", {})
|
||||
bust_crop_y_offset = crops.get("bustYOffset", DEFAULT_BUST_Y_OFFSET)
|
||||
|
||||
palette_fixed = {**DEFAULT_PALETTE_FIXED, **data.get("paletteFixed", {})}
|
||||
backdrop = {**DEFAULT_BACKDROP, **data.get("backdrop", {})}
|
||||
|
||||
return PersonaConfig(
|
||||
persona_dir=persona_dir,
|
||||
code=code,
|
||||
public_slug=public_slug,
|
||||
rig_file_name=rig_file_name,
|
||||
rig_export_name=rig_export_name,
|
||||
style_frame_path=style_frame_path,
|
||||
palette_ink_box=list(palette_ink_box) if palette_ink_box is not None else None,
|
||||
palette_ink_lum_threshold=(
|
||||
float(palette_ink_lum_threshold) if palette_ink_lum_threshold is not None else None
|
||||
),
|
||||
eye_override=eye_override,
|
||||
motif_petal_boxes=motif_petal_boxes,
|
||||
motif_leaf_boxes=motif_leaf_boxes,
|
||||
moles=moles,
|
||||
pivots=pivots,
|
||||
bust_crop_y_offset=float(bust_crop_y_offset),
|
||||
palette_fixed=palette_fixed,
|
||||
backdrop=backdrop,
|
||||
brow_landmarks_override=brow_landmarks_override,
|
||||
)
|
||||
141
docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py
Normal file
141
docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""공통 리노컷 리그 파이프라인 — 단일 진입점.
|
||||
|
||||
페르소나 폴더 하나(예: docs/avatar-art/p1-linocut/, persona.json이 있어야 한다)에
|
||||
대해 1~8단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다
|
||||
(mediapipe FaceLandmarker+ImageSegmenter를 같은 프로세스에서 같이 쓰면 세그폴트가
|
||||
나는 것과 같은 이유로, 모든 단계를 서브프로세스로 분리해 두면 한 단계의 문제가
|
||||
다른 단계·전체 파이프라인을 오염시키지 않는다).
|
||||
|
||||
단계 의존 순서(실제 코드 의존성 기준 — 전달받은 작업 설명의 번호는 주제별
|
||||
묶음이라 실행 순서와 다르다. 특히 final_previews는 export_rig가 쓴
|
||||
export-rig-report.json을 읽으므로 반드시 export_rig *다음*이다):
|
||||
|
||||
1. landmarks — base-front.png 랜드마크 검출, manifest.landmarks 작성
|
||||
2. brow_centerline — manifest.landmarks.eyebrowLeft/Right를 중심선으로 보정
|
||||
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로 모션·눈/입 확대 미리보기
|
||||
|
||||
주의(재실행 순서): landmarks를 혼자 다시 돌리면 manifest.landmarks를 통째로
|
||||
새로 쓰므로, brow_centerline이 보정한 eyebrowLeft/Right가 사라진다. landmarks만
|
||||
다시 돌릴 때는 brow_centerline도 같이 다시 돌려야 한다(이 스크립트가 --only로
|
||||
한 단계만 돌릴 때는 호출하는 쪽이 책임진다 — 자동으로 뒤따라 돌리지 않는다).
|
||||
|
||||
실행:
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> # 전체 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail # 한 단계만
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture # 중간부터
|
||||
<venv>/python.exe run_pipeline.py --list # 단계 이름 목록
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
# (단계 이름, 스크립트 파일명) — 실행 순서 그대로.
|
||||
STAGES: list[tuple[str, str]] = [
|
||||
("landmarks", "landmarks.py"),
|
||||
("brow_centerline", "brow_centerline.py"),
|
||||
("segmentation", "segmentation.py"),
|
||||
("layers_v2", "layers_v2.py"),
|
||||
("face_detail", "face_detail.py"),
|
||||
("paper_grain", "paper_grain.py"),
|
||||
("lip_texture", "lip_texture.py"),
|
||||
("jaw_pieces", "jaw_pieces.py"),
|
||||
("export_rig", "export_rig.py"),
|
||||
("final_previews", "final_previews.py"),
|
||||
]
|
||||
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()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-u", str(script_path), str(persona_dir)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
output = proc.stdout + proc.stderr
|
||||
ok = proc.returncode == 0
|
||||
return ok, output, elapsed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="공통 리노컷 파이프라인 실행")
|
||||
parser.add_argument("persona_dir", nargs="?", help="페르소나 폴더(예: docs/avatar-art/p1-linocut)")
|
||||
parser.add_argument("--only", choices=STAGE_NAMES, help="이 단계만 실행한다")
|
||||
parser.add_argument("--from", dest="from_stage", choices=STAGE_NAMES, help="이 단계부터 끝까지 실행한다")
|
||||
parser.add_argument("--list", action="store_true", help="단계 이름 목록을 출력하고 끝낸다")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
for name in STAGE_NAMES:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
if not args.persona_dir:
|
||||
parser.error("persona_dir가 필요하다(--list가 아니면).")
|
||||
|
||||
persona_dir = Path(args.persona_dir).resolve()
|
||||
cfg = load_persona_config(persona_dir) # persona.json 검증 겸 조기 실패
|
||||
|
||||
if args.only:
|
||||
selected = [s for s in STAGES if s[0] == args.only]
|
||||
elif args.from_stage:
|
||||
start = STAGE_NAMES.index(args.from_stage)
|
||||
selected = STAGES[start:]
|
||||
else:
|
||||
selected = STAGES
|
||||
|
||||
print(f"=== 공통 리노컷 파이프라인: persona={cfg.code} dir={persona_dir} ===")
|
||||
print(f"실행할 단계: {[s[0] for s in selected]}")
|
||||
|
||||
run_log: list[dict] = []
|
||||
for name, script in selected:
|
||||
print(f"\n--- [{name}] {script} ---")
|
||||
ok, output, elapsed = run_stage(name, script, persona_dir)
|
||||
print(output.rstrip())
|
||||
print(f"--- [{name}] {'OK' if ok else '실패'} ({elapsed:.1f}s) ---")
|
||||
run_log.append({"stage": name, "ok": ok, "elapsedSec": round(elapsed, 2)})
|
||||
if not ok:
|
||||
print(f"\n[중단] 단계 {name}에서 실패했다. 이후 단계는 실행하지 않는다.")
|
||||
_record_run_log(cfg.manifest_path, cfg.code, run_log, completed=False)
|
||||
return 1
|
||||
|
||||
_record_run_log(cfg.manifest_path, cfg.code, run_log, completed=True)
|
||||
print(f"\n=== 전체 완료: {len(selected)}개 단계 ===")
|
||||
return 0
|
||||
|
||||
|
||||
def _record_run_log(manifest_path: Path, persona_code: str, run_log: list[dict], completed: bool) -> None:
|
||||
"""단계별 실행 기록(성공 여부·소요 시간)을 manifest에 남긴다. 각 단계 자체의
|
||||
검사 수치(halo%, 평균절대차 등)는 이미 각 단계 스크립트가 manifest의 해당
|
||||
섹션(layersV2, lipTexture, jaw, ...)에 기록하므로 여기서 중복하지 않는다."""
|
||||
if not manifest_path.exists():
|
||||
return
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["pipelineRun"] = {
|
||||
"persona": persona_code,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"completed": completed,
|
||||
"stages": run_log,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
"""P1 서연 리노컷 리그 — 분할 기반 재작업 (2단계-A 반려 후 설계).
|
||||
"""공통 리노컷 리그 — 분할 기반 덩어리 레이어(body/head/hairFront) v1 빌드.
|
||||
|
||||
레이어 픽셀은 항상 base-faceless.png(패딩판 F)에서 가져온다. mediapipe
|
||||
ImageSegmenter(selfie_multiclass_256x256)로 F를 분할해 head/hairFront/body
|
||||
마스크를 만들고, body는 head 마스크가 덮는 영역 중 기존에 수용된 재생성
|
||||
raw/body.png(크로마키 결과, offset 0,0 확인됨)의 알파>0 부분만 가려진 영역
|
||||
채움으로 사용한다. imagegen 추가 호출 없음.
|
||||
마스크를 만들고, body는 head 마스크가 덮는 영역 중 raw/body.png(크로마키 결과)의
|
||||
알파>0 부분만 가려진 영역 채움으로 쓴다(결정문 §8.2).
|
||||
|
||||
실행: <venv>/python.exe build_layers_segmented.py
|
||||
layers_v2.py가 이 모듈의 결과(layers/*.png, manifest 기준 섹션)를 보정 입력으로
|
||||
쓰므로 파이프라인에서 가장 먼저 실행해야 한다.
|
||||
|
||||
실행: <venv>/python.exe segmentation.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -18,21 +20,20 @@ from pathlib import Path
|
|||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy.ndimage import binary_dilation, distance_transform_edt, gaussian_filter
|
||||
from scipy.ndimage import binary_dilation, binary_erosion, distance_transform_edt, gaussian_filter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE_DIR = ROOT / "base"
|
||||
RAW_DIR = ROOT / "raw"
|
||||
LAYERS_DIR = ROOT / "layers"
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
MODEL_SEG = SCRIPTS_DIR / "_models" / "selfie_multiclass_256x256.tflite"
|
||||
MODEL_FACE = SCRIPTS_DIR / "_models" / "face_landmarker.task"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import PersonaConfig, load_persona_config # noqa: E402
|
||||
|
||||
# 모든 페르소나가 같은 리노컷 판화 종이(결정문 §8.1)라 persona.json 축이 아니다.
|
||||
CREAM_BG = (0xEE, 0xE5, 0xD3)
|
||||
|
||||
MEDIAPIPE_VERSION = "1.0.1" # pip show mediapipe로 확인(런타임 import 생략 — 동일 프로세스 세그폴트 회피)
|
||||
|
||||
CREAM_BG = (0xEE, 0xE5, 0xD3)
|
||||
FEATHER_PX = 1.0 # 레이어 자체 경계 페더
|
||||
FILL_FEATHER_PX = 4.0 # body 채움 이음매 페더
|
||||
HAIR_EDGE_DILATE_PX = 6
|
||||
|
|
@ -41,6 +42,14 @@ CHIN_MARGIN = 8
|
|||
FACE_OVAL_SCALE = 1.04
|
||||
GREEN_RESIDUE_MARGIN = 30
|
||||
|
||||
# 크로마키(HSV 기반) 튜닝값. #00ff00 배경 기준(AGENTS.md §4.2 알파 정제 임계치 포함).
|
||||
ALPHA_LO, ALPHA_HI = 35, 205
|
||||
CHROMA_FEATHER_SIGMA = 0.6 # ~1px 페더
|
||||
HUE_TARGET_DEG = 120.0
|
||||
HUE_WINDOW_DEG = 40.0
|
||||
SAT_LO, SAT_HI = 0.15, 0.5
|
||||
VAL_LO, VAL_HI = 0.15, 0.5
|
||||
|
||||
# mediapipe FaceMesh FACE_OVAL 연결(468 캐노니컬 토폴로지)을 순서대로 이은 폐곡선.
|
||||
FACE_OVAL_LOOP = [
|
||||
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379,
|
||||
|
|
@ -48,23 +57,72 @@ FACE_OVAL_LOOP = [
|
|||
162, 21, 54, 103, 67, 109,
|
||||
]
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from build_layers import chroma_key # noqa: E402 (기존 크로마키 함수 재사용)
|
||||
|
||||
# --- 크로마키(기존 build_layers.py 재사용) ----------------------------------
|
||||
|
||||
|
||||
def build_padded_faceless() -> Image.Image:
|
||||
bf = Image.open(BASE_DIR / "base-faceless.png").convert("RGB")
|
||||
def rgb_to_hsv_np(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
|
||||
maxc = np.max(rgb, axis=-1)
|
||||
minc = np.min(rgb, axis=-1)
|
||||
v = maxc
|
||||
delta = maxc - minc
|
||||
s = np.where(maxc > 0, delta / np.where(maxc == 0, 1, maxc), 0.0)
|
||||
safe_delta = np.where(delta == 0, 1, delta)
|
||||
rc = (maxc - r) / safe_delta
|
||||
gc = (maxc - g) / safe_delta
|
||||
bc = (maxc - b) / safe_delta
|
||||
h = np.zeros_like(maxc)
|
||||
h = np.where(maxc == r, (bc - gc), h)
|
||||
h = np.where(maxc == g, 2.0 + rc - bc, h)
|
||||
h = np.where(maxc == b, 4.0 + gc - rc, h)
|
||||
h = (h / 6.0) % 1.0
|
||||
h = np.where(delta == 0, 0.0, h)
|
||||
return h, s, v
|
||||
|
||||
|
||||
def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""RGB(0-255) 배열을 받아 (despill된 RGB uint8, 정제된 알파 uint8)를 반환."""
|
||||
rgb = rgb_u8.astype(np.float64) / 255.0
|
||||
h, s, v = rgb_to_hsv_np(rgb)
|
||||
hue_deg = h * 360.0
|
||||
hue_dist = np.abs(hue_deg - HUE_TARGET_DEG)
|
||||
hue_dist = np.minimum(hue_dist, 360.0 - hue_dist)
|
||||
hue_component = np.clip(1.0 - hue_dist / HUE_WINDOW_DEG, 0.0, 1.0)
|
||||
sat_component = np.clip((s - SAT_LO) / (SAT_HI - SAT_LO), 0.0, 1.0)
|
||||
val_component = np.clip((v - VAL_LO) / (VAL_HI - VAL_LO), 0.0, 1.0)
|
||||
green_score = hue_component * sat_component * val_component
|
||||
|
||||
alpha_raw = (1.0 - green_score) * 255.0
|
||||
alpha_refined = np.clip((alpha_raw - ALPHA_LO) * 255.0 / (ALPHA_HI - ALPHA_LO), 0, 255)
|
||||
alpha_feathered = gaussian_filter(alpha_refined, sigma=CHROMA_FEATHER_SIGMA)
|
||||
alpha_feathered = np.clip(alpha_feathered, 0, 255)
|
||||
|
||||
r = rgb_u8[..., 0].astype(np.float64)
|
||||
g = rgb_u8[..., 1].astype(np.float64)
|
||||
b = rgb_u8[..., 2].astype(np.float64)
|
||||
g_despill = np.minimum(g, np.maximum(r, b))
|
||||
despilled_rgb = np.stack([r, g_despill, b], axis=-1)
|
||||
|
||||
return despilled_rgb.astype(np.uint8), alpha_feathered.astype(np.uint8)
|
||||
|
||||
|
||||
# --- 공통 유틸 ---------------------------------------------------------------
|
||||
|
||||
|
||||
def build_padded_faceless(base_dir: Path) -> Image.Image:
|
||||
bf = Image.open(base_dir / "base-faceless.png").convert("RGB")
|
||||
arr = np.array(bf)
|
||||
padded = np.concatenate([arr, arr[-1:, :, :]], axis=0)
|
||||
out = Image.fromarray(padded, "RGB")
|
||||
out.save(BASE_DIR / "base-faceless-padded.png")
|
||||
out.save(base_dir / "base-faceless-padded.png")
|
||||
return out
|
||||
|
||||
|
||||
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
|
||||
"""FaceLandmarker를 별도 프로세스로 실행한다.
|
||||
같은 프로세스에서 ImageSegmenter와 함께 호출하면 세그폴트(exit 139)가
|
||||
재현확인됐다(scripts/_run_face_landmarks.py, _run_segmentation.py 분리 사유)."""
|
||||
재현확인됐다(_run_face_landmarks.py, _run_segmentation.py 분리 사유)."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out_json = Path(td) / "landmarks.json"
|
||||
proc = subprocess.run(
|
||||
|
|
@ -163,16 +221,24 @@ def translate_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: float, dy: float) ->
|
|||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
LAYERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
raw_dir = cfg.raw_dir
|
||||
layers_dir = cfg.layers_dir
|
||||
preview_dir = cfg.preview_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
cream_bg = CREAM_BG
|
||||
|
||||
f_img = build_padded_faceless()
|
||||
layers_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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}")
|
||||
|
||||
front_pts = detect_face_landmarks(BASE_DIR / "base-front.png")
|
||||
front_pts = detect_face_landmarks(base_dir / "base-front.png")
|
||||
chin_y = front_pts[152][1]
|
||||
chin_xy = front_pts[152]
|
||||
face_oval_mask, face_oval_poly = polygon_mask(
|
||||
|
|
@ -180,7 +246,7 @@ def main() -> int:
|
|||
)
|
||||
print(f"턱끝(152) 좌표: {chin_xy}, chinY+{CHIN_MARGIN}={chin_y + CHIN_MARGIN:.1f}")
|
||||
|
||||
category_mask = run_segmentation(BASE_DIR / "base-faceless-padded.png")
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
cat_counts = {int(k): int(v) for k, v in zip(*np.unique(category_mask, return_counts=True))}
|
||||
print(f"분할 카테고리 픽셀 수(F 전체 {w*h}): {cat_counts}")
|
||||
|
||||
|
|
@ -217,7 +283,7 @@ def main() -> int:
|
|||
body_base_alpha = feather_bool_mask(body_base_mask, FEATHER_PX)
|
||||
body_base_rgb = f_arr.copy()
|
||||
|
||||
raw_body = np.array(Image.open(RAW_DIR / "body.png").convert("RGB"))
|
||||
raw_body = np.array(Image.open(raw_dir / "body.png").convert("RGB"))
|
||||
if raw_body.shape[:2] != (h, w):
|
||||
raise SystemExit(f"[중단] raw/body.png 크기 {raw_body.shape[:2][::-1]}가 캔버스 {w}x{h}와 다르다.")
|
||||
regen_body_rgb, regen_body_alpha = chroma_key(raw_body)
|
||||
|
|
@ -251,7 +317,7 @@ def main() -> int:
|
|||
layers_report = []
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
out_path = LAYERS_DIR / f"{layer_id}.png"
|
||||
out_path = layers_dir / f"{layer_id}.png"
|
||||
Image.fromarray(np.dstack([rgb, alpha]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(alpha)
|
||||
opaque = int((alpha == 255).sum())
|
||||
|
|
@ -272,15 +338,15 @@ def main() -> int:
|
|||
|
||||
# --- 정지 합성 vs F ---
|
||||
canvas_rgba = np.zeros((h, w, 4), dtype=np.float64)
|
||||
canvas_rgba[..., 0] = CREAM_BG[0]
|
||||
canvas_rgba[..., 1] = CREAM_BG[1]
|
||||
canvas_rgba[..., 2] = CREAM_BG[2]
|
||||
canvas_rgba[..., 0] = cream_bg[0]
|
||||
canvas_rgba[..., 1] = cream_bg[1]
|
||||
canvas_rgba[..., 2] = cream_bg[2]
|
||||
canvas_rgba[..., 3] = 255.0
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
canvas_rgba = composite_over(canvas_rgba, rgb, alpha)
|
||||
composite = to_u8(canvas_rgba)
|
||||
Image.fromarray(composite, "RGBA").save(PREVIEW_DIR / "composite-faceless.png")
|
||||
Image.fromarray(composite, "RGBA").save(preview_dir / "composite-faceless.png")
|
||||
|
||||
composite_rgb = composite[..., :3].astype(np.float64)
|
||||
f_rgb = f_arr.astype(np.float64)
|
||||
|
|
@ -288,7 +354,6 @@ def main() -> int:
|
|||
mean_abs_full = float(full_diff.mean())
|
||||
mean_abs_face = float(full_diff[head_mask].mean()) if head_mask.any() else None
|
||||
|
||||
from scipy.ndimage import binary_erosion
|
||||
dil = binary_dilation(hair_mask, iterations=6)
|
||||
ero = binary_erosion(hair_mask, iterations=6)
|
||||
hair_outline_band = dil & ~ero
|
||||
|
|
@ -307,10 +372,10 @@ def main() -> int:
|
|||
f"전경 평균절대차={fg_diff:.3f}"
|
||||
)
|
||||
|
||||
side = Image.new("RGB", (w * 2 + 20, h), CREAM_BG)
|
||||
side = Image.new("RGB", (w * 2 + 20, h), cream_bg)
|
||||
side.paste(Image.fromarray(f_arr), (0, 0))
|
||||
side.paste(Image.fromarray(composite[..., :3]), (w + 20, 0))
|
||||
side.save(PREVIEW_DIR / "compare.png")
|
||||
side.save(preview_dir / "compare.png")
|
||||
|
||||
# --- masks.png ---
|
||||
colors = {0: (0, 0, 0), 1: (255, 0, 0), 2: (0, 255, 0), 3: (0, 120, 255), 4: (255, 255, 0), 5: (255, 0, 255)}
|
||||
|
|
@ -321,7 +386,7 @@ def main() -> int:
|
|||
masks_img = Image.fromarray(blend)
|
||||
d = ImageDraw.Draw(masks_img)
|
||||
d.polygon(face_oval_poly, outline=(255, 255, 255), width=3)
|
||||
masks_img.save(PREVIEW_DIR / "masks.png")
|
||||
masks_img.save(preview_dir / "masks.png")
|
||||
|
||||
# --- motion-test.png ---
|
||||
pivot = chin_xy
|
||||
|
|
@ -346,9 +411,9 @@ def main() -> int:
|
|||
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
|
||||
|
||||
frame = np.zeros((h, w, 4), dtype=np.float64)
|
||||
frame[..., 0] = CREAM_BG[0]
|
||||
frame[..., 1] = CREAM_BG[1]
|
||||
frame[..., 2] = CREAM_BG[2]
|
||||
frame[..., 0] = cream_bg[0]
|
||||
frame[..., 1] = cream_bg[1]
|
||||
frame[..., 2] = cream_bg[2]
|
||||
frame[..., 3] = 255.0
|
||||
frame = composite_over(frame, body_rgb0, body_a0)
|
||||
frame = composite_over(frame, h_rgb, h_a)
|
||||
|
|
@ -356,29 +421,29 @@ def main() -> int:
|
|||
frames.append((name, Image.fromarray(to_u8(frame), "RGBA").convert("RGB")))
|
||||
|
||||
gap = 12
|
||||
strip = Image.new("RGB", (w * 4 + gap * 3, h), CREAM_BG)
|
||||
strip = Image.new("RGB", (w * 4 + gap * 3, h), cream_bg)
|
||||
x = 0
|
||||
for name, fr in frames:
|
||||
strip.paste(fr, (x, 0))
|
||||
d2 = ImageDraw.Draw(strip)
|
||||
d2.text((x + 10, 10), name, fill=(255, 0, 0))
|
||||
x += w + gap
|
||||
strip.save(PREVIEW_DIR / "motion-test.png")
|
||||
strip.save(preview_dir / "motion-test.png")
|
||||
|
||||
# --- manifest 갱신 ---
|
||||
manifest = {}
|
||||
if MANIFEST_PATH.exists():
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
manifest["schemaVersion"] = "vignette.avatar.v3.layers.v1"
|
||||
manifest["persona"] = "P1"
|
||||
manifest["persona"] = cfg.code
|
||||
manifest["canvas"] = {"w": w, "h": h}
|
||||
manifest["base"] = {
|
||||
"front": "base/base-front.png",
|
||||
"faceless": "base/base-faceless.png",
|
||||
"facelessPadded": "base/base-faceless-padded.png",
|
||||
"facelessSize": [1005, 1565],
|
||||
"padNote": "base-faceless.png 마지막 행을 복제해 1005x1566(base-front.png 크기)으로 패딩한 것이 F다.",
|
||||
"facelessSize": [w, h - 1],
|
||||
"padNote": f"base-faceless.png 마지막 행을 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.",
|
||||
}
|
||||
manifest["segmenter"] = {
|
||||
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",
|
||||
|
|
@ -389,11 +454,9 @@ def main() -> int:
|
|||
manifest["chinLine"] = {"landmarkIndex": 152, "xy": [round(chin_xy[0], 2), round(chin_xy[1], 2)], "marginPx": CHIN_MARGIN, "cutY": round(chin_line, 2)}
|
||||
manifest["faceOval"] = {"landmarkLoop": FACE_OVAL_LOOP, "scale": FACE_OVAL_SCALE, "sourceImage": "base/base-front.png"}
|
||||
manifest["layers"] = layers_report
|
||||
manifest["rejectedRawEdits"] = {
|
||||
"head": {"file": "raw/head.png", "status": "rejected", "reason": "얼굴 폭·턱선·귀·머리숱이 base-faceless와 달라짐(2단계-A 1차 반려 사유)"},
|
||||
"hairFront": {"file": "raw/hair-front.png", "status": "rejected", "reason": "노란 하이라이트 획 등 기준에 없던 색상 아티팩트, 형태 변형(2단계-A 1차 반려 사유)"},
|
||||
manifest["rejectedRawEdits"] = manifest.get("rejectedRawEdits", {
|
||||
"body": {"file": "raw/body.png", "status": "accepted-as-fill-source", "reason": "정렬 (0,0), 형태 변형 없음 — head_mask 채움 전용 소스로 재사용"},
|
||||
}
|
||||
})
|
||||
manifest["composite"] = {
|
||||
"meanAbsDiff": {"full": mean_abs_full, "face": mean_abs_face, "hairOutline": mean_abs_hair_outline},
|
||||
"target": {"full": 3.0, "pass": bool(mean_abs_full < 3.0)},
|
||||
|
|
@ -402,10 +465,10 @@ def main() -> int:
|
|||
manifest["landmarkDetector"] = manifest.get(
|
||||
"landmarkDetector", f"mediapipe FaceLandmarker (tasks) {MEDIAPIPE_VERSION}, model=face_landmarker(float16, v1)"
|
||||
)
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 저장: {MANIFEST_PATH}")
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 저장: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -1,21 +1,36 @@
|
|||
# P1 서연 리노컷 아바타 자산
|
||||
|
||||
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/avatar-expression-engine-v3.md) §8)의 P1 원본·중간 산출물·파이프라인이다.
|
||||
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/avatar-expression-engine-v3.md) §8)의 P1 원본·중간 산출물이다.
|
||||
|
||||
파이프라인 스크립트는 이 폴더가 아니라 모든 페르소나가 공유하는
|
||||
[`../linocut-pipeline/`](../linocut-pipeline/README.md)에 있다. 이 폴더에는 P1 고유의 원화·설정만 둔다.
|
||||
|
||||
- `persona.json`: P1 고유 설정(게시 경로, rig 내보내기 이름, 점 위치, 팔레트 표본 상자, 피벗 등).
|
||||
스키마는 [`../linocut-pipeline/README.md`](../linocut-pipeline/README.md#personajson-스키마) 참고.
|
||||
- `base/`: 정면 원화(`base-front.png`)와 얼굴 없는 기본형(`base-faceless*.png`). 모든 레이어 픽셀의 원천이다.
|
||||
- `raw/`: 참조 편집 생성 원본(가려진 영역 채움용).
|
||||
- `layers/v2/`: 분할된 레이어·입술·턱 조각 PNG. `scripts/`가 만든다.
|
||||
- `motif/`: 봉오리·날씨 스프라이트 원본과 프롬프트.
|
||||
- `layers/v2/`: 분할된 레이어·입술·턱 조각 PNG. 공통 파이프라인이 만든다.
|
||||
- `motif/`: 봉오리·날씨 스프라이트 원본과 프롬프트(이 폴더 전용 — 공통 파이프라인 범위 밖).
|
||||
- `reference/`: 소유자 기준 이미지(비탄 강도 1).
|
||||
- `preview/`: 검사 증거. JPG만 저장소에 둔다. PNG 진단 그림은 다시 만들 수 있어 무시한다.
|
||||
- `manifest.json`: 랜드마크·검사 수치·게시 기록.
|
||||
- `scripts/`: 파이프라인. `export_rig.py`가 `apps/web/public/avatar/v3/p1/`에 게시하고 `apps/web/src/components/avatar/v3/rigs/p1Rig.ts`를 만든다.
|
||||
|
||||
## 파이프라인 재실행
|
||||
|
||||
```
|
||||
<venv>/python.exe ../linocut-pipeline/scripts/run_pipeline.py .
|
||||
```
|
||||
|
||||
저장소 루트에서는:
|
||||
|
||||
```
|
||||
<venv>/python.exe docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py docs/avatar-art/p1-linocut
|
||||
```
|
||||
|
||||
`export_rig.py`가 `apps/web/public/avatar/v3/p1/`에 게시하고
|
||||
`apps/web/src/components/avatar/v3/rigs/p1Rig.ts`를 만든다.
|
||||
|
||||
## 모델 파일(저장소에 없음)
|
||||
|
||||
`scripts/_models/`에 MediaPipe 모델을 받아 둔다.
|
||||
|
||||
- `face_landmarker.task`: https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
|
||||
- `selfie_multiclass_256x256.tflite`: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite
|
||||
|
||||
python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
|
||||
모델은 이 폴더가 아니라 `../linocut-pipeline/scripts/_models/`에 받아 둔다(모든 페르소나가 공유).
|
||||
자세한 내용은 [`../linocut-pipeline/README.md`](../linocut-pipeline/README.md) 참고.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
46
docs/avatar-art/p1-linocut/persona.json
Normal file
46
docs/avatar-art/p1-linocut/persona.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"code": "P1",
|
||||
"publicSlug": "p1",
|
||||
"rigFileName": "p1Rig.ts",
|
||||
"rigExportName": "P1_LINOCUT_RIG",
|
||||
"styleFrame": "../art-direction-v3/p1/r2-b-linocut.png",
|
||||
"paletteSamples": {
|
||||
"ink": {
|
||||
"box": [280, 100, 720, 350],
|
||||
"lumThreshold": 55
|
||||
},
|
||||
"eyeOverride": {
|
||||
"sclera": "#D8CEBD",
|
||||
"iris": "#4F3B2C",
|
||||
"irisRing": "#1E1F1F"
|
||||
},
|
||||
"motifPetalBoxes": [
|
||||
{ "label": "sun", "kind": "ochre", "box": [1230, 10, 1536, 210] },
|
||||
{ "label": "tulip", "kind": "ochre", "box": [1030, 280, 1170, 560] }
|
||||
],
|
||||
"motifLeafBoxes": [
|
||||
{ "label": "cloudLeft", "kind": "blue", "box": [20, 20, 380, 190] },
|
||||
{ "label": "raincloud", "kind": "blue", "box": [520, 10, 930, 230] },
|
||||
{ "label": "wiltedFlowerLeaf", "kind": "blue", "box": [520, 280, 650, 580] },
|
||||
{ "label": "closedBudLeaf", "kind": "blue", "box": [10, 290, 110, 570] }
|
||||
]
|
||||
},
|
||||
"faceDetail": {
|
||||
"moles": [
|
||||
{ "center": [661.3, 627.9], "radius": 20.0 }
|
||||
],
|
||||
"browLandmarksOverride": {
|
||||
"_note": "P1 전용 호환 장치(linocut-pipeline/README.md 참고) — 눈썹 중심선 보정(browCenterline) 이전 좌표로 고정 빌드된 기존 face-detail.png/jaw-detail.png/해당 webp/p1Rig.ts와 바이트 단위로 같게 재현하기 위한 값이다. manifest.json의 browCenterline.oldPoints와 같다. 새 페르소나는 이 필드를 쓰지 않는다.",
|
||||
"browLeft": { "inner": [441.16, 535.91], "peak": [345.46, 497.87], "outer": [298.95, 524.32] },
|
||||
"browRight": { "inner": [531.24, 535.37], "peak": [628.82, 496.05], "outer": [687.99, 522.25] }
|
||||
}
|
||||
},
|
||||
"pivots": {
|
||||
"neck": [500, 990],
|
||||
"body": [502, 1566],
|
||||
"face": [490, 660]
|
||||
},
|
||||
"crops": {
|
||||
"bustYOffset": 40
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
}
|
||||
},
|
||||
"scleraIrisIrisRingFixedOverride": {
|
||||
"reason": "고정값(오케스트레이터 결정), 측정 참고값: sclera 중앙값 #A5917A, 홍채 중간 링 #3E3028",
|
||||
"reason": "persona.json paletteSamples.eyeOverride(고정 설계값)",
|
||||
"measured": {
|
||||
"sclera": "#C1966F",
|
||||
"iris": "#382D26",
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
720,
|
||||
350
|
||||
],
|
||||
"lumThreshold": 55,
|
||||
"lumThreshold": 55.0,
|
||||
"n": 80222,
|
||||
"hex": "#1E1F1F"
|
||||
},
|
||||
|
|
@ -105,70 +105,74 @@
|
|||
"hex": "#292421"
|
||||
},
|
||||
"motifPetal": {
|
||||
"sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png",
|
||||
"sun": {
|
||||
"box": [
|
||||
1230,
|
||||
10,
|
||||
1536,
|
||||
210
|
||||
],
|
||||
"n": 20570,
|
||||
"hex": "#D3A661"
|
||||
},
|
||||
"tulip": {
|
||||
"box": [
|
||||
1030,
|
||||
280,
|
||||
1170,
|
||||
560
|
||||
],
|
||||
"n": 4314,
|
||||
"hex": "#CCA063"
|
||||
"sourceImage": "docs\\avatar-art\\art-direction-v3\\p1\\r2-b-linocut.png",
|
||||
"samples": {
|
||||
"sun": {
|
||||
"box": [
|
||||
1230,
|
||||
10,
|
||||
1536,
|
||||
210
|
||||
],
|
||||
"n": 20570,
|
||||
"hex": "#D3A661"
|
||||
},
|
||||
"tulip": {
|
||||
"box": [
|
||||
1030,
|
||||
280,
|
||||
1170,
|
||||
560
|
||||
],
|
||||
"n": 4314,
|
||||
"hex": "#CCA063"
|
||||
}
|
||||
},
|
||||
"hex": "#D0A362"
|
||||
},
|
||||
"motifLeaf": {
|
||||
"sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png",
|
||||
"cloudLeft": {
|
||||
"box": [
|
||||
20,
|
||||
20,
|
||||
380,
|
||||
190
|
||||
],
|
||||
"n": 21312,
|
||||
"hex": "#636D76"
|
||||
},
|
||||
"raincloud": {
|
||||
"box": [
|
||||
520,
|
||||
10,
|
||||
930,
|
||||
230
|
||||
],
|
||||
"n": 27607,
|
||||
"hex": "#586B76"
|
||||
},
|
||||
"wiltedFlowerLeaf": {
|
||||
"box": [
|
||||
520,
|
||||
280,
|
||||
650,
|
||||
580
|
||||
],
|
||||
"n": 5818,
|
||||
"hex": "#495961"
|
||||
},
|
||||
"closedBudLeaf": {
|
||||
"box": [
|
||||
10,
|
||||
290,
|
||||
110,
|
||||
570
|
||||
],
|
||||
"n": 6589,
|
||||
"hex": "#495961"
|
||||
"sourceImage": "docs\\avatar-art\\art-direction-v3\\p1\\r2-b-linocut.png",
|
||||
"samples": {
|
||||
"cloudLeft": {
|
||||
"box": [
|
||||
20,
|
||||
20,
|
||||
380,
|
||||
190
|
||||
],
|
||||
"n": 21312,
|
||||
"hex": "#636D76"
|
||||
},
|
||||
"raincloud": {
|
||||
"box": [
|
||||
520,
|
||||
10,
|
||||
930,
|
||||
230
|
||||
],
|
||||
"n": 27607,
|
||||
"hex": "#586B76"
|
||||
},
|
||||
"wiltedFlowerLeaf": {
|
||||
"box": [
|
||||
520,
|
||||
280,
|
||||
650,
|
||||
580
|
||||
],
|
||||
"n": 5818,
|
||||
"hex": "#495961"
|
||||
},
|
||||
"closedBudLeaf": {
|
||||
"box": [
|
||||
10,
|
||||
290,
|
||||
110,
|
||||
570
|
||||
],
|
||||
"n": 6589,
|
||||
"hex": "#495961"
|
||||
}
|
||||
},
|
||||
"hex": "#53626C"
|
||||
}
|
||||
|
|
@ -223,8 +227,7 @@
|
|||
"maxAbsDiffPremultipliedRgb": 40.0,
|
||||
"meanAbsDiffAlpha": 0.0,
|
||||
"maxAbsDiffAlpha": 0.0,
|
||||
"meetsTarget": false,
|
||||
"orchestratorAcceptanceNote": "평균차 3.13(목표 2.0 초과) 수용(오케스트레이터, 2026-10-01). 근거: 최대차가 난 자리(왼쪽 위 머리카락)는 띠 clip(얼굴 윤곽 18px 바깥 다각형) 밖이라 렌더러가 그리지 않는다. 띠가 켜지는 동안에도 윗경계(y_n) 부근은 변위 f(y)가 0에 가까워 이 조각의 위쪽 여백이 눈에 띄게 움직이지 않는다. quality를 올려도 이미 게시된 head.webp 자체의 압축 오차만큼은 남아 이득이 없다(비교 대상 자체가 손실 압축본)."
|
||||
"meetsTarget": false
|
||||
},
|
||||
"detail": {
|
||||
"comparedAgainstPublishedLayer": "face-detail.webp",
|
||||
|
|
@ -395,10 +398,10 @@
|
|||
1566
|
||||
],
|
||||
"bust": [
|
||||
0,
|
||||
40,
|
||||
1005,
|
||||
1005
|
||||
0.0,
|
||||
40.0,
|
||||
1005.0,
|
||||
1005.0
|
||||
],
|
||||
"face": [
|
||||
204.65089959816135,
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 751 KiB |
3
docs/avatar-art/p1-linocut/prompts/base-faceless.txt
Normal file
3
docs/avatar-art/p1-linocut/prompts/base-faceless.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Call the built-in image_gen tool immediately, using the attached image as the reference to edit. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as b2-faceless.png:
|
||||
|
||||
Redraw exactly the same girl, same frontal pose, same framing and same linocut relief-print style as the reference, but leave the face blank: remove the eyes, eyebrows and mouth completely and fill those areas with plain skin in the same flat skin color, keeping the nose, the face outline, the ears, the hair, the neck and the hoodie exactly as they are. Plain flat cream background, nothing else, no text.
|
||||
3
docs/avatar-art/p1-linocut/prompts/base-front.txt
Normal file
3
docs/avatar-art/p1-linocut/prompts/base-front.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Call the built-in image_gen tool immediately, using the attached image as the reference to edit. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as b1-front-neutral.png:
|
||||
|
||||
Redraw the same girl from the reference in exactly the same linocut relief-print style, same face, same hair, same charcoal hoodie and white t-shirt, same ink colors and paper. Change only the pose: head and body facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level. Head, neck and shoulders only, hands out of frame. The figure is centered on a plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
"""P1 서연 리노컷 리그 덩어리 레이어(body/head/hairFront) 빌드 스크립트.
|
||||
|
||||
raw/*.png (초록 배경 위 codex exec 생성본) -> 크로마키 -> 기준 이미지 위상상관 정렬
|
||||
-> layers/*.png(투명 PNG) + preview/*.png + manifest.json(layers 섹션).
|
||||
|
||||
실행: <venv>/python.exe build_layers.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE_DIR = ROOT / "base"
|
||||
RAW_DIR = ROOT / "raw"
|
||||
LAYERS_DIR = ROOT / "layers"
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
MANIFEST_PATH = ROOT / "manifest.json"
|
||||
|
||||
CREAM_BG = (0xEE, 0xE5, 0xD3)
|
||||
|
||||
# AGENTS.md §4.2 알파 정제 임계치
|
||||
ALPHA_LO, ALPHA_HI = 35, 205
|
||||
FEATHER_SIGMA = 0.6 # ~1px 페더
|
||||
|
||||
# 크로마키(HSV 기반) 튜닝값. #00ff00 배경 기준.
|
||||
HUE_TARGET_DEG = 120.0
|
||||
HUE_WINDOW_DEG = 40.0
|
||||
SAT_LO, SAT_HI = 0.15, 0.5
|
||||
VAL_LO, VAL_HI = 0.15, 0.5
|
||||
|
||||
GREEN_RESIDUE_MARGIN = 30 # G > R+margin && G > B+margin
|
||||
|
||||
|
||||
def rgb_to_hsv_np(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
|
||||
maxc = np.max(rgb, axis=-1)
|
||||
minc = np.min(rgb, axis=-1)
|
||||
v = maxc
|
||||
delta = maxc - minc
|
||||
s = np.where(maxc > 0, delta / np.where(maxc == 0, 1, maxc), 0.0)
|
||||
safe_delta = np.where(delta == 0, 1, delta)
|
||||
rc = (maxc - r) / safe_delta
|
||||
gc = (maxc - g) / safe_delta
|
||||
bc = (maxc - b) / safe_delta
|
||||
h = np.zeros_like(maxc)
|
||||
h = np.where(maxc == r, (bc - gc), h)
|
||||
h = np.where(maxc == g, 2.0 + rc - bc, h)
|
||||
h = np.where(maxc == b, 4.0 + gc - rc, h)
|
||||
h = (h / 6.0) % 1.0
|
||||
h = np.where(delta == 0, 0.0, h)
|
||||
return h, s, v
|
||||
|
||||
|
||||
def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""RGB(0-255) 배열을 받아 (despill된 RGB uint8, 정제된 알파 uint8)를 반환."""
|
||||
rgb = rgb_u8.astype(np.float64) / 255.0
|
||||
h, s, v = rgb_to_hsv_np(rgb)
|
||||
hue_deg = h * 360.0
|
||||
hue_dist = np.abs(hue_deg - HUE_TARGET_DEG)
|
||||
hue_dist = np.minimum(hue_dist, 360.0 - hue_dist)
|
||||
hue_component = np.clip(1.0 - hue_dist / HUE_WINDOW_DEG, 0.0, 1.0)
|
||||
sat_component = np.clip((s - SAT_LO) / (SAT_HI - SAT_LO), 0.0, 1.0)
|
||||
val_component = np.clip((v - VAL_LO) / (VAL_HI - VAL_LO), 0.0, 1.0)
|
||||
green_score = hue_component * sat_component * val_component
|
||||
|
||||
alpha_raw = (1.0 - green_score) * 255.0
|
||||
alpha_refined = np.clip((alpha_raw - ALPHA_LO) * 255.0 / (ALPHA_HI - ALPHA_LO), 0, 255)
|
||||
alpha_feathered = gaussian_filter(alpha_refined, sigma=FEATHER_SIGMA)
|
||||
alpha_feathered = np.clip(alpha_feathered, 0, 255)
|
||||
|
||||
r = rgb_u8[..., 0].astype(np.float64)
|
||||
g = rgb_u8[..., 1].astype(np.float64)
|
||||
b = rgb_u8[..., 2].astype(np.float64)
|
||||
g_despill = np.minimum(g, np.maximum(r, b))
|
||||
despilled_rgb = np.stack([r, g_despill, b], axis=-1)
|
||||
|
||||
return despilled_rgb.astype(np.uint8), alpha_feathered.astype(np.uint8)
|
||||
|
||||
|
||||
def load_and_normalize(path: Path, canvas_size: tuple[int, int]) -> tuple[np.ndarray, dict]:
|
||||
im = Image.open(path).convert("RGB")
|
||||
src_w, src_h = im.size
|
||||
tgt_w, tgt_h = canvas_size
|
||||
report = {"srcSize": [src_w, src_h], "targetSize": [tgt_w, tgt_h], "resized": False}
|
||||
if (src_w, src_h) != (tgt_w, tgt_h):
|
||||
src_ratio = src_w / src_h
|
||||
tgt_ratio = tgt_w / tgt_h
|
||||
ratio_diff_pct = abs(src_ratio - tgt_ratio) / tgt_ratio * 100.0
|
||||
report["srcRatio"] = src_ratio
|
||||
report["targetRatio"] = tgt_ratio
|
||||
report["ratioDiffPct"] = ratio_diff_pct
|
||||
if ratio_diff_pct > 1.0:
|
||||
raise SystemExit(
|
||||
f"[중단] {path.name}: 종횡비 차이 {ratio_diff_pct:.3f}% > 1% "
|
||||
f"(src={src_w}x{src_h}, target={tgt_w}x{tgt_h}) — 보고 후 정지."
|
||||
)
|
||||
im = im.resize((tgt_w, tgt_h), Image.LANCZOS)
|
||||
report["resized"] = True
|
||||
return np.array(im), report
|
||||
|
||||
|
||||
def phase_correlate(mask_a: np.ndarray, mask_b: np.ndarray) -> tuple[int, int]:
|
||||
"""mask_a를 mask_b에 맞추기 위한 정수 (dx, dy) 오프셋을 반환한다.
|
||||
mask_a를 (dy行, dx열)만큼 이동시키면 mask_b와 정렬된다."""
|
||||
a = mask_a.astype(np.float64)
|
||||
b = mask_b.astype(np.float64)
|
||||
fa = np.fft.fft2(a)
|
||||
fb = np.fft.fft2(b)
|
||||
cross = fa * np.conj(fb)
|
||||
denom = np.abs(cross)
|
||||
denom[denom == 0] = 1e-12
|
||||
r = np.fft.ifft2(cross / denom)
|
||||
r = np.abs(r)
|
||||
peak = np.unravel_index(np.argmax(r), r.shape)
|
||||
dy, dx = peak
|
||||
h, w = a.shape
|
||||
if dy > h // 2:
|
||||
dy -= h
|
||||
if dx > w // 2:
|
||||
dx -= w
|
||||
# 교차 위상 스펙트럼 peak는 -d(이동량)에서 나타난다(이산 이동 정리) — 부호 반전해 반환.
|
||||
return int(-dx), int(-dy)
|
||||
|
||||
|
||||
def shift_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
h, w = alpha.shape
|
||||
out_rgb = np.zeros_like(rgb)
|
||||
out_alpha = np.zeros_like(alpha)
|
||||
|
||||
src_x0, src_x1 = max(0, -dx), min(w, w - dx)
|
||||
src_y0, src_y1 = max(0, -dy), min(h, h - dy)
|
||||
dst_x0, dst_x1 = max(0, dx), min(w, w + dx)
|
||||
dst_y0, dst_y1 = max(0, dy), min(h, h + dy)
|
||||
|
||||
out_rgb[dst_y0:dst_y1, dst_x0:dst_x1] = rgb[src_y0:src_y1, src_x0:src_x1]
|
||||
out_alpha[dst_y0:dst_y1, dst_x0:dst_x1] = alpha[src_y0:src_y1, src_x0:src_x1]
|
||||
return out_rgb, out_alpha
|
||||
|
||||
|
||||
def pad_to_canvas(mask: np.ndarray, canvas_w: int, canvas_h: int) -> np.ndarray:
|
||||
src_h, src_w = mask.shape
|
||||
if (src_w, src_h) == (canvas_w, canvas_h):
|
||||
return mask
|
||||
out = np.zeros((canvas_h, canvas_w), dtype=mask.dtype)
|
||||
h = min(src_h, canvas_h)
|
||||
w = min(src_w, canvas_w)
|
||||
out[:h, :w] = mask[:h, :w]
|
||||
return out
|
||||
|
||||
|
||||
def build_body_ref_mask(base_front_rgb: np.ndarray) -> np.ndarray:
|
||||
h, w, _ = base_front_rgb.shape
|
||||
lum = base_front_rgb.astype(np.float64).mean(axis=2)
|
||||
y0 = int(0.62 * h)
|
||||
mask = np.zeros((h, w), dtype=bool)
|
||||
mask[y0:, :] = lum[y0:, :] < 90
|
||||
return mask
|
||||
|
||||
|
||||
def build_head_ref_mask(base_faceless_rgb: np.ndarray) -> np.ndarray:
|
||||
h, w, _ = base_faceless_rgb.shape
|
||||
bg = np.array([233.0, 226.0, 207.0])
|
||||
diff = np.sqrt(((base_faceless_rgb.astype(np.float64) - bg) ** 2).sum(axis=2))
|
||||
y1 = int(0.735 * h)
|
||||
mask = np.zeros((h, w), dtype=bool)
|
||||
mask[:y1, :] = diff[:y1, :] > 25
|
||||
return mask
|
||||
|
||||
|
||||
def build_hair_front_ref_mask(base_front_rgb: np.ndarray) -> np.ndarray:
|
||||
h, w, _ = base_front_rgb.shape
|
||||
lum = base_front_rgb.astype(np.float64).mean(axis=2)
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
cx, cy = w * 0.5, h * 0.365
|
||||
rx, ry = w * 0.30, h * 0.34
|
||||
oval = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2 <= 1.0
|
||||
dark = lum < 90
|
||||
return oval & dark
|
||||
|
||||
|
||||
def alpha_bbox(alpha: np.ndarray, threshold: int = 1) -> list[int] | None:
|
||||
ys, xs = np.where(alpha >= threshold)
|
||||
if len(xs) == 0:
|
||||
return None
|
||||
return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
|
||||
|
||||
|
||||
def green_residue_count(rgb: np.ndarray, alpha: np.ndarray) -> int:
|
||||
r = rgb[..., 0].astype(np.int32)
|
||||
g = rgb[..., 1].astype(np.int32)
|
||||
b = rgb[..., 2].astype(np.int32)
|
||||
mask = (alpha > 0) & (g > r + GREEN_RESIDUE_MARGIN) & (g > b + GREEN_RESIDUE_MARGIN)
|
||||
return int(mask.sum())
|
||||
|
||||
|
||||
def composite_over(base_rgba: np.ndarray, layer_rgb: np.ndarray, layer_alpha: np.ndarray) -> np.ndarray:
|
||||
out = base_rgba.astype(np.float64).copy()
|
||||
a = (layer_alpha.astype(np.float64) / 255.0)[..., None]
|
||||
out[..., :3] = layer_rgb.astype(np.float64) * a + out[..., :3] * (1 - a)
|
||||
out[..., 3] = 255.0 * a[..., 0] + out[..., 3] * (1 - a[..., 0])
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
LAYERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base_front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB"))
|
||||
base_faceless = np.array(Image.open(BASE_DIR / "base-faceless.png").convert("RGB"))
|
||||
canvas_h, canvas_w = base_front.shape[0], base_front.shape[1]
|
||||
canvas_size = (canvas_w, canvas_h)
|
||||
print(f"기준 캔버스: {canvas_w}x{canvas_h}")
|
||||
|
||||
ref_masks = {
|
||||
"body": build_body_ref_mask(base_front),
|
||||
"head": pad_to_canvas(build_head_ref_mask(base_faceless), canvas_w, canvas_h),
|
||||
"hairFront": build_hair_front_ref_mask(base_front),
|
||||
}
|
||||
for k, m in ref_masks.items():
|
||||
Image.fromarray((m * 255).astype(np.uint8)).save(PREVIEW_DIR / f"refmask-{k}.png")
|
||||
|
||||
layer_specs = [
|
||||
("body", "body.png"),
|
||||
("head", "head.png"),
|
||||
("hairFront", "hair-front.png"),
|
||||
]
|
||||
|
||||
layers_report = []
|
||||
results = {}
|
||||
|
||||
for layer_id, filename in layer_specs:
|
||||
raw_path = RAW_DIR / filename
|
||||
rgb, norm_report = load_and_normalize(raw_path, canvas_size)
|
||||
despilled_rgb, alpha = chroma_key(rgb)
|
||||
|
||||
layer_mask = alpha > 127
|
||||
ref_mask = ref_masks[layer_id]
|
||||
dx0, dy0 = phase_correlate(layer_mask, ref_mask)
|
||||
|
||||
shifted_rgb, shifted_alpha = shift_rgba(despilled_rgb, alpha, dx0, dy0)
|
||||
shifted_mask = shifted_alpha > 127
|
||||
dx1, dy1 = phase_correlate(shifted_mask, ref_mask)
|
||||
|
||||
residual_exceeds = abs(dx1) > 1 or abs(dy1) > 1
|
||||
|
||||
out = np.dstack([shifted_rgb, shifted_alpha]).astype(np.uint8)
|
||||
out_path = LAYERS_DIR / f"{layer_id}.png"
|
||||
Image.fromarray(out, "RGBA").save(out_path)
|
||||
|
||||
bbox = alpha_bbox(shifted_alpha)
|
||||
opaque_pixels = int((shifted_alpha == 255).sum())
|
||||
green_residue = green_residue_count(shifted_rgb, shifted_alpha)
|
||||
|
||||
entry = {
|
||||
"id": layer_id,
|
||||
"file": f"layers/{layer_id}.png",
|
||||
"sourceRaw": f"raw/{filename}",
|
||||
"rawNormalize": norm_report,
|
||||
"alphaBBox": bbox,
|
||||
"opaquePixels": opaque_pixels,
|
||||
"greenResidue": green_residue,
|
||||
"alignOffsetBefore": [dx0, dy0],
|
||||
"alignOffsetAfter": [dx1, dy1],
|
||||
"residualExceeds1px": residual_exceeds,
|
||||
}
|
||||
layers_report.append(entry)
|
||||
results[layer_id] = (shifted_rgb, shifted_alpha)
|
||||
print(
|
||||
f"[{layer_id}] normalize={norm_report} offsetBefore=({dx0},{dy0}) "
|
||||
f"offsetAfter=({dx1},{dy1}) bbox={bbox} opaque={opaque_pixels} "
|
||||
f"greenResidue={green_residue}"
|
||||
)
|
||||
if residual_exceeds:
|
||||
print(f" [경고] {layer_id} 잔여 오프셋이 ±1px를 초과했다: ({dx1},{dy1})")
|
||||
|
||||
canvas_rgba = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
|
||||
canvas_rgba[..., 0] = CREAM_BG[0]
|
||||
canvas_rgba[..., 1] = CREAM_BG[1]
|
||||
canvas_rgba[..., 2] = CREAM_BG[2]
|
||||
canvas_rgba[..., 3] = 255.0
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = results[layer_id]
|
||||
canvas_rgba = composite_over(canvas_rgba, rgb, alpha)
|
||||
composite = canvas_rgba.astype(np.uint8)
|
||||
composite_img = Image.fromarray(composite, "RGBA")
|
||||
composite_img.save(PREVIEW_DIR / "composite-faceless.png")
|
||||
|
||||
base_faceless_img = Image.open(BASE_DIR / "base-faceless.png").convert("RGBA")
|
||||
bf_w, bf_h = base_faceless_img.size
|
||||
cmp_w, cmp_h = composite_img.size
|
||||
diff_w, diff_h = min(bf_w, cmp_w), min(bf_h, cmp_h)
|
||||
size_note = None
|
||||
if (bf_w, bf_h) != (cmp_w, cmp_h):
|
||||
size_note = (
|
||||
f"base-faceless.png({bf_w}x{bf_h})와 composite({cmp_w}x{cmp_h}) 크기가 달라 "
|
||||
f"좌상단 기준 {diff_w}x{diff_h} 교차 영역만 비교했다."
|
||||
)
|
||||
print(f"[안내] {size_note}")
|
||||
|
||||
composite_arr = np.array(composite_img.convert("RGB"))[0:diff_h, 0:diff_w].astype(np.float64)
|
||||
base_arr = np.array(base_faceless_img.convert("RGB"))[0:diff_h, 0:diff_w].astype(np.float64)
|
||||
full_diff = np.abs(composite_arr - base_arr).mean(axis=2)
|
||||
mean_abs_diff_full = float(full_diff.mean())
|
||||
|
||||
head_ref = ref_masks["head"][0:diff_h, 0:diff_w]
|
||||
mean_abs_diff_face = float(full_diff[head_ref].mean()) if head_ref.any() else None
|
||||
|
||||
hair_edge = ref_masks["hairFront"][0:diff_h, 0:diff_w]
|
||||
from scipy.ndimage import binary_dilation, binary_erosion
|
||||
|
||||
dilated = binary_dilation(hair_edge, iterations=6)
|
||||
eroded = binary_erosion(hair_edge, iterations=6)
|
||||
hair_outline_band = dilated & ~eroded
|
||||
mean_abs_diff_hair_outline = (
|
||||
float(full_diff[hair_outline_band].mean()) if hair_outline_band.any() else None
|
||||
)
|
||||
|
||||
side_by_side = Image.new("RGB", (diff_w * 2 + 20, diff_h), CREAM_BG)
|
||||
side_by_side.paste(base_faceless_img.convert("RGB").crop((0, 0, diff_w, diff_h)), (0, 0))
|
||||
side_by_side.paste(composite_img.convert("RGB").crop((0, 0, diff_w, diff_h)), (diff_w + 20, 0))
|
||||
side_by_side.save(PREVIEW_DIR / "compare.png")
|
||||
|
||||
print(
|
||||
f"합성 차이: 전체={mean_abs_diff_full:.3f} 얼굴영역={mean_abs_diff_face} "
|
||||
f"머리윤곽영역={mean_abs_diff_hair_outline}"
|
||||
)
|
||||
|
||||
manifest = {}
|
||||
if MANIFEST_PATH.exists():
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
manifest["schemaVersion"] = "vignette.avatar.v3.layers.v1"
|
||||
manifest["persona"] = "P1"
|
||||
manifest["canvas"] = {"w": canvas_w, "h": canvas_h}
|
||||
manifest["base"] = {
|
||||
"front": "base/base-front.png",
|
||||
"faceless": "base/base-faceless.png",
|
||||
"facelessSize": list(base_faceless_img.size),
|
||||
}
|
||||
manifest["layers"] = layers_report
|
||||
manifest["composite"] = {
|
||||
"meanAbsDiff": {
|
||||
"full": mean_abs_diff_full,
|
||||
"face": mean_abs_diff_face,
|
||||
"hairOutline": mean_abs_diff_hair_outline,
|
||||
},
|
||||
"sizeNote": size_note,
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 저장: {MANIFEST_PATH}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue