현재 작업 상태 저장

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: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -0,0 +1 @@
dd0654bc60827deb6be7b9768ef0e747e3972e3370e8239510e89fd6d5df9ddd

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -0,0 +1 @@
0d0253579f1876067b2082af61d801d26a46f99033f2f637aa7afc9148dd2cf4

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -0,0 +1 @@
8ccd24cef046e5b7b16e7c3c87e64da3415ad9d4353cf99d0069eb14700dce72

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

@ -0,0 +1 @@
6dd8c6b87cd093e7beb42f23650f319852d3b6d3c5916c922360ee31b440924c

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1 @@
2b149c379cd66aab04b7784f17ad301657c1cead46eedbd7c830845bbf39c352

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View file

@ -0,0 +1 @@
2ffa463b3564944c406402cdff1ebb770d520d3df5ef8a60f858da0f799745a1

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,410 @@
{
"version": 3,
"artSet": "seoyeon-live2d-v3",
"sheet": {
"width": 1536,
"height": 1024,
"background": "flat pure white",
"template": "layout-template-v3.png",
"generated": "sheet-v3.png"
},
"canvas": {
"width": 900,
"height": 1125
},
"extraction": {
"sourceInsetByAlpha": {
"matte": 4,
"dark": 3,
"shape": 0
},
"frameEraseByAlpha": {
"matte": 3,
"dark": 2,
"shape": 0
},
"suppressEdgeArtifactsByAlpha": {
"matte": true,
"dark": false,
"shape": false
},
"edgeArtifactWidth": 3,
"edgeArtifactLuma": 150,
"edgeArtifactChroma": 30,
"aiBackend": "ben2",
"aiRefine": true,
"defringe": true
},
"character": {
"name": "Seoyeon",
"description": "young Korean woman, soft semi-realistic anime style, short warm-brown bob haircut with straight bangs, gray crewneck shirt, calm neutral expression"
},
"rules": [
"The assembled reference bust is only for visual consistency and is not extracted.",
"Every separated part must be centered inside its assigned slot.",
"Separated parts must use the same scale, skin tone, hair color, and line style as the assembled reference.",
"Do not draw labels, arrows, text, shadows, gradients, texture, or stray marks inside part slots.",
"Eye whites, irises, pupils, highlights, lashes, and eyelids must be separate pieces, not a combined eye image.",
"The faceless head must contain ears, jaw, neck top, and clean skin only: no brows, no eyes, no nose, no mouth.",
"Hair parts must include full painted pixels where hidden by other layers, so they can be moved slightly without holes."
],
"slots": [
{
"id": "reference.assembled",
"role": "reference-only",
"promptName": "assembled front-facing bust reference",
"sheetBox": [24, 80, 470, 920],
"extract": false
},
{
"id": "body.torso",
"file": "torso.png",
"role": "body",
"promptName": "torso and shoulders, gray crewneck shirt, no head",
"sheetBox": [470, 700, 812, 930],
"targetBox": [150, 520, 750, 875],
"alpha": "matte",
"aiCutout": false,
"minArea": 1200,
"keepLargest": true
},
{
"id": "head.faceless",
"file": "head-faceless.png",
"role": "base",
"promptName": "faceless head with ears and neck, clean matching skin, no facial features",
"sheetBox": [510, 40, 760, 455],
"targetBox": [285, 100, 615, 600],
"alpha": "matte",
"aiCutout": true,
"minArea": 1200,
"keepLargest": true
},
{
"id": "neck.fill",
"file": "neck-fill.png",
"role": "base",
"promptName": "derived neck and upper chest skin fill from faceless head",
"sheetBox": [570, 285, 700, 455],
"targetBox": [365, 430, 535, 625],
"alpha": "matte",
"maskShape": "neck-gap",
"minArea": 500,
"keepLargest": true,
"derived": true
},
{
"id": "hair.back",
"file": "hair-back.png",
"role": "hair",
"promptName": "back hair full bob silhouette, behind head",
"sheetBox": [1190, 620, 1448, 922],
"targetBox": [225, 85, 675, 575],
"alpha": "matte",
"aiCutout": true,
"minArea": 1200,
"keepLargest": true,
"pivot": [450, 180]
},
{
"id": "hair.front",
"file": "hair-front.png",
"role": "hair",
"promptName": "full front hair and straight bangs",
"sheetBox": [1145, 22, 1502, 255],
"targetBox": [215, 70, 685, 335],
"alpha": "matte",
"aiCutout": true,
"minArea": 1200,
"keepLargest": true,
"pivot": [450, 145]
},
{
"id": "hair.left",
"file": "hair-left.png",
"role": "hair",
"promptName": "left side hair strand cluster",
"sheetBox": [1142, 285, 1303, 596],
"targetBox": [165, 205, 380, 580],
"alpha": "matte",
"aiCutout": true,
"minArea": 600,
"keepLargest": true,
"pivot": [300, 280]
},
{
"id": "hair.right",
"file": "hair-right.png",
"role": "hair",
"promptName": "right side hair strand cluster",
"sheetBox": [1378, 285, 1518, 596],
"targetBox": [520, 205, 735, 580],
"alpha": "matte",
"aiCutout": true,
"minArea": 600,
"keepLargest": true,
"pivot": [600, 280]
},
{
"id": "brow.left",
"file": "brow-left.png",
"role": "brow",
"promptName": "left eyebrow only",
"sheetBox": [835, 28, 950, 62],
"targetBox": [318, 292, 412, 318],
"alpha": "dark",
"minArea": 10,
"pivot": [365, 305]
},
{
"id": "brow.right",
"file": "brow-right.png",
"role": "brow",
"promptName": "right eyebrow only",
"sheetBox": [995, 28, 1110, 62],
"targetBox": [488, 292, 582, 318],
"alpha": "dark",
"minArea": 10,
"pivot": [535, 305]
},
{
"id": "eye.left.white",
"file": "eye-white-left.png",
"role": "eye-mask",
"promptName": "left eye white sclera only, almond shape",
"sheetBox": [848, 100, 950, 154],
"targetBox": [316, 319, 410, 363],
"alpha": "shape",
"shape": "eye"
},
{
"id": "eye.right.white",
"file": "eye-white-right.png",
"role": "eye-mask",
"promptName": "right eye white sclera only, almond shape",
"sheetBox": [1000, 100, 1102, 154],
"targetBox": [490, 319, 584, 363],
"alpha": "shape",
"shape": "eye"
},
{
"id": "eye.left.iris",
"file": "iris-left.png",
"role": "eye-iris",
"promptName": "left brown iris only, no sclera",
"sheetBox": [865, 168, 925, 228],
"targetBox": [342, 322, 384, 364],
"alpha": "matte",
"keepLargest": true,
"cleanIrisDetail": true,
"pivot": [363, 344]
},
{
"id": "eye.right.iris",
"file": "iris-right.png",
"role": "eye-iris",
"promptName": "right brown iris only, no sclera",
"sheetBox": [1026, 168, 1086, 228],
"targetBox": [516, 322, 558, 364],
"alpha": "matte",
"keepLargest": true,
"cleanIrisDetail": true,
"pivot": [537, 344]
},
{
"id": "eye.left.pupil",
"file": "pupil-left.png",
"role": "eye-pupil",
"promptName": "left black pupil only",
"sheetBox": [875, 252, 916, 294],
"targetBox": [352, 331, 374, 353],
"alpha": "dark",
"keepLargest": true,
"pivot": [363, 344]
},
{
"id": "eye.right.pupil",
"file": "pupil-right.png",
"role": "eye-pupil",
"promptName": "right black pupil only",
"sheetBox": [1036, 252, 1078, 294],
"targetBox": [526, 331, 548, 353],
"alpha": "dark",
"keepLargest": true,
"pivot": [537, 344]
},
{
"id": "eye.left.highlight",
"file": "highlight-left.png",
"role": "eye-highlight",
"promptName": "left small white eye highlight dot only",
"sheetBox": [888, 333, 906, 351],
"targetBox": [358, 326, 368, 336],
"alpha": "shape",
"shape": "circle"
},
{
"id": "eye.right.highlight",
"file": "highlight-right.png",
"role": "eye-highlight",
"promptName": "right small white eye highlight dot only",
"sheetBox": [1048, 333, 1066, 351],
"targetBox": [532, 326, 542, 336],
"alpha": "shape",
"shape": "circle"
},
{
"id": "eye.left.lash",
"file": "lash-left.png",
"role": "eye-line",
"promptName": "left upper eyelash and eye line only",
"sheetBox": [835, 402, 950, 450],
"targetBox": [310, 309, 415, 361],
"alpha": "dark",
"minArea": 10
},
{
"id": "eye.right.lash",
"file": "lash-right.png",
"role": "eye-line",
"promptName": "right upper eyelash and eye line only",
"sheetBox": [1005, 402, 1115, 450],
"targetBox": [485, 309, 590, 361],
"alpha": "dark",
"minArea": 10
},
{
"id": "eye.left.closed",
"file": "eyelid-left-closed.png",
"role": "blink",
"promptName": "left closed eyelid curved line only",
"sheetBox": [835, 486, 950, 516],
"targetBox": [315, 333, 410, 360],
"alpha": "dark",
"minArea": 10
},
{
"id": "eye.right.closed",
"file": "eyelid-right-closed.png",
"role": "blink",
"promptName": "right closed eyelid curved line only",
"sheetBox": [1005, 486, 1115, 516],
"targetBox": [490, 333, 585, 360],
"alpha": "dark",
"minArea": 10
},
{
"id": "nose.neutral",
"file": "nose.png",
"role": "face-detail",
"promptName": "soft small nose only",
"sheetBox": [935, 545, 1008, 590],
"targetBox": [420, 385, 480, 432],
"alpha": "soft",
"minArea": 6,
"alphaScale": 0.45
},
{
"id": "mouth.neutral",
"file": "mouth-neutral.png",
"role": "mouth",
"promptName": "neutral closed mouth only, centered",
"sheetBox": [900, 620, 1042, 660],
"targetBox": [388, 430, 512, 462],
"alpha": "dark",
"minArea": 6,
"pivot": [450, 446]
},
{
"id": "mouth.sad",
"file": "mouth-sad.png",
"role": "mouth",
"promptName": "sad closed mouth only, centered",
"sheetBox": [905, 695, 1038, 735],
"targetBox": [388, 430, 512, 462],
"alpha": "dark",
"minArea": 6,
"pivot": [450, 446]
},
{
"id": "mouth.warm",
"file": "mouth-warm.png",
"role": "mouth",
"promptName": "small warm smile mouth only, centered",
"sheetBox": [905, 760, 1045, 812],
"targetBox": [388, 430, 512, 462],
"alpha": "dark",
"minArea": 6,
"pivot": [450, 446]
},
{
"id": "mouth.open",
"file": "mouth-open.png",
"role": "mouth",
"promptName": "open speaking mouth only, centered",
"sheetBox": [920, 835, 1030, 912],
"targetBox": [394, 418, 506, 474],
"alpha": "matte",
"keepLargest": true,
"pivot": [450, 446]
}
],
"layerOrder": [
"hair.back",
"body.torso",
"head.faceless",
"hair.left",
"hair.right",
"hair.front",
"eye.left.white",
"eye.right.white",
"eye.left.iris",
"eye.right.iris",
"eye.left.pupil",
"eye.right.pupil",
"eye.left.highlight",
"eye.right.highlight",
"eye.left.lash",
"eye.right.lash",
"brow.left",
"brow.right",
"nose.neutral",
"mouth.neutral"
],
"blinkLayerOrder": [
"hair.back",
"body.torso",
"head.faceless",
"hair.left",
"hair.right",
"hair.front",
"eye.left.closed",
"eye.right.closed",
"brow.left",
"brow.right",
"nose.neutral",
"mouth.neutral"
],
"speakingLayerOrder": [
"hair.back",
"body.torso",
"head.faceless",
"hair.left",
"hair.right",
"hair.front",
"eye.left.white",
"eye.right.white",
"eye.left.iris",
"eye.right.iris",
"eye.left.pupil",
"eye.right.pupil",
"eye.left.highlight",
"eye.right.highlight",
"eye.left.lash",
"eye.right.lash",
"brow.left",
"brow.right",
"nose.neutral",
"mouth.open"
]
}

View file

@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""Manifest-driven Live2D-style raster parts harness.
The layout JSON is the single source of truth. This script can:
* render a slot template for the image-generation prompt,
* write a prompt that names every required part and slot,
* extract full-canvas transparent PNG layers from a generated sheet,
* render offline recomposition previews without touching app public assets.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
from typing import Any, Iterable
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from scipy import ndimage as ndi
HERE = Path(__file__).resolve().parent
LAYOUT = HERE / "layout-v3.json"
OUT = HERE / "parts"
AI_WORK = HERE / "ai-cutout-work"
OBJECT_SEPARATE_PY = Path("C:/Users/encep/.agents/skills/object-separation/scripts/separate_object.py")
OBJECT_SEPARATE_VENV_PY = Path("C:/Users/encep/.venvs/object-separation/Scripts/python.exe")
Box = tuple[int, int, int, int]
Inset = tuple[int, int, int, int]
def load_layout() -> dict[str, Any]:
return json.loads(LAYOUT.read_text(encoding="utf-8"))
def as_box(value: list[int] | tuple[int, int, int, int]) -> Box:
return int(value[0]), int(value[1]), int(value[2]), int(value[3])
def as_inset(value: int | list[int] | tuple[int, int, int, int] | None) -> Inset:
if value is None:
return 0, 0, 0, 0
if isinstance(value, int):
return value, value, value, value
return int(value[0]), int(value[1]), int(value[2]), int(value[3])
def inset_box(box: Box, inset: Inset) -> Box:
x1, y1, x2, y2 = box
left, top, right, bottom = inset
nx1, ny1 = x1 + left, y1 + top
nx2, ny2 = x2 - right, y2 - bottom
if nx2 <= nx1 + 2 or ny2 <= ny1 + 2:
return box
return nx1, ny1, nx2, ny2
def slot_by_id(layout: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {slot["id"]: slot for slot in layout["slots"]}
def alpha_bbox(im: Image.Image, threshold: int = 8) -> Box | None:
alpha = np.array(im.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 remove_white_bg(im: Image.Image, *, lo: float = 10.0, hi: float = 58.0) -> Image.Image:
arr = np.array(im.convert("RGBA")).astype(np.float32)
rgb = arr[:, :, :3]
border = np.concatenate(
[
rgb[:6, :, :].reshape(-1, 3),
rgb[-6:, :, :].reshape(-1, 3),
rgb[:, :6, :].reshape(-1, 3),
rgb[:, -6:, :].reshape(-1, 3),
],
axis=0,
)
bg = np.median(border, axis=0)
dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2))
dist_white = np.sqrt(((255.0 - rgb) ** 2).sum(axis=2))
alpha = np.minimum(
np.clip((dist_bg - lo) * 255.0 / max(1.0, hi - lo), 0, 255),
np.clip((dist_white - 12.0) * 255.0 / 48.0, 0, 255),
)
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
chroma = rgb.max(axis=2) - rgb.min(axis=2)
neutral_artifact = (luma > 175) & (chroma < 20) & (dist_white < 110)
alpha = np.where(neutral_artifact, 0, alpha)
keep = ((luma < 225) | (chroma > 14)) & ~neutral_artifact
alpha = np.where(keep & (alpha > 28), np.maximum(alpha, 235), alpha)
arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3])
arr[arr[:, :, 3] < 1, :3] = 0
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA")
def soft_detail_alpha(im: Image.Image, *, lo: float = 2.0, hi: float = 36.0) -> Image.Image:
arr = np.array(im.convert("RGBA")).astype(np.float32)
rgb = arr[:, :, :3]
border = np.concatenate(
[
rgb[:4, :, :].reshape(-1, 3),
rgb[-4:, :, :].reshape(-1, 3),
rgb[:, :4, :].reshape(-1, 3),
rgb[:, -4:, :].reshape(-1, 3),
],
axis=0,
)
bg = np.median(border, axis=0)
dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2))
alpha = np.clip((dist_bg - lo) * 255.0 / max(1.0, hi - lo), 0, 255)
arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3])
arr[arr[:, :, 3] < 1, :3] = 0
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA")
def ai_cutout(im: Image.Image, slot: dict[str, Any], defaults: dict[str, Any]) -> Image.Image:
AI_WORK.mkdir(parents=True, exist_ok=True)
safe_id = slot["id"].replace(".", "-")
src = AI_WORK / f"{safe_id}-input.png"
out = AI_WORK / f"{safe_id}-cutout.png"
meta = AI_WORK / f"{safe_id}.sha256"
im.convert("RGB").save(src, optimize=True)
digest = hashlib.sha256(src.read_bytes()).hexdigest()
if out.exists() and meta.exists() and meta.read_text(encoding="utf-8") == digest:
return Image.open(out).convert("RGBA")
backend = slot.get("aiBackend", defaults.get("aiBackend", "ben2"))
object_separate = shutil.which("object-separate")
if object_separate:
cmd = [object_separate, str(src), str(out), "--backend", str(backend)]
else:
cmd = [str(OBJECT_SEPARATE_VENV_PY), str(OBJECT_SEPARATE_PY), str(src), str(out), "--backend", str(backend)]
if slot.get("aiRefine", defaults.get("aiRefine", True)):
cmd.append("--refine")
result = subprocess.run(cmd, cwd=HERE, text=True, capture_output=True)
if result.returncode != 0:
print(f"ai-cutout failed for {slot['id']}; falling back to matte")
if result.stderr:
print(result.stderr.strip())
return remove_white_bg(im)
meta.write_text(digest, encoding="utf-8")
return Image.open(out).convert("RGBA")
def neutralize_crop_frame(im: Image.Image, px: int) -> Image.Image:
"""Erase atlas slot borders before alpha extraction.
The generated sheet obeys the requested slot layout, so its faint rectangle
guides are useful for coordinates but must never become character pixels.
"""
if px <= 0:
return im
arr = np.array(im.convert("RGBA"))
px = min(px, max(0, arr.shape[0] // 3), max(0, arr.shape[1] // 3))
if px <= 0:
return im
arr[:px, :, :3] = 255
arr[-px:, :, :3] = 255
arr[:, :px, :3] = 255
arr[:, -px:, :3] = 255
arr[:, :, 3] = 255
return Image.fromarray(arr, "RGBA")
def dark_alpha(im: Image.Image) -> Image.Image:
arr = np.array(im.convert("RGBA")).astype(np.float32)
rgb = arr[:, :, :3]
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
chroma = rgb.max(axis=2) - rgb.min(axis=2)
alpha = np.clip((210.0 - luma) * 255.0 / 150.0, 0, 255)
alpha = np.where((luma < 210) | (chroma > 18), alpha, 0)
arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3])
arr[arr[:, :, 3] < 1, :3] = 0
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA")
def shape_mask(size: tuple[int, int], shape: str) -> Image.Image:
w, h = size
mask = Image.new("L", size, 0)
draw = ImageDraw.Draw(mask)
if shape == "circle":
draw.ellipse((1, 1, w - 2, h - 2), fill=255)
elif shape == "eye":
# Almond-like mask. This deliberately preserves white sclera pixels that
# ordinary white-background removal would erase.
pts = [
(1, h // 2),
(w // 5, h // 5),
(w // 2, 2),
(w * 4 // 5, h // 5),
(w - 2, h // 2),
(w * 4 // 5, h * 4 // 5),
(w // 2, h - 2),
(w // 5, h * 4 // 5),
]
draw.polygon(pts, fill=255)
mask = mask.filter(ImageFilter.GaussianBlur(0.9))
elif shape == "neck-fill":
pts = [
(w * 36 // 100, 1),
(w * 64 // 100, 1),
(w * 70 // 100, h * 54 // 100),
(w * 94 // 100, h * 74 // 100),
(w * 84 // 100, h - 2),
(w * 16 // 100, h - 2),
(w * 6 // 100, h * 74 // 100),
(w * 30 // 100, h * 54 // 100),
]
draw.polygon(pts, fill=255)
mask = mask.filter(ImageFilter.GaussianBlur(1.2))
elif shape == "neck-gap":
pts = [
(w * 36 // 100, 1),
(w * 64 // 100, 1),
(w * 67 // 100, h - 2),
(w * 33 // 100, h - 2),
]
draw.polygon(pts, fill=255)
mask = mask.filter(ImageFilter.GaussianBlur(1.4))
else:
draw.rounded_rectangle((1, 1, w - 2, h - 2), radius=max(2, h // 3), fill=255)
return mask
def apply_shape_mask(im: Image.Image, shape: str) -> Image.Image:
part = im.convert("RGBA")
arr = np.array(part)
mask = np.array(shape_mask(part.size, shape))
arr[:, :, 3] = np.minimum(arr[:, :, 3], mask)
arr[arr[:, :, 3] == 0, :3] = 0
return Image.fromarray(arr, "RGBA")
def shape_crop(im: Image.Image, shape: str) -> Image.Image:
return apply_shape_mask(im, shape)
def filter_components(im: Image.Image, *, min_area: int = 0, keep_largest: bool = False) -> Image.Image:
if min_area <= 0 and not keep_largest:
return im
arr = np.array(im.convert("RGBA"))
alpha = arr[:, :, 3] > 8
labels, count = ndi.label(alpha)
if count == 0:
return im
areas = np.bincount(labels.reshape(-1))
areas[0] = 0
if keep_largest:
keep = labels == int(areas.argmax())
else:
keep_labels = np.where(areas >= min_area)[0]
keep = np.isin(labels, keep_labels)
arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0)
arr[arr[:, :, 3] == 0, :3] = 0
return Image.fromarray(arr, "RGBA")
def decontaminate_edges(im: Image.Image, *, opaque_threshold: int = 210) -> Image.Image:
arr = np.array(im.convert("RGBA"))
alpha = arr[:, :, 3]
opaque = alpha >= opaque_threshold
if not opaque.any():
return im
indices = ndi.distance_transform_edt(~opaque, return_distances=False, return_indices=True)
fringe = (alpha > 0) & (alpha < opaque_threshold)
if fringe.any():
arr[fringe, :3] = arr[indices[0][fringe], indices[1][fringe], :3]
arr[alpha == 0, :3] = 0
return Image.fromarray(arr, "RGBA")
def suppress_edge_artifacts(
im: Image.Image,
*,
edge_width: int = 3,
luma_min: float = 150.0,
chroma_max: float = 30.0,
) -> Image.Image:
if edge_width <= 0:
return im
arr = np.array(im.convert("RGBA"))
alpha = arr[:, :, 3]
mask = alpha > 8
if not mask.any():
return im
eroded = ndi.binary_erosion(mask, iterations=edge_width, border_value=0)
edge = mask & ~eroded
rgb = arr[:, :, :3].astype(np.float32)
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
chroma = rgb.max(axis=2) - rgb.min(axis=2)
artifact = edge & (luma > luma_min) & (chroma < chroma_max)
if artifact.any():
arr[artifact, 3] = 0
arr[artifact, :3] = 0
return Image.fromarray(arr, "RGBA")
def clean_iris_detail(im: Image.Image) -> Image.Image:
arr = np.array(im.convert("RGBA"))
alpha = arr[:, :, 3]
if not (alpha > 8).any():
return im
h, w = alpha.shape
yy, xx = np.mgrid[:h, :w]
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
radius = np.sqrt(((xx - cx) / max(1.0, w * 0.46)) ** 2 + ((yy - cy) / max(1.0, h * 0.46)) ** 2)
rgb = arr[:, :, :3].astype(np.float32)
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
chroma = rgb.max(axis=2) - rgb.min(axis=2)
pupil = (radius < 0.62) & (luma < 48)
highlight = (radius < 0.85) & (luma > 235) & (chroma < 30)
pupil_or_highlight = pupil | highlight
arr[pupil_or_highlight, 3] = 0
arr[pupil_or_highlight, :3] = 0
return Image.fromarray(arr, "RGBA")
def resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image:
"""Resize RGBA in premultiplied-alpha space to avoid bright/dark halos."""
if im.size == size:
return im
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)
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA")
def place(part: Image.Image, dst_box: Box, canvas_size: tuple[int, int]) -> Image.Image:
x1, y1, x2, y2 = dst_box
resized = resize_rgba(part, (x2 - x1, y2 - y1))
canvas = Image.new("RGBA", canvas_size, (0, 0, 0, 0))
canvas.alpha_composite(resized, (x1, y1))
return canvas
def draw_template(layout: dict[str, Any], *, clean: bool) -> Path:
sheet = layout["sheet"]
width, height = int(sheet["width"]), int(sheet["height"])
image = Image.new("RGB", (width, height), (255, 255, 255))
draw = ImageDraw.Draw(image)
colors = {
"reference-only": (80, 120, 180),
"base": (220, 145, 70),
"body": (90, 130, 170),
"hair": (95, 70, 45),
"brow": (90, 70, 50),
"eye-mask": (80, 150, 210),
"eye-iris": (90, 90, 150),
"eye-pupil": (30, 30, 30),
"eye-highlight": (160, 160, 160),
"eye-line": (50, 50, 50),
"blink": (120, 80, 150),
"face-detail": (210, 130, 90),
"mouth": (200, 90, 90),
}
for slot in layout["slots"]:
if slot.get("derived", False):
continue
box = as_box(slot["sheetBox"])
role = slot["role"]
color = colors.get(role, (120, 120, 120))
draw.rectangle(box, outline=color, width=3)
if not clean:
label = f"{slot['id']}\n{box[0]},{box[1]}-{box[2]},{box[3]}"
draw.multiline_text((box[0] + 6, box[1] + 6), label, fill=color)
out = HERE / ("layout-template-v3-clean.png" if clean else "layout-template-v3.png")
image.save(out, optimize=True)
return out
def build_prompt(layout: dict[str, Any]) -> str:
lines: list[str] = []
sheet = layout["sheet"]
character = layout["character"]
lines.extend(
[
"Use case: stylized-concept",
"Asset type: Live2D-ready raster character parts atlas",
f"Primary request: Create a {sheet['width']}x{sheet['height']} pixel white-background parts sheet for {character['name']}.",
f"Subject: {character['description']}.",
"Style/medium: polished soft semi-realistic anime illustration, clean raster edges, consistent lighting, consistent scale.",
"Composition/framing: Place each item inside its exact assigned rectangular slot. Keep generous whitespace between slots.",
"Critical Live2D constraints:",
]
)
for rule in layout["rules"]:
lines.append(f"- {rule}")
lines.append("")
lines.append("Exact slot map. Put only the named item in that rectangle:")
for slot in layout["slots"]:
if slot.get("derived", False):
continue
box = as_box(slot["sheetBox"])
name = slot["promptName"]
extract = "reference only" if not slot.get("extract", True) else f"extracts to {slot.get('file')}"
lines.append(f"- {slot['id']}: x={box[0]} y={box[1]} w={box[2]-box[0]} h={box[3]-box[1]}: {name}; {extract}.")
lines.extend(
[
"",
"Eye construction must be layer-ready: white sclera pieces must contain no iris; iris pieces must contain no sclera; pupils must be black-only; highlights must be separate white dots; lashes must be separate line art.",
"Avoid: off-center mouth, mismatched eye scale, combined eyes, facial features on the faceless head, hair holes, side hair drawn as ponytails, labels, captions, watermark, decorative background, shadows, gradients, paper texture, creepy or distorted anatomy.",
]
)
return "\n".join(lines) + "\n"
def default_for_alpha(defaults: dict[str, Any], key: str, alpha_mode: str, fallback: Any) -> Any:
by_alpha = defaults.get(f"{key}ByAlpha", {})
return by_alpha.get(alpha_mode, defaults.get(key, fallback))
def extract_part(
sheet: Image.Image,
slot: dict[str, Any],
canvas_size: tuple[int, int],
defaults: dict[str, Any],
) -> Image.Image:
alpha_mode = slot.get("alpha", "matte")
default_inset = default_for_alpha(defaults, "sourceInset", alpha_mode, 0)
src = inset_box(as_box(slot["sheetBox"]), as_inset(slot.get("sourceInset", default_inset)))
dst = as_box(slot["targetBox"])
crop = sheet.crop(src).convert("RGBA")
frame_erase = int(slot.get("frameErase", default_for_alpha(defaults, "frameErase", alpha_mode, 0)))
crop = neutralize_crop_frame(crop, frame_erase)
if slot.get("aiCutout", False):
part = ai_cutout(crop, slot, defaults)
elif alpha_mode == "shape":
part = shape_crop(crop, slot.get("shape", "round"))
elif alpha_mode == "soft":
part = soft_detail_alpha(crop)
elif alpha_mode == "dark":
part = dark_alpha(crop)
else:
part = remove_white_bg(crop)
if "maskShape" in slot:
part = apply_shape_mask(part, slot["maskShape"])
if slot.get("cleanIrisDetail", False):
part = clean_iris_detail(part)
part = filter_components(
part,
min_area=int(slot.get("minArea", 0)),
keep_largest=bool(slot.get("keepLargest", False)),
)
suppress_artifacts = slot.get(
"suppressEdgeArtifacts",
default_for_alpha(defaults, "suppressEdgeArtifacts", alpha_mode, alpha_mode == "matte"),
)
if suppress_artifacts:
part = suppress_edge_artifacts(
part,
edge_width=int(slot.get("edgeArtifactWidth", defaults.get("edgeArtifactWidth", 3))),
luma_min=float(slot.get("edgeArtifactLuma", defaults.get("edgeArtifactLuma", 150))),
chroma_max=float(slot.get("edgeArtifactChroma", defaults.get("edgeArtifactChroma", 30))),
)
if defaults.get("defringe", True) and slot.get("defringe", True):
part = decontaminate_edges(part)
if "alphaScale" in slot:
arr = np.array(part.convert("RGBA"))
arr[:, :, 3] = np.clip(arr[:, :, 3].astype(np.float32) * float(slot["alphaScale"]), 0, 255).astype(np.uint8)
arr[arr[:, :, 3] == 0, :3] = 0
part = Image.fromarray(arr, "RGBA")
return place(part, dst, canvas_size)
def extract_all(layout: dict[str, Any], sheet_path: Path) -> None:
if not sheet_path.exists():
raise FileNotFoundError(sheet_path)
OUT.mkdir(parents=True, exist_ok=True)
sheet = Image.open(sheet_path).convert("RGBA")
canvas_size = (int(layout["canvas"]["width"]), int(layout["canvas"]["height"]))
defaults = layout.get("extraction", {})
for slot in layout["slots"]:
if not slot.get("extract", True):
continue
im = extract_part(sheet, slot, canvas_size, defaults)
out = OUT / slot["file"]
im.save(out, optimize=True)
print(f"{slot['id']:22} -> {slot['file']:26} bbox={alpha_bbox(im)}")
def composite(layout: dict[str, Any], order_name: str, out_path: Path) -> Image.Image:
slots = slot_by_id(layout)
canvas_size = (int(layout["canvas"]["width"]), int(layout["canvas"]["height"]))
bg = Image.new("RGBA", canvas_size, (30, 39, 36, 255))
for slot_id in layout[order_name]:
slot = slots[slot_id]
bg.alpha_composite(Image.open(OUT / slot["file"]).convert("RGBA"))
bg.save(out_path, optimize=True)
print(f"preview {out_path}")
return bg
def make_contact(layout: dict[str, Any]) -> None:
slots = [slot for slot in layout["slots"] if slot.get("extract", True)]
tile_w, tile_h = 180, 160
cols = 5
rows = (len(slots) + cols - 1) // cols
sheet = Image.new("RGBA", (cols * tile_w, rows * tile_h), (30, 39, 36, 255))
draw = ImageDraw.Draw(sheet)
for i, slot in enumerate(slots):
layer = Image.open(OUT / slot["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 - 24, tile_h - 42), Image.Resampling.LANCZOS)
x = (i % cols) * tile_w + (tile_w - thumb.width) // 2
y = (i // cols) * tile_h + 16
sheet.alpha_composite(thumb, (x, y))
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 22), slot["file"], fill=(200, 214, 208, 255))
out = HERE / "parts-contact-v3.png"
sheet.save(out, optimize=True)
print(f"contact {out}")
def preview(layout: dict[str, Any]) -> None:
composite(layout, "layerOrder", HERE / "preview-neutral-v3.png")
composite(layout, "blinkLayerOrder", HERE / "preview-blink-v3.png")
composite(layout, "speakingLayerOrder", HERE / "preview-speaking-v3.png")
make_contact(layout)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["template", "prompt", "extract", "preview", "all"])
parser.add_argument("--sheet", default=None, help="Generated sheet path for extract/all")
args = parser.parse_args()
layout = load_layout()
if args.command in {"template", "all"}:
labelled = draw_template(layout, clean=False)
clean = draw_template(layout, clean=True)
print(f"template {labelled}")
print(f"template-clean {clean}")
if args.command in {"prompt", "all"}:
prompt = build_prompt(layout)
out = HERE / "prompt-live2d-v3.txt"
out.write_text(prompt, encoding="utf-8")
print(f"prompt {out}")
if args.command in {"extract", "all"}:
sheet_path = Path(args.sheet) if args.sheet else HERE / layout["sheet"]["generated"]
extract_all(layout, sheet_path)
if args.command in {"preview", "all"}:
preview(layout)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

View file

@ -0,0 +1,45 @@
Use case: stylized-concept
Asset type: Live2D-ready raster character parts atlas
Primary request: Create a 1536x1024 pixel white-background parts sheet for Seoyeon.
Subject: young Korean woman, soft semi-realistic anime style, short warm-brown bob haircut with straight bangs, gray crewneck shirt, calm neutral expression.
Style/medium: polished soft semi-realistic anime illustration, clean raster edges, consistent lighting, consistent scale.
Composition/framing: Place each item inside its exact assigned rectangular slot. Keep generous whitespace between slots.
Critical Live2D constraints:
- The assembled reference bust is only for visual consistency and is not extracted.
- Every separated part must be centered inside its assigned slot.
- Separated parts must use the same scale, skin tone, hair color, and line style as the assembled reference.
- Do not draw labels, arrows, text, shadows, gradients, texture, or stray marks inside part slots.
- Eye whites, irises, pupils, highlights, lashes, and eyelids must be separate pieces, not a combined eye image.
- The faceless head must contain ears, jaw, neck top, and clean skin only: no brows, no eyes, no nose, no mouth.
- Hair parts must include full painted pixels where hidden by other layers, so they can be moved slightly without holes.
Exact slot map. Put only the named item in that rectangle:
- reference.assembled: x=24 y=80 w=446 h=840: assembled front-facing bust reference; reference only.
- body.torso: x=470 y=700 w=342 h=230: torso and shoulders, gray crewneck shirt, no head; extracts to torso.png.
- head.faceless: x=510 y=40 w=250 h=415: faceless head with ears and neck, clean matching skin, no facial features; extracts to head-faceless.png.
- hair.back: x=1190 y=620 w=258 h=302: back hair full bob silhouette, behind head; extracts to hair-back.png.
- hair.front: x=1145 y=22 w=357 h=233: full front hair and straight bangs; extracts to hair-front.png.
- hair.left: x=1142 y=285 w=161 h=311: left side hair strand cluster; extracts to hair-left.png.
- hair.right: x=1378 y=285 w=140 h=311: right side hair strand cluster; extracts to hair-right.png.
- brow.left: x=835 y=28 w=115 h=34: left eyebrow only; extracts to brow-left.png.
- brow.right: x=995 y=28 w=115 h=34: right eyebrow only; extracts to brow-right.png.
- eye.left.white: x=848 y=100 w=102 h=54: left eye white sclera only, almond shape; extracts to eye-white-left.png.
- eye.right.white: x=1000 y=100 w=102 h=54: right eye white sclera only, almond shape; extracts to eye-white-right.png.
- eye.left.iris: x=865 y=168 w=60 h=60: left brown iris only, no sclera; extracts to iris-left.png.
- eye.right.iris: x=1026 y=168 w=60 h=60: right brown iris only, no sclera; extracts to iris-right.png.
- eye.left.pupil: x=875 y=252 w=41 h=42: left black pupil only; extracts to pupil-left.png.
- eye.right.pupil: x=1036 y=252 w=42 h=42: right black pupil only; extracts to pupil-right.png.
- eye.left.highlight: x=888 y=333 w=18 h=18: left small white eye highlight dot only; extracts to highlight-left.png.
- eye.right.highlight: x=1048 y=333 w=18 h=18: right small white eye highlight dot only; extracts to highlight-right.png.
- eye.left.lash: x=835 y=402 w=115 h=48: left upper eyelash and eye line only; extracts to lash-left.png.
- eye.right.lash: x=1005 y=402 w=110 h=48: right upper eyelash and eye line only; extracts to lash-right.png.
- eye.left.closed: x=835 y=486 w=115 h=30: left closed eyelid curved line only; extracts to eyelid-left-closed.png.
- eye.right.closed: x=1005 y=486 w=110 h=30: right closed eyelid curved line only; extracts to eyelid-right-closed.png.
- nose.neutral: x=935 y=545 w=73 h=45: soft small nose only; extracts to nose.png.
- mouth.neutral: x=900 y=620 w=142 h=40: neutral closed mouth only, centered; extracts to mouth-neutral.png.
- mouth.sad: x=905 y=695 w=133 h=40: sad closed mouth only, centered; extracts to mouth-sad.png.
- mouth.warm: x=905 y=760 w=140 h=52: small warm smile mouth only, centered; extracts to mouth-warm.png.
- mouth.open: x=920 y=835 w=110 h=77: open speaking mouth only, centered; extracts to mouth-open.png.
Eye construction must be layer-ready: white sclera pieces must contain no iris; iris pieces must contain no sclera; pupils must be black-only; highlights must be separate white dots; lashes must be separate line art.
Avoid: off-center mouth, mismatched eye scale, combined eyes, facial features on the faceless head, hair holes, side hair drawn as ponytails, labels, captions, watermark, decorative background, shadows, gradients, paper texture, creepy or distorted anatomy.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB