|
|
import os |
|
|
import random |
|
|
import json |
|
|
import uuid |
|
|
import time |
|
|
from datetime import datetime, timedelta, timezone |
|
|
from typing import Dict, Any, List, Optional |
|
|
import spaces |
|
|
|
|
|
import requests |
|
|
from dotenv import load_dotenv |
|
|
import gradio as gr |
|
|
from gradio.components import LoginButton |
|
|
|
|
|
import data_manager |
|
|
|
|
|
from huggingface_hub import HfApi, hf_hub_download, whoami |
|
|
|
|
|
from transformers import Mistral3ForConditionalGeneration, AutoTokenizer, TextIteratorStreamer |
|
|
import threading |
|
|
import torch |
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
APP_SECRET = os.urandom(24) |
|
|
|
|
|
ZONES_FILE = 'zones.json' |
|
|
zones = { |
|
|
"easy": [], |
|
|
"medium": [], |
|
|
"hard": [] |
|
|
} |
|
|
|
|
|
user_sessions: Dict[str, Dict[str, Any]] = {} |
|
|
DEFAULT_USERNAME = "player" |
|
|
|
|
|
def save_zones_to_file(): |
|
|
with open(ZONES_FILE, 'w') as f: |
|
|
json.dump(zones, f, indent=4) |
|
|
|
|
|
def load_zones_from_file(): |
|
|
global zones |
|
|
if os.path.exists(ZONES_FILE): |
|
|
try: |
|
|
with open(ZONES_FILE, 'r') as f: |
|
|
loaded_zones = json.load(f) |
|
|
if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])): |
|
|
raise ValueError("Invalid format") |
|
|
migrated = False |
|
|
for difficulty in loaded_zones: |
|
|
for zone in loaded_zones[difficulty]: |
|
|
if 'id' not in zone: |
|
|
zone['id'] = uuid.uuid4().hex |
|
|
migrated = True |
|
|
zones = loaded_zones |
|
|
print(zones) |
|
|
if migrated: |
|
|
print("Info: Migrated old zone data by adding unique IDs.") |
|
|
save_zones_to_file() |
|
|
except (json.JSONDecodeError, IOError, ValueError): |
|
|
print(f"Warning: '{ZONES_FILE}' is corrupted or invalid. Recreating with empty zones.") |
|
|
save_zones_to_file() |
|
|
else: |
|
|
save_zones_to_file() |
|
|
|
|
|
LOCATIONS = [ |
|
|
{'lat': 48.85824, 'lng': 2.2945}, |
|
|
{'lat': 40.748440, 'lng': -73.985664}, |
|
|
{'lat': 35.689487, 'lng': 139.691711}, |
|
|
{'lat': -33.856784, 'lng': 151.215297} |
|
|
] |
|
|
|
|
|
def generate_id(): |
|
|
return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) |
|
|
|
|
|
HF_DATASET_REPO = 'Jofthomas/geoguessr_game_of_the_day' |
|
|
|
|
|
GOOGLE_MAPS_API_KEY = os.getenv('GOOGLE_MAPS_API_KEY') |
|
|
|
|
|
SERVER_HF_TOKEN = os.getenv('HF_TOKEN', '') |
|
|
|
|
|
model_id = "mistralai/Magistral-Small-2509" |
|
|
tokenizer = AutoTokenizer.from_pretrained(model_id, tokenizer_type="mistral", use_fast=False) |
|
|
model = Mistral3ForConditionalGeneration.from_pretrained( |
|
|
model_id, torch_dtype=torch.bfloat16, device_map="auto" |
|
|
).eval() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SYSTEM_PROMPT_TEXT = """First draft your thinking process (inner monologue) until you arrive at a response. Format your response using Markdown, and use LaTeX for any mathematical equations. Write both your thoughts and the response in the same language as the input. |
|
|
|
|
|
Your thinking process must follow the template below:[THINK]Your thoughts or/and draft, like working through an exercise on scratch paper. Be as casual and as long as you want until you are confident to generate the response to the user.[/THINK]Here, provide a self-contained response.""" |
|
|
|
|
|
USER_INSTRUCTION = """You are a world-class geolocation expert. Given a street-view style image, think step by step about visual clues and infer approximate coordinates. |
|
|
When you conclude, output your final answer inside [ANSWER]lat,lng[/ANSWER]. |
|
|
|
|
|
Please analyze this image and provide coordinates in the required format.""" |
|
|
|
|
|
@spaces.GPU(duration=120) |
|
|
def llm_decode_image_return_text(image_bytes: bytes) -> str: |
|
|
print(f"[llm] decode start. image_bytes={len(image_bytes)} bytes") |
|
|
import base64, mimetypes |
|
|
try: |
|
|
encoded_image = base64.b64encode(image_bytes).decode('utf-8') |
|
|
mime_type = 'image/jpeg' |
|
|
data_url = f"data:{mime_type};base64,{encoded_image}" |
|
|
messages = [ |
|
|
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_TEXT}]}, |
|
|
{"role": "user", "content": [ |
|
|
{"type": "text", "text": USER_INSTRUCTION}, |
|
|
{"type": "image_url", "image_url": {"url": data_url}}, |
|
|
]}, |
|
|
] |
|
|
print(f"[llm] messages prepared. system+user with image_url length={len(data_url)}") |
|
|
tokenized = tokenizer.apply_chat_template(messages, return_dict=True) |
|
|
print(f"[llm] tokenized keys={list(tokenized.keys())}") |
|
|
import torch |
|
|
input_ids = torch.tensor(tokenized.input_ids).unsqueeze(0) |
|
|
attention_mask = torch.tensor(tokenized.attention_mask).unsqueeze(0) |
|
|
print(f"[llm] input_ids shape={tuple(input_ids.shape)} attn_mask shape={tuple(attention_mask.shape)} device={model.device}") |
|
|
kwargs = { |
|
|
'input_ids': input_ids.to(model.device), |
|
|
'attention_mask': attention_mask.to(model.device), |
|
|
} |
|
|
if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0: |
|
|
pixel_values = torch.tensor(tokenized.pixel_values[0], dtype=model.dtype).unsqueeze(0).to(model.device) |
|
|
image_sizes = torch.tensor(pixel_values.shape[-2:]).unsqueeze(0).to(model.device) |
|
|
kwargs.update({'pixel_values': pixel_values, 'image_sizes': image_sizes}) |
|
|
print(f"[llm] pixel_values shape={tuple(pixel_values.shape)} image_sizes={tuple(image_sizes.shape)}") |
|
|
output = model.generate(**kwargs)[0] |
|
|
print(f"[llm] generate done. output length={len(output)}") |
|
|
decoded = tokenizer.decode(output[len(tokenized.input_ids): ( -1 if output[-1] == tokenizer.eos_token_id else len(output) )]) |
|
|
print(f"[llm] decode done. text length={len(decoded)}") |
|
|
return decoded |
|
|
except Exception as e: |
|
|
print(f"[llm] decode failed: {e}") |
|
|
return f"[Error] {e}" |
|
|
|
|
|
@spaces.GPU(duration=120) |
|
|
def llm_stream_image_text(image_bytes: bytes): |
|
|
print(f"[llm-stream] start. image_bytes={len(image_bytes)} bytes") |
|
|
import base64 |
|
|
try: |
|
|
encoded_image = base64.b64encode(image_bytes).decode('utf-8') |
|
|
data_url = f"data:image/jpeg;base64,{encoded_image}" |
|
|
messages = [ |
|
|
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_TEXT}]}, |
|
|
{"role": "user", "content": [ |
|
|
{"type": "text", "text": USER_INSTRUCTION}, |
|
|
{"type": "image_url", "image_url": {"url": data_url}}, |
|
|
]}, |
|
|
] |
|
|
tokenized = tokenizer.apply_chat_template(messages, return_dict=True) |
|
|
input_ids = torch.tensor(tokenized.input_ids).unsqueeze(0) |
|
|
attention_mask = torch.tensor(tokenized.attention_mask).unsqueeze(0) |
|
|
kwargs = { |
|
|
'input_ids': input_ids.to(model.device), |
|
|
'attention_mask': attention_mask.to(model.device), |
|
|
'max_new_tokens': 80_000, |
|
|
} |
|
|
if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0: |
|
|
pixel_values = torch.tensor(tokenized.pixel_values[0], dtype=model.dtype).unsqueeze(0).to(model.device) |
|
|
image_sizes = torch.tensor(pixel_values.shape[-2:]).unsqueeze(0).to(model.device) |
|
|
kwargs.update({'pixel_values': pixel_values, 'image_sizes': image_sizes}) |
|
|
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False) |
|
|
kwargs['streamer'] = streamer |
|
|
thread = threading.Thread(target=model.generate, kwargs=kwargs) |
|
|
thread.start() |
|
|
acc = "" |
|
|
for new_text in streamer: |
|
|
acc += new_text |
|
|
yield acc |
|
|
except Exception as e: |
|
|
yield f"[Error] {e}" |
|
|
|
|
|
|
|
|
def pick_random_location(difficulty: str) -> Dict[str, float]: |
|
|
candidates = zones.get(difficulty, []) |
|
|
if candidates: |
|
|
selected_zone = random.choice(candidates) |
|
|
if selected_zone.get('type') == 'rectangle': |
|
|
b = selected_zone['bounds'] |
|
|
north, south, east, west = b['north'], b['south'], b['east'], b['west'] |
|
|
if west > east: |
|
|
east += 360 |
|
|
lng = random.uniform(west, east) |
|
|
if lng > 180: |
|
|
lng -= 360 |
|
|
lat = random.uniform(south, north) |
|
|
ensured = _ensure_street_view_location(lat, lng) |
|
|
if ensured: |
|
|
return ensured |
|
|
fallback = random.choice(LOCATIONS) |
|
|
ensured_fallback = _ensure_street_view_location(fallback['lat'], fallback['lng']) |
|
|
return ensured_fallback or fallback |
|
|
|
|
|
|
|
|
def street_view_image_url(lat: float, lng: float) -> str: |
|
|
if not GOOGLE_MAPS_API_KEY: |
|
|
|
|
|
return "https://picsum.photos/1200/800" |
|
|
return ( |
|
|
f"https://maps.googleapis.com/maps/api/streetview?size=1200x800&location={lat},{lng}&fov=60&pitch=0&source=outdoor&key={GOOGLE_MAPS_API_KEY}" |
|
|
) |
|
|
|
|
|
|
|
|
def _has_street_view(lat: float, lng: float) -> bool: |
|
|
if not GOOGLE_MAPS_API_KEY: |
|
|
return True |
|
|
try: |
|
|
resp = requests.get( |
|
|
"https://maps.googleapis.com/maps/api/streetview/metadata", |
|
|
params={"location": f"{lat},{lng}", "source": "outdoor", "key": GOOGLE_MAPS_API_KEY}, |
|
|
timeout=5, |
|
|
) |
|
|
resp.raise_for_status() |
|
|
data = resp.json() |
|
|
|
|
|
if data.get("status") == "OK": |
|
|
|
|
|
location_type = data.get("location_type") |
|
|
|
|
|
if location_type and location_type == "INDOOR": |
|
|
return False |
|
|
return True |
|
|
return False |
|
|
except Exception: |
|
|
return False |
|
|
|
|
|
|
|
|
def _snap_to_nearest_road(lat: float, lng: float) -> Optional[Dict[str, float]]: |
|
|
if not GOOGLE_MAPS_API_KEY: |
|
|
return None |
|
|
try: |
|
|
resp = requests.get( |
|
|
"https://roads.googleapis.com/v1/nearestRoads", |
|
|
params={"points": f"{lat},{lng}", "key": GOOGLE_MAPS_API_KEY}, |
|
|
timeout=5, |
|
|
) |
|
|
resp.raise_for_status() |
|
|
data = resp.json() |
|
|
points = data.get("snappedPoints") or [] |
|
|
if not points: |
|
|
return None |
|
|
loc = points[0].get("location") or {} |
|
|
if "latitude" in loc and "longitude" in loc: |
|
|
return {"lat": float(loc["latitude"]), "lng": float(loc["longitude"])} |
|
|
except Exception: |
|
|
pass |
|
|
return None |
|
|
|
|
|
|
|
|
def _ensure_street_view_location(lat: float, lng: float) -> Optional[Dict[str, float]]: |
|
|
"""Return a coordinate with confirmed Street View coverage, snapped near a road when possible.""" |
|
|
if not GOOGLE_MAPS_API_KEY: |
|
|
return {"lat": lat, "lng": lng} |
|
|
|
|
|
checked: set[tuple] = set() |
|
|
snapped = _snap_to_nearest_road(lat, lng) |
|
|
candidates: List[Dict[str, float]] = [] |
|
|
if snapped: |
|
|
candidates.append(snapped) |
|
|
candidates.append({"lat": lat, "lng": lng}) |
|
|
|
|
|
|
|
|
if not snapped: |
|
|
increments = [0.0005, -0.0005, 0.001, -0.001] |
|
|
for d_lat in increments: |
|
|
for d_lng in increments: |
|
|
if d_lat == 0 and d_lng == 0: |
|
|
continue |
|
|
candidates.append({"lat": lat + d_lat, "lng": lng + d_lng}) |
|
|
|
|
|
for candidate in candidates: |
|
|
key = (round(candidate["lat"], 6), round(candidate["lng"], 6)) |
|
|
if key in checked: |
|
|
continue |
|
|
checked.add(key) |
|
|
if _has_street_view(candidate["lat"], candidate["lng"]): |
|
|
return candidate |
|
|
|
|
|
return None |
|
|
|
|
|
|
|
|
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: |
|
|
from math import radians, cos, sin, asin, sqrt |
|
|
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) |
|
|
dlon = lon2 - lon1 |
|
|
dlat = lat2 - lat1 |
|
|
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 |
|
|
c = 2 * asin(sqrt(a)) |
|
|
r = 6371 |
|
|
return c * r |
|
|
|
|
|
|
|
|
def score_from_distance_km(distance_km: float) -> float: |
|
|
max_score = 5000.0 |
|
|
return max(0.0, max_score - distance_km) |
|
|
|
|
|
|
|
|
def build_street_html(image_url: str) -> str: |
|
|
base = """ |
|
|
<div id="image-container" style="position:relative;max-width:960px;margin:0 auto;"> |
|
|
<div id="fullscreen-wrapper" style="position:relative; background-color: #000;"> |
|
|
<img id="street-image" src="__IMG_URL__" style="width:100%;height:auto;border-radius:8px;box-shadow:0 4px 6px rgba(0,0,0,0.1);display:block;" /> |
|
|
<button id="fullscreen-btn" title="Toggle Fullscreen" style="position:absolute;top:10px;right:10px;z-index:10;background:#FA500F;border:none;color:white;padding:8px;border-radius:4px;cursor:pointer;line-height:0;"> |
|
|
<!-- SVG icon will be injected by JS --> |
|
|
</button> |
|
|
</div> |
|
|
<div id="mini-map-wrap" style="transition:all 0.3s ease;position:absolute;right:10px;bottom:10px;border:2px solid #fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.2);background:#fff;"> |
|
|
<div id="mini-map" style="width:100%;height:100%;cursor:pointer"></div> |
|
|
<div id="map-controls" style="position:absolute;right:8px;top:8px;display:flex;gap:6px;z-index:5;"> |
|
|
<button id="map-size-minus" class="map-ctrl" style="width:34px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.2);background:#FA500F;color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;padding:0;line-height:0;">β</button> |
|
|
<button id="map-size-plus" class="map-ctrl" style="width:34px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.2);background:#FA500F;color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;padding:0;line-height:0;">+</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
return base.replace('__IMG_URL__', image_url) |
|
|
|
|
|
|
|
|
def gr_start_game(difficulty: str, username: str, request: gr.Request): |
|
|
rounds: List[Dict[str, Any]] = [] |
|
|
date_str = datetime.now(timezone.utc).date().isoformat() |
|
|
game_id = str(uuid.uuid4()) |
|
|
|
|
|
for _ in range(3): |
|
|
loc = pick_random_location(difficulty) |
|
|
round_id = generate_id() |
|
|
rounds.append({ |
|
|
'id': round_id, |
|
|
'lat': loc['lat'], |
|
|
'lng': loc['lng'], |
|
|
'image_url': street_view_image_url(loc['lat'], loc['lng']), |
|
|
'human_guess': None, |
|
|
'ai_guess': None, |
|
|
'human_score': 0.0, |
|
|
'ai_score': 0.0, |
|
|
}) |
|
|
|
|
|
user_sessions[username] = { |
|
|
'game_id': game_id, |
|
|
'difficulty': difficulty, |
|
|
'rounds': rounds, |
|
|
'total_score': 0.0, |
|
|
'completed': False, |
|
|
'date': date_str, |
|
|
} |
|
|
r0 = rounds[0] |
|
|
street_html = build_street_html(r0['image_url']) |
|
|
return rounds, 0, r0['id'], street_html, "", "" |
|
|
|
|
|
|
|
|
def get_round(username: str, round_id: str) -> Optional[Dict[str, Any]]: |
|
|
session_data = user_sessions.get(username) |
|
|
if not session_data: |
|
|
return None |
|
|
for r in session_data['rounds']: |
|
|
if r['id'] == round_id: |
|
|
return r |
|
|
return None |
|
|
|
|
|
|
|
|
def gr_submit_guess(round_id: str, lat: float, lng: float, username: str, request: gr.Request): |
|
|
rnd = get_round(username, round_id) |
|
|
if not rnd: |
|
|
return "", "Round not found", gr.update(), gr.update(), gr.update() |
|
|
distance_km = haversine_km(rnd['lat'], rnd['lng'], float(lat), float(lng)) |
|
|
score = score_from_distance_km(distance_km) |
|
|
rnd['human_guess'] = {'lat': float(lat), 'lng': float(lng)} |
|
|
rnd['human_score'] = score |
|
|
rnd['human_distance_km'] = float(distance_km) |
|
|
result_text = f"Your guess was {distance_km:.2f} km away. You scored {score:.0f} points." |
|
|
scoreboard_html = ( |
|
|
f"<div style='margin:8px 0;padding:26px;background:#040b1a;border-radius:18px;color:#f9fafb;border:1px solid rgba(148,163,184,0.4);box-shadow:0 26px 60px rgba(4,7,15,0.6);'>" |
|
|
f"<div style='font-weight:700;font-size:1.25rem;margin-bottom:14px;text-shadow:0 0 12px rgba(4,7,15,0.75);'>Human Guess Recorded</div>" |
|
|
f"<div style='margin-bottom:20px;color:#f8fafc;font-size:1.02rem;line-height:1.6;text-shadow:0 0 10px rgba(4,7,15,0.65);'>{result_text}</div>" |
|
|
f"<div style='display:flex;gap:24px;font-size:0.96rem;margin-bottom:18px;flex-wrap:wrap;color:#f8fafc;'>" |
|
|
f" <span><strong>Distance:</strong> {distance_km:.2f} km</span>" |
|
|
f" <span><strong>Score:</strong> {score:.0f} pts</span>" |
|
|
f"</div>" |
|
|
f"<div style='font-size:0.92rem;color:#e2e8f0;'>AI analysis will be added once the model finishes.</div>" |
|
|
f"</div>" |
|
|
) |
|
|
popup_html = """ |
|
|
<div id=\"popup-overlay\" style=\"position:fixed;inset:0;background:rgba(8,11,20,0.78);display:flex;align-items:center;justify-content:center;z-index:1000;\"> |
|
|
<div id=\"popup-card\" style=\"background:#04070f;padding:26px;border-radius:18px;width:92%;max-width:960px;color:#f9fafb;position:relative;box-shadow:0 28px 80px rgba(4,7,15,0.75);border:1px solid rgba(148,163,184,0.35);\" onclick=\"event.stopPropagation();\"> |
|
|
<button id=\"popup-close-next\" style=\"position:absolute;top:16px;right:18px;padding:10px 18px;border-radius:999px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 8px 18px rgba(37,99,235,0.45);\">Next Round</button> |
|
|
<h3 style=\"margin:0 0 20px;color:#f8fafc;text-shadow:0 0 14px rgba(37,99,235,0.35);\">Round Results</h3> |
|
|
__SCOREBOARD__ |
|
|
<div id=\"popup-map\" data-rnd-lat=\"__RND_LAT__\" data-rnd-lng=\"__RND_LNG__\" data-h-lat=\"__H_LAT__\" data-h-lng=\"__H_LNG__\" data-ai-lat=\"__AI_LAT__\" data-ai-lng=\"__AI_LNG__\" style=\"width:100%;height:440px;border-radius:16px;overflow:hidden;border:1px solid rgba(148,163,184,0.35);box-shadow:0 16px 40px rgba(4,7,15,0.72);\"></div> |
|
|
<div style=\"display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:0.98rem;color:#e2e8f0;flex-wrap:wrap;gap:14px;\"> |
|
|
<div style=\"display:flex;gap:16px;align-items:center;flex-wrap:wrap;\"> |
|
|
<span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.6);\"></span>G = Ground Truth</span> |
|
|
<span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#FA500F;box-shadow:0 0 8px rgba(249,115,22,0.55);\"></span>H = Human</span> |
|
|
<span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#2563EB;box-shadow:0 0 8px rgba(37,99,235,0.6);\"></span>A = AI</span> |
|
|
</div> |
|
|
<button id=\"popup-close-next-footer\" style=\"padding:12px 22px;border-radius:12px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 10px 22px rgba(37,99,235,0.48);\">Next Round</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
(function() { |
|
|
function boot() { |
|
|
if (window.__initPopupIfPresent) { |
|
|
window.__initPopupIfPresent(); |
|
|
} |
|
|
} |
|
|
if (document.readyState === 'complete' || document.readyState === 'interactive') { |
|
|
setTimeout(boot, 0); |
|
|
} else { |
|
|
document.addEventListener('DOMContentLoaded', boot, { once: true }); |
|
|
} |
|
|
})(); |
|
|
</script> |
|
|
""".replace('__SCOREBOARD__', scoreboard_html)\ |
|
|
.replace('__RND_LAT__', str(rnd['lat']))\ |
|
|
.replace('__RND_LNG__', str(rnd['lng']))\ |
|
|
.replace('__H_LAT__', str(float(lat)))\ |
|
|
.replace('__H_LNG__', str(float(lng)))\ |
|
|
.replace('__AI_LAT__', str(float(lat)))\ |
|
|
.replace('__AI_LNG__', str(float(lng)))\ |
|
|
.replace('__GMAPS_KEY__', GOOGLE_MAPS_API_KEY or '') |
|
|
return popup_html, result_text, rnd['lat'], rnd['lng'], score |
|
|
|
|
|
def extract_coords_from_text(text: str): |
|
|
import re |
|
|
m = re.search(r"\[ANSWER\]\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\[/ANSWER\]", text, re.IGNORECASE) |
|
|
if not m: |
|
|
return None |
|
|
try: |
|
|
lat = float(m.group(1)) |
|
|
lng = float(m.group(2)) |
|
|
return {'lat': lat, 'lng': lng} |
|
|
except Exception: |
|
|
return {'lat': 0, 'lng': 0} |
|
|
|
|
|
def geocode_text_to_coords(query: str) -> Optional[Dict[str, float]]: |
|
|
if not GOOGLE_MAPS_API_KEY: |
|
|
return None |
|
|
resp = requests.get('https://maps.googleapis.com/maps/api/geocode/json', params={'address': query, 'key': GOOGLE_MAPS_API_KEY}) |
|
|
try: |
|
|
j = resp.json() |
|
|
if j.get('results'): |
|
|
loc = j['results'][0]['geometry']['location'] |
|
|
return {'lat': loc['lat'], 'lng': loc['lng']} |
|
|
except Exception: |
|
|
return None |
|
|
return None |
|
|
|
|
|
|
|
|
def format_coords(coords: Optional[Dict[str, float]]) -> str: |
|
|
if not coords or 'lat' not in coords or 'lng' not in coords: |
|
|
return "N/A" |
|
|
return f"lat: {coords['lat']:.2f}, lng: {coords['lng']:.2f}" |
|
|
|
|
|
|
|
|
load_zones_from_file() |
|
|
|
|
|
def _read_text(path: str) -> str: |
|
|
try: |
|
|
with open(path, 'r') as f: |
|
|
return f.read() |
|
|
except Exception: |
|
|
return "" |
|
|
|
|
|
APP_CSS = _read_text('static/style.css') + "\n#lat_box, #lng_box { display:none; }\n" + """ |
|
|
#next_btn { position: absolute; left: -9999px; } |
|
|
#lobby_group, #game_group{max-width:1024px;margin:24px auto;padding:16px;} |
|
|
#start_btn{height:48px;font-weight:700} |
|
|
.gradio-container{background:#FFFBEB} |
|
|
body, .gradio-container, .gradio-container *{color:#111 !important} |
|
|
/* force markdown text to be dark */ |
|
|
.gradio-container .prose, .gradio-container .prose *{color:#111 !important} |
|
|
/* override: LLM output textbox uses white font on dark background */ |
|
|
#ai_chat, #ai_chat *{color:#fff !important} |
|
|
#ai_chat textarea{background:#111 !important;color:#fff !important} |
|
|
#ai_chat label{color:#fff !important} |
|
|
/* difficulty dropdown white text */ |
|
|
#difficulty_select, #difficulty_select *{color:#fff !important} |
|
|
/* keep dropdown menu items readable */ |
|
|
.svelte-3lgy39 .wrap-inner, .wrap-inner{ color: inherit; } |
|
|
#popup-overlay, #popup-overlay * {color:#f9fafb !important} |
|
|
#popup-overlay #ai-analysis-box, #popup-overlay #ai-analysis-box * { color: #1e293b !important; } |
|
|
/* Game Over summary text in orange */ |
|
|
.gradio-container div[style*='text-align:center'] h2, |
|
|
.gradio-container div[style*='text-align:center'] h3, |
|
|
.gradio-container div[style*='text-align:center'] p { |
|
|
color: #FA500F !important; |
|
|
} |
|
|
/* Remove all grey borders */ |
|
|
.gradio-container button, |
|
|
.gradio-container .form, |
|
|
.gradio-container .block, |
|
|
.gradio-container input, |
|
|
.gradio-container textarea, |
|
|
.gradio-container select, |
|
|
.gradio-container .gr-box, |
|
|
.gradio-container .gr-input, |
|
|
.gradio-container .gr-form { |
|
|
border: none !important; |
|
|
} |
|
|
/* Style login button to be compact */ |
|
|
#login_button {max-width: 180px !important;} |
|
|
#login_button button {padding: 6px 14px !important; font-size: 0.9rem !important;} |
|
|
/* Style Hugging Face GPU notifications */ |
|
|
.toast-wrap, .toast-body {background: #f8fafc !important; color: #111 !important; border: none !important;} |
|
|
.toast-title {color: #111 !important;} |
|
|
.toast-text {color: #334155 !important;} |
|
|
.toast-close {color: #111 !important;} |
|
|
""" |
|
|
|
|
|
|
|
|
APP_BOOT_JS = """ |
|
|
() => { |
|
|
const GMAPS_KEY = "__GMAPS_KEY__"; |
|
|
const log = (...a) => { try { console.log('[boot]', ...a); } catch(_) {} }; |
|
|
function ensureMapsLoaded(cb) { |
|
|
if (window.google && google.maps) return cb(); |
|
|
if (!GMAPS_KEY) { log('No GOOGLE_MAPS_API_KEY; mini-map disabled'); return; } |
|
|
window.__gmapsQueue = window.__gmapsQueue || []; |
|
|
window.__gmapsQueue.push(cb); |
|
|
if (window.__gmapsLoading) return; |
|
|
window.__gmapsLoading = true; |
|
|
window.__mini_cb__ = () => { |
|
|
log('Google Maps ready'); |
|
|
const q = window.__gmapsQueue || []; |
|
|
q.forEach(fn => { try { fn(); } catch(_) {} }); |
|
|
window.__gmapsQueue = []; |
|
|
}; |
|
|
const s = document.createElement('script'); |
|
|
s.async = true; s.defer = true; s.dataset.gmapsLoader = '1'; |
|
|
s.src = 'https://maps.googleapis.com/maps/api/js?key=' + GMAPS_KEY + '&callback=__mini_cb__'; |
|
|
s.onerror = () => log('Failed to load Google Maps script'); |
|
|
document.head.appendChild(s); |
|
|
} |
|
|
function initMiniMapIfPresent() { |
|
|
const el = document.getElementById('mini-map'); |
|
|
if (!el || el.dataset.initialized === '1') return; |
|
|
ensureMapsLoaded(() => { |
|
|
try { |
|
|
const map = new google.maps.Map(el, { center: { lat: 0, lng: 0 }, zoom: 1, streetViewControl: false, mapTypeControl: false, fullscreenControl: false }); |
|
|
window._miniMapInstance = map; |
|
|
el.dataset.initialized = '1'; |
|
|
let marker=null; |
|
|
map.addListener('click',(e)=>{ |
|
|
if(marker) marker.setMap(null); |
|
|
marker=new google.maps.Marker({position:e.latLng, map}); |
|
|
const latBox=document.querySelector('#lat_box input, #lat_box textarea, #lat_box input[type=number]'); |
|
|
const lngBox=document.querySelector('#lng_box input, #lng_box textarea, #lng_box input[type=number]'); |
|
|
if(latBox){ latBox.value=e.latLng.lat(); latBox.dispatchEvent(new Event('input',{bubbles:true})); } |
|
|
if(lngBox){ lngBox.value=e.latLng.lng(); lngBox.dispatchEvent(new Event('input',{bubbles:true})); } |
|
|
}); |
|
|
setTimeout(() => { try { google.maps.event.trigger(map, 'resize'); map.setCenter({ lat: 0, lng: 0 }); } catch(_) {} }, 150); |
|
|
log('Mini-map initialized'); |
|
|
} catch (e) { log('Mini-map init error', e); } |
|
|
}); |
|
|
} |
|
|
function initPopupIfPresent() { |
|
|
log('initPopupIfPresent called'); |
|
|
const el = document.getElementById('popup-map'); |
|
|
if (!el || el.dataset.initialized === '1') return; |
|
|
log("Raw AI dataset values:", { lat: el.dataset.aiLat, lng: el.dataset.aiLng }); |
|
|
const rnd = { lat: parseFloat(el.dataset.rndLat), lng: parseFloat(el.dataset.rndLng) }; |
|
|
const human = { lat: parseFloat(el.dataset.hLat), lng: parseFloat(el.dataset.hLng) }; |
|
|
const ai = { lat: parseFloat(el.dataset.aiLat), lng: parseFloat(el.dataset.aiLng) }; |
|
|
log("Parsed AI coords:", ai); |
|
|
ensureMapsLoaded(() => { |
|
|
try { |
|
|
log('Popup map element found, ensuring maps loaded...'); |
|
|
const mapOpts={zoom:6,center:rnd,mapTypeControl:false,streetViewControl:false,fullscreenControl:false}; |
|
|
const m = new google.maps.Map(el, mapOpts); |
|
|
el.dataset.initialized = '1'; |
|
|
const bounds = new google.maps.LatLngBounds(); |
|
|
const markerIcon = (fill, stroke) => ({ path: google.maps.SymbolPath.CIRCLE, scale: 9.5, fillColor: fill, fillOpacity: 1, strokeColor: stroke, strokeWeight: 2 }); |
|
|
const markerLabel = (text) => ({ text, color: '#ffffff', fontWeight: '700', fontSize: '12px' }); |
|
|
log("Marker label style:", markerLabel("A")); |
|
|
const gMk = new google.maps.Marker({ position: rnd, map: m, label: markerLabel('G'), icon: markerIcon('#22c55e', '#166534') }); |
|
|
bounds.extend(gMk.getPosition()); |
|
|
if (Number.isFinite(ai.lat) && Number.isFinite(ai.lng)) { |
|
|
log("AI coordinates are valid, creating marker."); |
|
|
const aMk = new google.maps.Marker({ position: ai, map: m, label: markerLabel('M'), icon: markerIcon('#FA500F', '#c2410c') }); |
|
|
bounds.extend(aMk.getPosition()); |
|
|
new google.maps.Polyline({ path: [rnd, ai], geodesic: true, strokeColor: '#FA500F', strokeOpacity: 1.0, strokeWeight: 2, map: m }); |
|
|
} else { |
|
|
log("AI coordinates are NOT valid, skipping marker creation."); |
|
|
} |
|
|
if (Number.isFinite(human.lat) && Number.isFinite(human.lng)) { |
|
|
const hMk = new google.maps.Marker({ position: human, map: m, label: markerLabel('H'), icon: markerIcon('#2563EB', '#1e3a8a') }); |
|
|
bounds.extend(hMk.getPosition()); |
|
|
new google.maps.Polyline({ path: [rnd, human], geodesic: true, strokeColor: '#2563EB', strokeOpacity: 1.0, strokeWeight: 2, map: m }); |
|
|
} |
|
|
const ne = bounds.getNorthEast(); |
|
|
const sw = bounds.getSouthWest(); |
|
|
if (ne && sw && ne.equals(sw)) { |
|
|
m.setCenter(ne); |
|
|
log("Setting zoom to 18"); |
|
|
m.setZoom(18); |
|
|
} else { |
|
|
log("Fitting map to bounds"); |
|
|
m.fitBounds(bounds); |
|
|
} |
|
|
setTimeout(() => { |
|
|
try { |
|
|
google.maps.event.trigger(m, 'resize'); |
|
|
const ne2 = bounds.getNorthEast(); |
|
|
const sw2 = bounds.getSouthWest(); |
|
|
if (ne2 && sw2 && ne2.equals(sw2)) { |
|
|
m.setCenter(ne2); |
|
|
log("Setting zoom to 18 after resize"); |
|
|
m.setZoom(18); |
|
|
} else { |
|
|
log("Fitting map to bounds after resize"); |
|
|
m.fitBounds(bounds); |
|
|
} |
|
|
} catch (e) { log('Resize error', e); } |
|
|
}, 120); |
|
|
const closeButtons = [document.getElementById('popup-close-next'), document.getElementById('popup-close-next-footer')]; |
|
|
closeButtons.forEach((btn) => { |
|
|
if (!btn) return; |
|
|
if (!btn.dataset.bound) { |
|
|
btn.addEventListener('click', () => { |
|
|
const nxt = document.getElementById('next_btn'); |
|
|
if (nxt) nxt.click(); |
|
|
}); |
|
|
btn.dataset.bound = '1'; |
|
|
} |
|
|
}); |
|
|
window.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { const nxt = document.getElementById('next_btn'); if (nxt) nxt.click(); } }, { once: true }); |
|
|
log('Popup map initialized'); |
|
|
} catch (e) { log('Popup map init error', e); } |
|
|
}); |
|
|
} |
|
|
function initFullscreenButtonIfPresent() { |
|
|
log('initFullscreenButtonIfPresent called'); |
|
|
const btn = document.getElementById('fullscreen-btn'); |
|
|
const wrapper = document.getElementById('fullscreen-wrapper'); |
|
|
const img = document.getElementById('street-image'); |
|
|
if (!btn || !wrapper || !img || btn.dataset.initialized === '1') return; |
|
|
|
|
|
const enterIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>'; |
|
|
const exitIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"></polyline><polyline points="20 10 14 10 14 4"></polyline><line x1="14" y1="10" x2="21" y2="3"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>'; |
|
|
|
|
|
btn.innerHTML = enterIcon; |
|
|
const originalImgParentStyle = img.parentElement.style; |
|
|
const originalImgStyle = { |
|
|
width: img.style.width, |
|
|
height: img.style.height, |
|
|
objectFit: img.style.objectFit, |
|
|
borderRadius: img.style.borderRadius, |
|
|
}; |
|
|
|
|
|
btn.addEventListener('click', () => { |
|
|
if (!document.fullscreenElement) { |
|
|
wrapper.requestFullscreen().catch(err => { |
|
|
log('Fullscreen error:', err); |
|
|
}); |
|
|
} else { |
|
|
document.exitFullscreen(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.addEventListener('fullscreenchange', () => { |
|
|
if (document.fullscreenElement === wrapper) { |
|
|
btn.innerHTML = exitIcon; |
|
|
img.style.width = '100%'; |
|
|
img.style.height = '100%'; |
|
|
img.style.objectFit = 'contain'; |
|
|
img.style.borderRadius = '0'; |
|
|
} else { |
|
|
btn.innerHTML = enterIcon; |
|
|
img.style.width = originalImgStyle.width; |
|
|
img.style.height = originalImgStyle.height; |
|
|
img.style.objectFit = originalImgStyle.objectFit; |
|
|
img.style.borderRadius = originalImgStyle.borderRadius; |
|
|
} |
|
|
}); |
|
|
|
|
|
btn.dataset.initialized = '1'; |
|
|
log('Fullscreen button initialized'); |
|
|
} |
|
|
function initMapControlsIfPresent() { |
|
|
const plusBtn = document.getElementById('map-size-plus'); |
|
|
const minusBtn = document.getElementById('map-size-minus'); |
|
|
const mapWrap = document.getElementById('mini-map-wrap'); |
|
|
if (!plusBtn || !minusBtn || !mapWrap || mapWrap.dataset.controlsInitialized) return; |
|
|
const sizes = [{w: 200, h: 130}, {w: 280, h: 180}, {w: 400, h: 260}, {w: 550, h: 360}]; |
|
|
let currentSizeIndex = 1; |
|
|
const updateSize = () => { |
|
|
const newSize = sizes[currentSizeIndex]; |
|
|
mapWrap.style.width = newSize.w + 'px'; |
|
|
mapWrap.style.height = newSize.h + 'px'; |
|
|
if (window._miniMapInstance) { |
|
|
setTimeout(() => { |
|
|
google.maps.event.trigger(window._miniMapInstance, 'resize'); |
|
|
}, 300); |
|
|
} |
|
|
log('Map size changed to', newSize); |
|
|
}; |
|
|
plusBtn.addEventListener('click', (e) => { |
|
|
e.stopPropagation(); |
|
|
if (currentSizeIndex < sizes.length - 1) { |
|
|
currentSizeIndex++; |
|
|
updateSize(); |
|
|
} |
|
|
}); |
|
|
minusBtn.addEventListener('click', (e) => { |
|
|
e.stopPropagation(); |
|
|
if (currentSizeIndex > 0) { |
|
|
currentSizeIndex--; |
|
|
updateSize(); |
|
|
} |
|
|
}); |
|
|
mapWrap.dataset.controlsInitialized = '1'; |
|
|
updateSize(); |
|
|
log('Map controls initialized'); |
|
|
} |
|
|
const obs = new MutationObserver(() => { initMiniMapIfPresent(); initPopupIfPresent(); initMapControlsIfPresent(); initFullscreenButtonIfPresent(); }); |
|
|
obs.observe(document.documentElement, { childList: true, subtree: true }); |
|
|
initMiniMapIfPresent(); |
|
|
initPopupIfPresent(); |
|
|
initMapControlsIfPresent(); |
|
|
initFullscreenButtonIfPresent(); |
|
|
} |
|
|
""".replace("__GMAPS_KEY__", GOOGLE_MAPS_API_KEY or '') |
|
|
|
|
|
with gr.Blocks(css=APP_CSS, title="LLM GeoGuessr") as demo: |
|
|
user_profile = gr.State() |
|
|
|
|
|
with gr.Row(): |
|
|
gr.Markdown("## LLM GeoGuessr", elem_id="title_md") |
|
|
login_button = LoginButton(visible=True, elem_id="login_button", scale=0, min_width=300) |
|
|
|
|
|
gr.Markdown(""" |
|
|
### Magistral |
|
|
Magistral is a multimodal model trained by Mistral AI to do visual reasoning. |
|
|
The model has not been trained on geogusser data, nor does it have access to any tools (maps, etc.) which makes it a fair experience as it purely relies on the visual clues in the image. |
|
|
|
|
|
### How to Play |
|
|
1. **Login** using your Hugging Face account and click "Start Game". |
|
|
2. You'll be shown a random google maps image. |
|
|
3. Place a marker on the mini-map to guess the location. |
|
|
4. Submit your guess and see how your score compares to the AI's! You score is 5000 - (distance to the ground truth in km). |
|
|
|
|
|
The game consists of 3 rounds ! |
|
|
""") |
|
|
|
|
|
login_prompt_md = gr.Markdown("### Please log in with your Hugging Face account to play.", visible=True) |
|
|
logged_in_md = gr.Markdown(visible=False) |
|
|
|
|
|
with gr.Group(visible=False, elem_id="lobby_group") as lobby_group: |
|
|
start_btn = gr.Button("Start Game", variant="primary", elem_id="start_btn") |
|
|
limit_msg = gr.Markdown(visible=False) |
|
|
|
|
|
with gr.Group(visible=False, elem_id="game_group") as game_group: |
|
|
rounds_state = gr.State([]) |
|
|
idx_state = gr.State(0) |
|
|
round_id_box = gr.Textbox(visible=False) |
|
|
lat_box = gr.Number(visible=True, elem_id="lat_box", label="lat") |
|
|
lng_box = gr.Number(visible=True, elem_id="lng_box", label="lng") |
|
|
street_html = gr.HTML(visible=True) |
|
|
map_html = gr.HTML(visible=False) |
|
|
validate_btn = gr.Button("Validate Guess", visible=True) |
|
|
result_md = gr.Markdown() |
|
|
popup_html = gr.HTML() |
|
|
ai_chat = gr.Textbox(label="AI Analysis", interactive=False, elem_id="ai_chat", lines=15) |
|
|
next_btn = gr.Button("Next", visible=True, elem_id="next_btn") |
|
|
final_md = gr.Markdown(visible=True) |
|
|
|
|
|
def on_login(token: gr.OAuthToken | None): |
|
|
if not token: |
|
|
return ( |
|
|
None, |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(), |
|
|
gr.update(visible=False), |
|
|
) |
|
|
|
|
|
try: |
|
|
profile = whoami(token=token.token) |
|
|
username = profile["name"] |
|
|
|
|
|
todays_games = data_manager.get_todays_games(token=SERVER_HF_TOKEN) |
|
|
|
|
|
has_played = data_manager.BLOCK_MULTIPLE_GAMES and data_manager.has_user_played_today(username, todays_games) |
|
|
except Exception as e: |
|
|
gr.Warning(f"Could not check your game status. Please try again. Error: {e}") |
|
|
return ( |
|
|
None, |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(), |
|
|
gr.update(visible=False), |
|
|
) |
|
|
|
|
|
welcome_message = f"Welcome, **{profile.get('fullname', username)}**! You are logged in as **{username}**." |
|
|
|
|
|
updates = [ |
|
|
profile, |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=True, value=welcome_message), |
|
|
gr.update(visible=True), |
|
|
] |
|
|
|
|
|
if has_played: |
|
|
limit_message = "You have already played today. Please come back tomorrow for a new challenge!" |
|
|
updates.extend([ |
|
|
gr.update(interactive=False), |
|
|
gr.update(visible=True, value=limit_message), |
|
|
]) |
|
|
else: |
|
|
updates.extend([ |
|
|
gr.update(interactive=True), |
|
|
gr.update(visible=False), |
|
|
]) |
|
|
|
|
|
return tuple(updates) |
|
|
|
|
|
|
|
|
|
|
|
demo.load( |
|
|
on_login, |
|
|
outputs=[ |
|
|
user_profile, |
|
|
login_prompt_md, |
|
|
login_button, |
|
|
logged_in_md, |
|
|
lobby_group, |
|
|
start_btn, |
|
|
limit_msg, |
|
|
], |
|
|
) |
|
|
|
|
|
|
|
|
login_button.click( |
|
|
on_login, |
|
|
outputs=[ |
|
|
user_profile, |
|
|
login_prompt_md, |
|
|
login_button, |
|
|
logged_in_md, |
|
|
lobby_group, |
|
|
start_btn, |
|
|
limit_msg, |
|
|
], |
|
|
) |
|
|
|
|
|
def start_click(profile: dict, request: gr.Request): |
|
|
if not profile: |
|
|
gr.Warning("Please log in before starting the game.") |
|
|
|
|
|
return None, 0, "", "", "", "", gr.update(), gr.update(), gr.update() |
|
|
|
|
|
r, idx, rid, s_html, m_html, err = gr_start_game("easy", profile["name"], request) |
|
|
return ( |
|
|
r, |
|
|
idx, |
|
|
rid, |
|
|
s_html, |
|
|
m_html, |
|
|
gr.update(value=""), |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=False), |
|
|
gr.update(value="") |
|
|
) |
|
|
|
|
|
start_btn.click( |
|
|
start_click, |
|
|
inputs=[user_profile], |
|
|
outputs=[rounds_state, idx_state, round_id_box, street_html, map_html, result_md, game_group, lobby_group, limit_msg] |
|
|
) |
|
|
|
|
|
def on_validate(rid, lat, lng, profile: dict, request: gr.Request): |
|
|
if not profile: return |
|
|
username = profile["name"] |
|
|
_popup, txt, a_lat, a_lng, _score = gr_submit_guess(rid, lat, lng, username, request) |
|
|
yield "", txt + "\n\n[AI] Analyzing image..." |
|
|
|
|
|
rnd = get_round(username, rid) |
|
|
if not rnd: |
|
|
yield "", txt + "\n\n[Error] Round not found" |
|
|
return |
|
|
try: |
|
|
img_resp = requests.get(rnd['image_url']) |
|
|
img_resp.raise_for_status() |
|
|
last_text = "" |
|
|
for partial_text in llm_stream_image_text(img_resp.content): |
|
|
last_text = partial_text |
|
|
yield "", txt + "\n\n" + partial_text |
|
|
|
|
|
ai_coords = extract_coords_from_text(last_text) or geocode_text_to_coords(last_text[-256:]) |
|
|
if ai_coords: |
|
|
rnd['ai_guess'] = ai_coords |
|
|
ai_dist_km = haversine_km(rnd['lat'], rnd['lng'], ai_coords['lat'], ai_coords['lng']) |
|
|
rnd['ai_distance_km'] = float(ai_dist_km) |
|
|
rnd['ai_score'] = score_from_distance_km(ai_dist_km) |
|
|
|
|
|
rnd['ai_analysis'] = last_text |
|
|
|
|
|
|
|
|
sess = user_sessions.get(username, {}) |
|
|
game_id = sess.get('game_id', '') |
|
|
round_idx = next((i for i, rr in enumerate(sess.get('rounds', [])) if rr['id'] == rid), 0) + 1 |
|
|
round_record = { |
|
|
"round_number": round_idx, |
|
|
"actual_location": {"lat": rnd.get('lat'), "lng": rnd.get('lng')}, |
|
|
"human_guess": rnd.get('human_guess'), |
|
|
"human_distance_km": round(rnd.get('human_distance_km', 0), 2), |
|
|
"human_score": float(round(rnd.get('human_score', 0))), |
|
|
"ai_guess": rnd.get('ai_guess'), |
|
|
"ai_distance_km": round(rnd.get('ai_distance_km', 0), 2) if rnd.get('ai_distance_km') else None, |
|
|
"ai_score": float(round(rnd.get('ai_score', 0))) if rnd.get('ai_score') else 0.0, |
|
|
"ai_analysis": rnd.get('ai_analysis', ''), |
|
|
} |
|
|
data_manager.update_game_record(username, game_id, round_data=round_record) |
|
|
except Exception as e: |
|
|
yield "", txt + f"\n\n[Error] {e}" |
|
|
return |
|
|
|
|
|
|
|
|
sess = user_sessions.get(username, {}) |
|
|
total_human = sum(float(r.get('human_score', 0.0)) for r in sess.get('rounds', [])) |
|
|
total_ai = sum(float(r.get('ai_score', 0.0)) for r in sess.get('rounds', [])) |
|
|
round_idx = next((i for i, rr in enumerate(sess.get('rounds', [])) if rr['id'] == rid), 0) + 1 |
|
|
|
|
|
import html as _html |
|
|
ai_text_safe = _html.escape(last_text or "") |
|
|
summary_safe = _html.escape(txt) |
|
|
human_guess_str = format_coords(rnd.get('human_guess')) |
|
|
ai_guess_str = format_coords(rnd.get('ai_guess')) |
|
|
scoreboard_html = ( |
|
|
f"<div style='margin:8px 0;padding:24px;background:#09101f;border-radius:18px;color:#f9fafb;border:1px solid rgba(148,163,184,0.35);box-shadow:0 22px 60px rgba(8,12,24,0.6);'>" |
|
|
f"<div style='font-weight:700;font-size:1.25rem;margin-bottom:12px;text-shadow:0 0 12px rgba(8,12,24,0.8);'>Round {round_idx}</div>" |
|
|
f"<div style='margin-bottom:18px;color:#f8fafc;font-size:1rem;line-height:1.55;text-shadow:0 0 10px rgba(8,12,24,0.75);'>{summary_safe}</div>" |
|
|
f"<div style='display:flex;gap:26px;font-size:0.98rem;margin-bottom:18px;flex-wrap:wrap;'>" |
|
|
f" <span><strong>Human:</strong> {rnd.get('human_score',0):.0f} pts ( {human_guess_str} ) <span style='color:#dbeafe;'>({rnd.get('human_distance_km',0.0):.1f} km)</span></span>" |
|
|
f" <span><strong>AI:</strong> {rnd.get('ai_score',0):.0f} pts ( {ai_guess_str} ) <span style='color:#dbeafe;'>({rnd.get('ai_distance_km',0.0):.1f} km)</span></span>" |
|
|
f"</div>" |
|
|
f"<div style='margin-bottom:16px;font-weight:600;font-size:1rem;color:#e2e8f0;text-shadow:0 0 10px rgba(8,12,24,0.65);'>Totals β Human {total_human:.0f} / AI {total_ai:.0f}</div>" |
|
|
f"<div id='ai-analysis-box' style='background:#f1f5f9; border-radius:14px;border:1px solid rgba(148,163,184,0.45);padding:16px;max-height:280px;overflow:auto;'>" |
|
|
f" <div style='margin-bottom:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;'>AI Analysis</div>" |
|
|
f" <pre style='margin:0;background:transparent;color:inherit;font-size:1rem;white-space:pre-wrap;line-height:1.6;font-family:\"SFMono-Regular\",Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;'>" + ai_text_safe + "</pre>" |
|
|
f"</div>" |
|
|
f"</div>" |
|
|
) |
|
|
|
|
|
ai = rnd.get('ai_guess') |
|
|
ai_lat_str = "" |
|
|
ai_lng_str = "" |
|
|
if ai: |
|
|
ai_lat = float(ai.get('lat', 0.0)) |
|
|
ai_lng = float(ai.get('lng', 0.0)) |
|
|
ai_lat_str = str(ai_lat) |
|
|
ai_lng_str = str(ai_lng) |
|
|
|
|
|
popup_html = """ |
|
|
<div id="popup-overlay" style="position:fixed;inset:0;background:rgba(8,11,20,0.78);display:flex;align-items:center;justify-content:center;z-index:1000;"> |
|
|
<div id="popup-card" style="background:#04070f;padding:26px;border-radius:18px;width:92%;max-width:960px;color:#f9fafb;position:relative;box-shadow:0 28px 80px rgba(4,7,15,0.75);border:1px solid rgba(148,163,184,0.35);" onclick="event.stopPropagation();"> |
|
|
<button id="popup-close-next" style="position:absolute;top:16px;right:18px;padding:10px 18px;border-radius:999px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 8px 18px rgba(37,99,235,0.45);">Next Round</button> |
|
|
<h3 style="margin:0 0 20px;color:#f8fafc;text-shadow:0 0 14px rgba(37,99,235,0.35);">Round Results</h3> |
|
|
__SCOREBOARD__ |
|
|
<div id="popup-map" data-rnd-lat="__RND_LAT__" data-rnd-lng="__RND_LNG__" data-h-lat="__H_LAT__" data-h-lng="__H_LNG__" data-ai-lat="__AI_LAT__" data-ai-lng="__AI_LNG__" style="width:100%;height:440px;border-radius:16px;overflow:hidden;border:1px solid rgba(148,163,184,0.35);box-shadow:0 16px 40px rgba(4,7,15,0.72);"></div> |
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:0.98rem;color:#e2e8f0;flex-wrap:wrap;gap:14px;"> |
|
|
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;"> |
|
|
<span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.6);"></span>G = Ground Truth</span> |
|
|
<span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#2563EB;box-shadow:0 0 8px rgba(37,99,235,0.6);"></span>H = Human</span> |
|
|
<span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#FA500F;box-shadow:0 0 8px rgba(249,115,22,0.55);"></span>M = Magistral</span> |
|
|
</div> |
|
|
<button id="popup-close-next-footer" style="padding:12px 22px;border-radius:12px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 10px 22px rgba(37,99,235,0.48);">Next Round</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
popup_html = popup_html.replace('__SCOREBOARD__', scoreboard_html) .replace('__RND_LAT__', str(rnd['lat'])) .replace('__RND_LNG__', str(rnd['lng'])) .replace('__H_LAT__', str(float(lat))) .replace('__H_LNG__', str(float(lng))) .replace('__AI_LAT__', ai_lat_str) .replace('__AI_LNG__', ai_lng_str) .replace('__GMAPS_KEY__', GOOGLE_MAPS_API_KEY or '') |
|
|
|
|
|
yield popup_html, (txt + ("\n\n" + (last_text or ""))) |
|
|
|
|
|
validate_btn.click(on_validate, inputs=[round_id_box, lat_box, lng_box, user_profile], outputs=[popup_html, ai_chat]) |
|
|
|
|
|
def on_next(r_state: list, idx: int, profile: dict, request: gr.Request): |
|
|
if not profile: return idx, gr.update(), gr.update(), gr.update(), gr.update() |
|
|
username = profile["name"] |
|
|
idx += 1 |
|
|
sess = user_sessions.get(username) |
|
|
if not sess or idx >= len(sess['rounds']): |
|
|
total_human = sum(float(r.get('human_score', 0.0)) for r in sess.get('rounds', [])) |
|
|
total_ai = sum(float(r.get('ai_score', 0.0)) for r in sess.get('rounds', [])) |
|
|
game_id = sess.get('game_id', '') |
|
|
|
|
|
|
|
|
data_manager.update_game_record(username, game_id, final_score=total_human, final_ai_score=total_ai) |
|
|
|
|
|
winner_message = "It's a tie!" |
|
|
if total_human > total_ai: |
|
|
winner_message = "Congratulations, you won!" |
|
|
elif total_ai > total_human: |
|
|
winner_message = "The AI won this time." |
|
|
|
|
|
summary_html = f""" |
|
|
<div style='text-align:center; padding: 40px; font-size: 1.2em; color: #FA500F;'> |
|
|
<h2 style='color: #FA500F;'>Game Over!</h2> |
|
|
<p style='color: #FA500F;'>Here are the final scores:</p> |
|
|
<p style='color: #FA500F;'><strong>Your Score:</strong> {total_human:.0f}</p> |
|
|
<p style='color: #FA500F;'><strong>AI's Score:</strong> {total_ai:.0f}</p> |
|
|
<h3 style='color: #FA500F;'>{winner_message}</h3> |
|
|
</div> |
|
|
""" |
|
|
return idx, gr.update(value=summary_html), gr.update(value=""), gr.update(value=""), gr.update(value="") |
|
|
r = sess['rounds'][idx] |
|
|
s_html = build_street_html(r['image_url']) |
|
|
return idx, gr.update(value=s_html), gr.update(value=r['id']), gr.update(value=""), gr.update(value="") |
|
|
|
|
|
next_btn.click(on_next, inputs=[rounds_state, idx_state, user_profile], outputs=[idx_state, street_html, round_id_box, popup_html, ai_chat]) |
|
|
|
|
|
|
|
|
demo.load(fn=lambda: None, inputs=None, outputs=None, js=APP_BOOT_JS) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.queue().launch(server_name="0.0.0.0", server_port=7860) |