P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
21
docs/avatar-art/p1-linocut/README.md
Normal 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가 필요하다.
|
||||
BIN
docs/avatar-art/p1-linocut/base/base-faceless-padded.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/avatar-art/p1-linocut/base/base-faceless.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/avatar-art/p1-linocut/base/base-front.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
docs/avatar-art/p1-linocut/layers/body.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/avatar-art/p1-linocut/layers/hairFront.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
docs/avatar-art/p1-linocut/layers/head.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/body.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/face-detail.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/hairFront.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/head.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/jaw-detail.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/jaw-head.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/lip-lower.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/lip-shadow.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/lip-upper.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/avatar-art/p1-linocut/layers/v2/paper-grain.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
828
docs/avatar-art/p1-linocut/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
600
docs/avatar-art/p1-linocut/motif/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
docs/avatar-art/p1-linocut/motif/preview/bud-crossfade.jpg
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/avatar-art/p1-linocut/motif/preview/bud-sizes.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/avatar-art/p1-linocut/motif/preview/sheets-raw-small.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 112 KiB |
1
docs/avatar-art/p1-linocut/motif/prompts/bud-sheet.txt
Normal 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.
|
||||
|
|
@ -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.
|
||||
BIN
docs/avatar-art/p1-linocut/motif/raw/bud-sheet.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/avatar-art/p1-linocut/motif/raw/weather-sheet.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
583
docs/avatar-art/p1-linocut/motif/scripts/build_motif_sprites.py
Normal 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()
|
||||
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-closed.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-droop.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-half.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-open.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-cognitive.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-defensive.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-energy.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-negative.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-positive.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/avatar-art/p1-linocut/preview/v2/brow-centerline.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
689
docs/avatar-art/p1-linocut/preview/v2/export-rig-report.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
docs/avatar-art/p1-linocut/preview/v2/ghost-check.jpg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/avatar-art/p1-linocut/preview/v2/holes-texture.jpg
Normal file
|
After Width: | Height: | Size: 806 KiB |
BIN
docs/avatar-art/p1-linocut/preview/v2/jaw-head-diff-check.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/avatar-art/p1-linocut/preview/v2/jaw-pieces.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/avatar-art/p1-linocut/preview/v2/lip-texture.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/avatar-art/p1-linocut/raw/body.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/avatar-art/p1-linocut/raw/hair-front.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/avatar-art/p1-linocut/raw/head.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
3
docs/avatar-art/p1-linocut/raw/prompt-body.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Call the built-in image_gen tool immediately, using the attached image as the reference to edit. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as 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.
|
||||
3
docs/avatar-art/p1-linocut/raw/prompt-hair-front.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Call the built-in image_gen tool immediately, using the attached image as the reference to edit. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as 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.
|
||||
3
docs/avatar-art/p1-linocut/raw/prompt-head.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Call the built-in image_gen tool immediately, using the attached image as the reference to edit. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as 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.
|
||||
BIN
docs/avatar-art/p1-linocut/reference/owner-grief-intensity1.png
Normal file
|
After Width: | Height: | Size: 610 KiB |
49
docs/avatar-art/p1-linocut/scripts/_run_face_landmarks.py
Normal 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())
|
||||
45
docs/avatar-art/p1-linocut/scripts/_run_segmentation.py
Normal 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())
|
||||
369
docs/avatar-art/p1-linocut/scripts/build_brow_centerline.py
Normal 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())
|
||||
740
docs/avatar-art/p1-linocut/scripts/build_face_detail.py
Normal 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())
|
||||
338
docs/avatar-art/p1-linocut/scripts/build_final_previews.py
Normal 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())
|
||||
176
docs/avatar-art/p1-linocut/scripts/build_jaw_pieces.py
Normal 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())
|
||||
361
docs/avatar-art/p1-linocut/scripts/build_layers.py
Normal 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())
|
||||
411
docs/avatar-art/p1-linocut/scripts/build_layers_segmented.py
Normal 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())
|
||||
650
docs/avatar-art/p1-linocut/scripts/build_layers_v2.py
Normal 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())
|
||||
452
docs/avatar-art/p1-linocut/scripts/build_lip_texture.py
Normal 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())
|
||||
177
docs/avatar-art/p1-linocut/scripts/build_paper_grain.py
Normal 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())
|
||||
221
docs/avatar-art/p1-linocut/scripts/detect_landmarks.py
Normal 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())
|
||||