Files
ICCery-CPU/.grok/skills/generate2dmap/scripts/compose_layered_preview.py

158 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Compose a flattened layered-map preview from a base image and prop placements."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from PIL import Image
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def resolve_path(value: str, roots: list[Path]) -> Path:
path = Path(value)
if path.is_absolute():
return path
for root in roots:
candidate = root / path
if candidate.exists():
return candidate
return roots[0] / path
def load_props(data: Any) -> list[dict[str, Any]]:
"""Load placement entries from a list or object with layer lists.
When the payload is an object, merge *all* of props / foreground / objects
(in that order) so layered QA previews do not drop platforms or occluders
just because another key appeared first.
"""
if isinstance(data, list):
return data
if isinstance(data, dict):
merged: list[dict[str, Any]] = []
for key in ("props", "foreground", "objects"):
value = data.get(key)
if not isinstance(value, list):
continue
if key == "foreground":
merged.extend({**item, "layer": "foreground"} for item in value)
else:
merged.extend(value)
if merged:
return merged
raise ValueError(
"Placement JSON must be a list or an object with a 'props', "
"'foreground', and/or 'objects' list."
)
def placement_xy(prop: dict[str, Any], width: int, height: int) -> tuple[int, int]:
anchor = str(prop.get("anchor", "center-bottom"))
x = float(prop.get("x", 0))
y = float(prop.get("y", 0))
if anchor == "top-left":
left = x
top = y
elif anchor == "center":
left = x - width / 2
top = y - height / 2
elif anchor == "bottom-left":
left = x
top = y - height
else:
left = x - width / 2
top = y - height
return round(left), round(top)
def paste_prop(canvas: Image.Image, prop: dict[str, Any], roots: list[Path]) -> dict[str, Any]:
image_key = prop.get("image") or prop.get("path")
if not image_key:
raise ValueError(f"Prop is missing image/path: {prop}")
image_path = resolve_path(str(image_key), roots)
if not image_path.exists():
raise FileNotFoundError(f"Prop image not found: {image_path}")
img = Image.open(image_path).convert("RGBA")
width = int(prop.get("w", prop.get("width", img.width)))
height = int(prop.get("h", prop.get("height", img.height)))
if width <= 0 or height <= 0:
raise ValueError(f"Invalid prop size for {image_path}: {width}x{height}")
if (width, height) != img.size:
img = img.resize((width, height), Image.Resampling.LANCZOS)
opacity = float(prop.get("opacity", 1.0))
if opacity < 1:
alpha = img.getchannel("A").point(lambda value: int(value * max(0.0, min(1.0, opacity))))
img.putalpha(alpha)
left, top = placement_xy(prop, width, height)
canvas.alpha_composite(img, (left, top))
return {
"id": prop.get("id", image_path.stem),
"image": str(image_path),
"left": left,
"top": top,
"w": width,
"h": height,
"sortY": prop.get("sortY", prop.get("y", top + height)),
"layer": prop.get("layer", "props"),
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True, type=Path)
parser.add_argument("--placements", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--report", type=Path)
parser.add_argument("--project-root", type=Path, default=Path.cwd())
return parser
def main() -> None:
args = build_parser().parse_args()
base = Image.open(args.base).convert("RGBA")
data = read_json(args.placements)
props = load_props(data)
roots = [args.placements.parent, args.base.parent, args.project_root]
props_layer = [prop for prop in props if str(prop.get("layer", "props")) != "foreground"]
foreground_layer = [prop for prop in props if str(prop.get("layer", "props")) == "foreground"]
props_layer.sort(key=lambda item: float(item.get("sortY", item.get("y", 0))))
foreground_layer.sort(key=lambda item: float(item.get("sortY", item.get("y", 0))))
pasted = []
for prop in props_layer + foreground_layer:
pasted.append(paste_prop(base, prop, roots))
args.output.parent.mkdir(parents=True, exist_ok=True)
base.save(args.output)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
json.dumps(
{
"base": str(args.base),
"placements": str(args.placements),
"output": str(args.output),
"pasted": pasted,
},
indent=2,
),
encoding="utf-8",
)
print(str(args.output.resolve()))
if __name__ == "__main__":
main()