192 lines
6.9 KiB
Python
192 lines
6.9 KiB
Python
#!/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())
|