Spaces:
Running
Running
File size: 40,113 Bytes
3b90ae7 |
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 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 |
from __future__ import annotations
import csv
import datetime as dt
import math
import random
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
if not hasattr(np, "bool"): # pandas<2 compatibility on numpy>=2
np.bool = bool # type: ignore[attr-defined]
EXAMPLE_PROMPT = "## Input-Output Example. When you submit an answer, the following lines will display the last input and the corresponding correct answer."
LAST_PROMPT = "## The input and the correct output of the last question."
import pandas as pd
from PIL import Image, ImageDraw
import plotly.graph_objects as go
def _create_plotly_map(marker_lat: Optional[float] = None, marker_lon: Optional[float] = None):
"""Create a Plotly map figure (OpenStreetMap) with optional visible marker.
Also adds a dense transparent grid of points so plotly_click 能在任意位置触发。
"""
# Dense transparent grid points to capture clicks anywhere on the map
grid_lats: List[float] = []
grid_lons: List[float] = []
# 1° 网格,尽量保证任意点击都能命中到一个数据点
for lat in range(-90, 91, 1):
for lon in range(-180, 181, 1):
grid_lats.append(float(lat))
grid_lons.append(float(lon))
fig = go.Figure()
fig.add_trace(
go.Scattermap(
lat=grid_lats,
lon=grid_lons,
mode="markers",
marker=go.scattermap.Marker(size=40, color="#000"),
opacity=0.01, # 透明但可点中
hoverinfo="skip",
name="grid",
)
)
if marker_lat is not None and marker_lon is not None:
fig.add_trace(
go.Scattermap(
lat=[marker_lat],
lon=[marker_lon],
mode="markers",
marker=go.scattermap.Marker(size=16, color="red"),
hoverinfo="text",
text=["Your guess"],
name="marker",
)
)
fig.update_layout(
map=dict(style="open-street-map", center=dict(lat=20, lon=0), zoom=2),
margin=dict(l=0, r=0, t=0, b=0),
height=600,
clickmode="event+select", # 明确开启点击事件
dragmode="pan",
)
return fig
import gradio as gr
BASE_DIR = Path(__file__).resolve().parent
DATASET_PATH = BASE_DIR / "data" / "dataset.csv"
AUDIO_BASE_DIR = BASE_DIR / "data" / "audios"
LOG_PATH = BASE_DIR / "player_runs.csv"
RECENT_COLUMNS = ["timestamp", "player_id", "question_id", "distance_km"]
@dataclass
class Sample:
question_id: str
audio_path: Path
longitude: float
latitude: float
city: str
country: str
continent: str
description: str
title: str
def _load_samples() -> List[Sample]:
if not DATASET_PATH.exists():
raise FileNotFoundError(f"Dataset not found at {DATASET_PATH}")
df = pd.read_csv(DATASET_PATH)
start_idx = int(len(df) * 0.9)
test_df = df.iloc[start_idx:].reset_index(drop=True)
samples: List[Sample] = []
missing_audio = 0
missing_coords = 0
for row in test_df.itertuples():
audio_path = AUDIO_BASE_DIR / getattr(row, "mp3name")
longitude = getattr(row, "longitude")
latitude = getattr(row, "latitude")
if math.isnan(longitude) or math.isnan(latitude):
missing_coords += 1
continue
if not audio_path.exists():
missing_audio += 1
continue
samples.append(
Sample(
question_id=str(getattr(row, "key")),
audio_path=audio_path,
longitude=float(longitude),
latitude=float(latitude),
city=str(getattr(row, "city", "") or ""),
country=str(getattr(row, "country", "") or ""),
continent=str(getattr(row, "continent", "") or ""),
description=str(getattr(row, "description", "") or ""),
title=str(getattr(row, "title", "") or ""),
)
)
if not samples:
raise RuntimeError("No playable samples were found in the test split.")
if missing_audio:
print(f"[game_app] Skipped {missing_audio} samples because audio files are missing.")
if missing_coords:
print(f"[game_app] Skipped {missing_coords} samples because coordinates are missing.")
return samples
def _get_example_sample() -> Optional[Sample]:
"""Get the example sample (aporee_45102_51242) for the first round."""
if not DATASET_PATH.exists():
return None
df = pd.read_csv(DATASET_PATH)
example_row = df[df["key"] == "aporee_45102_51242"]
if example_row.empty:
return None
row = example_row.iloc[0]
audio_path = AUDIO_BASE_DIR / row["mp3name"]
if not audio_path.exists():
return None
return Sample(
question_id=str(row["key"]),
audio_path=audio_path,
longitude=float(row["longitude"]),
latitude=float(row["latitude"]),
city=str(row.get("city", "") or ""),
country=str(row.get("country", "") or ""),
continent=str(row.get("continent", "") or ""),
description=str(row.get("description", "") or ""),
title=str(row.get("title", "") or ""),
)
SAMPLES: List[Sample] = _load_samples()
def _random_queue() -> List[int]:
queue = list(range(len(SAMPLES)))
random.shuffle(queue)
return queue
def _latlon_to_text(lat: float, lon: float) -> str:
return f"Selected latitude {lat:.3f}°, longitude {lon:.3f}°"
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lambda = math.radians(lon2 - lon1)
a = math.sin(d_phi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2.0) ** 2
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
return r * c
def _base_map_array() -> np.ndarray:
return np.array(BASE_MAP_IMAGE)
def _on_coords_change(lon_text: str, lat_text: str) -> None:
"""Log coordinate changes to backend terminal for debugging."""
try:
lon = float(lon_text) if lon_text else None
lat = float(lat_text) if lat_text else None
print(f"[PLOT CLICK] lon={lon} lat={lat}")
except Exception as e:
print(f"[PLOT CLICK] parse error: {e} | lon_text={lon_text} lat_text={lat_text}")
def _prepare_clip_info(sample: Sample, round_idx: int) -> str:
intro_lines = [
f"## Question ID: {sample.question_id}. Now it's your turn to play."
]
return "\n\n".join(intro_lines)
def _append_log(entry: Dict[str, object]) -> None:
write_header = not LOG_PATH.exists()
with LOG_PATH.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(entry.keys()))
if write_header:
writer.writeheader()
writer.writerow(entry)
def _load_recent_runs(limit: int = 5) -> List[Dict[str, object]]:
if not LOG_PATH.exists():
return []
rows = []
with LOG_PATH.open("r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(row)
return rows[-limit:]
def _format_recent_rows(rows: List[Dict[str, object]]) -> List[List[object]]:
formatted: List[List[object]] = []
for row in rows:
timestamp_str = row.get("timestamp", "")
if timestamp_str:
try:
# Parse ISO format timestamp and format as readable string
dt_obj = dt.datetime.fromisoformat(str(timestamp_str).replace('Z', '+00:00'))
formatted_timestamp = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, AttributeError):
formatted_timestamp = str(timestamp_str)
else:
formatted_timestamp = ""
formatted_row = [formatted_timestamp]
formatted_row.extend([row.get(col, "") for col in RECENT_COLUMNS[1:]])
formatted.append(formatted_row)
return formatted
def _format_previous_answer(sample: Optional[Sample], is_example: bool = False, distance_km: Optional[float] = None, guess_lat: Optional[float] = None, guess_lon: Optional[float] = None) -> str:
"""Format the previous answer display with location info and OpenStreetMap link."""
if sample is None:
return ""
lat = sample.latitude
lon = sample.longitude
city = sample.city or "Unknown city"
country = sample.country or "Unknown country"
continent = sample.continent or "Unknown continent"
# Create OpenStreetMap link
osm_link = f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=15"
lines = [
f"**Location:** {city}, {country}, {continent}",
f"**Coordinates:** {lat:.6f}°, {lon:.6f}°",
]
# Add player's prediction for non-example cases
if not is_example and guess_lat is not None and guess_lon is not None:
lines.append(f"**Your Prediction:** {guess_lat:.6f}°, {guess_lon:.6f}°")
# Add error distance for non-example cases
if not is_example and distance_km is not None:
lines.append(f"**Error distance:** {distance_km:.1f} km")
lines.append(f"**OpenStreetMap:** [View on map]({osm_link})")
# Add example explanation if it's the example sample
if is_example and sample.question_id == "aporee_45102_51242":
lines.append("")
lines.append("**Explanation:** I can clearly identify the rolling of polyurethane wheels on concrete, the sharp 'pop' of a skateboard's tail hitting the ground to initiate a trick. This firmly places the recording at a skatepark. The background ambiance is that of a bustling city, characterized by the constant, though somewhat distant, hum of traffic. Furthermore, there are several instances of spoken English. The speakers have a clear North American English accent. The combination points to a major city in the United States with a prominent skate culture. New York City is one of the most iconic locations for street skateboarding globally.")
return "\n\n".join(lines)
def initialize_round() -> Tuple[Dict[str, object], Dict[str, Optional[float]], str, str, str, str, List[List[object]], str, str, str, str, str]:
queue = _random_queue()
current_index = queue.pop()
state = {
"queue": queue,
"current_index": current_index,
"round": 1,
"previous_sample": None, # No previous sample for first round
"is_example": True, # First round shows example
}
current_sample = SAMPLES[current_index]
audio_value = str(current_sample.audio_path)
clip_md = _prepare_clip_info(current_sample, state["round"])
prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
guess_state = {"lat": None, "lon": None, "pixel": None}
recent_runs = _format_recent_rows(_load_recent_runs())
# For first round, show example sample
example_sample = _get_example_sample()
previous_audio = str(example_sample.audio_path) if example_sample else None
previous_answer = _format_previous_answer(example_sample, is_example=True) if example_sample else ""
previous_title = EXAMPLE_PROMPT if example_sample else ""
return state, guess_state, audio_value, clip_md, _create_plotly_map(), prompt_text, recent_runs, "", "", previous_audio or "", previous_answer, previous_title
def _next_sample(state: Dict[str, object]) -> Sample:
queue: List[int] = state["queue"]
if not queue:
queue.extend(_random_queue())
next_index = queue.pop()
state["current_index"] = next_index
state["round"] = state.get("round", 1) + 1
return SAMPLES[next_index]
def _ensure_guess_state(state: Optional[Dict[str, Optional[float]]]) -> Dict[str, Optional[float]]:
if not isinstance(state, dict):
return {"lat": None, "lon": None, "pixel": None}
return {
"lat": state.get("lat"),
"lon": state.get("lon"),
"pixel": state.get("pixel"),
}
def submit_guess(
player_id: str,
game_state: Optional[Dict[str, object]],
guess_state: Optional[Dict[str, Optional[float]]],
longitude: str,
latitude: str,
) -> Tuple[
Dict[str, object],
Dict[str, Optional[float]],
str,
str,
str,
str,
List[List[object]],
str,
str,
str,
str,
str,
]:
if game_state is None:
# Re-initialize if state is lost
return initialize_round()
guess_state = _ensure_guess_state(guess_state)
player_id = (player_id or "").strip()
if not player_id:
message = "Please enter your player ID before submitting."
current_sample = SAMPLES[game_state["current_index"]]
clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
prompt_text = message
previous_sample = game_state.get("previous_sample")
previous_audio = str(previous_sample.audio_path) if previous_sample else ""
previous_distance = game_state.get("previous_distance_km")
previous_guess_lat = game_state.get("previous_guess_lat")
previous_guess_lon = game_state.get("previous_guess_lon")
previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance, guess_lat=previous_guess_lat, guess_lon=previous_guess_lon) if previous_sample else ""
is_example = game_state.get("is_example", False)
previous_title = EXAMPLE_PROMPT if is_example else ""
return (
game_state,
guess_state,
str(current_sample.audio_path),
clip_md,
_create_plotly_map(),
prompt_text,
_format_recent_rows(_load_recent_runs()),
longitude,
latitude,
previous_audio,
previous_answer,
previous_title,
)
# Parse longitude and latitude from text inputs
try:
longitude = longitude.strip() if longitude else ""
latitude = latitude.strip() if latitude else ""
if not longitude or not latitude:
message = "Please enter both longitude and latitude, or click on the map to select a location."
current_sample = SAMPLES[game_state["current_index"]]
clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
prompt_text = message
previous_sample = game_state.get("previous_sample")
previous_audio = str(previous_sample.audio_path) if previous_sample else ""
previous_distance = game_state.get("previous_distance_km")
previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance) if previous_sample else ""
is_example = game_state.get("is_example", False)
previous_title = EXAMPLE_PROMPT if is_example else ""
return (
game_state,
guess_state,
str(current_sample.audio_path),
clip_md,
gr.update(),
prompt_text,
_format_recent_rows(_load_recent_runs()),
longitude,
latitude,
previous_audio,
previous_answer,
previous_title,
)
guess_lon = float(longitude)
guess_lat = float(latitude)
# Validate ranges
if not (-180 <= guess_lon <= 180):
message = "Longitude must be between -180 and 180."
current_sample = SAMPLES[game_state["current_index"]]
clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
prompt_text = message
previous_sample = game_state.get("previous_sample")
previous_audio = str(previous_sample.audio_path) if previous_sample else ""
previous_distance = game_state.get("previous_distance_km")
previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance) if previous_sample else ""
is_example = game_state.get("is_example", False)
previous_title = EXAMPLE_PROMPT if is_example else ""
return (
game_state,
guess_state,
str(current_sample.audio_path),
clip_md,
gr.update(),
prompt_text,
_format_recent_rows(_load_recent_runs()),
longitude,
latitude,
previous_audio,
previous_answer,
previous_title,
)
if not (-90 <= guess_lat <= 90):
message = "Latitude must be between -90 and 90."
current_sample = SAMPLES[game_state["current_index"]]
clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
prompt_text = message
previous_sample = game_state.get("previous_sample")
previous_audio = str(previous_sample.audio_path) if previous_sample else ""
previous_distance = game_state.get("previous_distance_km")
previous_guess_lat = game_state.get("previous_guess_lat")
previous_guess_lon = game_state.get("previous_guess_lon")
previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance, guess_lat=previous_guess_lat, guess_lon=previous_guess_lon) if previous_sample else ""
is_example = game_state.get("is_example", False)
previous_title = EXAMPLE_PROMPT if is_example else LAST_PROMPT
return (
game_state,
guess_state,
str(current_sample.audio_path),
clip_md,
gr.update(),
prompt_text,
_format_recent_rows(_load_recent_runs()),
longitude,
latitude,
previous_audio,
previous_answer,
previous_title,
)
except ValueError:
message = "Invalid coordinates. Please enter valid numbers."
current_sample = SAMPLES[game_state["current_index"]]
clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
prompt_text = message
previous_sample = game_state.get("previous_sample")
previous_audio = str(previous_sample.audio_path) if previous_sample else ""
previous_distance = game_state.get("previous_distance_km")
previous_guess_lat = game_state.get("previous_guess_lat")
previous_guess_lon = game_state.get("previous_guess_lon")
previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance, guess_lat=previous_guess_lat, guess_lon=previous_guess_lon) if previous_sample else ""
is_example = game_state.get("is_example", False)
previous_title = EXAMPLE_PROMPT if is_example else LAST_PROMPT
return (
game_state,
guess_state,
str(current_sample.audio_path),
clip_md,
_create_plotly_map(),
prompt_text,
_format_recent_rows(_load_recent_runs()),
longitude,
latitude,
previous_audio,
previous_answer,
previous_title,
)
current_sample = SAMPLES[game_state["current_index"]]
true_lat = current_sample.latitude
true_lon = current_sample.longitude
distance_km = _haversine(true_lat, true_lon, guess_lat, guess_lon)
reveal_lines = [
f"Real location: {current_sample.city or 'Unknown city'}, {current_sample.country or 'Unknown country'} ({true_lat:.3f}°, {true_lon:.3f}°)",
f"Your guess: ({guess_lat:.3f}°, {guess_lon:.3f}°)",
f"Error distance: {distance_km:.1f} km",
]
log_entry = {
"timestamp": dt.datetime.utcnow().isoformat(),
"player_id": player_id,
"question_id": current_sample.question_id,
"audio_path": str(current_sample.audio_path),
"guess_latitude": guess_lat,
"guess_longitude": guess_lon,
"true_latitude": true_lat,
"true_longitude": true_lon,
"distance_km": round(distance_km, 3),
"city": current_sample.city,
"country": current_sample.country,
"continent": current_sample.continent,
"title": current_sample.title,
"description": current_sample.description,
}
_append_log(log_entry)
# Save current sample and distance as previous for next round
game_state["previous_sample"] = current_sample
game_state["previous_distance_km"] = distance_km
game_state["previous_guess_lat"] = guess_lat
game_state["previous_guess_lon"] = guess_lon
game_state["is_example"] = False # After first round, it's no longer example
next_sample = _next_sample(game_state)
audio_value = str(next_sample.audio_path)
clip_md = _prepare_clip_info(next_sample, game_state["round"])
new_guess_state = {"lat": None, "lon": None, "pixel": None}
prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
recent_runs = _format_recent_rows(_load_recent_runs())
# Format previous answer (current sample becomes previous for next round)
previous_audio = str(current_sample.audio_path)
previous_distance = game_state.get("previous_distance_km")
previous_guess_lat = game_state.get("previous_guess_lat")
previous_guess_lon = game_state.get("previous_guess_lon")
previous_answer = _format_previous_answer(current_sample, is_example=False, distance_km=previous_distance, guess_lat=previous_guess_lat, guess_lon=previous_guess_lon)
previous_title = LAST_PROMPT
return (
game_state,
new_guess_state,
audio_value,
clip_md,
gr.update(),
prompt_text,
recent_runs,
"", # Reset longitude input
"", # Reset latitude input
previous_audio,
previous_answer,
previous_title,
)
custom_css = """
h1 {
font-size: 2.5rem !important;
font-weight: 700 !important;
margin-bottom: 1rem !important;
text-align: center !important;
}
.intro-text {
font-size: 1.1rem !important;
line-height: 1.6 !important;
margin-bottom: 1.5rem !important;
color: #555 !important;
text-align: center !important;
}
/* Apply size restrictions to regular (non-fullscreen) image */
.gradio-image:not([class*="fullscreen"]) {
max-width: 100% !important;
max-height: none !important;
height: auto !important;
margin: 0 auto !important;
display: block !important;
}
.gradio-image:not([class*="fullscreen"]) > div {
max-width: 100% !important;
max-height: none !important;
height: auto !important;
margin: 0 auto !important;
display: block !important;
}
.gradio-image:not([class*="fullscreen"]) iframe,
.gradio-image:not([class*="fullscreen"]) div[id^="map-"] {
max-width: 100% !important;
max-height: none !important;
height: 800px !important;
min-height: 800px !important;
width: 100% !important;
margin: 0 auto !important;
display: block !important;
}
.gradio-image:not([class*="fullscreen"]) img {
max-width: 100% !important;
max-height: 600px !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
margin: 0 auto !important;
}
/* Ensure map plot has consistent height */
.gradio-plot {
min-height: 500px !important;
height: 500px !important;
}
/* Make sure the map column aligns properly */
.gradio-row > .gradio-column:first-child {
display: flex !important;
flex-direction: column !important;
}
.gradio-row > .gradio-column:first-child > * {
flex-shrink: 0 !important;
}
/* Allow fullscreen modal to override size restrictions - higher specificity */
/* Target common Gradio fullscreen containers */
div[class*="modal"] .gradio-image,
div[class*="modal"] .gradio-image > div,
div[class*="modal"] .gradio-image img,
div[class*="fullscreen"] .gradio-image,
div[class*="fullscreen"] .gradio-image > div,
div[class*="fullscreen"] .gradio-image img,
div[id*="lightbox"] .gradio-image,
div[id*="lightbox"] .gradio-image img,
.gradio-image[class*="fullscreen"],
.gradio-image[class*="fullscreen"] > div,
.gradio-image[class*="fullscreen"] img {
max-width: none !important;
max-height: none !important;
width: 100% !important;
height: 100% !important;
}
.selection-text {
font-size: 1.15rem !important;
text-align: left !important;
color: #666 !important;
margin: 0.5rem 0 !important;
}
.selection-text * {
font-size: 1.15rem !important;
}
.feedback-box {
font-size: 1.15rem !important;
line-height: 1.6 !important;
}
.feedback-box p,
.feedback-box div,
.feedback-box span {
font-size: 1.15rem !important;
line-height: 1.6 !important;
}
.clip-info {
font-size: 1.1rem !important;
}
.clip-info p,
.clip-info div,
.clip-info span {
font-size: 1.1rem !important;
}
.clip-info label {
font-size: 1.1rem !important;
font-weight: 600 !important;
}
label {
font-size: 1rem !important;
font-weight: 500 !important;
}
.form-text input {
font-size: 0.95rem !important;
}
/* Table label styling - only target the label, not table content */
.gradio-dataframe label,
[data-testid="dataframe"] label,
label[for*="recent"],
.form-group label {
font-size: 1.15rem !important;
font-weight: 600 !important;
}
"""
with gr.Blocks(title="Audio Geo-Localization Game", theme=gr.themes.Soft(), css=custom_css) as demo:
gr.Markdown("# Audio Geo-Localization Game")
gr.HTML('<p class="intro-text">Welcome to the Audio Geo-Localization Game. Listen to an ambient audio clip, then guess where it was recorded by clicking on the world map. Submit to see the true location and how close you came.</p>')
game_state = gr.State()
guess_state = gr.State()
# New row for previous question audio and answer with title
previous_title_display = gr.Markdown(value="", elem_classes=["clip-info"])
with gr.Row():
with gr.Column(scale=1):
previous_audio_player = gr.Audio(
label="Previous audio clip",
autoplay=False,
interactive=False,
streaming=False,
visible=True
)
with gr.Column(scale=1):
previous_answer_display = gr.Markdown(
label="Previous answer",
value="",
elem_classes=["clip-info"]
)
# Add separator/divider between previous and current round
gr.Markdown("---")
clip_info = gr.Markdown(label="Clip details", elem_classes=["clip-info"])
with gr.Row():
with gr.Column(scale=2):
map_component = gr.Plot(
value=_create_plotly_map(),
label="World map (click to set your guess)",
elem_classes=["gradio-image"]
)
with gr.Column(scale=1):
player_id = gr.Textbox(label="Player ID", placeholder="Enter an identifier so scores can be tracked", elem_classes=["form-text"])
audio_player = gr.Audio(label="Mystery audio clip", autoplay=False, interactive=False, streaming=False)
selection_text = gr.Markdown("Click once on the map to pick your guess. The marker will update to your last selection.", elem_classes=["selection-text"])
with gr.Row():
longitude_input = gr.Textbox(
label="Longitude",
placeholder="Enter longitude or click map",
elem_classes=["form-text"],
elem_id="lon_input"
)
latitude_input = gr.Textbox(
label="Latitude",
placeholder="Enter latitude or click map",
elem_classes=["form-text"],
elem_id="lat_input"
)
submit_button = gr.Button("Submit Guess", variant="primary")
recent_table = gr.Dataframe(
headers=[
"timestamp",
"player_id",
"question_id",
"distance_km",
],
datatype=["str", "str", "str", "number"],
value=[],
interactive=False,
label="Recent submissions (latest last)",
wrap=True,
)
demo.load(
initialize_round,
inputs=None,
outputs=[game_state, guess_state, audio_player, clip_info, map_component, selection_text, recent_table, longitude_input, latitude_input, previous_audio_player, previous_answer_display, previous_title_display]
)
# 后端监听文本框变化,在终端打印坐标(便于你观察点击数据是否到达后端)
longitude_input.change(_on_coords_change, inputs=[longitude_input, latitude_input], outputs=[])
latitude_input.change(_on_coords_change, inputs=[longitude_input, latitude_input], outputs=[])
# Attach plotly_click to update the lon/lat textboxes (no Python roundtrip)
plotly_click_js = r"""
() => {
console.log('[PLOTLY SETUP] Starting plotly click handler setup...');
const updateInputs = (lat, lon) => {
console.log('[PLOTLY CLICK] Received coordinates:', {lat, lon});
// Method 1: Try Gradio component API
if (window.gradio_app) {
try {
const components = window.gradio_app.__components || {};
Object.keys(components).forEach(key => {
const comp = components[key];
if (comp && comp.props && comp.props.elem_id) {
if (comp.props.elem_id === 'lon_input' && comp.component) {
comp.component.value = Number(lon).toFixed(6);
comp.component.dispatch('change');
console.log('[PLOTLY CLICK] Updated longitude via Gradio API');
}
if (comp.props.elem_id === 'lat_input' && comp.component) {
comp.component.value = Number(lat).toFixed(6);
comp.component.dispatch('change');
console.log('[PLOTLY CLICK] Updated latitude via Gradio API');
}
}
});
} catch (e) {
console.log('[PLOTLY CLICK] Gradio API method failed:', e);
}
}
// Method 2: Find by label text, then find input nearby
const allLabels = Array.from(document.querySelectorAll('label'));
allLabels.forEach(label => {
const labelText = label.textContent.toLowerCase();
if (labelText.includes('longitude')) {
const container = label.closest('div, form, .gr-box');
if (container) {
const input = container.querySelector('input, textarea');
if (input) {
input.value = Number(lon).toFixed(6);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
console.log('[PLOTLY CLICK] Updated longitude via label:', input.value);
}
}
}
if (labelText.includes('latitude')) {
const container = label.closest('div, form, .gr-box');
if (container) {
const input = container.querySelector('input, textarea');
if (input) {
input.value = Number(lat).toFixed(6);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
console.log('[PLOTLY CLICK] Updated latitude via label:', input.value);
}
}
}
});
// Method 3: Try finding by placeholder in all possible elements
const allElements = document.querySelectorAll('input, textarea, [contenteditable="true"]');
allElements.forEach(el => {
const ph = (el.placeholder || el.getAttribute('placeholder') || '').toLowerCase();
if (ph.includes('longitude') && !ph.includes('latitude')) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.value = Number(lon).toFixed(6);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
console.log('[PLOTLY CLICK] Updated longitude via placeholder:', el.value);
}
}
if (ph.includes('latitude') && !ph.includes('longitude')) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.value = Number(lat).toFixed(6);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
console.log('[PLOTLY CLICK] Updated latitude via placeholder:', el.value);
}
}
});
console.log('[PLOTLY CLICK] Update attempts completed');
};
const wirePlotly = () => {
const plots = document.querySelectorAll('.js-plotly-plot');
console.log('[PLOTLY SETUP] Found', plots.length, 'plotly plots');
plots.forEach((plotDiv, idx) => {
if (plotDiv.dataset._wired === '1') {
console.log('[PLOTLY SETUP] Plot', idx, 'already wired');
return;
}
console.log('[PLOTLY SETUP] Wiring plot', idx);
plotDiv.dataset._wired = '1';
// Listen for plotly_click DOM event (Plotly fires this as a custom event)
plotDiv.addEventListener('plotly_click', (e) => {
console.log('[PLOTLY CLICK] Event received:', e);
console.log('[PLOTLY CLICK] e.points:', e.points);
// Plotly click event data is directly on the event object
let points = e.points;
if (!points && e.detail) {
points = e.detail.points;
}
if (points && points.length > 0) {
const pt = points[0];
console.log('[PLOTLY CLICK] Point data:', pt);
const lat = pt.lat;
const lon = pt.lon;
console.log('[PLOTLY CLICK] Extracted lat:', lat, 'lon:', lon);
if (lat !== undefined && lon !== undefined && !isNaN(lat) && !isNaN(lon)) {
updateInputs(lat, lon);
} else {
console.error('[PLOTLY CLICK] Invalid coordinates:', {lat, lon, point: pt});
}
} else {
console.error('[PLOTLY CLICK] No points found in event');
}
});
// Fallback: Listen for raw click events and calculate coordinates from map config
plotDiv.addEventListener('click', (e) => {
console.log('[PLOTLY CLICK] Raw click event:', e);
try {
if (!plotDiv._fullLayout || !plotDiv._fullLayout.map) {
console.error('[PLOTLY CLICK] map layout not available');
return;
}
const mapConfig = plotDiv._fullLayout.map;
const center = mapConfig.center || {lat: 0, lon: 0};
const zoom = mapConfig.zoom || 2;
console.log('[PLOTLY CLICK] Map config:', {center, zoom});
// Get click position relative to plot div
const rect = plotDiv.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
console.log('[PLOTLY CLICK] Click position:', {x, y, width, height});
// Convert pixel coordinates to lat/lon using Web Mercator projection
// Simplified calculation based on map center and zoom
const normalizedX = (x / width) - 0.5; // -0.5 to 0.5
const normalizedY = 0.5 - (y / height); // 0.5 to -0.5 (flipped Y)
// Calculate pixel-to-degree conversion at current zoom
// At zoom level z, one tile = 256 pixels, world = 2^z tiles wide
const tilesAtZoom = Math.pow(2, zoom);
const pixelsPerTile = 256;
const worldWidthPixels = tilesAtZoom * pixelsPerTile;
const worldWidthDegrees = 360;
const degreesPerPixel = worldWidthDegrees / worldWidthPixels;
// Longitude: linear mapping
const lon = center.lon + (normalizedX * width * degreesPerPixel);
// Latitude: Web Mercator inverse
// Mercator Y at center: y_center = ln(tan(π/4 + center_lat/2))
// Click Y offset in pixels: dy = normalizedY * height
// Convert to Mercator units and then to latitude
const mercatorYCenter = Math.log(Math.tan(Math.PI / 4 + (center.lat * Math.PI / 180) / 2));
const dyMercator = normalizedY * height * degreesPerPixel / (180 / Math.PI);
const mercatorYClick = mercatorYCenter + dyMercator;
const latRad = 2 * Math.atan(Math.exp(mercatorYClick)) - Math.PI / 2;
const lat = latRad * 180 / Math.PI;
console.log('[PLOTLY CLICK] Calculated coordinates - lat:', lat, 'lon:', lon);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
updateInputs(lat, lon);
} else {
console.error('[PLOTLY CLICK] Invalid calculated coordinates:', {lat, lon});
}
} catch (err) {
console.error('[PLOTLY CLICK] Error in click handler:', err);
console.error('[PLOTLY CLICK] Error stack:', err.stack);
}
});
});
};
// Initial wire with retry logic
let retryCount = 0;
const maxRetries = 10;
const tryWire = () => {
const plots = document.querySelectorAll('.js-plotly-plot');
if (plots.length === 0 && retryCount < maxRetries) {
retryCount++;
console.log('[PLOTLY SETUP] No plots found, retry', retryCount, '/', maxRetries);
setTimeout(tryWire, 500);
return;
}
wirePlotly();
console.log('[PLOTLY SETUP] Initial wire complete, found', plots.length, 'plots');
};
// Wait for Plotly library to load
if (window.Plotly) {
console.log('[PLOTLY SETUP] Plotly library found immediately');
setTimeout(tryWire, 1500);
} else {
console.warn('[PLOTLY SETUP] Plotly library not found, waiting...');
let plotlyWaitCount = 0;
const waitForPlotly = () => {
if (window.Plotly) {
console.log('[PLOTLY SETUP] Plotly library loaded after', plotlyWaitCount * 200, 'ms');
setTimeout(tryWire, 1000);
} else if (plotlyWaitCount < 20) {
plotlyWaitCount++;
setTimeout(waitForPlotly, 200);
} else {
console.error('[PLOTLY SETUP] Plotly library not found after 4 seconds, proceeding anyway');
setTimeout(tryWire, 1000);
}
};
waitForPlotly();
}
// Watch for new plots
const obs = new MutationObserver(() => {
setTimeout(wirePlotly, 500);
});
obs.observe(document.body, { childList: true, subtree: true });
}
"""
demo.load(None, js=plotly_click_js)
submit_button.click(
submit_guess,
inputs=[player_id, game_state, guess_state, longitude_input, latitude_input],
outputs=[game_state, guess_state, audio_player, clip_info, map_component, selection_text, recent_table, longitude_input, latitude_input, previous_audio_player, previous_answer_display, previous_title_display],
)
if __name__ == "__main__":
# Allow Gradio to serve audio files that live outside the CWD by whitelisting the project dir
demo.launch()
|