Files
Dansori_Characters/Noeul_Live2D/tools/generate_live2d_layers.py
T
2026-07-04 10:34:46 +09:00

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()