#!/usr/bin/env python3 """Generate small authored textures for Gig 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 re import urllib.error import urllib.request from pathlib import Path 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("GIG LINE", font=f) d.text(((w - tw) / 2, 30), "GIG 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(90, 165) 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) # Opaque bottom border removed for transparency - planes will now render as silhouettes # d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # commented out - was causing opaque black slabs 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 CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions" _MIME_BY_SUFFIX = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"} def _openrouter_headers() -> dict: return { "Authorization": f"Bearer {_api_key()}", "Content-Type": "application/json", "HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game", "X-Title": "Gig Line", } def _openrouter_post(req: urllib.request.Request) -> dict: try: with urllib.request.urlopen(req, timeout=300) as resp: return 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}") def _data_url(path) -> str: """Encode a local image file as a base64 `data:` URL for multimodal input.""" path = Path(path) if not path.is_file(): sys.exit(f"error: reference image not found: {path}") mime = _MIME_BY_SUFFIX.get(path.suffix.lower()) if not mime: sys.exit(f"error: unsupported reference image type: {path.suffix}") encoded = base64.b64encode(path.read_bytes()).decode("ascii") return f"data:{mime};base64,{encoded}" def _decode_chat_image(payload: dict) -> Image.Image: """Pull the returned image out of a chat-completions response. Models return either `message.images[].image_url.url` (a `data:` URL or an https URL) or a `data:` URL embedded in the message content. """ message = (payload.get("choices") or [{}])[0].get("message", {}) urls = [] for item in message.get("images") or []: if isinstance(item, dict): urls.append(item.get("image_url", {}).get("url", "")) if not urls: content = message.get("content") or "" match = re.search(r"data:image/[a-z]+;base64,[A-Za-z0-9+/=]+", str(content)) if match: urls.append(match.group(0)) if not urls: sys.exit(f"error: OpenRouter returned no image data: {payload}") url = urls[0] if url.startswith("data:"): raw = base64.b64decode(url.split(",", 1)[1]) else: try: with urllib.request.urlopen(urllib.request.Request(url), timeout=120) as resp: raw = resp.read() except urllib.error.URLError as exc: sys.exit(f"error: could not download returned image: {exc.reason}") 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 _generate_image_with_reference(prompt: str, model: str, reference_path) -> Image.Image: """Image-to-image request: the reference photo anchors the composition.""" body = { "model": model, "messages": [{ "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": _data_url(reference_path)}}, ], }], } req = urllib.request.Request( CHAT_COMPLETIONS_URL, data=json.dumps(body).encode("utf-8"), headers=_openrouter_headers(), method="POST", ) return _decode_chat_image(_openrouter_post(req)) def generate_image(prompt: str, model: str, size: int, transparent: bool = False, aspect_ratio: str = "1:1", reference_path=None) -> 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. When `reference_path` points at an image file, the request switches to the multimodal chat endpoint and sends the file as a base64 data URL next to the prompt (image-to-image guidance). Size/aspect parameters do not apply on that endpoint; the caller crops or rescales the returned frame as usual. Only licence-cleared reference photos may be passed (see `scene_concepts._reference_source`). """ size = max(1, int(size)) if model == DEFAULT_IMAGE_MODEL and reference_path is None: size = max(GEN_SIZE, size) if reference_path is not None: return _generate_image_with_reference(prompt, model, reference_path) 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=_openrouter_headers(), method="POST", ) payload = _openrouter_post(req) 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, reference_path=None) -> None: """Generate one texture via OpenRouter and save it under assets/textures/. With `reference_path`, the request is image-to-image (licence-cleared reference photo anchoring value and detail); the returned frame is then centre-cropped to the requested square so tile wraps stay predictable. """ img = generate_image(prompt, model, size, transparent, reference_path=reference_path) if reference_path is not None: side = min(img.size) left = (img.width - side) // 2 top = (img.height - side) // 2 img = img.crop((left, top, left + side, top + side)) 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") if bool(args.reference) != bool(args.reference_licence): sys.exit("error: --reference and --reference-licence must be given " "together (the licence note records the sidecar clearance)") generate_ai_textures(args.prompt, args.name, args.model, args.size, reference_path=args.reference or None) 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 Gig 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( "--reference", default="", help="licence-cleared photo (listed in a refs-board SIDECAR.md) used " "as an image-to-image anchor; requires --reference-licence") parser.add_argument( "--reference-licence", default="", help="licence label from the photo's sidecar row, recorded in the " "generation provenance") 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")