straywild/tools/art/export_creature_art.py

230 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Export cataloged creature artwork with consistent transparent margins."""
from __future__ import annotations
import argparse
import hashlib
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from PIL import Image
TABLE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
TABLE_ATTRIBUTE = "{%s}" % TABLE_NAMESPACE
NAMESPACES = {"table": TABLE_NAMESPACE}
VALID_EXPORT_SIZES = {64, 128, 256, 512}
SAFE_AREA_RATIO = 0.875
KNOWN_SOURCE_ALIASES = {
# The delivered filename predates the catalog's authoritative species ID.
"bowfish": "bowfin",
}
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Export PNG creature art according to the catalog's recommended "
"canvas size, using alpha-bound trimming and nearest-neighbor scaling."
)
)
parser.add_argument("--tracker", required=True, type=Path)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument(
"--skip",
action="append",
default=[],
help="Catalog creature ID or source filename stem to skip; repeat as needed.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate and report the batch without writing output files.",
)
return parser.parse_args()
def normalize_name(value: str) -> str:
normalized = value.strip().lower().replace("&", " and ")
normalized = re.sub(r"[^a-z0-9]+", "_", normalized)
return normalized.strip("_")
def expanded_row_values(row: ET.Element) -> list[str]:
values: list[str] = []
for cell in row.findall("table:table-cell", NAMESPACES):
repeat = int(
cell.attrib.get(
TABLE_ATTRIBUTE + "number-columns-repeated",
"1",
)
)
values.extend(["".join(cell.itertext()).strip()] * repeat)
return values
def load_catalog(tracker_path: Path) -> list[dict[str, str]]:
with zipfile.ZipFile(tracker_path) as archive:
root = ET.fromstring(archive.read("content.xml"))
table = root.find(".//table:table", NAMESPACES)
if table is None:
raise ValueError("tracker does not contain a table")
rows = table.findall("table:table-row", NAMESPACES)
if not rows:
raise ValueError("tracker table is empty")
headers = expanded_row_values(rows[0])
required_headers = {
"catalog_number",
"display_name",
"id",
"recommended_export_canvas_px",
}
missing_headers = required_headers.difference(headers)
if missing_headers:
raise ValueError(
"tracker is missing required columns: "
+ ", ".join(sorted(missing_headers))
)
records: list[dict[str, str]] = []
for row in rows[1:]:
values = expanded_row_values(row)
if not any(values):
continue
record = dict(zip(headers, values))
if record.get("id", "").strip():
records.append(record)
return records
def catalog_lookup(records: list[dict[str, str]]) -> dict[str, dict[str, str]]:
lookup: dict[str, dict[str, str]] = {}
for record in records:
keys = {
normalize_name(record["id"]),
normalize_name(record["display_name"]),
}
for key in keys:
previous = lookup.get(key)
if previous is not None and previous["id"] != record["id"]:
raise ValueError(
f"ambiguous normalized catalog name {key!r}: "
f"{previous['id']} and {record['id']}"
)
lookup[key] = record
return lookup
def resolve_record(
source_path: Path,
lookup: dict[str, dict[str, str]],
) -> dict[str, str]:
source_key = normalize_name(source_path.stem)
catalog_key = KNOWN_SOURCE_ALIASES.get(source_key, source_key)
record = lookup.get(catalog_key)
if record is None:
raise ValueError(
f"cannot map source {source_path.name!r} to one catalog creature"
)
return record
def normalize_artwork(source_path: Path, canvas_size: int) -> tuple[Image.Image, tuple[int, int, int, int], tuple[int, int]]:
with Image.open(source_path) as source_image:
artwork = source_image.convert("RGBA")
alpha_bounds = artwork.getchannel("A").getbbox()
if alpha_bounds is None:
raise ValueError(f"source {source_path.name!r} is fully transparent")
visible = artwork.crop(alpha_bounds)
safe_long_side = round(canvas_size * SAFE_AREA_RATIO)
scale = safe_long_side / max(visible.width, visible.height)
scaled_size = (
max(1, round(visible.width * scale)),
max(1, round(visible.height * scale)),
)
visible = visible.resize(scaled_size, Image.Resampling.NEAREST)
canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
offset = (
(canvas_size - scaled_size[0]) // 2,
(canvas_size - scaled_size[1]) // 2,
)
canvas.alpha_composite(visible, offset)
return canvas, alpha_bounds, scaled_size
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def main() -> int:
arguments = parse_arguments()
records = load_catalog(arguments.tracker)
lookup = catalog_lookup(records)
skip_keys = {normalize_name(value) for value in arguments.skip}
source_paths = sorted(arguments.source.glob("*.png"))
if not source_paths:
raise ValueError(f"no PNG files found under {arguments.source}")
resolved: list[tuple[Path, dict[str, str]]] = []
output_names: set[str] = set()
for source_path in source_paths:
record = resolve_record(source_path, lookup)
source_key = normalize_name(source_path.stem)
creature_key = normalize_name(record["id"])
if source_key in skip_keys or creature_key in skip_keys:
print(f"SKIP {source_path.name} -> {record['id']}")
continue
output_name = f"{record['catalog_number']}.png"
if output_name in output_names:
raise ValueError(f"duplicate output filename {output_name}")
output_names.add(output_name)
resolved.append((source_path, record))
if not arguments.dry_run:
arguments.output.mkdir(parents=True, exist_ok=True)
for source_path, record in resolved:
canvas_size = int(record["recommended_export_canvas_px"])
if canvas_size not in VALID_EXPORT_SIZES:
raise ValueError(
f"{record['id']} has invalid export size {canvas_size}"
)
image, alpha_bounds, scaled_size = normalize_artwork(
source_path,
canvas_size,
)
output_path = arguments.output / f"{record['catalog_number']}.png"
if not arguments.dry_run:
image.save(output_path, format="PNG", optimize=False, compress_level=6)
output_hash = sha256(output_path)
else:
output_hash = "dry-run"
print(
"EXPORT "
f"{source_path.name} -> {output_path.name} "
f"id={record['id']} canvas={canvas_size}x{canvas_size} "
f"alpha_bounds={alpha_bounds} visible={scaled_size[0]}x{scaled_size[1]} "
f"source_sha256={sha256(source_path)} output_sha256={output_hash}"
)
print(
f"Complete: {len(resolved)} export(s), "
f"{len(source_paths) - len(resolved)} skipped."
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, zipfile.BadZipFile) as error:
print(f"ERROR: {error}", file=sys.stderr)
raise SystemExit(1)