현재 작업 상태 저장

This commit is contained in:
Yun Chan 2026-06-27 11:20:24 +09:00
parent 07cc67761e
commit 6bd91b0d5e
674 changed files with 8726 additions and 298 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,007 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

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

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

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Some files were not shown because too many files have changed in this diff Show more