Add finished creature artwork
This commit is contained in:
parent
bb75121dcd
commit
7f5a522ae5
54 changed files with 1013 additions and 35 deletions
230
tools/art/export_creature_art.py
Normal file
230
tools/art/export_creature_art.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
#!/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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue