리노컷 자산 파이프라인 공통화와 캐스트 외형 설계

- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
This commit is contained in:
Yun Chan 2026-10-01 16:11:28 +09:00
parent f11e76ff18
commit ecb36d123f
38 changed files with 2393 additions and 990 deletions

View file

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

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

View 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`).

View 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에도 남긴다)를 기준치와 비교해 판정한다.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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())

View file

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

View file

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

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

View file

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

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Before After
Before After

View 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.

View 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.

View file

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