Bestiary portraits in the high-res style: a painted pixel-art bust for every creature

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 00:05:41 +01:00
1 parent a24fc51605
commit 8d2362fc11
41 files changed
+308 -1

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# Generate bestiary portraits with fal-ai/nano-banana-pro/edit; the reference image is the
# front and front three-quarter view of the creature's 64 px idle sheet, scaled up 6x.
import base64, io, json, os, sys, urllib.request, concurrent.futures as cf
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from portraits import CREATURES, prompt_for, sheet
OUT = "/home/zink/Projects/dungeoncraft/art/generated/portraits"
KEY = open(os.path.expanduser("~/.config/dungeoncraft/fal-key")).read().strip()
EXTRA = os.environ.get("EXTRA", "")
def ref_uri(cid):
im = Image.open(sheet(cid)).convert("RGBA")
h = im.height
two = im.crop((0, 0, 2 * h, h)).resize((12 * h, 6 * h), Image.NEAREST)
bg = Image.new("RGBA", two.size, (40, 36, 44, 255))
bg.alpha_composite(two)
buf = io.BytesIO()
bg.convert("RGB").save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def gen(cid, suffix=""):
body = {"prompt": prompt_for(cid, EXTRA), "image_urls": [ref_uri(cid)], "aspect_ratio": "1:1",
"resolution": "1K", "num_images": 1, "output_format": "png"}
req = urllib.request.Request("https://fal.run/fal-ai/nano-banana-pro/edit", data=json.dumps(body).encode(),
headers={"Authorization": "Key " + KEY, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=600) as r:
res = json.load(r)
with urllib.request.urlopen(res["images"][0]["url"], timeout=300) as r:
data = r.read()
path = f"{OUT}/{cid}-raw{suffix}.png"
open(path, "wb").write(data)
return cid, "ok " + path
except Exception as e:
msg = str(e)
if hasattr(e, "read"):
try:
msg += " " + e.read().decode()[:300]
except Exception:
pass
return cid, "ERROR " + msg
if __name__ == "__main__":
ids = sys.argv[1:] or [c["id"] for c in CREATURES]
suffix = os.environ.get("SUFFIX", "")
with cf.ThreadPoolExecutor(4) as ex:
for cid, st in ex.map(lambda i: gen(i, suffix), ids):
print(cid, st, flush=True)
@@ -0,0 +1,43 @@
# Portrait prompts for the bestiary (high-res style). Descriptions come from the sprite pipeline.
import importlib.util, os
ROOT = "/home/zink/Projects/dungeoncraft"
_spec = importlib.util.spec_from_file_location("creatures", f"{ROOT}/art/generated/sprites/scripts/creatures.py")
_cr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_cr)
IMP = dict(id="imp", kind="Worker", name="Pit Imp",
desc="A small goblin-like worker demon: big head, oversized pointed ears and two small black horns, orange skin, glowing yellow eyes, a wide mischievous grin with tiny fangs, a ragged dark-red tunic with a rope belt, carrying a small iron pickaxe with a wooden handle over its right shoulder.")
CREATURES = [IMP] + [dict(id=c["id"], kind=c["kind"], name=c["name"], desc=c["desc"]) for c in _cr.CREATURES]
BY_ID = {c["id"]: c for c in CREATURES}
SIEGE = {"ram", "catapult"}
BEAST = {"spider", "bat", "owl", "wyrm", "beetle", "cavebear", "gargoyle"}
def sheet(cid):
if cid == "imp":
return f"{ROOT}/assets/sprites/imp-64.png"
return f"{ROOT}/art/generated/sprites/{cid}-64.png"
TEMPLATE = """The attached image shows a small pixel art game sprite of the "{name}" (left: front view, right: three-quarter view). {desc}
Paint a PORTRAIT of exactly this {thing} for the illustrated monster compendium of a dark-fantasy dungeon strategy game: {framing}, three-quarter view turned slightly to the side, looking at the viewer, filling most of the square picture. It must clearly be the same {thing} as in the sprite: the same colours, clothing, armour, markings, gear and weapon (show the weapon or tool where it fits in the frame), but drawn with much more detail and slightly more natural, characterful proportions.
Style: hand-painted pixel art, like a detailed 128x128 pixel portrait scaled up with hard square pixels, a limited colour palette, clean dark outlines, no anti-aliasing blur, no smooth gradients, no photo realism.
Lighting and background: a little dramatic - a warm torch light from one side (the torch itself outside the picture) and a deep shadow on the other side, a rim light on the silhouette. Dark atmospheric dungeon background: rough stone wall in darkness, a faint glow and drifting dust or mist, darkening to near black at the edges.
Exactly one {thing}, no text, no letters, no name, no labels, no frame, no border, no signature, no UI."""
def prompt_for(cid, extra=""):
c = BY_ID[cid]
if cid in SIEGE:
thing, framing = "siege engine", "a close-up of the front part of the machine with its crew-less wooden frame and iron parts"
elif cid in BEAST:
thing, framing = "creature", "a close-up of its head and the front of its body (bust portrait)"
else:
thing, framing = "character", "a head-and-shoulders bust portrait"
p = TEMPLATE.format(name=c["name"], desc=c["desc"], thing=thing, framing=framing)
return p + ("\n\n" + extra if extra else "")
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# Raw portraits (1024x1024) -> 128x128 pixel-art portraits (box downscale, 48 colours, no
# dithering, opaque) and the atlas assets/portraits/portraits.png (8 columns) + order.txt.
import os, sys
from PIL import Image, ImageEnhance
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from portraits import CREATURES
ROOT = "/home/zink/Projects/dungeoncraft"
D = f"{ROOT}/art/generated/portraits"
OUT = f"{ROOT}/assets/portraits"
SIZE, COLORS, COLS = 128, 48, 8
CROP = {} # id -> (l, t, r, b) fractions, default the whole picture
os.makedirs(OUT, exist_ok=True)
rows = (len(CREATURES) + COLS - 1) // COLS
atlas = Image.new("RGB", (COLS * SIZE, rows * SIZE), (0, 0, 0))
order = []
for i, c in enumerate(CREATURES):
im = Image.open(f"{D}/{c['id']}-raw.png").convert("RGB")
l, t, r, b = CROP.get(c["id"], (0, 0, 1, 1))
w, h = im.size
im = im.crop((round(l * w), round(t * h), round(r * w), round(b * h)))
im = im.resize((SIZE, SIZE), Image.BOX)
im = ImageEnhance.Sharpness(im).enhance(1.6)
im = im.quantize(colors=COLORS, method=Image.Quantize.MAXCOVERAGE, kmeans=4, dither=Image.Dither.NONE).convert("RGB")
im.save(f"{D}/{c['id']}-{SIZE}.png", optimize=True)
atlas.paste(im, ((i % COLS) * SIZE, (i // COLS) * SIZE))
order.append(c["kind"])
atlas.save(f"{OUT}/portraits.png", optimize=True)
open(f"{OUT}/order.txt", "w").write("\n".join(order) + "\n")
print(atlas.size, os.path.getsize(f"{OUT}/portraits.png"), "bytes")