545 lines
24 KiB
Python
545 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ASSETS = ROOT / "03_Assets"
|
|
LIVE2D = ASSETS / "Live2D"
|
|
PARTS = ASSETS / "Parts" / "Images"
|
|
REFERENCE = ASSETS / "Reference"
|
|
MANIFEST = LIVE2D / "layer_manifest.json"
|
|
OUT_BASE = LIVE2D / "LayerPNGs"
|
|
PREVIEW = LIVE2D / "noeul_live2d_layer_preview.png"
|
|
PREVIEW_CHECKER = LIVE2D / "noeul_live2d_layer_preview_checker.png"
|
|
SWAP_PREVIEW_CHECKER = LIVE2D / "noeul_live2d_swap_parts_preview_checker.png"
|
|
REPORT_JSON = LIVE2D / "layer_generation_report.json"
|
|
REPORT_MD = LIVE2D / "LayerPNGs_README.md"
|
|
|
|
SRC_W, SRC_H = 520, 900
|
|
OUT_W, OUT_H = 1600, 2800
|
|
SCALE = 3
|
|
OFFSET_X, OFFSET_Y = 20, 50
|
|
|
|
|
|
def load_rgba(path: Path) -> Image.Image:
|
|
return Image.open(path).convert("RGBA")
|
|
|
|
|
|
def blank(size: tuple[int, int] = (SRC_W, SRC_H)) -> Image.Image:
|
|
return Image.new("RGBA", size, (0, 0, 0, 0))
|
|
|
|
|
|
def arr(img: Image.Image) -> np.ndarray:
|
|
return np.array(img.convert("RGBA"))
|
|
|
|
|
|
def alpha(img: Image.Image, min_alpha: int = 8) -> np.ndarray:
|
|
return arr(img)[:, :, 3] > min_alpha
|
|
|
|
|
|
def rect(x0: int, y0: int, x1: int, y1: int) -> np.ndarray:
|
|
mask = np.zeros((SRC_H, SRC_W), dtype=bool)
|
|
mask[max(0, y0) : min(SRC_H, y1), max(0, x0) : min(SRC_W, x1)] = True
|
|
return mask
|
|
|
|
|
|
def soften_mask(mask: np.ndarray, radius: float = 0.6) -> Image.Image:
|
|
img = Image.fromarray((mask.astype(np.uint8) * 255), "L")
|
|
if radius:
|
|
img = img.filter(ImageFilter.GaussianBlur(radius))
|
|
return img
|
|
|
|
|
|
def masked(src: Image.Image, mask: np.ndarray, radius: float = 0.45, opacity: float = 1.0) -> Image.Image:
|
|
out = src.copy().convert("RGBA")
|
|
source_alpha = out.getchannel("A")
|
|
mask_img = soften_mask(mask, radius)
|
|
if opacity != 1.0:
|
|
mask_img = mask_img.point(lambda p: int(p * opacity))
|
|
new_alpha = Image.composite(source_alpha, Image.new("L", source_alpha.size, 0), mask_img)
|
|
out.putalpha(new_alpha)
|
|
return out
|
|
|
|
|
|
def merge_source_layers(*imgs: Image.Image) -> Image.Image:
|
|
out = blank()
|
|
for img in imgs:
|
|
out.alpha_composite(img.convert("RGBA"))
|
|
return out
|
|
|
|
|
|
def source_to_output(img: Image.Image) -> Image.Image:
|
|
scaled = img.resize((SRC_W * SCALE, SRC_H * SCALE), Image.Resampling.LANCZOS)
|
|
out = Image.new("RGBA", (OUT_W, OUT_H), (0, 0, 0, 0))
|
|
out.alpha_composite(scaled, (OFFSET_X, OFFSET_Y))
|
|
return out
|
|
|
|
|
|
def fit_to_output(img: Image.Image, max_w: int, max_h: int, y: int) -> Image.Image:
|
|
src = img.convert("RGBA")
|
|
ratio = min(max_w / src.width, max_h / src.height)
|
|
size = (int(src.width * ratio), int(src.height * ratio))
|
|
scaled = src.resize(size, Image.Resampling.LANCZOS)
|
|
out = Image.new("RGBA", (OUT_W, OUT_H), (0, 0, 0, 0))
|
|
out.alpha_composite(scaled, ((OUT_W - size[0]) // 2, y))
|
|
return out
|
|
|
|
|
|
def anti_alias_draw(draw_fn: Callable[[ImageDraw.ImageDraw, int], None]) -> Image.Image:
|
|
factor = 4
|
|
img = Image.new("RGBA", (SRC_W * factor, SRC_H * factor), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
draw_fn(draw, factor)
|
|
return img.resize((SRC_W, SRC_H), Image.Resampling.LANCZOS)
|
|
|
|
|
|
def ellipse_layer(box: tuple[int, int, int, int], fill: tuple[int, int, int, int], blur: float = 0.0) -> Image.Image:
|
|
def draw_fn(draw: ImageDraw.ImageDraw, f: int) -> None:
|
|
draw.ellipse(tuple(v * f for v in box), fill=fill)
|
|
|
|
img = anti_alias_draw(draw_fn)
|
|
if blur:
|
|
img = img.filter(ImageFilter.GaussianBlur(blur))
|
|
return img
|
|
|
|
|
|
def line_layer(
|
|
points: list[tuple[int, int]],
|
|
fill: tuple[int, int, int, int],
|
|
width: int = 2,
|
|
joint: str = "curve",
|
|
) -> Image.Image:
|
|
def draw_fn(draw: ImageDraw.ImageDraw, f: int) -> None:
|
|
pts = [(x * f, y * f) for x, y in points]
|
|
draw.line(pts, fill=fill, width=width * f, joint=joint)
|
|
|
|
return anti_alias_draw(draw_fn)
|
|
|
|
|
|
def polygon_layer(points: list[tuple[int, int]], fill: tuple[int, int, int, int]) -> Image.Image:
|
|
def draw_fn(draw: ImageDraw.ImageDraw, f: int) -> None:
|
|
draw.polygon([(x * f, y * f) for x, y in points], fill=fill)
|
|
|
|
return anti_alias_draw(draw_fn)
|
|
|
|
|
|
def face_underpaint_layer() -> Image.Image:
|
|
base = merge_source_layers(
|
|
ellipse_layer((196, 62, 324, 229), (238, 184, 156, 245), 0.3),
|
|
polygon_layer([(210, 168), (310, 168), (289, 235), (260, 252), (231, 235)], (236, 178, 151, 245)),
|
|
ellipse_layer((218, 80, 302, 198), (248, 199, 174, 125), 5.0),
|
|
)
|
|
return base
|
|
|
|
|
|
def draw_capsule(layer: Image.Image, p0: tuple[int, int], p1: tuple[int, int], width: int, fill: tuple[int, int, int, int]) -> None:
|
|
draw = ImageDraw.Draw(layer)
|
|
draw.line([p0, p1], fill=fill, width=width)
|
|
r = width // 2
|
|
for x, y in (p0, p1):
|
|
draw.ellipse((x - r, y - r, x + r, y + r), fill=fill)
|
|
|
|
|
|
def body_limb_layer(kind: str) -> Image.Image:
|
|
layer = blank()
|
|
skin = (238, 184, 156, 220)
|
|
if kind == "arm_upper_L":
|
|
draw_capsule(layer, (354, 268), (390, 350), 28, skin)
|
|
elif kind == "arm_fore_L":
|
|
draw_capsule(layer, (386, 350), (404, 405), 23, skin)
|
|
elif kind == "arm_upper_R":
|
|
draw_capsule(layer, (166, 268), (128, 350), 28, skin)
|
|
elif kind == "arm_fore_R":
|
|
draw_capsule(layer, (134, 350), (116, 405), 23, skin)
|
|
elif kind == "leg_upper_L":
|
|
draw_capsule(layer, (294, 410), (298, 610), 46, skin)
|
|
elif kind == "leg_lower_L":
|
|
draw_capsule(layer, (292, 595), (287, 740), 34, skin)
|
|
elif kind == "leg_upper_R":
|
|
draw_capsule(layer, (226, 410), (222, 610), 46, skin)
|
|
elif kind == "leg_lower_R":
|
|
draw_capsule(layer, (228, 595), (233, 740), 34, skin)
|
|
return layer.filter(ImageFilter.GaussianBlur(0.25))
|
|
|
|
|
|
def source_color_masks(sources: dict[str, Image.Image]) -> dict[str, np.ndarray]:
|
|
masks: dict[str, np.ndarray] = {}
|
|
for name, img in sources.items():
|
|
a = arr(img)
|
|
r = a[:, :, 0].astype(np.int16)
|
|
g = a[:, :, 1].astype(np.int16)
|
|
b = a[:, :, 2].astype(np.int16)
|
|
al = a[:, :, 3] > 8
|
|
masks[f"{name}:alpha"] = al
|
|
masks[f"{name}:skin"] = al & (r > 125) & (g > 75) & (b > 55) & (r > g - 5) & (r > b + 10)
|
|
masks[f"{name}:hair"] = al & (g > 65) & (b > 60) & (r < 135) & ((g - r) > 12) & ((b - r) > 3)
|
|
masks[f"{name}:hair_hi"] = al & (g > 120) & (b > 105) & (r < 125) & ((g - r) > 30)
|
|
masks[f"{name}:white"] = al & (r > 170) & (g > 170) & (b > 168) & (np.abs(r - g) < 45) & (np.abs(g - b) < 55)
|
|
masks[f"{name}:black"] = al & (r < 90) & (g < 95) & (b < 100)
|
|
masks[f"{name}:mint"] = al & (g > 115) & (b > 105) & (r < 165) & ((g - r) > 20)
|
|
masks[f"{name}:dark_teal"] = al & (g > 55) & (b > 55) & (r < 85) & ((g - r) > 8)
|
|
return masks
|
|
|
|
|
|
def facial_layers() -> dict[str, Image.Image]:
|
|
layers: dict[str, Image.Image] = {}
|
|
eye_specs = {
|
|
"L": {"cx": 294, "cy": 140, "tilt": -2},
|
|
"R": {"cx": 226, "cy": 140, "tilt": 2},
|
|
}
|
|
for side, spec in eye_specs.items():
|
|
cx, cy = spec["cx"], spec["cy"]
|
|
white = polygon_layer(
|
|
[(cx - 22, cy), (cx - 14, cy - 8), (cx + 13, cy - 8), (cx + 22, cy - 1), (cx + 13, cy + 8), (cx - 13, cy + 7)],
|
|
(255, 246, 236, 232),
|
|
)
|
|
iris = ellipse_layer((cx - 8, cy - 10, cx + 8, cy + 10), (139, 89, 47, 240))
|
|
iris.alpha_composite(ellipse_layer((cx - 6, cy - 7, cx + 6, cy + 8), (180, 121, 62, 195)))
|
|
pupil = ellipse_layer((cx - 4, cy - 6, cx + 4, cy + 6), (38, 26, 19, 240))
|
|
highlight = merge_source_layers(
|
|
ellipse_layer((cx - 2, cy - 7, cx + 3, cy - 2), (255, 255, 255, 230)),
|
|
ellipse_layer((cx + 4, cy + 1, cx + 6, cy + 3), (255, 255, 255, 170)),
|
|
)
|
|
upper = line_layer([(cx - 23, cy - 3), (cx - 11, cy - 10), (cx + 10, cy - 10), (cx + 23, cy - 3)], (50, 28, 24, 235), 2)
|
|
lower = line_layer([(cx - 19, cy + 6), (cx - 6, cy + 9), (cx + 11, cy + 8), (cx + 19, cy + 5)], (82, 44, 38, 165), 1)
|
|
lid = line_layer([(cx - 20, cy - 13), (cx - 6, cy - 16), (cx + 11, cy - 15), (cx + 20, cy - 12)], (226, 160, 139, 125), 1)
|
|
layers[f"eye_{side}_white"] = white
|
|
layers[f"eye_{side}_iris"] = iris
|
|
layers[f"eye_{side}_pupil"] = pupil
|
|
layers[f"eye_{side}_highlight"] = highlight
|
|
layers[f"eye_{side}_upper_lash"] = upper
|
|
layers[f"eye_{side}_lower_lash"] = lower
|
|
layers[f"eye_{side}_lid"] = lid
|
|
|
|
layers["brow_L"] = line_layer([(274, 116), (288, 111), (310, 114)], (66, 55, 48, 210), 3)
|
|
layers["brow_R"] = line_layer([(210, 114), (232, 111), (246, 116)], (66, 55, 48, 210), 3)
|
|
layers["mouth_inside"] = ellipse_layer((249, 174, 271, 188), (85, 22, 28, 220))
|
|
layers["teeth_upper"] = polygon_layer([(252, 175), (268, 175), (266, 180), (254, 180)], (250, 245, 232, 220))
|
|
layers["teeth_lower"] = polygon_layer([(254, 184), (266, 184), (265, 187), (255, 187)], (242, 232, 220, 165))
|
|
layers["tongue"] = ellipse_layer((252, 181, 268, 190), (214, 98, 105, 195))
|
|
layers["mouth_line_upper"] = line_layer([(243, 174), (253, 171), (260, 173), (267, 171), (277, 174)], (94, 42, 36, 225), 2)
|
|
layers["mouth_line_lower"] = line_layer([(251, 187), (260, 191), (269, 187)], (120, 65, 60, 145), 1)
|
|
layers["lip_highlight"] = line_layer([(252, 170), (260, 168), (268, 170)], (255, 216, 205, 120), 1)
|
|
layers["nose"] = merge_source_layers(
|
|
line_layer([(260, 144), (257, 158), (261, 164)], (154, 91, 76, 135), 1),
|
|
ellipse_layer((257, 161, 265, 167), (124, 70, 62, 70), 0.4),
|
|
)
|
|
layers["cheek_L"] = ellipse_layer((288, 155, 323, 177), (255, 112, 130, 60), 2.5)
|
|
layers["cheek_R"] = ellipse_layer((197, 155, 232, 177), (255, 112, 130, 60), 2.5)
|
|
layers["face_shadow"] = merge_source_layers(
|
|
ellipse_layer((201, 185, 318, 232), (140, 81, 65, 36), 4.0),
|
|
ellipse_layer((220, 70, 300, 102), (190, 125, 100, 35), 3.0),
|
|
)
|
|
return layers
|
|
|
|
|
|
def string_and_accessory_primitives() -> dict[str, Image.Image]:
|
|
layers: dict[str, Image.Image] = {}
|
|
layers["hoodie_string_L"] = merge_source_layers(
|
|
line_layer([(276, 265), (282, 305), (281, 356)], (54, 54, 54, 220), 2),
|
|
line_layer([(281, 354), (283, 365)], (65, 207, 184, 230), 2),
|
|
)
|
|
layers["hoodie_string_R"] = merge_source_layers(
|
|
line_layer([(244, 265), (238, 305), (239, 356)], (54, 54, 54, 220), 2),
|
|
line_layer([(239, 354), (237, 365)], (65, 207, 184, 230), 2),
|
|
)
|
|
layers["choker_band_draw"] = merge_source_layers(
|
|
line_layer([(218, 220), (245, 226), (275, 226), (302, 220)], (31, 28, 28, 235), 5),
|
|
line_layer([(220, 217), (246, 222), (274, 222), (300, 217)], (72, 64, 63, 85), 1),
|
|
)
|
|
layers["pendant_draw"] = merge_source_layers(
|
|
ellipse_layer((253, 231, 267, 248), (38, 203, 188, 225)),
|
|
ellipse_layer((257, 233, 262, 238), (180, 255, 247, 155)),
|
|
line_layer([(260, 223), (260, 230)], (35, 35, 35, 230), 1),
|
|
)
|
|
return layers
|
|
|
|
|
|
def swap_layers(sources: dict[str, Image.Image]) -> dict[str, Image.Image]:
|
|
layers: dict[str, Image.Image] = {}
|
|
for side, hand_name, angle, xy in (
|
|
("L", "hand_l", -32, (276, 292)),
|
|
("R", "hand_r", 32, (190, 292)),
|
|
):
|
|
hand = sources[hand_name].crop(sources[hand_name].getbbox())
|
|
hand = hand.resize((64, 94), Image.Resampling.LANCZOS).rotate(angle, resample=Image.Resampling.BICUBIC, expand=True)
|
|
layer = blank()
|
|
layer.alpha_composite(hand, xy)
|
|
layers[f"swap_hand_heart_{side}"] = layer
|
|
|
|
sleeve_l = merge_source_layers(sources["upperarm_l"], sources["forearm_l"], sources["hand_l"])
|
|
sleeve_r = merge_source_layers(sources["upperarm_r"], sources["forearm_r"], sources["hand_r"])
|
|
for side, src, angle, xy in (
|
|
("L", sleeve_l, -47, (220, 280)),
|
|
("R", sleeve_r, 47, (185, 276)),
|
|
):
|
|
crop = src.crop(src.getbbox())
|
|
crop = crop.resize((190, 120), Image.Resampling.LANCZOS).rotate(angle, resample=Image.Resampling.BICUBIC, expand=True)
|
|
layer = blank()
|
|
layer.alpha_composite(crop, xy)
|
|
layers[f"swap_arm_cross_{side}"] = layer
|
|
return layers
|
|
|
|
|
|
def build_source_layers() -> tuple[dict[str, Image.Image], dict[str, str]]:
|
|
sources = {
|
|
"master": load_rgba(PARTS / "noeul_part_master_apose.png"),
|
|
"head": load_rgba(PARTS / "noeul_part_head.png"),
|
|
"chest": load_rgba(PARTS / "noeul_part_chest.png"),
|
|
"neck": load_rgba(PARTS / "noeul_part_neck.png"),
|
|
"pelvis": load_rgba(PARTS / "noeul_part_pelvis.png"),
|
|
"upperarm_l": load_rgba(PARTS / "noeul_part_upperarm_l.png"),
|
|
"upperarm_r": load_rgba(PARTS / "noeul_part_upperarm_r.png"),
|
|
"forearm_l": load_rgba(PARTS / "noeul_part_forearm_l.png"),
|
|
"forearm_r": load_rgba(PARTS / "noeul_part_forearm_r.png"),
|
|
"hand_l": load_rgba(PARTS / "noeul_part_hand_l.png"),
|
|
"hand_r": load_rgba(PARTS / "noeul_part_hand_r.png"),
|
|
"thigh_l": load_rgba(PARTS / "noeul_part_thigh_l.png"),
|
|
"thigh_r": load_rgba(PARTS / "noeul_part_thigh_r.png"),
|
|
"shin_l": load_rgba(PARTS / "noeul_part_shin_l.png"),
|
|
"shin_r": load_rgba(PARTS / "noeul_part_shin_r.png"),
|
|
"foot_l": load_rgba(PARTS / "noeul_part_foot_l.png"),
|
|
"foot_r": load_rgba(PARTS / "noeul_part_foot_r.png"),
|
|
}
|
|
masks = source_color_masks(sources)
|
|
layers: dict[str, Image.Image] = {}
|
|
notes: dict[str, str] = {}
|
|
|
|
head = sources["head"]
|
|
chest = sources["chest"]
|
|
neck = sources["neck"]
|
|
pelvis = sources["pelvis"]
|
|
|
|
# Hair split. L/R are from the character's point of view; L is screen right.
|
|
hair = masks["head:hair"] | (alpha(head) & ~masks["head:skin"] & rect(120, 0, 400, 385))
|
|
layers["back_hair_base"] = masked(head, hair & rect(145, 45, 376, 370), 0.35, 0.9)
|
|
layers["back_hair_shadow"] = masked(head, (hair & (masks["head:dark_teal"] | rect(145, 105, 376, 370))) & rect(145, 105, 376, 370), 0.45, 0.35)
|
|
layers["back_hair_tip_L"] = masked(head, hair & rect(305, 170, 382, 370), 0.55)
|
|
layers["back_hair_tip_R"] = masked(head, hair & rect(138, 170, 215, 370), 0.55)
|
|
layers["back_hair_strand_L01"] = masked(head, hair & rect(325, 70, 382, 305), 0.5)
|
|
layers["back_hair_strand_R01"] = masked(head, hair & rect(138, 70, 195, 305), 0.5)
|
|
layers["front_hair_center"] = masked(head, hair & rect(205, 18, 315, 158), 0.35)
|
|
layers["front_hair_L"] = masked(head, hair & rect(270, 28, 368, 190), 0.4)
|
|
layers["front_hair_R"] = masked(head, hair & rect(152, 28, 250, 190), 0.4)
|
|
layers["side_hair_L"] = masked(head, hair & rect(300, 115, 382, 350), 0.45)
|
|
layers["side_hair_R"] = masked(head, hair & rect(138, 115, 220, 350), 0.45)
|
|
layers["hair_highlight_front"] = masked(head, (masks["head:hair_hi"] | (hair & rect(160, 20, 365, 245))) & rect(160, 20, 365, 245), 0.8, 0.45)
|
|
|
|
# Body and hidden under-paint.
|
|
layers["neck_back_fill"] = merge_source_layers(masked(neck, masks["neck:skin"] | alpha(neck), 0.5, 0.55), body_limb_layer("leg_upper_L").crop((0, 0, 1, 1)))
|
|
layers["neck_front"] = masked(neck, alpha(neck), 0.35)
|
|
torso_mask = masks["chest:skin"] & rect(190, 240, 330, 402)
|
|
layers["torso_skin"] = masked(chest, torso_mask, 0.6)
|
|
for layer_id in ("arm_upper_L", "arm_fore_L", "arm_upper_R", "arm_fore_R", "leg_upper_L", "leg_lower_L", "leg_upper_R", "leg_lower_R"):
|
|
layers[layer_id] = body_limb_layer(layer_id)
|
|
layers["hand_L_base"] = masked(sources["hand_l"], alpha(sources["hand_l"]), 0.35)
|
|
layers["hand_R_base"] = masked(sources["hand_r"], alpha(sources["hand_r"]), 0.35)
|
|
|
|
# Clothes.
|
|
white_chest = masks["chest:white"] | (alpha(chest) & ~masks["chest:skin"] & rect(150, 210, 370, 360))
|
|
jacket_chest = alpha(chest) & ~masks["chest:skin"] & ~(white_chest & rect(195, 230, 330, 350))
|
|
layers["hood_back"] = merge_source_layers(masked(chest, (white_chest | (alpha(chest) & ~masks["chest:skin"])) & rect(160, 222, 362, 292), 0.55, 0.45), polygon_layer([(178, 244), (232, 220), (288, 220), (342, 244), (315, 286), (205, 286)], (42, 45, 52, 72)))
|
|
layers["hood_front_L"] = masked(chest, white_chest & rect(260, 225, 365, 325), 0.45)
|
|
layers["hood_front_R"] = masked(chest, white_chest & rect(155, 225, 260, 325), 0.45)
|
|
layers["jacket_body"] = masked(chest, jacket_chest, 0.45)
|
|
layers["jacket_sleeve_L"] = merge_source_layers(
|
|
masked(sources["upperarm_l"], alpha(sources["upperarm_l"]), 0.35),
|
|
masked(sources["forearm_l"], alpha(sources["forearm_l"]), 0.35),
|
|
)
|
|
layers["jacket_sleeve_R"] = merge_source_layers(
|
|
masked(sources["upperarm_r"], alpha(sources["upperarm_r"]), 0.35),
|
|
masked(sources["forearm_r"], alpha(sources["forearm_r"]), 0.35),
|
|
)
|
|
layers["hoodie_front"] = masked(chest, white_chest & rect(198, 235, 324, 350), 0.4)
|
|
layers["pants_base"] = merge_source_layers(
|
|
masked(pelvis, alpha(pelvis), 0.35),
|
|
masked(sources["thigh_l"], alpha(sources["thigh_l"]), 0.35),
|
|
masked(sources["thigh_r"], alpha(sources["thigh_r"]), 0.35),
|
|
masked(sources["shin_l"], alpha(sources["shin_l"]), 0.35),
|
|
masked(sources["shin_r"], alpha(sources["shin_r"]), 0.35),
|
|
)
|
|
layers["shoe_L"] = masked(sources["foot_l"], alpha(sources["foot_l"]), 0.35)
|
|
layers["shoe_R"] = masked(sources["foot_r"], alpha(sources["foot_r"]), 0.35)
|
|
|
|
# Face and Accessories from source masks plus primitives.
|
|
face_mask = masks["head:skin"] & rect(185, 72, 335, 232)
|
|
layers["face_base"] = merge_source_layers(face_underpaint_layer(), masked(head, face_mask, 0.5))
|
|
layers["ear_L"] = masked(head, masks["head:skin"] & rect(315, 105, 370, 210), 0.5)
|
|
layers["ear_R"] = masked(head, masks["head:skin"] & rect(150, 105, 205, 210), 0.5)
|
|
layers.update(facial_layers())
|
|
|
|
headphone_l = (masks["head:white"] | masks["head:mint"] | (alpha(head) & rect(322, 58, 376, 220))) & rect(310, 45, 382, 235)
|
|
headphone_r = (masks["head:white"] | masks["head:mint"] | (alpha(head) & rect(145, 58, 198, 220))) & rect(138, 45, 210, 235)
|
|
band = (masks["head:white"] | masks["head:mint"] | alpha(head)) & rect(195, 0, 330, 88)
|
|
layers["headphone_band"] = masked(head, band, 0.45)
|
|
layers["headphone_L"] = masked(head, headphone_l, 0.45)
|
|
layers["headphone_R"] = masked(head, headphone_r, 0.45)
|
|
primitive_Accessories = string_and_accessory_primitives()
|
|
layers["hoodie_string_L"] = primitive_Accessories["hoodie_string_L"]
|
|
layers["hoodie_string_R"] = primitive_Accessories["hoodie_string_R"]
|
|
choker_src = masked(head, masks["head:black"] & rect(205, 205, 315, 240), 0.45)
|
|
layers["choker_band"] = merge_source_layers(choker_src, primitive_Accessories["choker_band_draw"])
|
|
pendant_src = masked(head, (masks["head:mint"] | masks["head:white"]) & rect(242, 225, 280, 260), 0.45)
|
|
layers["pendant"] = merge_source_layers(pendant_src, primitive_Accessories["pendant_draw"])
|
|
|
|
layers.update(swap_layers(sources))
|
|
|
|
for key in layers:
|
|
notes[key] = "generated from existing Noeul A-pose assets and manifest mapping"
|
|
return layers, notes
|
|
|
|
|
|
def checker(size: tuple[int, int]) -> Image.Image:
|
|
w, h = size
|
|
img = Image.new("RGBA", size, (255, 255, 255, 255))
|
|
draw = ImageDraw.Draw(img)
|
|
step = 40
|
|
for y in range(0, h, step):
|
|
for x in range(0, w, step):
|
|
if (x // step + y // step) % 2:
|
|
draw.rectangle((x, y, x + step - 1, y + step - 1), fill=(220, 220, 220, 255))
|
|
return img
|
|
|
|
|
|
def write_guides(manifest: dict) -> dict[str, Image.Image]:
|
|
guide_sheet = fit_to_output(load_rgba(REFERENCE / "noeul_sheet.png"), 1520, 1140, 80)
|
|
guide_apose = source_to_output(load_rgba(PARTS / "noeul_part_master_apose.png"))
|
|
return {
|
|
"guide_noeul_sheet": guide_sheet,
|
|
"guide_apose_current": guide_apose,
|
|
}
|
|
|
|
|
|
def bbox_of(img: Image.Image) -> list[int] | None:
|
|
box = img.getbbox()
|
|
return list(box) if box else None
|
|
|
|
|
|
def save_report(manifest: dict, layer_outputs: dict[str, Image.Image], notes: dict[str, str]) -> None:
|
|
rows = []
|
|
missing: list[str] = []
|
|
nonempty_required = 0
|
|
for layer in manifest["layers"]:
|
|
layer_id = layer["id"]
|
|
file_rel = layer["file"]
|
|
path = OUT_BASE / file_rel
|
|
img = layer_outputs.get(layer_id)
|
|
bbox = bbox_of(img) if img else None
|
|
if not path.exists():
|
|
missing.append(file_rel)
|
|
if layer.get("required") and bbox:
|
|
nonempty_required += 1
|
|
rows.append(
|
|
{
|
|
"id": layer_id,
|
|
"file": file_rel,
|
|
"group": layer["group"],
|
|
"required": bool(layer.get("required")),
|
|
"import": bool(layer.get("import")),
|
|
"exists": path.exists(),
|
|
"size": list(img.size) if img else None,
|
|
"bbox": bbox,
|
|
"note": notes.get(layer_id, ""),
|
|
}
|
|
)
|
|
|
|
report = {
|
|
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
|
"canvas": {"width": OUT_W, "height": OUT_H},
|
|
"scaleFromSource": {"source": [SRC_W, SRC_H], "scale": SCALE, "offset": [OFFSET_X, OFFSET_Y]},
|
|
"layerCount": len(rows),
|
|
"requiredLayerCount": sum(1 for layer in manifest["layers"] if layer.get("required")),
|
|
"nonemptyRequiredLayerCount": nonempty_required,
|
|
"missingFiles": missing,
|
|
"rows": rows,
|
|
"psdNote": "Layered PSD was not written in this environment. Use LayerPNGs in manifest order to assemble the Cubism import PSD.",
|
|
}
|
|
REPORT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
lines = [
|
|
"# Live2D Layer PNG Bundle",
|
|
"",
|
|
f"- Generated: {report['generatedAt']}",
|
|
f"- Canvas: {OUT_W}x{OUT_H}, transparent RGBA",
|
|
f"- Layers: {report['layerCount']}",
|
|
f"- Required non-empty: {nonempty_required}/{report['requiredLayerCount']}",
|
|
"- PSD note: layered PSD was not written here; assemble these PNGs in manifest order in Photoshop/Clip Studio/Cubism workflow.",
|
|
"",
|
|
"## Files",
|
|
"",
|
|
"| Group | ID | File | Required | Non-empty |",
|
|
"|---|---|---|---:|---:|",
|
|
]
|
|
for row in rows:
|
|
lines.append(
|
|
f"| {row['group']} | `{row['id']}` | `{row['file']}` | {str(row['required']).lower()} | {str(row['bbox'] is not None).lower()} |"
|
|
)
|
|
REPORT_MD.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main() -> None:
|
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8-sig"))
|
|
OUT_BASE.mkdir(parents=True, exist_ok=True)
|
|
layer_outputs: dict[str, Image.Image] = {}
|
|
notes: dict[str, str] = {}
|
|
guide_layers = write_guides(manifest)
|
|
source_layers, source_notes = build_source_layers()
|
|
notes.update({key: "guide layer, not for Cubism import" for key in guide_layers})
|
|
notes.update(source_notes)
|
|
|
|
for layer in manifest["layers"]:
|
|
layer_id = layer["id"]
|
|
rel = layer["file"]
|
|
out_path = OUT_BASE / rel
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if layer_id in guide_layers:
|
|
out = guide_layers[layer_id]
|
|
elif layer_id in source_layers:
|
|
out = source_to_output(source_layers[layer_id])
|
|
else:
|
|
raise KeyError(f"No generator for {layer_id}")
|
|
out.save(out_path)
|
|
layer_outputs[layer_id] = out
|
|
|
|
composite = Image.new("RGBA", (OUT_W, OUT_H), (0, 0, 0, 0))
|
|
for layer in manifest["layers"]:
|
|
if not layer.get("import"):
|
|
continue
|
|
if layer.get("group") == "SwapParts":
|
|
continue
|
|
composite.alpha_composite(layer_outputs[layer["id"]])
|
|
composite.save(PREVIEW)
|
|
checker_bg = checker((OUT_W, OUT_H))
|
|
checker_bg.alpha_composite(composite)
|
|
checker_bg.convert("RGB").save(PREVIEW_CHECKER)
|
|
|
|
swap_composite = composite.copy()
|
|
for layer in manifest["layers"]:
|
|
if layer.get("group") == "SwapParts":
|
|
swap_composite.alpha_composite(layer_outputs[layer["id"]])
|
|
swap_checker = checker((OUT_W, OUT_H))
|
|
swap_checker.alpha_composite(swap_composite)
|
|
swap_checker.convert("RGB").save(SWAP_PREVIEW_CHECKER)
|
|
save_report(manifest, layer_outputs, notes)
|
|
print(f"wrote {len(layer_outputs)} layer PNGs to {OUT_BASE}")
|
|
print(f"preview: {PREVIEW}")
|
|
print(f"report: {REPORT_JSON}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|