현재 작업 상태 저장
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1,007 KiB |
|
After Width: | Height: | Size: 293 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/app-render/render-grid.png
Normal file
|
After Width: | Height: | Size: 848 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-0.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-1.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-2.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-3.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero.png
Normal file
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/debug-face-group.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
|
@ -0,0 +1,792 @@
|
|||
{
|
||||
"source": "C:\\Users\\encep\\OneDrive\\문서\\카카오톡 받은 파일\\라투디 여캐.psb",
|
||||
"sourceCanvas": [
|
||||
1080,
|
||||
1920
|
||||
],
|
||||
"targetCanvas": [
|
||||
900,
|
||||
1125
|
||||
],
|
||||
"cropBox": [
|
||||
0,
|
||||
240,
|
||||
1080,
|
||||
1590
|
||||
],
|
||||
"layerOrder": [
|
||||
"hair-back-base",
|
||||
"hair-back-left",
|
||||
"hair-back-right",
|
||||
"body",
|
||||
"outfit",
|
||||
"ear-left",
|
||||
"ear-right",
|
||||
"face-base",
|
||||
"blush-left",
|
||||
"blush-right",
|
||||
"eye-white-left",
|
||||
"eye-white-right",
|
||||
"iris-left",
|
||||
"iris-right",
|
||||
"pupil-left",
|
||||
"pupil-right",
|
||||
"highlight-left",
|
||||
"highlight-right",
|
||||
"lash-left",
|
||||
"lash-right",
|
||||
"eyelid-upper-left",
|
||||
"eyelid-upper-right",
|
||||
"eyelid-lower-left",
|
||||
"eyelid-lower-right",
|
||||
"brow-left",
|
||||
"brow-right",
|
||||
"nose",
|
||||
"hair-side-left-1",
|
||||
"hair-side-right-1",
|
||||
"hair-side-left-2",
|
||||
"hair-side-right-2",
|
||||
"hair-bangs",
|
||||
"mouth-neutral"
|
||||
],
|
||||
"parts": [
|
||||
{
|
||||
"id": "hair-back-left",
|
||||
"source": "back hair left group",
|
||||
"file": "hair-back-left.png",
|
||||
"alphaBox": [
|
||||
0,
|
||||
0,
|
||||
463,
|
||||
1125
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 25.72,
|
||||
"y": 8.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-back-right",
|
||||
"source": "back hair right group",
|
||||
"file": "hair-back-right.png",
|
||||
"alphaBox": [
|
||||
437,
|
||||
0,
|
||||
900,
|
||||
1125
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 74.28,
|
||||
"y": 8.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "body",
|
||||
"source": "body group",
|
||||
"file": "body.png",
|
||||
"alphaBox": [
|
||||
339,
|
||||
439,
|
||||
605,
|
||||
759
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 52.44,
|
||||
"y": 53.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "neck",
|
||||
"source": "neck group",
|
||||
"file": "neck.png",
|
||||
"alphaBox": [
|
||||
339,
|
||||
439,
|
||||
605,
|
||||
759
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 52.44,
|
||||
"y": 53.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clavicle",
|
||||
"source": "clavicle group",
|
||||
"file": "clavicle.png",
|
||||
"alphaBox": [
|
||||
368,
|
||||
632,
|
||||
560,
|
||||
664
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.56,
|
||||
"y": 57.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "outfit",
|
||||
"source": "outfit group",
|
||||
"file": "outfit.png",
|
||||
"alphaBox": [
|
||||
130,
|
||||
521,
|
||||
806,
|
||||
1015
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 52.0,
|
||||
"y": 68.27
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "outfit-inner",
|
||||
"source": "inner outfit group",
|
||||
"file": "outfit-inner.png",
|
||||
"alphaBox": [
|
||||
232,
|
||||
521,
|
||||
705,
|
||||
1009
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 52.06,
|
||||
"y": 68.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "outfit-outer",
|
||||
"source": "outer outfit group",
|
||||
"file": "outfit-outer.png",
|
||||
"alphaBox": [
|
||||
130,
|
||||
541,
|
||||
806,
|
||||
1015
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 52.0,
|
||||
"y": 69.16
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ribbon",
|
||||
"source": "ribbon group",
|
||||
"file": "ribbon.png",
|
||||
"alphaBox": [
|
||||
368,
|
||||
636,
|
||||
570,
|
||||
832
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 52.11,
|
||||
"y": 65.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "face",
|
||||
"source": "face group",
|
||||
"file": "face.png",
|
||||
"alphaBox": [
|
||||
271,
|
||||
31,
|
||||
650,
|
||||
532
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.17,
|
||||
"y": 25.02
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "face-base",
|
||||
"source": "face base group",
|
||||
"file": "face-base.png",
|
||||
"alphaBox": [
|
||||
276,
|
||||
31,
|
||||
630,
|
||||
532
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 50.33,
|
||||
"y": 25.02
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ear-left",
|
||||
"source": "left ear group",
|
||||
"file": "ear-left.png",
|
||||
"alphaBox": [
|
||||
271,
|
||||
362,
|
||||
349,
|
||||
445
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 34.44,
|
||||
"y": 35.87
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ear-right",
|
||||
"source": "right ear group",
|
||||
"file": "ear-right.png",
|
||||
"alphaBox": [
|
||||
572,
|
||||
355,
|
||||
650,
|
||||
438
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 67.89,
|
||||
"y": 35.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "blush-left",
|
||||
"source": "left blush group",
|
||||
"file": "blush-left.png",
|
||||
"alphaBox": [
|
||||
301,
|
||||
338,
|
||||
449,
|
||||
446
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 41.67,
|
||||
"y": 34.84
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "blush-right",
|
||||
"source": "right blush group",
|
||||
"file": "blush-right.png",
|
||||
"alphaBox": [
|
||||
467,
|
||||
330,
|
||||
615,
|
||||
438
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 60.11,
|
||||
"y": 34.13
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nose",
|
||||
"source": "nose group",
|
||||
"file": "nose.png",
|
||||
"alphaBox": [
|
||||
448,
|
||||
410,
|
||||
461,
|
||||
426
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 50.5,
|
||||
"y": 37.16
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eye-left",
|
||||
"source": "left eye group",
|
||||
"file": "eye-left.png",
|
||||
"alphaBox": [
|
||||
296,
|
||||
292,
|
||||
436,
|
||||
392
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 40.67,
|
||||
"y": 30.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eye-right",
|
||||
"source": "right eye group",
|
||||
"file": "eye-right.png",
|
||||
"alphaBox": [
|
||||
472,
|
||||
283,
|
||||
619,
|
||||
377
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 60.61,
|
||||
"y": 29.33
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eye-white-left",
|
||||
"source": "left eye white group",
|
||||
"file": "eye-white-left.png",
|
||||
"alphaBox": [
|
||||
327,
|
||||
341,
|
||||
413,
|
||||
384
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 41.11,
|
||||
"y": 32.22
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eye-white-right",
|
||||
"source": "right eye white group",
|
||||
"file": "eye-white-right.png",
|
||||
"alphaBox": [
|
||||
503,
|
||||
327,
|
||||
588,
|
||||
370
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 60.61,
|
||||
"y": 30.98
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "iris-left",
|
||||
"source": "left iris group",
|
||||
"file": "iris-left.png",
|
||||
"alphaBox": [
|
||||
349,
|
||||
334,
|
||||
404,
|
||||
384
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 41.83,
|
||||
"y": 31.91
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "iris-right",
|
||||
"source": "right iris group",
|
||||
"file": "iris-right.png",
|
||||
"alphaBox": [
|
||||
510,
|
||||
321,
|
||||
566,
|
||||
372
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 59.78,
|
||||
"y": 30.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pupil-left",
|
||||
"source": "left pupil group",
|
||||
"file": "pupil-left.png",
|
||||
"alphaBox": [
|
||||
374,
|
||||
350,
|
||||
389,
|
||||
369
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 42.39,
|
||||
"y": 31.96
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pupil-right",
|
||||
"source": "right pupil group",
|
||||
"file": "pupil-right.png",
|
||||
"alphaBox": [
|
||||
525,
|
||||
339,
|
||||
540,
|
||||
357
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 59.17,
|
||||
"y": 30.93
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "highlight-left",
|
||||
"source": "left eye highlight group",
|
||||
"file": "highlight-left.png",
|
||||
"alphaBox": [
|
||||
367,
|
||||
349,
|
||||
389,
|
||||
378
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 42.0,
|
||||
"y": 32.31
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "highlight-right",
|
||||
"source": "right eye highlight group",
|
||||
"file": "highlight-right.png",
|
||||
"alphaBox": [
|
||||
528,
|
||||
337,
|
||||
552,
|
||||
367
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 60.0,
|
||||
"y": 31.29
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lash-left",
|
||||
"source": "left lash group",
|
||||
"file": "lash-left.png",
|
||||
"alphaBox": [
|
||||
324,
|
||||
316,
|
||||
421,
|
||||
355
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 41.39,
|
||||
"y": 29.82
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lash-right",
|
||||
"source": "right lash group",
|
||||
"file": "lash-right.png",
|
||||
"alphaBox": [
|
||||
490,
|
||||
305,
|
||||
588,
|
||||
347
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 59.89,
|
||||
"y": 28.98
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eyelid-upper-left",
|
||||
"source": "left upper eyelid group",
|
||||
"file": "eyelid-upper-left.png",
|
||||
"alphaBox": [
|
||||
296,
|
||||
330,
|
||||
418,
|
||||
381
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 39.67,
|
||||
"y": 31.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eyelid-upper-right",
|
||||
"source": "right upper eyelid group",
|
||||
"file": "eyelid-upper-right.png",
|
||||
"alphaBox": [
|
||||
498,
|
||||
314,
|
||||
619,
|
||||
365
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 62.06,
|
||||
"y": 30.18
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eyelid-lower-left",
|
||||
"source": "left lower eyelid group",
|
||||
"file": "eyelid-lower-left.png",
|
||||
"alphaBox": [
|
||||
323,
|
||||
371,
|
||||
415,
|
||||
392
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 41.0,
|
||||
"y": 33.91
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eyelid-lower-right",
|
||||
"source": "right lower eyelid group",
|
||||
"file": "eyelid-lower-right.png",
|
||||
"alphaBox": [
|
||||
502,
|
||||
353,
|
||||
593,
|
||||
377
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 60.83,
|
||||
"y": 32.44
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "brow-left",
|
||||
"source": "left brow group",
|
||||
"file": "brow-left.png",
|
||||
"alphaBox": [
|
||||
308,
|
||||
292,
|
||||
436,
|
||||
317
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 41.33,
|
||||
"y": 27.07
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "brow-right",
|
||||
"source": "right brow group",
|
||||
"file": "brow-right.png",
|
||||
"alphaBox": [
|
||||
472,
|
||||
283,
|
||||
601,
|
||||
300
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 59.61,
|
||||
"y": 25.91
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-front",
|
||||
"source": "front hair group",
|
||||
"file": "hair-front.png",
|
||||
"alphaBox": [
|
||||
212,
|
||||
20,
|
||||
692,
|
||||
829
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 50.22,
|
||||
"y": 37.73
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-bangs",
|
||||
"source": "bangs group",
|
||||
"file": "hair-bangs.png",
|
||||
"alphaBox": [
|
||||
290,
|
||||
52,
|
||||
622,
|
||||
386
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 50.67,
|
||||
"y": 7.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-side-left-1",
|
||||
"source": "left side hair 1 group",
|
||||
"file": "hair-side-left-1.png",
|
||||
"alphaBox": [
|
||||
212,
|
||||
21,
|
||||
457,
|
||||
441
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 37.17,
|
||||
"y": 6.35
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-side-left-2",
|
||||
"source": "left side hair 2 group",
|
||||
"file": "hair-side-left-2.png",
|
||||
"alphaBox": [
|
||||
219,
|
||||
299,
|
||||
351,
|
||||
813
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 31.67,
|
||||
"y": 32.06
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-side-right-1",
|
||||
"source": "right side hair 1 group",
|
||||
"file": "hair-side-right-1.png",
|
||||
"alphaBox": [
|
||||
447,
|
||||
20,
|
||||
692,
|
||||
440
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 63.28,
|
||||
"y": 6.26
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-side-right-2",
|
||||
"source": "right side hair 2 group",
|
||||
"file": "hair-side-right-2.png",
|
||||
"alphaBox": [
|
||||
529,
|
||||
315,
|
||||
660,
|
||||
829
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 66.06,
|
||||
"y": 33.48
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-neutral",
|
||||
"source": "mouth smile group",
|
||||
"file": "mouth-neutral.png",
|
||||
"alphaBox": [
|
||||
431,
|
||||
454,
|
||||
498,
|
||||
474
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 41.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hair-back-base",
|
||||
"source": "top extra back hair layers",
|
||||
"file": "hair-back-base.png",
|
||||
"alphaBox": null,
|
||||
"originPercent": {
|
||||
"x": 50.0,
|
||||
"y": 50.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-open",
|
||||
"source": "hidden open mouth groups",
|
||||
"file": "mouth-open.png",
|
||||
"alphaBox": [
|
||||
405,
|
||||
424,
|
||||
524,
|
||||
539
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 42.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-warm",
|
||||
"source": "derived from mouth-neutral bbox",
|
||||
"file": "mouth-warm.png",
|
||||
"alphaBox": [
|
||||
427,
|
||||
453,
|
||||
502,
|
||||
473
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 41.16
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-tired",
|
||||
"source": "derived from mouth-neutral bbox",
|
||||
"file": "mouth-tired.png",
|
||||
"alphaBox": [
|
||||
435,
|
||||
464,
|
||||
494,
|
||||
470
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 41.51
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-sad",
|
||||
"source": "derived from mouth-neutral bbox",
|
||||
"file": "mouth-sad.png",
|
||||
"alphaBox": [
|
||||
433,
|
||||
454,
|
||||
496,
|
||||
469
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 41.02
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-anxious",
|
||||
"source": "derived from mouth-neutral bbox",
|
||||
"file": "mouth-anxious.png",
|
||||
"alphaBox": [
|
||||
439,
|
||||
456,
|
||||
490,
|
||||
472
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 41.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-startled",
|
||||
"source": "derived from mouth-open bbox",
|
||||
"file": "mouth-startled.png",
|
||||
"alphaBox": [
|
||||
424,
|
||||
439,
|
||||
505,
|
||||
522
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.61,
|
||||
"y": 42.71
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mouth-open-small",
|
||||
"source": "derived from mouth-open bbox",
|
||||
"file": "mouth-open-small.png",
|
||||
"alphaBox": [
|
||||
413,
|
||||
445,
|
||||
515,
|
||||
516
|
||||
],
|
||||
"originPercent": {
|
||||
"x": 51.56,
|
||||
"y": 42.71
|
||||
}
|
||||
}
|
||||
],
|
||||
"imageGenerationHarness": {
|
||||
"canvas": [
|
||||
900,
|
||||
1125
|
||||
],
|
||||
"rule": "Generate each replacement part on this exact canvas, transparent outside the part bbox, matching alphaBox and originPercent.",
|
||||
"file": "imagegen-coordinate-harness.png"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/body.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/neck.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/nose.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 688 KiB |
517
docs/avatar-art/seoyeon/live2d-psb/export_detailed_parts.py
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export detailed app-ready Live2D-style raster parts from the source PSB.
|
||||
|
||||
All exported PNGs keep the same normalized 900x1125 canvas. That makes every
|
||||
part usable by the renderer without per-image CSS sizing hacks, and gives
|
||||
image-generation follow-up work a stable coordinate harness.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
from collections.abc import Iterable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from psd_tools import PSDImage
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parents[3]
|
||||
PSB = Path(
|
||||
"C:/Users/encep/OneDrive/"
|
||||
"\ubb38\uc11c/\uce74\uce74\uc624\ud1a1 \ubc1b\uc740 \ud30c\uc77c/"
|
||||
"\ub77c\ud22c\ub514 \uc5ec\uce90.psb"
|
||||
)
|
||||
|
||||
OUT = HERE / "detailed-parts"
|
||||
PARTS = OUT / "parts"
|
||||
APP_PARTS = REPO_ROOT / "apps/web/public/avatar/seoyeon-live2d-psb/parts"
|
||||
|
||||
SOURCE_CANVAS = (1080, 1920)
|
||||
TARGET_CANVAS = (900, 1125)
|
||||
CROP_BOX = (0, 240, 1080, 1590)
|
||||
|
||||
|
||||
def k(name: str) -> str:
|
||||
"""Keep the source file ASCII while addressing Korean PSB layer names."""
|
||||
return name.encode("utf-8").decode("unicode_escape")
|
||||
|
||||
|
||||
N = {
|
||||
"paper": k("\\uc6a9\\uc9c0"),
|
||||
"back_hair": k("\\ub4b7\\uba38\\ub9ac"),
|
||||
"back_hair_left": k("\\uc88c \\ub4b7\\uba38\\ub9ac"),
|
||||
"back_hair_right": k("\\uc6b0 \\ub4b7\\uba38\\ub9ac"),
|
||||
"body": k("\\ubab8"),
|
||||
"neck": k("\\ubaa9"),
|
||||
"clavicle": k("\\uc1c4\\uace8"),
|
||||
"outfit": k("\\uc758\\uc0c1"),
|
||||
"inner_outfit": k("\\uc548\\uc637"),
|
||||
"outer_outfit": k("\\uac89\\uc637"),
|
||||
"ribbon": k("\\ub9ac\\ubcf8"),
|
||||
"face": k("\\uc5bc\\uad74"),
|
||||
"ear_left": k("\\uadc0 \\uc88c"),
|
||||
"ear_right": k("\\uadc0\\uc6b0"),
|
||||
"face_base": k("\\uc5bc\\uad74 \\ubca0\\uc774\\uc2a4"),
|
||||
"blush_left": k("\\uc88c \\ud64d\\uc870"),
|
||||
"blush_right": k("\\uc6b0 \\ud64d\\uc870"),
|
||||
"nose": k("\\ucf54"),
|
||||
"eye_left": k("\\uc88c \\ub208"),
|
||||
"eye_right": k("\\uc6b0 \\ub208"),
|
||||
"white": k("\\ud770 \\uc790"),
|
||||
"white_copy": k("\\ud770 \\uc790 \\ubcf5\\uc0ac"),
|
||||
"iris": k("\\ud64d\\ucc44"),
|
||||
"iris_copy": k("\\ud64d\\ucc44 \\ubcf5\\uc0ac"),
|
||||
"pupil": k("\\ub3d9\\uacf5"),
|
||||
"pupil_copy": k("\\ub3d9\\uacf5 \\ubcf5\\uc0ac"),
|
||||
"highlight": k("\\ub208\\ub3d9\\uc790 \\ud558\\uc774\\ub77c\\uc774\\ud2b8"),
|
||||
"highlight_copy": k("\\ub208\\ub3d9\\uc790 \\ud558\\uc774\\ub77c\\uc774\\ud2b8 \\ubcf5\\uc0ac"),
|
||||
"lash": k("\\uc18d\\ub208\\uc379"),
|
||||
"lash_copy": k("\\uc18d\\ub208\\uc379 \\ubcf5\\uc0ac"),
|
||||
"upper_lid": k("\\uc717 \\ub208\\uaebc\\ud480"),
|
||||
"upper_lid_copy": k("\\uc717 \\ub208\\uaebc\\ud480 \\ubcf5\\uc0ac"),
|
||||
"lower_lid": k("\\uc544\\ub7ab \\ub208\\uaebc\\ud480"),
|
||||
"lower_lid_copy": k("\\uc544\\ub7ab \\ub208\\uaebc\\ud480 \\ubcf5\\uc0ac"),
|
||||
"brow": k("\\ub208\\uc379"),
|
||||
"brow_copy": k("\\ub208\\uc379 \\ubcf5\\uc0ac"),
|
||||
"front_hair": k("\\uba38\\ub9ac\\uce74\\ub77d"),
|
||||
"bangs": k("\\uc55e\\uba38\\ub9ac"),
|
||||
"side_left_2": k("\\uc88c \\uc606\\uba38\\ub9ac2"),
|
||||
"side_right_2": k("\\uc6b0 \\uc606\\uba38\\ub9ac 2"),
|
||||
"side_right_1": k("\\uc6b0 \\uc606\\uba38\\ub9ac 1"),
|
||||
"side_left_1": k("\\uc88c \\uc606\\uba38\\ub9ac 1"),
|
||||
"mouth": k("\\uc785"),
|
||||
"mouth_inner": k("\\uc785\\uc548"),
|
||||
"upper_teeth": k("\\uc717\\ub2c8"),
|
||||
"lower_side_teeth": k("\\uc544\\ub798\\uc606\\ub2c8"),
|
||||
"tongue": k("\\ud600"),
|
||||
"lower_teeth": k("\\uc544\\ub7ab\\ub2c8"),
|
||||
"upper_lip": k("\\uc785\\uc220 \\uc704"),
|
||||
"lower_lip": k("\\uc785 \\uc220 \\uc544\\ub798"),
|
||||
"smile": k("\\ubbf8\\uc18c"),
|
||||
"back_extra_left": k("\\ub808\\uc774\\uc5b4 110 \\ubcf5\\uc0ac"),
|
||||
"back_extra_right": k("\\ub808\\uc774\\uc5b4 110"),
|
||||
}
|
||||
|
||||
LAYER_ORDER = [
|
||||
"hair-back-base",
|
||||
"hair-back-left",
|
||||
"hair-back-right",
|
||||
"body",
|
||||
"outfit",
|
||||
"ear-left",
|
||||
"ear-right",
|
||||
"face-base",
|
||||
"blush-left",
|
||||
"blush-right",
|
||||
"eye-white-left",
|
||||
"eye-white-right",
|
||||
"iris-left",
|
||||
"iris-right",
|
||||
"pupil-left",
|
||||
"pupil-right",
|
||||
"highlight-left",
|
||||
"highlight-right",
|
||||
"lash-left",
|
||||
"lash-right",
|
||||
"eyelid-upper-left",
|
||||
"eyelid-upper-right",
|
||||
"eyelid-lower-left",
|
||||
"eyelid-lower-right",
|
||||
"brow-left",
|
||||
"brow-right",
|
||||
"nose",
|
||||
"hair-side-left-1",
|
||||
"hair-side-right-1",
|
||||
"hair-side-left-2",
|
||||
"hair-side-right-2",
|
||||
"hair-bangs",
|
||||
"mouth-neutral",
|
||||
]
|
||||
|
||||
|
||||
def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None:
|
||||
alpha = np.array(im.convert("RGBA").getchannel("A"))
|
||||
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 resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
arr = np.array(im.convert("RGBA")).astype(np.float32)
|
||||
alpha = arr[:, :, 3:4] / 255.0
|
||||
premultiplied = arr.copy()
|
||||
premultiplied[:, :, :3] *= alpha
|
||||
resized = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGBA").resize(
|
||||
size,
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
out = np.array(resized).astype(np.float32)
|
||||
out_alpha = out[:, :, 3:4] / 255.0
|
||||
out[:, :, :3] = np.where(out_alpha > 0.001, out[:, :, :3] / np.maximum(out_alpha, 0.001), 0)
|
||||
out[out[:, :, 3] < 1, :3] = 0
|
||||
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA")
|
||||
|
||||
|
||||
def normalize(full_canvas: Image.Image) -> Image.Image:
|
||||
return resize_rgba(full_canvas.crop(CROP_BOX), TARGET_CANVAS)
|
||||
|
||||
|
||||
def blank(size: tuple[int, int] = SOURCE_CANVAS) -> Image.Image:
|
||||
return Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
|
||||
|
||||
def find_path(root, path: Sequence[str]):
|
||||
current = root
|
||||
for name in path:
|
||||
for child in current:
|
||||
if child.name == name:
|
||||
current = child
|
||||
break
|
||||
else:
|
||||
rendered = " / ".join(item.encode("unicode_escape").decode() for item in path)
|
||||
raise KeyError(f"Missing PSB layer path: {rendered}")
|
||||
return current
|
||||
|
||||
|
||||
def composite_layer(layer) -> Image.Image:
|
||||
im = layer.composite(viewport=(0, 0, SOURCE_CANVAS[0], SOURCE_CANVAS[1]), force=True)
|
||||
return im.convert("RGBA") if im else blank()
|
||||
|
||||
|
||||
def composite_path(psd: PSDImage, path: Sequence[str]) -> Image.Image:
|
||||
return composite_layer(find_path(psd, path))
|
||||
|
||||
|
||||
def composite_many(images: Iterable[Image.Image]) -> Image.Image:
|
||||
canvas = blank()
|
||||
for image in images:
|
||||
canvas.alpha_composite(image.convert("RGBA"))
|
||||
return canvas
|
||||
|
||||
|
||||
def composite_with_visibility(
|
||||
psd: PSDImage,
|
||||
root_path: Sequence[str],
|
||||
visible_paths: Sequence[Sequence[str]],
|
||||
hidden_paths: Sequence[Sequence[str]] = (),
|
||||
) -> Image.Image:
|
||||
changed = []
|
||||
try:
|
||||
for path in visible_paths:
|
||||
layer = find_path(psd, path)
|
||||
changed.append((layer, layer.visible))
|
||||
layer.visible = True
|
||||
for path in hidden_paths:
|
||||
layer = find_path(psd, path)
|
||||
changed.append((layer, layer.visible))
|
||||
layer.visible = False
|
||||
return composite_path(psd, root_path)
|
||||
finally:
|
||||
for layer, visible in reversed(changed):
|
||||
layer.visible = visible
|
||||
|
||||
|
||||
def dominant_color(im: Image.Image) -> tuple[int, int, int, int]:
|
||||
arr = np.array(im.convert("RGBA"))
|
||||
mask = arr[:, :, 3] > 24
|
||||
if not np.any(mask):
|
||||
return (130, 72, 72, 255)
|
||||
rgb = np.median(arr[mask][:, :3], axis=0)
|
||||
alpha = np.percentile(arr[mask][:, 3], 85)
|
||||
return (int(rgb[0]), int(rgb[1]), int(rgb[2]), int(max(alpha, 220)))
|
||||
|
||||
|
||||
def paste_scaled(
|
||||
source: Image.Image,
|
||||
scale_x: float,
|
||||
scale_y: float,
|
||||
offset_y: int = 0,
|
||||
rotate: float = 0,
|
||||
) -> Image.Image:
|
||||
source = source.convert("RGBA")
|
||||
box = alpha_bbox(source)
|
||||
if box is None:
|
||||
return blank(TARGET_CANVAS)
|
||||
crop = source.crop(box)
|
||||
new_size = (
|
||||
max(1, int(round(crop.width * scale_x))),
|
||||
max(1, int(round(crop.height * scale_y))),
|
||||
)
|
||||
crop = crop.resize(new_size, Image.Resampling.LANCZOS)
|
||||
if rotate:
|
||||
crop = crop.rotate(rotate, resample=Image.Resampling.BICUBIC, expand=True)
|
||||
cx = (box[0] + box[2]) // 2
|
||||
cy = (box[1] + box[3]) // 2 + offset_y
|
||||
out = blank(TARGET_CANVAS)
|
||||
out.alpha_composite(crop, (cx - crop.width // 2, cy - crop.height // 2))
|
||||
return out
|
||||
|
||||
|
||||
def make_curve_mouth(reference: Image.Image, curve: float, width_scale: float = 1.0) -> Image.Image:
|
||||
ref = reference.convert("RGBA")
|
||||
box = alpha_bbox(ref)
|
||||
out = blank(TARGET_CANVAS)
|
||||
if box is None:
|
||||
return out
|
||||
color = dominant_color(ref)
|
||||
cx = (box[0] + box[2]) / 2
|
||||
cy = (box[1] + box[3]) / 2 + 1
|
||||
width = max(38, (box[2] - box[0]) * width_scale)
|
||||
height = max(7, (box[3] - box[1]) * 0.48)
|
||||
points = []
|
||||
for i in range(24):
|
||||
t = i / 23
|
||||
x = cx - width / 2 + width * t
|
||||
y = cy + math.sin(t * math.pi) * curve * height
|
||||
points.append((x, y))
|
||||
draw = ImageDraw.Draw(out)
|
||||
shadow = (max(0, color[0] - 36), max(0, color[1] - 26), max(0, color[2] - 20), 92)
|
||||
draw.line([(x, y + 1.4) for x, y in points], fill=shadow, width=4, joint="curve")
|
||||
draw.line(points, fill=color, width=3, joint="curve")
|
||||
return out.filter(Image.Resampling.BICUBIC) if False else out
|
||||
|
||||
|
||||
def origin_for(part_id: str, box: tuple[int, int, int, int] | None) -> dict[str, float]:
|
||||
if not box:
|
||||
return {"x": 50.0, "y": 50.0}
|
||||
x0, y0, x1, y1 = box
|
||||
cx = (x0 + x1) / 2 / TARGET_CANVAS[0] * 100
|
||||
if part_id.startswith("hair-back"):
|
||||
y = (y0 + (y1 - y0) * 0.08) / TARGET_CANVAS[1] * 100
|
||||
elif part_id.startswith("hair-side"):
|
||||
y = (y0 + (y1 - y0) * 0.12) / TARGET_CANVAS[1] * 100
|
||||
elif part_id == "hair-bangs":
|
||||
y = (y0 + (y1 - y0) * 0.08) / TARGET_CANVAS[1] * 100
|
||||
elif part_id.startswith("brow"):
|
||||
y = (y0 + y1) / 2 / TARGET_CANVAS[1] * 100
|
||||
else:
|
||||
y = (y0 + y1) / 2 / TARGET_CANVAS[1] * 100
|
||||
return {"x": round(cx, 2), "y": round(y, 2)}
|
||||
|
||||
|
||||
def save_part(entries: list[dict[str, object]], part_id: str, source: str, image: Image.Image) -> Image.Image:
|
||||
normalized = normalize(image) if image.size == SOURCE_CANVAS else image.convert("RGBA")
|
||||
path = PARTS / f"{part_id}.png"
|
||||
normalized.save(path, optimize=True)
|
||||
box = alpha_bbox(normalized)
|
||||
entries.append(
|
||||
{
|
||||
"id": part_id,
|
||||
"source": source,
|
||||
"file": path.name,
|
||||
"alphaBox": list(box) if box else None,
|
||||
"originPercent": origin_for(part_id, box),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def draw_label(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str) -> None:
|
||||
x, y = xy
|
||||
draw.rectangle((x - 3, y - 2, x + len(text) * 6 + 4, y + 12), fill=(22, 29, 27, 210))
|
||||
draw.text((x, y), text, fill=(230, 236, 232, 255))
|
||||
|
||||
|
||||
def make_contact(entries: list[dict[str, object]]) -> None:
|
||||
tile_w, tile_h = 180, 180
|
||||
cols = 5
|
||||
rows = math.ceil(len(entries) / cols)
|
||||
sheet = Image.new("RGBA", (tile_w * cols, tile_h * rows), (30, 39, 36, 255))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for i, entry in enumerate(entries):
|
||||
layer = Image.open(PARTS / str(entry["file"])).convert("RGBA")
|
||||
box = alpha_bbox(layer)
|
||||
thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box)
|
||||
thumb.thumbnail((tile_w - 22, tile_h - 44), Image.Resampling.LANCZOS)
|
||||
x = (i % cols) * tile_w + (tile_w - thumb.width) // 2
|
||||
y = (i // cols) * tile_h + 12
|
||||
sheet.alpha_composite(thumb, (x, y))
|
||||
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 28), str(entry["id"]), fill=(210, 222, 216, 255))
|
||||
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 14), str(entry["alphaBox"]), fill=(150, 166, 160, 255))
|
||||
sheet.save(OUT / "detailed-parts-contact.png", optimize=True)
|
||||
|
||||
|
||||
def make_preview(layer_order: Sequence[str]) -> None:
|
||||
preview = Image.new("RGBA", TARGET_CANVAS, (30, 39, 36, 255))
|
||||
transparent = blank(TARGET_CANVAS)
|
||||
for part_id in layer_order:
|
||||
im = Image.open(PARTS / f"{part_id}.png").convert("RGBA")
|
||||
preview.alpha_composite(im)
|
||||
transparent.alpha_composite(im)
|
||||
preview.save(OUT / "preview-detailed-parts.png", optimize=True)
|
||||
transparent.save(OUT / "preview-detailed-parts-transparent.png", optimize=True)
|
||||
|
||||
|
||||
def make_imagegen_harness(entries: list[dict[str, object]]) -> None:
|
||||
base = Image.open(OUT / "preview-detailed-parts.png").convert("RGBA")
|
||||
draw = ImageDraw.Draw(base)
|
||||
focus = {
|
||||
"hair-back-left",
|
||||
"hair-back-right",
|
||||
"hair-side-left-1",
|
||||
"hair-side-right-1",
|
||||
"hair-side-left-2",
|
||||
"hair-side-right-2",
|
||||
"hair-bangs",
|
||||
"eye-white-left",
|
||||
"eye-white-right",
|
||||
"iris-left",
|
||||
"iris-right",
|
||||
"brow-left",
|
||||
"brow-right",
|
||||
"mouth-neutral",
|
||||
"mouth-open",
|
||||
}
|
||||
for entry in entries:
|
||||
if entry["id"] not in focus or not entry["alphaBox"]:
|
||||
continue
|
||||
x0, y0, x1, y1 = entry["alphaBox"]
|
||||
color = (120, 223, 177, 210) if str(entry["id"]).startswith("hair") else (255, 214, 92, 220)
|
||||
if str(entry["id"]).startswith("mouth"):
|
||||
color = (255, 128, 128, 220)
|
||||
draw.rectangle((x0, y0, x1, y1), outline=color, width=2)
|
||||
origin = entry["originPercent"]
|
||||
ox = int(float(origin["x"]) / 100 * TARGET_CANVAS[0])
|
||||
oy = int(float(origin["y"]) / 100 * TARGET_CANVAS[1])
|
||||
draw.ellipse((ox - 4, oy - 4, ox + 4, oy + 4), fill=color)
|
||||
draw_label(draw, (x0, max(0, y0 - 14)), str(entry["id"]))
|
||||
base.save(OUT / "imagegen-coordinate-harness.png", optimize=True)
|
||||
|
||||
|
||||
def sync_to_app() -> None:
|
||||
APP_PARTS.mkdir(parents=True, exist_ok=True)
|
||||
for source in PARTS.glob("*.png"):
|
||||
shutil.copy2(source, APP_PARTS / source.name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not PSB.exists():
|
||||
raise FileNotFoundError(PSB)
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
PARTS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
psd = PSDImage.open(PSB)
|
||||
entries: list[dict[str, object]] = []
|
||||
|
||||
simple_parts: list[tuple[str, str, Sequence[str]]] = [
|
||||
("hair-back-left", "back hair left group", [N["back_hair"], N["back_hair_left"]]),
|
||||
("hair-back-right", "back hair right group", [N["back_hair"], N["back_hair_right"]]),
|
||||
("body", "body group", [N["body"]]),
|
||||
("neck", "neck group", [N["body"], N["neck"]]),
|
||||
("clavicle", "clavicle group", [N["body"], N["clavicle"]]),
|
||||
("outfit", "outfit group", [N["outfit"]]),
|
||||
("outfit-inner", "inner outfit group", [N["outfit"], N["inner_outfit"]]),
|
||||
("outfit-outer", "outer outfit group", [N["outfit"], N["outer_outfit"]]),
|
||||
("ribbon", "ribbon group", [N["outfit"], N["ribbon"]]),
|
||||
("face", "face group", [N["face"]]),
|
||||
("face-base", "face base group", [N["face"], N["face_base"]]),
|
||||
("ear-left", "left ear group", [N["face"], N["ear_left"]]),
|
||||
("ear-right", "right ear group", [N["face"], N["ear_right"]]),
|
||||
("blush-left", "left blush group", [N["face"], N["blush_left"]]),
|
||||
("blush-right", "right blush group", [N["face"], N["blush_right"]]),
|
||||
("nose", "nose group", [N["face"], N["nose"]]),
|
||||
("eye-left", "left eye group", [N["eye_left"]]),
|
||||
("eye-right", "right eye group", [N["eye_right"]]),
|
||||
("eye-white-left", "left eye white group", [N["eye_left"], N["white"]]),
|
||||
("eye-white-right", "right eye white group", [N["eye_right"], N["white_copy"]]),
|
||||
("iris-left", "left iris group", [N["eye_left"], N["iris"]]),
|
||||
("iris-right", "right iris group", [N["eye_right"], N["iris_copy"]]),
|
||||
("pupil-left", "left pupil group", [N["eye_left"], N["pupil"]]),
|
||||
("pupil-right", "right pupil group", [N["eye_right"], N["pupil_copy"]]),
|
||||
("highlight-left", "left eye highlight group", [N["eye_left"], N["highlight"]]),
|
||||
("highlight-right", "right eye highlight group", [N["eye_right"], N["highlight_copy"]]),
|
||||
("lash-left", "left lash group", [N["eye_left"], N["lash"]]),
|
||||
("lash-right", "right lash group", [N["eye_right"], N["lash_copy"]]),
|
||||
("eyelid-upper-left", "left upper eyelid group", [N["eye_left"], N["upper_lid"]]),
|
||||
("eyelid-upper-right", "right upper eyelid group", [N["eye_right"], N["upper_lid_copy"]]),
|
||||
("eyelid-lower-left", "left lower eyelid group", [N["eye_left"], N["lower_lid"]]),
|
||||
("eyelid-lower-right", "right lower eyelid group", [N["eye_right"], N["lower_lid_copy"]]),
|
||||
("brow-left", "left brow group", [N["eye_left"], N["brow"]]),
|
||||
("brow-right", "right brow group", [N["eye_right"], N["brow_copy"]]),
|
||||
("hair-front", "front hair group", [N["front_hair"]]),
|
||||
("hair-bangs", "bangs group", [N["front_hair"], N["bangs"]]),
|
||||
("hair-side-left-1", "left side hair 1 group", [N["front_hair"], N["side_left_1"]]),
|
||||
("hair-side-left-2", "left side hair 2 group", [N["front_hair"], N["side_left_2"]]),
|
||||
("hair-side-right-1", "right side hair 1 group", [N["front_hair"], N["side_right_1"]]),
|
||||
("hair-side-right-2", "right side hair 2 group", [N["front_hair"], N["side_right_2"]]),
|
||||
("mouth-neutral", "mouth smile group", [N["mouth"], N["smile"]]),
|
||||
]
|
||||
|
||||
for part_id, source, path in simple_parts:
|
||||
save_part(entries, part_id, source, composite_path(psd, path))
|
||||
|
||||
hair_back_base = composite_many(
|
||||
[
|
||||
composite_path(psd, [N["back_extra_left"]]),
|
||||
composite_path(psd, [N["back_extra_right"]]),
|
||||
]
|
||||
)
|
||||
save_part(entries, "hair-back-base", "top extra back hair layers", hair_back_base)
|
||||
|
||||
mouth_open_groups = [
|
||||
[N["mouth"], N["mouth_inner"]],
|
||||
[N["mouth"], N["upper_teeth"]],
|
||||
[N["mouth"], N["lower_side_teeth"]],
|
||||
[N["mouth"], N["tongue"]],
|
||||
[N["mouth"], N["lower_teeth"]],
|
||||
[N["mouth"], N["upper_lip"]],
|
||||
[N["mouth"], N["lower_lip"]],
|
||||
]
|
||||
mouth_open = save_part(
|
||||
entries,
|
||||
"mouth-open",
|
||||
"hidden open mouth groups",
|
||||
composite_with_visibility(
|
||||
psd,
|
||||
[N["mouth"]],
|
||||
visible_paths=mouth_open_groups,
|
||||
hidden_paths=[[N["mouth"], N["smile"]]],
|
||||
),
|
||||
)
|
||||
mouth_neutral = Image.open(PARTS / "mouth-neutral.png").convert("RGBA")
|
||||
save_part(entries, "mouth-warm", "derived from mouth-neutral bbox", paste_scaled(mouth_neutral, 1.12, 1.02, offset_y=-1))
|
||||
save_part(entries, "mouth-tired", "derived from mouth-neutral bbox", make_curve_mouth(mouth_neutral, curve=0.15, width_scale=0.86))
|
||||
save_part(entries, "mouth-sad", "derived from mouth-neutral bbox", make_curve_mouth(mouth_neutral, curve=-1.0, width_scale=0.9))
|
||||
save_part(entries, "mouth-anxious", "derived from mouth-neutral bbox", paste_scaled(mouth_neutral, 0.76, 0.82, offset_y=0))
|
||||
save_part(entries, "mouth-startled", "derived from mouth-open bbox", paste_scaled(mouth_open, 0.68, 0.72, offset_y=-1))
|
||||
save_part(entries, "mouth-open-small", "derived from mouth-open bbox", paste_scaled(mouth_open, 0.86, 0.62, offset_y=-1))
|
||||
|
||||
make_preview(LAYER_ORDER)
|
||||
make_contact(entries)
|
||||
make_imagegen_harness(entries)
|
||||
|
||||
(OUT / "detailed-parts-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": str(PSB),
|
||||
"sourceCanvas": SOURCE_CANVAS,
|
||||
"targetCanvas": TARGET_CANVAS,
|
||||
"cropBox": CROP_BOX,
|
||||
"layerOrder": LAYER_ORDER,
|
||||
"parts": entries,
|
||||
"imageGenerationHarness": {
|
||||
"canvas": TARGET_CANVAS,
|
||||
"rule": "Generate each replacement part on this exact canvas, transparent outside the part bbox, matching alphaBox and originPercent.",
|
||||
"file": "imagegen-coordinate-harness.png",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sync_to_app()
|
||||
print(f"exported {len(entries)} detailed parts")
|
||||
print(f"parts {PARTS}")
|
||||
print(f"app parts {APP_PARTS}")
|
||||
print(f"harness {OUT / 'imagegen-coordinate-harness.png'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
docs/avatar-art/seoyeon/live2d-psb/export_psb_layers.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export PSB layers through ImageMagick and preserve canvas offsets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
OUT_RAW = HERE / "layers-raw"
|
||||
OUT_CANVAS = HERE / "layers-canvas"
|
||||
MANIFEST = HERE / "layers-manifest.json"
|
||||
|
||||
PSB = Path(r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\라투디 여캐.psb")
|
||||
CANVAS_SIZE = (1080, 1920)
|
||||
|
||||
|
||||
def run_magick(args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["magick", *args],
|
||||
cwd=HERE,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
|
||||
return result.stdout
|
||||
|
||||
|
||||
def identify_layers() -> list[dict[str, object]]:
|
||||
output = run_magick(["identify", "-format", "%p\t%w\t%h\t%g\t%X\t%Y\n", str(PSB)])
|
||||
layers: list[dict[str, object]] = []
|
||||
for line in output.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
index_s, width_s, height_s, page, x_s, y_s = line.split("\t")
|
||||
layers.append(
|
||||
{
|
||||
"index": int(index_s),
|
||||
"width": int(width_s),
|
||||
"height": int(height_s),
|
||||
"page": page,
|
||||
"x": int(x_s),
|
||||
"y": int(y_s),
|
||||
}
|
||||
)
|
||||
return layers
|
||||
|
||||
|
||||
def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None:
|
||||
arr = np.array(im.convert("RGBA").getchannel("A"))
|
||||
ys, xs = np.where(arr > threshold)
|
||||
if len(xs) == 0:
|
||||
return None
|
||||
return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)
|
||||
|
||||
|
||||
def export_layer(layer: dict[str, object]) -> dict[str, object]:
|
||||
index = int(layer["index"])
|
||||
raw = OUT_RAW / f"layer-{index:03d}.png"
|
||||
full = OUT_CANVAS / f"layer-{index:03d}.png"
|
||||
|
||||
run_magick([f"{PSB}[{index}]", "-background", "none", "-alpha", "on", "png32:" + str(raw)])
|
||||
src = Image.open(raw).convert("RGBA")
|
||||
full_canvas = Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0))
|
||||
full_canvas.alpha_composite(src, (int(layer["x"]), int(layer["y"])))
|
||||
full_canvas.save(full, optimize=True)
|
||||
|
||||
bbox = alpha_bbox(full_canvas)
|
||||
return {
|
||||
**layer,
|
||||
"raw": raw.relative_to(HERE).as_posix(),
|
||||
"canvas": full.relative_to(HERE).as_posix(),
|
||||
"alphaBox": list(bbox) if bbox else None,
|
||||
}
|
||||
|
||||
|
||||
def make_contact(layers: list[dict[str, object]]) -> None:
|
||||
visible = [layer for layer in layers if layer.get("alphaBox") is not None]
|
||||
tile_w, tile_h = 180, 220
|
||||
cols = 6
|
||||
rows = (len(visible) + cols - 1) // cols
|
||||
sheet = Image.new("RGBA", (cols * tile_w, rows * tile_h), (30, 39, 36, 255))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for i, layer in enumerate(visible):
|
||||
full = Image.open(HERE / str(layer["canvas"])).convert("RGBA")
|
||||
box = tuple(layer["alphaBox"])
|
||||
thumb = full.crop(box)
|
||||
thumb.thumbnail((tile_w - 18, tile_h - 54), Image.Resampling.LANCZOS)
|
||||
x = (i % cols) * tile_w + (tile_w - thumb.width) // 2
|
||||
y = (i // cols) * tile_h + 12
|
||||
sheet.alpha_composite(thumb, (x, y))
|
||||
label = f"{int(layer['index']):03d} {layer['width']}x{layer['height']} {layer['page']}"
|
||||
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 36), label, fill=(210, 222, 216, 255))
|
||||
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 20), str(layer["alphaBox"]), fill=(150, 166, 160, 255))
|
||||
sheet.save(HERE / "layers-contact.png", optimize=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not PSB.exists():
|
||||
raise FileNotFoundError(PSB)
|
||||
OUT_RAW.mkdir(parents=True, exist_ok=True)
|
||||
OUT_CANVAS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
layers = identify_layers()
|
||||
exported = [export_layer(layer) for layer in layers]
|
||||
MANIFEST.write_text(json.dumps({"source": str(PSB), "canvas": CANVAS_SIZE, "layers": exported}, indent=2), encoding="utf-8")
|
||||
make_contact(exported)
|
||||
|
||||
flattened = HERE / "flattened.png"
|
||||
run_magick([f"{PSB}[0]", "-background", "none", "-alpha", "on", "png32:" + str(flattened)])
|
||||
print(f"exported {len(exported)} layers")
|
||||
print(f"manifest {MANIFEST}")
|
||||
print(f"contact {HERE / 'layers-contact.png'}")
|
||||
print(f"flattened {flattened}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
158
docs/avatar-art/seoyeon/live2d-psb/export_top_groups.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export top-level PSB groups as normalized app-ready raster layers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from psd_tools import PSDImage
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PSB = Path(r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\라투디 여캐.psb")
|
||||
OUT = HERE / "top-groups"
|
||||
PARTS = OUT / "parts"
|
||||
|
||||
SOURCE_CANVAS = (1080, 1920)
|
||||
TARGET_CANVAS = (900, 1125)
|
||||
CROP_BOX = (0, 240, 1080, 1590)
|
||||
|
||||
LAYER_ORDER = [
|
||||
"hair-back",
|
||||
"body",
|
||||
"outfit",
|
||||
"face",
|
||||
"eye-left",
|
||||
"eye-right",
|
||||
"hair-front",
|
||||
"mouth",
|
||||
]
|
||||
|
||||
GROUP_MAP = {
|
||||
"뒷머리": "hair-back",
|
||||
"몸": "body",
|
||||
"의상": "outfit",
|
||||
"얼굴": "face",
|
||||
"좌 눈": "eye-left",
|
||||
"우 눈": "eye-right",
|
||||
"머리카락": "hair-front",
|
||||
"입": "mouth",
|
||||
}
|
||||
|
||||
EXTRA_BACK_HAIR = {"레이어 110", "레이어 110 복사"}
|
||||
|
||||
|
||||
def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None:
|
||||
alpha = np.array(im.convert("RGBA").getchannel("A"))
|
||||
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 resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
arr = np.array(im.convert("RGBA")).astype(np.float32)
|
||||
alpha = arr[:, :, 3:4] / 255.0
|
||||
premultiplied = arr.copy()
|
||||
premultiplied[:, :, :3] *= alpha
|
||||
resized = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGBA").resize(
|
||||
size,
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
out = np.array(resized).astype(np.float32)
|
||||
out_alpha = out[:, :, 3:4] / 255.0
|
||||
out[:, :, :3] = np.where(out_alpha > 0.001, out[:, :, :3] / np.maximum(out_alpha, 0.001), 0)
|
||||
out[out[:, :, 3] < 1, :3] = 0
|
||||
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA")
|
||||
|
||||
|
||||
def normalize(full_canvas: Image.Image) -> Image.Image:
|
||||
return resize_rgba(full_canvas.crop(CROP_BOX), TARGET_CANVAS)
|
||||
|
||||
|
||||
def composite_layers(layers: Iterable[Image.Image]) -> Image.Image:
|
||||
canvas = Image.new("RGBA", SOURCE_CANVAS, (0, 0, 0, 0))
|
||||
for layer in layers:
|
||||
canvas.alpha_composite(layer.convert("RGBA"))
|
||||
return canvas
|
||||
|
||||
|
||||
def make_contact(entries: list[dict[str, object]]) -> None:
|
||||
tile_w, tile_h = 180, 180
|
||||
sheet = Image.new("RGBA", (tile_w * 4, tile_h * 2), (30, 39, 36, 255))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for i, entry in enumerate(entries):
|
||||
layer = Image.open(PARTS / str(entry["file"])).convert("RGBA")
|
||||
box = alpha_bbox(layer)
|
||||
thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box)
|
||||
thumb.thumbnail((tile_w - 22, tile_h - 46), Image.Resampling.LANCZOS)
|
||||
x = (i % 4) * tile_w + (tile_w - thumb.width) // 2
|
||||
y = (i // 4) * tile_h + 12
|
||||
sheet.alpha_composite(thumb, (x, y))
|
||||
draw.text(((i % 4) * tile_w + 8, (i // 4) * tile_h + tile_h - 28), str(entry["id"]), fill=(210, 222, 216, 255))
|
||||
draw.text(((i % 4) * tile_w + 8, (i // 4) * tile_h + tile_h - 14), str(entry["source"]), fill=(150, 166, 160, 255))
|
||||
sheet.save(OUT / "top-groups-contact.png", optimize=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
psd = PSDImage.open(PSB)
|
||||
PARTS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
top = {layer.name: layer for layer in psd}
|
||||
entries: list[dict[str, object]] = []
|
||||
|
||||
extra_back = [top[name].composite(viewport=(0, 0, psd.width, psd.height)) for name in EXTRA_BACK_HAIR if name in top]
|
||||
|
||||
for source_name, part_id in GROUP_MAP.items():
|
||||
if source_name not in top:
|
||||
raise KeyError(f"Missing top-level group: {source_name}")
|
||||
image = top[source_name].composite(viewport=(0, 0, psd.width, psd.height))
|
||||
if part_id == "hair-back":
|
||||
image = composite_layers([*extra_back, image])
|
||||
normalized = normalize(image)
|
||||
file_name = f"{part_id}.png"
|
||||
normalized.save(PARTS / file_name, optimize=True)
|
||||
entries.append(
|
||||
{
|
||||
"id": part_id,
|
||||
"source": source_name,
|
||||
"file": file_name,
|
||||
"alphaBox": list(alpha_bbox(normalized) or ()),
|
||||
}
|
||||
)
|
||||
|
||||
preview = Image.new("RGBA", TARGET_CANVAS, (30, 39, 36, 255))
|
||||
for part_id in LAYER_ORDER:
|
||||
preview.alpha_composite(Image.open(PARTS / f"{part_id}.png").convert("RGBA"))
|
||||
preview.save(OUT / "preview-psb-groups.png", optimize=True)
|
||||
|
||||
transparent = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0))
|
||||
for part_id in LAYER_ORDER:
|
||||
transparent.alpha_composite(Image.open(PARTS / f"{part_id}.png").convert("RGBA"))
|
||||
transparent.save(OUT / "preview-psb-groups-transparent.png", optimize=True)
|
||||
|
||||
(OUT / "top-groups-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": str(PSB),
|
||||
"sourceCanvas": SOURCE_CANVAS,
|
||||
"targetCanvas": TARGET_CANVAS,
|
||||
"cropBox": CROP_BOX,
|
||||
"layerOrder": LAYER_ORDER,
|
||||
"parts": entries,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
make_contact(entries)
|
||||
print(f"exported {len(entries)} top-level groups")
|
||||
print(f"preview {OUT / 'preview-psb-groups.png'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Turn an imagegen eye atlas into coordinate-locked app eye parts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parents[3]
|
||||
ATLAS = HERE / "imagegen" / "eye-expression-atlas-v1.png"
|
||||
OUT = HERE / "imagegen" / "eye-variants"
|
||||
PARTS = OUT / "parts"
|
||||
APP_PARTS = REPO_ROOT / "apps/web/public/avatar/seoyeon-live2d-psb/parts"
|
||||
|
||||
CANVAS = (900, 1125)
|
||||
|
||||
TARGETS = {
|
||||
"joy": {
|
||||
"brow-left": (308, 292, 436, 317),
|
||||
"brow-right": (472, 283, 601, 300),
|
||||
"eye-left": (296, 312, 436, 365),
|
||||
"eye-right": (472, 303, 619, 356),
|
||||
},
|
||||
"sad": {
|
||||
"brow-left": (308, 294, 436, 322),
|
||||
"brow-right": (472, 286, 601, 308),
|
||||
"eye-left": (296, 306, 436, 392),
|
||||
"eye-right": (472, 297, 619, 377),
|
||||
},
|
||||
"startled": {
|
||||
"brow-left": (308, 286, 436, 313),
|
||||
"brow-right": (472, 277, 601, 300),
|
||||
"eye-left": (296, 292, 436, 392),
|
||||
"eye-right": (472, 283, 619, 377),
|
||||
},
|
||||
}
|
||||
|
||||
ROW_NAMES = ["joy", "sad", "startled"]
|
||||
ROW_PARTS = ["brow-left", "brow-right", "eye-left", "eye-right"]
|
||||
|
||||
|
||||
def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None:
|
||||
alpha = np.array(im.convert("RGBA").getchannel("A"))
|
||||
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 chroma_to_alpha(im: Image.Image) -> Image.Image:
|
||||
arr_u8 = np.array(im.convert("RGBA"))
|
||||
rgb_u8 = arr_u8[:, :, :3]
|
||||
hsv = cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2HSV)
|
||||
hue = hsv[:, :, 0]
|
||||
sat = hsv[:, :, 1]
|
||||
val = hsv[:, :, 2]
|
||||
green_bg = (hue >= 42) & (hue <= 92) & (sat > 45) & (val > 18)
|
||||
# Keep a tiny soft edge by making only clearly non-green pixels opaque.
|
||||
alpha = np.where(green_bg, 0, 255).astype(np.uint8)
|
||||
alpha = cv2.medianBlur(alpha, 3)
|
||||
arr = arr_u8.astype(np.float32)
|
||||
rgb = arr[:, :, :3]
|
||||
greenish_edge = (rgb[:, :, 1] > rgb[:, :, 0] + 8) & (rgb[:, :, 1] > rgb[:, :, 2] + 8)
|
||||
rgb[:, :, 1] = np.where(greenish_edge, np.minimum(rgb[:, :, 1], np.maximum(rgb[:, :, 0], rgb[:, :, 2]) + 12), rgb[:, :, 1])
|
||||
arr[:, :, :3] = rgb
|
||||
arr[:, :, 3] = alpha
|
||||
arr[arr[:, :, 3] < 2, :3] = 0
|
||||
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA")
|
||||
|
||||
|
||||
def component_boxes(alpha_im: Image.Image) -> list[tuple[int, int, int, int]]:
|
||||
alpha = np.array(alpha_im.getchannel("A"))
|
||||
mask = (alpha > 22).astype(np.uint8)
|
||||
num, _labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, 8)
|
||||
boxes: list[tuple[int, int, int, int, int]] = []
|
||||
for i in range(1, num):
|
||||
x, y, w, h, area = stats[i]
|
||||
if area > 200:
|
||||
boxes.append((int(x), int(y), int(x + w), int(y + h), int(area)))
|
||||
boxes.sort(key=lambda box: (box[1], box[0]))
|
||||
if len(boxes) != 12:
|
||||
raise RuntimeError(f"Expected 12 eye atlas components, got {len(boxes)}: {boxes}")
|
||||
return [(x0, y0, x1, y1) for x0, y0, x1, y1, _area in boxes]
|
||||
|
||||
|
||||
def crop_with_padding(im: Image.Image, box: tuple[int, int, int, int], padding: int = 10) -> Image.Image:
|
||||
x0, y0, x1, y1 = box
|
||||
x0 = max(0, x0 - padding)
|
||||
y0 = max(0, y0 - padding)
|
||||
x1 = min(im.width, x1 + padding)
|
||||
y1 = min(im.height, y1 + padding)
|
||||
return im.crop((x0, y0, x1, y1))
|
||||
|
||||
|
||||
def fit_into_canvas(crop: Image.Image, target: tuple[int, int, int, int]) -> Image.Image:
|
||||
target_w = target[2] - target[0]
|
||||
target_h = target[3] - target[1]
|
||||
box = alpha_bbox(crop)
|
||||
source = crop.crop(box) if box else crop
|
||||
scale = min(target_w / source.width, target_h / source.height)
|
||||
size = (max(1, round(source.width * scale)), max(1, round(source.height * scale)))
|
||||
source = source.resize(size, Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
|
||||
x = target[0] + (target_w - source.width) // 2
|
||||
y = target[1] + (target_h - source.height) // 2
|
||||
canvas.alpha_composite(source, (x, y))
|
||||
return canvas
|
||||
|
||||
|
||||
def make_contact(entries: list[dict[str, object]]) -> None:
|
||||
tile_w, tile_h = 180, 150
|
||||
cols = 4
|
||||
rows = 3
|
||||
sheet = Image.new("RGBA", (tile_w * cols, tile_h * rows), (30, 39, 36, 255))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for i, entry in enumerate(entries):
|
||||
part = Image.open(PARTS / str(entry["file"])).convert("RGBA")
|
||||
box = alpha_bbox(part)
|
||||
thumb = part.crop(box) if box else part
|
||||
thumb.thumbnail((tile_w - 24, tile_h - 42), Image.Resampling.LANCZOS)
|
||||
x = (i % cols) * tile_w + (tile_w - thumb.width) // 2
|
||||
y = (i // cols) * tile_h + 10
|
||||
sheet.alpha_composite(thumb, (x, y))
|
||||
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 30), str(entry["id"]), fill=(220, 228, 224, 255))
|
||||
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 15), str(entry["alphaBox"]), fill=(150, 166, 160, 255))
|
||||
sheet.save(OUT / "eye-variants-contact.png", optimize=True)
|
||||
|
||||
|
||||
def sync_to_app() -> None:
|
||||
APP_PARTS.mkdir(parents=True, exist_ok=True)
|
||||
for source in PARTS.glob("*.png"):
|
||||
shutil.copy2(source, APP_PARTS / source.name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not ATLAS.exists():
|
||||
raise FileNotFoundError(ATLAS)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
PARTS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
keyed = chroma_to_alpha(Image.open(ATLAS))
|
||||
keyed.save(OUT / "eye-expression-atlas-alpha.png", optimize=True)
|
||||
boxes = component_boxes(keyed)
|
||||
|
||||
entries: list[dict[str, object]] = []
|
||||
for row_index, variant in enumerate(ROW_NAMES):
|
||||
row_boxes = boxes[row_index * 4 : row_index * 4 + 4]
|
||||
for part_name, source_box in zip(ROW_PARTS, row_boxes):
|
||||
part_id = f"eyegen-{variant}-{part_name}"
|
||||
crop = crop_with_padding(keyed, source_box)
|
||||
canvas = fit_into_canvas(crop, TARGETS[variant][part_name])
|
||||
path = PARTS / f"{part_id}.png"
|
||||
canvas.save(path, optimize=True)
|
||||
box = alpha_bbox(canvas)
|
||||
entries.append(
|
||||
{
|
||||
"id": part_id,
|
||||
"file": path.name,
|
||||
"sourceBox": list(source_box),
|
||||
"targetBox": list(TARGETS[variant][part_name]),
|
||||
"alphaBox": list(box) if box else None,
|
||||
}
|
||||
)
|
||||
|
||||
(OUT / "eye-variants-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": str(ATLAS),
|
||||
"canvas": CANVAS,
|
||||
"variants": ROW_NAMES,
|
||||
"parts": entries,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
make_contact(entries)
|
||||
sync_to_app()
|
||||
print(f"extracted {len(entries)} imagegen eye variant parts")
|
||||
print(f"parts {PARTS}")
|
||||
print(f"app parts {APP_PARTS}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
BIN
docs/avatar-art/seoyeon/live2d-psb/flattened.png
Normal file
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
|
@ -0,0 +1,278 @@
|
|||
{
|
||||
"source": "D:\\workspace\\vignette\\docs\\avatar-art\\seoyeon\\live2d-psb\\imagegen\\eye-expression-atlas-v1.png",
|
||||
"canvas": [
|
||||
900,
|
||||
1125
|
||||
],
|
||||
"variants": [
|
||||
"joy",
|
||||
"sad",
|
||||
"startled"
|
||||
],
|
||||
"parts": [
|
||||
{
|
||||
"id": "eyegen-joy-brow-left",
|
||||
"file": "eyegen-joy-brow-left.png",
|
||||
"sourceBox": [
|
||||
157,
|
||||
211,
|
||||
412,
|
||||
261
|
||||
],
|
||||
"targetBox": [
|
||||
308,
|
||||
292,
|
||||
436,
|
||||
317
|
||||
],
|
||||
"alphaBox": [
|
||||
308,
|
||||
292,
|
||||
436,
|
||||
317
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-joy-brow-right",
|
||||
"file": "eyegen-joy-brow-right.png",
|
||||
"sourceBox": [
|
||||
611,
|
||||
211,
|
||||
867,
|
||||
261
|
||||
],
|
||||
"targetBox": [
|
||||
472,
|
||||
283,
|
||||
601,
|
||||
300
|
||||
],
|
||||
"alphaBox": [
|
||||
493,
|
||||
283,
|
||||
580,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-joy-eye-left",
|
||||
"file": "eyegen-joy-eye-left.png",
|
||||
"sourceBox": [
|
||||
133,
|
||||
311,
|
||||
400,
|
||||
386
|
||||
],
|
||||
"targetBox": [
|
||||
296,
|
||||
312,
|
||||
436,
|
||||
365
|
||||
],
|
||||
"alphaBox": [
|
||||
296,
|
||||
319,
|
||||
436,
|
||||
358
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-joy-eye-right",
|
||||
"file": "eyegen-joy-eye-right.png",
|
||||
"sourceBox": [
|
||||
626,
|
||||
311,
|
||||
891,
|
||||
387
|
||||
],
|
||||
"targetBox": [
|
||||
472,
|
||||
303,
|
||||
619,
|
||||
356
|
||||
],
|
||||
"alphaBox": [
|
||||
472,
|
||||
308,
|
||||
619,
|
||||
350
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-sad-brow-left",
|
||||
"file": "eyegen-sad-brow-left.png",
|
||||
"sourceBox": [
|
||||
174,
|
||||
664,
|
||||
399,
|
||||
723
|
||||
],
|
||||
"targetBox": [
|
||||
308,
|
||||
294,
|
||||
436,
|
||||
322
|
||||
],
|
||||
"alphaBox": [
|
||||
318,
|
||||
294,
|
||||
425,
|
||||
322
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-sad-brow-right",
|
||||
"file": "eyegen-sad-brow-right.png",
|
||||
"sourceBox": [
|
||||
625,
|
||||
664,
|
||||
848,
|
||||
723
|
||||
],
|
||||
"targetBox": [
|
||||
472,
|
||||
286,
|
||||
601,
|
||||
308
|
||||
],
|
||||
"alphaBox": [
|
||||
495,
|
||||
286,
|
||||
578,
|
||||
308
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-sad-eye-left",
|
||||
"file": "eyegen-sad-eye-left.png",
|
||||
"sourceBox": [
|
||||
135,
|
||||
740,
|
||||
397,
|
||||
861
|
||||
],
|
||||
"targetBox": [
|
||||
296,
|
||||
306,
|
||||
436,
|
||||
392
|
||||
],
|
||||
"alphaBox": [
|
||||
296,
|
||||
316,
|
||||
436,
|
||||
381
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-sad-eye-right",
|
||||
"file": "eyegen-sad-eye-right.png",
|
||||
"sourceBox": [
|
||||
628,
|
||||
740,
|
||||
890,
|
||||
861
|
||||
],
|
||||
"targetBox": [
|
||||
472,
|
||||
297,
|
||||
619,
|
||||
377
|
||||
],
|
||||
"alphaBox": [
|
||||
472,
|
||||
303,
|
||||
619,
|
||||
371
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-startled-brow-left",
|
||||
"file": "eyegen-startled-brow-left.png",
|
||||
"sourceBox": [
|
||||
206,
|
||||
1060,
|
||||
387,
|
||||
1101
|
||||
],
|
||||
"targetBox": [
|
||||
308,
|
||||
286,
|
||||
436,
|
||||
313
|
||||
],
|
||||
"alphaBox": [
|
||||
312,
|
||||
286,
|
||||
431,
|
||||
313
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-startled-brow-right",
|
||||
"file": "eyegen-startled-brow-right.png",
|
||||
"sourceBox": [
|
||||
637,
|
||||
1060,
|
||||
818,
|
||||
1101
|
||||
],
|
||||
"targetBox": [
|
||||
472,
|
||||
277,
|
||||
601,
|
||||
300
|
||||
],
|
||||
"alphaBox": [
|
||||
485,
|
||||
277,
|
||||
587,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-startled-eye-left",
|
||||
"file": "eyegen-startled-eye-left.png",
|
||||
"sourceBox": [
|
||||
144,
|
||||
1132,
|
||||
388,
|
||||
1285
|
||||
],
|
||||
"targetBox": [
|
||||
296,
|
||||
292,
|
||||
436,
|
||||
392
|
||||
],
|
||||
"alphaBox": [
|
||||
296,
|
||||
298,
|
||||
436,
|
||||
386
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eyegen-startled-eye-right",
|
||||
"file": "eyegen-startled-eye-right.png",
|
||||
"sourceBox": [
|
||||
637,
|
||||
1132,
|
||||
880,
|
||||
1285
|
||||
],
|
||||
"targetBox": [
|
||||
472,
|
||||
283,
|
||||
619,
|
||||
377
|
||||
],
|
||||
"alphaBox": [
|
||||
472,
|
||||
283,
|
||||
619,
|
||||
376
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-000.png
Normal file
|
After Width: | Height: | Size: 777 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-001.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-002.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-003.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-004.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-005.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-006.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-007.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-008.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-009.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-010.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-011.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-012.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-013.png
Normal file
|
After Width: | Height: | Size: 486 KiB |