#!/usr/bin/env python3 """Export top-level PSB groups as normalized app-ready raster layers.""" from __future__ import annotations import json from pathlib import Path from typing import Iterable import numpy as np from PIL import Image, ImageDraw from psd_tools import PSDImage HERE = Path(__file__).resolve().parent PSB = Path(r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\라투디 여캐.psb") OUT = HERE / "top-groups" PARTS = OUT / "parts" SOURCE_CANVAS = (1080, 1920) TARGET_CANVAS = (900, 1125) CROP_BOX = (0, 240, 1080, 1590) LAYER_ORDER = [ "hair-back", "body", "outfit", "face", "eye-left", "eye-right", "hair-front", "mouth", ] GROUP_MAP = { "뒷머리": "hair-back", "몸": "body", "의상": "outfit", "얼굴": "face", "좌 눈": "eye-left", "우 눈": "eye-right", "머리카락": "hair-front", "입": "mouth", } EXTRA_BACK_HAIR = {"레이어 110", "레이어 110 복사"} def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: alpha = np.array(im.convert("RGBA").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 resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image: 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) out[out[:, :, 3] < 1, :3] = 0 return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA") def normalize(full_canvas: Image.Image) -> Image.Image: return resize_rgba(full_canvas.crop(CROP_BOX), TARGET_CANVAS) def composite_layers(layers: Iterable[Image.Image]) -> Image.Image: canvas = Image.new("RGBA", SOURCE_CANVAS, (0, 0, 0, 0)) for layer in layers: canvas.alpha_composite(layer.convert("RGBA")) return canvas def make_contact(entries: list[dict[str, object]]) -> None: tile_w, tile_h = 180, 180 sheet = Image.new("RGBA", (tile_w * 4, tile_h * 2), (30, 39, 36, 255)) draw = ImageDraw.Draw(sheet) for i, entry in enumerate(entries): layer = Image.open(PARTS / str(entry["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 - 22, tile_h - 46), Image.Resampling.LANCZOS) x = (i % 4) * tile_w + (tile_w - thumb.width) // 2 y = (i // 4) * tile_h + 12 sheet.alpha_composite(thumb, (x, y)) draw.text(((i % 4) * tile_w + 8, (i // 4) * tile_h + tile_h - 28), str(entry["id"]), fill=(210, 222, 216, 255)) draw.text(((i % 4) * tile_w + 8, (i // 4) * tile_h + tile_h - 14), str(entry["source"]), fill=(150, 166, 160, 255)) sheet.save(OUT / "top-groups-contact.png", optimize=True) def main() -> int: psd = PSDImage.open(PSB) PARTS.mkdir(parents=True, exist_ok=True) top = {layer.name: layer for layer in psd} entries: list[dict[str, object]] = [] extra_back = [top[name].composite(viewport=(0, 0, psd.width, psd.height)) for name in EXTRA_BACK_HAIR if name in top] for source_name, part_id in GROUP_MAP.items(): if source_name not in top: raise KeyError(f"Missing top-level group: {source_name}") image = top[source_name].composite(viewport=(0, 0, psd.width, psd.height)) if part_id == "hair-back": image = composite_layers([*extra_back, image]) normalized = normalize(image) file_name = f"{part_id}.png" normalized.save(PARTS / file_name, optimize=True) entries.append( { "id": part_id, "source": source_name, "file": file_name, "alphaBox": list(alpha_bbox(normalized) or ()), } ) preview = Image.new("RGBA", TARGET_CANVAS, (30, 39, 36, 255)) for part_id in LAYER_ORDER: preview.alpha_composite(Image.open(PARTS / f"{part_id}.png").convert("RGBA")) preview.save(OUT / "preview-psb-groups.png", optimize=True) transparent = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0)) for part_id in LAYER_ORDER: transparent.alpha_composite(Image.open(PARTS / f"{part_id}.png").convert("RGBA")) transparent.save(OUT / "preview-psb-groups-transparent.png", optimize=True) (OUT / "top-groups-manifest.json").write_text( json.dumps( { "source": str(PSB), "sourceCanvas": SOURCE_CANVAS, "targetCanvas": TARGET_CANVAS, "cropBox": CROP_BOX, "layerOrder": LAYER_ORDER, "parts": entries, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) make_contact(entries) print(f"exported {len(entries)} top-level groups") print(f"preview {OUT / 'preview-psb-groups.png'}") return 0 if __name__ == "__main__": raise SystemExit(main())