140 lines
5.8 KiB
Python
140 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
|
|
ROOT = Path(r"D:\Work_AI\Dansori")
|
|
SHEET = ROOT / "Characters_Build_Docs" / "LeeSori_Profile" / "03_Assets" / "Reference" / "sori_sheet.png"
|
|
APP_ASSETS = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App" / "Assets"
|
|
PUPPET = APP_ASSETS / "Characters" / "Puppets" / "LeeSoriV2"
|
|
IMAGES = PUPPET / "Images"
|
|
PREVIEW = APP_ASSETS / "Characters" / "Live2DPreview" / "leesori.png"
|
|
|
|
|
|
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
|
|
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
|
|
|
|
|
|
def clean_alpha(im: Image.Image) -> Image.Image:
|
|
im = im.convert("RGBA")
|
|
r, g, b, a = im.split()
|
|
# Remove very dark residual antialias pixels from the black sheet background.
|
|
pixels = im.load()
|
|
for y in range(im.height):
|
|
for x in range(im.width):
|
|
rr, gg, bb, aa = pixels[x, y]
|
|
if aa == 0:
|
|
continue
|
|
if max(rr, gg, bb) < 18:
|
|
pixels[x, y] = (0, 0, 0, 0)
|
|
elif max(rr, gg, bb) < 44 and aa < 255:
|
|
pixels[x, y] = (rr, gg, bb, max(0, aa - 80))
|
|
return im
|
|
|
|
|
|
def crop_front_pose(sheet: Image.Image) -> Image.Image:
|
|
# The new sheet's front full-body pose is the leftmost character.
|
|
crop = sheet.crop((30, 30, 365, 1038))
|
|
crop = clean_alpha(crop)
|
|
crop = crop.crop(alpha_bbox(crop))
|
|
|
|
margin = 36
|
|
out = Image.new("RGBA", (crop.width + margin * 2, crop.height + margin * 2), (0, 0, 0, 0))
|
|
out.alpha_composite(crop, (margin, margin))
|
|
return out
|
|
|
|
|
|
def mask_from_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 3.5) -> Image.Image:
|
|
w, h = size
|
|
pts = [(int(x * w), int(y * h)) for x, y in points]
|
|
mask = Image.new("L", size, 0)
|
|
ImageDraw.Draw(mask).polygon(pts, fill=255)
|
|
if blur:
|
|
mask = mask.filter(ImageFilter.GaussianBlur(blur))
|
|
return mask
|
|
|
|
|
|
def make_part(src: Image.Image, name: str, points: list[tuple[float, float]], blur: float = 3.5) -> None:
|
|
part = Image.new("RGBA", src.size, (0, 0, 0, 0))
|
|
mask = mask_from_polygon(src.size, points, blur)
|
|
part.alpha_composite(src)
|
|
alpha = Image.composite(part.getchannel("A"), Image.new("L", src.size, 0), mask)
|
|
part.putalpha(alpha)
|
|
part.save(IMAGES / name)
|
|
|
|
|
|
def composite_black(parts: list[str], dest: Path, width: int = 390, height: int = 600) -> None:
|
|
canvas = Image.new("RGBA", (width, height), (16, 16, 18, 255))
|
|
src_size = Image.open(IMAGES / parts[0]).size
|
|
scale = min(width * 0.83 / src_size[0], height * 1.08 / src_size[1])
|
|
target = (int(src_size[0] * scale), int(src_size[1] * scale))
|
|
x = (width - target[0]) // 2 + 4
|
|
y = int(height - target[1] + 78)
|
|
for part_name in parts:
|
|
part = Image.open(IMAGES / part_name).convert("RGBA").resize(target, Image.Resampling.LANCZOS)
|
|
canvas.alpha_composite(part, (x, y))
|
|
canvas.convert("RGB").save(dest)
|
|
|
|
|
|
def main() -> None:
|
|
IMAGES.mkdir(parents=True, exist_ok=True)
|
|
sheet = Image.open(SHEET).convert("RGBA")
|
|
source_path = PUPPET / "leesori_v2_source.png"
|
|
if source_path.exists():
|
|
src = Image.open(source_path).convert("RGBA")
|
|
else:
|
|
src = crop_front_pose(sheet)
|
|
src.save(source_path)
|
|
|
|
w, h = src.size
|
|
# Full source is retained underneath; overlays are intentionally separated
|
|
# enough for visible idle motion while staying close to the sheet artwork.
|
|
src.save(IMAGES / "leesori_v2_v3_base.png")
|
|
|
|
# LeeSoriV2 uses the full base for torso/waist. A moving chest overlay
|
|
# creates green/crop-hem ghosting around the exposed waist, so the active
|
|
# rig below omits chest and keeps only head/right-arm motion.
|
|
make_part(src, "leesori_v2_v3_arm_r.png", [(0.65, 0.24), (0.86, 0.24), (0.84, 0.49), (0.69, 0.49), (0.61, 0.34)], blur=2.0)
|
|
make_part(src, "leesori_v2_v3_hand_r.png", [(0.70, 0.47), (0.88, 0.47), (0.87, 0.61), (0.67, 0.60)], blur=1.5)
|
|
make_part(src, "leesori_v2_v3_head.png", [(0.19, 0.00), (0.81, 0.00), (0.82, 0.27), (0.65, 0.36), (0.35, 0.36), (0.18, 0.27)])
|
|
|
|
rig = {
|
|
"name": "LeeSoriV2",
|
|
"status": "new_sheet_overlap_puppet_v4_static_torso_clean_waist",
|
|
"canvas": {"width": w, "height": h},
|
|
"imageBase": "./Images/",
|
|
"note": "New LeeSori sheet-based overlap puppet. Torso, waist, lower body, and pocketed left hand stay in the base image to avoid doubled outlines, green waist ghosting, and awkward hand motion.",
|
|
"bones": [
|
|
{"name": "base", "parent": None, "pivot": [w * 0.50, h * 0.62], "z": 0, "image": "leesori_v2_v3_base.png"},
|
|
{"name": "upperarm_r", "parent": "base", "pivot": [w * 0.74, h * 0.28], "z": 4, "image": "leesori_v2_v3_arm_r.png"},
|
|
{"name": "hand_r", "parent": "upperarm_r", "pivot": [w * 0.78, h * 0.55], "z": 8, "image": "leesori_v2_v3_hand_r.png"},
|
|
{"name": "head", "parent": "base", "pivot": [w * 0.50, h * 0.20], "z": 10, "image": "leesori_v2_v3_head.png"},
|
|
],
|
|
}
|
|
(PUPPET / "rig.json").write_text(json.dumps(rig, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
preview = Image.new("RGBA", (512, 768), (0, 0, 0, 0))
|
|
scale = min(512 * 0.76 / w, 768 * 1.02 / h)
|
|
target = (int(w * scale), int(h * scale))
|
|
resized = src.resize(target, Image.Resampling.LANCZOS)
|
|
preview.alpha_composite(resized, ((512 - target[0]) // 2, 768 - target[1] + 44))
|
|
preview.save(PREVIEW)
|
|
|
|
parts = [
|
|
"leesori_v2_v3_base.png",
|
|
"leesori_v2_v3_arm_r.png",
|
|
"leesori_v2_v3_hand_r.png",
|
|
"leesori_v2_v3_head.png",
|
|
]
|
|
composite_black(parts, PUPPET / "qa_view_390x600_upper_bias.png")
|
|
black = Image.new("RGBA", src.size, (16, 16, 18, 255))
|
|
black.alpha_composite(src)
|
|
black.convert("RGB").save(PUPPET / "qa_source_black.png")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|