vignette/docs/avatar-art/seoyeon/live2d-psb/export_psb_layers.py
2026-06-27 11:20:24 +09:00

125 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Export PSB layers through ImageMagick and preserve canvas offsets."""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
HERE = Path(__file__).resolve().parent
OUT_RAW = HERE / "layers-raw"
OUT_CANVAS = HERE / "layers-canvas"
MANIFEST = HERE / "layers-manifest.json"
PSB = Path(r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\라투디 여캐.psb")
CANVAS_SIZE = (1080, 1920)
def run_magick(args: list[str]) -> str:
result = subprocess.run(
["magick", *args],
cwd=HERE,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
return result.stdout
def identify_layers() -> list[dict[str, object]]:
output = run_magick(["identify", "-format", "%p\t%w\t%h\t%g\t%X\t%Y\n", str(PSB)])
layers: list[dict[str, object]] = []
for line in output.splitlines():
if not line.strip():
continue
index_s, width_s, height_s, page, x_s, y_s = line.split("\t")
layers.append(
{
"index": int(index_s),
"width": int(width_s),
"height": int(height_s),
"page": page,
"x": int(x_s),
"y": int(y_s),
}
)
return layers
def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None:
arr = np.array(im.convert("RGBA").getchannel("A"))
ys, xs = np.where(arr > threshold)
if len(xs) == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)
def export_layer(layer: dict[str, object]) -> dict[str, object]:
index = int(layer["index"])
raw = OUT_RAW / f"layer-{index:03d}.png"
full = OUT_CANVAS / f"layer-{index:03d}.png"
run_magick([f"{PSB}[{index}]", "-background", "none", "-alpha", "on", "png32:" + str(raw)])
src = Image.open(raw).convert("RGBA")
full_canvas = Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0))
full_canvas.alpha_composite(src, (int(layer["x"]), int(layer["y"])))
full_canvas.save(full, optimize=True)
bbox = alpha_bbox(full_canvas)
return {
**layer,
"raw": raw.relative_to(HERE).as_posix(),
"canvas": full.relative_to(HERE).as_posix(),
"alphaBox": list(bbox) if bbox else None,
}
def make_contact(layers: list[dict[str, object]]) -> None:
visible = [layer for layer in layers if layer.get("alphaBox") is not None]
tile_w, tile_h = 180, 220
cols = 6
rows = (len(visible) + cols - 1) // cols
sheet = Image.new("RGBA", (cols * tile_w, rows * tile_h), (30, 39, 36, 255))
draw = ImageDraw.Draw(sheet)
for i, layer in enumerate(visible):
full = Image.open(HERE / str(layer["canvas"])).convert("RGBA")
box = tuple(layer["alphaBox"])
thumb = full.crop(box)
thumb.thumbnail((tile_w - 18, tile_h - 54), Image.Resampling.LANCZOS)
x = (i % cols) * tile_w + (tile_w - thumb.width) // 2
y = (i // cols) * tile_h + 12
sheet.alpha_composite(thumb, (x, y))
label = f"{int(layer['index']):03d} {layer['width']}x{layer['height']} {layer['page']}"
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 36), label, fill=(210, 222, 216, 255))
draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 20), str(layer["alphaBox"]), fill=(150, 166, 160, 255))
sheet.save(HERE / "layers-contact.png", optimize=True)
def main() -> int:
if not PSB.exists():
raise FileNotFoundError(PSB)
OUT_RAW.mkdir(parents=True, exist_ok=True)
OUT_CANVAS.mkdir(parents=True, exist_ok=True)
layers = identify_layers()
exported = [export_layer(layer) for layer in layers]
MANIFEST.write_text(json.dumps({"source": str(PSB), "canvas": CANVAS_SIZE, "layers": exported}, indent=2), encoding="utf-8")
make_contact(exported)
flattened = HERE / "flattened.png"
run_magick([f"{PSB}[0]", "-background", "none", "-alpha", "on", "png32:" + str(flattened)])
print(f"exported {len(exported)} layers")
print(f"manifest {MANIFEST}")
print(f"contact {HERE / 'layers-contact.png'}")
print(f"flattened {flattened}")
return 0
if __name__ == "__main__":
raise SystemExit(main())