현재 작업 상태 저장
This commit is contained in:
parent
07cc67761e
commit
6bd91b0d5e
674 changed files with 8726 additions and 298 deletions
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue