RisingZhang commited on
Commit
a73d0ab
·
1 Parent(s): c6b93b1

add new ver

Browse files
Files changed (5) hide show
  1. app.py +604 -72
  2. appPre.py +618 -0
  3. player_runs.csv +6 -0
  4. requirements.txt +12 -4
  5. requirements2.txt +119 -0
app.py CHANGED
@@ -13,18 +13,68 @@ import numpy as np
13
  if not hasattr(np, "bool"): # pandas<2 compatibility on numpy>=2
14
  np.bool = bool # type: ignore[attr-defined]
15
 
 
 
16
  import pandas as pd
17
  from PIL import Image, ImageDraw
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
 
 
 
 
 
 
 
 
19
  import gradio as gr
 
20
 
21
 
22
- BASE_DIR = Path(__file__).resolve().parent
23
  DATASET_PATH = BASE_DIR / "data" / "dataset.csv"
24
  AUDIO_BASE_DIR = BASE_DIR / "data" / "audios"
25
- ASSETS_DIR = BASE_DIR / "assets"
26
  MAP_IMAGE_PATH = ASSETS_DIR / "world_map.png"
27
- LOG_PATH = BASE_DIR / "player_runs.csv"
28
  RECENT_COLUMNS = ["timestamp", "player_id", "question_id", "distance_km"]
29
 
30
 
@@ -91,6 +141,36 @@ def _load_samples() -> List[Sample]:
91
  return samples
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  SAMPLES: List[Sample] = _load_samples()
95
  BASE_MAP_IMAGE = Image.open(MAP_IMAGE_PATH).convert("RGB")
96
  MAP_WIDTH, MAP_HEIGHT = BASE_MAP_IMAGE.size
@@ -145,9 +225,21 @@ def _base_map_array() -> np.ndarray:
145
  return np.array(BASE_MAP_IMAGE)
146
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def _prepare_clip_info(sample: Sample, round_idx: int) -> str:
149
  intro_lines = [
150
- f"**Round:** {round_idx}"
151
  ]
152
  return "\n\n".join(intro_lines)
153
 
@@ -193,14 +285,53 @@ def _format_recent_rows(rows: List[Dict[str, object]]) -> List[List[object]]:
193
  return formatted
194
 
195
 
196
- def initialize_round() -> Tuple[Dict[str, object], Dict[str, Optional[float]], str, str, np.ndarray, str, List[List[object]], str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  queue = _random_queue()
198
  current_index = queue.pop()
199
 
200
  state = {
201
- "queue": queue,
202
  "current_index": current_index,
203
  "round": 1,
 
 
204
  }
205
 
206
  current_sample = SAMPLES[current_index]
@@ -209,8 +340,14 @@ def initialize_round() -> Tuple[Dict[str, object], Dict[str, Optional[float]], s
209
  prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
210
  guess_state = {"lat": None, "lon": None, "pixel": None}
211
  recent_runs = _format_recent_rows(_load_recent_runs())
 
 
 
 
 
 
212
 
213
- return state, guess_state, audio_value, clip_md, _base_map_array(), prompt_text, recent_runs, "", ""
214
 
215
 
216
  def _next_sample(state: Dict[str, object]) -> Sample:
@@ -238,44 +375,79 @@ def handle_map_click(
238
  evt: gr.SelectData,
239
  current_guess_state: Optional[Dict[str, Optional[float]]],
240
  ) -> Tuple[np.ndarray, str, Dict[str, Optional[float]], str, str]:
 
241
  guess_state = _ensure_guess_state(current_guess_state)
 
242
  if evt is None:
243
  return _base_map_array(), "Unable to read selection. Please try again.", guess_state, "", ""
244
 
245
- index = getattr(evt, "index", None)
246
- value = getattr(evt, "value", None)
247
-
248
- x = y = None
249
- if isinstance(index, (tuple, list)) and len(index) >= 2:
250
- x, y = index[0], index[1]
251
- elif isinstance(index, dict):
252
- x = index.get("x")
253
- y = index.get("y")
254
-
255
- if (x is None or y is None) and isinstance(value, dict):
256
- x = value.get("x", x)
257
- y = value.get("y", y)
258
- elif (x is None or y is None) and isinstance(value, (tuple, list)) and len(value) >= 2:
259
- if x is None:
260
- x = value[0]
261
- if y is None:
262
- y = value[1]
263
-
264
- if x is None or y is None:
265
- return _base_map_array(), "Unable to read selection. Please try again.", guess_state, "", ""
266
-
267
- x = int(x)
268
- y = int(y)
269
-
270
- lat, lon = _pixel_to_latlon(x, y)
271
- guess_state = {"lat": lat, "lon": lon, "pixel": (x, y)}
272
- image_with_marker = _map_with_marker(x, y)
273
- return image_with_marker, _latlon_to_text(lat, lon), guess_state, f"{lon:.6f}", f"{lat:.6f}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
 
276
  def submit_guess(
277
  player_id: str,
278
- game_state: Dict[str, object],
279
  guess_state: Optional[Dict[str, Optional[float]]],
280
  longitude: str,
281
  latitude: str,
@@ -284,12 +456,19 @@ def submit_guess(
284
  Dict[str, Optional[float]],
285
  str,
286
  str,
287
- np.ndarray,
288
  str,
289
  List[List[object]],
290
  str,
291
  str,
 
 
 
292
  ]:
 
 
 
 
293
  guess_state = _ensure_guess_state(guess_state)
294
  player_id = (player_id or "").strip()
295
  if not player_id:
@@ -297,16 +476,27 @@ def submit_guess(
297
  current_sample = SAMPLES[game_state["current_index"]]
298
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
299
  prompt_text = message
 
 
 
 
 
 
 
 
300
  return (
301
  game_state,
302
  guess_state,
303
  str(current_sample.audio_path),
304
  clip_md,
305
- _base_map_array(),
306
  prompt_text,
307
  _format_recent_rows(_load_recent_runs()),
308
  longitude,
309
  latitude,
 
 
 
310
  )
311
 
312
  # Parse longitude and latitude from text inputs
@@ -319,16 +509,25 @@ def submit_guess(
319
  current_sample = SAMPLES[game_state["current_index"]]
320
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
321
  prompt_text = message
 
 
 
 
 
 
322
  return (
323
  game_state,
324
  guess_state,
325
  str(current_sample.audio_path),
326
  clip_md,
327
- _base_map_array(),
328
  prompt_text,
329
  _format_recent_rows(_load_recent_runs()),
330
  longitude,
331
  latitude,
 
 
 
332
  )
333
 
334
  guess_lon = float(longitude)
@@ -340,16 +539,25 @@ def submit_guess(
340
  current_sample = SAMPLES[game_state["current_index"]]
341
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
342
  prompt_text = message
 
 
 
 
 
 
343
  return (
344
  game_state,
345
  guess_state,
346
  str(current_sample.audio_path),
347
  clip_md,
348
- _base_map_array(),
349
  prompt_text,
350
  _format_recent_rows(_load_recent_runs()),
351
  longitude,
352
  latitude,
 
 
 
353
  )
354
 
355
  if not (-90 <= guess_lat <= 90):
@@ -357,32 +565,54 @@ def submit_guess(
357
  current_sample = SAMPLES[game_state["current_index"]]
358
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
359
  prompt_text = message
 
 
 
 
 
 
 
 
360
  return (
361
  game_state,
362
  guess_state,
363
  str(current_sample.audio_path),
364
  clip_md,
365
- _base_map_array(),
366
  prompt_text,
367
  _format_recent_rows(_load_recent_runs()),
368
  longitude,
369
  latitude,
 
 
 
370
  )
371
  except ValueError:
372
  message = "Invalid coordinates. Please enter valid numbers."
373
  current_sample = SAMPLES[game_state["current_index"]]
374
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
375
  prompt_text = message
 
 
 
 
 
 
 
 
376
  return (
377
  game_state,
378
  guess_state,
379
  str(current_sample.audio_path),
380
  clip_md,
381
- _base_map_array(),
382
  prompt_text,
383
  _format_recent_rows(_load_recent_runs()),
384
  longitude,
385
  latitude,
 
 
 
386
  )
387
 
388
  current_sample = SAMPLES[game_state["current_index"]]
@@ -414,6 +644,13 @@ def submit_guess(
414
  }
415
  _append_log(log_entry)
416
 
 
 
 
 
 
 
 
417
  next_sample = _next_sample(game_state)
418
  audio_value = str(next_sample.audio_path)
419
  clip_md = _prepare_clip_info(next_sample, game_state["round"])
@@ -421,17 +658,28 @@ def submit_guess(
421
  new_guess_state = {"lat": None, "lon": None, "pixel": None}
422
  prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
423
  recent_runs = _format_recent_rows(_load_recent_runs())
424
-
 
 
 
 
 
 
 
 
425
  return (
426
  game_state,
427
  new_guess_state,
428
  audio_value,
429
  clip_md,
430
- _base_map_array(),
431
  prompt_text,
432
  recent_runs,
433
  "", # Reset longitude input
434
  "", # Reset latitude input
 
 
 
435
  )
436
 
437
 
@@ -452,17 +700,27 @@ custom_css = """
452
  /* Apply size restrictions to regular (non-fullscreen) image */
453
  .gradio-image:not([class*="fullscreen"]) {
454
  max-width: 100% !important;
455
- max-height: 600px !important;
 
456
  margin: 0 auto !important;
457
- display: flex !important;
458
- justify-content: center !important;
459
  }
460
  .gradio-image:not([class*="fullscreen"]) > div {
461
  max-width: 100% !important;
462
- max-height: 600px !important;
 
463
  margin: 0 auto !important;
464
- display: flex !important;
465
- justify-content: center !important;
 
 
 
 
 
 
 
 
 
466
  }
467
  .gradio-image:not([class*="fullscreen"]) img {
468
  max-width: 100% !important;
@@ -472,6 +730,19 @@ custom_css = """
472
  object-fit: contain !important;
473
  margin: 0 auto !important;
474
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  /* Allow fullscreen modal to override size restrictions - higher specificity */
476
  /* Target common Gradio fullscreen containers */
477
  div[class*="modal"] .gradio-image,
@@ -546,18 +817,38 @@ with gr.Blocks(title="Audio Geo-Localization Game", theme=gr.themes.Soft(), css=
546
  guess_state = gr.State()
547
 
548
 
549
- clip_info = gr.Markdown(label="Clip details", elem_classes=["clip-info"])
550
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
 
 
 
 
552
  with gr.Row():
553
  with gr.Column(scale=2):
554
- map_component = gr.Image(
555
- value=_base_map_array(),
556
  label="World map (click to set your guess)",
557
- interactive=True,
558
- type="numpy",
559
- image_mode="RGB",
560
- elem_classes=["gradio-image"],
561
  )
562
 
563
  with gr.Column(scale=1):
@@ -570,13 +861,15 @@ with gr.Blocks(title="Audio Geo-Localization Game", theme=gr.themes.Soft(), css=
570
  with gr.Row():
571
  longitude_input = gr.Textbox(
572
  label="Longitude",
573
- placeholder="Enter longitude (-180 to 180) or click map",
574
- elem_classes=["form-text"]
 
575
  )
576
  latitude_input = gr.Textbox(
577
  label="Latitude",
578
- placeholder="Enter latitude (-90 to 90) or click map",
579
- elem_classes=["form-text"]
 
580
  )
581
 
582
  submit_button = gr.Button("Submit Guess", variant="primary")
@@ -597,22 +890,261 @@ with gr.Blocks(title="Audio Geo-Localization Game", theme=gr.themes.Soft(), css=
597
  demo.load(
598
  initialize_round,
599
  inputs=None,
600
- outputs=[game_state, guess_state, audio_player, clip_info, map_component, selection_text, recent_table, longitude_input, latitude_input],
601
  )
602
 
603
- map_component.select(
604
- handle_map_click,
605
- inputs=[guess_state],
606
- outputs=[map_component, selection_text, guess_state, longitude_input, latitude_input],
607
- preprocess=False,
608
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
 
610
  submit_button.click(
611
  submit_guess,
612
  inputs=[player_id, game_state, guess_state, longitude_input, latitude_input],
613
- outputs=[game_state, guess_state, audio_player, clip_info, map_component, selection_text, recent_table, longitude_input, latitude_input],
614
  )
615
 
616
 
617
  if __name__ == "__main__":
618
- demo.launch(server_name="0.0.0.0", server_port=3828)
 
 
13
  if not hasattr(np, "bool"): # pandas<2 compatibility on numpy>=2
14
  np.bool = bool # type: ignore[attr-defined]
15
 
16
+ EXAMPLE_PROMPT = "## Input-Output Example. When you submit an answer, the following lines will display the last input and the corresponding correct answer."
17
+ LAST_PROMPT = "## The input and the correct output of the last question."
18
  import pandas as pd
19
  from PIL import Image, ImageDraw
20
+ import plotly.graph_objects as go
21
+ def _create_plotly_map(marker_lat: Optional[float] = None, marker_lon: Optional[float] = None):
22
+ """Create a Plotly map figure (OpenStreetMap) with optional visible marker.
23
+ Also adds a dense transparent grid of points so plotly_click 能在任意位置触发。
24
+ """
25
+ # Dense transparent grid points to capture clicks anywhere on the map
26
+ grid_lats: List[float] = []
27
+ grid_lons: List[float] = []
28
+ # 1° 网格,尽量保证任意点击都能命中到一个数据点
29
+ for lat in range(-90, 91, 1):
30
+ for lon in range(-180, 181, 1):
31
+ grid_lats.append(float(lat))
32
+ grid_lons.append(float(lon))
33
+
34
+ fig = go.Figure()
35
+ fig.add_trace(
36
+ go.Scattermap(
37
+ lat=grid_lats,
38
+ lon=grid_lons,
39
+ mode="markers",
40
+ marker=go.scattermap.Marker(size=40, color="#000"),
41
+ opacity=0.01, # 透明但可点中
42
+ hoverinfo="skip",
43
+ name="grid",
44
+ )
45
+ )
46
+
47
+ if marker_lat is not None and marker_lon is not None:
48
+ fig.add_trace(
49
+ go.Scattermap(
50
+ lat=[marker_lat],
51
+ lon=[marker_lon],
52
+ mode="markers",
53
+ marker=go.scattermap.Marker(size=16, color="red"),
54
+ hoverinfo="text",
55
+ text=["Your guess"],
56
+ name="marker",
57
+ )
58
+ )
59
 
60
+ fig.update_layout(
61
+ map=dict(style="open-street-map", center=dict(lat=20, lon=0), zoom=2),
62
+ margin=dict(l=0, r=0, t=0, b=0),
63
+ height=600,
64
+ clickmode="event+select", # 明确开启点击事件
65
+ dragmode="pan",
66
+ )
67
+ return fig
68
  import gradio as gr
69
+
70
 
71
 
72
+ BASE_DIR = Path(__file__).resolve().parents[1]
73
  DATASET_PATH = BASE_DIR / "data" / "dataset.csv"
74
  AUDIO_BASE_DIR = BASE_DIR / "data" / "audios"
75
+ ASSETS_DIR = Path(__file__).resolve().parent / "assets"
76
  MAP_IMAGE_PATH = ASSETS_DIR / "world_map.png"
77
+ LOG_PATH = Path(__file__).resolve().parent / "player_runs.csv"
78
  RECENT_COLUMNS = ["timestamp", "player_id", "question_id", "distance_km"]
79
 
80
 
 
141
  return samples
142
 
143
 
144
+ def _get_example_sample() -> Optional[Sample]:
145
+ """Get the example sample (aporee_45102_51242) for the first round."""
146
+ if not DATASET_PATH.exists():
147
+ return None
148
+
149
+ df = pd.read_csv(DATASET_PATH)
150
+ example_row = df[df["key"] == "aporee_45102_51242"]
151
+
152
+ if example_row.empty:
153
+ return None
154
+
155
+ row = example_row.iloc[0]
156
+ audio_path = AUDIO_BASE_DIR / row["mp3name"]
157
+
158
+ if not audio_path.exists():
159
+ return None
160
+
161
+ return Sample(
162
+ question_id=str(row["key"]),
163
+ audio_path=audio_path,
164
+ longitude=float(row["longitude"]),
165
+ latitude=float(row["latitude"]),
166
+ city=str(row.get("city", "") or ""),
167
+ country=str(row.get("country", "") or ""),
168
+ continent=str(row.get("continent", "") or ""),
169
+ description=str(row.get("description", "") or ""),
170
+ title=str(row.get("title", "") or ""),
171
+ )
172
+
173
+
174
  SAMPLES: List[Sample] = _load_samples()
175
  BASE_MAP_IMAGE = Image.open(MAP_IMAGE_PATH).convert("RGB")
176
  MAP_WIDTH, MAP_HEIGHT = BASE_MAP_IMAGE.size
 
225
  return np.array(BASE_MAP_IMAGE)
226
 
227
 
228
+
229
+
230
+ def _on_coords_change(lon_text: str, lat_text: str) -> None:
231
+ """Log coordinate changes to backend terminal for debugging."""
232
+ try:
233
+ lon = float(lon_text) if lon_text else None
234
+ lat = float(lat_text) if lat_text else None
235
+ print(f"[PLOT CLICK] lon={lon} lat={lat}")
236
+ except Exception as e:
237
+ print(f"[PLOT CLICK] parse error: {e} | lon_text={lon_text} lat_text={lat_text}")
238
+
239
+
240
  def _prepare_clip_info(sample: Sample, round_idx: int) -> str:
241
  intro_lines = [
242
+ f"## Question ID: {sample.question_id}. Now it's your turn to play."
243
  ]
244
  return "\n\n".join(intro_lines)
245
 
 
285
  return formatted
286
 
287
 
288
+ 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:
289
+ """Format the previous answer display with location info and OpenStreetMap link."""
290
+ if sample is None:
291
+ return ""
292
+
293
+ lat = sample.latitude
294
+ lon = sample.longitude
295
+ city = sample.city or "Unknown city"
296
+ country = sample.country or "Unknown country"
297
+ continent = sample.continent or "Unknown continent"
298
+
299
+ # Create OpenStreetMap link
300
+ osm_link = f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=15"
301
+
302
+ lines = [
303
+ f"**Location:** {city}, {country}, {continent}",
304
+ f"**Coordinates:** {lat:.6f}°, {lon:.6f}°",
305
+ ]
306
+
307
+ # Add player's prediction for non-example cases
308
+ if not is_example and guess_lat is not None and guess_lon is not None:
309
+ lines.append(f"**Your Prediction:** {guess_lat:.6f}°, {guess_lon:.6f}°")
310
+
311
+ # Add error distance for non-example cases
312
+ if not is_example and distance_km is not None:
313
+ lines.append(f"**Error distance:** {distance_km:.1f} km")
314
+
315
+ lines.append(f"**OpenStreetMap:** [View on map]({osm_link})")
316
+
317
+ # Add example explanation if it's the example sample
318
+ if is_example and sample.question_id == "aporee_45102_51242":
319
+ lines.append("")
320
+ 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.")
321
+
322
+ return "\n\n".join(lines)
323
+
324
+
325
+ def initialize_round() -> Tuple[Dict[str, object], Dict[str, Optional[float]], str, str, str, str, List[List[object]], str, str, str, str, str]:
326
  queue = _random_queue()
327
  current_index = queue.pop()
328
 
329
  state = {
330
+ "queue": queue,
331
  "current_index": current_index,
332
  "round": 1,
333
+ "previous_sample": None, # No previous sample for first round
334
+ "is_example": True, # First round shows example
335
  }
336
 
337
  current_sample = SAMPLES[current_index]
 
340
  prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
341
  guess_state = {"lat": None, "lon": None, "pixel": None}
342
  recent_runs = _format_recent_rows(_load_recent_runs())
343
+
344
+ # For first round, show example sample
345
+ example_sample = _get_example_sample()
346
+ previous_audio = str(example_sample.audio_path) if example_sample else None
347
+ previous_answer = _format_previous_answer(example_sample, is_example=True) if example_sample else ""
348
+ previous_title = EXAMPLE_PROMPT if example_sample else ""
349
 
350
+ return state, guess_state, audio_value, clip_md, _create_plotly_map(), prompt_text, recent_runs, "", "", previous_audio or "", previous_answer, previous_title
351
 
352
 
353
  def _next_sample(state: Dict[str, object]) -> Sample:
 
375
  evt: gr.SelectData,
376
  current_guess_state: Optional[Dict[str, Optional[float]]],
377
  ) -> Tuple[np.ndarray, str, Dict[str, Optional[float]], str, str]:
378
+ """Handle Plotly map click event to get coordinates."""
379
  guess_state = _ensure_guess_state(current_guess_state)
380
+
381
  if evt is None:
382
  return _base_map_array(), "Unable to read selection. Please try again.", guess_state, "", ""
383
 
384
+ # Extract coordinates from Plotly SelectData
385
+ # Plotly SelectData for mapbox may have different structure
386
+ try:
387
+ lat = None
388
+ lon = None
389
+
390
+ # Try different ways to extract coordinates
391
+ if hasattr(evt, 'points') and evt.points:
392
+ # Check if points is a list
393
+ if isinstance(evt.points, list) and len(evt.points) > 0:
394
+ point = evt.points[0]
395
+ if isinstance(point, dict):
396
+ lat = point.get('lat') or point.get('latitude')
397
+ lon = point.get('lon') or point.get('longitude')
398
+
399
+ # Try index attribute
400
+ if (lat is None or lon is None) and hasattr(evt, 'index'):
401
+ if isinstance(evt.index, dict):
402
+ lat = evt.index.get('lat') or evt.index.get('latitude')
403
+ lon = evt.index.get('lon') or evt.index.get('longitude')
404
+
405
+ # Try value attribute
406
+ if (lat is None or lon is None) and hasattr(evt, 'value'):
407
+ if isinstance(evt.value, dict):
408
+ lat = evt.value.get('lat') or evt.value.get('latitude')
409
+ lon = evt.value.get('lon') or evt.value.get('longitude')
410
+ # Also check nested points
411
+ if (lat is None or lon is None) and 'points' in evt.value:
412
+ points = evt.value['points']
413
+ if isinstance(points, list) and len(points) > 0:
414
+ point = points[0]
415
+ if isinstance(point, dict):
416
+ lat = point.get('lat') or point.get('latitude')
417
+ lon = point.get('lon') or point.get('longitude')
418
+
419
+ # Debug: print the event structure
420
+ print(f"[MAP] SelectData event structure: {type(evt)}")
421
+ print(f"[MAP] Has points: {hasattr(evt, 'points')}")
422
+ print(f"[MAP] Has index: {hasattr(evt, 'index')}")
423
+ print(f"[MAP] Has value: {hasattr(evt, 'value')}")
424
+ if hasattr(evt, 'points'):
425
+ print(f"[MAP] Points: {evt.points}")
426
+ if hasattr(evt, 'index'):
427
+ print(f"[MAP] Index: {evt.index}")
428
+ if hasattr(evt, 'value'):
429
+ print(f"[MAP] Value: {evt.value}")
430
+
431
+ if lat is None or lon is None:
432
+ return _base_map_array(), "Unable to read coordinates. Please try clicking on the map again.", guess_state, "", ""
433
+
434
+ # Update guess state
435
+ guess_state = {"lat": float(lat), "lon": float(lon), "pixel": None}
436
+ # Create map with marker at clicked location
437
+ x = int((lon + 180.0) / 360.0 * MAP_WIDTH)
438
+ y = int((90.0 - lat) / 180.0 * MAP_HEIGHT)
439
+ image_with_marker = _map_with_marker(x, y)
440
+ return image_with_marker, _latlon_to_text(lat, lon), guess_state, f"{lon:.6f}", f"{lat:.6f}"
441
+ except Exception as e:
442
+ import traceback
443
+ print(f"[MAP] Error handling click: {e}")
444
+ print(f"[MAP] Traceback: {traceback.format_exc()}")
445
+ return _base_map_array(), f"Error: {str(e)}", guess_state, "", ""
446
 
447
 
448
  def submit_guess(
449
  player_id: str,
450
+ game_state: Optional[Dict[str, object]],
451
  guess_state: Optional[Dict[str, Optional[float]]],
452
  longitude: str,
453
  latitude: str,
 
456
  Dict[str, Optional[float]],
457
  str,
458
  str,
459
+ str,
460
  str,
461
  List[List[object]],
462
  str,
463
  str,
464
+ str,
465
+ str,
466
+ str,
467
  ]:
468
+ if game_state is None:
469
+ # Re-initialize if state is lost
470
+ return initialize_round()
471
+
472
  guess_state = _ensure_guess_state(guess_state)
473
  player_id = (player_id or "").strip()
474
  if not player_id:
 
476
  current_sample = SAMPLES[game_state["current_index"]]
477
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
478
  prompt_text = message
479
+ previous_sample = game_state.get("previous_sample")
480
+ previous_audio = str(previous_sample.audio_path) if previous_sample else ""
481
+ previous_distance = game_state.get("previous_distance_km")
482
+ previous_guess_lat = game_state.get("previous_guess_lat")
483
+ previous_guess_lon = game_state.get("previous_guess_lon")
484
+ 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 ""
485
+ is_example = game_state.get("is_example", False)
486
+ previous_title = EXAMPLE_PROMPT if is_example else ""
487
  return (
488
  game_state,
489
  guess_state,
490
  str(current_sample.audio_path),
491
  clip_md,
492
+ _create_plotly_map(),
493
  prompt_text,
494
  _format_recent_rows(_load_recent_runs()),
495
  longitude,
496
  latitude,
497
+ previous_audio,
498
+ previous_answer,
499
+ previous_title,
500
  )
501
 
502
  # Parse longitude and latitude from text inputs
 
509
  current_sample = SAMPLES[game_state["current_index"]]
510
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
511
  prompt_text = message
512
+ previous_sample = game_state.get("previous_sample")
513
+ previous_audio = str(previous_sample.audio_path) if previous_sample else ""
514
+ previous_distance = game_state.get("previous_distance_km")
515
+ previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance) if previous_sample else ""
516
+ is_example = game_state.get("is_example", False)
517
+ previous_title = EXAMPLE_PROMPT if is_example else ""
518
  return (
519
  game_state,
520
  guess_state,
521
  str(current_sample.audio_path),
522
  clip_md,
523
+ gr.update(),
524
  prompt_text,
525
  _format_recent_rows(_load_recent_runs()),
526
  longitude,
527
  latitude,
528
+ previous_audio,
529
+ previous_answer,
530
+ previous_title,
531
  )
532
 
533
  guess_lon = float(longitude)
 
539
  current_sample = SAMPLES[game_state["current_index"]]
540
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
541
  prompt_text = message
542
+ previous_sample = game_state.get("previous_sample")
543
+ previous_audio = str(previous_sample.audio_path) if previous_sample else ""
544
+ previous_distance = game_state.get("previous_distance_km")
545
+ previous_answer = _format_previous_answer(previous_sample, distance_km=previous_distance) if previous_sample else ""
546
+ is_example = game_state.get("is_example", False)
547
+ previous_title = EXAMPLE_PROMPT if is_example else ""
548
  return (
549
  game_state,
550
  guess_state,
551
  str(current_sample.audio_path),
552
  clip_md,
553
+ gr.update(),
554
  prompt_text,
555
  _format_recent_rows(_load_recent_runs()),
556
  longitude,
557
  latitude,
558
+ previous_audio,
559
+ previous_answer,
560
+ previous_title,
561
  )
562
 
563
  if not (-90 <= guess_lat <= 90):
 
565
  current_sample = SAMPLES[game_state["current_index"]]
566
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
567
  prompt_text = message
568
+ previous_sample = game_state.get("previous_sample")
569
+ previous_audio = str(previous_sample.audio_path) if previous_sample else ""
570
+ previous_distance = game_state.get("previous_distance_km")
571
+ previous_guess_lat = game_state.get("previous_guess_lat")
572
+ previous_guess_lon = game_state.get("previous_guess_lon")
573
+ 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 ""
574
+ is_example = game_state.get("is_example", False)
575
+ previous_title = EXAMPLE_PROMPT if is_example else LAST_PROMPT
576
  return (
577
  game_state,
578
  guess_state,
579
  str(current_sample.audio_path),
580
  clip_md,
581
+ gr.update(),
582
  prompt_text,
583
  _format_recent_rows(_load_recent_runs()),
584
  longitude,
585
  latitude,
586
+ previous_audio,
587
+ previous_answer,
588
+ previous_title,
589
  )
590
  except ValueError:
591
  message = "Invalid coordinates. Please enter valid numbers."
592
  current_sample = SAMPLES[game_state["current_index"]]
593
  clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
594
  prompt_text = message
595
+ previous_sample = game_state.get("previous_sample")
596
+ previous_audio = str(previous_sample.audio_path) if previous_sample else ""
597
+ previous_distance = game_state.get("previous_distance_km")
598
+ previous_guess_lat = game_state.get("previous_guess_lat")
599
+ previous_guess_lon = game_state.get("previous_guess_lon")
600
+ 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 ""
601
+ is_example = game_state.get("is_example", False)
602
+ previous_title = EXAMPLE_PROMPT if is_example else LAST_PROMPT
603
  return (
604
  game_state,
605
  guess_state,
606
  str(current_sample.audio_path),
607
  clip_md,
608
+ _create_plotly_map(),
609
  prompt_text,
610
  _format_recent_rows(_load_recent_runs()),
611
  longitude,
612
  latitude,
613
+ previous_audio,
614
+ previous_answer,
615
+ previous_title,
616
  )
617
 
618
  current_sample = SAMPLES[game_state["current_index"]]
 
644
  }
645
  _append_log(log_entry)
646
 
647
+ # Save current sample and distance as previous for next round
648
+ game_state["previous_sample"] = current_sample
649
+ game_state["previous_distance_km"] = distance_km
650
+ game_state["previous_guess_lat"] = guess_lat
651
+ game_state["previous_guess_lon"] = guess_lon
652
+ game_state["is_example"] = False # After first round, it's no longer example
653
+
654
  next_sample = _next_sample(game_state)
655
  audio_value = str(next_sample.audio_path)
656
  clip_md = _prepare_clip_info(next_sample, game_state["round"])
 
658
  new_guess_state = {"lat": None, "lon": None, "pixel": None}
659
  prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
660
  recent_runs = _format_recent_rows(_load_recent_runs())
661
+
662
+ # Format previous answer (current sample becomes previous for next round)
663
+ previous_audio = str(current_sample.audio_path)
664
+ previous_distance = game_state.get("previous_distance_km")
665
+ previous_guess_lat = game_state.get("previous_guess_lat")
666
+ previous_guess_lon = game_state.get("previous_guess_lon")
667
+ previous_answer = _format_previous_answer(current_sample, is_example=False, distance_km=previous_distance, guess_lat=previous_guess_lat, guess_lon=previous_guess_lon)
668
+ previous_title = LAST_PROMPT
669
+
670
  return (
671
  game_state,
672
  new_guess_state,
673
  audio_value,
674
  clip_md,
675
+ gr.update(),
676
  prompt_text,
677
  recent_runs,
678
  "", # Reset longitude input
679
  "", # Reset latitude input
680
+ previous_audio,
681
+ previous_answer,
682
+ previous_title,
683
  )
684
 
685
 
 
700
  /* Apply size restrictions to regular (non-fullscreen) image */
701
  .gradio-image:not([class*="fullscreen"]) {
702
  max-width: 100% !important;
703
+ max-height: none !important;
704
+ height: auto !important;
705
  margin: 0 auto !important;
706
+ display: block !important;
 
707
  }
708
  .gradio-image:not([class*="fullscreen"]) > div {
709
  max-width: 100% !important;
710
+ max-height: none !important;
711
+ height: auto !important;
712
  margin: 0 auto !important;
713
+ display: block !important;
714
+ }
715
+ .gradio-image:not([class*="fullscreen"]) iframe,
716
+ .gradio-image:not([class*="fullscreen"]) div[id^="map-"] {
717
+ max-width: 100% !important;
718
+ max-height: none !important;
719
+ height: 800px !important;
720
+ min-height: 800px !important;
721
+ width: 100% !important;
722
+ margin: 0 auto !important;
723
+ display: block !important;
724
  }
725
  .gradio-image:not([class*="fullscreen"]) img {
726
  max-width: 100% !important;
 
730
  object-fit: contain !important;
731
  margin: 0 auto !important;
732
  }
733
+ /* Ensure map plot has consistent height */
734
+ .gradio-plot {
735
+ min-height: 500px !important;
736
+ height: 500px !important;
737
+ }
738
+ /* Make sure the map column aligns properly */
739
+ .gradio-row > .gradio-column:first-child {
740
+ display: flex !important;
741
+ flex-direction: column !important;
742
+ }
743
+ .gradio-row > .gradio-column:first-child > * {
744
+ flex-shrink: 0 !important;
745
+ }
746
  /* Allow fullscreen modal to override size restrictions - higher specificity */
747
  /* Target common Gradio fullscreen containers */
748
  div[class*="modal"] .gradio-image,
 
817
  guess_state = gr.State()
818
 
819
 
820
+ # New row for previous question audio and answer with title
821
+ previous_title_display = gr.Markdown(value="", elem_classes=["clip-info"])
822
+ with gr.Row():
823
+ with gr.Column(scale=1):
824
+ previous_audio_player = gr.Audio(
825
+ label="Previous audio clip",
826
+ autoplay=False,
827
+ interactive=False,
828
+ streaming=False,
829
+ visible=True
830
+ )
831
+ with gr.Column(scale=1):
832
+ previous_answer_display = gr.Markdown(
833
+ label="Previous answer",
834
+ value="",
835
+ elem_classes=["clip-info"]
836
+ )
837
+
838
+ # Add separator/divider between previous and current round
839
+ gr.Markdown("---")
840
+
841
 
842
+
843
+ clip_info = gr.Markdown(label="Clip details", elem_classes=["clip-info"])
844
+
845
+
846
  with gr.Row():
847
  with gr.Column(scale=2):
848
+ map_component = gr.Plot(
849
+ value=_create_plotly_map(),
850
  label="World map (click to set your guess)",
851
+ elem_classes=["gradio-image"]
 
 
 
852
  )
853
 
854
  with gr.Column(scale=1):
 
861
  with gr.Row():
862
  longitude_input = gr.Textbox(
863
  label="Longitude",
864
+ placeholder="Enter longitude or click map",
865
+ elem_classes=["form-text"],
866
+ elem_id="lon_input"
867
  )
868
  latitude_input = gr.Textbox(
869
  label="Latitude",
870
+ placeholder="Enter latitude or click map",
871
+ elem_classes=["form-text"],
872
+ elem_id="lat_input"
873
  )
874
 
875
  submit_button = gr.Button("Submit Guess", variant="primary")
 
890
  demo.load(
891
  initialize_round,
892
  inputs=None,
893
+ 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]
894
  )
895
 
896
+ # 后端监听文本框变化,在终端打印坐标(便于你观察点击数据是否到达后端)
897
+ longitude_input.change(_on_coords_change, inputs=[longitude_input, latitude_input], outputs=[])
898
+ latitude_input.change(_on_coords_change, inputs=[longitude_input, latitude_input], outputs=[])
899
+
900
+ # Attach plotly_click to update the lon/lat textboxes (no Python roundtrip)
901
+ plotly_click_js = r"""
902
+ () => {
903
+ console.log('[PLOTLY SETUP] Starting plotly click handler setup...');
904
+
905
+ const updateInputs = (lat, lon) => {
906
+ console.log('[PLOTLY CLICK] Received coordinates:', {lat, lon});
907
+
908
+ // Method 1: Try Gradio component API
909
+ if (window.gradio_app) {
910
+ try {
911
+ const components = window.gradio_app.__components || {};
912
+ Object.keys(components).forEach(key => {
913
+ const comp = components[key];
914
+ if (comp && comp.props && comp.props.elem_id) {
915
+ if (comp.props.elem_id === 'lon_input' && comp.component) {
916
+ comp.component.value = Number(lon).toFixed(6);
917
+ comp.component.dispatch('change');
918
+ console.log('[PLOTLY CLICK] Updated longitude via Gradio API');
919
+ }
920
+ if (comp.props.elem_id === 'lat_input' && comp.component) {
921
+ comp.component.value = Number(lat).toFixed(6);
922
+ comp.component.dispatch('change');
923
+ console.log('[PLOTLY CLICK] Updated latitude via Gradio API');
924
+ }
925
+ }
926
+ });
927
+ } catch (e) {
928
+ console.log('[PLOTLY CLICK] Gradio API method failed:', e);
929
+ }
930
+ }
931
+
932
+ // Method 2: Find by label text, then find input nearby
933
+ const allLabels = Array.from(document.querySelectorAll('label'));
934
+ allLabels.forEach(label => {
935
+ const labelText = label.textContent.toLowerCase();
936
+ if (labelText.includes('longitude')) {
937
+ const container = label.closest('div, form, .gr-box');
938
+ if (container) {
939
+ const input = container.querySelector('input, textarea');
940
+ if (input) {
941
+ input.value = Number(lon).toFixed(6);
942
+ input.dispatchEvent(new Event('input', { bubbles: true }));
943
+ input.dispatchEvent(new Event('change', { bubbles: true }));
944
+ console.log('[PLOTLY CLICK] Updated longitude via label:', input.value);
945
+ }
946
+ }
947
+ }
948
+ if (labelText.includes('latitude')) {
949
+ const container = label.closest('div, form, .gr-box');
950
+ if (container) {
951
+ const input = container.querySelector('input, textarea');
952
+ if (input) {
953
+ input.value = Number(lat).toFixed(6);
954
+ input.dispatchEvent(new Event('input', { bubbles: true }));
955
+ input.dispatchEvent(new Event('change', { bubbles: true }));
956
+ console.log('[PLOTLY CLICK] Updated latitude via label:', input.value);
957
+ }
958
+ }
959
+ }
960
+ });
961
+
962
+ // Method 3: Try finding by placeholder in all possible elements
963
+ const allElements = document.querySelectorAll('input, textarea, [contenteditable="true"]');
964
+ allElements.forEach(el => {
965
+ const ph = (el.placeholder || el.getAttribute('placeholder') || '').toLowerCase();
966
+ if (ph.includes('longitude') && !ph.includes('latitude')) {
967
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
968
+ el.value = Number(lon).toFixed(6);
969
+ el.dispatchEvent(new Event('input', { bubbles: true }));
970
+ el.dispatchEvent(new Event('change', { bubbles: true }));
971
+ console.log('[PLOTLY CLICK] Updated longitude via placeholder:', el.value);
972
+ }
973
+ }
974
+ if (ph.includes('latitude') && !ph.includes('longitude')) {
975
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
976
+ el.value = Number(lat).toFixed(6);
977
+ el.dispatchEvent(new Event('input', { bubbles: true }));
978
+ el.dispatchEvent(new Event('change', { bubbles: true }));
979
+ console.log('[PLOTLY CLICK] Updated latitude via placeholder:', el.value);
980
+ }
981
+ }
982
+ });
983
+
984
+ console.log('[PLOTLY CLICK] Update attempts completed');
985
+ };
986
+
987
+ const wirePlotly = () => {
988
+ const plots = document.querySelectorAll('.js-plotly-plot');
989
+ console.log('[PLOTLY SETUP] Found', plots.length, 'plotly plots');
990
+
991
+ plots.forEach((plotDiv, idx) => {
992
+ if (plotDiv.dataset._wired === '1') {
993
+ console.log('[PLOTLY SETUP] Plot', idx, 'already wired');
994
+ return;
995
+ }
996
+
997
+ console.log('[PLOTLY SETUP] Wiring plot', idx);
998
+ plotDiv.dataset._wired = '1';
999
+
1000
+ // Listen for plotly_click DOM event (Plotly fires this as a custom event)
1001
+ plotDiv.addEventListener('plotly_click', (e) => {
1002
+ console.log('[PLOTLY CLICK] Event received:', e);
1003
+ console.log('[PLOTLY CLICK] e.points:', e.points);
1004
+
1005
+ // Plotly click event data is directly on the event object
1006
+ let points = e.points;
1007
+ if (!points && e.detail) {
1008
+ points = e.detail.points;
1009
+ }
1010
+
1011
+ if (points && points.length > 0) {
1012
+ const pt = points[0];
1013
+ console.log('[PLOTLY CLICK] Point data:', pt);
1014
+ const lat = pt.lat;
1015
+ const lon = pt.lon;
1016
+ console.log('[PLOTLY CLICK] Extracted lat:', lat, 'lon:', lon);
1017
+ if (lat !== undefined && lon !== undefined && !isNaN(lat) && !isNaN(lon)) {
1018
+ updateInputs(lat, lon);
1019
+ } else {
1020
+ console.error('[PLOTLY CLICK] Invalid coordinates:', {lat, lon, point: pt});
1021
+ }
1022
+ } else {
1023
+ console.error('[PLOTLY CLICK] No points found in event');
1024
+ }
1025
+ });
1026
+
1027
+ // Fallback: Listen for raw click events and calculate coordinates from map config
1028
+ plotDiv.addEventListener('click', (e) => {
1029
+ console.log('[PLOTLY CLICK] Raw click event:', e);
1030
+
1031
+ try {
1032
+ if (!plotDiv._fullLayout || !plotDiv._fullLayout.map) {
1033
+ console.error('[PLOTLY CLICK] map layout not available');
1034
+ return;
1035
+ }
1036
+
1037
+ const mapConfig = plotDiv._fullLayout.map;
1038
+ const center = mapConfig.center || {lat: 0, lon: 0};
1039
+ const zoom = mapConfig.zoom || 2;
1040
+
1041
+ console.log('[PLOTLY CLICK] Map config:', {center, zoom});
1042
+
1043
+ // Get click position relative to plot div
1044
+ const rect = plotDiv.getBoundingClientRect();
1045
+ const width = rect.width;
1046
+ const height = rect.height;
1047
+ const x = e.clientX - rect.left;
1048
+ const y = e.clientY - rect.top;
1049
+
1050
+ console.log('[PLOTLY CLICK] Click position:', {x, y, width, height});
1051
+
1052
+ // Convert pixel coordinates to lat/lon using Web Mercator projection
1053
+ // Simplified calculation based on map center and zoom
1054
+ const normalizedX = (x / width) - 0.5; // -0.5 to 0.5
1055
+ const normalizedY = 0.5 - (y / height); // 0.5 to -0.5 (flipped Y)
1056
+
1057
+ // Calculate pixel-to-degree conversion at current zoom
1058
+ // At zoom level z, one tile = 256 pixels, world = 2^z tiles wide
1059
+ const tilesAtZoom = Math.pow(2, zoom);
1060
+ const pixelsPerTile = 256;
1061
+ const worldWidthPixels = tilesAtZoom * pixelsPerTile;
1062
+ const worldWidthDegrees = 360;
1063
+ const degreesPerPixel = worldWidthDegrees / worldWidthPixels;
1064
+
1065
+ // Longitude: linear mapping
1066
+ const lon = center.lon + (normalizedX * width * degreesPerPixel);
1067
+
1068
+ // Latitude: Web Mercator inverse
1069
+ // Mercator Y at center: y_center = ln(tan(π/4 + center_lat/2))
1070
+ // Click Y offset in pixels: dy = normalizedY * height
1071
+ // Convert to Mercator units and then to latitude
1072
+ const mercatorYCenter = Math.log(Math.tan(Math.PI / 4 + (center.lat * Math.PI / 180) / 2));
1073
+ const dyMercator = normalizedY * height * degreesPerPixel / (180 / Math.PI);
1074
+ const mercatorYClick = mercatorYCenter + dyMercator;
1075
+ const latRad = 2 * Math.atan(Math.exp(mercatorYClick)) - Math.PI / 2;
1076
+ const lat = latRad * 180 / Math.PI;
1077
+
1078
+ console.log('[PLOTLY CLICK] Calculated coordinates - lat:', lat, 'lon:', lon);
1079
+
1080
+ if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
1081
+ updateInputs(lat, lon);
1082
+ } else {
1083
+ console.error('[PLOTLY CLICK] Invalid calculated coordinates:', {lat, lon});
1084
+ }
1085
+ } catch (err) {
1086
+ console.error('[PLOTLY CLICK] Error in click handler:', err);
1087
+ console.error('[PLOTLY CLICK] Error stack:', err.stack);
1088
+ }
1089
+ });
1090
+
1091
+ });
1092
+ };
1093
+
1094
+ // Initial wire with retry logic
1095
+ let retryCount = 0;
1096
+ const maxRetries = 10;
1097
+ const tryWire = () => {
1098
+ const plots = document.querySelectorAll('.js-plotly-plot');
1099
+ if (plots.length === 0 && retryCount < maxRetries) {
1100
+ retryCount++;
1101
+ console.log('[PLOTLY SETUP] No plots found, retry', retryCount, '/', maxRetries);
1102
+ setTimeout(tryWire, 500);
1103
+ return;
1104
+ }
1105
+ wirePlotly();
1106
+ console.log('[PLOTLY SETUP] Initial wire complete, found', plots.length, 'plots');
1107
+ };
1108
+
1109
+ // Wait for Plotly library to load
1110
+ if (window.Plotly) {
1111
+ console.log('[PLOTLY SETUP] Plotly library found immediately');
1112
+ setTimeout(tryWire, 1500);
1113
+ } else {
1114
+ console.warn('[PLOTLY SETUP] Plotly library not found, waiting...');
1115
+ let plotlyWaitCount = 0;
1116
+ const waitForPlotly = () => {
1117
+ if (window.Plotly) {
1118
+ console.log('[PLOTLY SETUP] Plotly library loaded after', plotlyWaitCount * 200, 'ms');
1119
+ setTimeout(tryWire, 1000);
1120
+ } else if (plotlyWaitCount < 20) {
1121
+ plotlyWaitCount++;
1122
+ setTimeout(waitForPlotly, 200);
1123
+ } else {
1124
+ console.error('[PLOTLY SETUP] Plotly library not found after 4 seconds, proceeding anyway');
1125
+ setTimeout(tryWire, 1000);
1126
+ }
1127
+ };
1128
+ waitForPlotly();
1129
+ }
1130
+
1131
+ // Watch for new plots
1132
+ const obs = new MutationObserver(() => {
1133
+ setTimeout(wirePlotly, 500);
1134
+ });
1135
+ obs.observe(document.body, { childList: true, subtree: true });
1136
+ }
1137
+ """
1138
+
1139
+ demo.load(None, js=plotly_click_js)
1140
 
1141
  submit_button.click(
1142
  submit_guess,
1143
  inputs=[player_id, game_state, guess_state, longitude_input, latitude_input],
1144
+ 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],
1145
  )
1146
 
1147
 
1148
  if __name__ == "__main__":
1149
+ # Allow Gradio to serve audio files that live outside the CWD by whitelisting the project dir
1150
+ demo.launch(server_name="0.0.0.0", server_port=38339, allowed_paths=[str(BASE_DIR)])
appPre.py ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import datetime as dt
5
+ import math
6
+ import random
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ import numpy as np
12
+
13
+ if not hasattr(np, "bool"): # pandas<2 compatibility on numpy>=2
14
+ np.bool = bool # type: ignore[attr-defined]
15
+
16
+ import pandas as pd
17
+ from PIL import Image, ImageDraw
18
+
19
+ import gradio as gr
20
+
21
+
22
+ BASE_DIR = Path(__file__).resolve().parent
23
+ DATASET_PATH = BASE_DIR / "data" / "dataset.csv"
24
+ AUDIO_BASE_DIR = BASE_DIR / "data" / "audios"
25
+ ASSETS_DIR = BASE_DIR / "assets"
26
+ MAP_IMAGE_PATH = ASSETS_DIR / "world_map.png"
27
+ LOG_PATH = BASE_DIR / "player_runs.csv"
28
+ RECENT_COLUMNS = ["timestamp", "player_id", "question_id", "distance_km"]
29
+
30
+
31
+ @dataclass
32
+ class Sample:
33
+ question_id: str
34
+ audio_path: Path
35
+ longitude: float
36
+ latitude: float
37
+ city: str
38
+ country: str
39
+ continent: str
40
+ description: str
41
+ title: str
42
+
43
+
44
+ def _load_samples() -> List[Sample]:
45
+ if not DATASET_PATH.exists():
46
+ raise FileNotFoundError(f"Dataset not found at {DATASET_PATH}")
47
+
48
+ df = pd.read_csv(DATASET_PATH)
49
+ start_idx = int(len(df) * 0.9)
50
+ test_df = df.iloc[start_idx:].reset_index(drop=True)
51
+
52
+ samples: List[Sample] = []
53
+ missing_audio = 0
54
+ missing_coords = 0
55
+
56
+ for row in test_df.itertuples():
57
+ audio_path = AUDIO_BASE_DIR / getattr(row, "mp3name")
58
+ longitude = getattr(row, "longitude")
59
+ latitude = getattr(row, "latitude")
60
+
61
+ if math.isnan(longitude) or math.isnan(latitude):
62
+ missing_coords += 1
63
+ continue
64
+
65
+ if not audio_path.exists():
66
+ missing_audio += 1
67
+ continue
68
+
69
+ samples.append(
70
+ Sample(
71
+ question_id=str(getattr(row, "key")),
72
+ audio_path=audio_path,
73
+ longitude=float(longitude),
74
+ latitude=float(latitude),
75
+ city=str(getattr(row, "city", "") or ""),
76
+ country=str(getattr(row, "country", "") or ""),
77
+ continent=str(getattr(row, "continent", "") or ""),
78
+ description=str(getattr(row, "description", "") or ""),
79
+ title=str(getattr(row, "title", "") or ""),
80
+ )
81
+ )
82
+
83
+ if not samples:
84
+ raise RuntimeError("No playable samples were found in the test split.")
85
+
86
+ if missing_audio:
87
+ print(f"[game_app] Skipped {missing_audio} samples because audio files are missing.")
88
+ if missing_coords:
89
+ print(f"[game_app] Skipped {missing_coords} samples because coordinates are missing.")
90
+
91
+ return samples
92
+
93
+
94
+ SAMPLES: List[Sample] = _load_samples()
95
+ BASE_MAP_IMAGE = Image.open(MAP_IMAGE_PATH).convert("RGB")
96
+ MAP_WIDTH, MAP_HEIGHT = BASE_MAP_IMAGE.size
97
+
98
+
99
+ def _random_queue() -> List[int]:
100
+ queue = list(range(len(SAMPLES)))
101
+ random.shuffle(queue)
102
+ return queue
103
+
104
+
105
+ def _pixel_to_latlon(x: int, y: int) -> Tuple[float, float]:
106
+ lon = (x / (MAP_WIDTH - 1)) * 360.0 - 180.0
107
+ lat = 90.0 - (y / (MAP_HEIGHT - 1)) * 180.0
108
+ return round(lat, 6), round(lon, 6)
109
+
110
+
111
+ def _latlon_to_text(lat: float, lon: float) -> str:
112
+ return f"Selected latitude {lat:.3f}°, longitude {lon:.3f}°"
113
+
114
+
115
+ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
116
+ r = 6371.0
117
+ phi1, phi2 = math.radians(lat1), math.radians(lat2)
118
+ d_phi = math.radians(lat2 - lat1)
119
+ d_lambda = math.radians(lon2 - lon1)
120
+
121
+ a = math.sin(d_phi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2.0) ** 2
122
+ c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
123
+ return r * c
124
+
125
+
126
+ def _map_with_marker(x: int, y: int) -> np.ndarray:
127
+ marker_radius = max(6, MAP_WIDTH // 150)
128
+ img = BASE_MAP_IMAGE.copy()
129
+ draw = ImageDraw.Draw(img)
130
+ draw.ellipse(
131
+ (
132
+ x - marker_radius,
133
+ y - marker_radius,
134
+ x + marker_radius,
135
+ y + marker_radius,
136
+ ),
137
+ fill=(225, 64, 64),
138
+ outline=(0, 0, 0),
139
+ width=2,
140
+ )
141
+ return np.array(img)
142
+
143
+
144
+ def _base_map_array() -> np.ndarray:
145
+ return np.array(BASE_MAP_IMAGE)
146
+
147
+
148
+ def _prepare_clip_info(sample: Sample, round_idx: int) -> str:
149
+ intro_lines = [
150
+ f"**Round:** {round_idx}"
151
+ ]
152
+ return "\n\n".join(intro_lines)
153
+
154
+
155
+ def _append_log(entry: Dict[str, object]) -> None:
156
+ write_header = not LOG_PATH.exists()
157
+ with LOG_PATH.open("a", newline="", encoding="utf-8") as f:
158
+ writer = csv.DictWriter(f, fieldnames=list(entry.keys()))
159
+ if write_header:
160
+ writer.writeheader()
161
+ writer.writerow(entry)
162
+
163
+
164
+ def _load_recent_runs(limit: int = 5) -> List[Dict[str, object]]:
165
+ if not LOG_PATH.exists():
166
+ return []
167
+
168
+ rows = []
169
+ with LOG_PATH.open("r", encoding="utf-8") as f:
170
+ reader = csv.DictReader(f)
171
+ for row in reader:
172
+ rows.append(row)
173
+ return rows[-limit:]
174
+
175
+
176
+ def _format_recent_rows(rows: List[Dict[str, object]]) -> List[List[object]]:
177
+ formatted: List[List[object]] = []
178
+ for row in rows:
179
+ timestamp_str = row.get("timestamp", "")
180
+ if timestamp_str:
181
+ try:
182
+ # Parse ISO format timestamp and format as readable string
183
+ dt_obj = dt.datetime.fromisoformat(str(timestamp_str).replace('Z', '+00:00'))
184
+ formatted_timestamp = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
185
+ except (ValueError, AttributeError):
186
+ formatted_timestamp = str(timestamp_str)
187
+ else:
188
+ formatted_timestamp = ""
189
+
190
+ formatted_row = [formatted_timestamp]
191
+ formatted_row.extend([row.get(col, "") for col in RECENT_COLUMNS[1:]])
192
+ formatted.append(formatted_row)
193
+ return formatted
194
+
195
+
196
+ def initialize_round() -> Tuple[Dict[str, object], Dict[str, Optional[float]], str, str, np.ndarray, str, List[List[object]], str, str]:
197
+ queue = _random_queue()
198
+ current_index = queue.pop()
199
+
200
+ state = {
201
+ "queue": queue,
202
+ "current_index": current_index,
203
+ "round": 1,
204
+ }
205
+
206
+ current_sample = SAMPLES[current_index]
207
+ audio_value = str(current_sample.audio_path)
208
+ clip_md = _prepare_clip_info(current_sample, state["round"])
209
+ prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
210
+ guess_state = {"lat": None, "lon": None, "pixel": None}
211
+ recent_runs = _format_recent_rows(_load_recent_runs())
212
+
213
+ return state, guess_state, audio_value, clip_md, _base_map_array(), prompt_text, recent_runs, "", ""
214
+
215
+
216
+ def _next_sample(state: Dict[str, object]) -> Sample:
217
+ queue: List[int] = state["queue"]
218
+ if not queue:
219
+ queue.extend(_random_queue())
220
+
221
+ next_index = queue.pop()
222
+ state["current_index"] = next_index
223
+ state["round"] = state.get("round", 1) + 1
224
+ return SAMPLES[next_index]
225
+
226
+
227
+ def _ensure_guess_state(state: Optional[Dict[str, Optional[float]]]) -> Dict[str, Optional[float]]:
228
+ if not isinstance(state, dict):
229
+ return {"lat": None, "lon": None, "pixel": None}
230
+ return {
231
+ "lat": state.get("lat"),
232
+ "lon": state.get("lon"),
233
+ "pixel": state.get("pixel"),
234
+ }
235
+
236
+
237
+ def handle_map_click(
238
+ evt: gr.SelectData,
239
+ current_guess_state: Optional[Dict[str, Optional[float]]],
240
+ ) -> Tuple[np.ndarray, str, Dict[str, Optional[float]], str, str]:
241
+ guess_state = _ensure_guess_state(current_guess_state)
242
+ if evt is None:
243
+ return _base_map_array(), "Unable to read selection. Please try again.", guess_state, "", ""
244
+
245
+ index = getattr(evt, "index", None)
246
+ value = getattr(evt, "value", None)
247
+
248
+ x = y = None
249
+ if isinstance(index, (tuple, list)) and len(index) >= 2:
250
+ x, y = index[0], index[1]
251
+ elif isinstance(index, dict):
252
+ x = index.get("x")
253
+ y = index.get("y")
254
+
255
+ if (x is None or y is None) and isinstance(value, dict):
256
+ x = value.get("x", x)
257
+ y = value.get("y", y)
258
+ elif (x is None or y is None) and isinstance(value, (tuple, list)) and len(value) >= 2:
259
+ if x is None:
260
+ x = value[0]
261
+ if y is None:
262
+ y = value[1]
263
+
264
+ if x is None or y is None:
265
+ return _base_map_array(), "Unable to read selection. Please try again.", guess_state, "", ""
266
+
267
+ x = int(x)
268
+ y = int(y)
269
+
270
+ lat, lon = _pixel_to_latlon(x, y)
271
+ guess_state = {"lat": lat, "lon": lon, "pixel": (x, y)}
272
+ image_with_marker = _map_with_marker(x, y)
273
+ return image_with_marker, _latlon_to_text(lat, lon), guess_state, f"{lon:.6f}", f"{lat:.6f}"
274
+
275
+
276
+ def submit_guess(
277
+ player_id: str,
278
+ game_state: Dict[str, object],
279
+ guess_state: Optional[Dict[str, Optional[float]]],
280
+ longitude: str,
281
+ latitude: str,
282
+ ) -> Tuple[
283
+ Dict[str, object],
284
+ Dict[str, Optional[float]],
285
+ str,
286
+ str,
287
+ np.ndarray,
288
+ str,
289
+ List[List[object]],
290
+ str,
291
+ str,
292
+ ]:
293
+ guess_state = _ensure_guess_state(guess_state)
294
+ player_id = (player_id or "").strip()
295
+ if not player_id:
296
+ message = "Please enter your player ID before submitting."
297
+ current_sample = SAMPLES[game_state["current_index"]]
298
+ clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
299
+ prompt_text = message
300
+ return (
301
+ game_state,
302
+ guess_state,
303
+ str(current_sample.audio_path),
304
+ clip_md,
305
+ _base_map_array(),
306
+ prompt_text,
307
+ _format_recent_rows(_load_recent_runs()),
308
+ longitude,
309
+ latitude,
310
+ )
311
+
312
+ # Parse longitude and latitude from text inputs
313
+ try:
314
+ longitude = longitude.strip() if longitude else ""
315
+ latitude = latitude.strip() if latitude else ""
316
+
317
+ if not longitude or not latitude:
318
+ message = "Please enter both longitude and latitude, or click on the map to select a location."
319
+ current_sample = SAMPLES[game_state["current_index"]]
320
+ clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
321
+ prompt_text = message
322
+ return (
323
+ game_state,
324
+ guess_state,
325
+ str(current_sample.audio_path),
326
+ clip_md,
327
+ _base_map_array(),
328
+ prompt_text,
329
+ _format_recent_rows(_load_recent_runs()),
330
+ longitude,
331
+ latitude,
332
+ )
333
+
334
+ guess_lon = float(longitude)
335
+ guess_lat = float(latitude)
336
+
337
+ # Validate ranges
338
+ if not (-180 <= guess_lon <= 180):
339
+ message = "Longitude must be between -180 and 180."
340
+ current_sample = SAMPLES[game_state["current_index"]]
341
+ clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
342
+ prompt_text = message
343
+ return (
344
+ game_state,
345
+ guess_state,
346
+ str(current_sample.audio_path),
347
+ clip_md,
348
+ _base_map_array(),
349
+ prompt_text,
350
+ _format_recent_rows(_load_recent_runs()),
351
+ longitude,
352
+ latitude,
353
+ )
354
+
355
+ if not (-90 <= guess_lat <= 90):
356
+ message = "Latitude must be between -90 and 90."
357
+ current_sample = SAMPLES[game_state["current_index"]]
358
+ clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
359
+ prompt_text = message
360
+ return (
361
+ game_state,
362
+ guess_state,
363
+ str(current_sample.audio_path),
364
+ clip_md,
365
+ _base_map_array(),
366
+ prompt_text,
367
+ _format_recent_rows(_load_recent_runs()),
368
+ longitude,
369
+ latitude,
370
+ )
371
+ except ValueError:
372
+ message = "Invalid coordinates. Please enter valid numbers."
373
+ current_sample = SAMPLES[game_state["current_index"]]
374
+ clip_md = _prepare_clip_info(current_sample, game_state.get("round", 1))
375
+ prompt_text = message
376
+ return (
377
+ game_state,
378
+ guess_state,
379
+ str(current_sample.audio_path),
380
+ clip_md,
381
+ _base_map_array(),
382
+ prompt_text,
383
+ _format_recent_rows(_load_recent_runs()),
384
+ longitude,
385
+ latitude,
386
+ )
387
+
388
+ current_sample = SAMPLES[game_state["current_index"]]
389
+ true_lat = current_sample.latitude
390
+ true_lon = current_sample.longitude
391
+
392
+ distance_km = _haversine(true_lat, true_lon, guess_lat, guess_lon)
393
+ reveal_lines = [
394
+ f"Real location: {current_sample.city or 'Unknown city'}, {current_sample.country or 'Unknown country'} ({true_lat:.3f}°, {true_lon:.3f}°)",
395
+ f"Your guess: ({guess_lat:.3f}°, {guess_lon:.3f}°)",
396
+ f"Error distance: {distance_km:.1f} km",
397
+ ]
398
+
399
+ log_entry = {
400
+ "timestamp": dt.datetime.utcnow().isoformat(),
401
+ "player_id": player_id,
402
+ "question_id": current_sample.question_id,
403
+ "audio_path": str(current_sample.audio_path),
404
+ "guess_latitude": guess_lat,
405
+ "guess_longitude": guess_lon,
406
+ "true_latitude": true_lat,
407
+ "true_longitude": true_lon,
408
+ "distance_km": round(distance_km, 3),
409
+ "city": current_sample.city,
410
+ "country": current_sample.country,
411
+ "continent": current_sample.continent,
412
+ "title": current_sample.title,
413
+ "description": current_sample.description,
414
+ }
415
+ _append_log(log_entry)
416
+
417
+ next_sample = _next_sample(game_state)
418
+ audio_value = str(next_sample.audio_path)
419
+ clip_md = _prepare_clip_info(next_sample, game_state["round"])
420
+
421
+ new_guess_state = {"lat": None, "lon": None, "pixel": None}
422
+ prompt_text = "Click once on the map to pick your guess. The marker will update to your last selection."
423
+ recent_runs = _format_recent_rows(_load_recent_runs())
424
+
425
+ return (
426
+ game_state,
427
+ new_guess_state,
428
+ audio_value,
429
+ clip_md,
430
+ _base_map_array(),
431
+ prompt_text,
432
+ recent_runs,
433
+ "", # Reset longitude input
434
+ "", # Reset latitude input
435
+ )
436
+
437
+
438
+ custom_css = """
439
+ h1 {
440
+ font-size: 2.5rem !important;
441
+ font-weight: 700 !important;
442
+ margin-bottom: 1rem !important;
443
+ text-align: center !important;
444
+ }
445
+ .intro-text {
446
+ font-size: 1.1rem !important;
447
+ line-height: 1.6 !important;
448
+ margin-bottom: 1.5rem !important;
449
+ color: #555 !important;
450
+ text-align: center !important;
451
+ }
452
+ /* Apply size restrictions to regular (non-fullscreen) image */
453
+ .gradio-image:not([class*="fullscreen"]) {
454
+ max-width: 100% !important;
455
+ max-height: 600px !important;
456
+ margin: 0 auto !important;
457
+ display: flex !important;
458
+ justify-content: center !important;
459
+ }
460
+ .gradio-image:not([class*="fullscreen"]) > div {
461
+ max-width: 100% !important;
462
+ max-height: 600px !important;
463
+ margin: 0 auto !important;
464
+ display: flex !important;
465
+ justify-content: center !important;
466
+ }
467
+ .gradio-image:not([class*="fullscreen"]) img {
468
+ max-width: 100% !important;
469
+ max-height: 600px !important;
470
+ width: auto !important;
471
+ height: auto !important;
472
+ object-fit: contain !important;
473
+ margin: 0 auto !important;
474
+ }
475
+ /* Allow fullscreen modal to override size restrictions - higher specificity */
476
+ /* Target common Gradio fullscreen containers */
477
+ div[class*="modal"] .gradio-image,
478
+ div[class*="modal"] .gradio-image > div,
479
+ div[class*="modal"] .gradio-image img,
480
+ div[class*="fullscreen"] .gradio-image,
481
+ div[class*="fullscreen"] .gradio-image > div,
482
+ div[class*="fullscreen"] .gradio-image img,
483
+ div[id*="lightbox"] .gradio-image,
484
+ div[id*="lightbox"] .gradio-image img,
485
+ .gradio-image[class*="fullscreen"],
486
+ .gradio-image[class*="fullscreen"] > div,
487
+ .gradio-image[class*="fullscreen"] img {
488
+ max-width: none !important;
489
+ max-height: none !important;
490
+ width: 100% !important;
491
+ height: 100% !important;
492
+ }
493
+ .selection-text {
494
+ font-size: 1.15rem !important;
495
+ text-align: left !important;
496
+ color: #666 !important;
497
+ margin: 0.5rem 0 !important;
498
+ }
499
+ .selection-text * {
500
+ font-size: 1.15rem !important;
501
+ }
502
+ .feedback-box {
503
+ font-size: 1.15rem !important;
504
+ line-height: 1.6 !important;
505
+ }
506
+ .feedback-box p,
507
+ .feedback-box div,
508
+ .feedback-box span {
509
+ font-size: 1.15rem !important;
510
+ line-height: 1.6 !important;
511
+ }
512
+ .clip-info {
513
+ font-size: 1.1rem !important;
514
+ }
515
+ .clip-info p,
516
+ .clip-info div,
517
+ .clip-info span {
518
+ font-size: 1.1rem !important;
519
+ }
520
+ .clip-info label {
521
+ font-size: 1.1rem !important;
522
+ font-weight: 600 !important;
523
+ }
524
+ label {
525
+ font-size: 1rem !important;
526
+ font-weight: 500 !important;
527
+ }
528
+ .form-text input {
529
+ font-size: 0.95rem !important;
530
+ }
531
+ /* Table label styling - only target the label, not table content */
532
+ .gradio-dataframe label,
533
+ [data-testid="dataframe"] label,
534
+ label[for*="recent"],
535
+ .form-group label {
536
+ font-size: 1.15rem !important;
537
+ font-weight: 600 !important;
538
+ }
539
+ """
540
+
541
+ with gr.Blocks(title="Audio Geo-Localization Game", theme=gr.themes.Soft(), css=custom_css) as demo:
542
+ gr.Markdown("# Audio Geo-Localization Game")
543
+ 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>')
544
+
545
+ game_state = gr.State()
546
+ guess_state = gr.State()
547
+
548
+
549
+ clip_info = gr.Markdown(label="Clip details", elem_classes=["clip-info"])
550
+
551
+
552
+ with gr.Row():
553
+ with gr.Column(scale=2):
554
+ map_component = gr.Image(
555
+ value=_base_map_array(),
556
+ label="World map (click to set your guess)",
557
+ interactive=True,
558
+ type="numpy",
559
+ image_mode="RGB",
560
+ elem_classes=["gradio-image"],
561
+ )
562
+
563
+ with gr.Column(scale=1):
564
+ player_id = gr.Textbox(label="Player ID", placeholder="Enter an identifier so scores can be tracked", elem_classes=["form-text"])
565
+
566
+ audio_player = gr.Audio(label="Mystery audio clip", autoplay=False, interactive=False, streaming=False)
567
+
568
+ 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"])
569
+
570
+ with gr.Row():
571
+ longitude_input = gr.Textbox(
572
+ label="Longitude",
573
+ placeholder="Enter longitude (-180 to 180) or click map",
574
+ elem_classes=["form-text"]
575
+ )
576
+ latitude_input = gr.Textbox(
577
+ label="Latitude",
578
+ placeholder="Enter latitude (-90 to 90) or click map",
579
+ elem_classes=["form-text"]
580
+ )
581
+
582
+ submit_button = gr.Button("Submit Guess", variant="primary")
583
+ recent_table = gr.Dataframe(
584
+ headers=[
585
+ "timestamp",
586
+ "player_id",
587
+ "question_id",
588
+ "distance_km",
589
+ ],
590
+ datatype=["str", "str", "str", "number"],
591
+ value=[],
592
+ interactive=False,
593
+ label="Recent submissions (latest last)",
594
+ wrap=True,
595
+ )
596
+
597
+ demo.load(
598
+ initialize_round,
599
+ inputs=None,
600
+ outputs=[game_state, guess_state, audio_player, clip_info, map_component, selection_text, recent_table, longitude_input, latitude_input],
601
+ )
602
+
603
+ map_component.select(
604
+ handle_map_click,
605
+ inputs=[guess_state],
606
+ outputs=[map_component, selection_text, guess_state, longitude_input, latitude_input],
607
+ preprocess=False,
608
+ )
609
+
610
+ submit_button.click(
611
+ submit_guess,
612
+ inputs=[player_id, game_state, guess_state, longitude_input, latitude_input],
613
+ outputs=[game_state, guess_state, audio_player, clip_info, map_component, selection_text, recent_table, longitude_input, latitude_input],
614
+ )
615
+
616
+
617
+ if __name__ == "__main__":
618
+ demo.launch(server_name="0.0.0.0", server_port=3828)
player_runs.csv CHANGED
@@ -18,3 +18,9 @@ timestamp,player_id,question_id,audio_path,guess_latitude,guess_longitude,true_l
18
  2025-11-05T19:07:06.291597,hekki,aporee_42331_48275,/home/zhangrx/SoundingEarth/data/audios/190104152720nimmermeinefreundin20190105000218.mp3,39.893575,117.559826,47.269730795987,11.391281483893,7890.937,Innsbruck,Austria,Europe,"Innstraße 21, 6020 Innsbruck, Österreich - girls talk",girl playing with friendship
19
  2025-11-05T19:07:07.756012,hekki,aporee_33154_38104,/home/zhangrx/SoundingEarth/data/audios/1608060553summerrain.mp3,39.775741,117.203053,52.474707169672,13.423805683851,7414.23,Tempelhof Bezirk,Germany,Europe,"Schillerkiez, Berlin, Germany - summer rain",summer rain recorded from in between the old double windows
20
  2025-11-05T19:18:37.169326,hekki,aporee_9261_11137,/home/zhangrx/SoundingEarth/data/audios/04temporale.mp3,40.035529,116.564321,45.5534230384,8.95841985941,8085.955,Parabiago,Italy,Europe,"via Oidio, Parabiago, Temporale estivo",Un temporale estivo rinfresca un caldo pomeriggio di agosto
 
 
 
 
 
 
 
18
  2025-11-05T19:07:06.291597,hekki,aporee_42331_48275,/home/zhangrx/SoundingEarth/data/audios/190104152720nimmermeinefreundin20190105000218.mp3,39.893575,117.559826,47.269730795987,11.391281483893,7890.937,Innsbruck,Austria,Europe,"Innstraße 21, 6020 Innsbruck, Österreich - girls talk",girl playing with friendship
19
  2025-11-05T19:07:07.756012,hekki,aporee_33154_38104,/home/zhangrx/SoundingEarth/data/audios/1608060553summerrain.mp3,39.775741,117.203053,52.474707169672,13.423805683851,7414.23,Tempelhof Bezirk,Germany,Europe,"Schillerkiez, Berlin, Germany - summer rain",summer rain recorded from in between the old double windows
20
  2025-11-05T19:18:37.169326,hekki,aporee_9261_11137,/home/zhangrx/SoundingEarth/data/audios/04temporale.mp3,40.035529,116.564321,45.5534230384,8.95841985941,8085.955,Parabiago,Italy,Europe,"via Oidio, Parabiago, Temporale estivo",Un temporale estivo rinfresca un caldo pomeriggio di agosto
21
+ 2025-11-06T16:53:32.981561,232323,aporee_7387_9112,/home/zhangrx/SoundingEarth/data/audios/10071805.mp3,66.587537,105.261608,41.8814299789,-87.6853635907,7903.304,Chicago,United States,North America,Walgreens on World Listening Day - shopping and recording in Walgreens,"walking through Walgreens store, guitar toys display, refrigerator drones, moozak, shoppers, cashiers, World Listening Day, WLD"
22
+ 2025-11-06T16:54:02.290489,232323,aporee_12215_14310,/home/zhangrx/SoundingEarth/data/audios/primadellospettacolo.mp3,32.046695,171.327748,45.4709716191,9.19473588467,11209.031,Milano,Italy,Europe,Teatro Manzoni - prima dello spettacolo,crowded indoor teathre
23
+ 2025-11-06T16:59:03.170077,23333,aporee_14899_17370,/home/zhangrx/SoundingEarth/data/audios/conveyerbelt.mp3,57.581051,127.410046,50.430385130568,13.318594859,6588.151,Krasna Lipa,Czechia,Europe,"Málkov, Czech Republic - Nástup mine - Conveyer belt",The sound at the start of the coal's long (sometimes kilometers) conveyer belt journey to the grinding plant. Here the lumps are crushed to a uniform 6cm size appropriate for burning in power stations
24
+ 2025-11-06T17:05:00.255666,sdsad,aporee_8087_9860,/home/zhangrx/SoundingEarth/data/audios/Firemen.mp3,69.700179,51.64993,48.8537809597,2.34422922134,3472.493,Paris,France,Europe,"Quai des Grands Augustins, Paris - Firemen stuck in traffic","Firemen stuck in traffic, Pont St. Michel, Paris. After de car finaly passes you can hear a child singing the sirene melody."
25
+ 2025-11-06T17:15:59.301123,sfsd,aporee_10756_12758,/home/zhangrx/SoundingEarth/data/audios/WolfburgParkhotelAufzug02.mp3,39.367256,116.267287,52.4146381687,10.8051931858,7545.315,Wolfsburg,Germany,Europe,"wolfsburg, hotel, elevator",a second recording of the same elevator - now driving downwards<br /><br />soundmap d - social ambiences and topographic field recordings
26
+ 2025-11-06T17:24:50.714426,hello,aporee_2378_3344,/home/zhangrx/SoundingEarth/data/audios/SemforoemfrentedosCorreios.mp3,33.904939,91.493599,40.2115957956073,-8.42737331986427,8391.574,Coimbra,Portugal,Europe,Coimbra: next to main post office - Pedestrian Crossing Song 1,"crossing the road, accompanied by androidlike chirps & burps"
requirements.txt CHANGED
@@ -1,4 +1,12 @@
1
- gradio
2
- numpy
3
- pandas
4
- Pillow
 
 
 
 
 
 
 
 
 
1
+ csv
2
+ datetime
3
+ math
4
+ random
5
+ typing-inspection==0.4.2
6
+ typing_extensions==4.15.0
7
+ numpy==2.2.6
8
+ pandas==2.3.3
9
+ pillow==11.3.0
10
+ plotly==6.3.1
11
+ gradio==5.49.1
12
+ gradio_client==1.13.3
requirements2.txt ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiofiles==24.1.0
2
+ annotated-doc==0.0.3
3
+ annotated-types==0.7.0
4
+ anyio==4.11.0
5
+ audioread==3.1.0
6
+ branca==0.8.2
7
+ Brotli==1.1.0
8
+ certifi==2025.10.5
9
+ cffi==2.0.0
10
+ charset-normalizer==3.4.4
11
+ choreographer==1.2.0
12
+ click==8.3.0
13
+ contourpy==1.3.2
14
+ cycler==0.12.1
15
+ decorator==5.2.1
16
+ exceptiongroup==1.3.0
17
+ fastapi==0.121.0
18
+ ffmpy==0.6.4
19
+ filelock==3.20.0
20
+ folium==0.20.0
21
+ fonttools==4.60.1
22
+ fsspec==2025.10.0
23
+ gradio==5.49.1
24
+ gradio_client==1.13.3
25
+ groovy==0.1.2
26
+ h11==0.16.0
27
+ hf-xet==1.2.0
28
+ httpcore==1.0.9
29
+ httpx==0.28.1
30
+ huggingface-hub==1.0.1
31
+ idna==3.11
32
+ iniconfig==2.3.0
33
+ Jinja2==3.1.6
34
+ joblib==1.5.2
35
+ kaleido==1.1.0
36
+ kiwisolver==1.4.9
37
+ lazy_loader==0.4
38
+ librosa==0.11.0
39
+ llvmlite==0.45.1
40
+ logistro==2.0.1
41
+ markdown-it-py==4.0.0
42
+ MarkupSafe==3.0.3
43
+ matplotlib==3.10.7
44
+ mdurl==0.1.2
45
+ mpmath==1.3.0
46
+ msgpack==1.1.2
47
+ narwhals==2.10.1
48
+ networkx==3.4.2
49
+ numba==0.62.1
50
+ numpy==2.2.6
51
+ nvidia-cublas-cu12==12.8.4.1
52
+ nvidia-cuda-cupti-cu12==12.8.90
53
+ nvidia-cuda-nvrtc-cu12==12.8.93
54
+ nvidia-cuda-runtime-cu12==12.8.90
55
+ nvidia-cudnn-cu12==9.10.2.21
56
+ nvidia-cufft-cu12==11.3.3.83
57
+ nvidia-cufile-cu12==1.13.1.3
58
+ nvidia-curand-cu12==10.3.9.90
59
+ nvidia-cusolver-cu12==11.7.3.90
60
+ nvidia-cusparse-cu12==12.5.8.93
61
+ nvidia-cusparselt-cu12==0.7.1
62
+ nvidia-nccl-cu12==2.27.5
63
+ nvidia-nvjitlink-cu12==12.8.93
64
+ nvidia-nvshmem-cu12==3.3.20
65
+ nvidia-nvtx-cu12==12.8.90
66
+ orjson==3.11.4
67
+ packaging==25.0
68
+ pandas==2.3.3
69
+ pillow==11.3.0
70
+ platformdirs==4.5.0
71
+ plotly==6.3.1
72
+ pluggy==1.6.0
73
+ pooch==1.8.2
74
+ pycparser==2.23
75
+ pydantic==2.11.10
76
+ pydantic_core==2.33.2
77
+ pydub==0.25.1
78
+ Pygments==2.19.2
79
+ pyparsing==3.2.5
80
+ pytest==8.4.2
81
+ pytest-timeout==2.4.0
82
+ python-dateutil==2.9.0.post0
83
+ python-multipart==0.0.20
84
+ pytz==2025.2
85
+ PyYAML==6.0.3
86
+ requests==2.32.5
87
+ rich==14.2.0
88
+ ruff==0.14.3
89
+ safehttpx==0.1.7
90
+ safetensors==0.6.2
91
+ scikit-learn==1.7.2
92
+ scipy==1.15.3
93
+ seaborn==0.13.2
94
+ semantic-version==2.10.0
95
+ shellingham==1.5.4
96
+ simplejson==3.20.2
97
+ six==1.17.0
98
+ sniffio==1.3.1
99
+ soundfile==0.13.1
100
+ soxr==1.0.0
101
+ starlette==0.49.3
102
+ sympy==1.14.0
103
+ threadpoolctl==3.6.0
104
+ timm==1.0.21
105
+ tomli==2.3.0
106
+ tomlkit==0.13.3
107
+ torch==2.9.0
108
+ torchvision==0.24.0
109
+ tqdm==4.67.1
110
+ triton==3.5.0
111
+ typer==0.20.0
112
+ typer-slim==0.20.0
113
+ typing-inspection==0.4.2
114
+ typing_extensions==4.15.0
115
+ tzdata==2025.2
116
+ urllib3==2.5.0
117
+ uvicorn==0.38.0
118
+ websockets==15.0.1
119
+ xyzservices==2025.10.0