snapquest / vision.py
ashish-doing's picture
feat: SnapQuest - photo-to-RPG game using MiniCPM-V 4.6
660c30f
Raw
History Blame Contribute Delete
15.3 kB
"""SnapQuest vision integration for MiniCPM-V 4.6 on Modal Labs.
Set SNAPQUEST_MODAL_ENDPOINT to the HTTPS endpoint for your Modal GPU app.
If the endpoint requires auth, set SNAPQUEST_MODAL_TOKEN as well.
"""
from __future__ import annotations
import base64
import json
import mimetypes
import os
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
MODAL_ENDPOINT_ENV = "SNAPQUEST_MODAL_ENDPOINT"
MODAL_TOKEN_ENV = "SNAPQUEST_MODAL_TOKEN"
REQUEST_TIMEOUT_SECONDS = 90
MAX_RETRIES = 3
VALID_CHARACTER_CLASSES = {
"Swordsman": "combat focus, brave and direct, noticing cover, threats, and weapons",
"Archer": "ranged and scouting focus, alert to distance, vantage points, and paths",
"Healer": "protective and healing focus, gentle, watchful, and concerned with safety",
"Rogue": "stealth and cunning focus, sly, quiet, and attentive to hiding places",
"Mage": "arcane and magical focus, mystical, symbolic, and curious about omens",
}
SYSTEM_PROMPT = """You are SnapQuest's fantasy scene oracle.
Analyze the user's real-world photo and transform it into a grounded fantasy quest moment.
Only mention objects that are genuinely visible in the image.
Return strict JSON with exactly these keys:
- scene_name: string
- scene_description: exactly 2 sentences, fantasy tone, grounded in real objects from the photo
- objects_found: list of exactly 3 real objects spotted in the image
- choices: list of exactly 3 action strings that reference real objects from the image
Do not include markdown, commentary, or extra keys."""
def analyze_scene(image_path: str, character_class: str) -> dict:
"""Analyze a photo and return a SnapQuest scene JSON dictionary.
Args:
image_path: Local path to the image to analyze.
character_class: One of Swordsman, Archer, Healer, Rogue, or Mage.
Returns:
dict with keys: scene_name, scene_description, objects_found, choices.
Falls back to a generic scene if Modal is unavailable.
Raises:
FileNotFoundError: If image_path does not exist.
ValueError: If character_class is invalid.
"""
image_file = Path(image_path).expanduser().resolve()
if not image_file.is_file():
raise FileNotFoundError(f"Image file not found: {image_file}")
class_name = _normalize_character_class(character_class)
endpoint = os.getenv(MODAL_ENDPOINT_ENV)
if not endpoint:
print(f"[vision] {MODAL_ENDPOINT_ENV} not set — using fallback scene.")
return _fallback_scene(class_name)
messages = _build_messages(image_file, class_name)
payload = {
"model": "MiniCPM-V-4.6",
"messages": messages,
"temperature": 0.4,
"max_tokens": 500,
}
try:
response_data = _post_to_modal(endpoint, payload)
content = _extract_message_content(response_data)
scene = _parse_scene_json(content)
return _validate_scene(scene)
except Exception as exc:
print(f"[vision] Modal call failed, using fallback: {exc}")
return _fallback_scene(class_name)
def _normalize_character_class(character_class: str) -> str:
if not isinstance(character_class, str) or not character_class.strip():
raise ValueError("character_class must be a non-empty string.")
normalized_lookup = {key.lower(): key for key in VALID_CHARACTER_CLASSES}
class_name = normalized_lookup.get(character_class.strip().lower())
if not class_name:
allowed = ", ".join(VALID_CHARACTER_CLASSES)
raise ValueError(
f"Unknown character_class '{character_class}'. Use one of: {allowed}."
)
return class_name
def _build_messages(image_file: Path, character_class: str) -> list[dict[str, Any]]:
image_media_type = mimetypes.guess_type(image_file.name)[0] or "image/jpeg"
image_base64 = base64.b64encode(image_file.read_bytes()).decode("utf-8")
class_tone = VALID_CHARACTER_CLASSES[character_class]
user_text = (
f"Character class: {character_class}. "
f"Shape the fantasy tone with this class perspective: {class_tone}. "
"Spot real objects in the photo and turn them into quest-relevant details."
)
return [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": image_media_type,
"data": image_base64,
},
},
{"type": "text", "text": user_text},
],
},
]
def _post_to_modal(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
headers = {"Content-Type": "application/json"}
token = os.getenv(MODAL_TOKEN_ENV)
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(
endpoint,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
for attempt in range(MAX_RETRIES + 1):
try:
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
raw_body = response.read().decode("utf-8")
break
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"Modal request failed with HTTP {exc.code}: {error_body}"
) from exc
except URLError as exc:
if attempt == MAX_RETRIES:
raise RuntimeError(f"Could not reach Modal endpoint: {exc.reason}") from exc
time.sleep(2**attempt)
except TimeoutError as exc:
if attempt == MAX_RETRIES:
raise RuntimeError("Modal request timed out.") from exc
time.sleep(2**attempt)
try:
return json.loads(raw_body)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Modal endpoint returned non-JSON response: {raw_body[:300]}"
) from exc
def _extract_message_content(response_data: dict[str, Any]) -> str:
"""Accept both Anthropic-compatible and OpenAI-compatible response shapes."""
# Anthropic format: {content: [{type: text, text: ...}]}
if isinstance(response_data.get("content"), list):
text_parts = [
block.get("text", "")
for block in response_data["content"]
if isinstance(block, dict) and block.get("type") == "text"
]
if text_parts:
return "\n".join(text_parts)
# Anthropic format flat string
if isinstance(response_data.get("content"), str):
return response_data["content"]
# OpenAI format: {choices: [{message: {content: ...}}]}
choices = response_data.get("choices")
if isinstance(choices, list) and choices:
message = choices[0].get("message", {})
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = [
block.get("text", "")
for block in content
if isinstance(block, dict) and block.get("type") == "text"
]
if text_parts:
return "\n".join(text_parts)
raise RuntimeError(
f"Could not find model text content in response: {response_data}"
)
def _parse_scene_json(content: str) -> dict[str, Any]:
cleaned = content.strip()
# Strip markdown code fences if model wrapped output
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").strip()
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:].strip()
import re
# Fix missing commas between JSON fields (common model error)
cleaned = re.sub(r'"\s*\n\s*"', '",\n"', cleaned)
cleaned = re.sub(r'}\s*\n\s*"', '},\n"', cleaned)
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError as exc:
raise ValueError(f"Model did not return valid JSON: {content}") from exc
if not isinstance(parsed, dict):
raise ValueError("Model JSON must be an object.")
return parsed
def _validate_scene(scene: dict[str, Any]) -> dict[str, Any]:
required = {"scene_name", "scene_description", "objects_found", "choices"}
missing = required - scene.keys()
if missing:
raise ValueError(f"Model response missing required fields: {missing}")
if not isinstance(scene["objects_found"], list) or len(scene["objects_found"]) < 1:
raise ValueError("objects_found must be a non-empty list.")
if not isinstance(scene["choices"], list) or len(scene["choices"]) < 3:
raise ValueError("choices must have at least 3 items.")
if not isinstance(scene["scene_name"], str) or not scene["scene_name"].strip():
raise ValueError("scene_name must be a non-empty string.")
if not isinstance(scene["scene_description"], str) or not scene["scene_description"].strip():
raise ValueError("scene_description must be a non-empty string.")
# Normalize to exactly 3 of each
scene["choices"] = scene["choices"][:3]
scene["objects_found"] = scene["objects_found"][:3]
return scene
def _fallback_scene(character_class: str) -> dict[str, Any]:
"""Returns a generic scene when Modal is unavailable or call fails."""
fallbacks = {
"Swordsman": {
"scene_name": "The Iron Threshold",
"scene_description": "A dimly lit chamber stretches before you, walls lined with the remnants of old battles. Your hand instinctively grips your sword as shadows shift in the corners.",
"objects_found": ["stone wall", "iron door", "torch bracket"],
"choices": [
"Examine the iron door for weaknesses",
"Take the torch bracket as a weapon",
"Press your back to the stone wall and listen",
],
},
"Archer": {
"scene_name": "The Vantage Hall",
"scene_description": "The space opens into a long corridor with high ceilings — a perfect killing ground if you control the far end. You scan for elevated positions and escape routes.",
"objects_found": ["high window", "wooden beam", "stone pillar"],
"choices": [
"Climb toward the high window for a vantage point",
"Use the wooden beam as a bridge to higher ground",
"Hide behind the stone pillar and observe",
],
},
"Healer": {
"scene_name": "The Wounded Sanctum",
"scene_description": "A quiet room carries the weight of suffering past — you sense it in the air. Your herbs grow warm in your satchel, responding to some hidden need nearby.",
"objects_found": ["cracked basin", "dusty shelf", "pale light"],
"choices": [
"Check the cracked basin for clean water",
"Search the dusty shelf for medical supplies",
"Follow the pale light toward whoever needs you",
],
},
"Rogue": {
"scene_name": "The Shadow Crossing",
"scene_description": "Every corner is an opportunity, every shadow a potential ally. You map the room in seconds — three entry points, two blind spots, one obvious trap.",
"objects_found": ["loose floorboard", "hanging tapestry", "locked chest"],
"choices": [
"Test the loose floorboard — it might hide something",
"Slip behind the hanging tapestry and wait",
"Pick the locked chest before anyone returns",
],
},
"Mage": {
"scene_name": "The Resonant Chamber",
"scene_description": "The room hums with dormant energy that only you can perceive. Ordinary objects reveal their arcane shadows — each one a potential conduit or a warning.",
"objects_found": ["glowing sigil", "old mirror", "scattered pages"],
"choices": [
"Trace the glowing sigil to decode its meaning",
"Look into the old mirror — it may show other times",
"Gather the scattered pages before the wind takes them",
],
},
}
return fallbacks.get(character_class, fallbacks["Swordsman"])
# ─── LOCAL TEST ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
import tempfile
try:
from PIL import Image, ImageDraw
except ImportError:
print("Pillow not installed. Run: pip install Pillow")
sys.exit(1)
print("=== vision.py local test ===\n")
# 1. Validation test (no Modal needed)
print("[1] Testing _validate_scene...")
fake_scene = {
"scene_name": "Test Sanctum",
"scene_description": "A chamber of testing. The walls hold secrets.",
"objects_found": ["obj1", "obj2", "obj3"],
"choices": ["Do A", "Do B", "Do C"],
}
result = _validate_scene(fake_scene)
print(f" OK — scene_name: {result['scene_name']}")
# 2. Class normalization test
print("[2] Testing _normalize_character_class...")
for cls in ["swordsman", "MAGE", "Healer", "rogue", "archer"]:
normalized = _normalize_character_class(cls)
print(f" '{cls}' → '{normalized}'")
# 3. Fallback test
print("[3] Testing _fallback_scene...")
for cls in VALID_CHARACTER_CLASSES:
fb = _fallback_scene(cls)
print(f" {cls}: '{fb['scene_name']}'")
# 4. Full analyze_scene test (requires SNAPQUEST_MODAL_ENDPOINT)
endpoint = os.getenv(MODAL_ENDPOINT_ENV)
if not endpoint:
print(f"\n[4] Skipping Modal test — {MODAL_ENDPOINT_ENV} not set.")
print(" Set it to test the full pipeline:")
print(f" export {MODAL_ENDPOINT_ENV}=https://your-modal-endpoint.modal.run/analyze")
print("\nAll local tests passed.")
sys.exit(0)
print(f"\n[4] Testing full pipeline against Modal endpoint...")
img = Image.new("RGB", (320, 240), color=(30, 20, 15))
draw = ImageDraw.Draw(img)
draw.rectangle([40, 80, 120, 180], fill=(60, 40, 20)) # fake bookshelf
draw.rectangle([150, 60, 200, 180], fill=(20, 20, 40)) # fake monitor
draw.ellipse([240, 100, 290, 150], fill=(80, 60, 10)) # fake lamp
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
img.save(f.name)
test_path = f.name
print(f" Test image: {test_path}")
try:
result = analyze_scene(test_path, "Mage")
print(f" scene_name: {result['scene_name']}")
print(f" description: {result['scene_description']}")
print(f" objects: {result['objects_found']}")
print(f" choices: {result['choices']}")
print("\nFull pipeline test passed.")
except Exception as e:
print(f" FAILED: {e}")
sys.exit(1)