P1 서연 리노컷 아트 원본과 자산 파이프라인

- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
Yun Chan 2026-10-01 09:58:15 +09:00
parent 85bd079d18
commit 00169533d8
83 changed files with 7843 additions and 0 deletions

View file

@ -0,0 +1,21 @@
# P1 서연 리노컷 아바타 자산
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/avatar-expression-engine-v3.md) §8)의 P1 원본·중간 산출물·파이프라인이다.
- `base/`: 정면 원화(`base-front.png`)와 얼굴 없는 기본형(`base-faceless*.png`). 모든 레이어 픽셀의 원천이다.
- `raw/`: 참조 편집 생성 원본(가려진 영역 채움용).
- `layers/v2/`: 분할된 레이어·입술·턱 조각 PNG. `scripts/`가 만든다.
- `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`를 만든다.
## 모델 파일(저장소에 없음)
`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가 필요하다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -0,0 +1,828 @@
{
"schemaVersion": "vignette.avatar.v3.layers.v1",
"persona": "P1",
"canvas": {
"w": 1005,
"h": 1566
},
"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다."
},
"layers": [
{
"id": "body",
"file": "layers/body.png",
"source": "base-faceless-masked+regenerated-fill",
"alphaBBox": [
0,
807,
1005,
1566
],
"opaquePixels": 676997,
"greenResidue": 0,
"fillTargetPixels": 111193,
"fillSourceRaw": "raw/body.png (rejected 재생성이 아니라 수용된 body.png의 크로마키 결과; head/hairFront와 달리 body 재생성은 승인됨)"
},
{
"id": "head",
"file": "layers/head.png",
"source": "base-faceless-masked",
"alphaBBox": [
114,
86,
941,
1121
],
"opaquePixels": 581114,
"greenResidue": 0
},
{
"id": "hairFront",
"file": "layers/hairFront.png",
"source": "base-faceless-masked",
"alphaBBox": [
260,
391,
751,
932
],
"opaquePixels": 26681,
"greenResidue": 0
}
],
"composite": {
"meanAbsDiff": {
"full": 1.2217247097844115,
"face": 0.31938995265429254,
"hairOutline": 5.581258439408577
},
"target": {
"full": 3.0,
"pass": true
},
"backgroundNote": "카테고리0(배경)은 종이결 텍스처 노이즈(표준편차 ~29/채널)를 포함해 평탄한 크림(#EEE5D3)과 자체적으로 평균절대차 ~7 차이가 난다."
},
"landmarks": {
"coordSystem": "screen (image pixel: x=0 좌측, y=0 상단; left=작은 x, right=큰 x; 인물 해부학적 좌우 아님)",
"eyeLeft": {
"innerCorner": [
430.91,
592.22
],
"outerCorner": [
339.05,
578.79
],
"upperLidTop": [
376.31,
564.31
],
"lowerLidBottom": [
381.06,
598.18
],
"iris": {
"center": [
387.33,
579.15
],
"radius": 21.43
}
},
"eyeRight": {
"innerCorner": [
552.78,
590.87
],
"outerCorner": [
648.01,
575.45
],
"upperLidTop": [
607.07,
562.12
],
"lowerLidBottom": [
603.89,
595.01
],
"iris": {
"center": [
601.93,
576.21
],
"radius": 21.28
}
},
"eyebrowLeft": {
"inner": [
450.62,
525.06
],
"peak": [
360.1,
510.44
],
"outer": [
313.38,
513.28
]
},
"eyebrowRight": {
"inner": [
518.25,
521.66
],
"peak": [
626.75,
508.19
],
"outer": [
682.75,
510.7
]
},
"noseTip": [
486.6,
713.03
],
"mouthCornerLeft": [
422.12,
804.71
],
"mouthCornerRight": [
567.47,
805.8
],
"upperLipTopCenter": [
489.77,
773.37
],
"lowerLipBottomCenter": [
492.98,
831.48
],
"chinTip": [
500.61,
919.55
],
"faceWidthAtEyeLevelLeft": [
281.78,
623.96
],
"faceWidthAtEyeLevelRight": [
736.2,
621.0
]
},
"landmarkDetector": "mediapipe FaceLandmarker (tasks) 1.0.1, model=face_landmarker(float16, v1)",
"segmenter": {
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",
"categories": {
"0": "background",
"1": "hair",
"2": "bodySkin",
"3": "faceSkin",
"4": "clothes",
"5": "others"
},
"categoryPixelCounts": {
"0": 407753,
"1": 383398,
"2": 68311,
"3": 188992,
"4": 525376
},
"hairEdgeRefine": {
"dilatePx": 6,
"lumThreshold": 110,
"addedPixels": 22965
}
},
"chinLine": {
"landmarkIndex": 152,
"xy": [
500.61,
919.55
],
"marginPx": 8,
"cutY": 927.55
},
"faceOval": {
"landmarkLoop": [
10,
338,
297,
332,
284,
251,
389,
356,
454,
323,
361,
288,
397,
365,
379,
378,
400,
377,
152,
148,
176,
149,
150,
136,
172,
58,
132,
93,
234,
127,
162,
21,
54,
103,
67,
109
],
"scale": 1.04,
"sourceImage": "base/base-front.png"
},
"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차 반려 사유)"
},
"body": {
"file": "raw/body.png",
"status": "accepted-as-fill-source",
"reason": "정렬 (0,0), 형태 변형 없음 — head_mask 채움 전용 소스로 재사용"
}
},
"layersV2": {
"chinBandFix": {
"targetRegionPixels": 51052,
"sourceBandHeight": 150,
"rowJitterRange": [
-2.0,
2.0
],
"rngSeed": 20260930
},
"hairEdgeFix": {
"bandOuterPx": 40,
"bandInnerPx": 6,
"lumSmoothstep": [
205.0,
150.0
],
"minFragmentAreaPx": 60,
"head": {
"bandOuterPixels": 93622,
"bandInnerPixels": 60104,
"componentsBefore": 67,
"removed": 63,
"removedPixels": 322
},
"hairFront": {
"bandOuterPixels": 0,
"bandInnerPixels": 21491,
"componentsBefore": 217,
"removed": 207,
"removedPixels": 526
}
},
"hairPaperGapFix": {
"regionDilatePx": 6,
"paperColorDistThreshold": 40.0,
"paperLumThreshold": 195.0,
"paperSatThreshold": 40.0,
"opaqueAlphaThreshold": 0.95,
"regionPixels": 410064,
"head": {
"targetPixels": 1359,
"componentsBefore": 4,
"removed": 0,
"removedPixels": 0,
"faceSkinAlphaDiff": 0.0
},
"hairFront": {
"targetPixels": 0,
"componentsBefore": 10,
"removed": 0,
"removedPixels": 0,
"faceSkinAlphaDiff": 0.0
}
},
"jawInkFix": {
"mouthCenterY": 797.52,
"distThresholdPx": 14.0,
"mergePx": 3,
"aspectThreshold": 1.0,
"removedGroups": [
{
"pixels": 4410,
"bbox": [
459,
798,
700,
933
],
"width": 241,
"height": 135,
"aspect": 1.785
}
],
"keptGroups": [
{
"pixels": 1471,
"bbox": [
322,
798,
382,
870
],
"width": 60,
"height": 72,
"aspect": 0.833
}
],
"removedPixels": 4410,
"strayFragmentCleanup": {
"componentsBefore": 21,
"removed": 14,
"removedPixels": 118
}
},
"metrics": {
"body": {
"haloPct": 0.6339468302658486
},
"head": {
"haloPct": 0.04071661237785017,
"bgLeakAlphaMean": 0.0,
"hairPaperOpaquePct": 0.0,
"disconnectedFragmentsUnder60px": 0
},
"hairFront": {
"haloPct": 0.0,
"bgLeakAlphaMean": 0.0,
"hairPaperOpaquePct": 0.0,
"disconnectedFragmentsUnder60px": 0
}
},
"layers": [
{
"id": "body",
"bbox": [
0,
807,
1005,
1566
],
"opaque": 672038,
"greenResidue": 1
},
{
"id": "head",
"bbox": [
111,
72,
960,
1121
],
"opaque": 593623,
"greenResidue": 0
},
{
"id": "hairFront",
"bbox": [
260,
391,
751,
907
],
"opaque": 31928,
"greenResidue": 0
}
]
},
"motifPublish": {
"quality": 82,
"fileSizes": {
"bud-closed": 14576,
"bud-half": 14932,
"bud-open": 18278,
"bud-droop": 14102,
"weather-positive": 7594,
"weather-negative": 13262,
"weather-defensive": 14886,
"weather-cognitive": 7790,
"weather-energy": 7132
},
"totalBytes": 112552,
"budgetBytes": 163840,
"halo": {
"bud-closed": 0.09157509157509157,
"bud-half": 0.09174311926605505,
"bud-open": 0.06657789613848203,
"bud-droop": 0.205761316872428,
"weather-positive": 0.0,
"weather-negative": 0.0,
"weather-defensive": 0.0,
"weather-cognitive": 0.0,
"weather-energy": 0.2890173410404624
},
"haloThreshold": 2.0
},
"browCenterline": {
"method": "Otsu(front lum in bbox) AND diff(F-front)>=25, morphological closing to merge hatching, single global degree-2 polyfit on column-weighted centroid with one-pass outlier removal",
"diffThresh": 25.0,
"bboxPad": {
"x": 25.0,
"yUp": 42.0,
"yDown": 14.0
},
"closingKernel": [
7,
11
],
"minComponentAreaPx": 30.0,
"outlierStdMult": 2.0,
"edgeInsetFrac": 0.03,
"peakWindow": [
0.25,
0.6
],
"peakFallbackFrac": 0.35,
"peakFlatHeightPx": 3.0,
"peakFailEdgeFrac": 0.2,
"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
]
}
},
"newPoints": {
"browLeft": {
"name": "browLeft",
"bbox": [
274,
456,
466,
550
],
"otsuThresh": 105.0,
"colRange": [
309,
455
],
"width": 146.0,
"nColumns": 146,
"nOutliersRemoved": 7,
"fitCoeffs": [
0.00161949,
-1.15147737,
715.08915879
],
"inner": [
450.62,
525.06
],
"peak": [
360.1,
510.44
],
"outer": [
313.38,
513.28
],
"peakMethod": "fallback35",
"peakFracFromOuterEdge": 0.35,
"peakFail": false,
"checkA_fracColumnsFitWithinMask": 0.9247,
"checkA_pass": true,
"bandX0": 274,
"bandY0": 456,
"colMin": 309,
"colMax": 455
},
"browRight": {
"name": "browRight",
"bbox": [
506,
454,
713,
549
],
"otsuThresh": 110.0,
"colRange": [
513,
688
],
"width": 175.0,
"nColumns": 163,
"nOutliersRemoved": 11,
"fitCoeffs": [
0.00102658,
-1.29955603,
919.43219697
],
"inner": [
518.25,
521.66
],
"peak": [
626.75,
508.19
],
"outer": [
682.75,
510.7
],
"peakMethod": "fallback35",
"peakFracFromOuterEdge": 0.35,
"peakFail": false,
"checkA_fracColumnsFitWithinMask": 0.9202,
"checkA_pass": true,
"bandX0": 506,
"bandY0": 454,
"colMin": 513,
"colMax": 688
}
},
"checkB_innerMinusPeakY": {
"browLeft": 14.62,
"browRight": 13.47
},
"checkD_symmetry": {
"innerYDiff": 3.4,
"peakYDiff": 2.25
},
"evidenceImage": "preview/v2/brow-centerline.jpg"
},
"lipTexture": {
"designVersion": "A5 (2단계-B-1d-A5, 작업 패킷 A5 — 아랫입술 그늘 띠를 lip-shadow 조각으로 분리)",
"landmarks": {
"mouthCornerLeft": [
422.12,
804.71
],
"mouthCornerRight": [
567.47,
805.8
],
"upperLipTop": [
489.77,
773.37
],
"lowerLipBottom": [
492.98,
831.48
],
"mouthCenter": [
491.55401542782784,
797.5187528729439
]
},
"capBBox": [
413.8,
767.42,
575.8,
837.42
],
"otsuThreshLabA": 136.0,
"diffThresh": 20.0,
"dilatePx": 10.0,
"alphaRampPx": 4.0,
"splitOverlapPx": 2.0,
"shadowBandPx": 18.0,
"shadowTopOverlapPx": 6.0,
"shadowSideRampPx": 6.0,
"shadowTopRampPx": 4.0,
"checks": {
"lip-upper": {
"regionPixelCount": 5996,
"fullOpacityPixelCount": 5179,
"rampPixelCount": 817,
"preserveMaxDiffFullOpacity": 0,
"check1Preserve": true,
"spriteBBoxWorkingLocal": [
30,
31,
182,
54
]
},
"lip-lower": {
"regionPixelCount": 6482,
"fullOpacityPixelCount": 5704,
"rampPixelCount": 778,
"preserveMaxDiffFullOpacity": 0,
"check1Preserve": true,
"spriteBBoxWorkingLocal": [
30,
71,
182,
49
]
},
"lip-shadow": {
"regionPixelCount": 3888,
"fullOpacityPixelCount": 2071,
"rampPixelCount": 1817,
"preserveMaxDiffFullOpacity": 0,
"check1Preserve": true,
"spriteBBoxWorkingLocal": [
40,
75,
162,
53
]
},
"splitOverlapPixelCount": 362,
"splitOverlapBandOk": true,
"splitOverlapAlphaMaxDiff": 0,
"splitOverlapConsistentOk": true,
"splitCoverageMismatchPixelCount": 0,
"splitCheckOk": true,
"shadowLowerOverlapPixelCount": 2845,
"shadowLowerOverlapMaxDiff": 35.95294117647059,
"shadowLowerOverlapDiffGt1PixelCount": 138,
"shadowLowerOverlapOk": false,
"shadowLowerOverlapNote": "국소 실패(입꼬리 첨점 부근, diff>1인 픽셀 수 참고). 원인: 두 입꼬리 근처에서 원화 입술 색 마스크가 하이라이트로 끊겨 윗/아랫 두 블롭으로 갈라지고, 기존 Cu0 부호 분할(A3/A4부터의 로직, 이번 작업에서 변경하지 않음)이 그 다리 부분을 '윗'으로 분류해 alpha_lower=0이 되는 지점이 생긴다. 그 자리는 그늘 조각의 옆(열 끊김) 램프 구간과도 겹쳐 그늘 쪽 알파도 완전 불투명이 아니라서 등식이 깨진다. preview/v2/lip-texture.jpg의 원화|중립합성 패널을 3배 확대로 육안 확인한 결과 이음매는 보이지 않는다(오케스트레이터 육안 무해 판단, 2026-10-01)."
},
"check3NeutralComposite": {
"fullOpacityMaxDiff": 0.0,
"rampMeanAbsDiff": 3.069,
"mouthBBoxMeanAbsDiff": 0.707
},
"pngFileSizes": {
"lip-upper": 24050,
"lip-lower": 22210,
"lip-shadow": 21762
},
"canvasOrigin": {
"upper": [
404,
758
],
"lower": [
404,
798
],
"shadow": [
414,
802
]
},
"evidenceImage": "preview/v2/lip-texture.jpg",
"check4WebpVsPngMeanAbsDiff": {
"upper": 0.0,
"lower": 0.0,
"shadow": 0.0
}
},
"lipTexturePublish": {
"quality": "lossless",
"fileSizes": {
"lip-upper": 15904,
"lip-lower": 14630,
"lip-shadow": 14868
},
"totalBytes": 45402,
"budgetBytes": 81920,
"webpVsPngMeanAbsDiff": {
"upper": 0.0,
"lower": 0.0,
"shadow": 0.0
}
},
"jaw": {
"designVersion": "A5 (작업 패킷 A5, 결정문 §8.4 하관 띠 변형 — 턱 조각 원본)",
"noseTip": [
486.6,
713.03
],
"chinTip": [
500.61,
919.55
],
"yFormula": "noseTip.y - 50 - 10 .. chinTip.y + 40",
"noseToTopPx": 60.0,
"chinToBottomPx": 40.0,
"ovalOutsetPx": 18.0,
"faceOvalSource": "export_rig.detect_face_landmarks + compute_face_oval(base-front.png, 결정적)",
"bboxCanvas": [
260,
653,
760,
960
],
"naiveXRangeForComparison": [
260,
760
],
"checks": {
"head": {
"pngRoundTripMaxDiff": 0,
"pngRoundTripOk": true,
"pngFileSize": 314304
},
"detail": {
"pngRoundTripMaxDiff": 0,
"pngRoundTripOk": true,
"pngFileSize": 332332
}
},
"evidenceImage": "preview/v2/jaw-pieces.jpg",
"check1VsPublishedLayerAbsDiff": {
"head": {
"comparedAgainstPublishedLayer": "head.webp",
"meanAbsDiffPremultipliedRgb": 3.131,
"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 자체의 압축 오차만큼은 남아 이득이 없다(비교 대상 자체가 손실 압축본)."
},
"detail": {
"comparedAgainstPublishedLayer": "face-detail.webp",
"meanAbsDiffPremultipliedRgb": 0.565,
"maxAbsDiffPremultipliedRgb": 25.0,
"meanAbsDiffAlpha": 0.0,
"maxAbsDiffAlpha": 0.0,
"meetsTarget": true
}
}
},
"jawPublish": {
"quality": 80,
"scale": 1.0,
"fileSizes": {
"jaw-head": 24580,
"jaw-detail": 15488
},
"totalBytes": 40068,
"budgetBytes": 204800,
"vsPublishedLayerAbsDiff": {
"head": {
"comparedAgainstPublishedLayer": "head.webp",
"meanAbsDiffPremultipliedRgb": 3.131,
"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 자체의 압축 오차만큼은 남아 이득이 없다(비교 대상 자체가 손실 압축본)."
},
"detail": {
"comparedAgainstPublishedLayer": "face-detail.webp",
"meanAbsDiffPremultipliedRgb": 0.565,
"maxAbsDiffPremultipliedRgb": 25.0,
"meanAbsDiffAlpha": 0.0,
"maxAbsDiffAlpha": 0.0,
"meetsTarget": true
}
}
}
}

View file

@ -0,0 +1,600 @@
{
"bud": {
"raw_sheet": {
"file": "raw/bud-sheet.png",
"size": [
1881,
836
],
"paper_color_rgb": [
238.0,
229.0,
211.0
],
"panel_gap_widths_px": [
209,
217,
199
],
"panel_col_ranges": [
[
0,
483
],
[
483,
937
],
[
937,
1395
],
[
1395,
1881
]
]
},
"sprites": [
{
"name": "bud-closed",
"path": "sprites/bud-closed.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.53,
398.67
]
},
{
"name": "bud-half",
"path": "sprites/bud-half.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.42,
399.08
]
},
{
"name": "bud-open",
"path": "sprites/bud-open.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.0,
398.69
]
},
{
"name": "bud-droop",
"path": "sprites/bud-droop.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.0,
398.87
]
}
],
"scale_factor": 0.7677,
"canvas": [
280,
420
],
"base_anchor_px": [
134.23,
399.0
],
"union_bbox_native_px": {
"up": 488.94,
"down": 3.44,
"left": 127.52,
"right": 142.56
},
"stem_bottom_alignment_max_dev_px": 0.571,
"qc": {
"bud-closed": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
69.8,
89.1,
98.8
],
"pixel_count": 12879
},
{
"center_rgb": [
113.2,
123.9,
124.7
],
"pixel_count": 2595
},
{
"center_rgb": [
36.4,
47.8,
53.2
],
"pixel_count": 2251
},
{
"center_rgb": [
186.3,
183.5,
171.5
],
"pixel_count": 1690
}
]
},
"bud-half": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
74.3,
93.4,
102.6
],
"pixel_count": 13550
},
{
"center_rgb": [
202.9,
158.2,
93.2
],
"pixel_count": 3028
},
{
"center_rgb": [
37.6,
49.5,
55.3
],
"pixel_count": 2227
},
{
"center_rgb": [
180.0,
177.0,
163.3
],
"pixel_count": 2159
}
]
},
"bud-open": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
77.8,
96.4,
105.5
],
"pixel_count": 10746
},
{
"center_rgb": [
206.9,
162.3,
95.9
],
"pixel_count": 7984
},
{
"center_rgb": [
46.4,
61.9,
69.8
],
"pixel_count": 2847
},
{
"center_rgb": [
196.8,
184.8,
160.1
],
"pixel_count": 2724
}
]
},
"bud-droop": {
"background_leak_mean_alpha_outside_bbox": 1e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
68.5,
87.9,
97.7
],
"pixel_count": 10902
},
{
"center_rgb": [
105.3,
117.9,
120.5
],
"pixel_count": 2961
},
{
"center_rgb": [
187.0,
174.1,
148.9
],
"pixel_count": 2295
},
{
"center_rgb": [
33.0,
42.5,
46.7
],
"pixel_count": 2111
}
]
}
}
},
"weather": {
"raw_sheet": {
"file": "raw/weather-sheet.png",
"size": [
1774,
887
],
"paper_color_rgb": [
236.0,
227.0,
207.0
],
"panel_gap_widths_px": [
47,
40,
56,
47
],
"panel_col_ranges": [
[
0,
351
],
[
351,
708
],
[
708,
1146
],
[
1146,
1413
],
[
1413,
1774
]
]
},
"sprites": [
{
"name": "weather-positive",
"path": "sprites/weather-positive.png",
"canvas": [
320,
200
],
"scale_factor": 0.6308
},
{
"name": "weather-negative",
"path": "sprites/weather-negative.png",
"canvas": [
320,
200
],
"scale_factor": 0.6048
},
{
"name": "weather-defensive",
"path": "sprites/weather-defensive.png",
"canvas": [
320,
200
],
"scale_factor": 0.7258
},
{
"name": "weather-energy",
"path": "sprites/weather-energy.png",
"canvas": [
320,
200
],
"scale_factor": 0.8421
},
{
"name": "weather-cognitive",
"path": "sprites/weather-cognitive.png",
"canvas": [
320,
200
],
"scale_factor": 0.9644
}
],
"qc": {
"weather-positive": {
"background_leak_mean_alpha_outside_bbox": 1e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
205.8,
156.1,
85.0
],
"pixel_count": 3199
},
{
"center_rgb": [
198.8,
144.6,
68.4
],
"pixel_count": 3114
},
{
"center_rgb": [
213.4,
172.2,
111.1
],
"pixel_count": 1251
},
{
"center_rgb": [
223.2,
193.9,
146.5
],
"pixel_count": 1072
}
]
},
"weather-negative": {
"background_leak_mean_alpha_outside_bbox": 3e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
91.2,
110.9,
120.0
],
"pixel_count": 5253
},
{
"center_rgb": [
67.8,
89.2,
101.3
],
"pixel_count": 4246
},
{
"center_rgb": [
133.2,
142.6,
142.6
],
"pixel_count": 2003
},
{
"center_rgb": [
182.2,
182.6,
173.5
],
"pixel_count": 1672
}
]
},
"weather-defensive": {
"background_leak_mean_alpha_outside_bbox": 7e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
72.5,
91.5,
103.1
],
"pixel_count": 6508
},
{
"center_rgb": [
96.1,
112.7,
120.3
],
"pixel_count": 6443
},
{
"center_rgb": [
133.0,
142.1,
141.8
],
"pixel_count": 2762
},
{
"center_rgb": [
180.8,
181.7,
172.9
],
"pixel_count": 2254
}
]
},
"weather-energy": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
205.2,
156.9,
86.9
],
"pixel_count": 3074
},
{
"center_rgb": [
198.0,
145.1,
70.7
],
"pixel_count": 3008
},
{
"center_rgb": [
213.5,
173.5,
113.1
],
"pixel_count": 1275
},
{
"center_rgb": [
224.1,
195.8,
149.9
],
"pixel_count": 949
}
]
},
"weather-cognitive": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
135.7,
147.7,
152.0
],
"pixel_count": 2387
},
{
"center_rgb": [
119.6,
134.0,
142.1
],
"pixel_count": 2284
},
{
"center_rgb": [
158.2,
165.5,
163.8
],
"pixel_count": 1300
},
{
"center_rgb": [
185.9,
187.9,
179.6
],
"pixel_count": 1244
}
]
}
}
},
"preview": {
"sprites_on_backdrops": "preview/sprites-on-backdrops.jpg",
"bud_crossfade": "preview/bud-crossfade.jpg",
"bud_crossfade_cells": [
"closed->half 25%",
"closed->half 50%",
"closed->half 75%",
"half->open 25%",
"half->open 50%",
"half->open 75%",
"closed->droop 50%"
],
"bud_sizes": "preview/bud-sizes.jpg",
"sheets_raw_small": "preview/sheets-raw-small.jpg"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -0,0 +1 @@
A linocut relief print sprite sheet on plain flat cream paper (#EEE5D3). The background must be completely flat and empty: no paper texture, no border, no frame, no text, no signature. Four separate panels in one horizontal row with wide empty space between them, each showing the SAME small symbolic plant at the SAME scale. In every panel the bottom end of the stem sits at exactly the same height near the bottom edge and is horizontally centered in its panel. The plant is a single slender dark ink stem with two slate-blue leaves carved with a few white gouge lines, and one flower bud at the top. Panel 1: the bud tightly closed and upright, slate blue with a thin ochre tip. Panel 2: the same bud half open and upright, ochre petals just parting. Panel 3: the flower fully open and upright, five ochre petals spread with carved lines. Panel 4: the closed bud drooping, the upper stem bent so the bud hangs down beside the stem, leaves slightly lowered. Style exactly like the plant, cloud and sun motifs in the attached reference image: bold carved ink outlines, flat colors limited to slate blue (#53626C), ochre (#D0A362) and ink black, slight print misregistration and ink grain inside the shapes only. Nothing touches the panel edges and nothing overlaps between panels. No people.

View file

@ -0,0 +1 @@
A linocut relief print sprite sheet on plain flat cream paper (#EEE5D3). The background must be completely flat and empty: no paper texture, no border, no frame, no text. Five separate small weather symbols in one horizontal row with wide empty space between them, each roughly the same width, nothing overlapping: 1) a round ochre sun with short carved light rays; 2) a slate-blue rain cloud with slanted rain streaks falling below it; 3) a heavy low fog bank: a flat slate-grey cloud with horizontal carved fog lines beneath it; 4) a thin ochre crescent moon with two or three tiny four-pointed stars; 5) a very faint haze: three or four soft horizontal carved wisps in pale slate blue. Style exactly like the clouds, rain and sun in the attached reference image: bold carved edges, gouge marks and white carved lines inside the shapes, flat colors limited to slate blue (#53626C), ochre (#D0A362) and ink black, slight ink grain inside the shapes only. Symbols only: no plants, no people.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,583 @@
"""P1 리노컷 모티프 스프라이트 후처리.
원본 시트(raw/bud-sheet.png, raw/weather-sheet.png)를 읽어 칸을 분리하고,
종이 배경을 알파로 바꾸고(unpremultiply), 정렬한 스프라이트 PNG를 만든다.
검사 수치를 motif/manifest.json에 기록하고 미리보기를 motif/preview/에 만든다.
작업 패킷: 2단계-B-1c. 결정적으로 동작해야 하며 난수를 쓰지 않는다.
"""
import json
import os
import numpy as np
from PIL import Image
MOTIF_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW_DIR = os.path.join(MOTIF_DIR, "raw")
SPRITES_DIR = os.path.join(MOTIF_DIR, "sprites")
PREVIEW_DIR = os.path.join(MOTIF_DIR, "preview")
MANIFEST_PATH = os.path.join(MOTIF_DIR, "manifest.json")
BUD_CANVAS = (280, 420) # (W, H) — 2B-1c 반려 재작업: 240x360에서 확대(봉오리 머리 잘림)
BUD_MARGIN_FRAC = 0.05 # 4장 합집합 bbox 사방 여백 최소치
BUD_FIT_FRAC = 0.90 # 합집합 bbox가 캔버스에서 차지할 최대 비율
WEATHER_CANVAS = (320, 200) # (W, H)
WEATHER_MAX_FRAC = 0.88
CLIP_BAND_PX = 3 # 잘림 검사용 가장자리 띠 두께
CLIP_ALPHA_CUT = 0.05 # 잘림 검사용 알파 임계값
BUD_PANEL_NAMES = ["bud-closed", "bud-half", "bud-open", "bud-droop"]
WEATHER_PANEL_NAMES = [
"weather-positive", # 해
"weather-negative", # 비구름
"weather-defensive", # 안개
"weather-energy", # 초승달
"weather-cognitive", # 옅은 안개결
]
ALPHA_LO = 18.0
ALPHA_HI = 60.0
SPLIT_THRESHOLD = 12.0 # 칸 분리용 전경 판정 임계값(종이색 거리)
CROP_PAD = 12
def smoothstep(d, lo, hi):
t = np.clip((d - lo) / (hi - lo), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def color_dist(arr, color):
diff = arr.astype(np.float64) - np.asarray(color, dtype=np.float64)
return np.sqrt((diff ** 2).sum(axis=-1))
def estimate_paper_color(arr, border=20):
h, w = arr.shape[:2]
top = arr[:border, :, :].reshape(-1, 3)
bottom = arr[-border:, :, :].reshape(-1, 3)
left = arr[:, :border, :].reshape(-1, 3)
right = arr[:, -border:, :].reshape(-1, 3)
allb = np.concatenate([top, bottom, left, right], axis=0)
return np.median(allb.astype(np.float64), axis=0)
def find_panel_col_ranges(mask, n_expected):
"""mask: (H,W) bool 전경. 열 투영 간격으로 n_expected개 칸의 (c0,c1)을 반환."""
col_has = mask.any(axis=0)
n = len(col_has)
gaps = []
i = 0
while i < n:
if not col_has[i]:
j = i
while j < n and not col_has[j]:
j += 1
gaps.append((i, j))
i = j
else:
i += 1
internal = [g for g in gaps if g[0] > 0 and g[1] < n]
internal_sorted = sorted(internal, key=lambda g: g[1] - g[0], reverse=True)
chosen = sorted(internal_sorted[: n_expected - 1], key=lambda g: g[0])
bounds = [0] + [(g[0] + g[1]) // 2 for g in chosen] + [n]
ranges = [(bounds[k], bounds[k + 1]) for k in range(len(bounds) - 1)]
gap_widths = [g[1] - g[0] for g in chosen]
return ranges, gap_widths
def crop_panel(arr, mask, col_range, pad=CROP_PAD):
h, w = mask.shape
c0, c1 = col_range
sub_mask = mask[:, c0:c1]
rows = np.where(sub_mask.any(axis=1))[0]
r0, r1 = int(rows.min()), int(rows.max()) + 1
r0p = max(0, r0 - pad)
r1p = min(h, r1 + pad)
c0p = max(0, c0 - pad)
c1p = min(w, c1 + pad)
return arr[r0p:r1p, c0p:c1p, :].copy(), (r0p, r1p, c0p, c1p)
def unpremultiply_crop(sub_rgb_u8, paper_color):
sub = sub_rgb_u8.astype(np.float64)
d = color_dist(sub, paper_color)
alpha = smoothstep(d, ALPHA_LO, ALPHA_HI)
paper_b = np.asarray(paper_color, dtype=np.float64).reshape(1, 1, 3)
a3 = alpha[..., None]
with np.errstate(invalid="ignore", divide="ignore"):
unprem = (sub - (1.0 - a3) * paper_b) / np.clip(a3, 1e-6, None)
rgb = np.where(a3 > 0.02, unprem, sub)
rgb = np.clip(rgb, 0, 255)
return rgb, alpha
def ink_bottom_anchor(rgb, alpha, luminance_cut=90.0, alpha_cut=0.5, band=3):
"""알파>alpha_cut 이고 어두운(잉크) 픽셀 중 가장 아래쪽 무리의 중심을 반환한다."""
lum = rgb.mean(axis=2)
ink = (alpha > alpha_cut) & (lum < luminance_cut)
rows = np.where(ink.any(axis=1))[0]
if rows.size == 0:
# ink 판정 실패 시 전체 알파 콘텐츠 최하단으로 대체
rows_all = np.where((alpha > alpha_cut).any(axis=1))[0]
bottom_row = int(rows_all.max())
band_mask = (alpha > alpha_cut)[max(0, bottom_row - band + 1): bottom_row + 1, :]
ys, xs = np.where(band_mask)
ys = ys + max(0, bottom_row - band + 1)
return float(xs.mean()), float(ys.mean())
bottom_row = int(rows.max())
r0 = max(0, bottom_row - band + 1)
band_mask = ink[r0: bottom_row + 1, :]
ys, xs = np.where(band_mask)
ys = ys + r0
return float(xs.mean()), float(ys.mean())
def content_bbox(alpha, alpha_cut=0.5):
rows = np.where((alpha > alpha_cut).any(axis=1))[0]
cols = np.where((alpha > alpha_cut).any(axis=0))[0]
if rows.size == 0 or cols.size == 0:
return None
return int(rows.min()), int(rows.max()) + 1, int(cols.min()), int(cols.max()) + 1
def resize_raw_and_unpremultiply(raw_u8, paper_color, factor):
"""원본(unpremultiply 전) crop을 리사이즈한 뒤 alpha/unpremultiply를 다시 계산한다.
이미 unpremultiply한 (rgb,alpha)를 premultiply-리샘플-재분할하면 저알파 경계에서
링잉으로 종이색에 가까운 색이 남는 halo가 생긴다(실측 확인). 대신 원본 색상만
리샘플하고 동일한 색-거리 기준 alpha 계산을 다시 적용하면 네이티브 해상도와
같은 방식으로 일관된 결과를 얻는다.
"""
if abs(factor - 1.0) < 1e-6:
return unpremultiply_crop(raw_u8, paper_color)
h, w = raw_u8.shape[:2]
new_w = max(1, int(round(w * factor)))
new_h = max(1, int(round(h * factor)))
resized = np.asarray(Image.fromarray(raw_u8).resize((new_w, new_h), Image.LANCZOS))
return unpremultiply_crop(resized, paper_color)
def paste_into_canvas(rgb, alpha, canvas_w, canvas_h, ox, oy):
"""sprite(rgb,alpha)의 좌상단이 canvas 좌표 (ox,oy)에 오도록 붙인다. ox,oy는 float(반올림)."""
ox_i, oy_i = int(round(ox)), int(round(oy))
h, w = alpha.shape
canvas_rgb = np.zeros((canvas_h, canvas_w, 3), dtype=np.float64)
canvas_a = np.zeros((canvas_h, canvas_w), dtype=np.float64)
src_x0, src_y0 = 0, 0
src_x1, src_y1 = w, h
dst_x0, dst_y0 = ox_i, oy_i
dst_x1, dst_y1 = ox_i + w, oy_i + h
if dst_x0 < 0:
src_x0 -= dst_x0
dst_x0 = 0
if dst_y0 < 0:
src_y0 -= dst_y0
dst_y0 = 0
if dst_x1 > canvas_w:
src_x1 -= (dst_x1 - canvas_w)
dst_x1 = canvas_w
if dst_y1 > canvas_h:
src_y1 -= (dst_y1 - canvas_h)
dst_y1 = canvas_h
if dst_x1 > dst_x0 and dst_y1 > dst_y0:
canvas_rgb[dst_y0:dst_y1, dst_x0:dst_x1, :] = rgb[src_y0:src_y1, src_x0:src_x1, :]
canvas_a[dst_y0:dst_y1, dst_x0:dst_x1] = alpha[src_y0:src_y1, src_x0:src_x1]
return canvas_rgb, canvas_a
def save_rgba(path, rgb, alpha):
out = np.zeros((*alpha.shape, 4), dtype=np.uint8)
out[..., :3] = np.clip(rgb, 0, 255).astype(np.uint8)
out[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8)
Image.fromarray(out, mode="RGBA").save(path)
def kmeans_np(pixels, k=4, n_init=6, iters=50, seed=0):
"""sklearn 부재 시 수동 k-means. pixels: (N,3) float64."""
rng = np.random.RandomState(seed)
best_centers, best_inertia, best_labels = None, None, None
n = pixels.shape[0]
for init_i in range(n_init):
idx = rng.choice(n, size=k, replace=False)
centers = pixels[idx].copy()
labels = np.zeros(n, dtype=np.int64)
for _ in range(iters):
d = np.linalg.norm(pixels[:, None, :] - centers[None, :, :], axis=2)
new_labels = d.argmin(axis=1)
if np.array_equal(new_labels, labels) and _ > 0:
labels = new_labels
break
labels = new_labels
for c in range(k):
sel = pixels[labels == c]
if sel.shape[0] > 0:
centers[c] = sel.mean(axis=0)
d = np.linalg.norm(pixels[:, None, :] - centers[None, :, :], axis=2)
inertia = (d[np.arange(n), labels] ** 2).sum()
if best_inertia is None or inertia < best_inertia:
best_inertia, best_centers, best_labels = inertia, centers.copy(), labels.copy()
return best_centers, best_labels, best_inertia
def process_sheet(sheet_name, n_panels, kind, manifest):
path = os.path.join(RAW_DIR, sheet_name)
im = Image.open(path).convert("RGB")
arr = np.asarray(im)
h, w = arr.shape[:2]
paper_color = estimate_paper_color(arr)
fg_mask = color_dist(arr, paper_color) > SPLIT_THRESHOLD
col_ranges, gap_widths = find_panel_col_ranges(fg_mask, n_panels)
panels = []
for i, cr in enumerate(col_ranges):
crop_rgb_u8, bbox = crop_panel(arr, fg_mask, cr)
rgb, alpha = unpremultiply_crop(crop_rgb_u8, paper_color)
panels.append({"raw_u8": crop_rgb_u8, "rgb": rgb, "alpha": alpha, "bbox": bbox, "col_range": cr})
manifest[kind]["raw_sheet"] = {
"file": f"raw/{sheet_name}",
"size": [w, h],
"paper_color_rgb": [round(float(c), 1) for c in paper_color],
"panel_gap_widths_px": gap_widths,
"panel_col_ranges": [list(cr) for cr in col_ranges],
}
return panels, paper_color
def build_bud_sprites(panels, paper_color, manifest):
"""4장의 줄기 밑동을 기준으로 정렬하고, 합집합 bbox(alpha>0.05)가 캔버스의
BUD_FIT_FRAC 안(사방 여백 BUD_MARGIN_FRAC 이상)에 들어가도록 4장 공통 배율
하나를 정해 배치한다(1차 반려 사유: 밑동만 맞추고 축소하지 않아 봉오리 머리가
캔버스 위에서 잘림).
"""
W, H = BUD_CANVAS
# 1) 네이티브 해상도에서 밑동 앵커와 alpha>0.05 콘텐츠 bbox를 구해
# 앵커 기준 상하좌우 여유폭(extent)을 계산한다.
extents = []
for p in panels:
rgb, alpha = p["rgb"], p["alpha"]
ax, ay = ink_bottom_anchor(rgb, alpha)
bbox = content_bbox(alpha, alpha_cut=CLIP_ALPHA_CUT)
r0, r1, c0, c1 = bbox
extents.append({
"anchor": (ax, ay),
"up": ay - r0,
"down": max(0.0, r1 - ay),
"left": ax - c0,
"right": c1 - ax,
})
up_max = max(e["up"] for e in extents)
down_max = max(e["down"] for e in extents)
left_max = max(e["left"] for e in extents)
right_max = max(e["right"] for e in extents)
width_needed = left_max + right_max
height_needed = up_max + down_max
factor = min(BUD_FIT_FRAC * W / width_needed, BUD_FIT_FRAC * H / height_needed)
base_y = H * (1.0 - BUD_MARGIN_FRAC)
base_x = W / 2.0 + factor * (left_max - right_max) / 2.0
finals = []
anchors_out = []
for p, name in zip(panels, BUD_PANEL_NAMES):
rgb, alpha = resize_raw_and_unpremultiply(p["raw_u8"], paper_color, factor)
ax, ay = ink_bottom_anchor(rgb, alpha)
ox = base_x - ax
oy = base_y - ay
canvas_rgb, canvas_a = paste_into_canvas(rgb, alpha, W, H, ox, oy)
out_path = os.path.join(SPRITES_DIR, f"{name}.png")
save_rgba(out_path, canvas_rgb, canvas_a)
final_ax, final_ay = ink_bottom_anchor(canvas_rgb, canvas_a)
anchors_out.append((final_ax, final_ay))
finals.append({"name": name, "path": f"sprites/{name}.png", "canvas": list(BUD_CANVAS),
"stem_bottom_anchor_px": [round(final_ax, 2), round(final_ay, 2)]})
anchors_arr = np.array(anchors_out)
max_dev = 0.0
for i in range(len(anchors_arr)):
for j in range(i + 1, len(anchors_arr)):
dev = float(np.linalg.norm(anchors_arr[i] - anchors_arr[j]))
max_dev = max(max_dev, dev)
manifest["bud"]["sprites"] = finals
manifest["bud"]["scale_factor"] = round(factor, 4)
manifest["bud"]["canvas"] = list(BUD_CANVAS)
manifest["bud"]["base_anchor_px"] = [round(base_x, 2), round(base_y, 2)]
manifest["bud"]["union_bbox_native_px"] = {
"up": round(up_max, 2), "down": round(down_max, 2),
"left": round(left_max, 2), "right": round(right_max, 2),
}
manifest["bud"]["stem_bottom_alignment_max_dev_px"] = round(max_dev, 3)
return finals
def build_weather_sprites(panels, paper_color, manifest):
finals = []
for p, name in zip(panels, WEATHER_PANEL_NAMES):
rgb, alpha = p["rgb"], p["alpha"]
bbox = content_bbox(alpha)
r0, r1, c0, c1 = bbox
cw, ch = c1 - c0, r1 - r0
max_w = WEATHER_MAX_FRAC * WEATHER_CANVAS[0]
max_h = WEATHER_MAX_FRAC * WEATHER_CANVAS[1]
factor = min(1.0, max_w / cw, max_h / ch)
if factor < 1.0:
rgb, alpha = resize_raw_and_unpremultiply(p["raw_u8"], paper_color, factor)
bbox2 = content_bbox(alpha)
r0, r1, c0, c1 = bbox2
content_cx = (c0 + c1) / 2.0
content_cy = (r0 + r1) / 2.0
target_cx = WEATHER_CANVAS[0] / 2.0
target_cy = WEATHER_CANVAS[1] / 2.0
ox = target_cx - content_cx
oy = target_cy - content_cy
canvas_rgb, canvas_a = paste_into_canvas(rgb, alpha, WEATHER_CANVAS[0], WEATHER_CANVAS[1], ox, oy)
out_path = os.path.join(SPRITES_DIR, f"{name}.png")
save_rgba(out_path, canvas_rgb, canvas_a)
finals.append({"name": name, "path": f"sprites/{name}.png", "canvas": list(WEATHER_CANVAS),
"scale_factor": round(factor, 4)})
manifest["weather"]["sprites"] = finals
return finals
def qc_metrics(name, path, paper_color, manifest_section):
im = Image.open(path)
arr = np.asarray(im).astype(np.float64)
rgb = arr[..., :3]
alpha = arr[..., 3] / 255.0
bbox = content_bbox(alpha)
if bbox is None:
leak = float(alpha.mean())
halo_ratio = 0.0
else:
r0, r1, c0, c1 = bbox
outside = np.ones_like(alpha, dtype=bool)
outside[r0:r1, c0:c1] = False
leak = float(alpha[outside].mean()) if outside.any() else 0.0
edge_mask = (alpha > 0.05) & (alpha < 0.95)
n_edge = int(edge_mask.sum())
if n_edge > 0:
d = color_dist(rgb, paper_color)
close = (d < 30) & edge_mask
halo_ratio = float(close.sum()) / n_edge
else:
halo_ratio = 0.0
pix = rgb[alpha > 0.5]
color_clusters = None
if pix.shape[0] >= 4:
centers, labels, inertia = kmeans_np(pix.astype(np.float64), k=4)
counts = np.bincount(labels, minlength=4)
order = np.argsort(-counts)
color_clusters = [
{"center_rgb": [round(float(x), 1) for x in centers[o]], "pixel_count": int(counts[o])}
for o in order
]
h, w = alpha.shape
band = np.zeros((h, w), dtype=bool)
band[:CLIP_BAND_PX, :] = True
band[-CLIP_BAND_PX:, :] = True
band[:, :CLIP_BAND_PX] = True
band[:, -CLIP_BAND_PX:] = True
clip_count = int(((alpha > CLIP_ALPHA_CUT) & band).sum())
manifest_section.setdefault("qc", {})[name] = {
"background_leak_mean_alpha_outside_bbox": round(leak, 5),
"background_leak_pass": bool(leak < 0.01),
"halo_ratio_edge_pixels": round(halo_ratio, 5),
"halo_pass": bool(halo_ratio <= 0.02),
"clip_edge_band_px": CLIP_BAND_PX,
"clip_alpha_gt_0_05_count": clip_count,
"clip_pass": bool(clip_count == 0),
"color_clusters_k4": color_clusters,
}
def build_preview_backdrops(bud_names, weather_names):
backdrops = ["#ECE3D1", "#F1DEC2", "#DCE0E2", "#E6DAD3", "#E2E0D0"]
def hex_to_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
sprite_names = bud_names + weather_names
cell_w, cell_h = 360, 460 # 280x420 bud 캔버스가 여백 포함해 들어가도록
cols = len(backdrops)
rows = len(sprite_names)
sheet = Image.new("RGB", (cell_w * cols, cell_h * rows), (255, 255, 255))
for r, sname in enumerate(sprite_names):
sprite = Image.open(os.path.join(SPRITES_DIR, f"{sname}.png")).convert("RGBA")
sw, sh = sprite.size
scale = min((cell_w - 24) / sw, (cell_h - 24) / sh, 1.0)
disp = sprite.resize((max(1, int(sw * scale)), max(1, int(sh * scale))), Image.LANCZOS)
for c, bg_hex in enumerate(backdrops):
cell = Image.new("RGB", (cell_w, cell_h), hex_to_rgb(bg_hex))
px = (cell_w - disp.width) // 2
py = (cell_h - disp.height) // 2
cell.paste(disp, (px, py), disp)
sheet.paste(cell, (c * cell_w, r * cell_h))
long_side = max(sheet.size)
if long_side > 1400:
s = 1400.0 / long_side
sheet = sheet.resize((int(sheet.width * s), int(sheet.height * s)), Image.LANCZOS)
sheet.convert("RGB").save(os.path.join(PREVIEW_DIR, "sprites-on-backdrops.jpg"), quality=90)
def alpha_composite_over(base_rgb, sprite, opacity=1.0):
"""base_rgb: (H,W,3) float bg, sprite: PIL RGBA image same size. opacity 0..1 곱."""
sarr = np.asarray(sprite).astype(np.float64)
a = (sarr[..., 3] / 255.0) * opacity
rgb = sarr[..., :3]
out = base_rgb * (1 - a[..., None]) + rgb * a[..., None]
return out
def build_bud_crossfade():
bg_hex = "#ECE3D1"
bg = tuple(int(bg_hex.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
closed = Image.open(os.path.join(SPRITES_DIR, "bud-closed.png")).convert("RGBA")
half = Image.open(os.path.join(SPRITES_DIR, "bud-half.png")).convert("RGBA")
open_ = Image.open(os.path.join(SPRITES_DIR, "bud-open.png")).convert("RGBA")
droop = Image.open(os.path.join(SPRITES_DIR, "bud-droop.png")).convert("RGBA")
w, h = closed.size
base = np.tile(np.array(bg, dtype=np.float64), (h, w, 1))
def blend(a_img, b_img, t):
base_layer = alpha_composite_over(base.copy(), a_img, opacity=1.0)
out = alpha_composite_over(base_layer, b_img, opacity=t)
return out
cells = []
labels = []
for t in (0.25, 0.5, 0.75):
cells.append(blend(closed, half, t))
labels.append(f"closed->half {int(t*100)}%")
for t in (0.25, 0.5, 0.75):
cells.append(blend(half, open_, t))
labels.append(f"half->open {int(t*100)}%")
cells.append(blend(closed, droop, 0.5))
labels.append("closed->droop 50%")
pad = 10
cell_w, cell_h = w + pad * 2, h + pad * 2
cols = 4
rows = int(np.ceil(len(cells) / cols))
sheet = Image.new("RGB", (cell_w * cols, cell_h * rows), (255, 255, 255))
for i, cell_arr in enumerate(cells):
cell_img = Image.fromarray(np.clip(cell_arr, 0, 255).astype(np.uint8), mode="RGB")
r, c = divmod(i, cols)
sheet.paste(cell_img, (c * cell_w + pad, r * cell_h + pad))
sheet.save(os.path.join(PREVIEW_DIR, "bud-crossfade.jpg"), quality=90)
return labels
def build_bud_sizes():
"""4장을 표시 높이 96px, 48px로 줄여 실사용 크기에서 잘림·뭉개짐을 확인한다."""
bg_hex = "#ECE3D1"
bg = tuple(int(bg_hex.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
heights = [96, 48]
pad = 12
row_imgs = []
for target_h in heights:
cells = []
for name in BUD_PANEL_NAMES:
sprite = Image.open(os.path.join(SPRITES_DIR, f"{name}.png")).convert("RGBA")
sw, sh = sprite.size
new_w = max(1, round(sw * target_h / sh))
disp = sprite.resize((new_w, target_h), Image.LANCZOS)
cell = Image.new("RGB", (new_w + pad * 2, target_h + pad * 2), bg)
cell.paste(disp, (pad, pad), disp)
cells.append(cell)
row_w = sum(c.width for c in cells)
row_h = max(c.height for c in cells)
row = Image.new("RGB", (row_w, row_h), bg)
x = 0
for c in cells:
row.paste(c, (x, 0))
x += c.width
row_imgs.append(row)
total_w = max(r.width for r in row_imgs)
total_h = sum(r.height for r in row_imgs) + pad
sheet = Image.new("RGB", (total_w, total_h), (255, 255, 255))
y = 0
for r in row_imgs:
sheet.paste(r, (0, y))
y += r.height + pad
sheet.save(os.path.join(PREVIEW_DIR, "bud-sizes.jpg"), quality=90)
def build_sheets_raw_small():
imgs = []
for name in ["bud-sheet.png", "weather-sheet.png"]:
im = Image.open(os.path.join(RAW_DIR, name)).convert("RGB")
w, h = im.size
scale = 700.0 / w
im = im.resize((700, int(h * scale)), Image.LANCZOS)
imgs.append(im)
total_h = sum(i.height for i in imgs) + 10
max_w = max(i.width for i in imgs)
sheet = Image.new("RGB", (max_w, total_h), (255, 255, 255))
y = 0
for im in imgs:
sheet.paste(im, (0, y))
y += im.height + 10
sheet.save(os.path.join(PREVIEW_DIR, "sheets-raw-small.jpg"), quality=90)
def main():
manifest = {"bud": {}, "weather": {}}
bud_panels, bud_paper = process_sheet("bud-sheet.png", 4, "bud", manifest)
weather_panels, weather_paper = process_sheet("weather-sheet.png", 5, "weather", manifest)
build_bud_sprites(bud_panels, bud_paper, manifest)
build_weather_sprites(weather_panels, weather_paper, manifest)
for entry in manifest["bud"]["sprites"]:
qc_metrics(entry["name"], os.path.join(MOTIF_DIR, entry["path"]), bud_paper, manifest["bud"])
for entry in manifest["weather"]["sprites"]:
qc_metrics(entry["name"], os.path.join(MOTIF_DIR, entry["path"]), weather_paper, manifest["weather"])
bud_names = BUD_PANEL_NAMES
weather_names = WEATHER_PANEL_NAMES
build_preview_backdrops(bud_names, weather_names)
crossfade_labels = build_bud_crossfade()
build_bud_sizes()
build_sheets_raw_small()
manifest["preview"] = {
"sprites_on_backdrops": "preview/sprites-on-backdrops.jpg",
"bud_crossfade": "preview/bud-crossfade.jpg",
"bud_crossfade_cells": crossfade_labels,
"bud_sizes": "preview/bud-sizes.jpg",
"sheets_raw_small": "preview/sheets-raw-small.jpg",
}
with open(MANIFEST_PATH, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print("done")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -0,0 +1,689 @@
{
"faceOvalBBox": [
262.2,
392.8,
748.5,
929.7
],
"cropsFace": [
204.7,
360.6,
601.4,
601.4
],
"mouthCenter": [
491.6,
797.5
],
"palette": {
"ink": "#1E1F1F",
"sclera": "#D8CEBD",
"iris": "#4F3B2C",
"irisRing": "#1E1F1F",
"lipUpper": "#97684B",
"lipLower": "#AB7857",
"mouthLine": "#292421",
"mouthInner": "#3B201B",
"teeth": "#E9E0CF",
"blush": "#C0624A",
"tear": "#EEE5D3",
"pallor": "#9AA3A6",
"paper": "#EEE5D3",
"motifPetal": "#D0A362",
"motifLeaf": "#53626C"
},
"paletteSamples": {
"eyeLeft": {
"sclera": "#C19670",
"iris": "#372D26",
"irisRing": "#4F3E30",
"n": {
"sclera": 908,
"iris": 208,
"irisRing": 426
}
},
"eyeRight": {
"sclera": "#C1976F",
"iris": "#382D26",
"irisRing": "#463A2E",
"n": {
"sclera": 889,
"iris": 205,
"irisRing": 422
}
},
"scleraIrisIrisRingFixedOverride": {
"reason": "고정값(오케스트레이터 결정), 측정 참고값: sclera 중앙값 #A5917A, 홍채 중간 링 #3E3028",
"measured": {
"sclera": "#C1966F",
"iris": "#382D26",
"irisRing": "#4A3C2F"
},
"fixed": {
"sclera": "#D8CEBD",
"iris": "#4F3B2C",
"irisRing": "#1E1F1F"
}
},
"ink": {
"box": [
280,
100,
720,
350
],
"lumThreshold": 55,
"n": 80222,
"hex": "#1E1F1F"
},
"lipUpper": {
"box": [
417,
770,
572,
795
],
"hex": "#97684B"
},
"lipLower": {
"box": [
417,
799,
572,
834
],
"hex": "#AB7857"
},
"mouthLine": {
"box": [
437,
795,
552,
800
],
"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"
},
"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"
},
"hex": "#53626C"
}
},
"publish": {
"fileSizes": {
"body": 130646,
"head": 142134,
"hair-front": 28314,
"face-detail": 35128,
"paper-grain": 1428
},
"encodeMeta": {
"quality": 80,
"scale": 1.0,
"totalBytes": 337650
},
"totalBytes": 337650
},
"grain": {
"href": "/avatar/v3/p1/paper-grain.webp",
"size": 256
},
"lipTexturePublish": {
"quality": "lossless",
"fileSizes": {
"lip-upper": 15904,
"lip-lower": 14630,
"lip-shadow": 14868
},
"totalBytes": 45402,
"budgetBytes": 81920,
"webpVsPngMeanAbsDiff": {
"upper": 0.0,
"lower": 0.0,
"shadow": 0.0
}
},
"jawPublish": {
"quality": 80,
"scale": 1.0,
"fileSizes": {
"jaw-head": 24580,
"jaw-detail": 15488
},
"totalBytes": 40068,
"budgetBytes": 204800,
"vsPublishedLayerAbsDiff": {
"head": {
"comparedAgainstPublishedLayer": "head.webp",
"meanAbsDiffPremultipliedRgb": 3.131,
"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 자체의 압축 오차만큼은 남아 이득이 없다(비교 대상 자체가 손실 압축본)."
},
"detail": {
"comparedAgainstPublishedLayer": "face-detail.webp",
"meanAbsDiffPremultipliedRgb": 0.565,
"maxAbsDiffPremultipliedRgb": 25.0,
"meanAbsDiffAlpha": 0.0,
"maxAbsDiffAlpha": 0.0,
"meetsTarget": true
}
}
},
"motifPublish": {
"quality": 82,
"fileSizes": {
"bud-closed": 14576,
"bud-half": 14932,
"bud-open": 18278,
"bud-droop": 14102,
"weather-positive": 7594,
"weather-negative": 13262,
"weather-defensive": 14886,
"weather-cognitive": 7790,
"weather-energy": 7132
},
"totalBytes": 112552,
"budgetBytes": 163840,
"halo": {
"bud-closed": 0.09157509157509157,
"bud-half": 0.09174311926605505,
"bud-open": 0.06657789613848203,
"bud-droop": 0.205761316872428,
"weather-positive": 0.0,
"weather-negative": 0.0,
"weather-defensive": 0.0,
"weather-cognitive": 0.0,
"weather-energy": 0.2890173410404624
},
"haloThreshold": 2.0
},
"rig": {
"schemaVersion": "vignette.avatar.v3.rig.v1",
"persona": "P1",
"canvas": {
"w": 1005,
"h": 1566
},
"layers": {
"body": {
"href": "/avatar/v3/p1/body.webp",
"x": 0,
"y": 807,
"w": 1005,
"h": 759
},
"head": {
"href": "/avatar/v3/p1/head.webp",
"x": 111,
"y": 72,
"w": 849,
"h": 1049
},
"hairFront": {
"href": "/avatar/v3/p1/hair-front.webp",
"x": 260,
"y": 391,
"w": 491,
"h": 516
},
"faceDetail": {
"href": "/avatar/v3/p1/face-detail.webp",
"x": 284,
"y": 444,
"w": 437,
"h": 478
}
},
"grain": {
"href": "/avatar/v3/p1/paper-grain.webp",
"size": 256
},
"lipTexture": {
"upper": {
"href": "/avatar/v3/p1/lip-upper.webp",
"x": 404,
"y": 758,
"w": 182,
"h": 54
},
"lower": {
"href": "/avatar/v3/p1/lip-lower.webp",
"x": 404,
"y": 798,
"w": 182,
"h": 49
},
"shadow": {
"href": "/avatar/v3/p1/lip-shadow.webp",
"x": 414,
"y": 802,
"w": 162,
"h": 53
}
},
"jaw": {
"head": {
"href": "/avatar/v3/p1/jaw-head.webp",
"x": 260,
"y": 653,
"w": 500,
"h": 307
},
"detail": {
"href": "/avatar/v3/p1/jaw-detail.webp",
"x": 260,
"y": 653,
"w": 500,
"h": 307
}
},
"motif": {
"bud": {
"canvas": {
"w": 280,
"h": 420
},
"base": [
134.23,
399.0
],
"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"
},
"weather": {
"canvas": {
"w": 320,
"h": 200
},
"sprites": {
"positive": "/avatar/v3/p1/motif/weather-positive.webp",
"negative": "/avatar/v3/p1/motif/weather-negative.webp",
"defensive": "/avatar/v3/p1/motif/weather-defensive.webp",
"cognitive": "/avatar/v3/p1/motif/weather-cognitive.webp",
"energy": "/avatar/v3/p1/motif/weather-energy.webp"
}
}
},
"pivots": {
"neck": [
500,
990
],
"body": [
502,
1566
],
"face": [
490,
660
]
},
"crops": {
"portrait": [
0,
0,
1005,
1566
],
"bust": [
0,
40,
1005,
1005
],
"face": [
204.65089959816135,
360.5630198053121,
601.3858226852418,
601.3858226852418
]
},
"faceOval": [
[
481.0,
392.8
],
[
551.8,
393.6
],
[
612.4,
402.3
],
[
669.7,
421.4
],
[
707.5,
450.3
],
[
731.2,
487.1
],
[
743.8,
526.1
],
[
748.5,
573.7
],
[
745.6,
619.2
],
[
739.1,
666.9
],
[
727.3,
716.7
],
[
709.7,
769.5
],
[
687.0,
813.6
],
[
661.3,
846.5
],
[
629.6,
874.9
],
[
603.3,
894.4
],
[
574.9,
912.1
],
[
542.1,
926.0
],
[
500.6,
929.7
],
[
461.5,
924.7
],
[
432.5,
910.1
],
[
408.0,
891.9
],
[
384.7,
872.6
],
[
356.2,
844.9
],
[
333.7,
812.9
],
[
312.5,
770.3
],
[
294.2,
718.1
],
[
281.0,
668.9
],
[
273.0,
622.3
],
[
265.1,
577.6
],
[
262.2,
530.6
],
[
267.6,
491.4
],
[
282.1,
455.1
],
[
310.0,
426.1
],
[
357.1,
405.8
],
[
412.2,
395.7
]
],
"landmarks": {
"eyeLeft": {
"inner": [
430.9,
592.2
],
"outer": [
339.1,
578.8
],
"upperLidTop": [
376.3,
564.3
],
"lowerLidBottom": [
381.1,
598.2
],
"iris": {
"center": [
387.3,
579.1
],
"radius": 21.4
}
},
"eyeRight": {
"inner": [
552.8,
590.9
],
"outer": [
648.0,
575.5
],
"upperLidTop": [
607.1,
562.1
],
"lowerLidBottom": [
603.9,
595.0
],
"iris": {
"center": [
601.9,
576.2
],
"radius": 21.3
}
},
"browLeft": {
"inner": [
450.6,
525.1
],
"peak": [
360.1,
510.4
],
"outer": [
313.4,
513.3
]
},
"browRight": {
"inner": [
518.2,
521.7
],
"peak": [
626.8,
508.2
],
"outer": [
682.8,
510.7
]
},
"noseTip": [
486.6,
713.0
],
"mouthCornerLeft": [
422.1,
804.7
],
"mouthCornerRight": [
567.5,
805.8
],
"upperLipTop": [
489.8,
773.4
],
"lowerLipBottom": [
493.0,
831.5
],
"mouthCenter": [
491.6,
797.5
],
"chinTip": [
500.6,
919.5
]
},
"palette": {
"ink": "#1E1F1F",
"sclera": "#D8CEBD",
"iris": "#4F3B2C",
"irisRing": "#1E1F1F",
"lipUpper": "#97684B",
"lipLower": "#AB7857",
"mouthLine": "#292421",
"mouthInner": "#3B201B",
"teeth": "#E9E0CF",
"blush": "#C0624A",
"tear": "#EEE5D3",
"pallor": "#9AA3A6",
"paper": "#EEE5D3",
"motifPetal": "#D0A362",
"motifLeaf": "#53626C"
},
"backdrop": {
"cognitive": "#ECE3D1",
"positive": "#F1DEC2",
"negative": "#DCE0E2",
"defensive": "#E6DAD3",
"energy": "#E2E0D0"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

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 body.png:
Keep only the neck, the white t-shirt and the charcoal hoodie exactly as they are in the reference: same shapes, same position, same linocut relief-print style, same ink colors and texture. Remove the head, the face, the ears and all of the hair completely. Where the hair or the chin previously covered the neck, the t-shirt or the hoodie, continue them naturally in the same style so there are no holes. Place the result on a plain flat pure green background (#00ff00) with 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 hair-front.png:
Keep only the strands of hair that lie in front of the face: the bangs and the front locks that overlap the forehead, the temples and the cheeks, exactly as they are in the reference, with the same shapes, position, linocut relief-print style and ink colors. Remove everything else completely: the back hair, the top of the head, the face, the skin, the ears, the neck and the hoodie. Place the result on a plain flat pure green background (#00ff00) with 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 head.png:
Keep only the head exactly as it is in the reference: the blank face without eyes, eyebrows or mouth (plain skin in the same flat skin color), the nose, the face outline, the ears and all of the hair attached to the head, with the same shapes, position, linocut relief-print style and ink colors. Remove the neck below the jawline, the t-shirt and the hoodie completely. Place the result on a plain flat pure green background (#00ff00) with nothing else, no text.

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

View file

@ -0,0 +1,49 @@
"""단독 프로세스로 FaceLandmarker를 실행해 478 랜드마크를 JSON으로 출력한다.
(같은 프로세스에서 ImageSegmenter와 함께 쓰면 세그폴트가 재현되어 분리했다.)
사용: python _run_face_landmarks.py <이미지경로> <출력json경로>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from PIL import Image
MODEL_FACE = Path(__file__).resolve().parent / "_models" / "face_landmarker.task"
def main() -> int:
image_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_FACE))
options = vision.FaceLandmarkerOptions(
base_options=base_options, running_mode=vision.RunningMode.IMAGE, num_faces=1
)
im = Image.open(image_path).convert("RGB")
w, h = im.size
arr = np.array(im)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
with vision.FaceLandmarker.create_from_options(options) as landmarker:
result = landmarker.detect(mp_image)
if not result.face_landmarks:
out_path.write_text(json.dumps({"ok": False}), encoding="utf-8")
print("FACE_LANDMARKS_FAILED")
return 1
lm = result.face_landmarks[0]
pts = [[p.x * w, p.y * h] for p in lm]
out_path.write_text(json.dumps({"ok": True, "width": w, "height": h, "points": pts}), encoding="utf-8")
print(f"FACE_LANDMARKS_OK n={len(pts)}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,45 @@
"""단독 프로세스로 ImageSegmenter(selfie_multiclass_256x256)를 실행해
category_mask를 .npy로 저장한다. (세그폴트 회피를 위해 FaceLandmarker와 분리.)
사용: python _run_segmentation.py <이미지경로> <출력npy경로>
"""
from __future__ import annotations
import sys
from pathlib import Path
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from PIL import Image
MODEL_SEG = Path(__file__).resolve().parent / "_models" / "selfie_multiclass_256x256.tflite"
def main() -> int:
image_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_SEG))
options = vision.ImageSegmenterOptions(
base_options=base_options, output_confidence_masks=False, output_category_mask=True
)
im = Image.open(image_path).convert("RGB")
arr = np.array(im)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
with vision.ImageSegmenter.create_from_options(options) as seg:
result = seg.segment(mp_image)
if result.category_mask is None:
print("SEGMENTATION_FAILED")
return 1
category_mask = result.category_mask.numpy_view()[:, :, 0].copy()
np.save(out_path, category_mask)
print(f"SEGMENTATION_OK shape={category_mask.shape}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,369 @@
"""P1 서연 눈썹 랜드마크를 잉크 띠 중심선으로 정정 — 2단계-B-1d-A(재작업).
1차(반려)는 front-F 밝기 차이만으로 마스크를 만들어 눈두덩 그늘 해칭까지
눈썹으로 잡았고, 열별 지역 평활(Savitzky-Golay)이 평평한 눈썹에서 물결치며
peak를 안쪽 끝 열에 찍었다(오케스트레이터 판정, 2단계-B-1d).
이번 버전:
- 마스크 = (front 휘도가 눈썹 bbox 안 Otsu 임계값 이하, 짙은 잉크) AND
(front가 F보다 25 이상 어두움, 머리카락·눈꺼풀 크레이스 등 F에도 있는
구조물 제외). 두 조건의 교집합이라 중간 톤 그늘 해칭은 Otsu 임계값보다
밝아 빠진다.
- 눈썹은 조각도 해칭 때문에 잉크가 여러 조각으로 끊겨 있다(단일 최대
연결성분만 쓰면 절반 가까이 누락됨을 확인). 그래서 형태학적 closing으로
같은 눈썹에 속한 조각을 하나로 묶은 뒤, 그 묶음 영역 안의 "원본"(닫기
전) 마스크 픽셀만 최종 띠로 쓴다(닫기는 성분을 찾는 데만 쓰고 픽셀을
부풀리지 않는다). 그 안에서 면적 30px 미만 잔점은 버린다.
- 중심선은 전역 2차 다항식 하나를 열별 가중 무게중심에 적합한다(지역
평활 대신 — 평평한 눈썹에서 물결을 만들지 않는다). 잔차 2배 표준편차를
넘는 이상치 열을 한 번 제거하고 재적합한다.
- peak는 적합 곡선의 꼭짓점(2차 다항식은 극값이 하나뿐이다)이 바깥 끝
기준 25~60% 구간에 있으면 그 점, 아니면(구간 밖 = 그 구간에서 단조,
또는 구간 안 높이 차 < 3px = 거의 평평) 바깥 끝에서 35% 지점의 곡선
위 점을 쓴다.
실행: <venv>/python.exe build_brow_centerline.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
SCRIPTS_DIR = Path(__file__).resolve().parent
ROOT = SCRIPTS_DIR.parent
BASE_DIR = ROOT / "base"
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
BBOX_X_PAD = 25.0
BBOX_Y_UP = 42.0
BBOX_Y_DOWN = 14.0 # 30이면 눈꺼풀 크레이스(다크서클 경계) 잉크가 섞인다(탐색 확인).
DIFF_THRESH = 25.0 # front가 F보다 이만큼 어두우면 "front에만 있는 것"으로 본다
CLOSING_KERNEL = (7, 11) # (세로, 가로) — 눈썹 해칭 조각을 같은 띠로 묶기 위한 형태학적 닫기
MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보다 작은 잔점은 버린다
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
PEAK_FALLBACK_FRAC = 0.35
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
PEAK_FAIL_EDGE_FRAC = 0.20 # 최종 peak가 양끝 이 비율 안이면 실패
CHECK_A_MIN_FRAC = 0.80
EVIDENCE_MAX_SIDE = 1000
EVIDENCE_ZOOM = 3
def region_bbox_from_points(inner, peak, outer) -> tuple[int, int, int, int]:
xs = [inner[0], peak[0], outer[0]]
ys = [inner[1], peak[1], outer[1]]
x0 = int(round(min(xs) - BBOX_X_PAD))
x1 = int(round(max(xs) + BBOX_X_PAD))
y0 = int(round(min(ys) - BBOX_Y_UP))
y1 = int(round(max(ys) + BBOX_Y_DOWN))
return x0, y0, x1, y1
def build_ink_band(front_lum: np.ndarray, diff: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> tuple[np.ndarray, float]:
"""bbox 안에서 (Otsu 잉크) AND (front가 F보다 어두움) 마스크를 만들고,
형태학적 닫기로 같은 눈썹 조각을 묶은 최대 성분 안의 원본 픽셀만
남긴 뒤 작은 잔점을 버린다. 반환: (band bool 배열(bbox 로컬 좌표), otsu 임계값)."""
region_lum = front_lum[y0:y1, x0:x1]
u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8)
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
ink_mask = region_lum <= otsu_thresh
diff_region = diff[y0:y1, x0:x1]
diff_mask = diff_region >= DIFF_THRESH
combined = ink_mask & diff_mask
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
labeled, n = ndimage.label(closed)
if n == 0:
raise SystemExit("[중단] 눈썹 bbox 안에 잉크 마스크가 전혀 없다.")
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
top_label = int(np.argmax(sizes)) + 1
band = combined & (labeled == top_label)
lbl2, n2 = ndimage.label(band)
if n2 > 0:
sizes2 = ndimage.sum(band, lbl2, range(1, n2 + 1))
keep_labels = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
band = band & np.isin(lbl2, keep_labels)
return band, float(otsu_thresh)
def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int, y0: int) -> dict:
colmask = band.any(axis=0)
cols = np.where(colmask)[0]
col_min, col_max = int(cols.min()), int(cols.max())
xs_local = np.arange(col_min, col_max + 1)
centroid_y = np.full(len(xs_local), np.nan)
col_ylo = np.full(len(xs_local), np.nan)
col_yhi = np.full(len(xs_local), np.nan)
for i, cx in enumerate(xs_local):
colpix = band[:, cx]
if not colpix.any():
continue
yy = np.where(colpix)[0]
w = diff_region[yy, cx]
centroid_y[i] = np.average(yy, weights=w) + y0
col_ylo[i] = yy.min() + y0
col_yhi[i] = yy.max() + y0
return {
"col_min": col_min + x0, "col_max": col_max + x0,
"xs_local": xs_local, "centroid_y": centroid_y,
"col_ylo": col_ylo, "col_yhi": col_yhi, "x0": x0,
}
def fit_centerline(xs_abs: np.ndarray, ys: np.ndarray) -> tuple[np.poly1d, int]:
coef = np.polyfit(xs_abs, ys, 2)
fit = np.poly1d(coef)
resid = ys - fit(xs_abs)
std = resid.std()
outlier = np.abs(resid) > OUTLIER_STD_MULT * std if std > 0 else np.zeros_like(resid, dtype=bool)
n_outliers = int(outlier.sum())
if n_outliers > 0 and (~outlier).sum() >= 3:
coef2 = np.polyfit(xs_abs[~outlier], ys[~outlier], 2)
fit = np.poly1d(coef2)
return fit, n_outliers
def find_peak(fit: np.poly1d, col_min: int, col_max: int, outer_edge_x: float, width: float) -> tuple[tuple[float, float], str]:
a, b, _c = fit.coeffs
xs_sample = np.linspace(col_min, col_max, 400)
t_sample = np.abs(xs_sample - outer_edge_x) / width
in_window = (t_sample >= PEAK_WINDOW[0]) & (t_sample <= PEAK_WINDOW[1])
use_fallback = True
vertex_pt: tuple[float, float] | None = None
if abs(a) > 1e-6:
xv = -b / (2 * a)
if col_min <= xv <= col_max:
tv = abs(xv - outer_edge_x) / width
if PEAK_WINDOW[0] <= tv <= PEAK_WINDOW[1]:
window_ys = fit(xs_sample[in_window])
height_diff = float(window_ys.max() - window_ys.min()) if window_ys.size else 0.0
if height_diff >= PEAK_FLAT_HEIGHT_PX:
vertex_pt = (float(xv), float(fit(xv)))
use_fallback = False
if use_fallback:
# 바깥 끝에서 안쪽으로 35% 지점
if outer_edge_x <= col_min + 1e-6:
fx = col_min + PEAK_FALLBACK_FRAC * width
else:
fx = col_max - PEAK_FALLBACK_FRAC * width
peak_pt = (float(fx), float(fit(fx)))
method = "fallback35"
else:
peak_pt = vertex_pt # type: ignore[assignment]
method = "vertex"
return peak_pt, method
def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: dict) -> dict:
inner_old, peak_old, outer_old = old_pts["inner"], old_pts["peak"], old_pts["outer"]
x0, y0, x1, y1 = region_bbox_from_points(inner_old, peak_old, outer_old)
band, otsu_thresh = build_ink_band(front_lum, diff, x0, y0, x1, y1)
diff_region = diff[y0:y1, x0:x1]
cw = column_weighted_centroid(band, diff_region, x0, y0)
valid = ~np.isnan(cw["centroid_y"])
xs_abs = cw["xs_local"][valid] + x0
ys_valid = cw["centroid_y"][valid]
fit, n_outliers = fit_centerline(xs_abs, ys_valid)
col_min, col_max = cw["col_min"], cw["col_max"]
width = float(col_max - col_min)
inset_px = width * EDGE_INSET_FRAC
left_is_outer = outer_old[0] < inner_old[0]
if left_is_outer:
outer_edge_x = float(col_min)
inner_edge_x = float(col_max)
outer_x = col_min + inset_px
inner_x = col_max - inset_px
else:
outer_edge_x = float(col_max)
inner_edge_x = float(col_min)
outer_x = col_max - inset_px
inner_x = col_min + inset_px
inner_pt = (round(float(inner_x), 2), round(float(fit(inner_x)), 2))
outer_pt = (round(float(outer_x), 2), round(float(fit(outer_x)), 2))
peak_pt_raw, peak_method = find_peak(fit, col_min, col_max, outer_edge_x, width)
peak_pt = (round(peak_pt_raw[0], 2), round(peak_pt_raw[1], 2))
peak_frac = abs(peak_pt_raw[0] - outer_edge_x) / width
peak_fail = peak_frac < PEAK_FAIL_EDGE_FRAC or peak_frac > (1.0 - PEAK_FAIL_EDGE_FRAC)
# 검사(a): 열의 80% 이상에서 적합 곡선 y가 그 열 마스크의 위-아래 끝 사이
n_ok = 0
n_total = int(valid.sum())
for i in range(len(cw["xs_local"])):
if not valid[i]:
continue
cx_abs = cw["xs_local"][i] + x0
fy = float(fit(cx_abs))
if cw["col_ylo"][i] <= fy <= cw["col_yhi"][i]:
n_ok += 1
frac_ok = n_ok / n_total if n_total else 0.0
return {
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
"colRange": [col_min, col_max], "width": width,
"nColumns": n_total, "nOutliersRemoved": n_outliers,
"fitCoeffs": [round(float(c), 8) for c in fit.coeffs],
"inner": inner_pt, "peak": peak_pt, "outer": outer_pt,
"peakMethod": peak_method, "peakFracFromOuterEdge": round(float(peak_frac), 4),
"peakFail": bool(peak_fail),
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
"band": band, "bandX0": x0, "bandY0": y0,
"fit": fit, "colMin": col_min, "colMax": col_max,
}
def draw_evidence_panel(front_img: Image.Image, old_pts: dict, result: dict) -> Image.Image:
x0, y0, x1, y1 = result["bbox"]
pad = 15
box = (max(0, x0 - pad), max(0, y0 - pad), x1 + pad, y1 + pad)
crop = front_img.crop(box).convert("RGB")
crop = crop.resize((crop.width * EVIDENCE_ZOOM, crop.height * EVIDENCE_ZOOM), Image.LANCZOS)
d = ImageDraw.Draw(crop)
ox, oy = box[0], box[1]
def to_panel(px, py):
return ((px - ox) * EVIDENCE_ZOOM, (py - oy) * EVIDENCE_ZOOM)
# 마스크 윤곽선(노랑)
band_u8 = (result["band"].astype(np.uint8)) * 255
contours, _ = cv2.findContours(band_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
bx0, by0 = result["bandX0"], result["bandY0"]
for cnt in contours:
pts = [to_panel(p[0][0] + bx0, p[0][1] + by0) for p in cnt]
if len(pts) >= 2:
d.line(pts + [pts[0]], fill=(230, 200, 0), width=2)
# 적합 중심선(청록)
fit = result["fit"]
xs_line = np.linspace(result["colMin"], result["colMax"], 200)
ys_line = fit(xs_line)
line_pts = [to_panel(x, y) for x, y in zip(xs_line, ys_line)]
d.line(line_pts, fill=(0, 200, 200), width=2)
for key in ("inner", "peak", "outer"):
ox_pt, oy_pt = old_pts[key]
px, py = to_panel(ox_pt, oy_pt)
d.ellipse([px - 5, py - 5, px + 5, py + 5], outline=(230, 30, 30), width=2)
nx_pt, ny_pt = result[key]
px2, py2 = to_panel(nx_pt, ny_pt)
d.ellipse([px2 - 5, py2 - 5, px2 + 5, py2 + 5], fill=(0, 220, 220))
return crop
def main() -> int:
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_lum = front.mean(axis=2)
faceless_lum = faceless.mean(axis=2)
diff = np.clip(faceless_lum - front_lum, 0.0, None)
# 재실행 멱등성: 이 스크립트가 landmarks.eyebrowLeft/Right를 덮어쓰므로,
# 진짜 mediapipe 원본은 browCenterline.oldPoints에 한 번 고정해 두고
# 재실행 시 거기서 읽는다(자기가 쓴 결과를 다시 원본으로 오인하지 않는다).
prev_old_points = manifest.get("browCenterline", {}).get("oldPoints")
if prev_old_points is not None:
browL_old = prev_old_points["browLeft"]
browR_old = prev_old_points["browRight"]
else:
browL_old = lm["eyebrowLeft"]
browR_old = lm["eyebrowRight"]
resultL = process_brow("browLeft", front_lum, diff, browL_old)
resultR = process_brow("browRight", front_lum, diff, browR_old)
for result, old_pts in ((resultL, browL_old), (resultR, browR_old)):
print(f"=== {result['name']} ===")
print(f" bbox={result['bbox']} otsuThresh={result['otsuThresh']} colRange={result['colRange']}")
print(f" fit={result['fitCoeffs']} outliers제거={result['nOutliersRemoved']}/{result['nColumns']}")
print(f" old inner={old_pts['inner']} peak={old_pts['peak']} outer={old_pts['outer']}")
print(f" new inner={result['inner']} peak={result['peak']}({result['peakMethod']}) outer={result['outer']}")
print(f" 검사(a) 열 포함 비율 = {result['checkA_fracColumnsFitWithinMask']} (기준>=0.80) {'OK' if result['checkA_pass'] else '[실패]'}")
print(f" peak 위치비율(바깥끝기준) = {result['peakFracFromOuterEdge']} {'[실패:양끝20% 안]' if result['peakFail'] else 'OK'}")
dropL = resultL["inner"][1] - resultL["peak"][1]
dropR = resultR["inner"][1] - resultR["peak"][1]
print(f"검사(b) browLeft inner.y-peak.y = {dropL:.2f} (예상 0~20)")
print(f"검사(b) browRight inner.y-peak.y = {dropR:.2f} (예상 0~20)")
inner_y_symmetry = abs(resultL["inner"][1] - resultR["inner"][1])
peak_y_symmetry = abs(resultL["peak"][1] - resultR["peak"][1])
print(f"검사(d) 좌우 inner.y 차 = {inner_y_symmetry:.2f}")
print(f"검사(d) 좌우 peak.y 차 = {peak_y_symmetry:.2f}")
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")
panelL = draw_evidence_panel(front_img, browL_old, resultL)
panelR = draw_evidence_panel(front_img, browR_old, resultR)
gap = 20
combined = Image.new("RGB", (panelL.width + panelR.width + gap, max(panelL.height, panelR.height)), (255, 255, 255))
combined.paste(panelL, (0, 0))
combined.paste(panelR, (panelL.width + gap, 0))
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"
combined.convert("RGB").save(evidence_path, "JPEG", quality=90)
print(f"저장: {evidence_path}")
lm["eyebrowLeft"] = {"inner": list(resultL["inner"]), "peak": list(resultL["peak"]), "outer": list(resultL["outer"])}
lm["eyebrowRight"] = {"inner": list(resultR["inner"]), "peak": list(resultR["peak"]), "outer": list(resultR["outer"])}
def strip_for_json(r: dict) -> dict:
return {k: v for k, v in r.items() if k not in ("band", "fit")}
manifest["browCenterline"] = {
"method": "Otsu(front lum in bbox) AND diff(F-front)>=25, morphological closing to merge hatching, "
"single global degree-2 polyfit on column-weighted centroid with one-pass outlier removal",
"diffThresh": DIFF_THRESH,
"bboxPad": {"x": BBOX_X_PAD, "yUp": BBOX_Y_UP, "yDown": BBOX_Y_DOWN},
"closingKernel": list(CLOSING_KERNEL),
"minComponentAreaPx": MIN_COMPONENT_AREA,
"outlierStdMult": OUTLIER_STD_MULT,
"edgeInsetFrac": EDGE_INSET_FRAC,
"peakWindow": list(PEAK_WINDOW),
"peakFallbackFrac": PEAK_FALLBACK_FRAC,
"peakFlatHeightPx": PEAK_FLAT_HEIGHT_PX,
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
"checkB_innerMinusPeakY": {"browLeft": round(dropL, 2), "browRight": round(dropR, 2)},
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
"evidenceImage": "preview/v2/brow-centerline.jpg",
}
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())

View file

@ -0,0 +1,740 @@
"""P1 서연 faceDetail 레이어 생성 — 2단계-B-1a (3), 결정문 §8.2.
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
픽셀을 그대로 쓴다(비율 보간·블러 없음). 제외 영역 밖은 base-front를 그대로
쓴다(다크서클 해칭·입가 음영 등 살려야 할 그늘은 그대로 남는다). 제외 영역
경계는 10px 페더로 base-front↔F를 섞는다.
F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으로 얇은 성분만
추출)만 복제 도장으로 지운다 — 넓고 부드러운 그늘(브로우뼈 음영 등)은 F의
진짜 결이므로 건드리지 않는다.
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
실행: <venv>/python.exe build_face_detail.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import (
binary_closing, binary_dilation, binary_erosion, binary_fill_holes, binary_opening,
distance_transform_edt, gaussian_filter, label,
)
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"
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
REGION_FEATHER_PX = 12.0
MOLE_CENTER = (661.3, 627.9)
MOLE_RADIUS = 20.0
# --- 제외 영역(구멍) 상수: 이목구비 잉크선을 덮는 좁은 범위 ------------------
EYE_INK_MARGIN = 32.0
EYE_INK_THRESH = 150.0
EYE_CREASE_UP_PX = 18 # 쌍꺼풀 주름 커버(위로)
EYE_OUTER_EXT_PX = 12 # 바깥 꼬리 커버
EYE_FINAL_DILATE_PX = 6 # 위/바깥/눈 틈에 적용(아래쪽은 별도)
# 아랫눈꺼풀은 선 자체만 좁게 덮는다 — 다크서클 해칭(그 아래 살려야 할 그늘)을
# 먹지 않기 위해 위/바깥과는 다른(더 좁은) 팽창·페더를 쓴다.
EYE_LOWER_LINE_ABOVE_PX = 2.0
EYE_LOWER_LINE_BELOW_PX = 5.0
EYE_LOWER_DILATE_PX = 2
EYE_LOWER_FEATHER_PX = 4.0
EYE_INK_LOWER_CAP_PX = 7.0 # eye_dark_hole 연결요소가 다크서클 해칭까지 붙어 나오는 것을 자른다
DARK_CIRCLE_BAND_Y0 = 10.0 # 아랫눈꺼풀 아래 10~35px
DARK_CIRCLE_BAND_Y1 = 35.0
DARK_CIRCLE_BAND_HALF_W = 35.0 # 홍채 중심 x ±35px
DARK_CIRCLE_MATCH_TOLERANCE = 6.0
BROW_INK_MARGIN = 20.0
BROW_INK_THRESH = 150.0
BROW_STROKE_HALF_WIDTH = 14 # 랜드마크 곡선 ±14px 띠
BROW_FINAL_DILATE_PX = 10
MOUTH_INK_MARGIN = 15.0
MOUTH_INK_THRESH = 150.0
MOUTH_CORNER_EXT_PX = 12
MOUTH_SHADOW_EXT_PX = 24 # 아랫입술 아래 그늘선 커버
MOUTH_FINAL_DILATE_PX = 8
EXCLUSION_BOUNDARY_FEATHER_PX = 10.0
HAIR_DILATE_PX = 2
# --- 잔여 잉크(선 모양만) 복제 도장 상수 -------------------------------------
CLONE_TARGET_THRESH = 65.0
AVOID_THRESH = 110.0
LINE_OPENING_ITER = 3
CLONE_STAMP_FEATHER_PX = 4.0
CLONE_STAMP_CANDIDATES = [
(0, -40), (0, -60), (-40, 0), (40, 0), (0, 40), (0, 60),
(-60, 0), (60, 0), (0, -80), (0, 80), (-40, -40), (40, -40), (-40, 40), (40, 40),
(0, -100), (0, -120), (0, -150), (0, -180), (0, 100), (0, 120),
(-80, 0), (80, 0), (-100, 0), (100, 0), (-120, -60), (120, -60), (-60, -100), (60, -100),
]
# --- 검사 상수 ---------------------------------------------------------------
RING_PX = 12
HF_SIGMA = 2.0
LINE_SIGMA = 3.0
CHECKD_THRESH = 0.7
CHECKE_THRESH = 1.5
GENERIC_PATCH_SIZE = 48
GENERIC_PATCH_MARGIN = 40
def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray:
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
d = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2
alpha = (d <= 1.0).astype(np.float64) * 255.0
alpha = gaussian_filter(alpha, sigma=feather / 2.0)
return np.clip(alpha, 0, 255)
def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray:
img = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(img)
d.line([tuple(outer), tuple(peak), tuple(inner)], fill=255, width=width, joint="curve")
d.ellipse([outer[0] - width / 2, outer[1] - width / 2, outer[0] + width / 2, outer[1] + width / 2], fill=255)
d.ellipse([inner[0] - width / 2, inner[1] - width / 2, inner[0] + width / 2, inner[1] + width / 2], fill=255)
return np.array(img) > 127
def lens_polygon(inner, outer, upper, lower, pad: float, n: int = 24) -> list[tuple[float, float]]:
def quad_bezier(p0, p1, p2, n):
ts = np.linspace(0, 1, n)
pts = []
for t in ts:
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
pts.append((x, y))
return pts
upper_ctrl = (upper[0], upper[1] - pad)
lower_ctrl = (inner[0], lower[1] + pad)
upper_curve = quad_bezier(outer, upper_ctrl, inner, n)
lower_curve = quad_bezier(inner, lower_ctrl, outer, n)
return upper_curve + lower_curve
def nearest_component_label(labeled: np.ndarray, py: float, px: float) -> int:
"""(py,px) 위치의 라벨을 쓰되, 그 지점이 어떤 연결요소에도 안 속하면(랜드마크가
잉크 픽셀에서 살짝 벗어난 경우) 가장 가까운 연결요소의 라벨을 대신 쓴다."""
h, w = labeled.shape
iy, ix = int(round(py)), int(round(px))
iy = min(max(iy, 0), h - 1)
ix = min(max(ix, 0), w - 1)
seed_label = int(labeled[iy, ix])
if seed_label != 0:
return seed_label
if not (labeled != 0).any():
return 0
_, (near_y, near_x) = distance_transform_edt(labeled == 0, return_indices=True)
return int(labeled[near_y[iy, ix], near_x[iy, ix]])
def eye_dark_hole(
eye: dict, lum: np.ndarray, w: int, h: int, margin: float = EYE_INK_MARGIN,
thresh: float = EYE_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""눈 틈(공막·홍채·동공·아이라이너 잉크) 전체를 명도 임계값 + 연결요소로 찾는다."""
lens = lens_polygon(eye["innerCorner"], eye["outerCorner"], eye["upperLidTop"], eye["lowerLidBottom"], pad=6.0)
xs = [p[0] for p in lens]
ys = [p[1] for p in lens]
x0, x1 = min(xs) - margin, max(xs) + margin
y0, y1 = min(ys) - margin, max(ys) + margin
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
icx, icy = eye["iris"]["center"]
seed_label = nearest_component_label(labeled, icy, icx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def lower_lid_line_mask(
inner, outer, lower, w: int, h: int, above: float = EYE_LOWER_LINE_ABOVE_PX, below: float = EYE_LOWER_LINE_BELOW_PX
) -> np.ndarray:
"""아랫눈꺼풀 선(랜드마크 곡선) 자체만 위로 above px, 아래로 below px 두께로
감싸는 좁은 띠를 만든다 — 다크서클 해칭을 먹지 않기 위해 이 아래는 손대지 않는다."""
n = 40
ts = np.linspace(0, 1, n)
curve = [
((1 - t) ** 2 * outer[0] + 2 * (1 - t) * t * lower[0] + t ** 2 * inner[0],
(1 - t) ** 2 * outer[1] + 2 * (1 - t) * t * lower[1] + t ** 2 * inner[1])
for t in ts
]
upper_edge = [(x, y - above) for x, y in curve]
lower_edge = [(x, y + below) for x, y in curve]
poly = upper_edge + lower_edge[::-1]
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(poly, fill=255)
return np.array(img) > 127
def dark_circle_band_mask(eye: dict, w: int, h: int) -> np.ndarray:
"""아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35px 띠(다크서클 해칭 검사용)."""
icx = eye["iris"]["center"][0]
lower_y = eye["lowerLidBottom"][1]
y0, y1 = lower_y + DARK_CIRCLE_BAND_Y0, lower_y + DARK_CIRCLE_BAND_Y1
x0, x1 = icx - DARK_CIRCLE_BAND_HALF_W, icx + DARK_CIRCLE_BAND_HALF_W
m = np.zeros((h, w), dtype=bool)
yy0, yy1 = max(0, int(round(y0))), min(h, int(round(y1)))
xx0, xx1 = max(0, int(round(x0))), min(w, int(round(x1)))
m[yy0:yy1, xx0:xx1] = True
return m
def brow_dark_hole(
brow: dict, lum: np.ndarray, w: int, h: int, margin: float = BROW_INK_MARGIN,
thresh: float = BROW_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""눈썹 잉크(굵은 털 뭉치) 전체를 명도 임계값 + 연결요소로 찾는다(eye_dark_hole과 같은 방식)."""
inner, peak, outer = brow["inner"], brow["peak"], brow["outer"]
xs = [inner[0], peak[0], outer[0]]
ys = [inner[1], peak[1], outer[1]]
x0, x1 = min(xs) - margin, max(xs) + margin
y0, y1 = min(ys) - margin - 10, max(ys) + margin + 15
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
pcx, pcy = peak
seed_label = nearest_component_label(labeled, pcy, pcx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def mouth_dark_hole(
mcL, mcR, upLip, loLip, lum: np.ndarray, w: int, h: int, margin: float = MOUTH_INK_MARGIN,
thresh: float = MOUTH_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""입술 잉크(윗/아랫입술 선·안쪽 그늘) 전체를 명도 임계값 + 연결요소로 찾는다."""
x0, x1 = min(mcL[0], mcR[0]) - margin, max(mcL[0], mcR[0]) + margin
y0, y1 = upLip[1] - margin, loLip[1] + margin
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
ccx, ccy = (mcL[0] + mcR[0]) / 2, (upLip[1] + loLip[1]) / 2
seed_label = nearest_component_label(labeled, ccy, ccx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def shift_mask(mask: np.ndarray, dy: int, dx: int) -> np.ndarray:
"""mask를 (dy,dx)만큼 평행이동한다(래핑 없음, 밖으로 밀려난 부분은 버림)."""
h, w = mask.shape
out = np.zeros_like(mask)
src_y0, src_y1 = max(0, -dy), h - max(0, dy)
dst_y0, dst_y1 = max(0, dy), h - max(0, -dy)
src_x0, src_x1 = max(0, -dx), w - max(0, dx)
dst_x0, dst_x1 = max(0, dx), w - max(0, -dx)
if src_y1 <= src_y0 or src_x1 <= src_x0:
return out
out[dst_y0:dst_y1, dst_x0:dst_x1] = mask[src_y0:src_y1, src_x0:src_x1]
return out
def grow_directional(mask: np.ndarray, dy: int = 0, dx: int = 0) -> np.ndarray:
"""mask를 (dy,dx) 방향으로 1px씩 단계적으로 밀어 그 방향으로 최대
|dy| 또는 |dx| px까지 덮는다(원래 mask가 있던 자리에서 그 방향으로 "그림자를
드리운" 모양) — 눈 주름·아랫눈꺼풀·입가 그늘처럼 특정 방향으로만 구멍을
넓힐 때 쓴다."""
steps = max(abs(dy), abs(dx), 1)
out = mask.copy()
for i in range(1, steps + 1):
fy = round(dy * i / steps)
fx = round(dx * i / steps)
out |= shift_mask(mask, fy, fx)
return out
def line_only_mask(dark_mask: np.ndarray, iterations: int = LINE_OPENING_ITER) -> np.ndarray:
"""넓고 부드러운 그늘(오프닝으로 살아남는 덩어리)을 빼고, 얇은 선 성분만 남긴다."""
opened = binary_opening(dark_mask, iterations=iterations)
return dark_mask & ~opened
def clone_stamp_fill(f_arr: np.ndarray, bad_mask: np.ndarray, avoid_mask: np.ndarray) -> tuple[np.ndarray, list[dict]]:
"""bad_mask(F에 남은 선 모양 잔여 잉크)의 연결요소마다 avoid_mask(제외 영역 ∪
머리카락 ∪ F 잔여 잉크)를 피하는 이웃 패치를 후보 오프셋에서 찾아 그대로
옮겨 붙인다 — 복제 도장. 블러 없이 원본 해칭 텍스처를 재배치만 한다."""
h, w = bad_mask.shape
labeled, n = label(bad_mask)
out = f_arr.copy()
good = ~avoid_mask
report: list[dict] = []
for comp_id in range(1, n + 1):
comp = labeled == comp_id
ys, xs = np.where(comp)
y0, y1, x0, x1 = int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1
comp_sub = comp[y0:y1, x0:x1]
# shift_mask(f_arr, dy, dx)는 출력 위치 y의 값을 입력 위치 y-dy에서
# 가져온다(아래로 dy만큼 미는 것) — 그래서 comp가 실제로 퍼오는 소스
# 좌표는 y0-dy..y1-dy, x0-dx..x1-dx이다. 여기를 검증해야 한다(이전에는
# y0+dy로 반대 방향을 검증하는 부호 버그가 있었다).
chosen = None
best_std = -1.0
for dx, dy in CLONE_STAMP_CANDIDATES:
sy0, sy1, sx0, sx1 = y0 - dy, y1 - dy, x0 - dx, x1 - dx
if sy0 < 0 or sx0 < 0 or sy1 > h or sx1 > w:
continue
if not np.all(good[sy0:sy1, sx0:sx1][comp_sub]):
continue
src_patch = f_arr[sy0:sy1, sx0:sx1][comp_sub]
s = float(src_patch.std())
if s > best_std:
best_std = s
chosen = (dx, dy)
# comp 안쪽은 무조건 완전 교체(blend=1)한다. 컴포넌트별로 가우시안이나
# 거리변환 페더를 주면, 서로 몇 px 안 떨어진 작은 컴포넌트(대다수가
# 1~수십 px)들의 페더 자락이 이웃 컴포넌트의 comp 영역까지 침범해 이미
# 교체된 픽셀을 나중 컴포넌트의 도장 값으로 다시 섞어버려 어둡게 되돌리는
# 문제가 있었다 — comp 경계는 하드컷으로 두고, 바깥쪽 전환은 뒤에서
# bad_texture 전체 기준으로 한 번에 처리한다.
blend = comp.astype(np.float64)
if chosen is None:
_, (iy, ix) = distance_transform_edt(~good, return_indices=True)
shifted = f_arr[iy, ix, :]
method = "nearest-good-pixel"
offset = None
else:
dx, dy = chosen
shifted = np.stack([shift_mask(f_arr[..., c], dy, dx) for c in range(f_arr.shape[2])], axis=2)
method = "clone-stamp"
offset = [dx, dy]
out = out * (1 - blend[..., None]) + shifted * blend[..., None]
report.append({"componentId": comp_id, "pixels": int(comp.sum()), "offset": offset, "method": method})
return out, report
def high_freq_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = HF_SIGMA) -> float:
if not mask.any():
return 0.0
hf = lum - gaussian_filter(lum, sigma=sigma)
return float(hf[mask].std())
def line_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = LINE_SIGMA) -> float:
"""원본이 자신의 블러보다 어두운 정도(가는 잉크선 성분)의 평균 크기."""
if not mask.any():
return 0.0
blurred = gaussian_filter(lum, sigma=sigma)
neg = np.clip(blurred - lum, 0, None)
return float(neg[mask].mean())
def find_clean_patch(
avoid_dilated: np.ndarray, roi: tuple[int, int, int, int], size: int = GENERIC_PATCH_SIZE
) -> tuple[int, int]:
"""roi=(x0,y0,x1,y1) 범위 안에서 avoid_dilated가 전부 False인 size x size
창의 좌상단 좌표를 찾는다. 완전히 깨끗한 창이 없으면 가장 깨끗한 것을 쓴다."""
x0, y0, x1, y1 = roi
h, w = avoid_dilated.shape
x0, y0 = max(0, x0), max(0, y0)
x1, y1 = min(w, x1), min(h, y1)
eligible = ~avoid_dilated
mask_f = eligible.astype(np.float64)
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
sy0, sy1 = max(0, y0), min(sums.shape[0] - 1, y1 - size)
sx0, sx1 = max(0, x0), min(sums.shape[1] - 1, x1 - size)
if sy1 < sy0 or sx1 < sx0:
sy0, sy1, sx0, sx1 = 0, sums.shape[0] - 1, 0, sums.shape[1] - 1
sub = sums[sy0:sy1 + 1, sx0:sx1 + 1]
idx = np.unravel_index(np.argmax(sub), sub.shape)
return int(idx[1] + sx0), int(idx[0] + sy0)
def main() -> int:
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()
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"]
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
# ------------------------------------------------------------------
# 영역(region): faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영·점)
# ------------------------------------------------------------------
def eye_region_bbox(eye, brow, outward_sign: float):
outer, inner = eye["outerCorner"], eye["innerCorner"]
x_outer = outer[0] + outward_sign * 30.0
x_inner = inner[0] - outward_sign * 25.0
x_min, x_max = sorted([x_outer, x_inner])
y_min = brow["peak"][1] - 10.0
y_max = eye["lowerLidBottom"][1] + 70.0
return x_min, y_min, x_max, y_max
bx0, by0, bx1, by1 = eye_region_bbox(eyeL, browL, outward_sign=-1.0)
rx0, ry0, rx1, ry1 = eye_region_bbox(eyeR, browR, outward_sign=+1.0)
eyeL_cx, eyeL_cy = (bx0 + bx1) / 2, (by0 + by1) / 2
eyeL_rx, eyeL_ry = (bx1 - bx0) / 2, (by1 - by0) / 2
eyeR_cx, eyeR_cy = (rx0 + rx1) / 2, (ry0 + ry1) / 2
eyeR_rx, eyeR_ry = (rx1 - rx0) / 2, (ry1 - ry0) / 2
mouth_x0 = min(mcL[0], mcR[0]) - 45.0
mouth_x1 = max(mcL[0], mcR[0]) + 45.0
mouth_y0 = noseTip[1] + 25.0
mouth_y1 = chinTip[1] - 15.0
mouth_cx, mouth_cy = (mouth_x0 + mouth_x1) / 2, (mouth_y0 + mouth_y1) / 2
mouth_rx, mouth_ry = (mouth_x1 - mouth_x0) / 2, (mouth_y1 - mouth_y0) / 2
print(f"eyeLeft 영역 bbox=({bx0:.1f},{by0:.1f},{bx1:.1f},{by1:.1f})")
print(f"eyeRight 영역 bbox=({rx0:.1f},{ry0:.1f},{rx1:.1f},{ry1:.1f})")
print(f"mouth 영역 bbox=({mouth_x0:.1f},{mouth_y0:.1f},{mouth_x1:.1f},{mouth_y1:.1f})")
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)
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
# ------------------------------------------------------------------
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
# ------------------------------------------------------------------
print("=== 제외 영역 계산 ===")
eyeL_ink_raw = eye_dark_hole(eyeL, lum_front, w, h)
eyeR_ink_raw = eye_dark_hole(eyeR, lum_front, w, h)
# eye_dark_hole은 명도<150 연결요소를 찾는데, 다크서클 해칭이 눈 잉크와
# 이어져 있어(binary_closing으로 다리까지 놓여) 같은 컴포넌트로 잡혀
# 아랫눈꺼풀선 아래 30px까지 "눈 잉크"로 나온다 — 랜드마크선+7px 아래는
# 잘라내 다크서클 해칭이 눈 제외 영역에 섞이지 않게 한다.
yy_full, _ = np.mgrid[0:h, 0:w]
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
eyeL_ink = eyeL_ink_raw & eyeL_cap
eyeR_ink = eyeR_ink_raw & eyeR_cap
print(f"눈 잉크 다크서클 절단: L {int(eyeL_ink_raw.sum())}->{int(eyeL_ink.sum())}px, R {int(eyeR_ink_raw.sum())}->{int(eyeR_ink.sum())}px")
# 위(쌍꺼풀 주름)·바깥(꼬리)은 기존처럼 잉크에서 방향성 확장 + 6px 팽창.
# 아래쪽은 잉크에서 확장하지 않고, 랜드마크 곡선 자체의 좁은 띠(-2~+5px)만
# 별도로 2px 팽창한다 — 다크서클 해칭을 먹지 않기 위함(오케스트레이터 지시).
eyeL_grown = eyeL_ink | grow_directional(eyeL_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeL_ink, dx=-EYE_OUTER_EXT_PX)
eyeR_grown = eyeR_ink | grow_directional(eyeR_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeR_ink, dx=EYE_OUTER_EXT_PX)
# 6px 팽창은 사방으로 동시에 퍼지므로, 이미 절단한 아래쪽 경계를 다시
# 밀어 내린다 — 팽창 뒤에도 같은 절단선으로 다시 한 번 잘라 위/바깥
# 팽창(원하는 효과)만 남기고 아래쪽 재침범은 막는다.
eyeL_core_excl = binary_dilation(eyeL_grown, iterations=EYE_FINAL_DILATE_PX) & eyeL_cap
eyeR_core_excl = binary_dilation(eyeR_grown, iterations=EYE_FINAL_DILATE_PX) & eyeR_cap
eyeL_lower_line = lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], w, h)
eyeR_lower_line = lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], w, h)
eyeL_lower_excl = binary_dilation(eyeL_lower_line, iterations=EYE_LOWER_DILATE_PX)
eyeR_lower_excl = binary_dilation(eyeR_lower_line, iterations=EYE_LOWER_DILATE_PX)
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
# 페더 프로파일을 나눌 때는 "아래쪽 좁은 띠 전용 마스크"가 아니라 "제외
# 영역 중 아랫눈꺼풀선 근방(위로 3px)에 걸리는 부분 전체"를 기준으로
# 삼는다 — core_excl의 6px 팽창분도 같은 y대에 있으면 좁은 4px 페더를
# 받아야 다크서클 쪽으로 넓은 10px 페더가 새는 것을 막는다.
eyeL_lower_zone = eyeL_excl & (yy_full > eyeL["lowerLidBottom"][1] - 8.0)
eyeR_lower_zone = eyeR_excl & (yy_full > eyeR["lowerLidBottom"][1] - 8.0)
eye_lower_bands = eyeL_lower_zone | eyeR_lower_zone
browL_ink = brow_dark_hole(browL, lum_front, w, h)
browR_ink = brow_dark_hole(browR, lum_front, w, h)
browL_band = eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
browR_band = eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
browL_excl = binary_dilation(browL_ink | browL_band, iterations=BROW_FINAL_DILATE_PX)
browR_excl = binary_dilation(browR_ink | browR_band, iterations=BROW_FINAL_DILATE_PX)
mouth_ink = mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, w, h)
mouth_grown = (
mouth_ink
| grow_directional(mouth_ink, dx=-MOUTH_CORNER_EXT_PX)
| grow_directional(mouth_ink, dx=MOUTH_CORNER_EXT_PX)
| grow_directional(mouth_ink, dy=MOUTH_SHADOW_EXT_PX)
)
mouth_excl = binary_dilation(mouth_grown, iterations=MOUTH_FINAL_DILATE_PX)
exclusion_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
named_excl = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
print(f"제외 영역 픽셀: eye={int((eyeL_excl|eyeR_excl).sum())} brow={int((browL_excl|browR_excl).sum())} mouth={int(mouth_excl.sum())} union={int(exclusion_mask.sum())}")
# ------------------------------------------------------------------
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
# ------------------------------------------------------------------
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())}")
# ------------------------------------------------------------------
# 텍스처원(질감, 블러 금지): F가 선 모양 잔여 잉크인 자리만 복제 도장
# ------------------------------------------------------------------
dark_in_excl = exclusion_mask & ~hair_mask & (lum_f_raw < CLONE_TARGET_THRESH)
bad_texture = line_only_mask(dark_in_excl)
avoid_mask = exclusion_mask | hair_mask | (lum_f_raw < AVOID_THRESH)
print(f"텍스처 복제 대상(제외 영역 안 F 선 모양 잔여 잉크): {int(bad_texture.sum())}px (넓은 그늘 {int(dark_in_excl.sum()) - int(bad_texture.sum())}px는 보존)")
f_clean, stamp_report = clone_stamp_fill(f_arr, bad_texture, avoid_mask)
for r in stamp_report:
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
# ------------------------------------------------------------------
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
# 다크서클 해칭을 먹지 않도록 4px 페더만 쓴다(오케스트레이터 지시). 두
# 페더 영역을 나눠 계산한 뒤 max로 합친다. 복제 도장으로 갈아 끼운 자리
# (bad_texture)는 경계 페더가 원본 front(잉크 그 자체)를 다시 섞어 넣지
# 못하게 blend=1로 고정한다 — 안 그러면 제외 영역 가장자리 근처의 잔여
# 잉크는 도장으로 지워도 다시 어두워진다.
# ------------------------------------------------------------------
blend_main = np.clip(
gaussian_filter((exclusion_mask & ~eye_lower_bands).astype(np.float64), sigma=EXCLUSION_BOUNDARY_FEATHER_PX / 2.0), 0.0, 1.0
)
blend_lower = np.clip(gaussian_filter(eye_lower_bands.astype(np.float64), sigma=EYE_LOWER_FEATHER_PX / 2.0), 0.0, 1.0)
blend = np.maximum(blend_main, blend_lower)
blend = np.maximum(blend, bad_texture.astype(np.float64))
face_detail_rgb = front * (1 - blend[..., None]) + f_clean * blend[..., None]
# region_alpha(눈·입 타원)는 기존 "눈두덩~다크서클" 범위용으로 만든 것이라,
# 새로 넓힌 제외 영역(특히 눈썹은 자체 타원이 없다)을 다 못 덮을 수 있다 —
# 못 덮으면 그 자리는 faceDetail이 아니라 head 레이어의 원본(미처리) 잉크가
# 그대로 비쳐 보인다. 제외 영역은 RGB와 같은 10px 페더 프로필로 알파도
# 최소 보장한다.
face_detail_alpha = np.maximum(region_alpha, blend * 255.0)
face_detail_alpha[hair_mask] = 0.0
# ------------------------------------------------------------------
# 저장
# ------------------------------------------------------------------
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}")
# ------------------------------------------------------------------
# 정지 합성(body+head+faceDetail+hairFront, 종이 위)
# ------------------------------------------------------------------
def load_rgba(p: Path) -> tuple[np.ndarray, np.ndarray]:
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")
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
canvas = composite_over(canvas, to_u8(body_rgb), to_u8(body_a))
canvas = composite_over(canvas, to_u8(head_rgb), to_u8(head_a))
canvas = composite_over(canvas, to_u8(face_detail_rgb), to_u8(face_detail_alpha))
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
static_composite = to_u8(canvas)[..., :3].astype(np.float64)
lum_composite = static_composite.mean(axis=2)
# ------------------------------------------------------------------
# 검사 (a): 제외 영역 밖, faceDetail alpha>0.5에서 base-front 대비 <=3
# ------------------------------------------------------------------
check_mask = (face_detail_alpha > 127) & ~exclusion_mask
diff = np.abs(static_composite - front).mean(axis=2)
mean_abs_a = float(diff[check_mask].mean()) if check_mask.any() else None
print(f"검사(a) 제외영역 밖 & faceDetail>0.5 평균절대차 = {mean_abs_a:.3f} (기준 <=3)")
# ------------------------------------------------------------------
# 검사 (b): 제외 영역 경계 안팎 3px 띠 평균 명도 차 <=6
# ------------------------------------------------------------------
excl_inner_band = exclusion_mask & ~binary_erosion(exclusion_mask, iterations=3)
excl_outer_band = binary_dilation(exclusion_mask, iterations=3) & ~exclusion_mask
inner_mean = float(lum_composite[excl_inner_band].mean()) if excl_inner_band.any() else None
outer_mean = float(lum_composite[excl_outer_band].mean()) if excl_outer_band.any() else None
seam_diff = abs(inner_mean - outer_mean) if inner_mean is not None and outer_mean is not None else None
print(f"검사(b) 제외영역 경계 안({inner_mean:.2f})/밖({outer_mean:.2f}) 명도차 = {seam_diff:.3f} (기준 <=6)")
# ------------------------------------------------------------------
# 검사 (c): 제외 영역 안, 머리카락이 아닌 픽셀 중 명도<70 비율 <=0.3%
# ------------------------------------------------------------------
check_region_c = exclusion_mask & ~hair_mask
violations = check_region_c & (lum_composite < 70)
n_violations = int(violations.sum())
hole_dark_pct = float(violations.sum()) / float(check_region_c.sum()) * 100.0 if check_region_c.any() else 0.0
print(f"검사(c) 제외영역 안(머리카락 제외) 명도<70 비율 = {hole_dark_pct:.4f}% (기준 <=0.3%, 위반 {n_violations}px)")
if hole_dark_pct > 0.3 and n_violations > 0:
overlay = front.copy()
overlay[violations] = np.array([230.0, 30.0, 30.0])
ys, xs = np.where(violations)
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"
Image.fromarray(to_u8(overlay)).crop((cx0, cy0, cx1, cy1)).save(diag_path)
print(f" [checkC 미달] 위반 위치 진단 이미지 저장: {diag_path}")
# ------------------------------------------------------------------
# 일반 피부 표본(뺨 2곳 + 이마 1곳, F 기준, 이목구비·머리카락에서 40px+ 이격)
# ------------------------------------------------------------------
avoid_for_patch = binary_dilation(exclusion_mask | hair_mask, iterations=GENERIC_PATCH_MARGIN)
browL_peak, browR_peak = browL["peak"], browR["peak"]
eyeL_lower, eyeR_lower = eyeL["lowerLidBottom"], eyeR["lowerLidBottom"]
forehead_roi = (
int(min(browL_peak[0], browR_peak[0]) - 20), int(min(browL_peak[1], browR_peak[1]) - 100),
int(max(browL_peak[0], browR_peak[0]) + 20), int(min(browL_peak[1], browR_peak[1]) - 20),
)
cheekL_roi = (
int(eyeL["outerCorner"][0] - 100), int(eyeL_lower[1] + 10),
int(noseTip[0] - 20), int(mcL[1] - 5),
)
cheekR_roi = (
int(noseTip[0] + 20), int(eyeR_lower[1] + 10),
int(eyeR["outerCorner"][0] + 100), int(mcR[1] - 5),
)
patch_boxes: dict[str, tuple[int, int, int, int]] = {}
for name, roi in (("forehead", forehead_roi), ("cheekLeft", cheekL_roi), ("cheekRight", cheekR_roi)):
x0, y0 = find_clean_patch(avoid_for_patch, roi, GENERIC_PATCH_SIZE)
patch_boxes[name] = (x0, y0, x0 + GENERIC_PATCH_SIZE, y0 + GENERIC_PATCH_SIZE)
clean_frac = float((~avoid_for_patch[y0:y0 + GENERIC_PATCH_SIZE, x0:x0 + GENERIC_PATCH_SIZE]).mean()) * 100.0
print(f"일반 피부 표본 {name}: box={patch_boxes[name]} 깨끗한 비율={clean_frac:.1f}%")
lum_f_clean = f_clean.mean(axis=2)
patch_masks = {}
for name, (x0, y0, x1, y1) in patch_boxes.items():
m = np.zeros((h, w), dtype=bool)
m[y0:y1, x0:x1] = True
patch_masks[name] = m
baseline_hf = float(np.mean([high_freq_energy(lum_f_clean, m) for m in patch_masks.values()]))
baseline_line = float(np.mean([line_energy(lum_front, m) for m in patch_masks.values()]))
print(f"일반 피부 표본 기준: 고주파 에너지={baseline_hf:.3f}, 선 에너지={baseline_line:.3f}")
# ------------------------------------------------------------------
# 검사 (d): 질감 보존 — 구멍 안 고주파 에너지(F 기준) / 일반 피부 표본 평균 >= 0.7
# ------------------------------------------------------------------
checkD: dict[str, dict] = {}
all_pass_d = True
for name, side_excl in named_excl.items():
side_visible = side_excl & ~hair_mask
e_inside = high_freq_energy(lum_f_clean, side_visible)
ratio_d = e_inside / baseline_hf if baseline_hf > 1e-6 else 0.0
passed = ratio_d >= CHECKD_THRESH
all_pass_d = all_pass_d and passed
checkD[name] = {"insideEnergy": e_inside, "baselineEnergy": baseline_hf, "ratio": ratio_d, "pass": passed}
print(f"검사(d) {name}: 안={e_inside:.3f} 표본기준={baseline_hf:.3f} 비율={ratio_d:.3f} (기준 >={CHECKD_THRESH}) {'OK' if passed else '[미달]'}")
# ------------------------------------------------------------------
# 검사 (e): 유령 윤곽 — 제외 영역 밖 0~12px 띠(머리카락 제외)의 선
# 에너지(front 기준) / 일반 피부 표본 평균 <= 1.5. 머리카락 가닥은 이목구비
# 잉크선이 아니므로 checkC와 같은 기준으로 뺀다.
# ------------------------------------------------------------------
checkE: dict[str, dict] = {}
all_pass_e = True
for name, side_excl in named_excl.items():
band = binary_dilation(side_excl, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
e_band = line_energy(lum_front, band)
ratio_e = e_band / baseline_line if baseline_line > 1e-6 else 0.0
passed = ratio_e <= CHECKE_THRESH
all_pass_e = all_pass_e and passed
checkE[name] = {"bandLineEnergy": e_band, "baselineLineEnergy": baseline_line, "ratio": ratio_e, "pass": passed}
print(f"검사(e) {name}: 경계띠선에너지={e_band:.3f} 표본기준={baseline_line:.3f} 비율={ratio_e:.3f} (기준 <={CHECKE_THRESH}) {'OK' if passed else '[미달-유령윤곽]'}")
# ------------------------------------------------------------------
# 검사 (다크서클 복원): 아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35 띠의 합성
# 평균 명도가 base-front와 ±6 이내여야 한다(오케스트레이터 지시 — 다크서클
# 해칭이 지워지면 안 된다).
# ------------------------------------------------------------------
checkDarkCircle: dict[str, dict] = {}
all_pass_dc = True
for name, eye in (("eyeLeft", eyeL), ("eyeRight", eyeR)):
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
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 '[미달]'}")
manifest.setdefault("layersV2", {})
manifest["layersV2"]["faceDetail"] = {
"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},
},
"exclusionPixels": {
"eyeLeft": int(eyeL_excl.sum()), "eyeRight": int(eyeR_excl.sum()),
"browLeft": int(browL_excl.sum()), "browRight": int(browR_excl.sum()),
"mouth": int(mouth_excl.sum()), "union": int(exclusion_mask.sum()),
},
"hairMaskPixels": int(hair_mask.sum()),
"bbox": bbox,
"textureCloneStamp": {
"cloneTargetThreshold": CLONE_TARGET_THRESH,
"avoidThreshold": AVOID_THRESH,
"lineOpeningIterations": LINE_OPENING_ITER,
"featherPx": CLONE_STAMP_FEATHER_PX,
"badTexturePixels": int(bad_texture.sum()),
"preservedShadowPixels": int(dark_in_excl.sum()) - int(bad_texture.sum()),
"components": stamp_report,
},
"genericPatches": {name: list(box) for name, box in patch_boxes.items()},
"genericPatchBaseline": {"highFreqEnergy": baseline_hf, "lineEnergy": baseline_line},
"checkA_meanAbsDiff": mean_abs_a,
"checkB_seamLumDiff": seam_diff,
"checkC_holeDarkResiduePct": hole_dark_pct,
"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}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,338 @@
"""P1 서연 리노컷 최종 게시 미리보기 — 2단계-B-1a (5).
게시된 WebP(apps/web/public/avatar/v3/p1/*.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
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
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"
GRAIN_OPACITY = 0.35
BG_CREAM = (0xEE, 0xE5, 0xD3)
BG_COOL = (0xDC, 0xE0, 0xE2)
BG_DARK = (0x3A, 0x3A, 0x3A)
FRAMES = [
("rotate-4deg", {"rotate": -4.0, "tx": 0.0, "ty": 0.0}),
("rotate+4deg", {"rotate": 4.0, "tx": 0.0, "ty": 0.0}),
("up14px", {"rotate": 0.0, "tx": 0.0, "ty": -14.0}),
("down10px", {"rotate": 0.0, "tx": 0.0, "ty": 10.0}),
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
]
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]:
"""게시된 webp를 rig 사각형(x,y,w,h)에 맞춰 리사이즈하고 캔버스 크기로 패딩한다."""
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)
canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
canvas.paste(im, (round(x), round(y)))
arr = np.array(canvas).astype(np.float64)
return arr[..., :3], arr[..., 3]
def polygon_alpha_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray:
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(points, fill=255)
from scipy.ndimage import gaussian_filter
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")
if grain_im.size != (size, size):
grain_im = grain_im.resize((size, size), Image.LANCZOS)
grain = np.array(grain_im).astype(np.float64)
ny = -(-canvas_h // size)
nx = -(-canvas_w // size)
tiled = np.tile(grain, (ny, nx, 1))[:canvas_h, :canvas_w, :]
return tiled
def apply_grain_multiply(rgb: np.ndarray, grain: np.ndarray, opacity: float) -> np.ndarray:
factor = grain / 255.0
multiplied = rgb * factor
return np.clip(rgb * (1 - opacity) + multiplied * opacity, 0, 255)
def composite_static(body, head, face_detail, hair_front, bg: tuple[int, int, int], canvas_w: int, canvas_h: int) -> np.ndarray:
canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
canvas[..., 0], canvas[..., 1], canvas[..., 2] = bg
canvas[..., 3] = 255.0
canvas = composite_over(canvas, to_u8(body[0]), to_u8(body[1]))
canvas = composite_over(canvas, to_u8(head[0]), to_u8(head[1]))
canvas = composite_over(canvas, to_u8(face_detail[0]), to_u8(face_detail[1]))
canvas = composite_over(canvas, to_u8(hair_front[0]), to_u8(hair_front[1]))
return to_u8(canvas)[..., :3].astype(np.float64)
def main() -> int:
report = json.loads(REPORT_PATH.read_text(encoding="utf-8"))
rig = report["rig"]
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)
body = layer_of("body")
head = layer_of("head")
hair_front = layer_of("hairFront")
face_detail_raw = layer_of("faceDetail")
face_oval = [tuple(p) for p in rig["faceOval"]]
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"])
# ------------------------------------------------------------------
# motion-{cream,cool,dark}.png
# ------------------------------------------------------------------
for bg_name, bg in (("cream", BG_CREAM), ("cool", BG_COOL), ("dark", BG_DARK)):
frame_ims = []
for name, t in FRAMES:
h_rgb, h_a = to_u8(head[0]), to_u8(head[1])
fd_rgb, fd_a = to_u8(face_detail[0]), to_u8(face_detail[1])
hf_rgb, hf_a = to_u8(hair_front[0]), to_u8(hair_front[1])
if t["rotate"] != 0.0:
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], neck_pivot)
fd_rgb, fd_a = rotate_rgba(fd_rgb, fd_a, t["rotate"], neck_pivot)
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], neck_pivot)
if t["tx"] != 0.0 or t["ty"] != 0.0:
h_rgb, h_a = translate_rgba(h_rgb, h_a, t["tx"], t["ty"])
fd_rgb, fd_a = translate_rgba(fd_rgb, fd_a, t["tx"], t["ty"])
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
frame_rgb = composite_static(
(to_u8(body[0]), to_u8(body[1])), (h_rgb, h_a), (fd_rgb, fd_a), (hf_rgb, hf_a), bg, cw, ch
)
frame_rgb = apply_grain_multiply(frame_rgb, grain_tile, GRAIN_OPACITY)
im = Image.fromarray(to_u8(frame_rgb), "RGB")
d = ImageDraw.Draw(im)
label_color = (255, 60, 60) if bg_name != "dark" else (255, 200, 140)
d.text((20, 20), name, fill=label_color)
frame_ims.append(im)
# 세로로 긴 캔버스라 옆으로 5장 나열하면 매우 넓어지므로 절반 크기로 축소해 나열
scale = 0.45
sw, sh = round(cw * scale), round(ch * scale)
gap = 10
strip = Image.new("RGB", (sw * len(frame_ims) + gap * (len(frame_ims) - 1), sh), bg)
x = 0
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"
strip.save(out_path)
print(f"저장: {out_path}")
# ------------------------------------------------------------------
# 제외 영역 재구성(build_face_detail.py와 동일한 함수·상수) — 미리보기가
# 실제 게시물이 쓴 것과 같은 제외 영역 윤곽을 보여주게 한다.
# ------------------------------------------------------------------
lm = manifest["landmarks"]
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)
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
eyeL_ink = bfd.eye_dark_hole(eyeL, lum_front, cw, ch) & eyeL_cap
eyeR_ink = bfd.eye_dark_hole(eyeR, lum_front, cw, ch) & eyeR_cap
eyeL_grown = eyeL_ink | bfd.grow_directional(eyeL_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeL_ink, dx=-bfd.EYE_OUTER_EXT_PX)
eyeR_grown = eyeR_ink | bfd.grow_directional(eyeR_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeR_ink, dx=bfd.EYE_OUTER_EXT_PX)
eyeL_core_excl = ndi.binary_dilation(eyeL_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeL_cap
eyeR_core_excl = ndi.binary_dilation(eyeR_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeR_cap
eyeL_lower_excl = ndi.binary_dilation(
bfd.lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
)
eyeR_lower_excl = ndi.binary_dilation(
bfd.lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
)
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
browL_ink = bfd.brow_dark_hole(browL, lum_front, cw, ch)
browR_ink = bfd.brow_dark_hole(browR, lum_front, cw, ch)
browL_band = bfd.eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
browR_band = bfd.eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
browL_excl = ndi.binary_dilation(browL_ink | browL_band, iterations=bfd.BROW_FINAL_DILATE_PX)
browR_excl = ndi.binary_dilation(browR_ink | browR_band, iterations=bfd.BROW_FINAL_DILATE_PX)
mouth_ink = bfd.mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, cw, ch)
mouth_grown = (
mouth_ink
| bfd.grow_directional(mouth_ink, dx=-bfd.MOUTH_CORNER_EXT_PX)
| bfd.grow_directional(mouth_ink, dx=bfd.MOUTH_CORNER_EXT_PX)
| bfd.grow_directional(mouth_ink, dy=bfd.MOUTH_SHADOW_EXT_PX)
)
mouth_excl = ndi.binary_dilation(mouth_grown, iterations=bfd.MOUTH_FINAL_DILATE_PX)
named_holes = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
hole_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
def mask_outline(mask: np.ndarray) -> np.ndarray:
return mask & ~ndi.binary_erosion(mask, iterations=2)
# ------------------------------------------------------------------
# holes-texture.jpg: 구멍별로 [F 원본, 메운 결과(face_detail_rgb),
# base-front, 제외 영역 윤곽 겹침]을 2배 확대해 나란히 놓는다.
# ------------------------------------------------------------------
f_arr = np.array(build_padded_faceless()).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]
outline_overlay = front.copy()
outline_overlay[mask_outline(hole_mask)] = np.array([40.0, 200.0, 60.0])
rows = []
zoom = 2
hole_pad = 16
for name, m in named_holes.items():
ys, xs = np.where(m)
bx0, by0, bx1, by1 = int(xs.min()) - hole_pad, int(ys.min()) - hole_pad, int(xs.max()) + 1 + hole_pad, int(ys.max()) + 1 + hole_pad
box = (max(0, bx0), max(0, by0), min(cw, bx1), min(ch, by1))
f_crop = Image.fromarray(to_u8(f_arr), "RGB").crop(box)
fill_crop = Image.fromarray(to_u8(fd_rgb_full), "RGB").crop(box)
front_crop = Image.fromarray(to_u8(front), "RGB").crop(box)
outline_crop = Image.fromarray(to_u8(outline_overlay), "RGB").crop(box)
pw2, ph2 = f_crop.size
f_crop = f_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
fill_crop = fill_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
front_crop = front_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
outline_crop = outline_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
row = Image.new("RGB", (pw2 * zoom * 4 + 30, ph2 * zoom + 20), (255, 255, 255))
d = ImageDraw.Draw(row)
for i, (label, im) in enumerate([("F 원본", f_crop), ("메운 결과", fill_crop), ("base-front", front_crop), ("제외영역 윤곽", outline_crop)]):
row.paste(im, (i * (pw2 * zoom + 10), 20))
d.text((i * (pw2 * zoom + 10), 2), f"{name}: {label}", fill=(0, 0, 0))
rows.append(row)
max_w = max(r.width for r in rows)
total_h = sum(r.height for r in rows) + 10 * (len(rows) - 1)
holes_tex = Image.new("RGB", (max_w, total_h), (255, 255, 255))
y = 0
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.convert("RGB").save(holes_tex_path, "JPEG", quality=90)
print(f"저장: {holes_tex_path}")
fd_bbox = manifest["layersV2"]["faceDetail"]["bbox"]
fx0, fy0, fx1, fy1 = fd_bbox
pad = 20
fx0, fy0 = max(0, fx0 - pad), max(0, fy0 - pad)
fx1, fy1 = min(cw, fx1 + pad), min(ch, fy1 + pad)
# (1) faceDetail 단독(크림 배경 위)
fd_on_cream = np.zeros((ch, cw, 3), dtype=np.float64)
fd_on_cream[...] = BG_CREAM
fd_on_cream = composite_over(
np.dstack([fd_on_cream, np.full((ch, cw), 255.0)]), to_u8(face_detail[0]), to_u8(face_detail[1])
)[..., :3]
# (2) 구멍 표시(빨강 오버레이)
hole_overlay = front.copy()
hole_overlay[hole_mask] = hole_overlay[hole_mask] * 0.4 + np.array([230.0, 40.0, 40.0]) * 0.6
# (3) 정지 합성
static_full = composite_static(body, head, face_detail, hair_front, BG_CREAM, cw, ch)
static_full = apply_grain_multiply(static_full, grain_tile, GRAIN_OPACITY)
# (4) base-front 비교는 front 그대로
panels = [
("faceDetail 단독", Image.fromarray(to_u8(fd_on_cream), "RGB")),
("구멍 표시", Image.fromarray(to_u8(hole_overlay), "RGB")),
("정지 합성", Image.fromarray(to_u8(static_full), "RGB")),
("base-front", Image.fromarray(to_u8(front), "RGB")),
]
crop_box = (int(fx0), int(fy0), int(fx1), int(fy1))
cropped = [im.crop(crop_box) for _, im in panels]
pw, ph = cropped[0].size
strip = Image.new("RGB", (pw * 4 + 30, ph + 24), (255, 255, 255))
x = 0
for (label, _), im in zip(panels, cropped):
strip.paste(im, (x, 24))
d = ImageDraw.Draw(strip)
d.text((x, 4), label, fill=(0, 0, 0))
x += pw + 10
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)
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
eye_crop = eye_crop.resize((eye_crop.width * zoom, eye_crop.height * zoom), Image.LANCZOS)
mouth_w = eye_crop.width
mouth_h = round(mouth_crop.height * (mouth_w / mouth_crop.width))
mouth_crop = mouth_crop.resize((mouth_w, mouth_h), Image.LANCZOS)
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_im.save(out_path)
print(f"저장: {out_path}")
# ------------------------------------------------------------------
# ghost-check.jpg: faceDetail만 올린 얼굴(벡터 없음)의 눈·눈썹·입을 한
# 프레임으로 2배 확대 — 옛 잉크선(유령 윤곽)이 남았는지 보는 용도.
# ------------------------------------------------------------------
ghost_box = (260, 440, 760, 940)
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"
ghost_crop.convert("RGB").save(out_path, "JPEG", quality=92)
print(f"저장: {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,176 @@
"""P1 서연 턱 조각(jaw-head·jaw-detail) 생성 — 2단계-B, 결정문 §8.4 하관 띠 변형
(작업 패킷 A5).
하관 띠 변형(렌더러 v3)은 ㅏ·ㅗ 등에서 턱·아랫입술·콧방울을 세로 띠로 나눠 옮기는
변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 1005×1566 head·faceDetail
이미지를 매 프레임 다시 그리면 비용이 크므로(out5 실측: long task 14/7.3s), 변형이
실제로 필요한 사각형만 미리 잘라 둔다.
영역(결정문 §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).
가로 범위 측정: 얼굴 윤곽 다각형을 캔버스 크기 마스크로 채우고(cv2.fillPoly) 유클리드
거리 변환으로 18px 확장한 뒤(build_lip_texture.py의 DILATE_PX와 같은 기법), y 범위로
제한한 행들에서 열이 하나라도 켜진 범위를 x0~x1로 쓴다. (참고: 이 y 범위 안에서는
다각형 경계가 거의 수직에 가까워, 단순히 "y 범위 안 원본 다각형의 x0/x1에 18을 더/
빼는" 결과와 사실상 같다 — 두 방식을 대조해 확인했다.)
실행: <venv>/python.exe build_jaw_pieces.py
"""
from __future__ import annotations
import io
import json
import math
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
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"
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"))
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")
face_oval = compute_face_oval(points)
canvas_w, canvas_h = manifest["canvas"]["w"], manifest["canvas"]["h"]
y0f = nose_tip[1] - NOSE_TO_TOP_PX
y1f = chin_tip[1] + CHIN_TO_BOTTOM_PX
y0i, y1i = int(math.floor(y0f)), int(math.ceil(y1f))
print(f"y 범위 = [{y0f:.2f}, {y1f:.2f}] -> 정수 [{y0i}, {y1i}) (h={y1i-y0i})")
oval_mask = np.zeros((canvas_h, canvas_w), dtype=np.uint8)
oval_pts = np.array([[round(p[0]), round(p[1])] for p in face_oval], dtype=np.int32)
cv2.fillPoly(oval_mask, [oval_pts], 1)
oval_mask_b = oval_mask.astype(bool)
dist_out = ndimage.distance_transform_edt(~oval_mask_b)
dilated = oval_mask_b | (dist_out <= OVAL_OUTSET_PX)
if y0i <= 0 or y1i >= canvas_h:
raise SystemExit(f"[중단] y 범위가 캔버스 경계에 닿았다: [{y0i},{y1i}) canvas_h={canvas_h}")
band = dilated[y0i:y1i, :]
cols = np.where(band.any(axis=0))[0]
if len(cols) == 0:
raise SystemExit("[중단] 얼굴 윤곽 확장 다각형이 해당 y 범위에서 비어 있다.")
x0i, x1i = int(cols.min()), int(cols.max()) + 1
if x0i <= 0 or x1i >= canvas_w:
raise SystemExit(f"[중단] x 범위가 캔버스 경계에 닿았다: [{x0i},{x1i}) canvas_w={canvas_w}")
print(f"x 범위(얼굴 윤곽 {OVAL_OUTSET_PX}px 확장, y 범위 안) = [{x0i}, {x1i}) (w={x1i-x0i})")
# 대조: 다각형 경계가 이 y 범위에서 거의 수직인지 확인 — 확장 없이 구한 x 범위에 단순히
# OVAL_OUTSET_PX를 더/뺀 값과 비교해 기록만 한다(설계 검증용, 결과에는 dilated만 쓴다).
band_raw = oval_mask_b[y0i:y1i, :]
cols_raw = np.where(band_raw.any(axis=0))[0]
x0_raw, x1_raw = int(cols_raw.min()), int(cols_raw.max()) + 1
naive_x0, naive_x1 = x0_raw - int(OVAL_OUTSET_PX), x1_raw + int(OVAL_OUTSET_PX)
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"))
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")}
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()
Image.fromarray(crop, "RGBA").save(out_path)
reloaded = np.array(Image.open(out_path).convert("RGBA"))
max_diff = int(np.abs(reloaded.astype(np.int32) - crop.astype(np.int32)).max())
ok = max_diff == 0
print(f"저장: {out_path} size={crop.shape[1]}x{crop.shape[0]} "
f"검사(1) 원본 대비 PNG 왕복 최대차(알파 포함)={max_diff} {'OK' if ok else '[실패]'}")
checks[key] = {
"pngRoundTripMaxDiff": max_diff,
"pngRoundTripOk": ok,
"pngFileSize": out_path.stat().st_size,
}
_save_evidence(pieces, x0i, y0i, x1i, y1i)
manifest["jaw"] = {
"designVersion": "A5 (작업 패킷 A5, 결정문 §8.4 하관 띠 변형 — 턱 조각 원본)",
"noseTip": list(nose_tip),
"chinTip": list(chin_tip),
"yFormula": "noseTip.y - 50 - 10 .. chinTip.y + 40",
"noseToTopPx": NOSE_TO_TOP_PX,
"chinToBottomPx": CHIN_TO_BOTTOM_PX,
"ovalOutsetPx": OVAL_OUTSET_PX,
"faceOvalSource": "export_rig.detect_face_landmarks + compute_face_oval(base-front.png, 결정적)",
"bboxCanvas": [x0i, y0i, x1i, y1i],
"naiveXRangeForComparison": [naive_x0, naive_x1],
"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}")
return 0
def _save_evidence(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int) -> None:
"""jaw-head | jaw-detail(체커 배경), 원본 배율."""
panels = []
for key in ("head", "detail"):
src, _out_path = pieces[key]
crop = src[y0i:y1i, x0i:x1i]
rgb = crop[..., :3].astype(np.float64)
a = (crop[..., 3].astype(np.float64) / 255.0)[..., None]
check = np.indices(a.shape[:2])
checker = ((check[0] // 10 + check[1] // 10) % 2) * 60 + 180
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
out = rgb * a + checker3 * (1.0 - a)
panels.append(Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)))
gap = 12
max_h = max(p.height for p in panels)
total_w = sum(p.width for p in panels) + gap * (len(panels) - 1)
combined = Image.new("RGB", (total_w, max_h), (255, 255, 255))
x = 0
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"
combined.save(out_path, "JPEG", quality=92)
print(f"저장: {out_path}")
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,361 @@
"""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())

View file

@ -0,0 +1,411 @@
"""P1 서연 리노컷 리그 — 분할 기반 재작업 (2단계-A 반려 후 설계).
레이어 픽셀은 항상 base-faceless.png(패딩판 F)에서 가져온다. mediapipe
ImageSegmenter(selfie_multiclass_256x256)로 F를 분할해 head/hairFront/body
마스크를 만들고, body는 head 마스크가 덮는 영역 중 기존에 수용된 재생성
raw/body.png(크로마키 결과, offset 0,0 확인됨)의 알파>0 부분만 가려진 영역
채움으로 사용한다. imagegen 추가 호출 없음.
실행: <venv>/python.exe build_layers_segmented.py
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import binary_dilation, 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"
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
HAIR_EDGE_LUM_THRESH = 110
CHIN_MARGIN = 8
FACE_OVAL_SCALE = 1.04
GREEN_RESIDUE_MARGIN = 30
# mediapipe FaceMesh FACE_OVAL 연결(468 캐노니컬 토폴로지)을 순서대로 이은 폐곡선.
FACE_OVAL_LOOP = [
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379,
378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127,
162, 21, 54, 103, 67, 109,
]
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers import chroma_key # noqa: E402 (기존 크로마키 함수 재사용)
def build_padded_faceless() -> 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")
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 분리 사유)."""
with tempfile.TemporaryDirectory() as td:
out_json = Path(td) / "landmarks.json"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_json.exists():
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
data = json.loads(out_json.read_text(encoding="utf-8"))
if not data.get("ok"):
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
return [(p[0], p[1]) for p in data["points"]]
def run_segmentation(image_path: Path) -> np.ndarray:
"""ImageSegmenter를 별도 프로세스로 실행한다(세그폴트 회피, 위 설명 참고)."""
if not MODEL_SEG.exists():
raise SystemExit(f"[중단] 분할 모델이 없다: {MODEL_SEG}")
with tempfile.TemporaryDirectory() as td:
out_npy = Path(td) / "category_mask.npy"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_segmentation.py"), str(image_path), str(out_npy)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_npy.exists():
raise SystemExit(f"[중단] ImageSegmenter 서브프로세스 실패.\n{proc.stderr}")
category_mask = np.load(out_npy)
return category_mask
def refine_hair_edge(hair_mask: np.ndarray, rgb_arr: np.ndarray) -> np.ndarray:
dil = binary_dilation(hair_mask, iterations=HAIR_EDGE_DILATE_PX)
band = dil & ~hair_mask
lum = rgb_arr.astype(np.float64).mean(axis=2)
add = band & (lum < HAIR_EDGE_LUM_THRESH)
return hair_mask | add
def polygon_mask(points: list[tuple[float, float]], w: int, h: int, scale: float = 1.0) -> tuple[np.ndarray, list[tuple[float, float]]]:
cx = float(np.mean([p[0] for p in points]))
cy = float(np.mean([p[1] for p in points]))
scaled = [((x - cx) * scale + cx, (y - cy) * scale + cy) for x, y in points]
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(scaled, fill=255)
return np.array(img) > 127, scaled
def feather_bool_mask(mask: np.ndarray, px: float = FEATHER_PX) -> np.ndarray:
a = mask.astype(np.float64) * 255.0
a = gaussian_filter(a, sigma=px / 1.6)
return np.clip(a, 0, 255)
def to_u8(x: np.ndarray) -> np.ndarray:
"""float 배열을 반올림해 uint8로 캐스팅한다(truncation으로 255가 254 되는 것 방지)."""
return np.clip(np.round(x), 0, 255).astype(np.uint8)
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 rotate_rgba(rgb: np.ndarray, alpha: np.ndarray, angle_deg: float, pivot: tuple[float, float]) -> tuple[np.ndarray, np.ndarray]:
im = Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA")
rot = im.rotate(angle_deg, resample=Image.BICUBIC, center=pivot, fillcolor=(0, 0, 0, 0))
out = np.array(rot)
return out[..., :3], out[..., 3]
def translate_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: float, dy: float) -> tuple[np.ndarray, np.ndarray]:
im = Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA")
out = Image.new("RGBA", im.size, (0, 0, 0, 0))
out.paste(im, (round(dx), round(dy)))
arr = np.array(out)
return arr[..., :3], arr[..., 3]
def main() -> int:
LAYERS_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
f_img = build_padded_faceless()
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")
chin_y = front_pts[152][1]
chin_xy = front_pts[152]
face_oval_mask, face_oval_poly = polygon_mask(
[front_pts[i] for i in FACE_OVAL_LOOP], w, h, scale=FACE_OVAL_SCALE
)
print(f"턱끝(152) 좌표: {chin_xy}, chinY+{CHIN_MARGIN}={chin_y + CHIN_MARGIN:.1f}")
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}")
bg_mask = category_mask == 0
hair_mask_raw = category_mask == 1
body_skin_mask = category_mask == 2
face_skin_mask = category_mask == 3
clothes_mask = category_mask == 4
others_mask = category_mask == 5
hair_mask = refine_hair_edge(hair_mask_raw, f_arr)
hair_edge_added = int((hair_mask & ~hair_mask_raw).sum())
print(f"머리카락 가장자리 보강으로 추가된 픽셀: {hair_edge_added}")
yy = np.arange(h)[:, None] * np.ones((1, w))
chin_line = chin_y + CHIN_MARGIN
head_mask = hair_mask | face_skin_mask | (body_skin_mask & (yy < chin_line))
hairfront_mask = hair_mask & face_oval_mask
body_base_mask = clothes_mask | others_mask | (body_skin_mask & (yy >= chin_line))
print(
f"head_mask={int(head_mask.sum())} hairfront_mask={int(hairfront_mask.sum())} "
f"body_base_mask={int(body_base_mask.sum())} 배경={int(bg_mask.sum())}"
)
# --- head / hairFront: F 픽셀을 각 마스크로 잘라 1px 페더 ---
head_alpha = feather_bool_mask(head_mask, FEATHER_PX)
hairfront_alpha = feather_bool_mask(hairfront_mask, FEATHER_PX)
head_rgb = f_arr.copy()
hairfront_rgb = f_arr.copy()
# --- body: F 기반 기본 + 수용된 재생성 raw/body.png 채움(4px 페더) ---
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"))
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)
fill_target = head_mask & (regen_body_alpha > 0)
fill_target_px = int(fill_target.sum())
print(f"body 채움 대상(head_mask ∩ 재생성 알파>0) 픽셀 수: {fill_target_px}")
dist_out = distance_transform_edt(~fill_target)
fill_weight = np.clip(1.0 - dist_out / FILL_FEATHER_PX, 0.0, 1.0)
fill_weight = gaussian_filter(fill_weight, sigma=FILL_FEATHER_PX / 2.35)
fill_weight = np.clip(fill_weight, 0.0, 1.0)
base_a = body_base_alpha / 255.0
fill_a = (regen_body_alpha.astype(np.float64) / 255.0) * fill_weight
out_a = base_a + fill_a * (1 - base_a)
eps = 1e-6
body_final_rgb = (
body_base_rgb.astype(np.float64) * base_a[..., None]
+ regen_body_rgb.astype(np.float64) * (fill_a * (1 - base_a))[..., None]
) / np.clip(out_a[..., None], eps, None)
body_final_rgb = to_u8(body_final_rgb)
body_final_alpha = to_u8(out_a * 255.0)
layers_out = {
"head": (to_u8(head_rgb), to_u8(head_alpha)),
"hairFront": (to_u8(hairfront_rgb), to_u8(hairfront_alpha)),
"body": (body_final_rgb, body_final_alpha),
}
layers_report = []
for layer_id in ("body", "head", "hairFront"):
rgb, alpha = layers_out[layer_id]
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())
residue = green_residue_count(rgb, alpha)
entry = {
"id": layer_id,
"file": f"layers/{layer_id}.png",
"source": "base-faceless-masked+regenerated-fill" if layer_id == "body" else "base-faceless-masked",
"alphaBBox": bbox,
"opaquePixels": opaque,
"greenResidue": residue,
}
if layer_id == "body":
entry["fillTargetPixels"] = fill_target_px
entry["fillSourceRaw"] = "raw/body.png (rejected 재생성이 아니라 수용된 body.png의 크로마키 결과; head/hairFront와 달리 body 재생성은 승인됨)"
layers_report.append(entry)
print(f"[{layer_id}] bbox={bbox} opaque={opaque} greenResidue={residue}")
# --- 정지 합성 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[..., 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")
composite_rgb = composite[..., :3].astype(np.float64)
f_rgb = f_arr.astype(np.float64)
full_diff = np.abs(composite_rgb - f_rgb).mean(axis=2)
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
mean_abs_hair_outline = float(full_diff[hair_outline_band].mean()) if hair_outline_band.any() else None
print(
f"합성 vs F 평균절대차: 전체={mean_abs_full:.3f} 얼굴(head_mask)={mean_abs_face:.3f} "
f"머리윤곽밴드={mean_abs_hair_outline:.3f}"
)
if mean_abs_full >= 3.0:
bg_diff = float(full_diff[bg_mask].mean())
fg_diff = float(full_diff[~bg_mask].mean())
print(
f"[경고] 전체 평균절대차 {mean_abs_full:.3f} >= 3.0. 원인 분해: "
f"배경(카테고리0) 평균절대차={bg_diff:.3f}(전체의 {bg_mask.mean()*100:.1f}%), "
f"전경 평균절대차={fg_diff:.3f}"
)
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")
# --- 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)}
overlay = np.zeros((h, w, 3), dtype=np.uint8)
for k, c in colors.items():
overlay[category_mask == k] = c
blend = to_u8(f_arr.astype(np.float64) * 0.55 + overlay.astype(np.float64) * 0.45)
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")
# --- motion-test.png ---
pivot = chin_xy
transforms = [
("rotate-4deg", {"rotate": -4.0, "tx": 0.0, "ty": 0.0}),
("rotate+4deg", {"rotate": 4.0, "tx": 0.0, "ty": 0.0}),
("up14px", {"rotate": 0.0, "tx": 0.0, "ty": -14.0}),
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
]
frames = []
body_rgb0, body_a0 = layers_out["body"]
head_rgb0, head_a0 = layers_out["head"]
hf_rgb0, hf_a0 = layers_out["hairFront"]
for name, t in transforms:
h_rgb, h_a = head_rgb0, head_a0
hf_rgb, hf_a = hf_rgb0, hf_a0
if t["rotate"] != 0.0:
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], pivot)
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], pivot)
if t["tx"] != 0.0 or t["ty"] != 0.0:
h_rgb, h_a = translate_rgba(h_rgb, h_a, t["tx"], t["ty"])
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[..., 3] = 255.0
frame = composite_over(frame, body_rgb0, body_a0)
frame = composite_over(frame, h_rgb, h_a)
frame = composite_over(frame, hf_rgb, hf_a)
frames.append((name, Image.fromarray(to_u8(frame), "RGBA").convert("RGB")))
gap = 12
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")
# --- manifest 갱신 ---
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": 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다.",
}
manifest["segmenter"] = {
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",
"categories": {"0": "background", "1": "hair", "2": "bodySkin", "3": "faceSkin", "4": "clothes", "5": "others"},
"categoryPixelCounts": cat_counts,
"hairEdgeRefine": {"dilatePx": HAIR_EDGE_DILATE_PX, "lumThreshold": HAIR_EDGE_LUM_THRESH, "addedPixels": hair_edge_added},
}
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차 반려 사유)"},
"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)},
"backgroundNote": "카테고리0(배경)은 종이결 텍스처 노이즈(표준편차 ~29/채널)를 포함해 평탄한 크림(#EEE5D3)과 자체적으로 평균절대차 ~7 차이가 난다.",
}
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}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,650 @@
"""P1 서연 리노컷 v2 레이어 보정 — 2단계-B-1a (1)(2).
기존 layers/{body,head,hairFront}.png(2단계-A 산출물)을 다시 만들지 않고,
같은 분할·랜드마크 계산을 재실행해 다음 두 결함만 보정한 layers/v2/*.png를 만든다.
(1) body 턱 밑 밝은 띠: head_mask가 덮는 목 상단 영역(regen raw/body.png 채움)의
질감이 실제로 보이는 목 그늘(chin_line 바로 아래)과 이어지지 않는다.
-> 그 영역을 raw 채움 대신, 보이는 목 그늘 띠를 반사 + 행 방향 지터로 위로
연장한 질감으로 대체한다(목 영역에는 더 이상 raw/body.png를 쓰지 않는다).
(2) head/hairFront 잔머리 halo: 머리카락 바깥 경계에서 알파가 이진 분할 마스크를
그대로 따라가며 가느다란 잔머리를 끊어 점선으로 만들고, 마스크 안쪽 경계의
페더 픽셀은 원본이 이미 종이색과 섞여 있어 다른 배경 위에서 halo로 보인다.
-> 경계 띠(바깥 40px 이내 배경 분류 + 안쪽 6px)의 알파를 명도 기반
smoothstep(205->150)으로 다시 계산하고, RGB를 종이색을 뺀 잉크색으로
unpremultiply한다. 60px 미만 미연결 조각은 제거한다.
또한 모든 레이어의 부분 알파 경계 픽셀 전반에 일반 unpremultiply(halo 제거)를
적용해 배경색이 섞여 나오지 않게 한다.
실행: <venv>/python.exe build_layers_v2.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 (
binary_dilation, binary_erosion, distance_transform_edt, gaussian_filter, label,
)
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers_segmented 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,
)
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)
HAIR_EDGE_BAND_OUT_PX = 40
HAIR_EDGE_BAND_IN_PX = 6
LUM_PAPER = 205.0
LUM_INK = 150.0
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
JAW_INK_MERGE_PX = 3
JAW_INK_ASPECT_MIN = 1.0
# --- (2b) 머리카락 안쪽 불투명 종이 틈 보정 상수 ----------------------------
HAIR_PAPER_DILATE_PX = 6
PAPER_COLOR_DIST = 40.0
PAPER_LUM_THRESH = 195.0
PAPER_SAT_THRESH = 40.0
HAIR_PAPER_OPAQUE_ALPHA = 0.95
def smoothstep(t: np.ndarray) -> np.ndarray:
t = np.clip(t, 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def unpremultiply(rgb: np.ndarray, alpha01: np.ndarray, bg: np.ndarray, eps: float = 0.06) -> np.ndarray:
"""observed = fg*a + bg*(1-a) 를 풀어 fg를 복원한다. a가 아주 작을 때는 나눗셈을
eps로 바닥을 깔아 안정화한다(그 자리는 최종 알파가 작아 안 보이므로 무해)."""
a = np.clip(alpha01, 0.0, 1.0)[..., None]
denom = np.maximum(a, eps)
fg = (rgb - bg[None, None, :] * (1.0 - a)) / denom
return np.clip(fg, 0.0, 255.0)
def recompute_hair_edge_band(
rgb: np.ndarray, alpha: np.ndarray, mask: np.ndarray, category_mask: np.ndarray, ink_rgb: np.ndarray
) -> tuple[np.ndarray, np.ndarray, dict]:
"""머리카락 마스크 경계 띠(바깥 40px 배경 + 안쪽 6px)의 알파·RGB를 다시 계산한다."""
dil = binary_dilation(mask, iterations=HAIR_EDGE_BAND_OUT_PX)
band_outer = dil & ~mask & (category_mask == 0)
ero = binary_erosion(mask, iterations=HAIR_EDGE_BAND_IN_PX)
band_inner = mask & ~ero
band = band_outer | band_inner
lum = rgb.mean(axis=2)
t = (LUM_PAPER - lum) / (LUM_PAPER - LUM_INK)
alpha_new_band = smoothstep(t) * 255.0
out_rgb = rgb.copy()
out_alpha = alpha.copy()
out_alpha[band] = alpha_new_band[band]
a01 = out_alpha.astype(np.float64) / 255.0
unpre = unpremultiply(rgb, a01, CREAM_BG)
# 아주 어두운(순수 잉크에 가까운) 픽셀은 원본 유지, 나머지 band는 unpremultiply 결과 사용.
out_rgb[band] = unpre[band]
# ink 근접 폴백: 알파가 거의 0인 자리는 안 보이므로 잉크색으로 채워 안정화.
near_zero = band & (out_alpha < 3)
out_rgb[near_zero] = ink_rgb
report = {
"bandOuterPixels": int(band_outer.sum()),
"bandInnerPixels": int(band_inner.sum()),
}
return out_rgb, out_alpha, report
def remove_small_fragments(alpha: np.ndarray, min_area: int = MIN_FRAGMENT_AREA) -> tuple[np.ndarray, dict]:
binary = alpha > 127 # alpha>0.5
labeled, n = label(binary)
if n == 0:
return alpha, {"componentsBefore": 0, "removed": 0, "removedPixels": 0}
sizes = np.bincount(labeled.ravel())
sizes[0] = 0
main_label = int(np.argmax(sizes))
removed = 0
removed_px = 0
out = alpha.copy()
for lbl in range(1, n + 1):
if lbl == main_label:
continue
area = int(sizes[lbl])
if area < min_area:
out[labeled == lbl] = 0
removed += 1
removed_px += area
return out, {"componentsBefore": int(n), "removed": removed, "removedPixels": removed_px}
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))
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))
def fix_hair_paper_gaps(rgb: np.ndarray, alpha: np.ndarray, region: np.ndarray) -> tuple[np.ndarray, np.ndarray, dict]:
"""머리카락 분할 영역(6px 팽창, 피부 제외) 안에서 알파=1로 남은 종이색 픽셀(가닥
사이 틈)에 hair-edge 경계와 같은 잉크 알파 smoothstep을 적용하고 RGB를
unpremultiply한다. 이 함수가 받는 region은 이미 얼굴 피부·몸 피부를 뺀 것이어야
한다(호출부에서 보장)."""
a01 = alpha.astype(np.float64) / 255.0
paper = is_paper_color(rgb)
target = region & (a01 >= HAIR_PAPER_OPAQUE_ALPHA) & paper
lum = rgb.mean(axis=2)
t = (LUM_PAPER - lum) / (LUM_PAPER - LUM_INK)
alpha_new = smoothstep(t) * 255.0
out_alpha = alpha.copy()
out_alpha[target] = alpha_new[target]
a01_new = out_alpha.astype(np.float64) / 255.0
unpre = unpremultiply(rgb, a01_new, CREAM_BG)
out_rgb = rgb.copy()
out_rgb[target] = unpre[target]
return out_rgb, out_alpha, {"targetPixels": int(target.sum())}
def remove_jaw_ink_from_hairfront(
alpha: np.ndarray, mouth_center_y: float, boundary_dist: np.ndarray
) -> tuple[np.ndarray, dict]:
"""hairFront에만 있는 턱선 오분류 조각을 지운다(head는 건드리지 않는다 — head는
같은 픽셀을 face_skin_mask로도 이미 포함하므로 정상이다).
입 중심(mouth_center_y) 아래, faceOval 경계로부터 JAW_INK_DIST_TO_OVAL_PX 이내인
알파>0 픽셀을 후보로 모은다. 페더로 생긴 간극(<=JAW_INK_MERGE_PX)을 팽창으로 이어
붙여 하나의 성분으로 묶고, 성분별 폭/높이 비율을 본다. 비율이 JAW_INK_ASPECT_MIN을
넘는(가로로 긴, 턱선을 따라 이어지는) 성분만 알파를 0으로 지운다. 비율이 그 이하인
(세로로 긴, 실제로 늘어진 머리카락 가닥) 성분은 그대로 남긴다."""
h, w = alpha.shape
yy = np.arange(h)[:, None] * np.ones((1, w))
near_jaw = (alpha > 0) & (yy >= mouth_center_y) & (boundary_dist <= JAW_INK_DIST_TO_OVAL_PX)
out = alpha.copy()
removed_groups: list[dict] = []
kept_groups: list[dict] = []
if near_jaw.any():
dil = binary_dilation(near_jaw, iterations=JAW_INK_MERGE_PX, structure=np.ones((3, 3)))
labeled, n = label(dil, structure=np.ones((3, 3)))
for lbl in range(1, n + 1):
grp = (labeled == lbl) & near_jaw
cnt = int(grp.sum())
if cnt == 0:
continue
ys, xs = np.where(grp)
x0, x1 = int(xs.min()), int(xs.max())
y0, y1 = int(ys.min()), int(ys.max())
gw, gh = x1 - x0 + 1, y1 - y0 + 1
aspect = gw / max(gh, 1)
info = {
"pixels": cnt,
"bbox": [x0, y0, x1 + 1, y1 + 1],
"width": gw,
"height": gh,
"aspect": round(aspect, 3),
}
if aspect > JAW_INK_ASPECT_MIN:
out[grp] = 0
removed_groups.append(info)
else:
kept_groups.append(info)
report = {
"mouthCenterY": round(float(mouth_center_y), 2),
"distThresholdPx": JAW_INK_DIST_TO_OVAL_PX,
"mergePx": JAW_INK_MERGE_PX,
"aspectThreshold": JAW_INK_ASPECT_MIN,
"removedGroups": removed_groups,
"keptGroups": kept_groups,
"removedPixels": int(sum(g["pixels"] for g in removed_groups)),
}
return out, report
def hair_paper_opaque_pct(rgb: np.ndarray, alpha: np.ndarray, region: np.ndarray) -> float:
"""region(머리카락 6px 팽창, 피부 제외) 내에서 알파>=0.95이면서 종이색인 픽셀 비율(%)."""
n_region = int(region.sum())
if n_region == 0:
return 0.0
a01 = alpha.astype(np.float64) / 255.0
paper = is_paper_color(rgb)
hit = region & (a01 >= HAIR_PAPER_OPAQUE_ALPHA) & paper
return float(hit.sum()) / float(n_region) * 100.0
def general_halo_cleanup(
rgb: np.ndarray, alpha: np.ndarray, exclude: np.ndarray | None = None
) -> tuple[np.ndarray, np.ndarray]:
"""레이어 전체 경계(부분 알파 0.02~0.98)에 대해 unpremultiply를 적용한다.
exclude가 주어지면 이미 처리한 영역(예: 머리카락 경계 띠)은 다시 건드리지 않는다.
낮은 알파(<0.5)인데 unpremultiply해도 여전히 종이색에 가까운 픽셀은 실제 내용이
없는 페더 잡음이므로 알파를 0으로 접는다(다른 배경에서 종이색 유령이 보이는 것 방지)."""
a01 = alpha.astype(np.float64) / 255.0
edge = (a01 > 0.02) & (a01 < 0.98)
if exclude is not None:
edge = edge & ~exclude
if not edge.any():
return rgb, alpha
unpre = unpremultiply(rgb, a01, CREAM_BG)
out_rgb = rgb.copy()
out_rgb[edge] = unpre[edge]
dist = np.sqrt(((unpre - CREAM_BG[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:
a01 = alpha.astype(np.float64) / 255.0
edge = (a01 > 0.05) & (a01 < 0.95)
n_edge = int(edge.sum())
if n_edge == 0:
return 0.0
unpre = unpremultiply(rgb, a01, paper, eps=0.05)
dist = np.sqrt(((unpre - paper[None, None, :]) ** 2).sum(axis=2))
near_paper = (dist <= 30) & edge
return float(near_paper.sum()) / float(n_edge) * 100.0
def bg_leak_metric(alpha: np.ndarray, category_mask: np.ndarray, mask: np.ndarray) -> float:
dil = binary_dilation(mask, iterations=HAIR_EDGE_BAND_OUT_PX)
band = dil & ~mask
# mask(=hair_mask) 내부는 refine_hair_edge가 편입시킨 category0(배경) 픽셀을 포함할 수
# 있다(잔머리 보강용, 정상). "배경 분류 영역에서 경계 띠를 뺀 곳"은 머리카락이 아닌
# 순수 배경만 뜻하므로 mask 내부도 제외한다.
bg_far = (category_mask == 0) & ~band & ~mask
if not bg_far.any():
return 0.0
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)
print("=== 기준 데이터 재계산(2단계-A와 동일 알고리즘) ===")
f_img = build_padded_faceless()
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]
chin_xy = front_pts[152]
face_oval_mask, face_oval_poly = polygon_mask(
[front_pts[i] for i in FACE_OVAL_LOOP], w, h, scale=FACE_OVAL_SCALE
)
mouth_center_y = (
front_pts[MOUTH_CENTER_INNER_UPPER_IDX][1] + front_pts[MOUTH_CENTER_INNER_LOWER_IDX][1]
) / 2.0
face_oval_dist_in = distance_transform_edt(face_oval_mask)
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")
bg_mask = category_mask == 0
hair_mask_raw = category_mask == 1
body_skin_mask = category_mask == 2
face_skin_mask = category_mask == 3
clothes_mask = category_mask == 4
others_mask = category_mask == 5
hair_mask = refine_hair_edge(hair_mask_raw, f_arr.astype(np.uint8))
yy = np.arange(h)[:, None] * np.ones((1, w))
chin_line = chin_y + CHIN_MARGIN
head_mask = hair_mask | face_skin_mask | (body_skin_mask & (yy < chin_line))
hairfront_mask = hair_mask & face_oval_mask
body_base_mask_raw = clothes_mask | others_mask | (body_skin_mask & (yy >= chin_line))
# 분할 오분류 보정: 옷/기타로 분류됐지만 실제로는 종이 배경인 덩어리가 두 군데 있다
# (어깨 오른쪽 가장자리, 후드 왼쪽 밑단). 흰 티셔츠처럼 진짜 옷인데 밝은 영역과
# 구분하기 위해, "배경(category0)과 실제로 이어져 있는" 밝은 덩어리만 배경으로
# 되돌린다(흰 티셔츠는 어두운 후드에 둘러싸여 배경과 안 이어져 있어 보존된다).
paper_dist = np.sqrt(((f_arr - CREAM_BG[None, None, :]) ** 2).sum(axis=2))
paperlike = paper_dist < 20
seed_bg = category_mask == 0
candidate = body_base_mask_raw & paperlike
union_labeled, _ = label(seed_bg | candidate)
touch_labels = set(np.unique(union_labeled[seed_bg]).tolist()) - {0}
reclassify_to_bg = np.isin(union_labeled, list(touch_labels)) & candidate
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와 동일 방식으로 산출된 값
# ------------------------------------------------------------------
# (1) body 턱 밑 밝은 띠 보정
# ------------------------------------------------------------------
print("=== (1) body 턱 밑 띠 보정 ===")
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"))
regen_body_rgb, regen_body_alpha = chroma_key(raw_body)
fill_target = head_mask & (regen_body_alpha > 0)
dist_out = distance_transform_edt(~fill_target)
fill_weight = np.clip(1.0 - dist_out / FILL_FEATHER_PX, 0.0, 1.0)
fill_weight = gaussian_filter(fill_weight, sigma=FILL_FEATHER_PX / 2.35)
fill_weight = np.clip(fill_weight, 0.0, 1.0)
base_a = body_base_alpha / 255.0
fill_a = (regen_body_alpha.astype(np.float64) / 255.0) * fill_weight
out_a = base_a + fill_a * (1 - base_a)
eps = 1e-6
body_v1_rgb = (
body_base_rgb * base_a[..., None]
+ regen_body_rgb.astype(np.float64) * (fill_a * (1 - base_a))[..., None]
) / np.clip(out_a[..., None], eps, None)
body_v1_alpha = out_a * 255.0
# 목 그늘 소스 밴드(턱선 바로 아래, 실제로 보이는 F 픽셀)를 hole-fill해 위로 반사한다.
band_h = 150
source_valid = body_base_mask & (yy >= chin_line) & (yy < chin_line + band_h)
band_top = int(chin_line)
band_bottom = int(chin_line) + band_h
source_rgb = f_arr[band_top:band_bottom, :, :]
source_valid_band = source_valid[band_top:band_bottom, :]
# 결측(머리카락 등) 픽셀은 최근접 유효 픽셀로 채운다(가는 잔머리 틈 메움).
_, (iy, ix) = distance_transform_edt(~source_valid_band, return_indices=True)
clean_band = source_rgb[iy, ix, :]
target_region = fill_target & (yy < chin_line) & (~body_base_mask)
rng = np.random.default_rng(RNG_SEED)
row_jitter = rng.uniform(-2.0, 2.0, size=int(round(chin_line)) + 1)
reflected_rgb = body_v1_rgb.copy()
ys_t, xs_t = np.where(target_region)
if len(ys_t):
d = chin_line - ys_t
src_row_f = np.clip(d + row_jitter[ys_t], 0, band_h - 1.001)
r0 = np.floor(src_row_f).astype(int)
r1 = np.minimum(r0 + 1, band_h - 1)
frac = (src_row_f - r0)[:, None]
px = clean_band[r0, xs_t, :] * (1 - frac) + clean_band[r1, xs_t, :] * frac
reflected_rgb[ys_t, xs_t, :] = px
# target_region 경계를 부드럽게(가우시안 가중 블렌드)해 이음매를 없앤다.
blend_w = gaussian_filter(target_region.astype(np.float64), sigma=2.0)
blend_w = np.clip(blend_w, 0.0, 1.0)
body_v2_rgb = body_v1_rgb * (1 - blend_w[..., None]) + reflected_rgb * blend_w[..., None]
body_v2_alpha = body_v1_alpha # 알파(형태)는 바꾸지 않는다.
body_v2_rgb, body_v2_alpha = general_halo_cleanup(body_v2_rgb, body_v2_alpha)
print(f"target_region(턱 밑 채움 대상, raw 채움 제거 대상) 픽셀 수: {int(target_region.sum())}")
# ------------------------------------------------------------------
# (2) head / hairFront 잔머리·halo 보정
# ------------------------------------------------------------------
print("=== (2) head 잔머리·halo 보정 ===")
head_rgb_v1 = f_arr.copy()
head_alpha_v1 = feather_bool_mask(head_mask, FEATHER_PX)
head_rgb_v2, head_alpha_v2, head_band_report = recompute_hair_edge_band(
head_rgb_v1, head_alpha_v1, hair_mask, category_mask, ink_rgb
)
head_alpha_v2, head_frag_report = remove_small_fragments(head_alpha_v2)
hair_band_mask = binary_dilation(hair_mask, iterations=HAIR_EDGE_BAND_OUT_PX) & ~binary_erosion(hair_mask, iterations=HAIR_EDGE_BAND_IN_PX)
head_rgb_v2, head_alpha_v2 = general_halo_cleanup(head_rgb_v2, head_alpha_v2, exclude=hair_band_mask)
print(f"head band: {head_band_report}, fragment 제거: {head_frag_report}")
print("=== (2) hairFront 잔머리·halo 보정 ===")
hairfront_rgb_v1 = f_arr.copy()
hairfront_alpha_v1 = feather_bool_mask(hairfront_mask, FEATHER_PX)
hairfront_rgb_v2, hairfront_alpha_v2, hf_band_report = recompute_hair_edge_band(
hairfront_rgb_v1, hairfront_alpha_v1, hairfront_mask, category_mask, ink_rgb
)
hairfront_alpha_v2, hf_frag_report = remove_small_fragments(hairfront_alpha_v2)
hf_band_mask = binary_dilation(hairfront_mask, iterations=HAIR_EDGE_BAND_OUT_PX) & ~binary_erosion(hairfront_mask, iterations=HAIR_EDGE_BAND_IN_PX)
hairfront_rgb_v2, hairfront_alpha_v2 = general_halo_cleanup(hairfront_rgb_v2, hairfront_alpha_v2, exclude=hf_band_mask)
print(f"hairFront band: {hf_band_report}, fragment 제거: {hf_frag_report}")
# ------------------------------------------------------------------
# (2b) 머리카락 안쪽 불투명 종이 틈 제거 — 머리카락 분할(category==1) 6px
# 팽창 영역에서 얼굴/몸 피부를 뺀 범위. 가닥 사이에 알파=1로 남은 종이색
# 픽셀에 hair-edge와 같은 잉크 알파를 적용한다. 얼굴 피부·몸 피부·body
# 레이어는 이 범위에서 애초에 제외되어 절대 건드리지 않는다.
# ------------------------------------------------------------------
print("=== (2b) 머리카락 안쪽 종이 틈 제거 ===")
hair_paper_region = binary_dilation(hair_mask_raw, iterations=HAIR_PAPER_DILATE_PX) & ~body_skin_mask & ~face_skin_mask
head_alpha_before_gap = head_alpha_v2.copy()
head_rgb_v2, head_alpha_v2, head_paper_report = fix_hair_paper_gaps(head_rgb_v2, head_alpha_v2, hair_paper_region)
head_alpha_v2, head_frag_report2 = remove_small_fragments(head_alpha_v2)
head_face_alpha_diff = (
float(np.abs(head_alpha_v2[face_skin_mask].astype(np.float64) - head_alpha_before_gap[face_skin_mask].astype(np.float64)).mean())
if face_skin_mask.any() else 0.0
)
print(f"head paper-gap: {head_paper_report}, 추가 조각 제거: {head_frag_report2}, 얼굴피부 알파변화={head_face_alpha_diff:.6f}")
hairfront_alpha_before_gap = hairfront_alpha_v2.copy()
hairfront_rgb_v2, hairfront_alpha_v2, hf_paper_report = fix_hair_paper_gaps(hairfront_rgb_v2, hairfront_alpha_v2, hair_paper_region)
hairfront_alpha_v2, hf_frag_report2 = remove_small_fragments(hairfront_alpha_v2)
hf_face_alpha_diff = (
float(np.abs(hairfront_alpha_v2[face_skin_mask].astype(np.float64) - hairfront_alpha_before_gap[face_skin_mask].astype(np.float64)).mean())
if face_skin_mask.any() else 0.0
)
print(f"hairFront paper-gap: {hf_paper_report}, 추가 조각 제거: {hf_frag_report2}, 얼굴피부 알파변화={hf_face_alpha_diff:.6f}")
# ------------------------------------------------------------------
# (2c) hairFront 턱선 오분류 조각 제거 — head는 건드리지 않는다(위 설명 참고).
# ------------------------------------------------------------------
print("=== (2c) hairFront 턱선 오분류 조각 제거 ===")
hairfront_alpha_v2, jaw_ink_report = remove_jaw_ink_from_hairfront(
hairfront_alpha_v2, mouth_center_y, face_oval_boundary_dist
)
# 지운 덩어리에 안티앨리어싱으로 붙어 있던, 14px 경계띠 밖으로 살짝 벗어난 잔점(<60px)을 마저 치운다.
hairfront_alpha_v2, jaw_ink_frag_report = remove_small_fragments(hairfront_alpha_v2)
print(f"hairFront 턱선 조각: {jaw_ink_report}, 잔점 제거: {jaw_ink_frag_report}")
# ------------------------------------------------------------------
# 저장
# ------------------------------------------------------------------
layers_out = {
"body": (body_v2_rgb, body_v2_alpha),
"head": (head_rgb_v2, head_alpha_v2),
"hairFront": (hairfront_rgb_v2, hairfront_alpha_v2),
}
layers_report = []
for layer_id in ("body", "head", "hairFront"):
rgb, alpha = layers_out[layer_id]
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())
residue = green_residue_count(to_u8(rgb), to_u8(alpha))
print(f"[{layer_id}] 저장 {out_path.name} bbox={bbox} opaque={opaque} greenResidue={residue}")
layers_report.append({"id": layer_id, "bbox": bbox, "opaque": opaque, "greenResidue": residue})
# ------------------------------------------------------------------
# 검증 수치
# ------------------------------------------------------------------
print("=== 검증 ===")
metrics = {}
for layer_id in ("body", "head", "hairFront"):
rgb, alpha = layers_out[layer_id]
hm = halo_metric(to_u8(rgb).astype(np.float64), to_u8(alpha))
metrics[layer_id] = {"haloPct": hm}
print(f"halo({layer_id}) = {hm:.3f}% (기준 <=2%)")
for layer_id, mask in (("head", hair_mask), ("hairFront", hairfront_mask)):
_, alpha = layers_out[layer_id]
leak = bg_leak_metric(to_u8(alpha), category_mask, mask)
metrics[layer_id]["bgLeakAlphaMean"] = leak
print(f"배경 알파 누설({layer_id}) 평균 = {leak:.5f} (기준 <0.01)")
for layer_id in ("head", "hairFront"):
rgb, alpha = layers_out[layer_id]
pct = hair_paper_opaque_pct(to_u8(rgb).astype(np.float64), to_u8(alpha), hair_paper_region)
metrics[layer_id]["hairPaperOpaquePct"] = pct
print(f"hairPaperOpaquePct({layer_id}) = {pct:.4f}% (기준 <=0.5%)")
labeled_head, n_head = label(to_u8(layers_out["head"][1]) > 127)
sizes_head = np.bincount(labeled_head.ravel()); sizes_head[0] = 0
main_head = int(np.argmax(sizes_head))
frag_head = int(((sizes_head > 0) & (sizes_head < MIN_FRAGMENT_AREA) & (np.arange(len(sizes_head)) != main_head)).sum())
labeled_hf, n_hf = label(to_u8(layers_out["hairFront"][1]) > 127)
sizes_hf = np.bincount(labeled_hf.ravel()); sizes_hf[0] = 0
main_hf = int(np.argmax(sizes_hf)) if len(sizes_hf) > 1 else 0
frag_hf = int(((sizes_hf > 0) & (sizes_hf < MIN_FRAGMENT_AREA) & (np.arange(len(sizes_hf)) != main_hf)).sum())
print(f"미연결 조각(<60px, 주성분 제외) head={frag_head} hairFront={frag_hf} (기준 0개)")
metrics["head"]["disconnectedFragmentsUnder60px"] = frag_head
metrics["hairFront"]["disconnectedFragmentsUnder60px"] = frag_hf
# ------------------------------------------------------------------
# 미리보기: chin-band 전/후, hair-edge 전/후
# ------------------------------------------------------------------
def load_v1(name):
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
out = np.zeros((h2, w2, 4))
out[..., :3] = bg
out[..., 3] = 255.0
for rgb, alpha in layers:
out = composite_over(out, to_u8(rgb), to_u8(alpha))
return to_u8(out)
body_v1 = load_v1("body")
head_v1 = load_v1("head")
hf_v1 = load_v1("hairFront")
def pair_layers(use_v2_body, use_v2_head, use_v2_hf):
b = layers_out["body"] if use_v2_body else (body_v1[..., :3], body_v1[..., 3])
hh = layers_out["head"] if use_v2_head else (head_v1[..., :3], head_v1[..., 3])
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_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)
def motion_composite(bg, body_layer, head_layer, hf_layer, rotate_deg, ty):
b_rgb, b_a = body_layer
h_rgb, h_a = to_u8(head_layer[0]), to_u8(head_layer[1])
hf_rgb, hf_a = to_u8(hf_layer[0]), to_u8(hf_layer[1])
if rotate_deg != 0.0:
h_rgb, h_a = rotate_rgba(h_rgb, h_a, rotate_deg, neck_pivot)
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, rotate_deg, neck_pivot)
if ty != 0.0:
h_rgb, h_a = translate_rgba(h_rgb, h_a, 0, ty)
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, 0, ty * 1.4)
return composite(bg, [(to_u8(b_rgb), to_u8(b_a)), (h_rgb, h_a), (hf_rgb, hf_a)])
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)
box = (100, 750, 950, 1150)
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),
]
cw, ch = crops[0].size
chin_strip = Image.new("RGB", (cw * 4 + 30, ch), (255, 255, 255))
x = 0
for im in crops:
chin_strip.paste(im, (x, 0)); x += cw + 10
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))
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")
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
manifest.setdefault("layersV2", {})
manifest["layersV2"] = {
"chinBandFix": {
"targetRegionPixels": int(target_region.sum()),
"sourceBandHeight": band_h,
"rowJitterRange": [-2.0, 2.0],
"rngSeed": RNG_SEED,
},
"hairEdgeFix": {
"bandOuterPx": HAIR_EDGE_BAND_OUT_PX,
"bandInnerPx": HAIR_EDGE_BAND_IN_PX,
"lumSmoothstep": [LUM_PAPER, LUM_INK],
"minFragmentAreaPx": MIN_FRAGMENT_AREA,
"head": head_band_report | head_frag_report,
"hairFront": hf_band_report | hf_frag_report,
},
"hairPaperGapFix": {
"regionDilatePx": HAIR_PAPER_DILATE_PX,
"paperColorDistThreshold": PAPER_COLOR_DIST,
"paperLumThreshold": PAPER_LUM_THRESH,
"paperSatThreshold": PAPER_SAT_THRESH,
"opaqueAlphaThreshold": HAIR_PAPER_OPAQUE_ALPHA,
"regionPixels": int(hair_paper_region.sum()),
"head": head_paper_report | head_frag_report2 | {"faceSkinAlphaDiff": head_face_alpha_diff},
"hairFront": hf_paper_report | hf_frag_report2 | {"faceSkinAlphaDiff": hf_face_alpha_diff},
},
"jawInkFix": jaw_ink_report | {"strayFragmentCleanup": jaw_ink_frag_report},
"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}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,452 @@
"""P1 서연 입술 결 스프라이트 생성 — 2단계-B-1d-A5(작업 패킷 A5, §8.4 2026-10-01 개정 반영).
원칙(결정문 §8.4 개정): **중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면
원화 픽셀이 변형된다.** 입술 모양은 스프라이트 알파가 정하고, 렌더러는 스프라이트를
세로 띠로 변형만 한다. A2'(half 분할 + 6px 최근접 복제 확장)는 렌더러가 벡터 입술
윤곽으로 결을 잘라내자 윗입술이 뾰족해지고 입꼬리가 바늘처럼 튀어나오고 아랫입술
그늘이 성긴 빗금이 되는 문제를 낳았다 — 원인은 확장 영역이 좁고(6px) 입꼬리 홈·
입 선 전체·아랫입술 그늘이 벡터 윤곽 안에 다 들어가지 못한 것.
A3 재설계:
- 스프라이트 영역 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) 밖은 양끝 접선으로 연장한다.
A4 수정(2026-10-01, 렌더 비교 반려): 화면 배율로 재샘플링되면서 두 스프라이트가
Cu0에서 딱 맞닿는 이음매가 가는 밝은 실금으로 보였다(경계 알파가 재샘플링으로
반투명해져 밑 피부가 비침). 윗 스프라이트를 y<=Cu0(x)+2(분할선 아래 2px)까지
겹쳐 담아 이음매를 없앤다 — 겹침 2px는 같은 원화 픽셀·같은 알파이고, 렌더러가
아랫 스프라이트를 먼저 그리고 윗 스프라이트를 그 위에 덮어 그리므로(§8.4) 중립
합성 결과는 바뀌지 않는다. 아랫 스프라이트·알파 램프 규칙은 그대로.
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
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
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"
DIFF_THRESH = 20.0
MIN_COMPONENT_AREA = 30.0
DILATE_PX = 10.0 # 입술 색 마스크 확장 폭(유클리드 거리) — 윗/아랫 스프라이트 R
RAMP_PX = 4.0 # R 경계 안쪽 알파 선형 램프 폭(윗/아랫 스프라이트)
SPLIT_OVERLAP_PX = 2.0 # 윗 스프라이트가 분할선(Cu0) 아래로 겹쳐 담는 폭(이음매 방지, A4)
BAND_PX = 18.0 # 그늘 조각: 아랫입술 마스크 아래 그늘 띠 폭
SHADOW_TOP_OVERLAP_PX = 6.0 # 그늘 조각: 아랫입술 마스크 경계보다 위로 겹쳐 담는 폭
SHADOW_SIDE_RAMP_PX = 6.0 # 그늘 조각: 아래·옆(열 끊김) 알파 선형 램프 폭
SHADOW_TOP_RAMP_PX = 4.0 # 그늘 조각: 위쪽 겹침 구간 안의 알파 선형 램프 폭
WORKING_MARGIN = 40.0 # 확장(10px)+그늘 띠(18px)+여유를 위한 작업 캔버스 여백
SIZE_CAP_W = 162.0
SIZE_CAP_H = 70.0
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)
return np.poly1d(coef)
def extended_curve(cu: np.poly1d, x_lo: float, x_hi: float, xs: np.ndarray) -> np.ndarray:
"""Cu0을 [x_lo, x_hi] 안에서는 그대로, 밖에서는 양끝 접선으로 연장한 y값."""
deriv = cu.deriv()
y = cu(xs)
y_lo = cu(x_lo) + deriv(x_lo) * (xs - x_lo)
y_hi = cu(x_hi) + deriv(x_hi) * (xs - x_hi)
y = np.where(xs < x_lo, y_lo, y)
y = np.where(xs > x_hi, y_hi, y)
return y
def crop_to_alpha_bbox(alpha: np.ndarray, rgb: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]:
ys, xs = np.where(alpha > 0)
x0, x1 = int(xs.min()), int(xs.max()) + 1
y0, y1 = int(ys.min()), int(ys.max()) + 1
out = np.zeros((y1 - y0, x1 - x0, 4), dtype=np.uint8)
out[..., :3] = rgb[y0:y1, x0:x1]
out[..., 3] = alpha[y0:y1, x0:x1]
return out, (x0, y0, x1 - x0, y1 - y0)
def main() -> int:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
lm = manifest["landmarks"]
global LM
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")
mouth_center = compute_mouth_center(points)
LM["Mc"] = mouth_center
print(f"mouthCenter(재계산) = {mouth_center}")
lc, rc, t, b, mc = LM["Lc"], LM["Rc"], LM["T"], LM["B"], LM["Mc"]
cu = curve_fit(lc, mc, rc)
x_lo, x_hi = (lc[0], rc[0]) if lc[0] <= rc[0] else (rc[0], lc[0])
margin_x = (SIZE_CAP_W - (rc[0] - lc[0])) / 2.0
margin_y = (SIZE_CAP_H - (b[1] - t[1])) / 2.0
cap_x0, cap_x1 = lc[0] - margin_x, rc[0] + margin_x
cap_y0, cap_y1 = t[1] - margin_y, b[1] + margin_y
print(f"입술 마스크 검색 bbox(캡) = [{cap_x0:.1f},{cap_y0:.1f},{cap_x1:.1f},{cap_y1:.1f}] "
f"w={cap_x1-cap_x0:.1f}(<=162) h={cap_y1-cap_y0:.1f}(<=70)")
icx0, icy0, icx1, icy1 = (int(round(cap_x0)), int(round(cap_y0)), int(round(cap_x1)), int(round(cap_y1)))
wx0 = icx0 - int(WORKING_MARGIN)
wy0 = icy0 - int(WORKING_MARGIN)
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_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'와 동일) ---
cap_rgb = front_full[icy0:icy1, icx0:icx1]
lab = cv2.cvtColor(cap_rgb, cv2.COLOR_RGB2LAB)
a_chan = lab[..., 1].astype(np.float64)
u8 = np.clip(np.round(a_chan), 0, 255).astype(np.uint8)
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
lip_color_mask_cap = a_chan >= otsu_thresh
diff_cap = diff_full[icy0:icy1, icx0:icx1]
diff_mask_cap = diff_cap >= DIFF_THRESH
combined_cap = lip_color_mask_cap & diff_mask_cap
filled_cap = ndimage.binary_fill_holes(combined_cap)
lbl, n = ndimage.label(filled_cap)
sizes = ndimage.sum(filled_cap, lbl, range(1, n + 1)) if n else np.array([])
keep = np.isin(lbl, [i + 1 for i, s in enumerate(sizes) if s >= MIN_COMPONENT_AREA]) if n else np.zeros_like(lbl, dtype=bool)
combined_cap = filled_cap & keep
print(f"otsu(Lab a채널, 캡 bbox) = {otsu_thresh}, 입술 색 마스크 픽셀 = {int(combined_cap.sum())}")
combined_work = np.zeros((wy1 - wy0, wx1 - wx0), dtype=bool)
combined_work[icy0 - wy0:icy1 - wy0, icx0 - wx0:icx1 - wx0] = combined_cap
h, w = combined_work.shape
# --- 중립 입 선 곡선 Cu0(연장) → 위/아래 부호 d ---
xs_abs = np.arange(wx0, wx1, dtype=np.float64)
cu0_y = extended_curve(cu, x_lo, x_hi, xs_abs) # (w,)
rows_local = np.arange(h)[:, None]
yy_abs = rows_local + wy0
d = yy_abs - cu0_y[None, :] # d<=0: 윗입술 쪽, d>0: 아랫입술 쪽
# --- R = 입술 색 마스크를 10px 넓힌 영역(윗/아랫 스프라이트 전용, A5부터 그늘 띠 제외) ---
dist_out = ndimage.distance_transform_edt(~combined_work)
region = combined_work | (dist_out <= DILATE_PX)
print(f"R(확장, 윗/아랫 스프라이트) 픽셀 = {int(region.sum())}")
if region[0, :].any() or region[-1, :].any() or region[:, 0].any() or region[:, -1].any():
raise SystemExit("[중단] R이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.")
# --- 알파: R 기준 거리 변환 1회 → 분할선과 무관한 램프 ---
dist_in = ndimage.distance_transform_edt(region)
alpha_frac = np.clip(dist_in / RAMP_PX, 0.0, 1.0)
alpha_full = np.rint(alpha_frac * 255.0).astype(np.uint8)
alpha_full[~region] = 0
# 윗 스프라이트는 분할선(Cu0) 아래로 SPLIT_OVERLAP_PX만큼 겹쳐 담는다(이음매 방지, A4).
# 아랫 스프라이트는 그대로 d>0. 겹침 구간(0<d<=SPLIT_OVERLAP_PX)은 두 스프라이트 모두
# 같은 alpha_full·같은 front_work 픽셀을 담으므로 렌더러가 위(윗 스프라이트)를 덮어
# 그리면 중립 합성은 바뀌지 않는다.
mask_upper = region & (d <= SPLIT_OVERLAP_PX)
mask_lower = region & (d > 0)
alpha_upper = np.where(mask_upper, alpha_full, 0).astype(np.uint8)
alpha_lower = np.where(mask_lower, alpha_full, 0).astype(np.uint8)
overlap = mask_upper & mask_lower
overlap_expected = region & (d > 0) & (d <= SPLIT_OVERLAP_PX)
overlap_band_ok = bool(np.array_equal(overlap, overlap_expected))
overlap_alpha_diff = int(np.abs(alpha_upper[overlap].astype(np.int32) - alpha_lower[overlap].astype(np.int32)).max()) if overlap.any() else 0
overlap_consistent_ok = overlap_alpha_diff == 0
coverage_mismatch = region ^ (mask_upper | mask_lower)
split_ok = bool(overlap_band_ok and overlap_consistent_ok and coverage_mismatch.sum() == 0)
print(f"검사(2) 겹침 픽셀수={int(overlap.sum())}(전부 Cu0 아래 {SPLIT_OVERLAP_PX}px 띠 안={overlap_band_ok}) "
f"겹침 알파 불일치 최대차={overlap_alpha_diff} 틈(불일치) 픽셀수={int(coverage_mismatch.sum())} "
f"{'OK' if split_ok else '[실패]'}")
# --- 그늘 조각(lip-shadow, A5): 아랫입술 마스크(combined_work & d>0) 아래 경계에서
# 열마다 18px 띠. 위쪽은 마스크 경계보다 6px 위까지 겹쳐 담고, 그 6px 안에서만 4px
# 램프를 쓴다. 아래·옆(열이 끊기는 곳)은 6px 램프다. ---
lower_lip_mask = combined_work & (d > 0)
masked_rows = np.where(lower_lip_mask, rows_local, -1)
y_bottom_local = masked_rows.max(axis=0) # (w,), 마스크 없는 열은 -1
has_col = y_bottom_local >= 0
shadow_top_local = y_bottom_local.astype(np.float64) - SHADOW_TOP_OVERLAP_PX # (w,) 그늘 조각 윗 경계(제외)
shadow_bottom_local = y_bottom_local.astype(np.float64) + BAND_PX # (w,) 그늘 조각 아래 경계(포함)
shadow_region = (
has_col[None, :]
& (rows_local > shadow_top_local[None, :])
& (rows_local <= shadow_bottom_local[None, :])
)
print(f"그늘 조각 영역 픽셀 = {int(shadow_region.sum())} (열 수 {int(has_col.sum())})")
if shadow_region[0, :].any() or shadow_region[-1, :].any() or shadow_region[:, 0].any() or shadow_region[:, -1].any():
raise SystemExit("[중단] 그늘 조각이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.")
# 아래·옆 램프: 위쪽 경계를 두지 않은(열이 있으면 아래 경계까지는 전부 포함) 영역을
# 따로 거리 변환해, 옆(열이 끊기는 곳)·아래 가장자리에서만 램프가 생기게 한다.
outer_calc_region = has_col[None, :] & (rows_local <= shadow_bottom_local[None, :])
dist_in_outer = ndimage.distance_transform_edt(outer_calc_region)
alpha_outer_frac = np.clip(dist_in_outer / SHADOW_SIDE_RAMP_PX, 0.0, 1.0)
# 위쪽 겹침 램프: 그늘 조각 윗 경계(마스크 경계 6px 위)에서 4px에 걸쳐 0→1.
dist_from_top = rows_local - shadow_top_local[None, :]
alpha_top_frac = np.clip(dist_from_top / SHADOW_TOP_RAMP_PX, 0.0, 1.0)
alpha_shadow_frac = np.minimum(alpha_outer_frac, alpha_top_frac)
alpha_shadow = np.where(shadow_region, np.rint(alpha_shadow_frac * 255.0), 0).astype(np.uint8)
sprites = {
"upper": {"alpha": alpha_upper, "rgb": front_work, "mask": mask_upper},
"lower": {"alpha": alpha_lower, "rgb": front_work, "mask": mask_lower},
"shadow": {"alpha": alpha_shadow, "rgb": front_work, "mask": shadow_region},
}
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"
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)]
print(f"저장: {out_path} bbox(작업캔버스 로컬)={bbox} 캔버스원점={canvas_origin[half]} size={png_arr.shape[1]}x{png_arr.shape[0]}")
# --- 검사(1) 알파 255 영역의 PNG 픽셀 = base-front(원화 픽셀을 지우거나 채우지 않았으므로 항상 0) ---
full_op = s["alpha"] == 255
ramp = (s["alpha"] > 0) & (s["alpha"] < 255)
diff_full_op = np.abs(s["rgb"][full_op].astype(np.int32) - front_work[full_op].astype(np.int32))
max_diff_full_op = int(diff_full_op.max()) if full_op.any() else 0
check1 = max_diff_full_op == 0
print(f" 검사(1) [{key}] 알파255 영역 PNG 최대차={max_diff_full_op} "
f"(알파255 {int(full_op.sum())}px, 램프 {int(ramp.sum())}px) {'OK' if check1 else '[실패]'}")
checks[key] = {
"regionPixelCount": int(s["mask"].sum()),
"fullOpacityPixelCount": int(full_op.sum()),
"rampPixelCount": int(ramp.sum()),
"preserveMaxDiffFullOpacity": max_diff_full_op,
"check1Preserve": bool(check1),
"spriteBBoxWorkingLocal": list(bbox),
}
# --- 검사(3) 중립 합성: base-faceless-padded 위에 아랫 → 그늘 → 윗 스프라이트 순으로
# 알파 합성한다(§8.4 그리는 순서와 같게) 대 base-front. ---
front_f64 = front_work.astype(np.float64)
alpha_lower_frac = (alpha_lower.astype(np.float64) / 255.0)[..., None]
step_lower = faceless_work * (1.0 - alpha_lower_frac) + front_f64 * alpha_lower_frac
alpha_shadow_frac3 = (alpha_shadow.astype(np.float64) / 255.0)[..., None]
step_shadow = step_lower * (1.0 - alpha_shadow_frac3) + front_f64 * alpha_shadow_frac3
alpha_upper_frac = (alpha_upper.astype(np.float64) / 255.0)[..., None]
composite = step_shadow * (1.0 - alpha_upper_frac) + front_f64 * alpha_upper_frac
# 실효 알파(세 장을 겹쳐 그린 뒤 실제로 얼마나 덮였는지, over-over-over 합성식) — 보고용 분류 기준.
eff_alpha = 255.0 - (
(255.0 - alpha_upper.astype(np.float64))
* (255.0 - alpha_shadow.astype(np.float64))
* (255.0 - alpha_lower.astype(np.float64))
/ (255.0 ** 2)
)
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])
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())
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+아랫+그늘) 합성이 원화와 같다 ---
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]
max_diff_overlap = float(diff_overlap.max()) if overlap_shadow_lower.any() else 0.0
diff_overlap_gt1_count = int((diff_overlap > 1.0).sum())
shadow_lower_overlap_ok = max_diff_overlap == 0.0
print(f"검사(5-3) 그늘·아랫 겹침 픽셀수={int(overlap_shadow_lower.sum())} "
f"(F+아랫+그늘) 대 원화 최대차={max_diff_overlap:.3f} diff>1 픽셀수={diff_overlap_gt1_count} "
f"{'OK' if shadow_lower_overlap_ok else '[실패, 원인은 manifest 참고]'}")
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)
manifest["lipTexture"] = {
"designVersion": "A5 (2단계-B-1d-A5, 작업 패킷 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)],
"otsuThreshLabA": otsu_thresh,
"diffThresh": DIFF_THRESH,
"dilatePx": DILATE_PX,
"alphaRampPx": RAMP_PX,
"splitOverlapPx": SPLIT_OVERLAP_PX,
"shadowBandPx": BAND_PX,
"shadowTopOverlapPx": SHADOW_TOP_OVERLAP_PX,
"shadowSideRampPx": SHADOW_SIDE_RAMP_PX,
"shadowTopRampPx": SHADOW_TOP_RAMP_PX,
"checks": {
**checks,
"splitOverlapPixelCount": int(overlap.sum()),
"splitOverlapBandOk": overlap_band_ok,
"splitOverlapAlphaMaxDiff": overlap_alpha_diff,
"splitOverlapConsistentOk": overlap_consistent_ok,
"splitCoverageMismatchPixelCount": int(coverage_mismatch.sum()),
"splitCheckOk": split_ok,
"shadowLowerOverlapPixelCount": int(overlap_shadow_lower.sum()),
"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),
"rampMeanAbsDiff": round(mean_diff_ramp_cap, 3),
"mouthBBoxMeanAbsDiff": round(mean_diff_all_cap, 3),
},
"pngFileSizes": file_sizes_png,
"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}")
return 0
def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow) -> None:
"""원화 입 | 중립 합성 | 윗 스프라이트(체커) | 아랫 스프라이트(체커) | 그늘 조각(체커), 3배 확대.
bbox는 세 스프라이트 알파>0 영역의 합집합(+pad)에서 구한다 — 10px 확장·그늘
조각(6px 겹침+18px 띠)으로 R이 캡 bbox보다 커졌으므로 고정 pad만으로는 잘릴 수 있다.
"""
union_alpha = (alpha_upper > 0) | (alpha_lower > 0) | (alpha_shadow > 0)
ys_u, xs_u = np.where(union_alpha)
ubx0, uby0 = int(xs_u.min()), int(ys_u.min())
ubx1, uby1 = int(xs_u.max()) + 1, int(ys_u.max()) + 1
pad = EVIDENCE_PAD
bx0, by0 = max(0, wx0 + ubx0 - pad), max(0, wy0 + uby0 - pad)
bx1, by1 = wx0 + ubx1 + pad, wy0 + uby1 + pad
def zoom(arr_rgb, nearest=False):
crop = arr_rgb[by0:by1, bx0:bx1]
im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8))
resample = Image.NEAREST if nearest else Image.LANCZOS
return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), resample)
panel_orig = zoom(front_full)
# composite는 작업 캔버스 크기의 로컬 배열(원점 wx0,wy0)이다 — 절대좌표를 로컬로 옮겨 잘라야 한다.
comp_bx0, comp_by0 = bx0 - wx0, by0 - wy0
comp_bx1, comp_by1 = bx1 - wx0, by1 - wy0
panel_composite = Image.fromarray(
np.clip(composite[comp_by0:comp_by1, comp_bx0:comp_bx1], 0, 255).astype(np.uint8)
)
panel_composite = panel_composite.resize(
(panel_composite.width * EVIDENCE_ZOOM, panel_composite.height * EVIDENCE_ZOOM), Image.LANCZOS
)
def sprite_on_checker(half):
s = sprites[half]
alpha = s["alpha"]; rgb = s["rgb"]
ys_, xs_ = np.where(alpha > 0)
x0, x1 = xs_.min(), xs_.max() + 1
y0, y1 = ys_.min(), ys_.max() + 1
crop_rgb = rgb[y0:y1, x0:x1].astype(np.float64)
crop_a = (alpha[y0:y1, x0:x1].astype(np.float64) / 255.0)[..., None]
check = np.indices(crop_a.shape[:2])
checker = ((check[0] // 6 + check[1] // 6) % 2) * 60 + 180
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
out = crop_rgb * crop_a + checker3 * (1.0 - crop_a)
im = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), Image.NEAREST)
panel_upper = sprite_on_checker("upper")
panel_lower = sprite_on_checker("lower")
panel_shadow = sprite_on_checker("shadow")
panels = [panel_orig, panel_composite, panel_upper, panel_lower, panel_shadow]
gap = 12
max_h = max(p.height for p in panels)
total_w = sum(p.width for p in panels) + gap * (len(panels) - 1)
combined = Image.new("RGB", (total_w, max_h), (255, 255, 255))
x = 0
for p in panels:
combined.paste(p.convert("RGB"), (x, 0))
x += p.width + gap
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"
combined.save(out_path, "JPEG", quality=92)
print(f"저장: {out_path}")
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,177 @@
"""P1 서연 종이결 타일 생성 — 2단계-B-1a (4).
base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서
256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다.
np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤 그 십자선만 국소적으로
블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서
서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다.
실행: <venv>/python.exe build_paper_grain.py
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
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"
TILE_SIZE = 256
PERSON_MARGIN_PX = 20
CANVAS_EDGE_MARGIN_PX = 10 # 캔버스 실제 가장자리(테두리 비네팅 위험) 회피
MIN_CLEAN_FRAC = 0.90 # 완전히 깨끗한 256x256 창이 캔버스 어디에도 없어 최선의 창을 쓰고 나머지는 메운다
TARGET_LUM_MIN = 245.0
TARGET_LUM_MAX = 250.0
TARGET_LUM_MID = (TARGET_LUM_MIN + TARGET_LUM_MAX) / 2.0
CHROMA_KEEP = 0.30 # 원래 색조를 30%만 남기고(거의 무채색), 70%는 명도값으로 대체
SEAM_FALLOFF_PX = 24.0
SEAM_BLUR_SIGMA = 6.0
def find_tile_origin(eligible: np.ndarray, size: int) -> tuple[int, int, float]:
"""eligible 비율이 가장 높은 size x size 창의 좌상단 좌표와 그 비율을 고른다.
캔버스 인물이 배경 대부분 면적에 걸쳐 있어(세로로 긴 흉상 구도) 잔머리 등
가장자리 침범 때문에 완전히 100% 깨끗한 창은 어디에도 없다 — 최선의 창을 고르고
남은 오염 픽셀은 호출부에서 인접 픽셀로 메운다."""
h, w = eligible.shape
mask_f = eligible.astype(np.float64)
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
y_lo, y_hi = CANVAS_EDGE_MARGIN_PX, h - size - CANVAS_EDGE_MARGIN_PX
x_lo, x_hi = CANVAS_EDGE_MARGIN_PX, w - size - CANVAS_EDGE_MARGIN_PX
if y_hi < y_lo or x_hi < x_lo:
y_lo, y_hi, x_lo, x_hi = 0, h - size, 0, w - size
sub = sums[y_lo:y_hi + 1, x_lo:x_hi + 1]
idx = np.unravel_index(np.argmax(sub), sub.shape)
y0, x0 = int(idx[0] + y_lo), int(idx[1] + x_lo)
frac = float(sub[idx]) / float(size * size)
if frac < MIN_CLEAN_FRAC:
raise SystemExit(f"[중단] 가장 깨끗한 256x256 창도 {frac*100:.1f}%로 기준({MIN_CLEAN_FRAC*100:.0f}%) 미달이다.")
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)
front_path = BASE_DIR / "base-front.png"
front = np.array(Image.open(front_path).convert("RGB")).astype(np.float64)
h, w, _ = front.shape
category_mask = run_segmentation(front_path)
bg_mask = category_mask == 0
# 배경(category0) 자체가 종이결 텍스처 노이즈 때문에 분류기가 곳곳에 좁쌀만한
# 오분류 반점을 흩뿌려 놓는다(연결요소가 15개로 쪼개짐). 그 반점 하나하나에서
# 20px씩 침식하면 실제 인물과 무관한 곳까지 배경 후보가 사라진다. 대신
# "인물(가장 큰 전경 연결요소) 경계에서 20px 이상"만 걸러 낸다 — 반점은 실제
# 얼굴 이 아니라 채도 낮은 종이 위의 분류 잡음이므로 그 자리 원본 픽셀도 그냥
# 종이 질감이다.
fg_mask = category_mask != 0
labeled_fg, _ = label(fg_mask)
sizes = np.bincount(labeled_fg.ravel())
sizes[0] = 0
person_mask = labeled_fg == int(np.argmax(sizes))
dist_to_person = distance_transform_edt(~person_mask)
eligible = bg_mask & (dist_to_person >= PERSON_MARGIN_PX)
print(f"배경(category0) 픽셀: {int(bg_mask.sum())}, 인물 경계 {PERSON_MARGIN_PX}px 이상 떨어진 후보: {int(eligible.sum())}")
y0, x0, clean_frac = find_tile_origin(eligible, TILE_SIZE)
patch = front[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE, :].copy()
print(f"타일 원본 위치: (x={x0}, y={y0}), size={TILE_SIZE}, 깨끗한 비율={clean_frac*100:.2f}%")
# 인물이 거의 전체 캔버스 높이를 차지하는 흉상 구도라 완전히 깨끗한 창이 없다
# (가장 좋은 창도 잔머리가 모서리에 살짝 걸침). 그 창 안의 오염 픽셀만 같은
# 패치의 가장 가까운 깨끗한 픽셀 색으로 메운다 — 머리카락 색이 종이결에
# 섞이는 것을 막는다.
contaminated_frac = 1.0 - clean_frac
win_eligible = eligible[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE]
bad = ~win_eligible
n_bad = int(bad.sum())
if n_bad > 0:
_, (iy, ix) = distance_transform_edt(bad, return_indices=True)
patch[bad] = patch[iy[bad], ix[bad]]
print(f"오염 픽셀 메움: {n_bad}px ({contaminated_frac*100:.2f}%)")
# --- 무채색화 + 명도 목표대로 이동(결 대비=표준편차는 유지) ---
lum = patch.mean(axis=2)
shift = TARGET_LUM_MID - lum.mean()
lum_adj = np.clip(lum + shift, 0, 255)
# 각 픽셀의 원래 색조(자기 명도 대비 편차)를 유지한 채 명도만 이동
tinted = patch - lum[..., None] + lum_adj[..., None]
achromatic = np.clip(tinted * CHROMA_KEEP + lum_adj[..., None] * (1.0 - CHROMA_KEEP), 0, 255)
print(f"명도 이동: 원본평균={lum.mean():.2f} -> 목표={TARGET_LUM_MID:.2f} (shift={shift:.2f})")
print(f"조정 후 평균 명도={achromatic.mean(axis=2).mean():.2f}, 표준편차={lum.std():.2f}(원본)/{achromatic.mean(axis=2).std():.2f}(조정 후)")
# --- 이음매를 캔버스 중앙으로 옮기고 그 십자선만 블러로 감춘다 ---
rolled = np.roll(achromatic, shift=(TILE_SIZE // 2, TILE_SIZE // 2), axis=(0, 1))
blurred = np.stack([gaussian_filter(rolled[..., c], sigma=SEAM_BLUR_SIGMA) for c in range(3)], axis=2)
yy, xx = np.mgrid[0:TILE_SIZE, 0:TILE_SIZE]
dist_seam = np.minimum(np.abs(yy - TILE_SIZE // 2), np.abs(xx - TILE_SIZE // 2)).astype(np.float64)
seam_w = np.clip(1.0 - dist_seam / SEAM_FALLOFF_PX, 0.0, 1.0) ** 2
tile = rolled * (1 - seam_w[..., None]) + blurred * seam_w[..., None]
tile = np.clip(tile, 0, 255)
out_path = LAYERS_V2_DIR / "paper-grain.png"
Image.fromarray(tile.round().astype(np.uint8), "RGB").save(out_path)
print(f"저장: {out_path}")
# --- 이음매 연속성 검사: 좌우/상하 가장자리 1px 평균 차 ---
left_edge = tile[:, 0, :].mean(axis=0)
right_edge = tile[:, -1, :].mean(axis=0)
top_edge = tile[0, :, :].mean(axis=0)
bottom_edge = tile[-1, :, :].mean(axis=0)
lr_diff = float(np.abs(left_edge - right_edge).mean())
tb_diff = float(np.abs(top_edge - bottom_edge).mean())
print(f"이음매 연속성: 좌우 가장자리 평균차={lr_diff:.3f}, 상하 가장자리 평균차={tb_diff:.3f} (참고: 작을수록 매끄러움)")
final_lum = tile.mean(axis=2)
print(f"최종 타일 평균 명도={final_lum.mean():.2f} (목표 {TARGET_LUM_MIN}~{TARGET_LUM_MAX}), 표준편차={final_lum.std():.2f}")
# --- 미리보기: 3x3 타일링 ---
tiled = Image.new("RGB", (TILE_SIZE * 3, TILE_SIZE * 3))
tile_im = Image.fromarray(tile.round().astype(np.uint8), "RGB")
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'}")
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
manifest["layersV2"]["paperGrain"] = {
"sourceImage": "base/base-front.png",
"sourceOrigin": [x0, y0],
"tileSize": TILE_SIZE,
"personMarginPx": PERSON_MARGIN_PX,
"cleanFraction": clean_frac,
"contaminatedPixelsFilled": n_bad,
"chromaKeep": CHROMA_KEEP,
"targetLumRange": [TARGET_LUM_MIN, TARGET_LUM_MAX],
"lumShift": shift,
"finalMeanLum": float(final_lum.mean()),
"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}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,221 @@
"""base-front.png에서 mediapipe FaceLandmarker로 얼굴 랜드마크를 검출해
manifest.json의 landmarks 섹션을 채우고 preview/landmarks.png를 만든다.
좌표계: 화면(이미지) 기준 left/right. "left"는 이미지의 왼쪽(작은 x), "right"는
이미지의 오른쪽(큰 x)이다. 인물 해부학적 좌/우가 아니다.
실행: <venv>/python.exe detect_landmarks.py
"""
from __future__ import annotations
import json
from pathlib import Path
import mediapipe as mp
import numpy as np
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"
RIGHT_EYEBROW_IDX = [46, 53, 52, 65, 55, 70, 63, 105, 66, 107]
LEFT_EYEBROW_IDX = [276, 283, 282, 295, 285, 300, 293, 334, 296, 336]
EYE_A_IDX = {"outer": 33, "inner": 133, "top": 159, "bottom": 145}
EYE_B_IDX = {"inner": 362, "outer": 263, "top": 386, "bottom": 374}
NOSE_TIP_IDX = 1
CHIN_IDX = 152
MOUTH_CORNER_A_IDX = 61
MOUTH_CORNER_B_IDX = 291
UPPER_LIP_TOP_IDX = 0
LOWER_LIP_BOTTOM_IDX = 17
FACE_EDGE_A_IDX = 234
FACE_EDGE_B_IDX = 454
IRIS_A = {"center": 468, "ring": [469, 470, 471, 472]}
IRIS_B = {"center": 473, "ring": [474, 475, 476, 477]}
def detect() -> dict | None:
if not MODEL_PATH.exists():
return None
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_PATH))
options = vision.FaceLandmarkerOptions(
base_options=base_options,
running_mode=vision.RunningMode.IMAGE,
num_faces=1,
)
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)
with vision.FaceLandmarker.create_from_options(options) as landmarker:
result = landmarker.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0]
pts = [(p.x * w, p.y * h) for p in lm]
print(f"검출된 랜드마크 개수: {len(pts)}")
def pt(idx: int) -> list[float]:
x, y = pts[idx]
return [round(x, 2), round(y, 2)]
def screen_label(idx_a: int, idx_b: int) -> tuple[int, int]:
"""두 인덱스를 화면 기준 left(작은 x)/right(큰 x)로 정렬해 반환."""
xa = pts[idx_a][0]
xb = pts[idx_b][0]
return (idx_a, idx_b) if xa < xb else (idx_b, idx_a)
left_eye_idx, right_eye_idx = screen_label(EYE_A_IDX["outer"], EYE_B_IDX["outer"])
eyeA_is_left = left_eye_idx == EYE_A_IDX["outer"]
eyeL = EYE_A_IDX if eyeA_is_left else EYE_B_IDX
eyeR = EYE_B_IDX if eyeA_is_left else EYE_A_IDX
irisL, irisR = (IRIS_A, IRIS_B) if eyeA_is_left else (IRIS_B, IRIS_A)
def iris_stats(iris: dict) -> dict:
cx, cy = pts[iris["center"]]
radii = [
float(np.hypot(pts[i][0] - cx, pts[i][1] - cy)) for i in iris["ring"]
]
return {"center": [round(cx, 2), round(cy, 2)], "radius": round(float(np.mean(radii)), 2)}
browL_first_x = pts[RIGHT_EYEBROW_IDX[0]][0]
browB_first_x = pts[LEFT_EYEBROW_IDX[0]][0]
browSetL, browSetR = (
(RIGHT_EYEBROW_IDX, LEFT_EYEBROW_IDX)
if browL_first_x < browB_first_x
else (LEFT_EYEBROW_IDX, RIGHT_EYEBROW_IDX)
)
def brow_stats(idx_set: list[int], face_cx: float) -> dict:
xs = [pts[i][0] for i in idx_set]
ys = [pts[i][1] for i in idx_set]
peak_i = idx_set[int(np.argmin(ys))]
inner_i = min(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
outer_i = max(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
return {
"inner": pt(inner_i),
"peak": pt(peak_i),
"outer": pt(outer_i),
}
face_cx = pts[NOSE_TIP_IDX][0]
mouthL_idx, mouthR_idx = screen_label(MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX)
faceEdgeL_idx, faceEdgeR_idx = screen_label(FACE_EDGE_A_IDX, FACE_EDGE_B_IDX)
landmarks = {
"coordSystem": "screen (image pixel: x=0 좌측, y=0 상단; left=작은 x, right=큰 x; 인물 해부학적 좌우 아님)",
"eyeLeft": {
"innerCorner": pt(eyeL["inner"]),
"outerCorner": pt(eyeL["outer"]),
"upperLidTop": pt(eyeL["top"]),
"lowerLidBottom": pt(eyeL["bottom"]),
"iris": iris_stats(irisL),
},
"eyeRight": {
"innerCorner": pt(eyeR["inner"]),
"outerCorner": pt(eyeR["outer"]),
"upperLidTop": pt(eyeR["top"]),
"lowerLidBottom": pt(eyeR["bottom"]),
"iris": iris_stats(irisR),
},
"eyebrowLeft": brow_stats(browSetL, face_cx),
"eyebrowRight": brow_stats(browSetR, face_cx),
"noseTip": pt(NOSE_TIP_IDX),
"mouthCornerLeft": pt(mouthL_idx),
"mouthCornerRight": pt(mouthR_idx),
"upperLipTopCenter": pt(UPPER_LIP_TOP_IDX),
"lowerLipBottomCenter": pt(LOWER_LIP_BOTTOM_IDX),
"chinTip": pt(CHIN_IDX),
"faceWidthAtEyeLevelLeft": pt(faceEdgeL_idx),
"faceWidthAtEyeLevelRight": pt(faceEdgeR_idx),
}
draw_preview(im, landmarks)
return landmarks
def draw_preview(im: Image.Image, landmarks: dict) -> None:
canvas = im.convert("RGB").copy()
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype("arial.ttf", 13)
except Exception:
font = ImageFont.load_default()
GROUP_COLORS = {
"eyeLeft": (220, 0, 0),
"eyeRight": (0, 120, 220),
"eyebrowLeft": (180, 0, 180),
"eyebrowRight": (0, 150, 80),
"noseTip": (255, 140, 0),
"mouthCornerLeft": (200, 0, 100),
"mouthCornerRight": (0, 100, 200),
"upperLipTopCenter": (150, 100, 0),
"lowerLipBottomCenter": (0, 150, 150),
"chinTip": (100, 60, 0),
"faceWidthAtEyeLevelLeft": (120, 120, 120),
"faceWidthAtEyeLevelRight": (120, 120, 120),
}
def dot(xy: list[float], label: str, color=(255, 0, 0)) -> None:
x, y = xy
r = 5
draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=2)
draw.text((x + 7, y - 7), label, fill=color, font=font)
flat = []
for group, val in landmarks.items():
if group == "coordSystem":
continue
if isinstance(val, list):
flat.append((val, group))
elif isinstance(val, dict):
for k, v in val.items():
if isinstance(v, list):
flat.append((v, f"{group}.{k}"))
elif isinstance(v, dict) and "center" in v:
flat.append((v["center"], f"{group}.iris.center"))
for xy, label in flat:
group = label.split(".")[0]
dot(xy, label, GROUP_COLORS.get(group, (255, 0, 0)))
canvas.save(PREVIEW_PATH)
print(f"landmarks 미리보기 저장: {PREVIEW_PATH}")
def main() -> int:
manifest = {}
if MANIFEST_PATH.exists():
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
result = detect()
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")
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")
print("manifest.json landmarks 섹션 기록 완료")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load diff