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>
124 lines
5.9 KiB
Python
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:])
|