Files
DramaticShapeVoxelMod/tools/contact_sheets.py
T
2026-07-28 13:01:38 -04:00

123 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Assemble the per-map battle-arena screenshots into contact sheets.
One shot per map is a lot of files to open one at a time, so this lays them
out eight to a page -- a 2x4 grid, each tile labelled with its map id -- which
is enough to judge a whole region at a glance and spot the one that is wrong.
python mods/DramaticShapeVoxelMod/tools/contact_sheets.py \
.scratchpad/areas .scratchpad/sheets [order.txt]
Reads every PNG in the source directory and writes sheet_01.png,
sheet_02.png ... into the destination.
The optional third argument is a file of map ids, one per line, which both
FILTERS and ORDERS the sheets. That is what makes them worth looking at: the
authored list in data/battle_arenas.lua is grouped by region, so feeding its
keys through puts the routes together and the caves together, instead of
alphabetical order interleaving a gym with a tower floor. Without it every
PNG in the directory is used, sorted by name.
"""
import os
import sys
from PIL import Image, ImageDraw, ImageFont
PER_SHEET = 8
COLS, ROWS = 2, 4
TILE_W = 960 # each shot is 3840 wide; a quarter of that
LABEL_H = 34
PAD = 8
BG = (18, 18, 20)
LABEL_BG = (32, 32, 36)
LABEL_FG = (232, 232, 236)
def font(size):
for name in ("consola.ttf", "DejaVuSansMono.ttf", "arial.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def tile(path, w, h, label, f):
"""One labelled cell: the shot scaled to fit, with its map id under it."""
cell = Image.new("RGB", (w, h + LABEL_H), LABEL_BG)
try:
shot = Image.open(path).convert("RGB")
except OSError as err:
d = ImageDraw.Draw(cell)
d.text((8, 8), "unreadable: %s" % err, fill=(220, 90, 90), font=f)
return cell
shot.thumbnail((w, h), Image.LANCZOS)
cell.paste(shot, ((w - shot.width) // 2, (h - shot.height) // 2))
d = ImageDraw.Draw(cell)
d.rectangle([0, h, w, h + LABEL_H], fill=LABEL_BG)
d.text((10, h + 7), label, fill=LABEL_FG, font=f)
return cell
def wanted(src, order_path):
"""The PNGs to lay out, in the order to lay them out in."""
have = {n.lower(): n for n in os.listdir(src)
if n.lower().endswith(".png")}
if not order_path:
return sorted(have.values()), []
names, missing = [], []
with open(order_path, encoding="utf-8") as fh:
for line in fh:
key = line.strip()
if not key or key.startswith("#"):
continue
hit = have.get(key.lower() + ".png")
if hit:
names.append(hit)
else:
missing.append(key)
return names, missing
def main(src, dst, order_path=None):
names, missing = wanted(src, order_path)
if not names:
sys.exit("no PNGs to lay out from " + src)
# said out loud rather than silently skipped: a sheet set that quietly
# dropped a map would read as "every area is covered" when it is not
for key in missing:
print("MISSING no shot for %s" % key)
os.makedirs(dst, exist_ok=True)
probe = Image.open(os.path.join(src, names[0]))
tile_h = round(TILE_W * probe.height / probe.width)
f = font(24)
sheet_w = COLS * TILE_W + (COLS + 1) * PAD
sheet_h = ROWS * (tile_h + LABEL_H) + (ROWS + 1) * PAD
made = []
for page, start in enumerate(range(0, len(names), PER_SHEET), 1):
batch = names[start:start + PER_SHEET]
sheet = Image.new("RGB", (sheet_w, sheet_h), BG)
for i, name in enumerate(batch):
cell = tile(os.path.join(src, name), TILE_W, tile_h,
os.path.splitext(name)[0].upper(), f)
col, row = i % COLS, i // COLS
x = PAD + col * (TILE_W + PAD)
y = PAD + row * (tile_h + LABEL_H + PAD)
sheet.paste(cell, (x, y))
out = os.path.join(dst, "sheet_%02d.png" % page)
sheet.save(out, optimize=True)
made.append((out, [os.path.splitext(n)[0] for n in batch]))
print("%s %s" % (out, ", ".join(m for _, b in [made[-1]] for m in b)))
print("\n%d shots -> %d sheets" % (len(names), len(made)))
if __name__ == "__main__":
if len(sys.argv) not in (3, 4):
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2],
sys.argv[3] if len(sys.argv) == 4 else None)