현재 작업 상태 저장

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

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