현재 작업 상태 저장
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
|
@ -0,0 +1 @@
|
|||
dd0654bc60827deb6be7b9768ef0e747e3972e3370e8239510e89fd6d5df9ddd
|
||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
|
@ -0,0 +1 @@
|
|||
0d0253579f1876067b2082af61d801d26a46f99033f2f637aa7afc9148dd2cf4
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
|
@ -0,0 +1 @@
|
|||
8ccd24cef046e5b7b16e7c3c87e64da3415ad9d4353cf99d0069eb14700dce72
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
|
@ -0,0 +1 @@
|
|||
6dd8c6b87cd093e7beb42f23650f319852d3b6d3c5916c922360ee31b440924c
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
|
@ -0,0 +1 @@
|
|||
2b149c379cd66aab04b7784f17ad301657c1cead46eedbd7c830845bbf39c352
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
|
@ -0,0 +1 @@
|
|||
2ffa463b3564944c406402cdff1ebb770d520d3df5ef8a60f858da0f799745a1
|
||||
BIN
docs/avatar-art/seoyeon/live2d-v3/app-render/render-grid.png
Normal file
|
After Width: | Height: | Size: 803 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-0.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-1.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-2.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-3.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/debug-nose-crop.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/layout-template-v3-clean.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/layout-template-v3.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
410
docs/avatar-art/seoyeon/live2d-v3/layout-v3.json
Normal 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"
|
||||
]
|
||||
}
|
||||
566
docs/avatar-art/seoyeon/live2d-v3/live2d_harness.py
Normal 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())
|
||||
BIN
docs/avatar-art/seoyeon/live2d-v3/parts-contact-v3.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/brow-left.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/brow-right.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-left.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-right.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-left-closed.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-right-closed.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/hair-back.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/hair-front.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/hair-left.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/hair-right.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/head-faceless.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/highlight-left.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/highlight-right.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/iris-left.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/iris-right.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/lash-left.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/lash-right.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/mouth-neutral.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/mouth-open.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/mouth-sad.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/mouth-warm.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/neck-fill.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/nose.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/pupil-left.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/pupil-right.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/parts/torso.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/preview-blink-v3.png
Normal file
|
After Width: | Height: | Size: 614 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/preview-neutral-v3.png
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/preview-speaking-v3.png
Normal file
|
After Width: | Height: | Size: 622 KiB |
45
docs/avatar-art/seoyeon/live2d-v3/prompt-live2d-v3.txt
Normal 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.
|
||||
BIN
docs/avatar-art/seoyeon/live2d-v3/sheet-v3-grid.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/avatar-art/seoyeon/live2d-v3/sheet-v3.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |