Spaces:
Running
on
Zero
Running
on
Zero
File size: 50,333 Bytes
a303b0f 8e91d04 a303b0f 279bdde a303b0f 55eb384 a303b0f 8bd9cd3 a303b0f 8bd9cd3 a303b0f 8bd9cd3 a303b0f 9612e66 a303b0f b710989 a303b0f b710989 a303b0f 8e91d04 a303b0f 8e91d04 a303b0f b0e694e 4c42876 b0e694e a303b0f 4c42876 a303b0f 4a0d2aa a303b0f 4a0d2aa a303b0f 664783c a303b0f cc1c341 a303b0f 55eb384 a303b0f 4c42876 a303b0f 55eb384 a303b0f 9612e66 a303b0f 9612e66 a303b0f 664783c a303b0f cc1c341 47baf27 a303b0f 47baf27 a303b0f 8e088c0 47baf27 33bd7ec b2cc102 47baf27 b2cc102 a303b0f 4c42876 a303b0f 4c42876 a303b0f 55eb384 a303b0f 55eb384 a303b0f 682a8fc a303b0f 7f53d2e b0e694e 5c48063 b0e694e 7f53d2e a3407a6 7f53d2e 456bd12 b0e694e 7f53d2e b0e694e 7f53d2e b0e694e 7f53d2e b0e694e 7f53d2e b0e694e 7f53d2e a303b0f 7f53d2e a303b0f 7f53d2e a303b0f 33bd7ec 833c55a 33bd7ec 682a8fc cf79bd4 b1dc763 ad5bffb b1dc763 682a8fc b1dc763 682a8fc b1dc763 682a8fc a303b0f 55eb384 a303b0f 55eb384 cc1c341 a303b0f 2f92831 02817fd a303b0f d114ce7 54ea10b a303b0f 55eb384 a303b0f 55eb384 a303b0f 55eb384 a303b0f 02817fd add3bed 4a0d2aa add3bed 3e464ec b2cc102 add3bed 3e464ec b2cc102 add3bed 4a0d2aa a303b0f 664783c a303b0f 664783c a303b0f cc1c341 a303b0f 55eb384 4c42876 a303b0f 7d93d9d a303b0f 5fb2898 a303b0f 4a0d2aa a303b0f 3e464ec 4a0d2aa a303b0f 4c42876 a303b0f 5fb2898 a303b0f 5fb2898 a303b0f 5fb2898 a303b0f |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 |
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 = (
# "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 answer inside [ANSWER]lat,lng[/ANSWER]."
# )
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:
# Fallback placeholder to avoid blank image when key is missing
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()
# Check if it's OK and preferably outdoor (not inside buildings)
if data.get("status") == "OK":
# Prefer locations that are explicitly outdoor
location_type = data.get("location_type")
# If location_type is available, check it's not indoors
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})
# Explore a few jittered points if needed
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()) # Generate unique game ID
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} # instead of defaulting to geocode it will return 0 as default when failing
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;}
"""
# Client boot JS to initialize the mini-map reliably in Gradio (scripts in HTML are sanitized)
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"]
# Use server token to read the dataset, not user's token
todays_games = data_manager.get_todays_games(token=SERVER_HF_TOKEN)
# Only check if user played today if BLOCK_MULTIPLE_GAMES is enabled
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)
# Use demo.load to set the initial UI state when the app loads with an existing token.
# This is the key fix for the UI flickering issue.
demo.load(
on_login,
outputs=[
user_profile,
login_prompt_md,
login_button,
logged_in_md,
lobby_group,
start_btn,
limit_msg,
],
)
# The click handler is still needed to initiate the login flow if the user is not logged in.
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 no-ops for all outputs to prevent errors
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
# After stream completes, compute AI guess and score
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)
# Store the AI analysis text for later recording
rnd['ai_analysis'] = last_text
# Record this round's data immediately to prevent abuse
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
# 3) Show popup with both guesses and update scoreboard
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
# Escape AI analysis to display safely inside HTML
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', '')
# Update the final scores (rounds already recorded incrementally)
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])
# Inject boot JS using load(js=callable) compatible format
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) |