#!/usr/bin/env python3 """Ask an OpenRouter vision model to describe an image (free-form). Complements the structured judges (`scene_concepts.py realism --judge`, `screen_designs.py judge`): those return scores/verdicts, this one returns an open-ended textual observation for exactly the question asked. Used to review captures and references when the agent harness itself cannot display images. Usage: python3 tools/describe_image.py [--question "..."] [--model ...] python3 tools/describe_image.py [--question "..."] # compare Requires OPENROUTER_API_KEY (a gitignored `.env` in the repo root holds it; source with `set -a && . ./.env && set +a`, or the tool reads `.env` directly). """ from __future__ import annotations import argparse import base64 import json import os import sys import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "tools")) import openrouter_judge # noqa: E402 CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" DEFAULT_QUESTION = ( "Describe this image as an art director reviewing a game screenshot: the " "subject, composition, materials, lighting, silhouette, and any obvious " "visual defects or things that read as flat, broken or out of place. Be " "concrete and specific." ) def _load_env_file() -> None: env = ROOT / ".env" if not env.is_file(): return for line in env.read_text(encoding="utf-8").splitlines(): if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) os.environ.setdefault(key.strip(), value.strip().strip("\"'")) def _data_url(path: Path) -> str: return "data:image/png;base64," + base64.b64encode(Path(path).read_bytes()).decode("ascii") def _call(body: dict, timeout: int = 180) -> dict: key = os.environ.get("OPENROUTER_API_KEY", "").strip() if not key: raise ValueError("OPENROUTER_API_KEY is not set") req = urllib.request.Request( CHAT_URL, data=json.dumps(body).encode("utf-8"), headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json", "HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game", "X-Title": "Gig Line"}, method="POST") try: with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}") def build_body(model: str, images: list[Path], question: str) -> dict: content: list[dict] = [{"type": "text", "text": question}] for path in images: content.append({"type": "image_url", "image_url": {"url": _data_url(path)}}) return {"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0} def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("images", nargs="+", help="image file(s) to send") ap.add_argument("--question", default=DEFAULT_QUESTION, help="question to ask (default: a full art-director description)") ap.add_argument("--model", default="auto", help="explicit model, or 'auto' for the cheapest image-capable model") args = ap.parse_args(argv) _load_env_file() images = [Path(p) for p in args.images] for path in images: if not path.is_file(): print(f"error: not a file: {path}", file=sys.stderr) return 2 models = openrouter_judge.resolve_models(args.model) if len(images) > 1: args.question = args.question + ( f"\n\nI sent {len(images)} images in order. Compare them and describe the " "differences, especially anything that changed or regressed.") try: model, payload = openrouter_judge.call_with_fallback( models, lambda m: build_body(m, images, args.question), _call) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 try: text = payload["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): text = "unreadable model response" if isinstance(text, list): text = "".join(p.get("text", "") for p in text if isinstance(p, dict)) print(f"[model {model}]") print(text) return 0 if __name__ == "__main__": raise SystemExit(main())