Files
RingBRP/tools/make-art.py
T
slaguru666andClaude Opus 5 5351c25fda Ship the bestiary art: 47 plates and tokens, as webp
The illustrations were sitting in art/creatures/, where nothing looked for
them. build-packs resolves a creature's picture by stem under art/portraits/,
so none of the 47 reached the game.

tools/make-art.py (was make-tokens.py) now derives both from one plate:
art/portraits/<key>.webp and art/tokens/<key>.webp. Same drawing, so the
token cannot disagree with the portrait — the argument mj-queue already makes
for deriving prompts from statblocks.

Webp because every one of the 309 art files already here is one, at 160-220
KB. The raw Midjourney plates are 2.3 MB each; 47 of them would have put 93
MB into a 29 MB repository permanently, for pictures nothing renders at that
size. At quality 88 a plate is ~300 KB with the pencil hatching intact. The
plates stay on disk as working files and are gitignored.

The token lookup in build-packs was a hardcoded `.png`, so the first webp
token would have silently fallen back to the portrait and Foundry would have
gone back to cropping a 2:3 plate into a circle. Portraits and tokens now
share one resolver and one extension list.

146 of 147 npcs carry a portrait and 46 a derived token.

Also: the gryphon came back as a mech a second time. The first fix branched
the armour SIMILE on era while both arms still opened with the word
"armoured", and that word is what Midjourney draws — changing the reasoning
is not the same as changing the cause. No natural creature's prompt contains
"armour" now, and negativesFor() asks for plate, machinery and worn armour to
be left out, because a word choice is an argument and a --no is closer to a
guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:14:51 +01:00

124 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Derive the shipped creature art from the Midjourney illustrations.
`art/creatures/<key>-u1.png` is the raw plate as Midjourney files it: 2:3, full figure,
plain ground, and around 2.3 MB each. Two things are derived from it, and only the
derivatives are committed:
art/portraits/<key>.webp the plate, in the format the rest of the repo's art uses
art/tokens/<key>.webp 512x512 RGBA, circular, transparent surround
WHY WEBP, AND WHY DERIVED. Every one of the 309 art files already in this repository is
a .webp of 160-220 KB; the raw plates are 2.3 MB, and 47 of them would have added 93 MB
to a 29 MB repository, permanently, for pictures nothing renders at that size. Re-encoded
at quality 88 a plate is around 300 KB with the pencil hatching intact.
WHY PORTRAITS AND NOT CREATURES. build-packs.mjs resolves a creature's picture by stem
and extension under `art/portraits/`, preferring .webp, and says in as many words that
upgrading a creature should be a matter of dropping a file in. The plates were sitting in
`art/creatures/`, where nothing looked for them, so none of them reached the game.
A token is not a portrait. The plates are full-figure 2:3, and prototypeToken.texture was
pointing at the portrait file, so Foundry cropped a tall plate into a round token and
showed a torso. The token is DERIVED rather than generated separately for two reasons: it
costs nothing, where a second Midjourney pass would double the spend; and it cannot
disagree with the portrait, because it is the same drawing — the same argument mj-queue
makes for deriving prompts from statblocks.
python3 tools/make-art.py every illustration missing a derivative
python3 tools/make-art.py --force redo them all
python3 tools/make-art.py <key> ... just these
"""
import math
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "art" / "creatures"
DST = ROOT / "art" / "tokens"
PLATES = ROOT / "art" / "portraits"
SIZE, RING = 512, 6
# Quality 88 keeps the pencil hatching and lands a plate near the 160-220 KB the rest of
# the repository's art occupies. 80 visibly softens the finest cross-hatching.
PLATE_Q, TOKEN_Q = 88, 90
def subject_bbox(im, tol=236):
"""Bounding box of everything that is not the plain ground.
The plates are dark line work on near-white paper. Anything darker than `tol` in
greyscale is drawing. A median filter first, so paper speckle and MJ's signature
grain do not widen the box to the whole sheet — which is what happens with a naive
threshold and produces a token zoomed to nothing.
"""
g = im.convert("L").filter(ImageFilter.MedianFilter(5))
mask = g.point(lambda p: 255 if p < tol else 0)
return mask.getbbox()
def token_from(path, out):
im = Image.open(path).convert("RGBA")
box = subject_bbox(im) or (0, 0, im.width, im.height)
subject = im.crop(box)
# PAD to a square, never crop to one.
#
# These plates are 2:3 and the creature is usually taller than the sheet is wide, so
# a square crop clamped to the image clips whatever is at the top — which on the
# first gryphon token was its head. Build a square canvas instead, in the plate's own
# paper colour, and put the whole subject inside it. A token that is slightly small
# in its circle is a token; a token missing the animal's head is not.
#
# Size the canvas by the subject's DIAGONAL, not its longest side.
#
# A creature fits inside the circle exactly when sqrt(w^2 + h^2) <= diameter, so that
# is the number to use. Padding by the longest side instead (x1.5) is the safe-looking
# choice and it is badly wrong for these plates: the creatures are tall and narrow, so
# it wasted a third of the circle and left a gryphon the size of a thumbnail at battle
# -map scale. For a 300x900 subject the diagonal gives 996px against 1350 — the drawing
# lands about a third larger, with the same guarantee that nothing is clipped.
side = round(math.hypot(subject.width, subject.height) * 1.04)
paper = im.getpixel((2, 2))
canvas = Image.new("RGBA", (side, side), paper)
canvas.paste(subject, ((side - subject.width) // 2, (side - subject.height) // 2), subject)
crop = canvas.resize((SIZE, SIZE), Image.LANCZOS)
mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(mask).ellipse((0, 0, SIZE - 1, SIZE - 1), fill=255)
crop.putalpha(mask)
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
ImageDraw.Draw(ring).ellipse((RING // 2, RING // 2, SIZE - 1 - RING // 2, SIZE - 1 - RING // 2),
outline=(26, 22, 18, 235), width=RING)
out.parent.mkdir(parents=True, exist_ok=True)
Image.alpha_composite(crop, ring).save(out, "WEBP", quality=TOKEN_Q, method=6)
def plate_from(src, out):
"""The illustration itself, re-encoded. No crop, no resize — only the format."""
im = Image.open(src)
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGB")
out.parent.mkdir(parents=True, exist_ok=True)
im.save(out, "WEBP", quality=PLATE_Q, method=6)
def main(argv):
force = "--force" in argv
keys = [a for a in argv if not a.startswith("--")]
srcs = sorted(SRC.glob("*-u1.png"))
if keys:
srcs = [s for s in srcs if s.name[:-7] in keys]
made_t = made_p = skipped = 0
for s in srcs:
key = s.name[:-7] # strip "-u1.png"
token, plate = DST / f"{key}.webp", PLATES / f"{key}.webp"
did = False
if force or not token.exists():
token_from(s, token); made_t += 1; did = True
if force or not plate.exists():
plate_from(s, plate); made_p += 1; did = True
if not did:
skipped += 1
print(f"make-art: {made_p} portrait(s), {made_t} token(s), "
f"{skipped} already derived, of {len(srcs)} illustration(s)")
if __name__ == "__main__":
main(sys.argv[1:])