""" GeoAgent - Geolocation Battle Gradio Space Players compete with AI in a geolocation race, based on AI's pre-inferred answers. """ import json import math import random import re import warnings from pathlib import Path import folium import gradio as gr from branca.element import MacroElement, Template from geopy.distance import geodesic from gradio_folium import Folium from PIL import Image # Path configuration BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" QUESTIONS_PATH = DATA_DIR / "questions.json" IMAGES_DIR = DATA_DIR / "images" # GeoScore parameters: Full score 5000, sigma controls decay (differs by mode) GEO_SCORE_MAX = 5000 GEO_SCORE_SIGMA_CHINA = 5000 # km GEO_SCORE_SIGMA_WORLD = 18050 # km UI_THEME = gr.themes.Soft() ANALYSIS_PLACEHOLDER = "### AI Analysis\nThe explanation for this round will be shown after submitting an answer." UI_CSS = """ #coord_input { display: none !important; } #control_row, #battle_row, #round_section { width: 100% !important; max-width: none !important; margin-left: 0 !important; margin-right: 0 !important; } #battle_row { align-items: stretch !important; flex-wrap: nowrap !important; gap: clamp(8px, 1.2vw, 18px) !important; } #battle_row > .gr-column { align-self: stretch !important; min-width: 0 !important; } #player_col, #ai_col { flex: 1.05 1 0 !important; min-width: 0 !important; } #player_image, #ai_image { overflow: hidden !important; cursor: grab; } #player_image img, #ai_image img { object-fit: contain !important; transform-origin: center center; transition: transform 0.05s ease-out; -webkit-user-drag: none; user-drag: none; user-select: none; } #player_map, #ai_map { margin-top: 0 !important; margin-bottom: 0 !important; line-height: 0 !important; height: 480px !important; overflow: hidden !important; } #player_map > div, #ai_map > div { margin-top: 0 !important; margin-bottom: 0 !important; padding-top: 0 !important; padding-bottom: 0 !important; height: 480px !important; min-height: 0 !important; } #player_map iframe, #ai_map iframe { display: block !important; vertical-align: top !important; height: 480px !important; } #player_image, #ai_image, #player_map, #ai_map { width: 100% !important; max-width: none !important; min-width: 0 !important; margin-left: 0 !important; margin-right: 0 !important; } #ai_media_col { flex: 1 1 0 !important; max-width: none !important; min-width: 0 !important; } #ai_analysis_col { flex: 0.95 1 0 !important; min-width: 0 !important; max-width: 620px !important; display: flex !important; flex-direction: column !important; } #ai_analysis_box { flex: 1 1 auto !important; min-height: 0 !important; max-height: none !important; height: auto !important; overflow: auto; border: 1px solid #d1d5db; border-radius: 10px; padding: 14px; background: #ffffff; line-height: 1.65; text-align: justify; text-justify: inter-word; } #ai_analysis_box h3 { margin: 0 0 10px 0 !important; } #ai_analysis_box h4 { margin: 10px 0 6px 0 !important; } #ai_analysis_box p { margin: 6px 0 !important; } #ai_analysis_box ul, #ai_analysis_box ol { margin: 6px 0 !important; padding-left: 20px !important; } #ai_analysis_box li { margin: 3px 0 !important; } #ai_analysis_box hr { margin: 10px 0 !important; } """ UI_HEAD = """ """ ROUND_TABLE_HEADERS = ["Round", "Player Dist (km)", "Player GeoScore", "AI Dist (km)", "AI GeoScore", "Winner"] MEDIA_WIDTH = 700 MEDIA_HEIGHT = 480 # 4:3 class SingleClickMarker(MacroElement): """Folium plugin: Only one marker is kept when the map is clicked.""" def __init__(self): super().__init__() self._template = Template( """ {% macro script(this, kwargs) %} var map = {{ this._parent.get_name() }}; var selectedMarker = null; map.on('click', function(e) { if (selectedMarker) { map.removeLayer(selectedMarker); } selectedMarker = L.marker(e.latlng).addTo(map); selectedMarker.bindPopup( "Lat: " + e.latlng.lat.toFixed(6) + "
Lng: " + e.latlng.lng.toFixed(6) + "
Selected as your answer" ).openPopup(); // Sync to hidden Gradio input for backend submission var coord = e.latlng.lat + "," + e.latlng.lng; try { var doc = (window.parent && window.parent !== window) ? window.parent.document : document; var container = doc.getElementById("coord_input"); if (container) { var inp = container.querySelector("textarea") || container.querySelector("input"); if (inp) { inp.value = coord; inp.dispatchEvent(new Event("input", { bubbles: true })); inp.dispatchEvent(new Event("change", { bubbles: true })); } } } catch (err) {} }); {% endmacro %} """ ) def load_questions(mode: str | None = None) -> list[dict]: """Load questions, can filter by mode.""" if not QUESTIONS_PATH.exists(): return [] with open(QUESTIONS_PATH, encoding="utf-8") as f: data = json.load(f) if mode and mode != "all": data = [q for q in data if q.get("mode") == mode] return data def sample_questions(mode: str, num_rounds: int) -> list[dict]: """Randomly sample questions by mode and rounds.""" pool = load_questions(mode) if not pool: return [] num_rounds = min(num_rounds, len(pool)) return random.sample(pool, num_rounds) def get_image_path(rel_path: str) -> Path: """Get image absolute path under data/images/ (filename or subpath).""" return IMAGES_DIR / rel_path def _create_placeholder_image() -> Path: """Create a placeholder image and return its path.""" placeholder_path = IMAGES_DIR / "_placeholder.png" if placeholder_path.exists(): return placeholder_path IMAGES_DIR.mkdir(parents=True, exist_ok=True) img = Image.new("RGB", (400, 300), color=(240, 240, 240)) from PIL import ImageDraw, ImageFont draw = ImageDraw.Draw(img) try: font = ImageFont.truetype("arial.ttf", 24) except OSError: try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 24) except OSError: font = ImageFont.load_default() draw.text((50, 130), "Image Placeholder (Add images at data/images/)", fill=(100, 100, 100), font=font) img.save(placeholder_path) return placeholder_path def get_image_or_placeholder(rel_path: str) -> str: """Return image path, or placeholder if not exists.""" p = get_image_path(rel_path) if p.exists(): return str(p) return str(_create_placeholder_image()) def geodesic_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: """Compute geodesic (kilometers) between two points.""" return geodesic((lat1, lng1), (lat2, lng2)).kilometers def geo_score(distance_km: float, mode: str = "world") -> float: """Compute GeoScore from distance, sigma by mode 'china/world'.""" if distance_km < 0 or math.isinf(distance_km): return 0.0 sigma = GEO_SCORE_SIGMA_CHINA if mode == "china" else GEO_SCORE_SIGMA_WORLD return GEO_SCORE_MAX * math.exp(-10 * (distance_km / sigma)) def resolve_num_rounds(num_rounds_choice, custom_rounds) -> int: """Interpret the number of rounds (supports custom).""" if str(num_rounds_choice) == "Custom": try: val = int(custom_rounds) except (TypeError, ValueError): val = 5 else: try: val = int(num_rounds_choice) except (TypeError, ValueError): val = 5 return max(1, val) def resolve_time_limit_seconds(time_limit_choice: str, custom_time_limit) -> int: """Interpret time limit (seconds), supports custom.""" mapping = {"Off": 0, "30s": 30, "60s": 60, "3min": 180} if time_limit_choice == "Custom": try: val = int(custom_time_limit) except (TypeError, ValueError): val = 180 return max(1, val) return mapping.get(time_limit_choice, 180) def build_round_table_and_stats(round_results: list[dict]) -> tuple[list[list], str]: """Convert per-round results to table and statistics string.""" if not round_results: return [], "Average distance & GeoScore: No data yet." rows: list[list] = [] player_dist_sum = 0.0 ai_dist_sum = 0.0 player_score_sum = 0.0 ai_score_sum = 0.0 for item in round_results: p_score = item["player_score"] a_score = item["ai_score"] winner = "Player" if p_score > a_score else ("AI" if a_score > p_score else "Draw") rows.append( [ item["round"], f"{item['player_distance']:.1f}", int(p_score), f"{item['ai_distance']:.1f}", int(a_score), winner, ] ) player_dist_sum += item["player_distance"] ai_dist_sum += item["ai_distance"] player_score_sum += p_score ai_score_sum += a_score n = len(round_results) stats_md = ( f"Player avg. distance: {player_dist_sum / n:.1f} km | Player GeoScore: {int(player_score_sum / n)} | " f"AI avg. distance: {ai_dist_sum / n:.1f} km | AI GeoScore: {int(ai_score_sum / n)}" ) return rows, stats_md def _extract_markdown_json_block(text: str) -> str: """Extract JSON text from ```json ... ``` or ``` ... ``` (robust for stored strings).""" t = text.strip() # 1) Regex: 匹配 ```json 或 ``` 与结尾 ``` m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", t, flags=re.IGNORECASE) if m: return m.group(1).strip() # 2) 手动剥离:以 ``` 开头时去掉首行并截到最后一个 ``` if t.startswith("```"): first_line_end = t.find("\n") if first_line_end >= 0: t = t[first_line_end + 1 :] end = t.rfind("```") if end >= 0: t = t[:end] return t.strip() return t def _extract_balanced_json_object(text: str) -> str | None: """Extract first balanced-bracket JSON from a string.""" start = text.find("{") if start < 0: return None depth = 0 for idx in range(start, len(text)): ch = text[idx] if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return text[start : idx + 1] return None def _normalize_section(section: dict | None) -> dict: """Normalize section, fill missing values.""" section = section or {} clues = section.get("Clues", []) if isinstance(clues, str): clues = [clues] if not isinstance(clues, list): clues = [] return { "Clues": [str(c) for c in clues], "Reasoning": str(section.get("Reasoning", "")), "Conclusion": str(section.get("Conclusion", "")), "Uncertainty": str(section.get("Uncertainty", "")), } def parse_ai_analysis_payload(raw: str | dict | None) -> tuple[dict, str]: """ Multi-strategy parse of AI analysis JSON, returning structure+method. - strategy1: direct dict - strategy2: markdown fenced json - strategy3: direct json.loads - strategy4: balanced bracket extract then json.loads - strategy5: regex fallback """ if isinstance(raw, dict): data = raw method = "dict-direct" else: text = str(raw or "").strip() data = None method = "fallback-regex" if text: # 优先剥离 ```json ... ``` 再解析,兼容 selected_results 等存储格式 candidate = _extract_markdown_json_block(text) try: data = json.loads(candidate) method = "markdown-json" except Exception: try: data = json.loads(text) method = "json-direct" except Exception: # 对已剥离的 candidate 再试一次平衡括号提取(防止尾部杂项) obj = _extract_balanced_json_object(candidate) or _extract_balanced_json_object(text) if obj: try: data = json.loads(obj) method = "balanced-object-json" except Exception: data = None else: data = None if data is None: # Fallback: regex extract partial info def _rx(pattern: str) -> str: m = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL) return (m.group(1).strip() if m else "") data = { "ChainOfThought": { "CountryIdentification": { "Conclusion": _rx(r"CountryIdentification[\s\S]*?Conclusion\"\s*:\s*\"([^\"]+)"), "Reasoning": _rx(r"CountryIdentification[\s\S]*?Reasoning\"\s*:\s*\"([^\"]+)"), "Uncertainty": _rx(r"CountryIdentification[\s\S]*?Uncertainty\"\s*:\s*\"([^\"]+)"), "Clues": [], }, "RegionalGuess": { "Conclusion": _rx(r"RegionalGuess[\s\S]*?Conclusion\"\s*:\s*\"([^\"]+)"), "Reasoning": _rx(r"RegionalGuess[\s\S]*?Reasoning\"\s*:\s*\"([^\"]+)"), "Uncertainty": _rx(r"RegionalGuess[\s\S]*?Uncertainty\"\s*:\s*\"([^\"]+)"), "Clues": [], }, "PreciseLocalization": { "Conclusion": _rx(r"PreciseLocalization[\s\S]*?Conclusion\"\s*:\s*\"([^\"]+)"), "Reasoning": _rx(r"PreciseLocalization[\s\S]*?Reasoning\"\s*:\s*\"([^\"]+)"), "Uncertainty": _rx(r"PreciseLocalization[\s\S]*?Uncertainty\"\s*:\s*\"([^\"]+)"), "Clues": [], }, }, "FinalAnswer": _rx(r"FinalAnswer\"\s*:\s*\"([^\"]+)"), } cot = data.get("ChainOfThought", {}) if isinstance(data, dict) else {} country = _normalize_section(cot.get("CountryIdentification") if isinstance(cot, dict) else None) regional = _normalize_section(cot.get("RegionalGuess") if isinstance(cot, dict) else None) precise = _normalize_section(cot.get("PreciseLocalization") if isinstance(cot, dict) else None) final_answer = "" if isinstance(data, dict): final_answer = str(data.get("FinalAnswer", "") or "") if not final_answer: parts = [country["Conclusion"], regional["Conclusion"], precise["Conclusion"]] final_answer = "; ".join([p for p in parts if p]) return { "country": country, "regional": regional, "precise": precise, "final_answer": final_answer, }, method def _render_section_md(title: str, section: dict) -> str: clues = ", ".join(section["Clues"]) if section["Clues"] else "None" reasoning = section["Reasoning"] or "None" conclusion = section["Conclusion"] or "None" uncertainty = section["Uncertainty"] or "Unknown" return ( f"#### {title}\n" f"**Clues**: {clues}\n\n" f"**Reasoning**: {reasoning}\n\n" f"**Conclusion**: **{conclusion}**\n\n" f"**Uncertainty**: {uncertainty}\n" ) def build_ai_analysis_markdown(question: dict) -> str: """Render AI CoT/JSON explanation as markdown.""" raw = None for key in ( "ai_analysis_json", "ai_analysis", "analysis_json", "analysis", "cot_json", "cot", "llm_output", "ai_output", ): if key in question and question.get(key): raw = question.get(key) break if raw is None: return "### AI Analysis\n\nNo analysis data." parsed, method = parse_ai_analysis_payload(raw) return ( "### AI Analysis\n" + _render_section_md("Country Judgement", parsed["country"]) + "\n---\n" + _render_section_md("Region Judgement", parsed["regional"]) + "\n---\n" + _render_section_md("Precise Location", parsed["precise"]) + f"\n### Final Answer\n**{parsed['final_answer'] or 'None'}**\n" ) def _clamp_lat(lat: float) -> float: """Clamp latitude to Folium displayable.""" return max(min(lat, 85.0), -85.0) def _normalize_lng(lng: float) -> float: """Normalize longitude to [-180, 180].""" return ((lng + 180.0) % 360.0) - 180.0 def _expand_answer_point_for_visual_distance( mode: str, true_lat: float, true_lng: float, answer_lat: float, answer_lng: float, ) -> tuple[tuple[float, float], tuple[float, float], bool]: """ True loc fixed. If answer too close, push answer point away along true->answer. Ensures both player & AI area's true coord are identical. """ mid_lat = (true_lat + answer_lat) / 2 km_per_lat = 111.0 km_per_lng = max(111.0 * math.cos(math.radians(mid_lat)), 1e-6) dy_km = (answer_lat - true_lat) * km_per_lat dx_km = (answer_lng - true_lng) * km_per_lng dist_km = math.hypot(dx_km, dy_km) base_min_km = 120.0 if mode == "china" else 260.0 target_dist_km = base_min_km * 1.2 if dist_km < base_min_km * 0.4 else base_min_km target_dist_km = max(dist_km, target_dist_km) if target_dist_km <= dist_km + 1e-6: return (true_lat, true_lng), (answer_lat, answer_lng), False if dist_km < 1e-6: ux, uy = 1.0, 0.0 else: ux, uy = dx_km / dist_km, dy_km / dist_km move_km = target_dist_km - dist_km dlat = (uy * move_km) / km_per_lat dlng = (ux * move_km) / km_per_lng disp_answer_lat = _clamp_lat(answer_lat + dlat) disp_answer_lng = _normalize_lng(answer_lng + dlng) return (true_lat, true_lng), (disp_answer_lat, disp_answer_lng), True def _result_zoom_by_distance(distance_km: float, mode: str) -> int: """Choose map zoom by true distance (visual clarity, not coords).""" if distance_km <= 1: return 13 if distance_km <= 5: return 11 if distance_km <= 20: return 10 if distance_km <= 80: return 9 if distance_km <= 300: return 8 if distance_km <= 1200: return 6 if mode == "world" else 7 return 4 if mode == "world" else 5 def _create_folium_map( mode: str, center: tuple[float, float] | None = None, zoom: int | None = None, markers: list[dict] | None = None, lines: list[dict] | None = None, add_click_for_marker: bool = False, ) -> folium.Map: """Create Folium map, add_click_for_marker=True allows click-to-mark.""" if mode == "china": c = center or (35, 105) z = zoom if zoom is not None else 4 else: c = center or (20, 0) z = zoom if zoom is not None else 2 # Do NOT use fit_bounds here. # In gradio-folium, fit_bounds sometimes causes click issues (esp. for "china"). m = folium.Map(location=c, zoom_start=z, height=f"{MEDIA_HEIGHT}px") if add_click_for_marker: m.add_child(SingleClickMarker()) for marker in markers or []: color = marker.get("color", "red") label = marker.get("label", "") folium.CircleMarker( location=[marker["lat"], marker["lng"]], radius=8, color="white", fill=True, fill_color=color, fill_opacity=1, popup=label, ).add_to(m) for line in lines or []: folium.PolyLine( locations=line.get("locations", []), color=line.get("color", "#4f46e5"), weight=line.get("weight", 2), opacity=line.get("opacity", 0.9), dash_array=line.get("dash_array", "8, 8"), tooltip=line.get("tooltip", "Connection"), ).add_to(m) return m def build_empty_map(mode: str, clickable: bool = True, uid: str = "map") -> folium.Map: """Create a blank map; clickable=True makes it markable.""" return _create_folium_map(mode=mode, markers=[], add_click_for_marker=clickable) def build_result_map( mode: str, true_lat: float, true_lng: float, user_lat: float, user_lng: float, uid: str = "result", ) -> folium.Map: """Show a map with the true location and user location.""" dist_km = geodesic_km(true_lat, true_lng, user_lat, user_lng) map_zoom = _result_zoom_by_distance(dist_km, mode) disp_true_lat, disp_true_lng = true_lat, true_lng disp_user_lat, disp_user_lng = user_lat, user_lng user_label = "Your Answer" true_label = "True Location" markers = [ {"lat": disp_true_lat, "lng": disp_true_lng, "color": "green", "label": true_label}, {"lat": disp_user_lat, "lng": disp_user_lng, "color": "red", "label": user_label}, ] lines = [ { "locations": [[disp_true_lat, disp_true_lng], [disp_user_lat, disp_user_lng]], "tooltip": "True vs. Player", } ] center = ((disp_true_lat + disp_user_lat) / 2, (disp_true_lng + disp_user_lng) / 2) return _create_folium_map(mode=mode, center=center, zoom=map_zoom, markers=markers, lines=lines) def build_ai_map( mode: str, true_lat: float, true_lng: float, ai_lat: float, ai_lng: float, uid: str = "ai", ) -> folium.Map: """Show map with true location vs. AI's location.""" dist_km = geodesic_km(true_lat, true_lng, ai_lat, ai_lng) map_zoom = _result_zoom_by_distance(dist_km, mode) disp_true_lat, disp_true_lng = true_lat, true_lng disp_ai_lat, disp_ai_lng = ai_lat, ai_lng ai_label = "AI Answer" true_label = "True Location" markers = [ {"lat": disp_true_lat, "lng": disp_true_lng, "color": "green", "label": true_label}, {"lat": disp_ai_lat, "lng": disp_ai_lng, "color": "red", "label": ai_label}, ] lines = [ { "locations": [[disp_true_lat, disp_true_lng], [disp_ai_lat, disp_ai_lng]], "tooltip": "True vs. AI", } ] center = ((disp_true_lat + disp_ai_lat) / 2, (disp_true_lng + disp_ai_lng) / 2) return _create_folium_map(mode=mode, center=center, zoom=map_zoom, markers=markers, lines=lines) def _coord_from_lat_lng(lat, lng) -> str: """Make coordinate string from lat/lng.""" if lat is not None and lng is not None: return f"{lat},{lng}" return "" def on_lat_lng_change(lat, lng, state: dict) -> tuple: """Update map when coordinates change.""" coord = _coord_from_lat_lng(lat, lng) if not coord: return gr.update(), gr.update(value=""), state try: lat_f, lng_f = float(lat), float(lng) except (TypeError, ValueError): return gr.update(), gr.update(), state mode = state.get("mode", "world") markers = [{"lat": lat_f, "lng": lng_f, "color": "red", "label": "To Be Confirmed"}] m = _create_folium_map(mode=mode, center=(lat_f, lng_f), zoom=8, markers=markers) return gr.update(value=m), gr.update(value=coord), {**state, "pending_lat": lat_f, "pending_lng": lng_f} def on_coord_change(coord_str: str, state: dict) -> tuple: """When player area map is clicked, update pending coords in state.""" if not state.get("questions"): return state, gr.update(value="Please click 'Start Game' first") if not coord_str or "," not in coord_str: return state, gr.update() try: lat_str, lng_str = coord_str.strip().split(",", 1) lat_f = float(lat_str) lng_f = float(lng_str) except (ValueError, TypeError): return state, gr.update() return {**state, "pending_lat": lat_f, "pending_lng": lng_f}, gr.update() def start_game( num_rounds, custom_rounds: float | None, mode: str, time_limit: str, custom_time_limit: float | None, state: dict, ) -> tuple: """Start new game.""" mode_key = "china" if mode == "China Mode" else "world" num_rounds_val = resolve_num_rounds(num_rounds, custom_rounds) questions = sample_questions(mode_key, num_rounds_val) limit_seconds = resolve_time_limit_seconds(time_limit, custom_time_limit) if not questions: return ( gr.update(value="Question bank is empty or no matching problems found. Please check data/questions.json"), gr.update(value=build_empty_map(mode_key, clickable=False)), gr.update(value=build_empty_map(mode_key, clickable=False)), gr.update(value=None), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(value=""), gr.update(value=""), gr.update(value=ANALYSIS_PLACEHOLDER), gr.update(visible=False), gr.update(visible=False), gr.update(active=False), gr.update(value=[]), gr.update(value="Average distance & GeoScore: No data yet."), { **state, "questions": [], "current": 0, "mode": mode_key, "time_limit": time_limit, "time_limit_seconds": limit_seconds, "total_player": 0, "total_ai": 0, "round_results": [], }, ) q = questions[0] img_display = get_image_or_placeholder(q["image_path"]) timer_active = limit_seconds > 0 time_left = limit_seconds round_text = f"Round 1 / {len(questions)}" if timer_active: round_text += f" | Time left: {time_left} s" return ( gr.update(value=round_text), gr.update(value=build_empty_map(mode_key)), gr.update(value=build_empty_map(mode_key, clickable=False)), gr.update(value=img_display), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(value=""), gr.update(value=""), gr.update(value=ANALYSIS_PLACEHOLDER), gr.update(visible=True), gr.update(visible=False), gr.update(active=timer_active), gr.update(value=[]), gr.update(value="Average distance & GeoScore: No data yet."), { **state, "questions": questions, "current": 0, "mode": mode_key, "time_limit": time_limit, "time_limit_seconds": limit_seconds, "time_left": time_left, "timer_active": timer_active, "pending_lat": None, "pending_lng": None, "total_player": 0, "total_ai": 0, "round_results": [], }, ) def timer_tick(state: dict) -> tuple: """Timer countdown, auto submit on timeout.""" if not state.get("timer_active") or not state.get("questions"): return ( state, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(active=False), ) time_left = state.get("time_left", 0) - 1 new_state = {**state, "time_left": time_left} if time_left <= 0: new_state["timer_active"] = False coord_str = ( f"{state.get('pending_lat', 0)},{state.get('pending_lng', 0)}" if state.get("pending_lat") is not None else "0,0" ) res = submit_answer(coord_str, None, None, new_state) # submit_answer's output order is different; re-order: return ( res[15], res[0], res[1], res[2], res[3], res[4], res[5], res[6], res[7], res[8], res[10], res[11], gr.update(active=False), ) # Only update timer display questions = state["questions"] num_total = len(questions) current = state["current"] round_info = f"Round {current + 1} / {num_total} | Time left: {time_left} s" return ( new_state, gr.update(value=round_info), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(active=True), ) def submit_answer( coord_str: str, lat_val: float | None, lng_val: float | None, state: dict, ) -> tuple: """Submit an answer: compute distances, GeoScores, update display.""" if not state.get("questions"): return ( gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(), gr.update(visible=False), gr.update(visible=False), gr.update(active=False), gr.update(), gr.update(), state, ) q = state["questions"][state["current"]] ai_analysis_md = build_ai_analysis_markdown(q) true_lat, true_lng = q["true_lat"], q["true_lng"] ai_lat, ai_lng = q["ai_lat"], q["ai_lng"] mode = state.get("mode", "world") # Parse user coordinates (use what was written by clicking the map) user_lat, user_lng = None, None if coord_str and "," in coord_str: try: parts = coord_str.strip().split(",") user_lat, user_lng = float(parts[0]), float(parts[1]) except (ValueError, IndexError): pass if user_lat is None: user_lat, user_lng = state.get("pending_lat"), state.get("pending_lng") if user_lat is None: # No answer selected; do not submit; avoid accident using previous coords return ( gr.update(value="Please select a point on the player map before submitting your answer."), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(value=ANALYSIS_PLACEHOLDER), gr.update(visible=True), gr.update(visible=False), gr.update(active=state.get("timer_active", False)), gr.update(), gr.update(), state, ) # Player calculation dist_km = geodesic_km(true_lat, true_lng, user_lat, user_lng) score = geo_score(dist_km, mode) player_result = f"Distance: {dist_km:.1f} km | GeoScore: {int(score)}" player_map = build_result_map(mode, true_lat, true_lng, user_lat, user_lng, uid="p1") # AI calculation ai_dist_km = geodesic_km(true_lat, true_lng, ai_lat, ai_lng) ai_score = geo_score(ai_dist_km, mode) ai_result = f"Distance: {ai_dist_km:.1f} km | GeoScore: {int(ai_score)}" ai_map = build_ai_map(mode, true_lat, true_lng, ai_lat, ai_lng, uid="ai") # Scoring total_player = state.get("total_player", 0) + score total_ai = state.get("total_ai", 0) + ai_score round_results = [ *state.get("round_results", []), { "round": state["current"] + 1, "player_distance": dist_km, "player_score": score, "ai_distance": ai_dist_km, "ai_score": ai_score, }, ] round_table_rows, round_stats_md = build_round_table_and_stats(round_results) next_current = state["current"] + 1 questions = state["questions"] num_total = len(questions) if next_current >= num_total: # Game over round_info = f"Game Over! Total Score - Player: {int(total_player)} | AI: {int(total_ai)}" next_img = None next_player_map = player_map next_ai_map = ai_map submit_visible = False next_round_visible = False new_state = {**state, "questions": [], "current": 0} else: # Show this round's result; waiting for user to go next round round_info = f"Round {state['current'] + 1} / {num_total} Result | Click 'Next Round' to continue" # Retain image for result page next_img = get_image_or_placeholder(q["image_path"]) next_player_map = player_map next_ai_map = ai_map submit_visible = False next_round_visible = True new_state = { **state, "current": next_current, "total_player": total_player, "total_ai": total_ai, "round_results": round_results, } if next_current >= num_total: new_state = {**new_state, "total_player": total_player, "total_ai": total_ai, "round_results": round_results} return ( gr.update(value=round_info), gr.update(value=next_player_map), gr.update(value=player_result), gr.update(value=next_ai_map), gr.update(value=ai_result), gr.update(value=next_img), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(value=ai_analysis_md), gr.update(visible=submit_visible), gr.update(visible=next_round_visible), gr.update(active=submit_visible and state.get("timer_active", False)), gr.update(value=round_table_rows), gr.update(value=round_stats_md), new_state, ) def go_next_round(state: dict) -> tuple: """Proceed to next round.""" if not state.get("questions"): return ( gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(), gr.update(visible=False), gr.update(visible=False), gr.update(active=False), state, ) current = state["current"] questions = state["questions"] num_total = len(questions) mode = state.get("mode", "world") if current >= num_total: return ( gr.update(value="Game Over"), gr.update(value=build_empty_map(mode, clickable=False)), gr.update(value=""), gr.update(value=build_empty_map(mode, clickable=False)), gr.update(value=""), gr.update(value=ANALYSIS_PLACEHOLDER), gr.update(value=None), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(visible=False), gr.update(visible=False), gr.update(active=False), {**state, "questions": [], "current": 0}, ) q = questions[current] next_img = get_image_or_placeholder(q["image_path"]) round_info = f"Round {current + 1} / {num_total}" if state.get("timer_active"): time_left = int(state.get("time_limit_seconds", 0)) round_info += f" | Time left: {time_left} s" else: time_left = 0 return ( gr.update(value=round_info), gr.update(value=build_empty_map(mode, clickable=True)), gr.update(value=""), gr.update(value=build_empty_map(mode, clickable=False)), gr.update(value=""), gr.update(value=ANALYSIS_PLACEHOLDER), gr.update(value=next_img), gr.update(value=None), gr.update(value=None), gr.update(value=""), gr.update(visible=True), gr.update(visible=False), gr.update(active=state.get("timer_active", False)), {**state, "pending_lat": None, "pending_lng": None, "time_left": time_left}, ) def create_ui(): """Build Gradio UI""" warnings.filterwarnings( "ignore", message="The 'theme' parameter in the Blocks constructor will be removed in Gradio 6.0.*", category=DeprecationWarning, ) warnings.filterwarnings( "ignore", message="The 'css' parameter in the Blocks constructor will be removed in Gradio 6.0.*", category=DeprecationWarning, ) with gr.Blocks( title="GeoAgent - Geolocation Battle", theme=UI_THEME, css=UI_CSS, head=UI_HEAD, ) as demo: gr.Markdown("# GeoAgent - Geolocation Battle") gr.Markdown("Compete with AI in geolocation! Look at the image and guess the location by clicking on the map.") state = gr.State({ "questions": [], "current": 0, "mode": "world", "time_limit": "off", "time_limit_seconds": 180, "time_left": 180, "timer_active": False, "pending_lat": None, "pending_lng": None, "total_player": 0, "total_ai": 0, "round_results": [], }) game_timer = gr.Timer(value=1, active=False) with gr.Row(elem_id="control_row"): num_rounds = gr.Dropdown( choices=["3", "5", "10", "Custom"], value="5", label="Rounds", ) custom_rounds = gr.Number(label="Custom Rounds", value=5, minimum=1, precision=0, visible=False) mode = gr.Radio( choices=["China Mode", "World Mode"], value="World Mode", label="Mode", ) time_limit = gr.Dropdown( choices=["Off", "30s", "60s", "3min", "Custom"], value="3min", label="Time Limit", ) custom_time_limit = gr.Number(label="Custom Time Limit (s)", value=180, minimum=1, precision=0, visible=False) start_btn = gr.Button("Start Game", variant="primary") round_info = gr.Markdown("Click 'Start Game' to begin") with gr.Row(equal_height=False, elem_id="battle_row"): with gr.Column(scale=3, min_width=0, elem_id="player_col"): gr.Markdown("### Player Area") player_image = gr.Image( label="Current Image", type="filepath", interactive=False, elem_id="player_image", width=MEDIA_WIDTH, height=MEDIA_HEIGHT, ) player_map = Folium( value=build_empty_map("world", clickable=False), height=MEDIA_HEIGHT, elem_id="player_map", ) player_result = gr.Markdown("") with gr.Row(): submit_btn = gr.Button("Submit Answer", visible=False) next_round_btn = gr.Button("Next Round", visible=False, variant="primary") with gr.Column(scale=3, min_width=0, elem_id="ai_col"): gr.Markdown("### AI Area") ai_image = gr.Image( label="Current Image", type="filepath", interactive=False, elem_id="ai_image", width=MEDIA_WIDTH, height=MEDIA_HEIGHT, ) ai_map = Folium( value=build_empty_map("world", clickable=False), height=MEDIA_HEIGHT, elem_id="ai_map", ) ai_result = gr.Markdown("") with gr.Column(scale=4, min_width=0, elem_id="ai_analysis_col"): gr.Markdown("### Analysis Area") ai_analysis = gr.Markdown( ANALYSIS_PLACEHOLDER, elem_id="ai_analysis_box", ) with gr.Column(elem_id="round_section"): gr.Markdown("### Results per Round") round_table = gr.Dataframe( headers=ROUND_TABLE_HEADERS, value=[], row_count=(1, "dynamic"), col_count=(len(ROUND_TABLE_HEADERS), "fixed"), interactive=False, ) round_stats = gr.Markdown("Average distance & GeoScore: No data yet.") # Hide input widgets outside visible layout for UI neatness lat_input = gr.Number(label="Latitude", value=None, visible=False) lng_input = gr.Number(label="Longitude", value=None, visible=False) # Must render this to DOM for map click JS to find it coord_input = gr.Textbox(visible=True, elem_id="coord_input") # Synchronize image to AI area def sync_image(img): return img player_image.change(fn=sync_image, inputs=player_image, outputs=ai_image) num_rounds.change( fn=lambda v: gr.update(visible=str(v) == "Custom"), inputs=[num_rounds], outputs=[custom_rounds], ) time_limit.change( fn=lambda v: gr.update(visible=v == "Custom"), inputs=[time_limit], outputs=[custom_time_limit], ) start_btn.click( fn=start_game, inputs=[num_rounds, custom_rounds, mode, time_limit, custom_time_limit, state], outputs=[ round_info, player_map, ai_map, player_image, lat_input, lng_input, coord_input, player_result, ai_result, ai_analysis, submit_btn, next_round_btn, game_timer, round_table, round_stats, state, ], show_progress=False, ).then( fn=sync_image, inputs=player_image, outputs=ai_image, ) def _on_lat_lng(lat, lng, s): return on_lat_lng_change(lat, lng, s) lat_input.change( fn=_on_lat_lng, inputs=[lat_input, lng_input, state], outputs=[player_map, coord_input, state], ) lng_input.change( fn=_on_lat_lng, inputs=[lat_input, lng_input, state], outputs=[player_map, coord_input, state], ) coord_input.change( fn=on_coord_change, inputs=[coord_input, state], outputs=[state, round_info], ) game_timer.tick( fn=timer_tick, inputs=[state], outputs=[ state, round_info, player_map, player_result, ai_map, ai_result, player_image, lat_input, lng_input, coord_input, submit_btn, next_round_btn, game_timer, ], ) submit_btn.click( fn=submit_answer, inputs=[coord_input, lat_input, lng_input, state], outputs=[ round_info, player_map, player_result, ai_map, ai_result, player_image, lat_input, lng_input, coord_input, ai_analysis, submit_btn, next_round_btn, game_timer, round_table, round_stats, state, ], show_progress=False, ).then( fn=sync_image, inputs=player_image, outputs=ai_image, ) next_round_btn.click( fn=go_next_round, inputs=[state], outputs=[ round_info, player_map, player_result, ai_map, ai_result, ai_analysis, player_image, lat_input, lng_input, coord_input, submit_btn, next_round_btn, game_timer, state, ], show_progress=False, ).then( fn=sync_image, inputs=player_image, outputs=ai_image, ) return demo demo = create_ui() if __name__ == "__main__": demo.launch()