Files
security-game/tools/generate_textures.py
T

512 lines
19 KiB
Python

#!/usr/bin/env python3
"""Generate small authored textures for Step Line (assets/ import convention).
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
possible. Run from the repo root: python3 tools/generate_textures.py
Optional AI mode generates textures through the OpenRouter image API
(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md).
Requires an API key in the OPENROUTER_API_KEY environment variable:
python3 tools/generate_textures.py --ai
python3 tools/generate_textures.py --ai --prompt "..." --name foo.png
"""
import argparse
import base64
import io
import json
import math
import os
import random
import stat
import sys
import urllib.error
import urllib.request
from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ASSETS = os.path.join(ROOT, "assets", "textures")
# OpenRouter image generation endpoint and default model (from the API docs).
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5"
MAX_DIM = 1024 # hard cap on the longest side of any generated texture
# px requested from the API; some models (e.g. seedream) need >= 1920
GEN_SIZE = 2048
# Default batch for `--ai`: one entry per game texture (see test_world.gd).
# Each entry is (output name, prompt, transparent_background).
AI_TEXTURES = [
# --- world surfaces (seamless, flat-lit) ---
("ai/brick_facade.png",
"seamless tileable red brick wall texture, weathered urban facade, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/concrete.png",
"seamless tileable grey concrete wall texture, subtle cracks and stains, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/asphalt.png",
"seamless tileable dark asphalt road texture, fine grain, "
"flat even lighting, frontal view, photorealistic game texture", False),
# --- storefront dressing ---
("ai/awning.png",
"red and white striped storefront awning with scalloped bottom edge, "
"frontal view, flat even lighting, game texture", False),
("ai/skyline.png",
"night city skyline silhouette, dark building shapes with a few lit "
"windows, flat graphic style, isolated on transparent background", True),
# --- screen-space overlay ---
("ai/vignette.png",
"radial vignette overlay, black edges fading to fully transparent center, "
"grayscale, soft gradient", True),
# --- HUD icons (white glyphs on transparent) ---
("ai/icons/flashlight_on.png",
"simple flat flashlight icon, white glyph on transparent background, "
"minimal UI icon", True),
("ai/icons/door_locked.png",
"simple flat locked door icon with keyhole, white outline glyph on "
"transparent background, minimal UI icon", True),
("ai/icons/wand.png",
"simple flat handheld security wand icon, white glyph on transparent "
"background, minimal UI icon", True),
("ai/icons/rec_dot.png",
"record button icon, red filled dot inside a white circle outline, on "
"transparent background, minimal UI icon", True),
# --- access artifact cards ---
("ai/cards/id_card.png",
"driver license ID card, blue plastic card with photo placeholder and "
"text lines, flat frontal view, game UI asset", False),
("ai/cards/wristband.png",
"red event access wristband with strap, flat frontal view, game UI asset",
False),
("ai/cards/ticket.png",
"green admission ticket stub with perforated edge, flat frontal view, "
"game UI asset", False),
("ai/cards/credential.png",
"dark grey staff credential pass with lanyard clip, flat frontal view, "
"game UI asset", False),
("ai/cards/guest_list.png",
"beige guest list document with ruled lines and checkboxes, flat frontal "
"view, game UI asset", False),
]
FONT_CANDIDATES = [
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf",
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
]
def font(size: int):
for path in FONT_CANDIDATES:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def save(img: Image.Image, name: str) -> None:
os.makedirs(ASSETS, exist_ok=True)
path = os.path.join(ASSETS, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path, optimize=True)
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
def grain(size, base, spread, seed, blur=1.0):
rnd = random.Random(seed)
img = Image.new("L", (size, size), base)
px = img.load()
for y in range(size):
for x in range(size):
v = base + rnd.randint(-spread, spread)
px[x, y] = max(0, min(255, v))
return img.filter(ImageFilter.GaussianBlur(blur))
def brick_facade():
size = 512
mortar = (118, 110, 104)
base = (150, 92, 78)
img = Image.new("RGB", (size, size), mortar)
d = ImageDraw.Draw(img)
rnd = random.Random(11)
brick_h = size // 22
mortar_t = 5
offset = 0
for row in range(22):
y = row * brick_h
cols = 2 if row % 2 == 0 else 3
brick_w = (size - mortar_t) // (cols if cols > 1 else 1)
for c in range(cols + 1):
x0 = c * (brick_w + mortar_t) - (brick_w // 2 if row % 2 else 0)
x1 = x0 + brick_w
# wrap horizontally so the tile repeats seamlessly
d.rectangle([x0, y, x1, y + brick_h - mortar_t], fill=base)
# per-brick shading pass
for _ in range(size * size // 900):
bx = rnd.randint(0, size - 1)
by = rnd.randint(0, size - 1)
d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)),
max(0, base[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18))))
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
for y in [-mortar_t, size - mortar_t]:
d.rectangle([0, y, size, y + mortar_t], fill=mortar)
save(img, "brick_facade.png")
def seamless_concrete():
size = 512
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
d = ImageDraw.Draw(img)
rnd = random.Random(19)
for _ in range(90):
x, y = rnd.randint(0, size), rnd.randint(0, size)
r = rnd.choice([2, 3, 4])
d.ellipse([x - r, y - r, x + r, y + r],
fill=(0, 0, 0, rnd.randint(8, 22)))
for _ in range(8): # subtle crack segments
x, y = rnd.randint(0, size), rnd.randint(0, size)
pts = [(x, y)]
for _ in range(6):
x += rnd.randint(-24, 24)
y += rnd.randint(-8, 8)
pts.append((x % size, y % size))
d.line(pts, fill=(70, 70, 70, 170), width=1)
save(img, "concrete.png")
def asphalt():
size = 512
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
d = ImageDraw.Draw(img)
rnd = random.Random(31)
for _ in range(600):
x, y = rnd.randint(0, size), rnd.randint(0, size)
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
* 3 + (rnd.randint(40, 110),))
save(img, "asphalt.png")
def awning():
w, h = 1024, 256
img = Image.new("RGB", (w, h), (140, 40, 45))
d = ImageDraw.Draw(img)
stripe = 64
for i in range(w // stripe):
if i % 2 == 1:
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
fill=(230, 225, 220))
# scalloped bottom edge
for x in range(0, w, 32):
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
f = font(96)
tw = d.textlength("STEP LINE", font=f)
d.text(((w - tw) / 2, 30), "STEP LINE", font=f, fill=(255, 250, 235))
# subtle top shading
shade = Image.new("RGBA", (w, h), (0, 0, 0, 0))
sd = ImageDraw.Draw(shade)
sd.rectangle([0, 0, w, 10], fill=(0, 0, 0, 90))
img = Image.alpha_composite(img.convert("RGBA"), shade)
save(img, "awning.png")
def skyline():
w, h = 1024, 256
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
rnd = random.Random(41)
build_color = (16, 18, 30, 255)
x = -40
while x < w + 40:
bw = rnd.randint(64, 170)
bh = rnd.randint(150, 400)
d.rectangle([x, h - bh, x + bw, h], fill=build_color)
# few lit windows
for _ in range(bw * bh // 900):
wx = x + rnd.randint(4, max(4, bw - 8))
wy = h - rnd.randint(4, bh - 4)
if rnd.random() < 0.5:
d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
save(img, "skyline.png")
def vignette():
size = 512
img = Image.new("L", (size, size), 0)
px = img.load()
c = size / 2.0
max_d = math.hypot(c, c)
for y in range(size):
for x in range(size):
dist = math.hypot(x - c, y - c) / max_d
t = max(0.0, (dist - 0.55) / 0.45)
px[x, y] = int(255 * (t * t))
save(img, "vignette.png")
def icon(name, draw_fn, size=64):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_fn(d, size)
save(img, name)
def icons():
def flashlight_on(d, s):
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
radius=4, fill=(255, 255, 255, 255))
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s *
0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255))
def door_locked(d, s):
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
outline=(255, 255, 255, 255), width=5)
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66],
radius=3, fill=(255, 255, 255, 255))
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180,
360, fill=(255, 255, 255, 255), width=5)
def wand(d, s):
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
radius=3, fill=(255, 255, 255, 255))
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255))
def rec(d, s):
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86],
outline=(255, 255, 255, 255), width=5)
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
icon("icons/flashlight_on.png", flashlight_on)
icon("icons/door_locked.png", door_locked)
icon("icons/wand.png", wand)
icon("icons/rec_dot.png", rec)
def app_icon():
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
(PNG) and the Windows installer (ICO)."""
def badge(d, s):
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
outline=(120, 130, 170, 255), width=max(2, s//64))
# gold star
cx, cy, r = s*0.5, s*0.44, s*0.30
pts = []
for i in range(10):
ang = -90 + i * 36
rad = r if i % 2 == 0 else r * 0.42
import math
pts.append((cx + rad * math.cos(math.radians(ang)),
cy + rad * math.sin(math.radians(ang))))
d.polygon(pts, fill=(255, 205, 80, 255))
icon("icon.png", badge, size=1024)
icon("icon.ico", badge, size=256)
# ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
# PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
# (flag it so a human notices).
import subprocess
import sys
ico_path = os.path.join(ASSETS, "icon.ico")
src_png = os.path.join(ASSETS, "icon.png")
ran = False
for cmd in (
["magick", src_png, "-background", "none", "-define",
"icon:auto-resize=48,32,16", ico_path],
["convert", src_png, "-background", "none",
"-define", "icon:auto-resize=48,32,16", ico_path],
):
try:
subprocess.run(cmd, check=True, capture_output=True)
ran = True
break
except (FileNotFoundError, subprocess.CalledProcessError):
continue
if ran:
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
else:
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
Image.open(src_png).convert("RGBA").save(
ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
def cards():
CW, CH = 256, 128
def base(card_name, bg, label, band=(0, 0, 0, 0)):
img = Image.new("RGBA", (CW, CH), bg + (255,))
d = ImageDraw.Draw(img)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
outline=(0, 0, 0, 90), width=2)
if band and band[3] == 255:
d.rectangle([0, CH-34, CW, CH], fill=band)
f = font(26)
d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
# body text lines
f2 = font(15)
y = 52
for _ in range(4):
w = 150 - (24 if _ == 3 else 0)
d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
y += 24
# photo square for id-like cards
if "photo" in card_name:
d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
save(img, f"cards/{card_name}.png")
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
base("ticket", (56, 120, 96), "ADMIT ONE", band=(20, 80, 55, 255))
base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255))
base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255))
def _api_key() -> str:
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai")
return key
def generate_image(prompt: str, model: str, size: int,
transparent: bool = False,
aspect_ratio: str = "1:1") -> Image.Image:
"""Call the OpenRouter image API and return the decoded PIL image.
Requests a square image at `size` px or a role-shaped image at the matching
resolution tier and decodes `data[].b64_json`. Seedream's square requests
retain the existing GEN_SIZE floor. When `transparent` is true, the API is
asked for a transparent PNG background.
"""
size = max(1, int(size))
if model == DEFAULT_IMAGE_MODEL:
size = max(GEN_SIZE, size)
body = {
"model": model,
"prompt": prompt,
"output_format": "png",
"n": 1,
}
if aspect_ratio == "1:1":
body["size"] = f"{size}x{size}"
else:
body["aspect_ratio"] = aspect_ratio
body["resolution"] = next(
(tier for limit, tier in ((512, "512"), (768, "768"), (1024, "1K"),
(1536, "1.5K"), (2048, "2K"), (4096, "4K"))
if size <= limit), "4K")
if transparent:
body["background"] = "transparent"
req = urllib.request.Request(
OPENROUTER_IMAGES_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}")
except urllib.error.URLError as exc:
sys.exit(f"error: could not reach OpenRouter: {exc.reason}")
data = payload.get("data") or []
if not data or not data[0].get("b64_json"):
sys.exit(f"error: OpenRouter returned no image data: {payload}")
raw = base64.b64decode(data[0]["b64_json"])
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception as exc: # noqa: BLE001 - surface any decode failure clearly
sys.exit(f"error: could not decode returned image: {exc}")
return img
def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
"""Downscale to fit within max_dim on the longest side, preserving aspect."""
w, h = img.size
longest = max(w, h)
if longest <= max_dim:
return img
scale = max_dim / longest
return img.resize((max(1, round(w * scale)), max(1, round(h * scale))),
Image.Resampling.LANCZOS)
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
transparent: bool = False) -> None:
"""Generate one texture via OpenRouter and save it under assets/textures/."""
img = generate_image(prompt, model, size, transparent)
img = _fit(img)
if transparent:
img = img.convert("RGBA")
elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
else:
img = img.convert("RGB")
save(img, name)
def _run_ai(args: argparse.Namespace) -> None:
if args.prompt:
if not args.name:
sys.exit("error: --name is required when --prompt is given")
generate_ai_textures(args.prompt, args.name, args.model, args.size)
return
for name, prompt, transparent in AI_TEXTURES:
print(f"generating {name} ...")
generate_ai_textures(prompt, name, args.model, args.size, transparent)
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate Step Line textures (authored or via OpenRouter).")
parser.add_argument(
"--ai", action="store_true",
help="generate textures through the OpenRouter image API "
"(requires OPENROUTER_API_KEY)")
parser.add_argument(
"--prompt", default="",
help="single prompt for --ai (overrides the default batch)")
parser.add_argument(
"--name", default="",
help="output filename under assets/textures/ for a single --prompt")
parser.add_argument(
"--model", default=DEFAULT_IMAGE_MODEL,
help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})")
parser.add_argument(
"--size", type=int, default=GEN_SIZE,
help=f"square size requested from the API in px (min {GEN_SIZE}); "
f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})")
return parser
if __name__ == "__main__":
args = _build_arg_parser().parse_args()
if args.ai:
_run_ai(args)
print("done")
sys.exit(0)
brick_facade()
seamless_concrete()
asphalt()
awning()
skyline()
vignette()
icons()
app_icon()
cards()
print("done")