AdithyaSK HF Staff commited on
Commit
a1af590
·
verified ·
1 Parent(s): ee44678

Deploy geoguesser environment with train/eval splits

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. Dockerfile +11 -6
  3. geoguesser_env/.gitignore +23 -0
  4. geoguesser_env/.openenvignore +4 -0
  5. geoguesser_env/DESIGN.md +147 -0
  6. geoguesser_env/HARVEST.md +239 -0
  7. geoguesser_env/README.md +578 -0
  8. geoguesser_env/__init__.py +49 -0
  9. geoguesser_env/client.py +143 -0
  10. geoguesser_env/data/geo/detail/places.json +0 -0
  11. geoguesser_env/data/geo/detail/rivers.json +0 -0
  12. geoguesser_env/data/geo/detail/roads.json +3 -0
  13. geoguesser_env/data/geo/detail/urban.json +3 -0
  14. geoguesser_env/data/geo/ne_110m_admin_0_countries.geojson +0 -0
  15. geoguesser_env/data/geo/ne_50m_populated_places.geojson +0 -0
  16. geoguesser_env/examples/geoguesser_collect.py +120 -0
  17. geoguesser_env/examples/geoguesser_llm_rollout.py +658 -0
  18. geoguesser_env/examples/geoguesser_rollout.py +85 -0
  19. geoguesser_env/harness.py +440 -0
  20. geoguesser_env/models.py +458 -0
  21. geoguesser_env/openenv.yaml +22 -0
  22. geoguesser_env/openenv_geoguesser_env.egg-info/PKG-INFO +233 -0
  23. geoguesser_env/openenv_geoguesser_env.egg-info/SOURCES.txt +21 -0
  24. geoguesser_env/openenv_geoguesser_env.egg-info/dependency_links.txt +1 -0
  25. geoguesser_env/openenv_geoguesser_env.egg-info/entry_points.txt +2 -0
  26. geoguesser_env/openenv_geoguesser_env.egg-info/requires.txt +14 -0
  27. geoguesser_env/openenv_geoguesser_env.egg-info/top_level.txt +1 -0
  28. geoguesser_env/pyproject.toml +34 -0
  29. geoguesser_env/scripts/benchmark_throughput.py +113 -0
  30. geoguesser_env/scripts/build_dataset.sh +162 -0
  31. geoguesser_env/scripts/build_eval_set.py +511 -0
  32. geoguesser_env/scripts/build_pano_tasks.py +404 -0
  33. geoguesser_env/scripts/build_tasks.py +560 -0
  34. geoguesser_env/scripts/deploy_hub.py +427 -0
  35. geoguesser_env/scripts/fetch_detail_geo.py +106 -0
  36. geoguesser_env/scripts/harvest_tiles.py +330 -0
  37. geoguesser_env/scripts/merge_task_indexes.py +117 -0
  38. geoguesser_env/scripts/readiness_check.py +352 -0
  39. geoguesser_env/scripts/render_trace.py +237 -0
  40. geoguesser_env/scripts/split_tasks.py +193 -0
  41. geoguesser_env/scripts/verify_offline.py +206 -0
  42. geoguesser_env/server/Dockerfile +26 -0
  43. geoguesser_env/server/__init__.py +3 -0
  44. geoguesser_env/server/app.py +340 -0
  45. geoguesser_env/server/backends/__init__.py +7 -0
  46. geoguesser_env/server/backends/base.py +127 -0
  47. geoguesser_env/server/backends/panorama.py +399 -0
  48. geoguesser_env/server/geoguesser_environment.py +883 -0
  49. geoguesser_env/server/gradio_ui.py +1044 -0
  50. geoguesser_env/server/parser.py +149 -0
.gitattributes CHANGED
@@ -35,3 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  env/data/geo/detail/roads.json filter=lfs diff=lfs merge=lfs -text
37
  env/data/geo/detail/urban.json filter=lfs diff=lfs merge=lfs -text
 
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  env/data/geo/detail/roads.json filter=lfs diff=lfs merge=lfs -text
37
  env/data/geo/detail/urban.json filter=lfs diff=lfs merge=lfs -text
38
+ geoguesser_env/data/geo/detail/roads.json filter=lfs diff=lfs merge=lfs -text
39
+ geoguesser_env/data/geo/detail/urban.json filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -50,9 +50,14 @@ RUN pip install --no-cache-dir \
50
  "typing_extensions>=4.0.0"
51
 
52
  COPY src /app/src
53
- COPY env /app/env
 
 
 
 
 
54
 
55
- ENV PYTHONPATH=/app/env:/app/src
56
 
57
  # The web UI is off in core by default; without this the Space serves a 404 at
58
  # /web and looks broken.
@@ -66,12 +71,12 @@ ENV GEOGUESSER_TASKS_EVAL=/data/tasks/eval_pano_v3.jsonl \
66
  GEOGUESSER_CACHE=/data/panos \
67
  GEOGUESSER_ALLOW_FETCH=0 \
68
  GEOGUESSER_STREET_DETAIL=1 \
69
- GEOGUESSER_INDEX=/app/env/tasks/eval_pano_v3.jsonl
70
 
71
  # Overpass results cache here, on the container's own writable disk, which is
72
  # why street labels work even with Mapillary fetching disabled.
73
- RUN mkdir -p /app/env/data/geo/osm_cache /app/env/data/panos \
74
- && chmod -R 777 /app/env/data
75
 
76
  EXPOSE 8000
77
- CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
 
50
  "typing_extensions>=4.0.0"
51
 
52
  COPY src /app/src
53
+ # As a package directory, not a bare source root. Modules inside do
54
+ # `from ..models import ...`, which only resolves when `geoguesser_env` itself
55
+ # is the package -- with the sources at /app/env and PYTHONPATH pointing there,
56
+ # `server` becomes top-level and those imports fail with "attempted relative
57
+ # import beyond top-level package".
58
+ COPY env /app/geoguesser_env
59
 
60
+ ENV PYTHONPATH=/app:/app/src
61
 
62
  # The web UI is off in core by default; without this the Space serves a 404 at
63
  # /web and looks broken.
 
71
  GEOGUESSER_CACHE=/data/panos \
72
  GEOGUESSER_ALLOW_FETCH=0 \
73
  GEOGUESSER_STREET_DETAIL=1 \
74
+ GEOGUESSER_INDEX=/app/geoguesser_env/tasks/eval_pano_v3.jsonl
75
 
76
  # Overpass results cache here, on the container's own writable disk, which is
77
  # why street labels work even with Mapillary fetching disabled.
78
+ RUN mkdir -p /app/geoguesser_env/data/geo/osm_cache /app/geoguesser_env/data/panos \
79
+ && chmod -R 777 /app/geoguesser_env/data
80
 
81
  EXPOSE 8000
82
+ CMD ["uvicorn", "geoguesser_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
geoguesser_env/.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Panorama bytes are a runtime cache, not source. They are re-fetchable from
2
+ # the task index, which is committed.
3
+ data/panos/
4
+ data/geo/detail/
5
+ data/geo/osm_cache/
6
+ rollouts/
7
+
8
+ # The harvested sequence pool: 254 MB of upstream metadata, rebuildable with
9
+ # scripts/harvest_tiles.py and not source.
10
+ data/pool/
11
+
12
+ # Intermediate build output. eval/train are carved from it by split_tasks.py.
13
+ tasks/pool_offline_5k.jsonl
14
+
15
+ # The training index is ~20 MB and lives in the Storage Bucket alongside the
16
+ # imagery. The eval index stays committed: it is small, and a frozen benchmark
17
+ # belongs in version control where changes to it are visible in review.
18
+ tasks/train_pano_v3.jsonl
19
+
20
+ # Superseded eval sets, kept locally until the v3 split is deployed.
21
+ tasks/eval_pano_v1.jsonl
22
+ tasks/eval_pano_v2.jsonl
23
+ tasks/eval_balanced_trial.jsonl
geoguesser_env/.openenvignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ data/panos/
2
+ tests/fixtures/panos/
3
+ __pycache__/
4
+ *.pyc
geoguesser_env/DESIGN.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # geoguesser_env — design
2
+
3
+ A GeoGuessr-style visual geolocation environment: the agent is dropped at an
4
+ unknown street-level location, looks around, walks along the road, pins
5
+ candidate coordinates on a map to check itself, and commits to a final guess.
6
+ Reward is distance-based.
7
+
8
+ Independent open-source project, unaffiliated with GeoGuessr AB. Imagery is
9
+ openly licensed (CC-BY-SA, Mapillary contributors).
10
+
11
+ ## Why one environment, not three
12
+
13
+ The task, reward curve, response parser, map renderer and pin loop are
14
+ identical no matter where the imagery comes from. Only the imagery source
15
+ changes, and with it *which tools exist*. So there is one environment and a
16
+ `PanoramaBackend` protocol behind it:
17
+
18
+ | Backend | `look` | `move` | Licence | Role |
19
+ |---------|--------|--------|---------|------|
20
+ | `mapillary` (this PR) | yes — local reprojection | yes — sequence graph | CC-BY-SA | training + eval |
21
+ | `dataset` (osv5m, later) | no — single fixed view | no | CC-BY-SA-4.0 | 5.1M-image scale |
22
+ | `google` (later) | yes | yes — true pano links | ToS-restricted | eval/demo only |
23
+
24
+ Capability gaps surface as *unregistered tools*, never as a different
25
+ observation schema — a policy trained against one backend runs unmodified
26
+ against another. Registering a tool that always errors just teaches a policy
27
+ to burn its step budget.
28
+
29
+ ## Determinism contract
30
+
31
+ ```python
32
+ reset(split="eval", index=7) # exact task, byte-identical -> GRPO, eval
33
+ reset(seed=42) # tasks[42 % len(tasks)] -> replay
34
+ reset() # random task, index in metadata -> UI "next"
35
+ ```
36
+
37
+ Three things make repeats byte-identical: panorama bytes come from local disk
38
+ (never an expiring CDN URL), reprojection is pure numpy with integer
39
+ sampling, and the initial heading is pinned to the panorama's own
40
+ `compass_angle` so `look(0)` is true north in every task.
41
+
42
+ A GRPO group calls `reset(split=s, index=k)` N times and gets N identical
43
+ starting observations.
44
+
45
+ ## Task API
46
+
47
+ Tasks are consumed through `openenv.core.harness`, not a bespoke index.
48
+ `GeoGuesserSessionFactory` (see `harness.py`) maps a task dict to
49
+ `reset_kwargs={"task_index": ...}`, which gives us, unchanged:
50
+
51
+ - `CollectRunner(tasks=...)` — JSONL rollout collection with resume, and
52
+ `EpisodeRecord.task` recording which task produced each episode.
53
+ - `build_harness_rollout_func(...)` — a TRL-compatible rollout function where
54
+ each prompt *is* a task.
55
+ - `EvalConfig` / `EvalResult` — `harness_version`, `library_versions` and
56
+ `dataset` are exactly the provenance an eval score needs.
57
+
58
+ ## Imagery: lazy cache, not a bundled corpus
59
+
60
+ Mapillary `thumb_*_url` values are expiring signed CDN URLs, so imagery is
61
+ resolved to a local cache and never fetched mid-rollout from a URL stored in
62
+ the index. The task index (`tasks/eval_pano_v3.jsonl`, 1.2 MB, committed) holds
63
+ image ids, coordinates, sequence ids, headings, capture dates, creator
64
+ attribution and a sha256 per start frame.
65
+
66
+ - Build time: download each task's **start frame** (~29 MB for 100 tasks).
67
+ - Runtime: movement frames fetch on first use and cache to disk.
68
+ - `--prefetch-frames N` warms a task's whole sequence for a frozen eval.
69
+
70
+ `MAPILLARY_API_KEY` is needed by the builder, and at runtime only on a cache
71
+ miss. A fully warmed cache runs offline.
72
+
73
+ ## Reward
74
+
75
+ ```
76
+ geo = exp(-haversine_km / 1492.7) # GeoGuessr's curve, in [0, 1]
77
+ partial = 0.15 * country_hit + 0.10 * region_hit
78
+ cost = 0.01*looks + 0.01*maps + 0.02*pins + 0.05*moves
79
+ reward = clip(geo + partial, 0, 1) - cost
80
+ ```
81
+
82
+ An unparseable or out-of-range guess scores `0.0` with feedback, following
83
+ `pelican_svg_env`: extraction failures belong in the score, not hidden in the
84
+ harness.
85
+
86
+ ## The pin loop, and the trap in it
87
+
88
+ `place_pin(lat, lon)` returns a rendered map plus a description of **what the
89
+ agent pinned** — country, subregion, nearest city with distance and bearing,
90
+ and the distance to its own earlier pins. It reveals *nothing* about the
91
+ target.
92
+
93
+ If pin feedback carried any signal about the truth (distance, warmer/colder,
94
+ a highlighted region), the optimal policy would be binary search — about
95
+ twenty pins to metre-level accuracy — and the environment would measure
96
+ bisection instead of geographic reasoning. Distance and score arrive only
97
+ from `submit_guess`, which is terminal.
98
+
99
+ The same reasoning constrains map detail: **detail is a function of zoom
100
+ alone, never of proximity to the answer.** Prefetching high-resolution map
101
+ data around task locations would turn the cache into a ground-truth oracle.
102
+
103
+ ## Measured facts behind these choices
104
+
105
+ Probed against the live Graph API:
106
+
107
+ - `camera_type` returns `spherical` — *not* the `equirectangular` the docs
108
+ claim. Filtering on the documented value matches nothing.
109
+ - Panoramas are true 2:1 equirectangular (`thumb_2048` = 2048x1024,
110
+ original 7680x3840). Faces and plates arrive pre-blurred.
111
+ - Sequence frames sit ~3.3 m apart (measured mean over 12 frames: 3.3 m,
112
+ min 3.1, max 3.7) — finer granularity than Street View's ~10 m.
113
+ - Reprojection costs ~23 ms per 640x640 view in numpy.
114
+ - `/images` search is not a bulk endpoint: `limit` does not cap the scan and
115
+ dense bboxes beyond ~±0.0005 deg fail with "reduce the amount of data".
116
+ Discovery therefore uses many tiny bboxes.
117
+ - Panorama coverage at Street-View coordinates: any imagery 21/45 locations,
118
+ 360-degree panoramas only 7/45 (16%), heavily clustered. Expect a
119
+ Europe-weighted task distribution and document it rather than hiding it.
120
+
121
+ ## One guess per episode
122
+
123
+ `submit_guess` is terminal, so an episode is exactly one guess. The play page's
124
+ five-round game is a wrapper around five separate episodes; nothing in the
125
+ environment accepts a second guess, and the pin loop is deliberately not a
126
+ second chance — it is how a policy checks its own arithmetic before committing.
127
+
128
+ ## Zoom needs the original panorama
129
+
130
+ Zooming a 2048x1024 panorama is resolution-starved: a 30-degree view samples
131
+ about 170 source pixels, and the measured mean gradient barely moves (6.60 at 90
132
+ degrees, 7.03 at 30). The 7680x3840 original roughly doubles it (10.23, 14.87).
133
+ Each panorama is therefore cached twice, and the field of view selects the
134
+ derivative: wide views render from the 2048 in ~30 ms, views at or below 45
135
+ degrees from the original in ~70 ms. A missing original degrades to a soft view
136
+ rather than failing the step.
137
+
138
+ ## Known gaps versus the real game
139
+
140
+ Movement is bounded by captured sequences and dead-ends where one does; frame
141
+ spacing varies enormously between sequences, from ~3 m to ~57 m, so `move()`
142
+ reports the distance it actually travelled rather than the distance requested;
143
+ there is no multi-round cumulative score in the environment itself; step budget replaces the wall-clock
144
+ timer; there is no satellite layer on the guess map. NMPZ mode is nearly free
145
+ (register only `place_pin` and `submit_guess`) and should ship as a difficulty
146
+ tier. Coverage hints and web search are deliberately excluded — the first is a
147
+ crutch, the second turns the task into retrieval.
geoguesser_env/HARVEST.md ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Harvesting panorama tasks
2
+
3
+ Measured, not estimated. Every number below came from running the pipeline
4
+ against the live Mapillary Graph API.
5
+
6
+ ## The pipelines, and what actually happened
7
+
8
+ ```
9
+ ┌──────────────────────────────────────────────┐
10
+ seeds │ road points cities osv5m rows tiles │
11
+ └──────┬──────────┬───────────┬──────────┬─────┘
12
+ │ │ │ │
13
+ discovery ┌─────▼────┐┌────▼─────┐┌────▼─────┐┌───▼────────┐
14
+ │ P1 point ││ P2 city ││ P3 osv5m ││ P4 vector │
15
+ │ probe ││ adaptive ││ sequence ││ tiles │
16
+ └─────┬────┘└────┬─────┘└────┬─────┘└───┬────────┘
17
+ 5% hit 26% hit 0% hit not needed
18
+ │ │ │ │
19
+ └──────────┴─────┬─────┴──────────┘
20
+ │
21
+ expansion ┌────────────▼───────────┐
22
+ │ group by sequence_id │
23
+ │ 16 sequences per hit │
24
+ └────────────┬───────────┘
25
+ │
26
+ assembly ┌────────────▼───────────┐
27
+ │ image_ids?sequence_id │
28
+ │ -> ordered frames │
29
+ │ entity fetch per frame │
30
+ └────────────┬───────────┘
31
+ │
32
+ filtering ┌────────────▼───────────┐
33
+ │ is_pano, >= N frames │
34
+ │ country known │
35
+ │ per-country cap │
36
+ │ not in train sequences │
37
+ │ > 1 km from train │
38
+ └────────────┬───────────┘
39
+ │
40
+ ┌────────────────────┴────────────────────┐
41
+ │ │
42
+ ┌─────────▼──────────┐ ┌───────────▼─────────┐
43
+ │ eval: mirror bytes │ │ train: index only, │
44
+ │ + sha256, frozen │ │ imagery lazy │
45
+ └────────────────────┘ └─────────────────────┘
46
+ ```
47
+
48
+ ### P1 — point probe (what `build_pano_tasks.py` does)
49
+
50
+ Sample a coordinate, search a 45 m box, keep the panoramas.
51
+
52
+ **5% of seeds hit.** 3,600 probes produced 100 tasks over two passes. It works
53
+ but wastes 19 requests in 20.
54
+
55
+ The failure is not coverage, it is the box. A 45 m box misses a mapped road
56
+ 120 m away. That single misreading is why I first told you panorama coverage was
57
+ sparse; it is not.
58
+
59
+ ### P2 — city-seeded adaptive box (recommended)
60
+
61
+ Seed at populated places, try a 550 m box first and walk down to 220 m then 65 m
62
+ when the server refuses, and pass `is_pano=true`.
63
+
64
+ | Seeding | Hit rate | Sequences per hit |
65
+ |---------|---------:|------------------:|
66
+ | Street View coordinates, 45 m box | 5% | — |
67
+ | cities jittered ±28 km, adaptive | 3% | 6.0 |
68
+ | cities jittered ±2 km, adaptive | 21% | 6.5 |
69
+ | **city centres, adaptive** | **26%** | **16.0** |
70
+
71
+ 80 city seeds returned 336 sequences in 28 seconds on 10 workers, ~0.34 s per
72
+ seed. Denver alone gave 86 sequences, Ramallah 57, Cuenca 43.
73
+
74
+ Two facts make this work:
75
+
76
+ - `is_pano=true` is a **real server-side filter**. One request now answers "are
77
+ there panoramas here" rather than returning a hundred phone photographs to
78
+ sift. Verified: 76 results in a dense box, every one `spherical`.
79
+ - Panoramas are **extremely clustered**. A 360 rig drives a route and uploads
80
+ hundreds of frames, so one hit is a neighbourhood, not a point. Rural Iowa at
81
+ a 550 m box returned 1,801 panoramas.
82
+
83
+ ### P3 — expand osv5m sequences (dead end)
84
+
85
+ osv5m is itself Mapillary, and it carries a `sequence` column, so in principle
86
+ its 4.9M rows are free seeds. I probed 40 of its sequences.
87
+
88
+ **Zero contained a spherical image.** osv5m sampled perspective imagery, which
89
+ is what most of Mapillary is. Useful finding, negative result: do not spend time
90
+ here.
91
+
92
+ Its sequences are long, though — median 502 frames, up to 2,863 — which is worth
93
+ knowing for movement tasks.
94
+
95
+ ### P4 — vector coverage tiles, sequence layer (the right answer)
96
+
97
+ I dismissed this at first by looking at the wrong layer. `mly1_public` has three:
98
+
99
+ | Layer | Zooms | Geometry | Carries |
100
+ |-------|-------|----------|---------|
101
+ | overview | 0-5 | points | `id`, `is_pano`, `captured_at` |
102
+ | **sequence** | **6-14** | **LineString per sequence** | **`id`, `image_id`, `is_pano`, `creator_id`, `quality_score`, `captured_at`** |
103
+ | image | 14 | point per image | the above plus `compass_angle`, `sequence_id` |
104
+
105
+ The image layer is what made this look infeasible: a z14 tile covers 5.8 km², so
106
+ all land is ~25 million tiles against a 50,000/day budget. But the **sequence
107
+ layer is served from z6**, where a tile covers roughly 600 km on a side and
108
+ carries one feature per sequence — with `is_pano` and an anchor `image_id`
109
+ already on it.
110
+
111
+ Measured, one request each:
112
+
113
+ | z6 tile | Sequences | Of which panoramic | Size | Time |
114
+ |---------|----------:|-------------------:|-----:|-----:|
115
+ | Denver | 71,752 | **18,587** | 7.3 MB | 3.5 s |
116
+ | Paris | 85,920 | **11,617** | 8.6 MB | 4.2 s |
117
+ | Nairobi | 8,491 | **1,670** | 0.8 MB | 1.3 s |
118
+
119
+ So one tile over Denver yields more panorama sequences than a day of probing.
120
+
121
+ **A full global pass is 1,059 tiles.** The world is 4,096 tiles at z6 and 26%
122
+ intersect land, so at the measured sizes that is roughly 4 GB of tiles and under
123
+ an hour, against a 50,000/day budget — about 2% of it. Recall is complete rather
124
+ than a 26% lottery, and every sequence arrives with an anchor image to expand
125
+ from.
126
+
127
+ This replaces P2. Keep P2 only for topping up a specific city.
128
+
129
+ Caveats specific to tiles: features are **clipped to tile boundaries**, so a
130
+ sequence crossing tiles appears in both and must be deduplicated by `id`; the
131
+ z6 geometry is simplified, so treat it as "roughly here" and take exact
132
+ coordinates from the Graph API when assembling frames; and `is_pano` on a
133
+ sequence describes the sequence, so confirm per frame during assembly.
134
+
135
+ ## Caveats worth knowing before you trust a harvest
136
+
137
+ **The bbox ceiling is about scanned data, not returned data.** A dense city
138
+ rejects a 550 m box with HTTP 500 and "reduce the amount of data", even with
139
+ `is_pano=true`, because the filter is applied after the scan. Hence the ladder.
140
+ `limit` does not help — it caps results, not the scan.
141
+
142
+ **Clustering biases the set.** 16 sequences per hit are usually one
143
+ contributor's drive on one day. Without a per-country and ideally per-contributor
144
+ cap, a set of 10,000 tasks can be a few hundred drives, and a policy will learn
145
+ those cameras rather than geography.
146
+
147
+ **Frame spacing varies enormously**, from about 3 m to 57 m between frames in
148
+ the same request. `move()` therefore reports the distance it actually travelled
149
+ rather than the distance asked for.
150
+
151
+ **Thumbnail URLs expire** and uploads get deleted. Nothing that must be
152
+ reproducible may store a URL — mirror the bytes and hash them.
153
+
154
+ **Not every image has derivatives.** A successful metadata response can still
155
+ omit `thumb_2048_url`, which raised mid-build until it was handled.
156
+
157
+ **`camera_type` is `spherical`**, not the `equirectangular` the documentation
158
+ states. Filtering on the documented value silently matches nothing.
159
+
160
+ **Some capture dates are junk.** A frame in the current eval build reports
161
+ `1980-01`, which is a missing or zero `captured_at` rendered as an epoch date.
162
+ Harmless for scoring, but it breaks a temporal split and it is a visible oddity
163
+ in the UI, so filter to plausible years (Mapillary launched in 2013) when the
164
+ capture date matters.
165
+
166
+ **Coverage leans European** and 360 contributors cluster harder than Mapillary
167
+ as a whole. Africa and Oceania will be thin whatever the pipeline. Document it
168
+ rather than averaging it away.
169
+
170
+ ## Rate limits
171
+
172
+ | Endpoint | Limit | Used by |
173
+ |----------|-------|---------|
174
+ | Search `/images` | 10,000/min per app | discovery |
175
+ | Entity `/:id` | 60,000/min per app | frame assembly, thumbnails |
176
+ | Tiles | 50,000/day | P4 only |
177
+
178
+ Discovery at 10 workers runs about 3 requests/second, three orders of magnitude
179
+ under the search limit. The limit is not the constraint; the bbox ceiling is.
180
+
181
+ A separate key for harvesting is a good idea anyway: limits are per app, so a
182
+ harvest cannot then starve a training run filling its cache.
183
+
184
+ ## What OSV-5M did, since it is the same source
185
+
186
+ Their paper is worth copying from. They had the locations of all 1.8 billion
187
+ Mapillary images and:
188
+
189
+ - defined a **100 m x 100 m global grid** and took one image per cell, rather
190
+ than probing points
191
+ - sampled with a weight proportional to **local image density to the power
192
+ −0.75**, which sits between density-proportional sampling (biased to cities)
193
+ and area-proportional sampling (biased to large countries)
194
+ - filtered on **blurriness** (mean log magnitude spectrum under 120 dB),
195
+ **radiometry** (too dark, or a purple tint from encoding errors) and
196
+ **exposure** (70% of pixels above 250 or below 5)
197
+ - separated train from test by removing test images **within 1 km of any train
198
+ image or sharing a sequence id**
199
+
200
+ That last rule is exactly what `build_eval_set.py` already enforces, which is
201
+ reassuring. The −0.75 density weighting is the piece worth adopting: it is a
202
+ principled answer to the clustering problem that capped our first eval set at 58
203
+ tasks across only 30 countries.
204
+
205
+ ## Other openly licensed sources
206
+
207
+ **Panoramax** is a federated, open street-level imagery network with a STAC API
208
+ (`/search`, collections as sequences, items as pictures) under CC-BY-SA, with a
209
+ further grant for non-photographic derivatives under CC-BY or ODbL. Instances
210
+ are small — one reports about 315,000 pictures against Mapillary's 1.8 billion —
211
+ so it is a complement for Europe rather than a replacement, but the licensing is
212
+ cleaner and the API is a standard.
213
+
214
+ **KartaView** and the `streetlevel` library cover other providers; neither is
215
+ needed while Mapillary supplies this much.
216
+
217
+ ## The plan
218
+
219
+ 1. **Tile enumeration (P4)** — walk the 1,059 land tiles at z6, keep sequence
220
+ features with `is_pano`, deduplicate by id. About an hour, 4 GB, 2% of the
221
+ daily tile budget. Output: a global pool of panorama sequences with anchor
222
+ image ids, `creator_id` and `quality_score`.
223
+ 2. **Sample the pool, not the world** — apply OSV-5M's density weighting
224
+ (`density^-0.75`) plus a per-country and per-`creator_id` cap. Contributor
225
+ capping matters as much as country capping: 16 sequences per hit are usually
226
+ one person's drive.
227
+ 3. **Eval, 100 tasks** — sample from the pool, cap 2 per country, reject any
228
+ training sequence and anything within 1 km of one, mirror the bytes with
229
+ sha256, and add OSV-5M's blur and exposure checks on the downloaded
230
+ panorama.
231
+ 4. **Train, 10k tasks** — same pool, cap ~200 per country, index only and
232
+ imagery fetched lazily.
233
+ 5. **Augment** — derive the initial heading from the episode seed rather than
234
+ pinning it to `compass_angle`, for 8 to 12 times the effective tasks from
235
+ imagery already on disk, reproducibly.
236
+
237
+ The city-probing eval set built while researching this reached 58 of 100 tasks
238
+ across 30 countries before exhausting its 1,300 discovered sequences — which is
239
+ the clearest possible argument for step 1.
geoguesser_env/README.md ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GeoGuesser
2
+
3
+ A GeoGuessr-style visual geolocation environment. The agent is dropped at an
4
+ unknown street-level location, looks around, walks along the road, pins
5
+ candidate coordinates on a map to check itself, and commits to a final guess.
6
+ Reward is distance-based, using the game's own scoring curve.
7
+
8
+ Independent open-source project, unaffiliated with GeoGuessr AB. Imagery comes
9
+ from Mapillary contributors under CC-BY-SA-4.0.
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ cd envs/geoguesser_env
15
+
16
+ # The frozen 200-task eval split is committed, so the server runs as-is.
17
+ uv run --project . server # http://localhost:8000
18
+
19
+ # To build your own data (needs a free Mapillary token with READ scope):
20
+ export MAPILLARY_API_KEY_TRAIN="MLY|..."
21
+ python scripts/harvest_tiles.py # enumerate sequences
22
+ ./scripts/build_dataset.sh # mirror tasks offline
23
+ python scripts/verify_offline.py tasks/pool_offline_5k.jsonl
24
+ python scripts/split_tasks.py tasks/pool_offline_5k.jsonl --eval 200
25
+ ```
26
+
27
+ ```python
28
+ from geoguesser_env import GeoGuesserEnv, GuessAction, LookAction, PinAction
29
+
30
+ env = GeoGuesserEnv(base_url="http://localhost:8000")
31
+
32
+ result = env.reset(split="eval", index=7) # byte-identical on repeat
33
+ print(result.observation.prompt)
34
+
35
+ result = env.step(LookAction(heading_deg=90, fov_deg=45))
36
+ result = env.step(PinAction(lat=-16.5, lon=-68.1))
37
+ print(result.observation.feedback)
38
+ # Pin 1 placed at -16.5000, -68.1000 - Bolivia (South America).
39
+ # Nearest major city: La Paz, ~5 km E. 10 actions left.
40
+
41
+ result = env.step(GuessAction(response="Altiplano. <guess>-16.49, -68.12</guess>"))
42
+ print(result.reward, result.observation.distance_km)
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ | Tool | What it does | Cost |
48
+ |------|--------------|------|
49
+ | `look(heading_deg, pitch_deg, fov_deg)` | Render a view. Heading is absolute, `0` is true north | −0.01 |
50
+ | `pan(delta_deg)` | Turn relative to the current heading | −0.01 |
51
+ | `zoom(fov_deg)` | Narrow the field of view; around 30 reads distant signs | −0.01 |
52
+ | `move(direction, meters)` | Walk the captured road; reports distance actually travelled | −0.05 |
53
+ | `place_pin(lat, lon, label)` | Pin a candidate and see where it falls on the map | −0.02 |
54
+ | `view_map(lat, lon, span_deg)` | Pan and zoom the map without pinning | −0.01 |
55
+ | `list_pins()` / `clear_pins()` | Review or drop candidates | free |
56
+ | `measure(lat_a, lon_a, lat_b, lon_b)` | Distance between two of your own points | free |
57
+ | `reverse_geocode(lat, lon)` | Name the country and nearest city at a coordinate | free |
58
+ | `submit_guess(lat, lon, ...)` | Commit the answer. Terminal | — |
59
+
60
+ Tools the backend cannot serve are **not registered**, so the agent never sees
61
+ a tool that always fails.
62
+
63
+ ### The agent's map and the player's map agree
64
+
65
+ The player sees live OpenFreeMap tiles; the agent sees an offline Natural Earth
66
+ render. They have to agree about *how precisely a pin can be aimed*, because
67
+ that is what the distance reward measures — a map showing only country outlines
68
+ lets you place a country, not a point within a city.
69
+
70
+ So the guess map is zoom-aware. `place_pin` takes `span_deg`, and the render
71
+ adds detail as the window tightens:
72
+
73
+ | Window | What the agent's map shows |
74
+ |--------|----------------------------|
75
+ | wider than ~4 deg | coastlines, borders, country names |
76
+ | under ~4 deg | urban areas, highways, rivers, town names (Natural Earth 10m) |
77
+ | under ~0.35 deg | **real OSM streets**, fetched from Overpass and cached |
78
+
79
+ Natural Earth tops out at highway level — it shows the motorways around a city
80
+ but not the grid inside it. Below 0.35 degrees the map therefore fetches actual
81
+ ways from Overpass, generalising by zoom the way a real style does: minor
82
+ classes appear only once the window is tight enough to hold them, and widths
83
+ grow as it shrinks. A pin on Abuja at `span_deg=0.05` came back as an 11 km
84
+ window with the full street grid, drawn white-on-pale to read like the
85
+ player's Positron tiles.
86
+
87
+ Overpass has real limits, and they are the binding constraint on how this
88
+ scales: roughly **10,000 requests and 1 GB per day**, about **2 concurrent
89
+ slots per IP**, a 180 s runtime and 512 MiB memory ceiling per query, HTTP 429
90
+ when rate limited and 504 when a query is too large. Cooldowns lengthen for
91
+ heavy users. So street detail is right for eval, demos and modest training, and
92
+ the cache is what keeps it polite — a run doing millions of pins must pre-warm
93
+ or bundle a Protomaps extract instead.
94
+
95
+ The first render of a neighbourhood costs 3-16 s; every later one is served
96
+ from `data/geo/osm_cache/` in ~30 ms and is byte-identical. That makes an
97
+ episode deterministic once warm, and a frozen eval should pre-warm the cache
98
+ the same way it pre-warms panoramas — or set
99
+ `GEOGUESSER_STREET_DETAIL=0`, which falls back to Natural Earth and never
100
+ touches the network. Any fetch failure degrades to no streets rather than
101
+ failing the step.
102
+
103
+ The optional detail layers are fetched once, since 87 MB of GeoJSON does not
104
+ belong in the repo:
105
+
106
+ ```bash
107
+ python scripts/fetch_detail_geo.py # compacts to ~39 MB, gitignored
108
+ ```
109
+
110
+ Without them the map still renders, with outlines and major cities only.
111
+
112
+ In the play page the pin carries the zoom you are actually looking at, so the
113
+ "what the agent sees" panel is framed like your own view at the same scale and
114
+ with comparable detail.
115
+
116
+ Overpass has a usage policy that discourages heavy automated querying, so this
117
+ is right for eval, demos and modest training, and the cache is what keeps it
118
+ polite. A run doing millions of pins should pre-warm or bundle a Protomaps
119
+ extract instead.
120
+
121
+ ### Zoom resolves real detail
122
+
123
+ Zooming is not cosmetic, but it needs the right source. A 30-degree view of a
124
+ 2048x1024 panorama samples only about 170 source pixels, so narrowing the field
125
+ of view barely adds information — measured mean gradient 6.60 at 90 degrees
126
+ against 7.03 at 30. The 7680x3840 original roughly doubles it (10.23 against
127
+ 14.87), which is the difference between guessing at a sign and reading it.
128
+
129
+ So each panorama is cached twice. Wide views render from the 2048 derivative in
130
+ ~30 ms; a field of view at or below 45 degrees pulls the original and renders in
131
+ ~70 ms. If no original exists the step degrades to a soft view rather than
132
+ failing. Set `GEOGUESSER_HIRES_ZOOM=0` to disable it.
133
+
134
+ ### Pinning tells you where you pointed, not whether you are right
135
+
136
+ `place_pin` returns a rendered map and a description of the pinned location:
137
+ country, subregion, nearest city with distance and bearing, and the distance
138
+ to the agent's own earlier pins. It reveals nothing about the target.
139
+
140
+ That restraint is deliberate. Any signal about the truth — a distance, a
141
+ warmer/colder hint — would make binary search the optimal policy, and the
142
+ environment would measure bisection rather than geographic reasoning. Distance
143
+ and score arrive only from `submit_guess`.
144
+
145
+ ## Reward
146
+
147
+ ```
148
+ geo = exp(-distance_km / 1492.7) # GeoGuessr's curve, in [0, 1]
149
+ partial = 0.15 * country_hit + 0.10 * region_hit # when hierarchical
150
+ cost = 0.01*looks + 0.01*maps + 0.02*pins + 0.05*moves
151
+ reward = clip(geo + partial, 0, 1) - cost
152
+ ```
153
+
154
+ An unparseable or out-of-range guess scores `0.0` and says why. Parsing
155
+ accepts what models actually emit: decimal pairs, DMS (`48°51'29"N`), labelled
156
+ `lat:`/`lon:`, JSON, and `<guess>` tags.
157
+
158
+ ## Baselines
159
+
160
+ Measured with `examples/geoguesser_llm_rollout.py` on the committed index,
161
+ tasks 0/7/14/21/28, so the numbers are reproducible rather than illustrative.
162
+ Five episodes is far too few for a leaderboard; they are a smoke test that the
163
+ task is solvable and the reward is discriminative.
164
+
165
+ | Model | Mode | Mean reward | Median distance | Within 200 km | Parsed |
166
+ |-------|------|------------:|----------------:|--------------:|-------:|
167
+ | `claude-sonnet-5` | single-shot | **0.896** | 98 km | 4/5 | 5/5 |
168
+ | `claude-sonnet-5` | agentic, tasks 0-5 | 0.539-0.653 | 574-660 km | 3/6 | 6/6 |
169
+ | `Qwen/Qwen3.5-9B` | agentic, tasks 0-5 | 0.355 | 1,136 km | 0/5 | 5/6 |
170
+ | `Qwen/Qwen3.5-9B:together` | single-shot | 0.277 | 1,139 km | 1/3 | 3/5 |
171
+ | `Qwen/Qwen3.5-9B:together` | agentic, 6 turns | 0.304 | 579 km | 0/1 | 1/2 |
172
+ | `Qwen/Qwen3.5-9B:together` | agentic, 8k tokens | 0.087 | 2,423 km | 0/1 | **4/4 turns** |
173
+ | `Qwen/Qwen3.5-9B:together` | single-shot, 8 eps, 4 parallel | 0.412 | 787 km | 1/6 | 6/8 |
174
+
175
+ Sonnet placed two guesses within 2 km. The agentic score sits slightly below
176
+ single-shot on the same tasks because looking around costs reward and the extra
177
+ views did not always pay for themselves — which is the trade-off the
178
+ environment is meant to expose, not a defect.
179
+
180
+ Qwen does follow the multi-turn protocol: across the agentic runs it produced
181
+ `look`, `move`, `zoom`, `pin` and `guess` actions and navigated up to 68 m down
182
+ a road. Two things had to be right first, and both are prompting or plumbing
183
+ rather than capability:
184
+
185
+ - **Token budget.** Reasoning models put their chain of thought in a separate
186
+ `reasoning_content` field and can exhaust the budget before emitting any
187
+ content, which looks exactly like a model that cannot see images. At 1,024
188
+ tokens Qwen scored 0/5 with empty replies; at 3,500 it followed the protocol
189
+ intermittently, failing turns whose reply came back as pure reasoning; at
190
+ 8,000 it parsed 4/4 turns. The example defaults to 3,000 and takes
191
+ `--max-tokens`.
192
+ - **A deadline.** Left to itself Qwen explored until the turn budget ran out and
193
+ scored zero. The agentic prompt now warns it explicitly when two turns remain,
194
+ after which it committed.
195
+
196
+ What remains is accuracy, not plumbing: its guesses landed 452 km, 579 km and
197
+ 2,423 km out against Sonnet's 98 km median. It is also 10x slower — 110-193 s
198
+ per agentic episode against Sonnet's 10-16 s.
199
+
200
+ ```bash
201
+ python examples/geoguesser_llm_rollout.py --provider anthropic \
202
+ --model claude-sonnet-5 --episodes 5
203
+ python examples/geoguesser_llm_rollout.py --provider hf \
204
+ --model "Qwen/Qwen3.5-9B:together" --episodes 5 --max-tokens 4000
205
+ python examples/geoguesser_llm_rollout.py --provider anthropic \
206
+ --mode agentic --episodes 3 --verbose
207
+ ```
208
+
209
+ ## Parallel rollouts
210
+
211
+ An episode is stateful, so concurrent rollouts each need their own environment
212
+ instance over the shared read-only index and cache. `--concurrency` does that:
213
+
214
+ ```bash
215
+ python examples/geoguesser_llm_rollout.py --provider hf \
216
+ --model "Qwen/Qwen3.5-9B:together" --episodes 8 --concurrency 4
217
+ ```
218
+
219
+ Eight Qwen episodes took 113.6 s wall against 334.0 s of summed latency — a
220
+ **2.94x speedup** on 4 workers, the shortfall being the provider's own queuing
221
+ rather than the environment, which spends ~28 ms on a reset.
222
+
223
+ ## Scaling: what the environment can actually supply
224
+
225
+ Measured on an 18-core machine with a warm cache and street detail off, one
226
+ environment per worker, with a correctness assertion in the loop so an
227
+ interference bug cannot masquerade as throughput.
228
+
229
+ Per-step cost is dominated by map rendering, not imagery:
230
+
231
+ | Step | Cost |
232
+ |------|-----:|
233
+ | reset, or `look` at 90 deg fov | **29 ms** |
234
+ | `look` at 30 deg fov, from the original | 74 ms |
235
+ | `place_pin`, a two-panel map | **207 ms** |
236
+ | `submit_guess` with the reveal map | 278 ms |
237
+
238
+ That shapes the two configurations:
239
+
240
+ | Config | Workers | Episodes/s | Notes |
241
+ |--------|--------:|-----------:|-------|
242
+ | training — views only, no pins, no reveal | 8 threads | **31.7** | 158 env steps/s |
243
+ | eval — pins and reveal map | 4 processes | **3.5** | matplotlib is GIL-bound |
244
+ | eval — pins and reveal map | 8 threads | 1.9 | threads do not help here |
245
+
246
+ Two facts fall out of that. `look` releases the GIL — the reprojection is numpy
247
+ and the encode is Pillow — so **threads scale well for view-only work** and
248
+ processes only add startup cost. Map rendering is pure Python, so it is
249
+ **GIL-bound and needs processes**, which buy about 1.8x before contention.
250
+
251
+ `GEOGUESSER_REVEAL_MAP=0` is the single biggest throughput lever: a
252
+ training-shaped episode drops from **389 ms to 117 ms**, since the reveal map is
253
+ 280 ms that a training run never reads — the reward and the distance are in the
254
+ observation either way. Keep it on for evals, traces and the UI.
255
+
256
+ Memory is small: about 215 MB for the imports, 100 MB more once the geodata
257
+ caches fill, and **~13 MB per additional environment in the same process**. The
258
+ Natural Earth layers are process-shared through an LRU cache, so threads are far
259
+ cheaper than processes here too.
260
+
261
+ ### Rate limits that actually bind
262
+
263
+ | API | When it is called | Limit |
264
+ |-----|-------------------|-------|
265
+ | Mapillary | only on a cache miss | 60,000/min entity, 10,000/min search, 50,000/day tiles |
266
+ | Overpass | only a pin below 0.35 deg span | ~10,000/day, 2 concurrent slots per IP |
267
+ | the model | every turn | the real constraint |
268
+
269
+ With a warm cache the environment makes **no network calls at all**. Default
270
+ pins use a 7 degree span, which is above the street threshold, so they do not
271
+ touch Overpass either — only a deliberately zoomed pin does. For an eval sweep
272
+ set `GEOGUESSER_STREET_DETAIL=0` unless you have pre-warmed, since 100 zoomed
273
+ pins against 2 concurrent slots would throttle immediately.
274
+
275
+ ### 100-task eval
276
+
277
+ Sonnet agentic measured 13.1 s per episode, so 100 episodes cost about 1,310
278
+ model-seconds and the concurrency you can use is set by the provider, not by
279
+ this environment:
280
+
281
+ - concurrency 4 → **~5.5 minutes**
282
+ - concurrency 8 → **~2.7 minutes**, if your token-per-minute ceiling allows it
283
+
284
+ An agentic episode sends roughly five 640x640 views, about 550 tokens each, plus
285
+ a growing text prompt. At a 400k input-tokens-per-minute ceiling that is roughly
286
+ 6 episodes in flight before tokens, not latency, become the limit — which is why
287
+ 4 to 6 is the practical range for Sonnet. Qwen through the router reached 3.97x
288
+ on 6 workers, so 6 to 8 there.
289
+
290
+ Meanwhile the environment can supply 3.5 episodes/s in eval configuration
291
+ against the 0.3 to 0.6 episodes/s those concurrencies actually consume, so it
292
+ has roughly ten times the headroom it needs.
293
+
294
+ ### 1000 training steps
295
+
296
+ For GRPO with 8 prompts and a group of 16, that is 128 episodes per step and
297
+ **128,000 episodes**, or about 640,000 env steps:
298
+
299
+ - environment time: 128,000 / 31.7 ≈ **1.1 hours total**, spread across workers
300
+ - imagery: nothing, with a warm cache
301
+ - storage: 100 tasks warm is 60 MB; 5,000 tasks would be about 1.5 GB of start
302
+ frames
303
+
304
+ So the environment is not the constraint — 640,000 model calls are, which needs
305
+ batched local inference rather than an API. The constraint that *is* ours is
306
+ **task diversity**: 128,000 episodes over 100 tasks means each location is seen
307
+ 1,280 times, which is memorisation territory. Before a run that long, either
308
+ harvest more tasks or add seeded heading augmentation, which multiplies
309
+ effective tasks 8 to 12 times from imagery already on disk.
310
+
311
+ ## Readiness audit
312
+
313
+ `scripts/readiness_check.py` checks the properties that only appear at real
314
+ scale and concurrency, rather than on the four committed fixtures:
315
+
316
+ ```bash
317
+ python scripts/readiness_check.py --full
318
+ ```
319
+
320
+ ```
321
+ [PASS] index integrity 100 tasks, 47 countries, 100 unique sequences
322
+ [PASS] all tasks render 100/100 rendered, median reset 28 ms
323
+ [PASS] cross-process determinism two subprocesses and this process agree
324
+ [PASS] parallel isolation 8 concurrent episodes, each its own task
325
+ [PASS] offline with warm cache 12/12 served with fetching disabled
326
+ [PASS] reward is discriminative uniform-random 0.029, fixed-point 0.111
327
+ [PASS] step latency look 29 ms, pin + map 249 ms
328
+ [PASS] one guess per episode a second guess returns reward=None
329
+ [PASS] pin never leaks the target 60 pins across 20 tasks revealed nothing
330
+ ```
331
+
332
+ The reward check matters most: a uniform-random guesser scores **0.029** and the
333
+ best trivial constant guess **0.111**, against Sonnet's 0.896. The signal is
334
+ measuring geolocation rather than rewarding noise.
335
+
336
+ ## Tracing a rollout
337
+
338
+ `--trace-dir` records every turn: the image the model saw, what it said, the
339
+ action it chose, the environment's reply, steps left and running cost. A second
340
+ script renders that as one self-contained HTML page, which is the difference
341
+ between knowing the reward and seeing why:
342
+
343
+ ```bash
344
+ python examples/geoguesser_llm_rollout.py --provider anthropic \
345
+ --mode agentic --episodes 6 --trace-dir rollouts
346
+ python scripts/render_trace.py rollouts/anthropic_agentic
347
+ ```
348
+
349
+ Images are written beside the trace rather than inlined, since six agentic
350
+ episodes carry around 35 views and a JSONL with those in it is neither readable
351
+ nor loadable.
352
+
353
+ Every step carries an image, including the guess: a guess returns a **reveal
354
+ map** with the guess, the true location and the line between them. Truth is
355
+ drawn only there, after scoring. When the guess is more than 25 degrees out the
356
+ second panel frames the true location instead of both points, because squashing
357
+ a hemisphere into a panel shows nothing.
358
+
359
+ ## Task API
360
+
361
+ The environment implements the core `TaskProvider` protocol, so the Task API
362
+ routes core already registers become live. Task discovery is metadata only; it
363
+ never starts an episode.
364
+
365
+ ```bash
366
+ curl localhost:8000/geoguesser_env/splits
367
+ # [{"name":"train","type":"train","num_tasks":3452,"default":true},
368
+ # {"name":"eval","type":"test","num_tasks":200,"default":false}]
369
+
370
+ curl -X POST localhost:8000/geoguesser_env/num_tasks -d '{"split":"eval"}'
371
+ curl -X POST localhost:8000/geoguesser_env/task -d '{"split":"eval","index":12}'
372
+ ```
373
+
374
+ ```python
375
+ env.list_splits() # [{"name": "eval", "type": "test", ...}, ...]
376
+ env.num_tasks("eval") # 200
377
+ env.get_task("eval", 12) # metadata, no coordinates and no country
378
+ ```
379
+
380
+ **Task specs are deliberately truth-free.** They carry `task_index`, `task_id`,
381
+ `split`, `n_frames`, `provider`, `sequence_id` and `offline_ready` — never
382
+ coordinates and never the country. A spec travels to whatever orchestrates a
383
+ run, and a label sitting in a spec can reach a prompt. The true location is
384
+ revealed in observation metadata after the guess, which is the one place it
385
+ belongs. Per-country eval breakdowns therefore come from finished episodes, not
386
+ from `list_tasks`.
387
+
388
+ Splits are invisible to the agent. `RESERVED_TOOL_NAMES` blocks a `reset` MCP
389
+ tool, so there is no way for a policy to see or choose its own task — the
390
+ "agents cannot reset" invariant.
391
+
392
+ ## Reproducibility
393
+
394
+ ```python
395
+ env.reset(split="eval", index=7) # exact task, byte-identical -> GRPO, eval
396
+ env.reset(split="train", seed=42) # tasks[42 % n_tasks] -> replay
397
+ env.reset() # random task in the default split, split and
398
+ # index both recorded in metadata -> UI
399
+ ```
400
+
401
+ `task_index=` still works as an alias for `index=`, so trajectories recorded
402
+ before splits existed still replay. The split is recorded in observation
403
+ metadata: without it a bare index is ambiguous across three indexes, and a
404
+ trajectory stops being replayable.
405
+
406
+ Byte-identical repeats hold because panorama bytes come from a local cache
407
+ rather than an expiring CDN URL, reprojection is pure numpy with integer
408
+ sampling, and the initial heading is pinned to each panorama's own
409
+ `compass_angle`.
410
+
411
+ An eval score is only meaningful alongside its provenance — the env version,
412
+ the task index, and `GEODATA_VERSION` from `server/render/minimap.py`, since
413
+ the bundled vectors determine the reverse-geocode text the agent sees.
414
+
415
+ ## Training and collection
416
+
417
+ The environment plugs into `openenv.core.harness`, so a rollout function and a
418
+ collector come for free:
419
+
420
+ ```python
421
+ from geoguesser_env import GeoGuesserEnv
422
+ from geoguesser_env.harness import GeoGuesserSessionFactory, load_tasks
423
+
424
+ tasks = load_tasks("tasks/train_pano_v3.jsonl", repeat=16, split="train")
425
+ factory = GeoGuesserSessionFactory(
426
+ lambda: GeoGuesserEnv(base_url="http://localhost:8000")
427
+ )
428
+ ```
429
+
430
+ See `examples/geoguesser_rollout.py` for a scripted rollout and
431
+ `examples/geoguesser_collect.py` for JSONL collection with resume.
432
+
433
+ ## Human play
434
+
435
+ A five-round game, 5,000 points a round on the same curve the environment
436
+ rewards, so a human score is directly comparable to GeoGuessr intuition and to
437
+ the agent's reward (both are shown).
438
+
439
+ **A round is one episode with one guess.** The five-round game is a UI wrapper
440
+ around five separate episodes; the environment itself never accepts more than
441
+ one guess, because `submit_guess` is terminal.
442
+
443
+ The page plays through the environment rather than simulating it. It opens the
444
+ same WebSocket session API a client uses, calls `reset(task_index=...)`, and
445
+ sends every pin, look, zoom and move as a real charged step — so the step
446
+ counter, the accumulated cost and the final reward are the environment's own
447
+ numbers, not the browser's. A side panel shows the observation stream an agent
448
+ would receive, including the environment's own rendered map and views.
449
+
450
+ Note that plain REST `/step` builds a fresh environment per request, so a
451
+ stateful episode has to run over `/ws`; the Python client does this already.
452
+
453
+ ```bash
454
+ uv run --project . server
455
+ # then open http://localhost:8000/geoguesser/play
456
+ ```
457
+
458
+ The page stands alone at `/geoguesser/play` and is also embedded in the Gradio
459
+ playground's **Custom** tab when the web interface is enabled:
460
+
461
+ ```bash
462
+ ENABLE_WEB_INTERFACE=true uv run --project . server # http://localhost:8000/web/
463
+ ```
464
+
465
+ Pick a split and an episode with the `reset(split=)` and `reset(index=)`
466
+ controls above the game and press
467
+ **load episode**, or **random episode** — the same call an eval harness makes,
468
+ so you can replay exactly the episode an agent saw. Those controls live on the
469
+ Gradio side because choosing a task is orchestration, not something the player
470
+ does mid-round; the page itself reads `?task=` from its URL, so
471
+ `/geoguesser/play?task=42` opens that episode directly.
472
+
473
+ Drag to look around and scroll to zoom (free, for orientation). The `look()`
474
+ and `zoom(30)` buttons run charged environment steps and show what the agent
475
+ sees. Arrows, or the arrow keys, walk the road — the main view follows, keeping
476
+ your heading. **M** toggles a larger map, **T** the trace panel, **Enter**
477
+ submits and then advances. On submit the map takes the screen and draws the
478
+ line between guess and truth, exactly like the game; the result bar shows
479
+ distance, points, env reward and the true location, and a scoreboard breaks
480
+ down all five rounds at the end.
481
+
482
+ Panoramas are rendered by Pannellum and the map by MapLibre over OpenFreeMap
483
+ tiles — no API key, no request limits. The imagery credit line names the
484
+ Mapillary contributor, which the CC-BY-SA licence requires.
485
+
486
+ The page has to be a standalone document rather than a Gradio `gr.HTML`
487
+ fragment: `gr.HTML` inserts markup without executing `<script>` tags, so the
488
+ viewers never initialise and the panel renders blank with no error anywhere.
489
+
490
+ Extra routes, all local:
491
+
492
+ | Route | Returns |
493
+ |-------|---------|
494
+ | `/geoguesser/play` | the play page |
495
+ | `/geoguesser/tasks` | `{"n_tasks": N}` |
496
+ | `/geoguesser/task/{i}` | task metadata, including ground truth for the human UI |
497
+ | `/geoguesser/pano/{i}` | the starting equirectangular panorama |
498
+ | `/geoguesser/pano/{i}/{frame}` | one frame's panorama, so the viewer follows `move()` |
499
+
500
+ The human map uses live tiles; the agent's map stays the offline Natural Earth
501
+ render, so the agent keeps a determinism the browser does not need. Note that
502
+ `/geoguesser/task/{i}` exposes ground truth — it exists for a person playing in
503
+ their own browser, and agent observations still withhold it until the guess.
504
+
505
+ ## Configuration
506
+
507
+ | Variable | Default | Meaning |
508
+ |----------|---------|---------|
509
+ | `GEOGUESSER_TASKS_EVAL` | `tasks/eval_pano_v3.jsonl` | Frozen eval split |
510
+ | `GEOGUESSER_TASKS_TRAIN` | `tasks/train_pano_v3.jsonl` | Training split |
511
+ | `GEOGUESSER_DEFAULT_SPLIT` | `train` | Split `reset()` uses when none is named |
512
+ | `GEOGUESSER_INDEX` | `tasks/pano_v1.jsonl` | Legacy single index, used only when no split resolves |
513
+ | `GEOGUESSER_CACHE` | `data/panos` | Panorama cache directory |
514
+ | `GEOGUESSER_EPISODE_MODE` | `agentic` | `agentic`, `single_shot` or `nmpz` |
515
+ | `GEOGUESSER_MAX_STEPS` | `24` | Actions before the episode is cut off |
516
+ | `GEOGUESSER_REWARD_MODE` | `coords` | `coords` or `country_only` |
517
+ | `GEOGUESSER_HIERARCHICAL` | `0` | Add country and region partial credit |
518
+ | `GEOGUESSER_VIEW_SIZE` | `640` | Edge length of rendered views |
519
+ | `GEOGUESSER_ALLOW_FETCH` | `1` | Whether a cache miss may reach the API |
520
+ | `GEOGUESSER_HIRES_ZOOM` | `1` | Render views at or below 45 deg fov from the original |
521
+ | `GEOGUESSER_STREET_DETAIL` | `1` | Fetch real OSM streets below 0.35 deg. Governs Overpass only, independent of `ALLOW_FETCH`, and caches to local disk |
522
+ | `GEOGUESSER_REVEAL_MAP` | `1` | Draw the guess-versus-truth map; `0` is 3x faster for training |
523
+ | `MAPILLARY_API_KEY` | — | Needed by the builder, and only on a cache miss |
524
+
525
+ ## Data
526
+
527
+ Three splits, carved from one 3,673-task pool so contamination is enforced
528
+ exactly once, at split time, rather than reasoned about across two harvests:
529
+
530
+ | Split | Type | Tasks | Countries | Offline |
531
+ |---|---|---|---|---|
532
+ | `eval` | `test` | 200 | 73, capped at 4 each | all 24 frames mirrored |
533
+ | `train` | `train` | 3,452 | 132 | all 24 frames mirrored |
534
+ | `random` | `validation` | 1.2M pool rows | global | no, fetches on demand |
535
+
536
+ Separation follows the OSV-5M rule: no shared `sequence_id`, and no training
537
+ task within 1 km of an eval task. Frames sit ~3.3 m apart, so holding out an
538
+ image while keeping its neighbour holds out nothing. The split script verifies
539
+ its own work and exits non-zero if either rule is violated — the committed
540
+ split reports 0 shared sequences and a closest train task 1.07 km away.
541
+
542
+ | | |
543
+ |---|---|
544
+ | Frames per task | 23.2 mean (8 min, 24 max), ~3.3 m apart |
545
+ | Eval index | 1.2 MB, committed |
546
+ | Train index | 20 MB, in the Storage Bucket |
547
+ | Imagery | 22 GB for 86k frames, 0.26 MB mean per frame |
548
+
549
+ `eval` is committed because a frozen benchmark belongs in version control,
550
+ where a change to it shows up in review. The training index and the imagery
551
+ live in a Storage Bucket, mounted read-only at `/data` on a Space.
552
+
553
+ The `random` split is **not yet implemented** — the plumbing takes arbitrary
554
+ named splits, but the pool-backed sampler is still to come.
555
+
556
+ Each index is self-contained: every frame's coordinates, heading and capture
557
+ date live in the JSONL, so the movement graph resolves offline.
558
+ Only image bytes are fetched, and only on a cache miss, because Mapillary
559
+ `thumb_*_url` values are expiring signed URLs that cannot be stored.
560
+
561
+ Coverage is uneven and worth knowing about. Probing 45 Street-View
562
+ coordinates found any Mapillary imagery at 21 and a 360-degree panorama at
563
+ only 7, heavily clustered. Panorama-first discovery is therefore the only
564
+ approach that works — roughly 5% of probe points yield a usable sequence, so
565
+ reaching 100 tasks took two passes with different seeds, merged by
566
+ `scripts/merge_task_indexes.py`. Africa and Oceania are thin because 360-degree
567
+ contributors are; that is a property of the source, documented rather than
568
+ papered over.
569
+
570
+ ## Known gaps versus the real game
571
+
572
+ Movement follows captured sequences and stops where one ends. There is no
573
+ multi-round cumulative score, no wall-clock timer (a step budget stands in for
574
+ it), and no satellite layer on the guess map. Coverage hints and web search are
575
+ deliberately excluded: the first is a crutch, the second turns the task into
576
+ retrieval.
577
+
578
+ See [DESIGN.md](DESIGN.md) for the reasoning behind these choices.
geoguesser_env/__init__.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """GeoGuesser: a GeoGuessr-style visual geolocation environment.
4
+
5
+ Independent open-source project, unaffiliated with GeoGuessr AB. Imagery comes
6
+ from Mapillary contributors under CC-BY-SA-4.0.
7
+ """
8
+
9
+ from .client import GeoGuesserEnv
10
+ from .models import (
11
+ EpisodeMode,
12
+ from_wire,
13
+ GeoGuesserAction,
14
+ GeoGuesserObservation,
15
+ GeoGuesserState,
16
+ GuessAction,
17
+ LookAction,
18
+ MeasureAction,
19
+ MoveAction,
20
+ PanAction,
21
+ Pin,
22
+ PinAction,
23
+ RewardMode,
24
+ to_wire,
25
+ TypedAction,
26
+ ViewMapAction,
27
+ ZoomAction,
28
+ )
29
+
30
+ __all__ = [
31
+ "EpisodeMode",
32
+ "GeoGuesserAction",
33
+ "GeoGuesserEnv",
34
+ "GeoGuesserObservation",
35
+ "GeoGuesserState",
36
+ "GuessAction",
37
+ "LookAction",
38
+ "MeasureAction",
39
+ "MoveAction",
40
+ "PanAction",
41
+ "Pin",
42
+ "PinAction",
43
+ "RewardMode",
44
+ "TypedAction",
45
+ "ViewMapAction",
46
+ "ZoomAction",
47
+ "from_wire",
48
+ "to_wire",
49
+ ]
geoguesser_env/client.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """HTTP client for the GeoGuesser environment."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Dict
8
+
9
+ from openenv.core.client_types import StepResult
10
+ from openenv.core.env_client import EnvClient
11
+
12
+ from .models import (
13
+ GeoGuesserAction,
14
+ GeoGuesserObservation,
15
+ GeoGuesserState,
16
+ to_wire,
17
+ TypedAction,
18
+ )
19
+
20
+
21
+ ENV_NAME = "geoguesser_env"
22
+ """Task API routes are namespaced by the `env_name` the server registers."""
23
+
24
+
25
+ class GeoGuesserEnv(
26
+ EnvClient[GeoGuesserAction, GeoGuesserObservation, GeoGuesserState]
27
+ ):
28
+ """
29
+ Client for a running GeoGuesser environment server.
30
+
31
+ Examples:
32
+
33
+ ```python
34
+ env = GeoGuesserEnv(base_url="http://localhost:8000")
35
+ result = env.reset(task_index=0)
36
+ result = env.step(LookAction(heading_deg=90))
37
+ result = env.step(GuessAction(response("<guess>55.67, 12.57</guess>")))
38
+ print(result.reward, result.observation.distance_km)
39
+ ```
40
+ """
41
+
42
+ def _step_payload(self, action: GeoGuesserAction | TypedAction) -> Dict[str, Any]:
43
+ """
44
+ Serialise an action for the `/step` endpoint.
45
+
46
+ Typed actions are flattened to the single wire schema the server
47
+ declares; a wire action passes through unchanged.
48
+ """
49
+ wire = action if isinstance(action, GeoGuesserAction) else to_wire(action)
50
+ return wire.model_dump(exclude_none=True)
51
+
52
+ def _parse_result(self, response: Dict[str, Any]) -> StepResult:
53
+ """Build a [`StepResult`] from a `/step` or `/reset` response."""
54
+ observation = GeoGuesserObservation(**response["observation"])
55
+ return StepResult(
56
+ observation=observation,
57
+ reward=response.get("reward"),
58
+ done=response.get("done", False),
59
+ )
60
+
61
+ def _parse_state(self, response: Dict[str, Any]) -> GeoGuesserState:
62
+ """Build a [`GeoGuesserState`] from a `/state` response."""
63
+ return GeoGuesserState(**response)
64
+
65
+ def reset(
66
+ self,
67
+ task_index: int | None = None,
68
+ split: str | None = None,
69
+ index: int | None = None,
70
+ **kwargs: Any,
71
+ ) -> StepResult:
72
+ """
73
+ Start an episode.
74
+
75
+ Args:
76
+ task_index (`int`, *optional*):
77
+ Deprecated alias for `index`, kept so existing callers and
78
+ recorded trajectories keep working.
79
+ split (`str`, *optional*):
80
+ Which split to draw from, as named by [`list_splits`]. Defaults
81
+ to the server's default split.
82
+ index (`int`, *optional*):
83
+ Exact task to play within `split`. Repeated calls with the same
84
+ split and index yield byte-identical observations, which is what
85
+ a GRPO group needs. Omit it, and `seed`, for a random task.
86
+
87
+ Returns:
88
+ [`StepResult`]: The opening observation.
89
+ """
90
+ if index is None:
91
+ index = task_index
92
+ if split is not None:
93
+ kwargs["split"] = split
94
+ if index is not None:
95
+ kwargs["index"] = index
96
+ return super().reset(**kwargs)
97
+
98
+ def list_splits(self) -> list[dict[str, Any]]:
99
+ """
100
+ Which splits the server offers, and how many tasks each holds.
101
+
102
+ Core exposes the Task API over HTTP but ships no client for it, so this
103
+ posts to the routes directly.
104
+
105
+ Returns:
106
+ `list[dict]`: Split descriptors with `name`, `type`, `num_tasks` and
107
+ `default`.
108
+ """
109
+ return self._task_api("splits", method="GET")
110
+
111
+ def num_tasks(self, split: str) -> int:
112
+ """How many tasks a split holds."""
113
+ return int(self._task_api("num_tasks", {"split": split})["num_tasks"])
114
+
115
+ def get_task(self, split: str, index: int) -> dict[str, Any]:
116
+ """
117
+ Describe one task without starting an episode.
118
+
119
+ The spec carries no coordinates and no country: it is metadata for
120
+ whatever orchestrates a run, not a label source.
121
+ """
122
+ return self._task_api("task", {"split": split, "index": index})["task"]
123
+
124
+ def _task_api(
125
+ self,
126
+ route: str,
127
+ payload: dict[str, Any] | None = None,
128
+ method: str = "POST",
129
+ ) -> Any:
130
+ """Call one core Task API route on this environment."""
131
+ import json
132
+ import urllib.request
133
+
134
+ url = f"{self.base_url.rstrip('/')}/{ENV_NAME}/{route}"
135
+ data = None if payload is None else json.dumps(payload).encode()
136
+ request = urllib.request.Request(
137
+ url,
138
+ data=data,
139
+ method=method,
140
+ headers={"Content-Type": "application/json"},
141
+ )
142
+ with urllib.request.urlopen(request, timeout=30) as response:
143
+ return json.loads(response.read())
geoguesser_env/data/geo/detail/places.json ADDED
The diff for this file is too large to render. See raw diff
 
geoguesser_env/data/geo/detail/rivers.json ADDED
The diff for this file is too large to render. See raw diff
 
geoguesser_env/data/geo/detail/roads.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:96896a1362cb5e831e95361e297970e0ef4d83eea5624ed0b2a955d9619eed29
3
+ size 14390450
geoguesser_env/data/geo/detail/urban.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4ab8f88d498865f719eebb948ce6ffb213b8bcdf29a9a2d48394c707d607cf59
3
+ size 21183581
geoguesser_env/data/geo/ne_110m_admin_0_countries.geojson ADDED
The diff for this file is too large to render. See raw diff
 
geoguesser_env/data/geo/ne_50m_populated_places.geojson ADDED
The diff for this file is too large to render. See raw diff
 
geoguesser_env/examples/geoguesser_collect.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Collect rollouts into a JSONL dataset via `openenv.core.harness`.
4
+
5
+ Shows the task API doing the work: tasks come from the frozen index, so each
6
+ episode records which location produced it and a run can resume without losing
7
+ task-to-episode alignment.
8
+
9
+ PYTHONPATH=src:envs uv run python \\
10
+ envs/geoguesser_env/examples/geoguesser_collect.py --episodes 8
11
+
12
+ Point `--base-url` at a running server. The scripted policy here looks around
13
+ once, pins a guess and submits; swap `model_step` for an LLM to collect real
14
+ trajectories.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import pathlib
21
+ from typing import Any
22
+
23
+ from openenv.core.harness import HarnessRunLimits, MCPHarnessAdapter, ModelStepResult
24
+ from openenv.core.harness.collect import CollectRunner, RolloutSerializer
25
+ from openenv.core.llm_client import LLMResponse, ToolCall
26
+
27
+ from geoguesser_env.client import GeoGuesserEnv
28
+ from geoguesser_env.harness import GeoGuesserSessionFactory, load_tasks
29
+
30
+
31
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
32
+
33
+
34
+ def build_scripted_model_step():
35
+ """A deterministic stand-in policy: look, pin, guess.
36
+
37
+ A `ModelStepResult` wraps an `LLMResponse` carrying `ToolCall`s — the same
38
+ shape a real model's reply is normalised into — so swapping this for an
39
+ actual LLM changes only how the response is produced.
40
+ """
41
+ turns = {"n": 0}
42
+
43
+ def model_step(messages: Any, tools: Any, sampling: Any) -> Any:
44
+ del messages, sampling
45
+ step = turns["n"]
46
+ turns["n"] += 1
47
+ available = {tool.name for tool in (tools or [])}
48
+
49
+ if step == 0 and "look" in available:
50
+ name, args = "look", {"heading_deg": 90.0}
51
+ note = "Looking east."
52
+ elif step <= 1 and "place_pin" in available:
53
+ name, args = "place_pin", {"lat": 48.85, "lon": 2.29}
54
+ note = "Checking a candidate near Paris."
55
+ else:
56
+ name, args = (
57
+ "submit_guess",
58
+ {
59
+ "lat": 48.8584,
60
+ "lon": 2.2945,
61
+ "confidence": 0.4,
62
+ },
63
+ )
64
+ note = "Committing."
65
+
66
+ return ModelStepResult(
67
+ response=LLMResponse(
68
+ content=note,
69
+ tool_calls=[ToolCall(id=f"scripted-{step}", name=name, args=args)],
70
+ )
71
+ )
72
+
73
+ return model_step
74
+
75
+
76
+ def main() -> None:
77
+ """Run the collector."""
78
+ parser = argparse.ArgumentParser(description=__doc__)
79
+ parser.add_argument("--base-url", default="http://localhost:8000")
80
+ parser.add_argument("--episodes", type=int, default=8)
81
+ parser.add_argument(
82
+ "--repeat",
83
+ type=int,
84
+ default=1,
85
+ help="Episodes per task; use 16 for a GRPO group.",
86
+ )
87
+ parser.add_argument(
88
+ "--index", type=pathlib.Path, default=ROOT / "tasks" / "pano_v1.jsonl"
89
+ )
90
+ parser.add_argument(
91
+ "--output-dir", type=pathlib.Path, default=pathlib.Path("rollouts/geoguesser")
92
+ )
93
+ args = parser.parse_args()
94
+
95
+ tasks = load_tasks(args.index, repeat=args.repeat)
96
+ print(f"{len(tasks)} task slots from {args.index}")
97
+
98
+ runner = CollectRunner(
99
+ session_factory=GeoGuesserSessionFactory(
100
+ lambda: GeoGuesserEnv(base_url=args.base_url)
101
+ ),
102
+ harness_adapter=MCPHarnessAdapter(),
103
+ serializer=RolloutSerializer(args.output_dir),
104
+ tasks=tasks,
105
+ limits=HarnessRunLimits(max_turns=6),
106
+ )
107
+ result = runner.run(
108
+ model_step=build_scripted_model_step(),
109
+ num_episodes=args.episodes,
110
+ episode_id_prefix="gg",
111
+ )
112
+ print(
113
+ f"collected={result.num_collected} skipped={result.num_skipped} "
114
+ f"failed={result.num_failed} avg_reward={result.avg_reward:.3f}"
115
+ )
116
+ print(f"dataset: {args.output_dir}")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
geoguesser_env/examples/geoguesser_llm_rollout.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Run real rollouts against a vision model and report the rewards.
4
+
5
+ Two providers, one action protocol, so the same episode can be compared across
6
+ models:
7
+
8
+ - `anthropic` talks to the Messages API directly.
9
+ - `hf` talks to any OpenAI-compatible endpoint, defaulting to the Hugging Face
10
+ router, which serves open models such as Qwen.
11
+
12
+ Two modes:
13
+
14
+ - `single_shot` is one view, one guess. This is the shape a GRPO run wants and
15
+ the cheapest way to compare models.
16
+ - `agentic` gives the model the tool surface and lets it look around, walk,
17
+ and pin candidates before committing, one JSON action per turn.
18
+
19
+ Usage:
20
+ export ANTHROPIC_API_KEY=... HF_TOKEN=...
21
+
22
+ python examples/geoguesser_llm_rollout.py \\
23
+ --provider anthropic --model claude-sonnet-5 --episodes 5
24
+
25
+ python examples/geoguesser_llm_rollout.py \\
26
+ --provider hf --model "Qwen/Qwen3.5-9B:together" --episodes 5
27
+
28
+ python examples/geoguesser_llm_rollout.py \\
29
+ --provider anthropic --mode agentic --episodes 3 --max-turns 8
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import base64
36
+ import concurrent.futures
37
+ import json
38
+ import os
39
+ import pathlib
40
+ import re
41
+ import statistics
42
+ import sys
43
+ import threading
44
+ import time
45
+ from typing import Any
46
+
47
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
48
+
49
+ from geoguesser_env.models import ( # noqa: E402
50
+ GuessAction,
51
+ LookAction,
52
+ MoveAction,
53
+ PinAction,
54
+ to_wire,
55
+ ViewMapAction,
56
+ ZoomAction,
57
+ )
58
+ from geoguesser_env.server.geoguesser_environment import ( # noqa: E402
59
+ GeoGuesserEnvironment,
60
+ )
61
+
62
+
63
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
64
+
65
+ SINGLE_SHOT_PROMPT = """You are playing a geolocation game. This is a street-level
66
+ photograph taken somewhere in the world. Work out where it was taken.
67
+
68
+ Reason briefly about the evidence: language and script on signage, which side of
69
+ the road traffic drives on, vegetation and climate, road markings, architecture,
70
+ utility poles, vehicle number plates, terrain.
71
+
72
+ Then give your answer as coordinates on the last line, exactly like this:
73
+ <guess>LATITUDE, LONGITUDE</guess>"""
74
+
75
+ AGENTIC_PROMPT = """You are playing a geolocation game. You have been dropped at
76
+ an unknown street-level location and must work out where you are.
77
+
78
+ Reply with exactly one JSON object per turn, and nothing else:
79
+
80
+ {{"action": "look", "heading_deg": 90, "fov_deg": 90}} look in a direction
81
+ {{"action": "zoom", "fov_deg": 30}} zoom to read signage
82
+ {{"action": "move", "direction": "forward", "meters": 20}} walk along the road
83
+ {{"action": "pin", "lat": 12.34, "lon": 56.78, "span_deg": 2}} check a candidate;
84
+ span_deg is the map zoom, and below about 4 you get roads and town names
85
+ {{"action": "guess", "lat": 12.34, "lon": 56.78}} commit, ends the episode
86
+
87
+ Looking, zooming and pinning each cost a little reward, so gather what you need
88
+ and then commit.
89
+
90
+ Pinning is worth using before you guess: it tells you what is actually at a
91
+ coordinate — the country, the nearest city, and how far your candidate is from
92
+ your previous one — which catches a coordinate that lands in the sea or in the
93
+ wrong country. It does not tell you whether you are right.
94
+
95
+ You have {max_turns} turns. Available tools: {tools}
96
+
97
+ Output rules, which matter more than they look:
98
+ - Reply with the JSON object and nothing else. No explanation, no markdown, no
99
+ code fence, no commentary before or after.
100
+ - One action per turn. An action that does not parse wastes the turn.
101
+ - Coordinates are decimal degrees, negative for south and west."""
102
+
103
+
104
+ # ---------------------------------------------------------------- providers
105
+
106
+
107
+ def media_type(image_b64: str) -> str:
108
+ """
109
+ Detect an encoded image's media type from its own bytes.
110
+
111
+ Views come back as JPEG and maps as PNG, so a hardcoded type is wrong half
112
+ the time — and Anthropic rejects a mislabelled image outright rather than
113
+ sniffing it.
114
+
115
+ Args:
116
+ image_b64 (`str`):
117
+ Base64-encoded image.
118
+
119
+ Returns:
120
+ `str`: Either `"image/png"` or `"image/jpeg"`.
121
+ """
122
+ try:
123
+ head = base64.b64decode(image_b64[:24], validate=False)
124
+ except Exception: # noqa: BLE001 - fall back rather than fail a rollout
125
+ return "image/jpeg"
126
+ return "image/png" if head.startswith(b"\x89PNG") else "image/jpeg"
127
+
128
+
129
+ class AnthropicChat:
130
+ """Thin wrapper over the Anthropic Messages API."""
131
+
132
+ def __init__(self, model: str, max_tokens: int = 1024):
133
+ from anthropic import Anthropic
134
+
135
+ self._client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
136
+ self._model = model
137
+ self._max_tokens = max_tokens
138
+
139
+ @property
140
+ def name(self) -> str:
141
+ return f"anthropic/{self._model}"
142
+
143
+ def __call__(self, prompt: str, images: list[str]) -> str:
144
+ content: list[dict[str, Any]] = []
145
+ for image in images:
146
+ content.append(
147
+ {
148
+ "type": "image",
149
+ "source": {
150
+ "type": "base64",
151
+ "media_type": media_type(image),
152
+ "data": image,
153
+ },
154
+ }
155
+ )
156
+ content.append({"type": "text", "text": prompt})
157
+ message = self._client.messages.create(
158
+ model=self._model,
159
+ max_tokens=self._max_tokens,
160
+ messages=[{"role": "user", "content": content}],
161
+ )
162
+ return "".join(
163
+ block.text
164
+ for block in message.content
165
+ if getattr(block, "type", "") == "text"
166
+ )
167
+
168
+
169
+ class OpenAICompatChat:
170
+ """Thin wrapper over any OpenAI-compatible chat endpoint."""
171
+
172
+ def __init__(
173
+ self,
174
+ model: str,
175
+ base_url: str = "https://router.huggingface.co/v1",
176
+ api_key_env: str = "HF_TOKEN",
177
+ max_tokens: int = 1024,
178
+ ):
179
+ from openai import OpenAI
180
+
181
+ self._client = OpenAI(base_url=base_url, api_key=os.environ[api_key_env])
182
+ self._model = model
183
+ self._max_tokens = max_tokens
184
+ self._base_url = base_url
185
+ self.last_finish_reason = None
186
+
187
+ @property
188
+ def name(self) -> str:
189
+ return f"{self._base_url.split('//')[-1].split('/')[0]}/{self._model}"
190
+
191
+ def __call__(self, prompt: str, images: list[str]) -> str:
192
+ content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
193
+ for image in images:
194
+ content.append(
195
+ {
196
+ "type": "image_url",
197
+ "image_url": {"url": f"data:{media_type(image)};base64,{image}"},
198
+ }
199
+ )
200
+ completion = self._client.chat.completions.create(
201
+ model=self._model,
202
+ max_tokens=self._max_tokens,
203
+ messages=[{"role": "user", "content": content}],
204
+ )
205
+ choice = completion.choices[0]
206
+ text = choice.message.content or ""
207
+ # Reasoning models put their chain of thought in a separate field and
208
+ # can exhaust the token budget before emitting any content at all, so a
209
+ # short max_tokens looks exactly like a model that cannot see images.
210
+ # Fall back to the reasoning text, which usually still carries the
211
+ # answer, and say so when the budget was the limit.
212
+ if not text.strip():
213
+ reasoning = getattr(choice.message, "reasoning_content", None) or getattr(
214
+ choice.message, "reasoning", None
215
+ )
216
+ if reasoning:
217
+ text = str(reasoning)
218
+ elif choice.finish_reason == "length":
219
+ text = ""
220
+ self.last_finish_reason = choice.finish_reason
221
+ return text
222
+
223
+
224
+ def build_chat(args: argparse.Namespace):
225
+ """Construct the provider named on the command line."""
226
+ if args.provider == "anthropic":
227
+ return AnthropicChat(args.model, max_tokens=args.max_tokens)
228
+ return OpenAICompatChat(
229
+ args.model,
230
+ base_url=args.base_url,
231
+ api_key_env=args.api_key_env,
232
+ max_tokens=args.max_tokens,
233
+ )
234
+
235
+
236
+ # ------------------------------------------------------------------ rollouts
237
+
238
+
239
+ _JSON_OBJECT = re.compile(r"\{[^{}]*\}", re.DOTALL)
240
+
241
+
242
+ def parse_action(reply: str) -> dict[str, Any] | None:
243
+ """
244
+ Pull the intended JSON action out of a model reply.
245
+
246
+ A fenced block wins, since a model that formats its answer means it. Failing
247
+ that, the *last* action object in the text is taken rather than the first:
248
+ when the reply is a reasoning trace, earlier objects are usually drafts the
249
+ model then argued itself out of, and the decision comes last.
250
+
251
+ Args:
252
+ reply (`str`):
253
+ The raw model reply, possibly a chain of thought.
254
+
255
+ Returns:
256
+ `dict` or `None`: The action object, or `None` when none is present.
257
+ """
258
+ fenced = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", reply, re.DOTALL)
259
+ for candidate in reversed(fenced):
260
+ try:
261
+ parsed = json.loads(candidate)
262
+ except json.JSONDecodeError:
263
+ continue
264
+ if isinstance(parsed, dict) and "action" in parsed:
265
+ return parsed
266
+
267
+ for candidate in reversed(_JSON_OBJECT.findall(reply)):
268
+ try:
269
+ parsed = json.loads(candidate)
270
+ except json.JSONDecodeError:
271
+ continue
272
+ if isinstance(parsed, dict) and "action" in parsed:
273
+ return parsed
274
+ return None
275
+
276
+
277
+ def to_env_action(spec: dict[str, Any]):
278
+ """Translate a model action into a typed environment action."""
279
+ kind = str(spec.get("action", "")).lower()
280
+ if kind == "look":
281
+ return LookAction(
282
+ heading_deg=float(spec.get("heading_deg", 0.0)),
283
+ pitch_deg=float(spec.get("pitch_deg", 0.0)),
284
+ fov_deg=float(spec.get("fov_deg", 90.0)),
285
+ )
286
+ if kind == "zoom":
287
+ return ZoomAction(fov_deg=float(spec.get("fov_deg", 30.0)))
288
+ if kind == "move":
289
+ return MoveAction(
290
+ direction=str(spec.get("direction", "forward")),
291
+ meters=float(spec.get("meters", 20.0)),
292
+ )
293
+ if kind == "pin":
294
+ return PinAction(
295
+ lat=float(spec["lat"]),
296
+ lon=float(spec["lon"]),
297
+ span_deg=float(spec.get("span_deg", 7.0)),
298
+ )
299
+ if kind == "view_map":
300
+ return ViewMapAction(
301
+ lat=float(spec["lat"]),
302
+ lon=float(spec["lon"]),
303
+ span_deg=float(spec.get("span_deg", 7.0)),
304
+ )
305
+ if kind == "guess":
306
+ return GuessAction(
307
+ lat=float(spec["lat"]),
308
+ lon=float(spec["lon"]),
309
+ confidence=spec.get("confidence"),
310
+ reasoning=spec.get("reasoning"),
311
+ )
312
+ raise ValueError(f"unknown action: {kind!r}")
313
+
314
+
315
+ def _turn_record(
316
+ index: int,
317
+ action: Any,
318
+ reply: str,
319
+ observation: Any,
320
+ ) -> dict[str, Any]:
321
+ """One row of a trace: what the model saw, said, did and got back."""
322
+ return {
323
+ "turn": index,
324
+ "reply": reply.strip(),
325
+ "action": action,
326
+ "feedback": getattr(observation, "feedback", ""),
327
+ "heading_deg": getattr(observation, "heading_deg", None),
328
+ "fov_deg": getattr(observation, "fov_deg", None),
329
+ "steps_remaining": getattr(observation, "steps_remaining", None),
330
+ "action_cost": getattr(observation, "action_cost", None),
331
+ "image_kind": getattr(observation, "image_kind", "none"),
332
+ "image_base64": getattr(observation, "image_base64", None),
333
+ "reward": getattr(observation, "reward", None),
334
+ "distance_km": getattr(observation, "distance_km", None),
335
+ }
336
+
337
+
338
+ def run_single_shot(env, chat, task_index: int, verbose: bool) -> dict[str, Any]:
339
+ """One view, one guess."""
340
+ observation = env.reset(task_index=task_index)
341
+ opening = _turn_record(0, {"action": "reset"}, "", observation)
342
+ started = time.time()
343
+ reply = chat(SINGLE_SHOT_PROMPT, [observation.image_base64])
344
+ latency = time.time() - started
345
+ result = env.step(to_wire(GuessAction(response=reply)))
346
+ trace = [opening, _turn_record(1, {"action": "guess"}, reply, result)]
347
+ return {
348
+ "trace": trace,
349
+ "task_index": task_index,
350
+ "country": env._task.country,
351
+ "reward": result.reward or 0.0,
352
+ "distance_km": result.distance_km,
353
+ "parsed_ok": result.parsed_ok,
354
+ "latency_s": latency,
355
+ "turns": 1,
356
+ "reply": reply.strip(),
357
+ }
358
+
359
+
360
+ def run_agentic(
361
+ env, chat, task_index: int, max_turns: int, verbose: bool
362
+ ) -> dict[str, Any]:
363
+ """Let the model look around and pin before committing."""
364
+ observation = env.reset(task_index=task_index)
365
+ trace: list[dict[str, Any]] = [
366
+ _turn_record(0, {"action": "reset"}, "", observation)
367
+ ]
368
+ transcript: list[str] = []
369
+ started = time.time()
370
+ turns = 0
371
+
372
+ for turn in range(max_turns):
373
+ turns = turn + 1
374
+ prompt = AGENTIC_PROMPT.format(
375
+ max_turns=max_turns - turn,
376
+ tools=", ".join(observation.available_tools),
377
+ )
378
+ if transcript:
379
+ prompt += "\n\nWhat has happened so far:\n" + "\n".join(transcript[-8:])
380
+ prompt += (
381
+ f"\n\nYou are facing {observation.heading_deg:.0f} degrees with a "
382
+ f"{observation.fov_deg:.0f} degree field of view. "
383
+ f"{observation.steps_remaining} actions remain."
384
+ )
385
+ turns_left = max_turns - turn
386
+ if turns_left <= 2:
387
+ # Exploring until the budget runs out scores zero, which is a
388
+ # prompting failure rather than a capability one. Open models
389
+ # routinely need to be told the deadline is now.
390
+ prompt += (
391
+ f"\n\nWARNING: only {turns_left} turn(s) left. You must reply "
392
+ 'with {"action": "guess", "lat": ..., "lon": ...} now, or you '
393
+ "score zero. Give your best estimate even if you are unsure."
394
+ )
395
+
396
+ images = [observation.image_base64] if observation.image_base64 else []
397
+ reply = chat(prompt, images)
398
+ spec = parse_action(reply)
399
+ if spec is None:
400
+ transcript.append(f"turn {turns}: reply was not a JSON action, ignored")
401
+ if verbose:
402
+ print(
403
+ f" turn {turns}: unparseable -> {reply.strip()[:90]!r}",
404
+ flush=True,
405
+ )
406
+ continue
407
+
408
+ try:
409
+ action = to_env_action(spec)
410
+ except (KeyError, ValueError, TypeError) as exc:
411
+ transcript.append(f"turn {turns}: invalid action {spec} ({exc})")
412
+ if verbose:
413
+ print(
414
+ f" turn {turns}: invalid {spec} ({exc})",
415
+ flush=True,
416
+ )
417
+ continue
418
+
419
+ result = env.step(to_wire(action))
420
+ observation = result
421
+ trace.append(_turn_record(turns, spec, reply, result))
422
+ transcript.append(f"turn {turns}: {spec} -> {result.feedback}")
423
+ if verbose:
424
+ print(
425
+ f" turn {turns}: {spec.get('action'):9s} {result.feedback[:88]}",
426
+ flush=True,
427
+ )
428
+ if result.done:
429
+ return {
430
+ "trace": trace,
431
+ "task_index": task_index,
432
+ "country": env._task.country,
433
+ "reward": result.reward or 0.0,
434
+ "distance_km": result.distance_km,
435
+ "parsed_ok": result.parsed_ok,
436
+ "latency_s": time.time() - started,
437
+ "turns": turns,
438
+ "action_cost": result.action_cost,
439
+ "reply": reply.strip(),
440
+ }
441
+
442
+ # Out of turns without a guess: score it, because a policy that never
443
+ # commits should not be rewarded with a missing sample.
444
+ result = env.step(to_wire(GuessAction(response="")))
445
+ trace.append(_turn_record(turns + 1, {"action": "forced guess"}, "", result))
446
+ return {
447
+ "trace": trace,
448
+ "task_index": task_index,
449
+ "country": env._task.country,
450
+ "reward": result.reward or 0.0,
451
+ "distance_km": result.distance_km,
452
+ "parsed_ok": False,
453
+ "latency_s": time.time() - started,
454
+ "turns": turns,
455
+ "action_cost": result.action_cost,
456
+ "reply": "never committed to a guess",
457
+ }
458
+
459
+
460
+ class Worker:
461
+ """One environment and one chat client, owned by a single thread.
462
+
463
+ An episode is stateful, so concurrent rollouts cannot share an environment
464
+ instance — they would interleave resets and steps into one episode. Each
465
+ worker therefore builds its own environment over the same read-only task
466
+ index and panorama cache, which is how a distributed rollout is arranged
467
+ too.
468
+ """
469
+
470
+ def __init__(self, args: argparse.Namespace):
471
+ self.env = GeoGuesserEnvironment(
472
+ index_path=str(args.index),
473
+ cache_dir=str(args.cache),
474
+ episode_mode="agentic" if args.mode == "agentic" else "single_shot",
475
+ max_steps=args.max_turns,
476
+ )
477
+ self.chat = build_chat(args)
478
+
479
+
480
+ def run_episodes(args: argparse.Namespace, tasks: list[int]) -> list[dict[str, Any]]:
481
+ """
482
+ Run every task, optionally several at a time.
483
+
484
+ Args:
485
+ args (`argparse.Namespace`):
486
+ Parsed command line.
487
+ tasks (`list[int]`):
488
+ Task indices to play, one episode each.
489
+
490
+ Returns:
491
+ `list[dict]`: One record per episode, in task order.
492
+ """
493
+ local = threading.local()
494
+ lock = threading.Lock()
495
+ finished = {"n": 0}
496
+
497
+ def worker() -> Worker:
498
+ if not hasattr(local, "worker"):
499
+ local.worker = Worker(args)
500
+ return local.worker
501
+
502
+ def play(task_index: int) -> dict[str, Any]:
503
+ own = worker()
504
+ if args.mode == "single_shot":
505
+ record = run_single_shot(own.env, own.chat, task_index, args.verbose)
506
+ else:
507
+ record = run_agentic(
508
+ own.env, own.chat, task_index, args.max_turns, args.verbose
509
+ )
510
+ with lock:
511
+ finished["n"] += 1
512
+ distance = record["distance_km"]
513
+ shown = "unparsed" if not record["parsed_ok"] else f"{distance:8.0f} km"
514
+ print(
515
+ f"[{finished['n']:>3}/{len(tasks)}] task {record['task_index']:3d} "
516
+ f"{record['country'][:20]:20s} reward {record['reward']:.3f} "
517
+ f"{shown} {record['turns']} turn(s) {record['latency_s']:5.1f}s",
518
+ flush=True,
519
+ )
520
+ return record
521
+
522
+ if args.concurrency <= 1:
523
+ return [play(task_index) for task_index in tasks]
524
+
525
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
526
+ return list(pool.map(play, tasks))
527
+
528
+
529
+ def main() -> None:
530
+ """Run the rollouts and print a summary."""
531
+ parser = argparse.ArgumentParser(description=__doc__)
532
+ parser.add_argument("--provider", choices=["anthropic", "hf"], default="anthropic")
533
+ parser.add_argument("--model", default="claude-sonnet-5")
534
+ parser.add_argument("--base-url", default="https://router.huggingface.co/v1")
535
+ parser.add_argument("--api-key-env", default="HF_TOKEN")
536
+ parser.add_argument(
537
+ "--mode", choices=["single_shot", "agentic"], default="single_shot"
538
+ )
539
+ parser.add_argument("--episodes", type=int, default=5)
540
+ parser.add_argument("--start-task", type=int, default=0)
541
+ parser.add_argument("--stride", type=int, default=7)
542
+ parser.add_argument("--max-turns", type=int, default=8)
543
+ parser.add_argument(
544
+ "--max-tokens",
545
+ type=int,
546
+ default=3000,
547
+ help="Reasoning models need several thousand; 1024 truncates them mid-thought.",
548
+ )
549
+ parser.add_argument(
550
+ "--index", type=pathlib.Path, default=ROOT / "tasks" / "pano_v1.jsonl"
551
+ )
552
+ parser.add_argument("--cache", type=pathlib.Path, default=ROOT / "data" / "panos")
553
+ parser.add_argument(
554
+ "--concurrency",
555
+ type=int,
556
+ default=1,
557
+ help="Episodes to run at once; each gets its own environment.",
558
+ )
559
+ parser.add_argument(
560
+ "--trace-dir",
561
+ type=pathlib.Path,
562
+ default=pathlib.Path("rollouts"),
563
+ help="Where to write per-turn traces and their images.",
564
+ )
565
+ parser.add_argument("--verbose", action="store_true")
566
+ args = parser.parse_args()
567
+
568
+ probe = GeoGuesserEnvironment(
569
+ index_path=str(args.index), cache_dir=str(args.cache), allow_fetch=False
570
+ )
571
+ n_tasks = probe._backend.n_tasks
572
+ tasks = [
573
+ (args.start_task + i * args.stride) % n_tasks for i in range(args.episodes)
574
+ ]
575
+
576
+ print(
577
+ f"{args.provider}/{args.model} mode={args.mode} "
578
+ f"episodes={len(tasks)} concurrency={args.concurrency} "
579
+ f"tasks={tasks}",
580
+ flush=True,
581
+ )
582
+ print("-" * 88, flush=True)
583
+
584
+ wall_start = time.time()
585
+ records = run_episodes(args, tasks)
586
+ wall = time.time() - wall_start
587
+ rewards = [r["reward"] for r in records]
588
+ distances = [r["distance_km"] for r in records if r["distance_km"] is not None]
589
+ parsed = sum(1 for r in records if r["parsed_ok"])
590
+ print("-" * 88)
591
+ print(f"mean reward {statistics.mean(rewards):.3f}")
592
+ if len(rewards) > 1:
593
+ print(f"stdev reward {statistics.stdev(rewards):.3f}")
594
+ if distances:
595
+ print(f"median distance {statistics.median(distances):.0f} km")
596
+ print(
597
+ f"within 200 km {sum(1 for d in distances if d < 200)}/{len(distances)}"
598
+ )
599
+ print(
600
+ f"within 1000 km {sum(1 for d in distances if d < 1000)}/{len(distances)}"
601
+ )
602
+ print(f"parsed {parsed}/{len(records)}")
603
+ latencies = [r["latency_s"] for r in records]
604
+ print(f"wall clock {wall:.1f}s for {len(records)} episodes")
605
+ print(f"sum of latency {sum(latencies):.1f}s")
606
+ if args.concurrency > 1:
607
+ print(f"speedup {sum(latencies) / max(wall, 1e-9):.2f}x")
608
+
609
+ slug = f"{args.provider}_{args.mode}"
610
+ out_dir = pathlib.Path(args.trace_dir) / slug
611
+ images_dir = out_dir / "images"
612
+ images_dir.mkdir(parents=True, exist_ok=True)
613
+
614
+ # Images live beside the trace rather than inside it: a six-episode agentic
615
+ # run carries about 30 views, and inlining them makes the JSONL unreadable
616
+ # and unloadable.
617
+ manifest = []
618
+ for record in records:
619
+ trace = record.pop("trace", [])
620
+ rows = []
621
+ for row in trace:
622
+ payload = row.pop("image_base64", None)
623
+ if payload:
624
+ name = f"t{record['task_index']:03d}_turn{row['turn']:02d}.jpg"
625
+ suffix = ".png" if row.get("image_kind") == "map" else ".jpg"
626
+ name = name.replace(".jpg", suffix)
627
+ (images_dir / name).write_bytes(base64.b64decode(payload))
628
+ row["image"] = f"images/{name}"
629
+ rows.append(row)
630
+ record["trace"] = rows
631
+ manifest.append(record)
632
+
633
+ out = out_dir / "trace.jsonl"
634
+ with out.open("w") as handle:
635
+ for record in manifest:
636
+ handle.write(json.dumps(record) + "\n")
637
+ summary = {
638
+ "provider": args.provider,
639
+ "model": args.model,
640
+ "mode": args.mode,
641
+ "episodes": len(manifest),
642
+ "concurrency": args.concurrency,
643
+ "max_turns": args.max_turns,
644
+ "mean_reward": statistics.mean(rewards),
645
+ "median_distance_km": statistics.median(distances) if distances else None,
646
+ "within_200km": sum(1 for d in distances if d < 200),
647
+ "within_1000km": sum(1 for d in distances if d < 1000),
648
+ "parsed": parsed,
649
+ "wall_clock_s": wall,
650
+ "sum_latency_s": sum(latencies),
651
+ }
652
+ (out_dir / "summary.json").write_text(json.dumps(summary, indent=2))
653
+ print(f"trace -> {out} ({len(list(images_dir.iterdir()))} images)")
654
+ print(f"render it with: python scripts/render_trace.py {out_dir}")
655
+
656
+
657
+ if __name__ == "__main__":
658
+ main()
geoguesser_env/examples/geoguesser_rollout.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """A scripted rollout, end to end, with no model in the loop.
4
+
5
+ Runs in process against the committed fixtures, so it needs no server and no
6
+ network:
7
+
8
+ PYTHONPATH=src:envs uv run python \\
9
+ envs/geoguesser_env/examples/geoguesser_rollout.py
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pathlib
15
+
16
+ from geoguesser_env.models import (
17
+ GuessAction,
18
+ LookAction,
19
+ MeasureAction,
20
+ MoveAction,
21
+ PinAction,
22
+ to_wire,
23
+ )
24
+ from geoguesser_env.server.geoguesser_environment import GeoGuesserEnvironment
25
+
26
+
27
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
28
+ FIXTURES = ROOT / "tests" / "fixtures"
29
+
30
+
31
+ def main() -> None:
32
+ """Play one episode with a fixed script and print the trace."""
33
+ env = GeoGuesserEnvironment(
34
+ index_path=str(FIXTURES / "pano_v1.jsonl"),
35
+ cache_dir=str(FIXTURES / "panos"),
36
+ allow_fetch=False,
37
+ )
38
+
39
+ observation = env.reset(index=0)
40
+ print(
41
+ f"task {observation.metadata['task_index']} "
42
+ f"({observation.metadata['task_id']}), "
43
+ f"captured {observation.captured_at}"
44
+ )
45
+ print(f"tools: {', '.join(observation.available_tools)}\n")
46
+
47
+ script = [
48
+ LookAction(heading_deg=0),
49
+ LookAction(heading_deg=90),
50
+ LookAction(heading_deg=180, fov_deg=30),
51
+ MoveAction(direction="forward", meters=20),
52
+ PinAction(lat=39.7, lon=-104.9, label="Colorado?"),
53
+ MeasureAction(lat_a=39.7, lon_a=-104.9, lat_b=38.9, lon_b=-104.8),
54
+ PinAction(lat=38.87, lon=-104.79, label="Colorado Springs?"),
55
+ ]
56
+ for action in script:
57
+ observation = env.step(to_wire(action))
58
+ image = len(observation.image_base64 or "")
59
+ print(
60
+ f"{type(action).__name__:14s} [{observation.image_kind:4s} "
61
+ f"{image:>7d}B] {observation.feedback[:96]}"
62
+ )
63
+
64
+ result = env.step(
65
+ to_wire(
66
+ GuessAction(
67
+ response=(
68
+ "Wide roads, US plates, front range foothills.\n"
69
+ "<guess>38.87, -104.79</guess>"
70
+ ),
71
+ confidence=0.6,
72
+ )
73
+ )
74
+ )
75
+ print(f"\n{result.feedback}")
76
+ print(
77
+ f"reward {result.reward:.3f} "
78
+ f"score {result.score:.3f} "
79
+ f"cost {result.action_cost:.2f} "
80
+ f"distance {result.distance_km:.1f} km"
81
+ )
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
geoguesser_env/harness.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Harness-oriented GeoGuesser session adapters.
4
+
5
+ Follows the pattern in `reasoning_gym_env.harness`: expose a `GeoGuesserEnv`
6
+ client as a `ResourceSession` driven through MCP-style tools, so it plugs into
7
+ `openenv.core.harness` unchanged.
8
+
9
+ That single adapter is what makes the rest work without extra code:
10
+
11
+ - `CollectRunner(tasks=...)` walks a task list into a JSONL rollout dataset,
12
+ with resume, recording which task produced each episode.
13
+ - `build_harness_rollout_func(...)` yields a TRL-compatible rollout function
14
+ where each prompt *is* a task — the GRPO path.
15
+ - `EvalConfig` / `EvalResult` carry the provenance an eval score needs.
16
+
17
+ A task is a plain dict, so it survives serialisation into an `EpisodeRecord`:
18
+
19
+ ```python
20
+ {"task_index": 7, "task_id": "mly-0007"}
21
+ ```
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import pathlib
28
+ from typing import Any, Callable, Iterator
29
+
30
+ from openenv.core.env_server.mcp_types import Tool
31
+ from openenv.core.harness import (
32
+ ResourceSessionFactory,
33
+ StepEnvSessionAdapter,
34
+ ToolResult,
35
+ VerifyResult,
36
+ )
37
+
38
+ from .client import GeoGuesserEnv
39
+ from .models import (
40
+ GuessAction,
41
+ LookAction,
42
+ MeasureAction,
43
+ MoveAction,
44
+ PanAction,
45
+ PinAction,
46
+ to_wire,
47
+ ViewMapAction,
48
+ ZoomAction,
49
+ )
50
+
51
+
52
+ def _number(description: str) -> dict[str, Any]:
53
+ return {"type": "number", "description": description}
54
+
55
+
56
+ GEOGUESSER_TOOLS: list[Tool] = [
57
+ Tool(
58
+ name="look",
59
+ description=(
60
+ "Look in a direction from where you stand. heading_deg is "
61
+ "absolute, 0 being true north. Smaller fov_deg zooms in."
62
+ ),
63
+ input_schema={
64
+ "type": "object",
65
+ "properties": {
66
+ "heading_deg": _number("Compass heading in degrees."),
67
+ "pitch_deg": _number("Vertical angle; positive looks up."),
68
+ "fov_deg": _number("Field of view in degrees, 10 to 120."),
69
+ },
70
+ "required": ["heading_deg"],
71
+ },
72
+ ),
73
+ Tool(
74
+ name="pan",
75
+ description="Turn relative to your current heading; positive turns right.",
76
+ input_schema={
77
+ "type": "object",
78
+ "properties": {"delta_deg": _number("Degrees to turn.")},
79
+ "required": ["delta_deg"],
80
+ },
81
+ ),
82
+ Tool(
83
+ name="zoom",
84
+ description="Change field of view without turning. Around 30 reads distant signs.",
85
+ input_schema={
86
+ "type": "object",
87
+ "properties": {"fov_deg": _number("New field of view in degrees.")},
88
+ "required": ["fov_deg"],
89
+ },
90
+ ),
91
+ Tool(
92
+ name="move",
93
+ description=(
94
+ "Walk along the captured road. Reports how far you actually "
95
+ "travelled, since frame spacing is irregular."
96
+ ),
97
+ input_schema={
98
+ "type": "object",
99
+ "properties": {
100
+ "direction": {
101
+ "type": "string",
102
+ "enum": ["forward", "backward"],
103
+ "description": "Which way to walk.",
104
+ },
105
+ "meters": _number("Requested distance in metres."),
106
+ },
107
+ "required": ["direction"],
108
+ },
109
+ ),
110
+ Tool(
111
+ name="place_pin",
112
+ description=(
113
+ "Pin a candidate coordinate and see where it falls on the map. "
114
+ "Tells you what is at that coordinate. It says nothing about "
115
+ "whether you are right."
116
+ ),
117
+ input_schema={
118
+ "type": "object",
119
+ "properties": {
120
+ "lat": _number("Latitude of the candidate."),
121
+ "lon": _number("Longitude of the candidate."),
122
+ "label": {"type": "string", "description": "Optional note."},
123
+ "span_deg": _number(
124
+ "Half-width of the returned map in degrees. Below about 4 "
125
+ "the map adds roads, urban areas and town names."
126
+ ),
127
+ },
128
+ "required": ["lat", "lon"],
129
+ },
130
+ ),
131
+ Tool(
132
+ name="view_map",
133
+ description="Pan and zoom the map without placing a pin.",
134
+ input_schema={
135
+ "type": "object",
136
+ "properties": {
137
+ "lat": _number("Latitude at the centre of the view."),
138
+ "lon": _number("Longitude at the centre of the view."),
139
+ "span_deg": _number("Half-width of the window in degrees."),
140
+ },
141
+ "required": ["lat", "lon"],
142
+ },
143
+ ),
144
+ Tool(
145
+ name="measure",
146
+ description="Distance in km between two coordinates of your own choosing. Free.",
147
+ input_schema={
148
+ "type": "object",
149
+ "properties": {
150
+ "lat_a": _number("Latitude of the first point."),
151
+ "lon_a": _number("Longitude of the first point."),
152
+ "lat_b": _number("Latitude of the second point."),
153
+ "lon_b": _number("Longitude of the second point."),
154
+ },
155
+ "required": ["lat_a", "lon_a", "lat_b", "lon_b"],
156
+ },
157
+ ),
158
+ Tool(
159
+ name="submit_guess",
160
+ description="Commit your final answer. This ends the episode.",
161
+ input_schema={
162
+ "type": "object",
163
+ "properties": {
164
+ "lat": _number("Latitude of your guess."),
165
+ "lon": _number("Longitude of your guess."),
166
+ "country": {
167
+ "type": "string",
168
+ "description": "Optional ISO-3166 alpha-2 code or country name.",
169
+ },
170
+ "confidence": _number("Optional confidence in [0, 1]."),
171
+ "reasoning": {
172
+ "type": "string",
173
+ "description": "Optional rationale, recorded but not scored.",
174
+ },
175
+ },
176
+ "required": ["lat", "lon"],
177
+ },
178
+ ),
179
+ ]
180
+
181
+ _ACTION_BY_TOOL: dict[str, Callable[[dict[str, Any]], Any]] = {
182
+ "look": lambda a: LookAction(
183
+ heading_deg=float(a["heading_deg"]),
184
+ pitch_deg=float(a.get("pitch_deg", 0.0)),
185
+ fov_deg=float(a.get("fov_deg", 90.0)),
186
+ ),
187
+ "pan": lambda a: PanAction(delta_deg=float(a["delta_deg"])),
188
+ "zoom": lambda a: ZoomAction(fov_deg=float(a["fov_deg"])),
189
+ "move": lambda a: MoveAction(
190
+ direction=str(a["direction"]), meters=float(a.get("meters", 10.0))
191
+ ),
192
+ "place_pin": lambda a: PinAction(
193
+ lat=float(a["lat"]),
194
+ lon=float(a["lon"]),
195
+ label=a.get("label") or None,
196
+ span_deg=float(a.get("span_deg", 7.0)),
197
+ ),
198
+ "view_map": lambda a: ViewMapAction(
199
+ lat=float(a["lat"]),
200
+ lon=float(a["lon"]),
201
+ span_deg=float(a.get("span_deg", 7.0)),
202
+ ),
203
+ "measure": lambda a: MeasureAction(
204
+ lat_a=float(a["lat_a"]),
205
+ lon_a=float(a["lon_a"]),
206
+ lat_b=float(a["lat_b"]),
207
+ lon_b=float(a["lon_b"]),
208
+ ),
209
+ "submit_guess": lambda a: GuessAction(
210
+ lat=float(a["lat"]),
211
+ lon=float(a["lon"]),
212
+ country=a.get("country") or None,
213
+ confidence=(
214
+ float(a["confidence"]) if a.get("confidence") is not None else None
215
+ ),
216
+ reasoning=a.get("reasoning") or None,
217
+ ),
218
+ }
219
+
220
+
221
+ def load_tasks(
222
+ index_path: str | pathlib.Path,
223
+ repeat: int = 1,
224
+ split: str | None = None,
225
+ ) -> list[dict[str, Any]]:
226
+ """
227
+ Read a frozen task index into harness task dicts.
228
+
229
+ Args:
230
+ index_path (`str` or `pathlib.Path`):
231
+ The JSONL index written by `scripts/build_tasks.py`.
232
+ repeat (`int`, *optional*, defaults to `1`):
233
+ Emit each task this many times consecutively. `repeat=16` gives a
234
+ GRPO group of 16 rollouts per location.
235
+ split (`str`, *optional*):
236
+ Split name to record on every task, so the session factory selects
237
+ from the right index server-side. Required whenever the server
238
+ serves more than one split, because an index alone does not say
239
+ which one it is.
240
+
241
+ Returns:
242
+ `list[dict]`: One dict per episode, each with `task_index`, `task_id`,
243
+ `country` and, when given, `split`.
244
+
245
+ Examples:
246
+
247
+ ```python
248
+ tasks = load_tasks("tasks/eval_pano_v3.jsonl", split="eval")
249
+ tasks = load_tasks("tasks/train_pano_v3.jsonl", repeat=16, split="train")
250
+ ```
251
+ """
252
+ rows = []
253
+ for line in pathlib.Path(index_path).read_text().splitlines():
254
+ if not line.strip():
255
+ continue
256
+ row = json.loads(line)
257
+ task = {
258
+ "task_index": int(row["task_index"]),
259
+ "task_id": str(row["task_id"]),
260
+ "country": str(row.get("country", "")),
261
+ }
262
+ if split is not None:
263
+ task["split"] = split
264
+ rows.extend([dict(task) for _ in range(repeat)])
265
+ return rows
266
+
267
+
268
+ def cycle_tasks(tasks: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
269
+ """Yield tasks forever, so `num_episodes` may exceed the index size."""
270
+ while True:
271
+ for task in tasks:
272
+ yield dict(task)
273
+
274
+
275
+ def _initial_messages(result: Any, task: Any) -> list[dict[str, Any]]:
276
+ """Opening message: the prompt plus the first view, as an image part."""
277
+ observation = result.observation
278
+ content: list[dict[str, Any]] = [{"type": "text", "text": observation.prompt}]
279
+ if observation.image_base64:
280
+ content.append(
281
+ {
282
+ "type": "image_url",
283
+ "image_url": {
284
+ "url": f"data:image/jpeg;base64,{observation.image_base64}"
285
+ },
286
+ }
287
+ )
288
+ return [{"role": "user", "content": content}]
289
+
290
+
291
+ def _tool_result(
292
+ tool_name: str, arguments: dict[str, Any], result: Any, state: Any
293
+ ) -> ToolResult:
294
+ """Feed the observation back as text plus, when present, an image."""
295
+ observation = result.observation
296
+ data: dict[str, Any] = {
297
+ "feedback": observation.feedback,
298
+ "heading_deg": observation.heading_deg,
299
+ "fov_deg": observation.fov_deg,
300
+ "steps_remaining": observation.steps_remaining,
301
+ "pins": [p.model_dump() for p in observation.pins],
302
+ }
303
+ if observation.image_base64:
304
+ data["image_base64"] = observation.image_base64
305
+ data["image_kind"] = observation.image_kind
306
+ if observation.distance_km is not None:
307
+ data["distance_km"] = observation.distance_km
308
+ return ToolResult(
309
+ data=data,
310
+ done=bool(result.done),
311
+ metadata={
312
+ "reward": result.reward,
313
+ "tool": tool_name,
314
+ "state": state.model_dump() if hasattr(state, "model_dump") else state,
315
+ },
316
+ )
317
+
318
+
319
+ def _verify(
320
+ transcript: list[dict[str, Any]],
321
+ final_state: Any,
322
+ last_result: Any,
323
+ task: Any,
324
+ ) -> VerifyResult:
325
+ """
326
+ Summarise a finished episode for the collector.
327
+
328
+ `env_reward` forwards the reward the environment itself computed. Domain
329
+ knowledge belongs inside the environment, so nothing here recomputes or
330
+ adjusts it; the extra fields are derived statistics only.
331
+ """
332
+ observation = getattr(last_result, "observation", None)
333
+ distance = getattr(observation, "distance_km", None)
334
+ env_reward = getattr(last_result, "reward", None)
335
+ metrics: dict[str, Any] = {
336
+ "distance_km": float(distance) if distance is not None else -1.0,
337
+ "parsed_ok": float(bool(getattr(observation, "parsed_ok", False))),
338
+ "guessed": float(distance is not None),
339
+ "within_200km": float(distance is not None and distance < 200.0),
340
+ "within_25km": float(distance is not None and distance < 25.0),
341
+ }
342
+ if isinstance(task, dict) and task.get("task_index") is not None:
343
+ metrics["task_index"] = float(task["task_index"])
344
+ return VerifyResult(
345
+ env_reward=float(env_reward) if env_reward is not None else None,
346
+ done=bool(getattr(last_result, "done", False)),
347
+ metrics=metrics,
348
+ )
349
+
350
+
351
+ class GeoGuesserSessionFactory(ResourceSessionFactory):
352
+ """Create GeoGuesser-backed resource sessions for harness rollouts.
353
+
354
+ Args:
355
+ client_factory (`Callable[[], GeoGuesserEnv]`):
356
+ Builds a client per session, so concurrent sessions stay isolated.
357
+ include_navigation (`bool`, *optional*, defaults to `True`):
358
+ Advertise `move` in the tool list. Set `False` for a task index
359
+ whose backend cannot walk, so the model never sees a dead tool.
360
+
361
+ Examples:
362
+
363
+ ```python
364
+ factory = GeoGuesserSessionFactory(
365
+ lambda: GeoGuesserEnv(base_url="http://localhost:8000")
366
+ )
367
+ session = factory.create(task={"task_index": 7})
368
+ ```
369
+ """
370
+
371
+ def __init__(
372
+ self,
373
+ client_factory: Callable[[], GeoGuesserEnv],
374
+ *,
375
+ include_navigation: bool = True,
376
+ ):
377
+ self._client_factory = client_factory
378
+ self._tools = [
379
+ tool
380
+ for tool in GEOGUESSER_TOOLS
381
+ if include_navigation or tool.name != "move"
382
+ ]
383
+
384
+ def create(
385
+ self,
386
+ task: Any = None,
387
+ seed: int | None = None,
388
+ episode_id: str | None = None,
389
+ ) -> StepEnvSessionAdapter:
390
+ """
391
+ Open a session on one task.
392
+
393
+ The task's `split` and `task_index` are passed through `reset_kwargs`,
394
+ which is what makes a rollout reproducible: the same task always starts
395
+ from the same panorama at the same heading. Without the split, an index
396
+ is ambiguous once the server serves more than one.
397
+
398
+ Args:
399
+ task (`dict`, *optional*):
400
+ Task dict with a `task_index` and optionally a `split`. `None`
401
+ selects randomly from the server's default split.
402
+ seed (`int`, *optional*):
403
+ Fallback selector when no task is given.
404
+ episode_id (`str`, *optional*):
405
+ Episode identifier recorded by the collector.
406
+
407
+ Returns:
408
+ `StepEnvSessionAdapter`: The session, ready to be driven.
409
+ """
410
+ reset_kwargs: dict[str, Any] = {}
411
+ if isinstance(task, dict):
412
+ if task.get("split"):
413
+ reset_kwargs["split"] = str(task["split"])
414
+ if task.get("task_index") is not None:
415
+ reset_kwargs["index"] = int(task["task_index"])
416
+ elif isinstance(task, int):
417
+ reset_kwargs["index"] = task
418
+
419
+ return StepEnvSessionAdapter(
420
+ client=self._client_factory(),
421
+ task=task,
422
+ seed=seed,
423
+ episode_id=episode_id,
424
+ tool_specs=list(self._tools),
425
+ action_builder=lambda name, arguments: to_wire(
426
+ _ACTION_BY_TOOL[name](arguments)
427
+ ),
428
+ initial_messages_builder=_initial_messages,
429
+ tool_result_builder=_tool_result,
430
+ verify_builder=_verify,
431
+ reset_kwargs=reset_kwargs,
432
+ )
433
+
434
+
435
+ __all__ = [
436
+ "GEOGUESSER_TOOLS",
437
+ "GeoGuesserSessionFactory",
438
+ "cycle_tasks",
439
+ "load_tasks",
440
+ ]
geoguesser_env/models.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Data models for the GeoGuesser environment.
4
+
5
+ An episode places the agent at an unknown street-level location. It may look
6
+ around, walk along the road, and pin candidate coordinates on a map before
7
+ committing to a final guess. Only the final guess is scored.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum
13
+ from typing import Any, Literal
14
+
15
+ from openenv.core.env_server import Action, Observation, State
16
+ from pydantic import BaseModel, Field
17
+
18
+
19
+ class EpisodeMode(str, Enum):
20
+ """How much of the tool surface an episode exposes.
21
+
22
+ Attributes:
23
+ SINGLE_SHOT:
24
+ One view, one guess. No pin loop, no navigation.
25
+ AGENTIC:
26
+ The full tool surface, subject to what the backend supports.
27
+ NMPZ:
28
+ No move, pan or zoom — the competitive "NMPZ" mode. Only pinning
29
+ and guessing.
30
+ """
31
+
32
+ SINGLE_SHOT = "single_shot"
33
+ AGENTIC = "agentic"
34
+ NMPZ = "nmpz"
35
+
36
+
37
+ class RewardMode(str, Enum):
38
+ """Which ground-truth granularity the reward is computed against."""
39
+
40
+ COORDS = "coords"
41
+ COUNTRY_ONLY = "country_only"
42
+
43
+
44
+ # =============================================================================
45
+ # Actions
46
+ # =============================================================================
47
+
48
+
49
+ class TypedAction(Action):
50
+ """Base class for the ergonomic, per-operation action types.
51
+
52
+ These are what callers construct in Python. They are converted to the flat
53
+ [`GeoGuesserAction`] wire type by the client, because an HTTP environment
54
+ declares exactly one action schema and `Action` forbids unknown fields.
55
+ """
56
+
57
+
58
+ class LookAction(TypedAction):
59
+ """Render a perspective view out of the current panorama.
60
+
61
+ Args:
62
+ heading_deg (`float`):
63
+ Absolute compass heading in degrees, `0` being true north.
64
+ pitch_deg (`float`, *optional*, defaults to `0.0`):
65
+ Vertical angle in degrees; positive looks up.
66
+ fov_deg (`float`, *optional*, defaults to `90.0`):
67
+ Horizontal field of view. Smaller values zoom in.
68
+ """
69
+
70
+ heading_deg: float = Field(default=0.0, ge=-3600.0, le=3600.0)
71
+ pitch_deg: float = Field(default=0.0, ge=-90.0, le=90.0)
72
+ fov_deg: float = Field(default=90.0, ge=10.0, le=120.0)
73
+
74
+
75
+ class PanAction(TypedAction):
76
+ """Turn relative to the current heading.
77
+
78
+ Args:
79
+ delta_deg (`float`):
80
+ Degrees to turn; positive turns right.
81
+ """
82
+
83
+ delta_deg: float = Field(ge=-3600.0, le=3600.0)
84
+
85
+
86
+ class ZoomAction(TypedAction):
87
+ """Change the field of view without turning.
88
+
89
+ Args:
90
+ fov_deg (`float`):
91
+ New horizontal field of view in degrees.
92
+ """
93
+
94
+ fov_deg: float = Field(ge=10.0, le=120.0)
95
+
96
+
97
+ class MoveAction(TypedAction):
98
+ """Walk along the captured sequence.
99
+
100
+ Args:
101
+ direction (`str`):
102
+ Either `"forward"` or `"backward"` along the sequence.
103
+ meters (`float`, *optional*, defaults to `10.0`):
104
+ Requested distance. Frame spacing is irregular, so the observation
105
+ reports how far the move actually travelled.
106
+ """
107
+
108
+ direction: str = Field(pattern="^(forward|backward)$")
109
+ meters: float = Field(default=10.0, gt=0.0, le=500.0)
110
+
111
+
112
+ class PinAction(TypedAction):
113
+ """Place a candidate pin and receive a map of where it landed.
114
+
115
+ The response describes the pinned location only. It carries no information
116
+ about the true location.
117
+
118
+ Args:
119
+ lat (`float`):
120
+ Latitude of the candidate.
121
+ lon (`float`):
122
+ Longitude of the candidate.
123
+ label (`str`, *optional*):
124
+ Free-text note carried back in the pin list.
125
+ span_deg (`float`, *optional*, defaults to `7.0`):
126
+ Half-width in degrees of the map window returned with the pin.
127
+ Choosing the zoom matters: below roughly 4 degrees the map adds
128
+ roads, urban areas and town names, which is what makes aiming
129
+ within a city possible rather than guessing at its centre.
130
+ """
131
+
132
+ lat: float = Field(ge=-90.0, le=90.0)
133
+ lon: float = Field(ge=-180.0, le=180.0)
134
+ label: str | None = None
135
+ span_deg: float = Field(default=7.0, gt=0.02, le=180.0)
136
+
137
+
138
+ class ViewMapAction(TypedAction):
139
+ """Pan and zoom the map without committing a pin.
140
+
141
+ Args:
142
+ lat (`float`):
143
+ Latitude at the centre of the view.
144
+ lon (`float`):
145
+ Longitude at the centre of the view.
146
+ span_deg (`float`, *optional*, defaults to `7.0`):
147
+ Half-width of the window in degrees.
148
+ """
149
+
150
+ lat: float = Field(ge=-90.0, le=90.0)
151
+ lon: float = Field(ge=-180.0, le=180.0)
152
+ span_deg: float = Field(default=7.0, gt=0.05, le=180.0)
153
+
154
+
155
+ class MeasureAction(TypedAction):
156
+ """Great-circle distance between two of the agent's own coordinates."""
157
+
158
+ lat_a: float = Field(ge=-90.0, le=90.0)
159
+ lon_a: float = Field(ge=-180.0, le=180.0)
160
+ lat_b: float = Field(ge=-90.0, le=90.0)
161
+ lon_b: float = Field(ge=-180.0, le=180.0)
162
+
163
+
164
+ class GuessAction(TypedAction):
165
+ """Commit a final guess. Terminal.
166
+
167
+ Either supply `response` and let the environment parse it, or supply
168
+ `lat`/`lon` directly. Passing the raw reply keeps extraction failures
169
+ visible in the score rather than hidden in the harness.
170
+
171
+ Args:
172
+ response (`str`, *optional*):
173
+ The model's unedited reply. Coordinates are extracted from it.
174
+ lat (`float`, *optional*):
175
+ Latitude, when the caller has already parsed the reply.
176
+ lon (`float`, *optional*):
177
+ Longitude, when the caller has already parsed the reply.
178
+ country (`str`, *optional*):
179
+ ISO-3166 alpha-2 code or country name, scored for partial credit.
180
+ confidence (`float`, *optional*):
181
+ Self-reported confidence in [0, 1], recorded for calibration.
182
+ reasoning (`str`, *optional*):
183
+ Free-text rationale, recorded but not scored.
184
+ """
185
+
186
+ response: str | None = None
187
+ lat: float | None = Field(default=None, ge=-90.0, le=90.0)
188
+ lon: float | None = Field(default=None, ge=-180.0, le=180.0)
189
+ country: str | None = None
190
+ confidence: float | None = Field(default=None, ge=0.0, le=1.0)
191
+ reasoning: str | None = None
192
+
193
+
194
+ # =============================================================================
195
+ # Wire action
196
+ # =============================================================================
197
+
198
+
199
+ class GeoGuesserAction(Action):
200
+ """The single action schema the server accepts.
201
+
202
+ An HTTP environment declares one action class, so every operation travels
203
+ as this flat record with an `op` discriminator. Callers normally build a
204
+ [`TypedAction`] subclass instead and let the client convert.
205
+
206
+ Attributes:
207
+ op (`str`):
208
+ Which operation to perform: `"look"`, `"pan"`, `"zoom"`, `"move"`,
209
+ `"pin"`, `"view_map"`, `"measure"` or `"guess"`.
210
+ """
211
+
212
+ op: Literal["look", "pan", "zoom", "move", "pin", "view_map", "measure", "guess"]
213
+
214
+ heading_deg: float | None = None
215
+ pitch_deg: float | None = None
216
+ fov_deg: float | None = None
217
+ delta_deg: float | None = None
218
+ direction: str | None = None
219
+ meters: float | None = None
220
+ lat: float | None = None
221
+ lon: float | None = None
222
+ label: str | None = None
223
+ span_deg: float | None = None
224
+ lat_a: float | None = None
225
+ lon_a: float | None = None
226
+ lat_b: float | None = None
227
+ lon_b: float | None = None
228
+ response: str | None = None
229
+ country: str | None = None
230
+ confidence: float | None = None
231
+ reasoning: str | None = None
232
+
233
+
234
+ _OP_BY_TYPE: dict[type, str] = {}
235
+ _TYPE_BY_OP: dict[str, type] = {}
236
+
237
+
238
+ def _register_ops() -> None:
239
+ pairs = [
240
+ (LookAction, "look"),
241
+ (PanAction, "pan"),
242
+ (ZoomAction, "zoom"),
243
+ (MoveAction, "move"),
244
+ (PinAction, "pin"),
245
+ (ViewMapAction, "view_map"),
246
+ (MeasureAction, "measure"),
247
+ (GuessAction, "guess"),
248
+ ]
249
+ for cls, op in pairs:
250
+ _OP_BY_TYPE[cls] = op
251
+ _TYPE_BY_OP[op] = cls
252
+
253
+
254
+ _register_ops()
255
+
256
+
257
+ def to_wire(action: TypedAction) -> GeoGuesserAction:
258
+ """
259
+ Convert a typed action into the flat wire action.
260
+
261
+ Args:
262
+ action ([`TypedAction`]):
263
+ The action to convert.
264
+
265
+ Returns:
266
+ [`GeoGuesserAction`]: The same action, flattened, with `op` set.
267
+ """
268
+ op = _OP_BY_TYPE.get(type(action))
269
+ if op is None:
270
+ raise TypeError(f"No wire op registered for {type(action).__name__}")
271
+ payload = action.model_dump(exclude_none=True, exclude={"metadata"})
272
+ return GeoGuesserAction(op=op, **payload)
273
+
274
+
275
+ def from_wire(action: GeoGuesserAction) -> TypedAction:
276
+ """
277
+ Rebuild the typed action a wire action stands for.
278
+
279
+ Args:
280
+ action ([`GeoGuesserAction`]):
281
+ The received wire action.
282
+
283
+ Returns:
284
+ [`TypedAction`]: The corresponding typed action, validated.
285
+ """
286
+ cls = _TYPE_BY_OP.get(action.op)
287
+ if cls is None:
288
+ raise ValueError(f"Unknown op: {action.op!r}")
289
+ fields = set(cls.model_fields) - {"metadata"}
290
+ payload = {
291
+ k: v for k, v in action.model_dump(exclude_none=True).items() if k in fields
292
+ }
293
+ return cls(**payload)
294
+
295
+
296
+ # =============================================================================
297
+ # Observation
298
+ # =============================================================================
299
+
300
+
301
+ class Pin(BaseModel):
302
+ """One candidate pin and what the environment could say about it.
303
+
304
+ Attributes:
305
+ index (`int`):
306
+ 1-based position in the pin list.
307
+ lat (`float`):
308
+ Latitude of the candidate.
309
+ lon (`float`):
310
+ Longitude of the candidate.
311
+ label (`str` or `None`):
312
+ The note the agent attached, if any.
313
+ description (`str`):
314
+ What the environment could say about the pinned coordinate. Never
315
+ anything about the target.
316
+ """
317
+
318
+ index: int
319
+ lat: float
320
+ lon: float
321
+ label: str | None = None
322
+ description: str = ""
323
+
324
+
325
+ class GeoGuesserObservation(Observation):
326
+ """What the agent sees after a reset or a step.
327
+
328
+ The schema is identical across backends. A capability the backend lacks
329
+ shows up as an unregistered tool and an empty field, never as a different
330
+ shape, so one policy runs against every backend.
331
+
332
+ Attributes:
333
+ prompt (`str`):
334
+ Instructions, populated on reset.
335
+ image_base64 (`str` or `None`):
336
+ The most recent rendered image as base64 PNG or JPEG — a
337
+ perspective view, or a map after a pin.
338
+ image_kind (`str`):
339
+ Either `"view"`, `"map"` or `"none"`, saying what the image shows.
340
+ heading_deg (`float`):
341
+ Current compass heading in degrees.
342
+ pitch_deg (`float`):
343
+ Current vertical angle in degrees.
344
+ fov_deg (`float`):
345
+ Current field of view in degrees.
346
+ moved_meters (`float`):
347
+ Distance actually travelled by the last move.
348
+ total_moved_meters (`float`):
349
+ Cumulative distance travelled this episode.
350
+ can_move_forward (`bool`):
351
+ Whether a forward frame exists on the sequence.
352
+ can_move_backward (`bool`):
353
+ Whether a backward frame exists on the sequence.
354
+ available_tools (`list[str]`):
355
+ Tool names this backend actually registered.
356
+ steps_remaining (`int`):
357
+ Actions left before the episode is cut off.
358
+ pins (`list[Pin]`):
359
+ Candidates placed so far, in order.
360
+ feedback (`str`):
361
+ Text describing the result of the last action. For a pin, this
362
+ describes the pinned location and nothing about the target.
363
+ captured_at (`str`):
364
+ Capture date of the current panorama, `YYYY-MM` — a legitimate
365
+ meta clue, as in the real game.
366
+ distance_km (`float` or `None`):
367
+ Distance from guess to truth. Populated only after a guess.
368
+ score (`float` or `None`):
369
+ Distance score in [0, 1], before action costs. After a guess only.
370
+ action_cost (`float`):
371
+ Reward already spent on information gathering this episode. Visible
372
+ throughout, not only after the guess, so a policy can see what it
373
+ has committed.
374
+ true_lat (`float` or `None`):
375
+ Ground truth latitude, revealed only after a guess.
376
+ true_lon (`float` or `None`):
377
+ Ground truth longitude, revealed only after a guess.
378
+ parsed_ok (`bool`):
379
+ Whether coordinates could be extracted from the guess.
380
+ """
381
+
382
+ prompt: str = ""
383
+ image_base64: str | None = None
384
+ image_kind: str = "none"
385
+
386
+ heading_deg: float = 0.0
387
+ pitch_deg: float = 0.0
388
+ fov_deg: float = 90.0
389
+
390
+ moved_meters: float = 0.0
391
+ total_moved_meters: float = 0.0
392
+ can_move_forward: bool = False
393
+ can_move_backward: bool = False
394
+
395
+ available_tools: list[str] = Field(default_factory=list)
396
+ steps_remaining: int = 0
397
+ pins: list[Pin] = Field(default_factory=list)
398
+ feedback: str = ""
399
+ captured_at: str = ""
400
+
401
+ distance_km: float | None = None
402
+ score: float | None = None
403
+ action_cost: float | None = None
404
+ true_lat: float | None = None
405
+ true_lon: float | None = None
406
+ parsed_ok: bool = True
407
+
408
+
409
+ # =============================================================================
410
+ # State
411
+ # =============================================================================
412
+
413
+
414
+ class GeoGuesserState(State):
415
+ """Internal episode state. Never sent to the agent verbatim.
416
+
417
+ Attributes:
418
+ task_index (`int`):
419
+ Index into the frozen task list, `-1` before the first reset.
420
+ task_id (`str`):
421
+ Stable identifier of the sampled task.
422
+ frame_index (`int`):
423
+ Position within the task's sequence.
424
+ heading_deg (`float`):
425
+ Current heading in degrees.
426
+ pitch_deg (`float`):
427
+ Current pitch in degrees.
428
+ fov_deg (`float`):
429
+ Current field of view in degrees.
430
+ n_looks (`int`):
431
+ Count of view renders, for action cost.
432
+ n_maps (`int`):
433
+ Count of map renders that were not pins.
434
+ n_pins (`int`):
435
+ Count of pins placed.
436
+ n_moves (`int`):
437
+ Count of moves taken.
438
+ total_moved_meters (`float`):
439
+ Cumulative distance travelled.
440
+ submitted (`bool`):
441
+ Whether the single allowed guess has been made.
442
+ pins (`list[dict]`):
443
+ Pins placed so far.
444
+ """
445
+
446
+ task_index: int = -1
447
+ task_id: str = ""
448
+ frame_index: int = 0
449
+ heading_deg: float = 0.0
450
+ pitch_deg: float = 0.0
451
+ fov_deg: float = 90.0
452
+ n_looks: int = 0
453
+ n_maps: int = 0
454
+ n_pins: int = 0
455
+ n_moves: int = 0
456
+ total_moved_meters: float = 0.0
457
+ submitted: bool = False
458
+ pins: list[dict[str, Any]] = Field(default_factory=list)
geoguesser_env/openenv.yaml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: geoguesser_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
8
+ # Applied as Space variables when this environment is deployed. The paths point
9
+ # at a Storage Bucket mounted read-only at /data, because ~31 GB of panoramas
10
+ # cannot live in the Space image: Space disk is ephemeral and capped well below
11
+ # that. Locally these are unset and the server falls back to repo-relative
12
+ # defaults, so the same image serves both.
13
+ variables:
14
+ GEOGUESSER_TASKS_EVAL: /data/tasks/eval_pano_v3.jsonl
15
+ GEOGUESSER_TASKS_TRAIN: /data/tasks/train_pano_v3.jsonl
16
+ GEOGUESSER_DEFAULT_SPLIT: train
17
+ GEOGUESSER_CACHE: /data/panos
18
+ # A mounted bucket makes the dataset complete, so a cache miss is a bug
19
+ # rather than something to paper over with a network call.
20
+ GEOGUESSER_ALLOW_FETCH: "0"
21
+ # The street overlay is fetched per agent pin, so it cannot be pre-warmed.
22
+ GEOGUESSER_STREET_DETAIL: "0"
geoguesser_env/openenv_geoguesser_env.egg-info/PKG-INFO ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-geoguesser-env
3
+ Version: 0.1.0
4
+ Summary: GeoGuessr-style visual geolocation environment for OpenEnv
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: openenv>=0.3.1
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: pydantic>=2.0.0
10
+ Requires-Dist: uvicorn>=0.24.0
11
+ Requires-Dist: fastmcp>=2.0.0
12
+ Requires-Dist: pillow>=10.0.0
13
+ Requires-Dist: numpy>=1.24.0
14
+ Requires-Dist: matplotlib>=3.7.0
15
+ Provides-Extra: ui
16
+ Requires-Dist: gradio>=4.0.0; extra == "ui"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
19
+
20
+ # GeoGuesser
21
+
22
+ A GeoGuessr-style visual geolocation environment. The agent is dropped at an
23
+ unknown street-level location, looks around, walks along the road, pins
24
+ candidate coordinates on a map to check itself, and commits to a final guess.
25
+ Reward is distance-based, using the game's own scoring curve.
26
+
27
+ Independent open-source project, unaffiliated with GeoGuessr AB. Imagery comes
28
+ from Mapillary contributors under CC-BY-SA-4.0.
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ # 1. Build a task index (needs a free Mapillary token with READ scope)
34
+ export MAPILLARY_API_KEY="MLY|..."
35
+ cd envs/geoguesser_env
36
+ uv run python scripts/build_pano_tasks.py --tasks 100 --frames 24
37
+
38
+ # 2. Run the server
39
+ uv run --project . server # http://localhost:8000
40
+ ```
41
+
42
+ ```python
43
+ from geoguesser_env import GeoGuesserEnv, GuessAction, LookAction, PinAction
44
+
45
+ env = GeoGuesserEnv(base_url="http://localhost:8000")
46
+
47
+ result = env.reset(task_index=7) # byte-identical on repeat
48
+ print(result.observation.prompt)
49
+
50
+ result = env.step(LookAction(heading_deg=90, fov_deg=45))
51
+ result = env.step(PinAction(lat=-16.5, lon=-68.1))
52
+ print(result.observation.feedback)
53
+ # Pin 1 placed at -16.5000, -68.1000 - Bolivia (South America).
54
+ # Nearest major city: La Paz, ~5 km E. 10 actions left.
55
+
56
+ result = env.step(GuessAction(response="Altiplano. <guess>-16.49, -68.12</guess>"))
57
+ print(result.reward, result.observation.distance_km)
58
+ ```
59
+
60
+ ## Tools
61
+
62
+ | Tool | What it does | Cost |
63
+ |------|--------------|------|
64
+ | `look(heading_deg, pitch_deg, fov_deg)` | Render a view. Heading is absolute, `0` is true north | −0.01 |
65
+ | `pan(delta_deg)` | Turn relative to the current heading | −0.01 |
66
+ | `zoom(fov_deg)` | Narrow the field of view; around 30 reads distant signs | −0.01 |
67
+ | `move(direction, meters)` | Walk the captured road; reports distance actually travelled | −0.05 |
68
+ | `place_pin(lat, lon, label)` | Pin a candidate and see where it falls on the map | −0.02 |
69
+ | `view_map(lat, lon, span_deg)` | Pan and zoom the map without pinning | −0.01 |
70
+ | `list_pins()` / `clear_pins()` | Review or drop candidates | free |
71
+ | `measure(lat_a, lon_a, lat_b, lon_b)` | Distance between two of your own points | free |
72
+ | `reverse_geocode(lat, lon)` | Name the country and nearest city at a coordinate | free |
73
+ | `submit_guess(lat, lon, ...)` | Commit the answer. Terminal | — |
74
+
75
+ Tools the backend cannot serve are **not registered**, so the agent never sees
76
+ a tool that always fails.
77
+
78
+ ### Pinning tells you where you pointed, not whether you are right
79
+
80
+ `place_pin` returns a rendered map and a description of the pinned location:
81
+ country, subregion, nearest city with distance and bearing, and the distance
82
+ to the agent's own earlier pins. It reveals nothing about the target.
83
+
84
+ That restraint is deliberate. Any signal about the truth — a distance, a
85
+ warmer/colder hint — would make binary search the optimal policy, and the
86
+ environment would measure bisection rather than geographic reasoning. Distance
87
+ and score arrive only from `submit_guess`.
88
+
89
+ ## Reward
90
+
91
+ ```
92
+ geo = exp(-distance_km / 1492.7) # GeoGuessr's curve, in [0, 1]
93
+ partial = 0.15 * country_hit + 0.10 * region_hit # when hierarchical
94
+ cost = 0.01*looks + 0.01*maps + 0.02*pins + 0.05*moves
95
+ reward = clip(geo + partial, 0, 1) - cost
96
+ ```
97
+
98
+ An unparseable or out-of-range guess scores `0.0` and says why. Parsing
99
+ accepts what models actually emit: decimal pairs, DMS (`48°51'29"N`), labelled
100
+ `lat:`/`lon:`, JSON, and `<guess>` tags.
101
+
102
+ ## Reproducibility
103
+
104
+ ```python
105
+ env.reset(task_index=7) # exact task, byte-identical observation -> GRPO, eval
106
+ env.reset(seed=42) # tasks[42 % n_tasks] -> replay
107
+ env.reset() # random task, index in metadata -> UI
108
+ ```
109
+
110
+ Byte-identical repeats hold because panorama bytes come from a local cache
111
+ rather than an expiring CDN URL, reprojection is pure numpy with integer
112
+ sampling, and the initial heading is pinned to each panorama's own
113
+ `compass_angle`.
114
+
115
+ An eval score is only meaningful alongside its provenance — the env version,
116
+ the task index, and `GEODATA_VERSION` from `server/render/minimap.py`, since
117
+ the bundled vectors determine the reverse-geocode text the agent sees.
118
+
119
+ ## Training and collection
120
+
121
+ The environment plugs into `openenv.core.harness`, so a rollout function and a
122
+ collector come for free:
123
+
124
+ ```python
125
+ from geoguesser_env import GeoGuesserEnv
126
+ from geoguesser_env.harness import GeoGuesserSessionFactory, load_tasks
127
+
128
+ tasks = load_tasks("tasks/pano_v1.jsonl", repeat=16) # GRPO group of 16
129
+ factory = GeoGuesserSessionFactory(
130
+ lambda: GeoGuesserEnv(base_url="http://localhost:8000")
131
+ )
132
+ ```
133
+
134
+ See `examples/geoguesser_rollout.py` for a scripted rollout and
135
+ `examples/geoguesser_collect.py` for JSONL collection with resume.
136
+
137
+ ## Human play
138
+
139
+ A five-round game, 5,000 points a round on the same curve the environment
140
+ rewards, so a human score is directly comparable to GeoGuessr intuition and to
141
+ the agent's reward (both are shown).
142
+
143
+ ```bash
144
+ uv run --project . server
145
+ # then open http://localhost:8000/geoguesser/play
146
+ ```
147
+
148
+ The page stands alone at `/geoguesser/play` and is also embedded in the Gradio
149
+ playground's **Custom** tab when the web interface is enabled:
150
+
151
+ ```bash
152
+ ENABLE_WEB_INTERFACE=true uv run --project . server # http://localhost:8000/web/
153
+ ```
154
+
155
+ Drag to look around, scroll to zoom, **M** toggles a larger map, **Enter**
156
+ submits and then advances. On submit the map takes the screen and draws the
157
+ line between guess and truth, exactly like the game; the result bar shows
158
+ distance, points, env reward and the true location, and a scoreboard breaks
159
+ down all five rounds at the end.
160
+
161
+ Panoramas are rendered by Pannellum and the map by MapLibre over OpenFreeMap
162
+ tiles — no API key, no request limits. The imagery credit line names the
163
+ Mapillary contributor, which the CC-BY-SA licence requires.
164
+
165
+ The page has to be a standalone document rather than a Gradio `gr.HTML`
166
+ fragment: `gr.HTML` inserts markup without executing `<script>` tags, so the
167
+ viewers never initialise and the panel renders blank with no error anywhere.
168
+
169
+ Extra routes, all local:
170
+
171
+ | Route | Returns |
172
+ |-------|---------|
173
+ | `/geoguesser/play` | the play page |
174
+ | `/geoguesser/tasks` | `{"n_tasks": N}` |
175
+ | `/geoguesser/task/{i}` | task metadata, including ground truth for the human UI |
176
+ | `/geoguesser/pano/{i}` | the raw equirectangular panorama |
177
+
178
+ The human map uses live tiles; the agent's map stays the offline Natural Earth
179
+ render, so the agent keeps a determinism the browser does not need. Note that
180
+ `/geoguesser/task/{i}` exposes ground truth — it exists for a person playing in
181
+ their own browser, and agent observations still withhold it until the guess.
182
+
183
+ ## Configuration
184
+
185
+ | Variable | Default | Meaning |
186
+ |----------|---------|---------|
187
+ | `GEOGUESSER_INDEX` | `tasks/pano_v1.jsonl` | Task index to load |
188
+ | `GEOGUESSER_CACHE` | `data/panos` | Panorama cache directory |
189
+ | `GEOGUESSER_EPISODE_MODE` | `agentic` | `agentic`, `single_shot` or `nmpz` |
190
+ | `GEOGUESSER_MAX_STEPS` | `12` | Actions before the episode is cut off |
191
+ | `GEOGUESSER_REWARD_MODE` | `coords` | `coords` or `country_only` |
192
+ | `GEOGUESSER_HIERARCHICAL` | `0` | Add country and region partial credit |
193
+ | `GEOGUESSER_VIEW_SIZE` | `640` | Edge length of rendered views |
194
+ | `GEOGUESSER_ALLOW_FETCH` | `1` | Whether a cache miss may reach the API |
195
+ | `MAPILLARY_API_KEY` | — | Needed by the builder, and only on a cache miss |
196
+
197
+ ## Data
198
+
199
+ The committed index holds **100 tasks across 47 countries and all six
200
+ continents**, one per Mapillary sequence, captured between 2017 and 2026:
201
+
202
+ | | |
203
+ |---|---|
204
+ | Tasks | 100 (`task_index` 0-99) |
205
+ | Countries | 47, capped at 4 tasks each |
206
+ | Continents | Europe 34, Asia 27, South America 23, North America 11, Oceania 3, Africa 2 |
207
+ | Frames per task | 23.2 mean (3 min, 24 max), ~3.3 m apart |
208
+ | Index size | 417 KB, committed |
209
+ | Cache | 37 MB for the 100 start frames; ~0.6 GB fully warmed |
210
+
211
+ The index is self-contained: every frame's coordinates, heading and capture
212
+ date live in `tasks/pano_v1.jsonl`, so the movement graph resolves offline.
213
+ Only image bytes are fetched, and only on a cache miss, because Mapillary
214
+ `thumb_*_url` values are expiring signed URLs that cannot be stored.
215
+
216
+ Coverage is uneven and worth knowing about. Probing 45 Street-View
217
+ coordinates found any Mapillary imagery at 21 and a 360-degree panorama at
218
+ only 7, heavily clustered. Panorama-first discovery is therefore the only
219
+ approach that works — roughly 5% of probe points yield a usable sequence, so
220
+ reaching 100 tasks took two passes with different seeds, merged by
221
+ `scripts/merge_task_indexes.py`. Africa and Oceania are thin because 360-degree
222
+ contributors are; that is a property of the source, documented rather than
223
+ papered over.
224
+
225
+ ## Known gaps versus the real game
226
+
227
+ Movement follows captured sequences and stops where one ends. There is no
228
+ multi-round cumulative score, no wall-clock timer (a step budget stands in for
229
+ it), and no satellite layer on the guess map. Coverage hints and web search are
230
+ deliberately excluded: the first is a crutch, the second turns the task into
231
+ retrieval.
232
+
233
+ See [DESIGN.md](DESIGN.md) for the reasoning behind these choices.
geoguesser_env/openenv_geoguesser_env.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ openenv_geoguesser_env.egg-info/PKG-INFO
4
+ openenv_geoguesser_env.egg-info/SOURCES.txt
5
+ openenv_geoguesser_env.egg-info/dependency_links.txt
6
+ openenv_geoguesser_env.egg-info/entry_points.txt
7
+ openenv_geoguesser_env.egg-info/requires.txt
8
+ openenv_geoguesser_env.egg-info/top_level.txt
9
+ server/__init__.py
10
+ server/app.py
11
+ server/geoguesser_environment.py
12
+ server/gradio_ui.py
13
+ server/parser.py
14
+ server/scoring.py
15
+ server/backends/__init__.py
16
+ server/backends/base.py
17
+ server/backends/panorama.py
18
+ server/render/__init__.py
19
+ server/render/minimap.py
20
+ server/render/pano.py
21
+ tests/test_geoguesser_env.py
geoguesser_env/openenv_geoguesser_env.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
geoguesser_env/openenv_geoguesser_env.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = server.app:main
geoguesser_env/openenv_geoguesser_env.egg-info/requires.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv>=0.3.1
2
+ fastapi>=0.115.0
3
+ pydantic>=2.0.0
4
+ uvicorn>=0.24.0
5
+ fastmcp>=2.0.0
6
+ pillow>=10.0.0
7
+ numpy>=1.24.0
8
+ matplotlib>=3.7.0
9
+
10
+ [dev]
11
+ pytest>=8.0.0
12
+
13
+ [ui]
14
+ gradio>=4.0.0
geoguesser_env/openenv_geoguesser_env.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ server
geoguesser_env/pyproject.toml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-geoguesser-env"
7
+ version = "0.1.0"
8
+ description = "GeoGuessr-style visual geolocation environment for OpenEnv"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ # Core OpenEnv dependencies (required for server functionality).
13
+ "openenv>=0.3.1",
14
+ "fastapi>=0.115.0",
15
+ "pydantic>=2.0.0",
16
+ "uvicorn>=0.24.0",
17
+ "fastmcp>=2.0.0",
18
+ # Environment-specific: panorama reprojection and the offline guess map.
19
+ "pillow>=10.0.0",
20
+ "numpy>=1.24.0",
21
+ "matplotlib>=3.7.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ ui = ["gradio>=4.0.0"]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ server = "server.app:main"
32
+
33
+ [tool.setuptools]
34
+ packages = ["server", "server.backends", "server.render"]
geoguesser_env/scripts/benchmark_throughput.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Measure how many episodes the environment itself can supply.
4
+
5
+ Answers the question a training or eval run actually needs: is the environment
6
+ the bottleneck, or the model? Runs an agentic-shaped episode — reset, three
7
+ looks, optionally a pin, a guess — across a range of worker counts, both as
8
+ threads and as processes, and reports episodes and steps per second.
9
+
10
+ Every worker gets its own environment, because an episode is stateful; sharing
11
+ one interleaves resets and silently produces nonsense. A perfect guess is
12
+ asserted to score zero distance, so an interference bug fails loudly instead of
13
+ reporting excellent throughput.
14
+
15
+ Usage:
16
+ python scripts/benchmark_throughput.py threads 32
17
+ REVEAL=0 PIN=0 python scripts/benchmark_throughput.py threads 64
18
+ python scripts/benchmark_throughput.py processes 32
19
+
20
+ Environment variables:
21
+ REVEAL=0 skip the guess reveal map, as a training run would
22
+ PIN=0 skip the pin, leaving view renders only
23
+ """
24
+
25
+ import concurrent.futures as cf
26
+ import os
27
+ import pathlib
28
+ import statistics
29
+ import sys
30
+ import threading
31
+ import time
32
+
33
+ _LOCAL = threading.local()
34
+
35
+ _ROOT = pathlib.Path(__file__).resolve().parents[2]
36
+ sys.path.insert(0, str(_ROOT))
37
+ sys.path.insert(0, str(_ROOT.parent / "src"))
38
+
39
+ INDEX = str(_ROOT / "geoguesser_env" / "tasks" / "pano_v1.jsonl")
40
+ CACHE = str(_ROOT / "geoguesser_env" / "data" / "panos")
41
+
42
+
43
+ def episode(task_index: int) -> tuple[float, int]:
44
+ """One agentic-shaped episode: reset, 3 looks, 1 pin, 1 guess."""
45
+ from geoguesser_env.models import GuessAction, LookAction, PinAction, to_wire
46
+ from geoguesser_env.server.geoguesser_environment import GeoGuesserEnvironment
47
+ from geoguesser_env.server.render import minimap
48
+
49
+ minimap.set_street_detail(False) # training config: never touch Overpass
50
+ # An episode is stateful, so every worker needs its own environment. Sharing
51
+ # one through a module global interleaves resets between threads and silently
52
+ # produces nonsense — which is exactly what the first version of this
53
+ # benchmark measured.
54
+ if not hasattr(_LOCAL, "env"):
55
+ _LOCAL.env = GeoGuesserEnvironment(
56
+ index_path=INDEX,
57
+ cache_dir=CACHE,
58
+ allow_fetch=False,
59
+ view_size=640,
60
+ reveal_map=os.environ.get("REVEAL", "1") == "1",
61
+ )
62
+ env = _LOCAL.env
63
+ started = time.perf_counter()
64
+ env.reset(task_index=task_index)
65
+ steps = 1
66
+ for heading in (0, 120, 240):
67
+ env.step(to_wire(LookAction(heading_deg=heading, fov_deg=90)))
68
+ steps += 1
69
+ if os.environ.get("PIN", "1") == "1":
70
+ env.step(to_wire(PinAction(lat=10.0, lon=10.0, span_deg=7.0)))
71
+ steps += 1
72
+ truth = env._task.truth
73
+ result = env.step(to_wire(GuessAction(lat=truth[0], lon=truth[1])))
74
+ steps += 1
75
+ # A perfect guess must score ~1 minus costs. If workers interfered, this
76
+ # fails instead of quietly reporting a great throughput number.
77
+ assert result.distance_km is not None and result.distance_km < 0.01, (
78
+ f"cross-worker interference: task {task_index} scored "
79
+ f"{result.distance_km} km on a perfect guess"
80
+ )
81
+ return time.perf_counter() - started, steps
82
+
83
+
84
+ def run(pool_cls, workers: int, episodes: int) -> dict:
85
+ tasks = [i % 100 for i in range(episodes)]
86
+ wall0 = time.perf_counter()
87
+ with pool_cls(max_workers=workers) as pool:
88
+ results = list(pool.map(episode, tasks))
89
+ wall = time.perf_counter() - wall0
90
+ latencies = [r[0] for r in results]
91
+ total_steps = sum(r[1] for r in results)
92
+ return {
93
+ "workers": workers,
94
+ "episodes": episodes,
95
+ "wall_s": wall,
96
+ "episodes_per_s": episodes / wall,
97
+ "steps_per_s": total_steps / wall,
98
+ "median_episode_s": statistics.median(latencies),
99
+ }
100
+
101
+
102
+ if __name__ == "__main__":
103
+ kind = sys.argv[1]
104
+ episodes = int(sys.argv[2]) if len(sys.argv) > 2 else 24
105
+ pool = cf.ThreadPoolExecutor if kind == "threads" else cf.ProcessPoolExecutor
106
+ print(f"{kind}, {os.cpu_count()} cpus, warm cache, street detail off")
107
+ print(f"{'workers':>7} {'eps/s':>7} {'steps/s':>8} {'wall':>7} {'median ep':>10}")
108
+ for workers in (1, 2, 4, 8, 16):
109
+ r = run(pool, workers, episodes)
110
+ print(
111
+ f"{r['workers']:>7} {r['episodes_per_s']:>7.2f} {r['steps_per_s']:>8.1f} "
112
+ f"{r['wall_s']:>6.1f}s {r['median_episode_s']:>9.2f}s"
113
+ )
geoguesser_env/scripts/build_dataset.sh ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ #
4
+ # Build the offline task pool: every frame of every task mirrored to disk, so
5
+ # both training and eval run with the network off.
6
+ #
7
+ # Eval is NOT carved here. Sample it afterwards with split_tasks.py, which
8
+ # enforces the separation (shared sequences, plus a 1 km buffer) in one place
9
+ # rather than trusting two independent builds not to collide.
10
+ #
11
+ # Usage:
12
+ # ./scripts/build_dataset.sh # 5000 tasks, the default
13
+ # TASKS=100 ./scripts/build_dataset.sh # trial
14
+ # ./scripts/build_dataset.sh --seed 12 # extra args reach build_tasks.py
15
+ #
16
+ # Resumable: Ctrl-C and re-run the identical command. It continues where it
17
+ # stopped and skips every sequence already used.
18
+
19
+ set -euo pipefail
20
+
21
+ TASKS="${TASKS:-5000}"
22
+ FRAMES="${FRAMES:-24}"
23
+
24
+ # Only reject sequences too short to navigate at all. Demanding a full 24
25
+ # everywhere would drop ~22% of candidates, and short sequences cluster in
26
+ # sparsely mapped regions, so that filter quietly costs geographic diversity.
27
+ # Tasks therefore carry 8-24 frames; filter to 24 when sampling eval, where a
28
+ # uniform movement budget is what makes scores comparable.
29
+ MIN_FRAMES="${MIN_FRAMES:-8}"
30
+
31
+ # workers x frame-workers is the concurrent fetch count. Measured on an
32
+ # 18-core machine: 24->7.7 img/s, 48->16.1, 96->27.0, 144->37.0, zero failures,
33
+ # and 144 sits at ~7% of Mapillary's 60k/min entity limit.
34
+ WORKERS="${WORKERS:-12}"
35
+ FRAME_WORKERS="${FRAME_WORKERS:-12}"
36
+
37
+ # Loose country cap: this pool feeds training, where breadth beats balance, and
38
+ # eval balance is enforced at sampling time instead. The per-creator cap stays
39
+ # tight because one contributor holds 99,167 pool sequences (8% of the pool).
40
+ PER_COUNTRY="${PER_COUNTRY:-150}"
41
+ PER_CREATOR="${PER_CREATOR:-25}"
42
+
43
+ OUT="${OUT:-tasks/pool_offline_5k.jsonl}"
44
+
45
+ cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/.."
46
+ ENV_DIR="$PWD"
47
+ REPO_ROOT="$(cd ../.. && pwd)"
48
+ PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
49
+
50
+ if [ ! -x "$PYTHON" ]; then
51
+ echo "no interpreter at $PYTHON — run 'uv sync --all-extras' first, or set PYTHON=" >&2
52
+ exit 1
53
+ fi
54
+
55
+ # Read the token in Python, never by sourcing .env: the "|" in "MLY|..." is a
56
+ # pipe to the shell, which leaks the token into the terminal.
57
+ if [ -z "${MAPILLARY_API_KEY_TRAIN:-}" ] && [ -z "${MAPILLARY_API_KEY:-}" ]; then
58
+ MAPILLARY_API_KEY_TRAIN="$("$PYTHON" - "$REPO_ROOT/.env" <<'PY'
59
+ import pathlib, sys
60
+ path = pathlib.Path(sys.argv[1])
61
+ if path.exists():
62
+ for line in path.read_text().splitlines():
63
+ for name in ("MAPILLARY_API_KEY_TRAIN", "MAPILLARY_API_KEY"):
64
+ if line.startswith(name + "="):
65
+ print(line.split("=", 1)[1].strip().strip('"').strip("'"))
66
+ sys.exit()
67
+ PY
68
+ )"
69
+ export MAPILLARY_API_KEY_TRAIN
70
+ fi
71
+ if [ -z "${MAPILLARY_API_KEY_TRAIN:-}" ] && [ -z "${MAPILLARY_API_KEY:-}" ]; then
72
+ echo "no Mapillary token: set MAPILLARY_API_KEY_TRAIN or add it to $REPO_ROOT/.env" >&2
73
+ exit 1
74
+ fi
75
+
76
+ "$PYTHON" -c "import matplotlib, rich, PIL, numpy" 2>/dev/null || {
77
+ echo "missing dependencies — install with:" >&2
78
+ echo " uv pip install --python $PYTHON matplotlib rich pillow numpy" >&2
79
+ exit 1
80
+ }
81
+
82
+ POOL="$ENV_DIR/data/pool/sequences.jsonl"
83
+ if [ ! -s "$POOL" ]; then
84
+ echo "no sequence pool at $POOL — run scripts/harvest_tiles.py first" >&2
85
+ exit 1
86
+ fi
87
+
88
+ DONE=0
89
+ [ -f "$OUT" ] && DONE=$(grep -c . "$OUT" || true)
90
+ TODO=$((TASKS - DONE))
91
+
92
+ # 0.52 s/task and 0.256 MB/frame, both measured on the 200-task build.
93
+ "$PYTHON" - "$TODO" "$FRAMES" "$((WORKERS * FRAME_WORKERS))" "$DONE" <<'PY'
94
+ import sys
95
+ todo, frames, conc, done = (int(v) for v in sys.argv[1:5])
96
+ if done:
97
+ print(f"resuming: {done} tasks already built")
98
+ if todo <= 0:
99
+ raise SystemExit(0)
100
+ secs = todo * 0.52
101
+ print(
102
+ f"{todo} tasks to build · {frames} frames each · {conc} concurrent fetches\n"
103
+ f"estimated {secs / 60:.0f} min "
104
+ f"({int(secs // 3600)}h{int(secs % 3600 // 60):02d}m) "
105
+ f"and {todo * frames * 0.256 / 1000:.1f} GB of imagery"
106
+ )
107
+ PY
108
+
109
+ echo "pool: $(grep -c . "$POOL") sequences · out: $OUT"
110
+ echo
111
+
112
+ "$PYTHON" scripts/build_tasks.py \
113
+ --tasks "$TASKS" \
114
+ --frames "$FRAMES" \
115
+ --min-frames "$MIN_FRAMES" \
116
+ --mirror all \
117
+ --workers "$WORKERS" \
118
+ --frame-workers "$FRAME_WORKERS" \
119
+ --per-country "$PER_COUNTRY" \
120
+ --per-creator "$PER_CREATOR" \
121
+ --out "$OUT" \
122
+ "$@"
123
+
124
+ echo
125
+ "$PYTHON" - "$OUT" "$ENV_DIR/data/panos" <<'PY'
126
+ import collections, json, pathlib, sys
127
+
128
+ tasks = [
129
+ json.loads(line)
130
+ for line in pathlib.Path(sys.argv[1]).read_text().splitlines()
131
+ if line.strip()
132
+ ]
133
+ cache = pathlib.Path(sys.argv[2])
134
+ if not tasks:
135
+ raise SystemExit("no tasks were written")
136
+
137
+ missing = [
138
+ frame["image_id"]
139
+ for task in tasks
140
+ for frame in task["frames"]
141
+ if not (cache / f"{frame['image_id']}.jpg").exists()
142
+ ]
143
+ countries = collections.Counter(task["country"] for task in tasks)
144
+ creators = collections.Counter(task["attribution"]["creator_id"] for task in tasks)
145
+ full = sum(1 for task in tasks if len(task["frames"]) >= 24)
146
+ total_frames = sum(len(task["frames"]) for task in tasks)
147
+
148
+ print(f"{len(tasks)} tasks · {len(countries)} countries · {len(creators)} contributors")
149
+ print(f"frames: {total_frames} total · {full} tasks have the full 24")
150
+ top, top_n = countries.most_common(1)[0]
151
+ print(f"largest country: {top} at {top_n} ({100 * top_n / len(tasks):.1f}%)")
152
+ print(f"largest contributor: {creators.most_common(1)[0][1]} tasks")
153
+ print(f"eval candidates with 24 frames: {full}")
154
+
155
+ if missing:
156
+ raise SystemExit(f"NOT fully offline: {len(missing)} frames absent from the cache")
157
+ print(f"fully offline: all {total_frames} frames are on disk")
158
+ PY
159
+
160
+ echo
161
+ echo "next: sample eval out of the pool, which enforces the separation:"
162
+ echo " $PYTHON scripts/split_tasks.py $OUT --eval 200"
geoguesser_env/scripts/build_eval_set.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Build a frozen evaluation set of 360-degree panorama tasks.
4
+
5
+ Discovery is city-seeded and adaptive, which measured far better than the
6
+ road-point probing in `build_pano_tasks.py`:
7
+
8
+ 45 m probe boxes at Street View coordinates .......... 5% of seeds hit
9
+ adaptive box, jittered 28 km around cities ........... 3%
10
+ adaptive box, ±2 km around cities .................... 21%
11
+ adaptive box, city centres ........................... 26%, 16 sequences/hit
12
+
13
+ Two things drive that. Panoramas are extremely clustered — one hit typically
14
+ yields a dozen or more sequences from the same contributor's drive — so the
15
+ useful unit of discovery is a neighbourhood, not a point. And `is_pano=true` is
16
+ a real server-side filter, so one request answers "are there panoramas here"
17
+ instead of returning a hundred phone photographs to sift.
18
+
19
+ An eval set is not a training pool, so this applies three extra rules:
20
+
21
+ - **balance over size**: capped per country, because at n=100 balance decides
22
+ what the number means
23
+ - **disjoint from training**: any sequence already in the training index is
24
+ rejected, and so is anything within a spatial buffer of it, since frames sit
25
+ about 3.3 m apart and holding out an image holds out nothing
26
+ - **mirrored and checksummed**: the imagery is downloaded and hashed, because
27
+ Mapillary thumbnail URLs expire and uploads get deleted, and an eval whose
28
+ images can change is not an eval
29
+
30
+ Usage:
31
+ python scripts/build_eval_set.py --tasks 100 --per-country 2
32
+ python scripts/build_eval_set.py --tasks 100 --exclude tasks/pano_v1.jsonl
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import concurrent.futures
39
+ import functools
40
+ import hashlib
41
+ import json
42
+ import logging
43
+ import math
44
+ import pathlib
45
+ import random
46
+ import sys
47
+ import urllib.request
48
+
49
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
50
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
51
+
52
+ from build_pano_tasks import ( # noqa: E402
53
+ _get,
54
+ _month,
55
+ _token,
56
+ frame_detail,
57
+ sequence_frames,
58
+ TIMEOUT_S,
59
+ )
60
+ from matplotlib.path import Path as MplPath # noqa: E402
61
+ from server.render.minimap import locate # noqa: E402
62
+
63
+
64
+ # Boxes are tried widest first; a dense city rejects the wide ones with a 500
65
+ # because the scan happens before the filter, so the ladder walks down.
66
+ BBOX_LADDER = (0.005, 0.002, 0.0006)
67
+
68
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
69
+ logger = logging.getLogger("build_eval_set")
70
+
71
+
72
+ def _box(lat: float, lon: float, half: float) -> str:
73
+ return f"{lon - half},{lat - half},{lon + half},{lat + half}"
74
+
75
+
76
+ def _haversine_km(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
77
+ phi_a, phi_b = math.radians(lat_a), math.radians(lat_b)
78
+ d_phi, d_lambda = phi_b - phi_a, math.radians(lon_b - lon_a)
79
+ h = (
80
+ math.sin(d_phi / 2) ** 2
81
+ + math.cos(phi_a) * math.cos(phi_b) * math.sin(d_lambda / 2) ** 2
82
+ )
83
+ return 2 * 6371.0088 * math.asin(math.sqrt(min(1.0, h)))
84
+
85
+
86
+ @functools.lru_cache(maxsize=1)
87
+ def _country_polygons() -> list[tuple[str, MplPath]]:
88
+ """Country name and outline for every admin-0 polygon, loaded once."""
89
+ root = pathlib.Path(__file__).resolve().parents[1]
90
+ features = json.loads(
91
+ (root / "data" / "geo" / "ne_110m_admin_0_countries.geojson").read_text()
92
+ )["features"]
93
+ out = []
94
+ for feature in features:
95
+ geometry = feature["geometry"]
96
+ rings = (
97
+ [geometry["coordinates"][0]]
98
+ if geometry["type"] == "Polygon"
99
+ else [poly[0] for poly in geometry["coordinates"]]
100
+ )
101
+ for ring in rings:
102
+ out.append((feature["properties"]["ADMIN"], MplPath(ring)))
103
+ return out
104
+
105
+
106
+ def _country_of(lat: float, lon: float) -> str | None:
107
+ """Country containing a coordinate, or `None` over water.
108
+
109
+ Cheaper than `locate()`, which also scans every populated place for the
110
+ nearest city — needless when only the country is wanted for seeding.
111
+ """
112
+ for name, path in _country_polygons():
113
+ if path.contains_point((lon, lat)):
114
+ return name
115
+ return None
116
+
117
+
118
+ def load_seeds(
119
+ geo_dir: pathlib.Path, limit: int, seed: int, stratify: bool = True
120
+ ) -> list[tuple[str, float, float]]:
121
+ """
122
+ City seeds, ordered to spread across countries.
123
+
124
+ Panorama coverage clusters hard: a first run probed 900 cities and found
125
+ 1,300 sequences, but from only 30 countries — so a per-country cap ran out
126
+ of countries long before it ran out of sequences, and the set stopped at 58
127
+ of 100. Round-robin ordering by country means the first N probes touch N
128
+ different countries instead of hammering whichever metros the shuffle picked.
129
+
130
+ Args:
131
+ geo_dir (`pathlib.Path`):
132
+ Directory holding the bundled Natural Earth files.
133
+ limit (`int`):
134
+ How many seeds to return.
135
+ seed (`int`):
136
+ Seed for the shuffle within each country.
137
+ stratify (`bool`, *optional*, defaults to `True`):
138
+ Order round-robin by country rather than at random.
139
+
140
+ Returns:
141
+ `list[tuple[str, float, float]]`: `(name, lat, lon)` seeds.
142
+ """
143
+ detail = geo_dir / "detail" / "places.json"
144
+ if detail.exists():
145
+ rows = json.loads(detail.read_text())
146
+ points = [(r.get("n") or "", r["c"][1], r["c"][0]) for r in rows]
147
+ else:
148
+ features = json.loads((geo_dir / "ne_50m_populated_places.geojson").read_text())
149
+ points = [
150
+ (
151
+ f["properties"]["name"],
152
+ f["geometry"]["coordinates"][1],
153
+ f["geometry"]["coordinates"][0],
154
+ )
155
+ for f in features["features"]
156
+ ]
157
+ rng = random.Random(seed)
158
+ rng.shuffle(points)
159
+ if not stratify:
160
+ return points[:limit]
161
+
162
+ by_country: dict[str | None, list] = {}
163
+ for name, lat, lon in points:
164
+ by_country.setdefault(_country_of(lat, lon), []).append((name, lat, lon))
165
+ by_country.pop(None, None)
166
+
167
+ buckets = list(by_country.values())
168
+ rng.shuffle(buckets)
169
+ ordered: list[tuple[str, float, float]] = []
170
+ depth = 0
171
+ while len(ordered) < limit and any(len(b) > depth for b in buckets):
172
+ for bucket in buckets:
173
+ if len(bucket) > depth:
174
+ ordered.append(bucket[depth])
175
+ if len(ordered) >= limit:
176
+ break
177
+ depth += 1
178
+ logger.info(
179
+ "seeded %d cities round-robin across %d countries", len(ordered), len(buckets)
180
+ )
181
+ return ordered
182
+
183
+
184
+ def discover(token: str, seeds, workers: int) -> dict[str, dict]:
185
+ """Probe each seed and return one anchor image per panorama sequence."""
186
+
187
+ def probe(seed):
188
+ name, lat, lon = seed
189
+ for half in BBOX_LADDER:
190
+ result = _get(
191
+ token,
192
+ "images",
193
+ bbox=_box(lat, lon, half),
194
+ fields="id,sequence,camera_type,quality_score,computed_geometry",
195
+ is_pano="true",
196
+ )
197
+ if "__error" not in result:
198
+ return name, result.get("data", [])
199
+ return name, []
200
+
201
+ found: dict[str, dict] = {}
202
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
203
+ for index, (name, images) in enumerate(pool.map(probe, seeds), 1):
204
+ for image in images:
205
+ sequence = image.get("sequence")
206
+ if not sequence or sequence in found:
207
+ continue
208
+ if image.get("camera_type") != "spherical":
209
+ continue
210
+ found[sequence] = {**image, "__seed": name}
211
+ if index % 50 == 0:
212
+ logger.info(
213
+ " %d/%d seeds probed, %d sequences", index, len(seeds), len(found)
214
+ )
215
+ return found
216
+
217
+
218
+ def mirror_frames(
219
+ token: str, image_ids: list[str], cache_dir: pathlib.Path, workers: int
220
+ ) -> dict[str, str]:
221
+ """
222
+ Download and hash a task's frames, in parallel.
223
+
224
+ An eval that prefetches all 24 frames so `move()` can be evaluated offline
225
+ was spending 24 sequential round trips per task — about 30 s of the 34 s a
226
+ task cost. Fetching them concurrently makes mirroring roughly free next to
227
+ frame assembly.
228
+
229
+ Args:
230
+ token (`str`):
231
+ Mapillary access token.
232
+ image_ids (`list[str]`):
233
+ Frames to mirror.
234
+ cache_dir (`pathlib.Path`):
235
+ Where the JPEGs live.
236
+ workers (`int`):
237
+ Concurrent downloads.
238
+
239
+ Returns:
240
+ `dict[str, str]`: sha256 by image id, for every frame that landed.
241
+ """
242
+
243
+ def fetch_one(image_id: str) -> tuple[str, str | None]:
244
+ path = cache_dir / f"{image_id}.jpg"
245
+ if not path.exists():
246
+ meta = _get(token, image_id, fields="thumb_2048_url")
247
+ url = meta.get("thumb_2048_url") if "__error" not in meta else None
248
+ if not url:
249
+ return image_id, None
250
+ try:
251
+ with urllib.request.urlopen(url, timeout=TIMEOUT_S) as response:
252
+ path.write_bytes(response.read())
253
+ except Exception as exc: # noqa: BLE001
254
+ logger.warning(" fetch failed for %s: %r", image_id, exc)
255
+ return image_id, None
256
+ return image_id, hashlib.sha256(path.read_bytes()).hexdigest()
257
+
258
+ checksums: dict[str, str] = {}
259
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
260
+ for image_id, digest in pool.map(fetch_one, image_ids):
261
+ if digest:
262
+ checksums[image_id] = digest
263
+ return checksums
264
+
265
+
266
+ def load_exclusions(
267
+ paths: list[pathlib.Path],
268
+ ) -> tuple[set[str], list[tuple[float, float]]]:
269
+ """Sequences and coordinates that a new task must stay away from."""
270
+ sequences: set[str] = set()
271
+ points: list[tuple[float, float]] = []
272
+ for path in paths:
273
+ if not path.exists():
274
+ logger.warning("exclusion file missing, skipping: %s", path)
275
+ continue
276
+ for line in path.read_text().splitlines():
277
+ if not line.strip():
278
+ continue
279
+ row = json.loads(line)
280
+ if row.get("sequence_id"):
281
+ sequences.add(row["sequence_id"])
282
+ for frame in row.get("frames", []):
283
+ points.append((frame["lat"], frame["lon"]))
284
+ return sequences, points
285
+
286
+
287
+ def build(args: argparse.Namespace) -> None:
288
+ """Discover, filter, mirror and write the eval set."""
289
+ token = _token()
290
+ root = pathlib.Path(__file__).resolve().parents[1]
291
+ out_dir = args.out
292
+ cache_dir = args.cache
293
+ out_dir.mkdir(parents=True, exist_ok=True)
294
+ cache_dir.mkdir(parents=True, exist_ok=True)
295
+
296
+ excluded_sequences, excluded_points = load_exclusions(args.exclude)
297
+ logger.info(
298
+ "excluding %d training sequences and staying %.1f km from %d of their frames",
299
+ len(excluded_sequences),
300
+ args.buffer_km,
301
+ len(excluded_points),
302
+ )
303
+
304
+ seeds = load_seeds(
305
+ root / "data" / "geo", args.seeds, args.seed, stratify=not args.no_stratify
306
+ )
307
+ logger.info("probing %d city seeds with %d workers", len(seeds), args.workers)
308
+ candidates = discover(token, seeds, args.workers)
309
+ logger.info("found %d panorama sequences", len(candidates))
310
+
311
+ order = sorted(
312
+ candidates.items(),
313
+ key=lambda kv: -(kv[1].get("quality_score") or 0.0),
314
+ )
315
+ per_country: dict[str, int] = {}
316
+ tasks: list[dict] = []
317
+
318
+ rejected_early = 0
319
+ for sequence_id, anchor in order:
320
+ if len(tasks) >= args.tasks:
321
+ break
322
+ if sequence_id in excluded_sequences:
323
+ continue
324
+
325
+ # Reject before assembling, not after. Discovery already returned the
326
+ # anchor's coordinates, so the country cap and the training buffer can
327
+ # both be checked for free — and with coverage as clustered as it is,
328
+ # most candidates fall in an already-full country. Assembling 25
329
+ # requests' worth of frames first made a task cost 14.4 s instead of 5.
330
+ geometry = anchor.get("computed_geometry") or anchor.get("geometry")
331
+ if geometry:
332
+ anchor_lon, anchor_lat = geometry["coordinates"]
333
+ country_guess = _country_of(anchor_lat, anchor_lon)
334
+ if (
335
+ country_guess is None
336
+ or per_country.get(country_guess, 0) >= args.per_country
337
+ ):
338
+ rejected_early += 1
339
+ continue
340
+ if any(
341
+ _haversine_km(anchor_lat, anchor_lon, lat, lon) < args.buffer_km
342
+ for lat, lon in excluded_points
343
+ ):
344
+ rejected_early += 1
345
+ continue
346
+
347
+ ids = sequence_frames(token, sequence_id)
348
+ if len(ids) < args.min_frames:
349
+ continue
350
+ anchor_id = str(anchor["id"])
351
+ position = ids.index(anchor_id) if anchor_id in ids else 0
352
+ low = max(0, position - args.frames // 2)
353
+ window = ids[low : low + args.frames]
354
+
355
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
356
+ details = list(pool.map(lambda i: frame_detail(token, i), window))
357
+
358
+ frames = []
359
+ for detail in details:
360
+ if not detail or not detail.get("is_pano"):
361
+ continue
362
+ geometry = detail.get("computed_geometry") or detail.get("geometry")
363
+ if not geometry:
364
+ continue
365
+ lon, lat = geometry["coordinates"]
366
+ frames.append(
367
+ {
368
+ "image_id": str(detail["id"]),
369
+ "lat": lat,
370
+ "lon": lon,
371
+ "compass_angle": detail.get("computed_compass_angle")
372
+ or detail.get("compass_angle")
373
+ or 0.0,
374
+ "captured_at": _month(detail.get("captured_at")),
375
+ "is_pano": True,
376
+ }
377
+ )
378
+ if len(frames) < args.min_frames:
379
+ continue
380
+
381
+ # A missing captured_at renders as an epoch date, which is harmless for
382
+ # scoring but breaks a temporal split and looks wrong in the UI.
383
+ # Mapillary launched in 2013.
384
+ frames = [
385
+ f for f in frames if not f["captured_at"] or f["captured_at"][:4] >= "2010"
386
+ ]
387
+ if len(frames) < args.min_frames:
388
+ continue
389
+
390
+ start = min(len(frames) // 2, len(frames) - 1)
391
+ start_frame = frames[start]
392
+
393
+ too_close = any(
394
+ _haversine_km(start_frame["lat"], start_frame["lon"], lat, lon)
395
+ < args.buffer_km
396
+ for lat, lon in excluded_points
397
+ )
398
+ if too_close:
399
+ continue
400
+
401
+ place = locate(start_frame["lat"], start_frame["lon"])
402
+ country = place.country or "unknown"
403
+ if country == "unknown" or per_country.get(country, 0) >= args.per_country:
404
+ continue
405
+
406
+ anchor_detail = next(
407
+ (d for d in details if d and str(d["id"]) == start_frame["image_id"]), {}
408
+ )
409
+ creator = anchor_detail.get("creator", {}) or {}
410
+
411
+ # Mirror the imagery. An eval whose pictures can change upstream is not
412
+ # an eval, and Mapillary thumbnail URLs expire.
413
+ wanted = [f["image_id"] for f in frames[: args.prefetch]]
414
+ if start_frame["image_id"] not in wanted:
415
+ wanted.append(start_frame["image_id"])
416
+ checksums = mirror_frames(token, wanted, cache_dir, args.workers)
417
+
418
+ if start_frame["image_id"] not in checksums:
419
+ continue
420
+
421
+ per_country[country] = per_country.get(country, 0) + 1
422
+ tasks.append(
423
+ {
424
+ "task_index": len(tasks),
425
+ "task_id": f"eval-{len(tasks):04d}",
426
+ "country": country,
427
+ "sequence_id": sequence_id,
428
+ "provider": "mapillary",
429
+ "start_frame": start,
430
+ "frames": frames,
431
+ "attribution": {
432
+ "creator_username": creator.get("username", ""),
433
+ "creator_id": creator.get("id", ""),
434
+ "licence": "CC-BY-SA-4.0",
435
+ "source": "Mapillary",
436
+ },
437
+ "meta": {
438
+ "seed_name": anchor.get("__seed", ""),
439
+ "camera_make": anchor_detail.get("make", ""),
440
+ "camera_model": anchor_detail.get("model", ""),
441
+ "quality_score": anchor_detail.get("quality_score"),
442
+ "continent": place.continent,
443
+ "subregion": place.subregion,
444
+ "nearest_city": place.nearest_city,
445
+ "sha256": checksums,
446
+ "mirrored_frames": len(checksums),
447
+ },
448
+ }
449
+ )
450
+ logger.info(
451
+ " eval %3d %-24s %-8s %2d frames %d mirrored",
452
+ len(tasks) - 1,
453
+ country[:24],
454
+ start_frame["captured_at"],
455
+ len(frames),
456
+ len(checksums),
457
+ )
458
+
459
+ index_path = out_dir / args.name
460
+ with index_path.open("w") as handle:
461
+ for task in tasks:
462
+ handle.write(json.dumps(task) + "\n")
463
+
464
+ logger.info(
465
+ "rejected %d candidates before assembly, on country cap or buffer",
466
+ rejected_early,
467
+ )
468
+ countries = sorted(per_country.items(), key=lambda kv: -kv[1])
469
+ logger.info(
470
+ "\nwrote %d eval tasks to %s (%.0f KB)",
471
+ len(tasks),
472
+ index_path,
473
+ index_path.stat().st_size / 1024,
474
+ )
475
+ logger.info("countries: %d -> %s", len(countries), dict(countries[:12]))
476
+ mirrored = sum(t["meta"]["mirrored_frames"] for t in tasks)
477
+ logger.info("mirrored %d images into %s", mirrored, cache_dir)
478
+
479
+
480
+ def main() -> None:
481
+ """Command-line entry point."""
482
+ root = pathlib.Path(__file__).resolve().parents[1]
483
+ parser = argparse.ArgumentParser(description=__doc__)
484
+ parser.add_argument("--tasks", type=int, default=100)
485
+ parser.add_argument("--per-country", type=int, default=2)
486
+ parser.add_argument("--frames", type=int, default=24)
487
+ parser.add_argument("--min-frames", type=int, default=8)
488
+ parser.add_argument("--prefetch", type=int, default=1)
489
+ parser.add_argument("--seeds", type=int, default=900)
490
+ parser.add_argument("--workers", type=int, default=10)
491
+ parser.add_argument("--seed", type=int, default=1234)
492
+ parser.add_argument(
493
+ "--no-stratify",
494
+ action="store_true",
495
+ help="Probe cities in random order instead of round-robin by country.",
496
+ )
497
+ parser.add_argument("--buffer-km", type=float, default=1.0)
498
+ parser.add_argument("--name", default="eval_pano_v1.jsonl")
499
+ parser.add_argument(
500
+ "--exclude",
501
+ nargs="*",
502
+ type=pathlib.Path,
503
+ default=[root / "tasks" / "pano_v1.jsonl"],
504
+ )
505
+ parser.add_argument("--out", type=pathlib.Path, default=root / "tasks")
506
+ parser.add_argument("--cache", type=pathlib.Path, default=root / "data" / "panos")
507
+ build(parser.parse_args())
508
+
509
+
510
+ if __name__ == "__main__":
511
+ main()
geoguesser_env/scripts/build_pano_tasks.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Build a frozen task index of Mapillary 360-degree panoramas.
4
+
5
+ Discovery is panorama-first. Sampling coordinates and hoping for coverage does
6
+ not work: probing 45 Street-View locations found any Mapillary imagery at 21
7
+ and a 360-degree panorama at only 7. So this script probes many candidate
8
+ points, keeps whatever panoramas exist, and defines tasks there.
9
+
10
+ Two API constraints shape the implementation. The `/images` search is not a
11
+ bulk endpoint — `limit` does not cap the scan and dense bounding boxes fail
12
+ with "reduce the amount of data" — so probes use very small boxes. And
13
+ `camera_type` returns `spherical`, not the `equirectangular` the documentation
14
+ claims, so filtering on the documented value silently matches nothing.
15
+
16
+ Usage:
17
+ python scripts/build_pano_tasks.py --tasks 100 --frames 24
18
+ python scripts/build_pano_tasks.py --tasks 8 --frames 6 --out tests/fixtures
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import concurrent.futures
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import os
29
+ import pathlib
30
+ import random
31
+ import sys
32
+ import time
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+
37
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
38
+
39
+ from server.render.minimap import _cities, locate # noqa: E402
40
+
41
+
42
+ GRAPH_API = "https://graph.mapillary.com"
43
+ PROBE_HALF_DEG = 0.0004
44
+ TIMEOUT_S = 30.0
45
+
46
+ SEARCH_FIELDS = "id,is_pano,camera_type,sequence,quality_score"
47
+ FRAME_FIELDS = (
48
+ "id,geometry,computed_geometry,compass_angle,computed_compass_angle,"
49
+ "captured_at,is_pano,camera_type,quality_score,creator,make,model,width,height"
50
+ )
51
+
52
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
53
+ logger = logging.getLogger("build_pano_tasks")
54
+
55
+
56
+ # Rate limits are per application, so harvesting on its own key means a bulk
57
+ # harvest cannot starve a training run that is filling its panorama cache.
58
+ DATA_KEY_NAMES = ("MAPILLARY_API_KEY_TRAIN", "MAPILLARY_API_KEY")
59
+ RUNTIME_KEY_NAMES = ("MAPILLARY_API_KEY",)
60
+
61
+
62
+ def _read_env_file(names: tuple[str, ...]) -> str | None:
63
+ env_file = pathlib.Path(__file__).resolve().parents[3] / ".env"
64
+ if not env_file.exists():
65
+ return None
66
+ values = {}
67
+ for line in env_file.read_text().splitlines():
68
+ if "=" in line and not line.lstrip().startswith("#"):
69
+ key, value = line.split("=", 1)
70
+ values[key.strip()] = value.strip().strip('"').strip("'")
71
+ for name in names:
72
+ if values.get(name):
73
+ return values[name]
74
+ return None
75
+
76
+
77
+ def _token(names: tuple[str, ...] = DATA_KEY_NAMES) -> str:
78
+ """
79
+ Return a Mapillary token, preferring the harvesting key.
80
+
81
+ Args:
82
+ names (`tuple[str, ...]`, *optional*):
83
+ Environment variable names to try, in order. Defaults to the
84
+ harvesting key first, then the runtime key.
85
+
86
+ Returns:
87
+ `str`: The token.
88
+ """
89
+ for name in names:
90
+ if os.environ.get(name):
91
+ return os.environ[name]
92
+ token = _read_env_file(names)
93
+ if not token:
94
+ raise SystemExit(
95
+ "No Mapillary token found. Set one of "
96
+ + ", ".join(names)
97
+ + ". Register an application at "
98
+ "https://www.mapillary.com/dashboard/developers (READ scope only)."
99
+ )
100
+ return token
101
+
102
+
103
+ def _get(token: str, path: str, **params) -> dict:
104
+ params["access_token"] = token
105
+ url = f"{GRAPH_API}/{path}?" + urllib.parse.urlencode(params)
106
+ try:
107
+ with urllib.request.urlopen(url, timeout=TIMEOUT_S) as response:
108
+ return json.loads(response.read())
109
+ except urllib.error.HTTPError as exc:
110
+ return {"__error": exc.code, "__body": exc.read().decode()[:160]}
111
+ except Exception as exc: # network flake, DNS, timeout
112
+ return {"__error": -1, "__body": repr(exc)[:160]}
113
+
114
+
115
+ def _month(captured_at_ms: int | None) -> str:
116
+ if not captured_at_ms:
117
+ return ""
118
+ return time.strftime("%Y-%m", time.gmtime(captured_at_ms / 1000))
119
+
120
+
121
+ def seed_points(count: int, seed: int = 0) -> list[tuple[str, float, float]]:
122
+ """
123
+ Candidate probe points, drawn from the bundled populated-places file.
124
+
125
+ Using committed data keeps the builder reproducible and avoids a hardcoded
126
+ list of favourite cities. Each point is jittered so repeated runs explore
127
+ slightly different streets.
128
+
129
+ Args:
130
+ count (`int`):
131
+ How many probe points to return.
132
+ seed (`int`, *optional*, defaults to `0`):
133
+ Seed for the jitter and the shuffle.
134
+
135
+ Returns:
136
+ `list[tuple[str, float, float]]`: `(name, lat, lon)` triples.
137
+ """
138
+ rng = random.Random(seed)
139
+ cities = list(_cities())
140
+ rng.shuffle(cities)
141
+ points = []
142
+ while len(points) < count:
143
+ for name, lat, lon in cities:
144
+ if len(points) >= count:
145
+ break
146
+ # Later passes over the same city jitter further out, so a dense
147
+ # metro contributes several distinct streets rather than one point.
148
+ spread = 0.01 + 0.02 * (len(points) // max(1, len(cities)))
149
+ points.append(
150
+ (
151
+ name,
152
+ lat + rng.uniform(-spread, spread),
153
+ lon + rng.uniform(-spread, spread),
154
+ )
155
+ )
156
+ return points
157
+
158
+
159
+ def probe(token: str, point: tuple[str, float, float]) -> dict | None:
160
+ """Return the best spherical panorama near one probe point, if any."""
161
+ name, lat, lon = point
162
+ d = PROBE_HALF_DEG
163
+ bbox = f"{lon - d},{lat - d},{lon + d},{lat + d}"
164
+ result = _get(token, "images", bbox=bbox, fields=SEARCH_FIELDS)
165
+ if "__error" in result:
166
+ return None
167
+ panos = [
168
+ image
169
+ for image in result.get("data", [])
170
+ if image.get("is_pano") and image.get("camera_type") == "spherical"
171
+ ]
172
+ if not panos:
173
+ return None
174
+ best = max(panos, key=lambda image: image.get("quality_score") or 0.0)
175
+ best["__seed_name"] = name
176
+ return best
177
+
178
+
179
+ def sequence_frames(token: str, sequence_id: str) -> list[str]:
180
+ """Ordered image ids making up a sequence."""
181
+ result = _get(token, "image_ids", sequence_id=sequence_id)
182
+ if "__error" in result:
183
+ return []
184
+ return [str(row["id"]) for row in result.get("data", [])]
185
+
186
+
187
+ def frame_detail(token: str, image_id: str) -> dict | None:
188
+ """Full entity for one frame, or `None` when the request fails."""
189
+ result = _get(token, str(image_id), fields=FRAME_FIELDS)
190
+ return None if "__error" in result else result
191
+
192
+
193
+ def build(
194
+ n_tasks: int,
195
+ n_frames: int,
196
+ out_dir: pathlib.Path,
197
+ cache_dir: pathlib.Path,
198
+ max_per_country: int,
199
+ n_seeds: int,
200
+ workers: int,
201
+ prefetch_frames: int,
202
+ seed: int,
203
+ require_country: bool = True,
204
+ ) -> None:
205
+ """Discover panoramas, assemble tasks and write the index."""
206
+ token = _token()
207
+ out_dir.mkdir(parents=True, exist_ok=True)
208
+ cache_dir.mkdir(parents=True, exist_ok=True)
209
+
210
+ points = seed_points(n_seeds, seed=seed)
211
+ logger.info("probing %d candidate points with %d workers", len(points), workers)
212
+
213
+ found: list[dict] = []
214
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
215
+ for i, hit in enumerate(pool.map(lambda p: probe(token, p), points), 1):
216
+ if hit:
217
+ found.append(hit)
218
+ if i % 25 == 0:
219
+ logger.info(" %d/%d probed, %d panoramas", i, len(points), len(found))
220
+ logger.info("found %d panoramas from %d probes", len(found), len(points))
221
+
222
+ seen_sequences: set[str] = set()
223
+ per_country: dict[str, int] = {}
224
+ tasks: list[dict] = []
225
+
226
+ for hit in found:
227
+ if len(tasks) >= n_tasks:
228
+ break
229
+ sequence_id = hit.get("sequence")
230
+ if not sequence_id or sequence_id in seen_sequences:
231
+ continue
232
+ seen_sequences.add(sequence_id)
233
+
234
+ ids = sequence_frames(token, sequence_id)
235
+ if len(ids) < 2:
236
+ continue
237
+ anchor = ids.index(str(hit["id"])) if str(hit["id"]) in ids else 0
238
+ lo = max(0, anchor - n_frames // 2)
239
+ window = ids[lo : lo + n_frames]
240
+
241
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
242
+ details = list(pool.map(lambda i: frame_detail(token, i), window))
243
+
244
+ frames = []
245
+ for detail in details:
246
+ if not detail:
247
+ continue
248
+ geometry = detail.get("computed_geometry") or detail.get("geometry")
249
+ if not geometry:
250
+ continue
251
+ lon, lat = geometry["coordinates"]
252
+ frames.append(
253
+ {
254
+ "image_id": str(detail["id"]),
255
+ "lat": lat,
256
+ "lon": lon,
257
+ "compass_angle": detail.get("computed_compass_angle")
258
+ or detail.get("compass_angle")
259
+ or 0.0,
260
+ "captured_at": _month(detail.get("captured_at")),
261
+ "is_pano": bool(detail.get("is_pano")),
262
+ }
263
+ )
264
+ frames = [f for f in frames if f["is_pano"]]
265
+ if len(frames) < 2:
266
+ continue
267
+
268
+ start = min(len(frames) // 2, len(frames) - 1)
269
+ start_frame = frames[start]
270
+ place = locate(start_frame["lat"], start_frame["lon"])
271
+ country = place.country or "unknown"
272
+ if require_country and country == "unknown":
273
+ # The coordinate fell outside the coarse 110m polygons, usually a
274
+ # coastline or small island. Country partial credit would be
275
+ # meaningless there, so leave it out of the index.
276
+ continue
277
+ if per_country.get(country, 0) >= max_per_country:
278
+ continue
279
+
280
+ anchor_detail = next(
281
+ (d for d in details if d and str(d["id"]) == start_frame["image_id"]), {}
282
+ )
283
+ creator = anchor_detail.get("creator", {}) or {}
284
+
285
+ checksums: dict[str, str] = {}
286
+ to_cache = [start_frame["image_id"]]
287
+ if prefetch_frames:
288
+ to_cache = [f["image_id"] for f in frames[:prefetch_frames]]
289
+ for image_id in to_cache:
290
+ path = cache_dir / f"{image_id}.jpg"
291
+ if not path.exists():
292
+ detail = _get(token, image_id, fields="thumb_2048_url")
293
+ thumb_url = detail.get("thumb_2048_url")
294
+ if "__error" in detail or not thumb_url:
295
+ # A successful response can still omit the thumbnail, for
296
+ # images whose derivatives have not been generated.
297
+ logger.warning(" no thumbnail available for %s", image_id)
298
+ continue
299
+ try:
300
+ with urllib.request.urlopen(
301
+ thumb_url, timeout=TIMEOUT_S
302
+ ) as response:
303
+ path.write_bytes(response.read())
304
+ except Exception as exc:
305
+ logger.warning(" fetch failed for %s: %r", image_id, exc)
306
+ continue
307
+ checksums[image_id] = hashlib.sha256(path.read_bytes()).hexdigest()
308
+
309
+ if start_frame["image_id"] not in checksums:
310
+ continue
311
+
312
+ per_country[country] = per_country.get(country, 0) + 1
313
+ tasks.append(
314
+ {
315
+ "task_index": len(tasks),
316
+ "task_id": f"mly-{len(tasks):04d}",
317
+ "country": country,
318
+ "sequence_id": sequence_id,
319
+ "provider": "mapillary",
320
+ "start_frame": start,
321
+ "frames": frames,
322
+ "attribution": {
323
+ "creator_username": creator.get("username", ""),
324
+ "creator_id": creator.get("id", ""),
325
+ "licence": "CC-BY-SA-4.0",
326
+ "source": "Mapillary",
327
+ },
328
+ "meta": {
329
+ "seed_name": hit.get("__seed_name", ""),
330
+ "camera_make": anchor_detail.get("make", ""),
331
+ "camera_model": anchor_detail.get("model", ""),
332
+ "quality_score": anchor_detail.get("quality_score"),
333
+ "continent": place.continent,
334
+ "subregion": place.subregion,
335
+ "nearest_city": place.nearest_city,
336
+ "sha256": checksums,
337
+ },
338
+ }
339
+ )
340
+ logger.info(
341
+ " task %3d %-22s %-16s %2d frames %s",
342
+ len(tasks) - 1,
343
+ country[:22],
344
+ start_frame["captured_at"],
345
+ len(frames),
346
+ anchor_detail.get("model", "")[:18],
347
+ )
348
+
349
+ index_path = out_dir / "pano_v1.jsonl"
350
+ with index_path.open("w") as handle:
351
+ for task in tasks:
352
+ handle.write(json.dumps(task) + "\n")
353
+
354
+ countries = sorted(per_country.items(), key=lambda kv: -kv[1])
355
+ logger.info(
356
+ "\nwrote %d tasks to %s (%.0f KB)",
357
+ len(tasks),
358
+ index_path,
359
+ index_path.stat().st_size / 1024,
360
+ )
361
+ logger.info("countries: %s", ", ".join(f"{c}:{n}" for c, n in countries))
362
+ cached = list(cache_dir.glob("*.jpg"))
363
+ logger.info(
364
+ "cache: %d images, %.0f MB",
365
+ len(cached),
366
+ sum(p.stat().st_size for p in cached) / 1e6,
367
+ )
368
+
369
+
370
+ def main() -> None:
371
+ """Command-line entry point."""
372
+ root = pathlib.Path(__file__).resolve().parents[1]
373
+ parser = argparse.ArgumentParser(description=__doc__)
374
+ parser.add_argument("--tasks", type=int, default=100)
375
+ parser.add_argument("--frames", type=int, default=24)
376
+ parser.add_argument("--seeds", type=int, default=900)
377
+ parser.add_argument("--max-per-country", type=int, default=4)
378
+ parser.add_argument("--workers", type=int, default=8)
379
+ parser.add_argument("--prefetch-frames", type=int, default=0)
380
+ parser.add_argument("--seed", type=int, default=0)
381
+ parser.add_argument(
382
+ "--allow-unknown-country",
383
+ action="store_true",
384
+ help="Keep tasks whose coordinate falls outside the country polygons.",
385
+ )
386
+ parser.add_argument("--out", type=pathlib.Path, default=root / "tasks")
387
+ parser.add_argument("--cache", type=pathlib.Path, default=root / "data" / "panos")
388
+ args = parser.parse_args()
389
+ build(
390
+ n_tasks=args.tasks,
391
+ n_frames=args.frames,
392
+ out_dir=args.out,
393
+ cache_dir=args.cache,
394
+ max_per_country=args.max_per_country,
395
+ n_seeds=args.seeds,
396
+ workers=args.workers,
397
+ prefetch_frames=args.prefetch_frames,
398
+ seed=args.seed,
399
+ require_country=not args.allow_unknown_country,
400
+ )
401
+
402
+
403
+ if __name__ == "__main__":
404
+ main()
geoguesser_env/scripts/build_tasks.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Assemble tasks from a harvested sequence pool.
4
+
5
+ `harvest_tiles.py` enumerates panorama sequences worldwide; this turns a
6
+ selection of them into playable tasks. The two steps are separate because
7
+ harvesting is fast and assembly is not: a task costs about 27 Graph API
8
+ requests, so you harvest once and sample from the pool as often as you like.
9
+
10
+ Balance is enforced before assembly, not after. Panorama coverage clusters
11
+ hard — sixteen sequences from one hit are usually one contributor's drive — so
12
+ candidates are capped per country *and* per creator using the anchor
13
+ coordinates the pool already carries, before a single frame is fetched. Doing it
14
+ the other way round cost 14.4 s per accepted task instead of 3.
15
+
16
+ Offline mode: `--mirror all` downloads every frame, so the finished dataset
17
+ needs no network at rollout time at all. Rough sizes at 2,500 tasks:
18
+
19
+ --mirror start ~0.7 GB guessing only
20
+ --mirror all ~16 GB look and move offline
21
+ --mirror all --originals ~20 GB plus sharp zoom at the start frame
22
+
23
+ Measured over 964 cached files: mean 0.26 MB per 2048x1024 derivative (median
24
+ 0.24, p90 0.34) and 1.8 MB per full-resolution original.
25
+
26
+ The run is resumable. Tasks are appended to the index as they complete, and a
27
+ restart with the same --out picks up where it stopped, skipping every sequence
28
+ already used. A 2,500-task build costs ~68,000 Graph API requests; losing that
29
+ to a dropped connection at task 2,400 is not acceptable.
30
+
31
+ Usage:
32
+ export MAPILLARY_API_KEY_TRAIN="MLY|..."
33
+ python scripts/build_tasks.py --tasks 2500 --mirror all --workers 8
34
+ python scripts/build_tasks.py --tasks 50 --mirror start # quick trial
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import collections
41
+ import concurrent.futures
42
+ import functools
43
+ import hashlib
44
+ import json
45
+ import logging
46
+ import pathlib
47
+ import random
48
+ import shutil
49
+ import sys
50
+ import threading
51
+ import urllib.request
52
+
53
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
54
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
55
+
56
+ from build_pano_tasks import ( # noqa: E402
57
+ _get,
58
+ _month,
59
+ _token,
60
+ frame_detail,
61
+ sequence_frames,
62
+ TIMEOUT_S,
63
+ )
64
+
65
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
66
+ logger = logging.getLogger("build_tasks")
67
+
68
+
69
+ @functools.lru_cache(maxsize=1)
70
+ def _country_paths():
71
+ """Country name and outline for each admin-0 polygon."""
72
+ from matplotlib.path import Path as MplPath
73
+
74
+ root = pathlib.Path(__file__).resolve().parents[1]
75
+ features = json.loads(
76
+ (root / "data" / "geo" / "ne_110m_admin_0_countries.geojson").read_text()
77
+ )["features"]
78
+ out = []
79
+ for feature in features:
80
+ geometry = feature["geometry"]
81
+ rings = (
82
+ [geometry["coordinates"][0]]
83
+ if geometry["type"] == "Polygon"
84
+ else [poly[0] for poly in geometry["coordinates"]]
85
+ )
86
+ for ring in rings:
87
+ out.append((feature["properties"]["ADMIN"], MplPath(ring)))
88
+ return out
89
+
90
+
91
+ def country_of(lat: float, lon: float) -> str | None:
92
+ """Country containing a coordinate, or `None` over water."""
93
+ for name, path in _country_paths():
94
+ if path.contains_point((lon, lat)):
95
+ return name
96
+ return None
97
+
98
+
99
+ def choose_candidates(
100
+ pool: list[dict],
101
+ wanted: int,
102
+ per_country: int,
103
+ per_creator: int,
104
+ density_exponent: float,
105
+ seed: int,
106
+ already: list[dict] | None = None,
107
+ ) -> list[dict]:
108
+ """
109
+ Order the pool so the first N candidates are worth assembling.
110
+
111
+ Applies the sampling weight from the OSV-5M paper: proportional to local
112
+ image density raised to `density_exponent`, negative by default, which sits
113
+ between density-proportional sampling (biased to cities) and
114
+ area-proportional sampling (biased to large countries). Then caps per
115
+ country and per creator, which matters as much: without a creator cap a
116
+ "global" set can be a few hundred drives.
117
+
118
+ Args:
119
+ pool (`list[dict]`):
120
+ Harvested sequences with `lat`, `lon`, `creator_id`.
121
+ wanted (`int`):
122
+ How many candidates to return, before assembly losses.
123
+ per_country (`int`):
124
+ Maximum tasks per country.
125
+ per_creator (`int`):
126
+ Maximum tasks per contributor.
127
+ density_exponent (`float`):
128
+ Exponent on local density; `-0.75` follows OSV-5M.
129
+ seed (`int`):
130
+ Seed for the weighted draw.
131
+ already (`list[dict]`, *optional*):
132
+ Tasks a resumed run already holds. Their countries and creators
133
+ count against the caps, so resuming cannot exceed a limit that a
134
+ single run would have respected.
135
+
136
+ Returns:
137
+ `list[dict]`: Candidates in the order they should be assembled.
138
+ """
139
+ rng = random.Random(seed)
140
+ cells = collections.Counter((round(row["lat"]), round(row["lon"])) for row in pool)
141
+ weighted = []
142
+ for row in pool:
143
+ cell = (round(row["lat"]), round(row["lon"]))
144
+ weight = cells[cell] ** density_exponent
145
+ # One exponential draw per item, ordered ascending, is a weighted
146
+ # sample without replacement.
147
+ weighted.append((rng.expovariate(1.0) / max(weight, 1e-9), row))
148
+ weighted.sort(key=lambda pair: pair[0])
149
+
150
+ country_count: collections.Counter = collections.Counter(
151
+ task["country"] for task in already or []
152
+ )
153
+ creator_count: collections.Counter = collections.Counter(
154
+ task["attribution"]["creator_id"] for task in already or []
155
+ )
156
+ chosen: list[dict] = []
157
+ unknown = 0
158
+ for _, row in weighted:
159
+ if len(chosen) >= wanted:
160
+ break
161
+ country = country_of(row["lat"], row["lon"])
162
+ if country is None:
163
+ unknown += 1
164
+ continue
165
+ creator = str(row.get("creator_id") or "")
166
+ if country_count[country] >= per_country:
167
+ continue
168
+ if creator and creator_count[creator] >= per_creator:
169
+ continue
170
+ country_count[country] += 1
171
+ creator_count[creator] += 1
172
+ row["__country"] = country
173
+ chosen.append(row)
174
+ logger.info(
175
+ "selected %d candidates across %d countries and %d creators "
176
+ "(%d anchors fell over water)",
177
+ len(chosen),
178
+ len(country_count),
179
+ len(creator_count),
180
+ unknown,
181
+ )
182
+ return chosen
183
+
184
+
185
+ def mirror(
186
+ token: str,
187
+ image_ids: list[str],
188
+ cache: pathlib.Path,
189
+ workers: int,
190
+ originals: bool = False,
191
+ ) -> dict[str, str]:
192
+ """Download and hash frames concurrently; returns sha256 by image id."""
193
+
194
+ def one(image_id: str) -> tuple[str, str | None]:
195
+ path = cache / f"{image_id}.jpg"
196
+ if not path.exists():
197
+ fields = "thumb_2048_url"
198
+ meta = _get(token, image_id, fields=fields)
199
+ url = meta.get(fields) if "__error" not in meta else None
200
+ if not url:
201
+ return image_id, None
202
+ try:
203
+ with urllib.request.urlopen(url, timeout=TIMEOUT_S) as response:
204
+ path.write_bytes(response.read())
205
+ except Exception as exc: # noqa: BLE001
206
+ logger.warning(" %s: %r", image_id, exc)
207
+ return image_id, None
208
+ return image_id, hashlib.sha256(path.read_bytes()).hexdigest()
209
+
210
+ checksums: dict[str, str] = {}
211
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
212
+ for image_id, digest in pool.map(one, image_ids):
213
+ if digest:
214
+ checksums[image_id] = digest
215
+
216
+ if originals and image_ids:
217
+ # Only the starting frame gets its full-resolution original: zoom needs
218
+ # it, and every frame would be 3.3 MB each.
219
+ first = image_ids[0]
220
+ path = cache / f"{first}.orig.jpg"
221
+ if not path.exists():
222
+ meta = _get(token, first, fields="thumb_original_url")
223
+ url = meta.get("thumb_original_url") if "__error" not in meta else None
224
+ if url:
225
+ try:
226
+ with urllib.request.urlopen(url, timeout=TIMEOUT_S) as response:
227
+ path.write_bytes(response.read())
228
+ except Exception as exc: # noqa: BLE001
229
+ logger.warning(" original for %s: %r", first, exc)
230
+ return checksums
231
+
232
+
233
+ def assemble(token: str, candidate: dict, args: argparse.Namespace) -> dict | None:
234
+ """Turn one candidate sequence into a task, or `None` if it does not qualify."""
235
+ sequence_id = candidate["sequence_id"]
236
+ ids = sequence_frames(token, sequence_id)
237
+ if len(ids) < args.min_frames:
238
+ return None
239
+ anchor_id = str(candidate.get("image_id") or "")
240
+ position = ids.index(anchor_id) if anchor_id in ids else len(ids) // 2
241
+ low = max(0, position - args.frames // 2)
242
+ window = ids[low : low + args.frames]
243
+
244
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.frame_workers) as pool:
245
+ details = list(pool.map(lambda i: frame_detail(token, i), window))
246
+
247
+ frames = []
248
+ for detail in details:
249
+ if not detail or not detail.get("is_pano"):
250
+ continue
251
+ geometry = detail.get("computed_geometry") or detail.get("geometry")
252
+ if not geometry:
253
+ continue
254
+ lon, lat = geometry["coordinates"]
255
+ captured = _month(detail.get("captured_at"))
256
+ if captured and captured[:4] < "2010":
257
+ continue # a zero captured_at renders as 1980 and breaks temporal splits
258
+ frames.append(
259
+ {
260
+ "image_id": str(detail["id"]),
261
+ "lat": lat,
262
+ "lon": lon,
263
+ "compass_angle": detail.get("computed_compass_angle")
264
+ or detail.get("compass_angle")
265
+ or 0.0,
266
+ "captured_at": captured,
267
+ "is_pano": True,
268
+ }
269
+ )
270
+ if len(frames) < args.min_frames:
271
+ return None
272
+
273
+ start = min(len(frames) // 2, len(frames) - 1)
274
+ start_frame = frames[start]
275
+ anchor_detail = next(
276
+ (d for d in details if d and str(d["id"]) == start_frame["image_id"]), {}
277
+ )
278
+
279
+ if args.mirror == "all":
280
+ wanted = [start_frame["image_id"]] + [
281
+ f["image_id"] for f in frames if f["image_id"] != start_frame["image_id"]
282
+ ]
283
+ else:
284
+ wanted = [start_frame["image_id"]]
285
+ checksums = mirror(
286
+ token, wanted, args.cache, args.frame_workers, originals=args.originals
287
+ )
288
+ if start_frame["image_id"] not in checksums:
289
+ return None
290
+
291
+ creator = anchor_detail.get("creator", {}) or {}
292
+ return {
293
+ "country": candidate["__country"],
294
+ "sequence_id": sequence_id,
295
+ "provider": "mapillary",
296
+ "start_frame": start,
297
+ "frames": frames,
298
+ "attribution": {
299
+ "creator_username": creator.get("username", ""),
300
+ "creator_id": creator.get("id", "") or candidate.get("creator_id", ""),
301
+ "licence": "CC-BY-SA-4.0",
302
+ "source": "Mapillary",
303
+ },
304
+ "meta": {
305
+ "camera_make": anchor_detail.get("make", ""),
306
+ "camera_model": anchor_detail.get("model", ""),
307
+ "quality_score": anchor_detail.get("quality_score")
308
+ or candidate.get("quality_score"),
309
+ "pool_tile": candidate.get("tile", ""),
310
+ "sha256": checksums,
311
+ "mirrored_frames": len(checksums),
312
+ "offline_ready": len(checksums) >= len(frames),
313
+ },
314
+ }
315
+
316
+
317
+ MEAN_FRAME_BYTES = 0.26e6
318
+ """Mean size of a 2048x1024 derivative, measured over 964 cached files."""
319
+
320
+ MEAN_ORIGINAL_BYTES = 1.8e6
321
+ """Mean size of a full-resolution original, measured over 29 cached files."""
322
+
323
+
324
+ def resume(out: pathlib.Path) -> list[dict]:
325
+ """
326
+ Return the tasks already written to an index, ignoring a torn last line.
327
+
328
+ A run killed mid-write can leave a partial JSON object at the end of the
329
+ file. That line is dropped rather than raising, because the alternative is
330
+ discarding a complete 40-minute build over one truncated task.
331
+ """
332
+ if not out.exists():
333
+ return []
334
+ tasks = []
335
+ for number, line in enumerate(out.read_text().splitlines(), start=1):
336
+ if not line.strip():
337
+ continue
338
+ try:
339
+ tasks.append(json.loads(line))
340
+ except json.JSONDecodeError:
341
+ logger.warning("dropping torn line %d of %s", number, out)
342
+ for index, task in enumerate(tasks):
343
+ task["task_index"] = index
344
+ task["task_id"] = f"mly-{index:05d}"
345
+ return tasks
346
+
347
+
348
+ def task_bytes(task: dict, cache: pathlib.Path) -> int:
349
+ """Bytes this task added to the cache, counted from its own frames only."""
350
+ total = 0
351
+ for frame in task["frames"]:
352
+ for name in (f"{frame['image_id']}.jpg", f"{frame['image_id']}.orig.jpg"):
353
+ path = cache / name
354
+ if path.exists():
355
+ total += path.stat().st_size
356
+ return total
357
+
358
+
359
+ def check_disk(cache: pathlib.Path, remaining: int, args) -> None:
360
+ """
361
+ Refuse to start a mirror that cannot fit, before spending any API quota.
362
+
363
+ Raises:
364
+ SystemExit: When projected growth exceeds free space minus a 10 GB
365
+ headroom margin.
366
+ """
367
+ per_task = MEAN_FRAME_BYTES * (args.frames if args.mirror == "all" else 1)
368
+ if args.originals:
369
+ per_task += MEAN_ORIGINAL_BYTES
370
+ projected = per_task * remaining
371
+ free = shutil.disk_usage(cache).free
372
+ logger.info(
373
+ "mirror '%s' projects %.1f GB for %d tasks; %.1f GB free on %s",
374
+ args.mirror,
375
+ projected / 1e9,
376
+ remaining,
377
+ free / 1e9,
378
+ cache,
379
+ )
380
+ if projected + 10e9 > free:
381
+ raise SystemExit(
382
+ f"not enough space: need ~{projected / 1e9:.0f} GB plus 10 GB "
383
+ f"headroom, have {free / 1e9:.0f} GB free. Use --mirror start, "
384
+ f"lower --frames, or point --cache at a larger volume."
385
+ )
386
+
387
+
388
+ def build(args: argparse.Namespace) -> None:
389
+ """Select, assemble and write the task index."""
390
+ token = _token()
391
+ args.cache.mkdir(parents=True, exist_ok=True)
392
+ args.out.parent.mkdir(parents=True, exist_ok=True)
393
+
394
+ pool = [
395
+ json.loads(line) for line in args.pool.read_text().splitlines() if line.strip()
396
+ ]
397
+ logger.info("pool holds %d panorama sequences", len(pool))
398
+
399
+ done_tasks = resume(args.out)
400
+ if done_tasks:
401
+ logger.info(
402
+ "resuming: %d tasks already in %s, need %d more",
403
+ len(done_tasks),
404
+ args.out,
405
+ max(0, args.tasks - len(done_tasks)),
406
+ )
407
+ used = {t["sequence_id"] for t in done_tasks}
408
+ pool = [entry for entry in pool if entry["sequence_id"] not in used]
409
+
410
+ remaining = args.tasks - len(done_tasks)
411
+ if remaining <= 0:
412
+ logger.info("%s already holds %d tasks; nothing to do", args.out, args.tasks)
413
+ return
414
+ check_disk(args.cache, remaining, args)
415
+
416
+ # Over-select: some candidates lose their sequence listing, fall short on
417
+ # frames, or fail to mirror.
418
+ candidates = choose_candidates(
419
+ pool,
420
+ int(remaining * args.oversample),
421
+ args.per_country,
422
+ args.per_creator,
423
+ args.density_exponent,
424
+ args.seed,
425
+ already=done_tasks,
426
+ )
427
+
428
+ results: list[dict] = list(done_tasks)
429
+ lock = threading.Lock()
430
+ stats = {"tried": 0, "failed": 0, "mirrored": 0, "bytes": 0}
431
+ handle = args.out.open("a")
432
+
433
+ def work(candidate):
434
+ with lock:
435
+ if len(results) >= args.tasks:
436
+ return None
437
+ task = assemble(token, candidate, args)
438
+ with lock:
439
+ stats["tried"] += 1
440
+ if task is None:
441
+ stats["failed"] += 1
442
+ return None
443
+ if len(results) >= args.tasks:
444
+ return None
445
+ task["task_index"] = len(results)
446
+ task["task_id"] = f"mly-{len(results):05d}"
447
+ results.append(task)
448
+ stats["mirrored"] += task["meta"]["mirrored_frames"]
449
+ stats["bytes"] += task_bytes(task, args.cache)
450
+ # Append-and-flush so a killed run loses at most one task.
451
+ handle.write(json.dumps(task) + "\n")
452
+ handle.flush()
453
+ return task
454
+
455
+ from rich.progress import (
456
+ BarColumn,
457
+ MofNCompleteColumn,
458
+ Progress,
459
+ TextColumn,
460
+ TimeElapsedColumn,
461
+ TimeRemainingColumn,
462
+ )
463
+
464
+ progress = Progress(
465
+ TextColumn("[cyan]assembling tasks"),
466
+ BarColumn(),
467
+ MofNCompleteColumn(),
468
+ TextColumn(
469
+ "rejected={task.fields[failed]} · images={task.fields[mirrored]} · "
470
+ "{task.fields[gb]:.2f} GB"
471
+ ),
472
+ TimeElapsedColumn(),
473
+ TimeRemainingColumn(),
474
+ )
475
+ with progress:
476
+ task_id = progress.add_task(
477
+ "tasks",
478
+ total=args.tasks,
479
+ completed=len(results),
480
+ failed=0,
481
+ mirrored=0,
482
+ gb=0.0,
483
+ )
484
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool_x:
485
+ for _ in pool_x.map(work, candidates):
486
+ done = len(results)
487
+ progress.update(
488
+ task_id,
489
+ completed=min(done, args.tasks),
490
+ failed=stats["failed"],
491
+ mirrored=stats["mirrored"],
492
+ gb=stats["bytes"] / 1e9,
493
+ )
494
+ if done >= args.tasks:
495
+ break
496
+ handle.close()
497
+
498
+ results.sort(key=lambda t: t["task_index"])
499
+ args.out.write_text("".join(json.dumps(task) + "\n" for task in results))
500
+
501
+ countries = collections.Counter(t["country"] for t in results)
502
+ creators = {t["attribution"]["creator_id"] for t in results}
503
+ offline = sum(1 for t in results if t["meta"]["offline_ready"])
504
+ logger.info(
505
+ "\nwrote %d tasks to %s (%.0f KB)",
506
+ len(results),
507
+ args.out,
508
+ args.out.stat().st_size / 1024,
509
+ )
510
+ logger.info(
511
+ "countries %d · creators %d · fully offline %d/%d · candidates rejected %d",
512
+ len(countries),
513
+ len(creators),
514
+ offline,
515
+ len(results),
516
+ stats["failed"],
517
+ )
518
+ logger.info(
519
+ "cache now %.2f GB",
520
+ sum(p.stat().st_size for p in args.cache.glob("*.jpg")) / 1e9,
521
+ )
522
+ logger.info("next: python scripts/split_tasks.py %s --eval 200", args.out)
523
+
524
+
525
+ def main() -> None:
526
+ """Command-line entry point."""
527
+ root = pathlib.Path(__file__).resolve().parents[1]
528
+ parser = argparse.ArgumentParser(description=__doc__)
529
+ parser.add_argument("--tasks", type=int, default=2500)
530
+ parser.add_argument("--per-country", type=int, default=60)
531
+ parser.add_argument("--per-creator", type=int, default=25)
532
+ parser.add_argument("--frames", type=int, default=24)
533
+ parser.add_argument("--min-frames", type=int, default=8)
534
+ parser.add_argument("--mirror", choices=["start", "all"], default="all")
535
+ parser.add_argument(
536
+ "--originals",
537
+ action="store_true",
538
+ help="Also mirror the start frame's 7680px original, so zoom works offline.",
539
+ )
540
+ parser.add_argument(
541
+ "--workers", type=int, default=8, help="Tasks assembled at once."
542
+ )
543
+ parser.add_argument(
544
+ "--frame-workers", type=int, default=6, help="Fetches within a task."
545
+ )
546
+ parser.add_argument("--oversample", type=float, default=2.0)
547
+ parser.add_argument("--density-exponent", type=float, default=-0.75)
548
+ parser.add_argument("--seed", type=int, default=7)
549
+ parser.add_argument(
550
+ "--pool", type=pathlib.Path, default=root / "data" / "pool" / "sequences.jsonl"
551
+ )
552
+ parser.add_argument(
553
+ "--out", type=pathlib.Path, default=root / "tasks" / "pool_v1.jsonl"
554
+ )
555
+ parser.add_argument("--cache", type=pathlib.Path, default=root / "data" / "panos")
556
+ build(parser.parse_args())
557
+
558
+
559
+ if __name__ == "__main__":
560
+ main()
geoguesser_env/scripts/deploy_hub.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Deploy the GeoGuesser environment to the Hugging Face Hub.
4
+
5
+ Three artifacts, split by what each is good at:
6
+
7
+ bucket HuggingEnvs/geoguesser-panos 22 GB of imagery + both indexes
8
+ dataset HuggingEnvs/geoguesser-tasks the indexes alone, versioned
9
+ space HuggingEnvs/geoguesser-env the running environment
10
+
11
+ The bucket exists because Space disk is ephemeral and capped well below 22 GB,
12
+ and because buckets are mutable object storage rather than git. The dataset repo
13
+ exists because a bucket is *not* versioned: a frozen benchmark needs a place
14
+ where a change to it is visible in history. The Space mounts the bucket
15
+ read-only at /data, so the same image serves a local checkout and the Hub with
16
+ nothing but environment variables between them.
17
+
18
+ `openenv push` is deliberately not used: it cannot attach a bucket volume, and
19
+ its default excludes would upload 22 GB of panoramas into git.
20
+
21
+ Usage:
22
+ export HF_TOKEN=hf_...
23
+ python scripts/deploy_hub.py --all
24
+ python scripts/deploy_hub.py --space # code only, fast iteration
25
+ python scripts/deploy_hub.py --verify # check a live deployment
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import logging
33
+ import os
34
+ import pathlib
35
+ import shutil
36
+ import sys
37
+ import tempfile
38
+
39
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
40
+ logger = logging.getLogger("deploy_hub")
41
+
42
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
43
+ REPO_ROOT = ROOT.parents[1]
44
+
45
+ ORG = os.getenv("GEOGUESSER_HF_ORG", "HuggingEnvs")
46
+ SPACE_ID = f"{ORG}/geoguesser-env"
47
+ BUCKET_ID = f"{ORG}/geoguesser-panos"
48
+ DATASET_ID = f"{ORG}/geoguesser-tasks"
49
+ MOUNT_PATH = "/data"
50
+
51
+ EVAL_INDEX = "eval_pano_v3.jsonl"
52
+ TRAIN_INDEX = "train_pano_v3.jsonl"
53
+
54
+ # Anything matching these never reaches the Space repo. The panorama cache and
55
+ # the sequence pool are the dangerous ones: 22 GB and 254 MB respectively.
56
+ SPACE_EXCLUDES = (
57
+ "geoguesser_env/data/panos/*",
58
+ "geoguesser_env/data/pool/*",
59
+ f"geoguesser_env/tasks/{TRAIN_INDEX}",
60
+ "geoguesser_env/tasks/pool_offline_5k.jsonl",
61
+ "geoguesser_env/tasks/eval_pano_v1.jsonl",
62
+ "geoguesser_env/tasks/eval_pano_v2.jsonl",
63
+ "geoguesser_env/tasks/eval_balanced_trial.jsonl",
64
+ "geoguesser_env/rollouts/*",
65
+ "geoguesser_env/tests/*",
66
+ "geoguesser_env/.venv/*",
67
+ "**/__pycache__/*",
68
+ "**/*.pyc",
69
+ "src/**/__pycache__/*",
70
+ )
71
+
72
+ SPACE_VARIABLES = {
73
+ "ENABLE_WEB_INTERFACE": "true",
74
+ "GEOGUESSER_TASKS_EVAL": f"{MOUNT_PATH}/tasks/{EVAL_INDEX}",
75
+ "GEOGUESSER_TASKS_TRAIN": f"{MOUNT_PATH}/tasks/{TRAIN_INDEX}",
76
+ "GEOGUESSER_DEFAULT_SPLIT": "train",
77
+ "GEOGUESSER_CACHE": f"{MOUNT_PATH}/panos",
78
+ # The mirror is complete, so a cache miss is a bug worth hearing about
79
+ # rather than something to paper over with a network call.
80
+ "GEOGUESSER_ALLOW_FETCH": "0",
81
+ # Overpass only, and it caches to the container's own disk, so labelled
82
+ # streets work even with imagery fetching disabled.
83
+ "GEOGUESSER_STREET_DETAIL": "1",
84
+ "GEOGUESSER_MAX_STEPS": "24",
85
+ }
86
+
87
+ CARD_FRONTMATTER = """---
88
+ title: Geoguesser Environment
89
+ emoji: 🌍
90
+ colorFrom: green
91
+ colorTo: blue
92
+ sdk: docker
93
+ pinned: false
94
+ app_port: 8000
95
+ base_path: /web
96
+ tags:
97
+ - openenv
98
+ - reinforcement-learning
99
+ - geolocation
100
+ - mcp-server
101
+ ---
102
+
103
+ """
104
+
105
+
106
+ def token() -> str:
107
+ """Resolve an HF token from the environment or the repo `.env`."""
108
+ for name in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
109
+ value = os.getenv(name)
110
+ if value:
111
+ return value
112
+ env_file = REPO_ROOT / ".env"
113
+ if env_file.exists():
114
+ for line in env_file.read_text().splitlines():
115
+ if line.startswith("HF_TOKEN="):
116
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
117
+ raise SystemExit("no HF_TOKEN; export it or add it to the repo .env")
118
+
119
+
120
+ def stage_space(staging: pathlib.Path) -> None:
121
+ """
122
+ Assemble the Space repository layout.
123
+
124
+ The Space is self-contained: it vendors `src/openenv` rather than installing
125
+ a release, because the Gradio tab naming this environment uses is newer than
126
+ the published package.
127
+ """
128
+ shutil.copytree(
129
+ REPO_ROOT / "src",
130
+ staging / "src",
131
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.egg-info"),
132
+ )
133
+ shutil.copytree(
134
+ ROOT,
135
+ staging / "geoguesser_env",
136
+ ignore=shutil.ignore_patterns(
137
+ "__pycache__",
138
+ "*.pyc",
139
+ ".venv",
140
+ "panos",
141
+ "pool",
142
+ "rollouts",
143
+ "tests",
144
+ "space",
145
+ "osm_cache",
146
+ TRAIN_INDEX,
147
+ "pool_offline_5k.jsonl",
148
+ "eval_pano_v1.jsonl",
149
+ "eval_pano_v2.jsonl",
150
+ "eval_balanced_trial.jsonl",
151
+ ),
152
+ )
153
+ shutil.copy(ROOT / "space" / "Dockerfile", staging / "Dockerfile")
154
+ # The card is the env README with Space frontmatter prepended, so the Hub
155
+ # page and the repo documentation cannot drift apart.
156
+ body = (ROOT / "README.md").read_text()
157
+ if body.startswith("---"):
158
+ body = body.split("---", 2)[-1].lstrip()
159
+ (staging / "README.md").write_text(CARD_FRONTMATTER + body)
160
+
161
+ # The detail vectors drive the labelled maps and are gitignored locally, so
162
+ # confirm they made it rather than shipping a Space with bare maps.
163
+ detail = staging / "geoguesser_env" / "data" / "geo" / "detail"
164
+ present = sorted(p.name for p in detail.glob("*.json")) if detail.exists() else []
165
+ missing = {"places.json", "roads.json", "rivers.json", "urban.json"} - set(present)
166
+ if missing:
167
+ logger.warning(
168
+ "detail vectors missing from the staged Space: %s -- agent maps will "
169
+ "have no towns or roads. Run scripts/fetch_detail_geo.py first.",
170
+ sorted(missing),
171
+ )
172
+ size = sum(p.stat().st_size for p in staging.rglob("*") if p.is_file())
173
+ logger.info(
174
+ "staged %d files, %.1f MB",
175
+ sum(1 for p in staging.rglob("*") if p.is_file()),
176
+ size / 1e6,
177
+ )
178
+ if size > 500e6:
179
+ raise SystemExit(
180
+ f"staged Space is {size / 1e6:.0f} MB, which means an exclude did "
181
+ "not match. Refusing to push."
182
+ )
183
+
184
+
185
+ def push_space(hf_token: str) -> None:
186
+ """Create the Space if needed, upload the code, and wire up its runtime."""
187
+ from huggingface_hub import HfApi, Volume
188
+
189
+ api = HfApi(token=hf_token)
190
+ api.create_repo(
191
+ SPACE_ID, repo_type="space", space_sdk="docker", private=False, exist_ok=True
192
+ )
193
+
194
+ with tempfile.TemporaryDirectory() as tmp:
195
+ staging = pathlib.Path(tmp) / "space"
196
+ staging.mkdir()
197
+ stage_space(staging)
198
+ logger.info("uploading to %s", SPACE_ID)
199
+ api.upload_folder(
200
+ repo_id=SPACE_ID,
201
+ repo_type="space",
202
+ folder_path=str(staging),
203
+ ignore_patterns=list(SPACE_EXCLUDES),
204
+ commit_message="Deploy geoguesser environment with train/eval splits",
205
+ )
206
+
207
+ for key, value in SPACE_VARIABLES.items():
208
+ api.add_space_variable(repo_id=SPACE_ID, key=key, value=value)
209
+ logger.info("set %d Space variables", len(SPACE_VARIABLES))
210
+
211
+ # set_space_volumes REPLACES the whole list, so anything already attached
212
+ # has to be carried forward or it is silently unmounted.
213
+ existing = []
214
+ try:
215
+ existing = list(api.get_space_runtime(SPACE_ID).volumes or [])
216
+ except Exception as exc: # noqa: BLE001
217
+ logger.warning("could not read current volumes: %r", exc)
218
+ keep = [
219
+ v
220
+ for v in existing
221
+ if getattr(v, "mount_path", None) != MOUNT_PATH
222
+ and getattr(v, "source", None) != BUCKET_ID
223
+ ]
224
+ api.set_space_volumes(
225
+ repo_id=SPACE_ID,
226
+ volumes=keep
227
+ + [
228
+ Volume(
229
+ type="bucket",
230
+ source=BUCKET_ID,
231
+ mount_path=MOUNT_PATH,
232
+ read_only=True,
233
+ )
234
+ ],
235
+ )
236
+ logger.info("mounted %s read-only at %s", BUCKET_ID, MOUNT_PATH)
237
+ logger.info("space: https://huggingface.co/spaces/%s", SPACE_ID)
238
+
239
+
240
+ def push_dataset(hf_token: str) -> None:
241
+ """Publish the task indexes and a card describing how they were built."""
242
+ from huggingface_hub import HfApi
243
+
244
+ api = HfApi(token=hf_token)
245
+ api.create_repo(DATASET_ID, repo_type="dataset", private=False, exist_ok=True)
246
+
247
+ eval_path = ROOT / "tasks" / EVAL_INDEX
248
+ train_path = ROOT / "tasks" / TRAIN_INDEX
249
+ stats = {}
250
+ for name, path in (("eval", eval_path), ("train", train_path)):
251
+ if not path.exists():
252
+ logger.warning("%s missing, skipping: %s", name, path)
253
+ continue
254
+ rows = [
255
+ json.loads(line) for line in path.read_text().splitlines() if line.strip()
256
+ ]
257
+ stats[name] = {
258
+ "tasks": len(rows),
259
+ "countries": len({r["country"] for r in rows}),
260
+ "frames": sum(len(r["frames"]) for r in rows),
261
+ "offline": sum(1 for r in rows if r["meta"].get("offline_ready")),
262
+ }
263
+
264
+ with tempfile.TemporaryDirectory() as tmp:
265
+ staging = pathlib.Path(tmp)
266
+ for path in (eval_path, train_path):
267
+ if path.exists():
268
+ shutil.copy(path, staging / path.name)
269
+ (staging / "README.md").write_text(dataset_card(stats))
270
+ api.upload_folder(
271
+ repo_id=DATASET_ID,
272
+ repo_type="dataset",
273
+ folder_path=str(staging),
274
+ commit_message="GeoGuesser task splits",
275
+ )
276
+ logger.info("dataset: https://huggingface.co/datasets/%s", DATASET_ID)
277
+
278
+
279
+ def dataset_card(stats: dict) -> str:
280
+ """Render the dataset card from measured figures, never hardcoded ones."""
281
+ rows = "\n".join(
282
+ f"| `{name}` | {s['tasks']} | {s['countries']} | {s['frames']} | "
283
+ f"{s['offline']}/{s['tasks']} |"
284
+ for name, s in stats.items()
285
+ )
286
+ return f"""---
287
+ license: cc-by-sa-4.0
288
+ task_categories:
289
+ - image-classification
290
+ tags:
291
+ - geolocation
292
+ - openenv
293
+ - reinforcement-learning
294
+ pretty_name: GeoGuesser Task Splits
295
+ ---
296
+
297
+ # GeoGuesser Task Splits
298
+
299
+ Task indexes for the [GeoGuesser OpenEnv environment](https://huggingface.co/spaces/{SPACE_ID}).
300
+ Each line is one episode: an ordered list of panorama frames with coordinates,
301
+ headings and capture dates, plus the sequence and contributor it came from.
302
+
303
+ | Split | Tasks | Countries | Frames | Fully mirrored |
304
+ |---|---|---|---|---|
305
+ {rows}
306
+
307
+ ## What a task is
308
+
309
+ These files carry **metadata only**, not imagery. Every frame's coordinates,
310
+ heading and capture date are here, so the movement graph resolves with no
311
+ network access; only image bytes need fetching, and Mapillary's `thumb_*_url`
312
+ values are expiring signed CDN URLs that cannot be stored. Resolve them from
313
+ `image_id` through the Mapillary Graph API, or mirror them once with
314
+ `scripts/build_tasks.py` from the environment repository.
315
+
316
+ ## How the split was made
317
+
318
+ Both splits are carved from one 3,673-task pool, so contamination is enforced
319
+ exactly once rather than reasoned about across two separate harvests. The rules
320
+ follow the OSV-5M paper, which built its train/test split from the same
321
+ Mapillary source:
322
+
323
+ - no shared `sequence_id` between splits
324
+ - no training task within **1 km** of an eval task
325
+
326
+ The buffer matters because frames sit about 3.3 m apart: holding out an image
327
+ while keeping its neighbour holds out nothing. The split script verifies its own
328
+ output and exits non-zero if either rule is violated.
329
+
330
+ Eval is carved first, balanced by country and capped at 4 tasks each, because at
331
+ a couple of hundred tasks the balance decides what the score means. An earlier
332
+ unbalanced attempt put 28% of the set in one country.
333
+
334
+ ## Provenance
335
+
336
+ Imagery is from [Mapillary](https://www.mapillary.com), CC BY-SA 4.0. Each task
337
+ records its contributor in `attribution`, which the environment displays. Only
338
+ 360-degree panoramas are included (`camera_type == "spherical"`; note that the
339
+ documented value `equirectangular` does not appear in practice).
340
+
341
+ Sequences were discovered by enumerating Mapillary's `mly1_public` vector tiles
342
+ at zoom 6, where the sequence layer carries `is_pano` — 1.2 million panorama
343
+ sequences worldwide. Candidates are sampled with weight proportional to local
344
+ image density raised to **-0.75**, the OSV-5M weighting, then capped per country
345
+ and per contributor: one contributor alone holds 8% of the pool.
346
+
347
+ ## Reproducing
348
+
349
+ ```bash
350
+ git clone https://github.com/huggingface/OpenEnv
351
+ cd OpenEnv/envs/geoguesser_env
352
+ export MAPILLARY_API_KEY_TRAIN="MLY|..."
353
+
354
+ python scripts/harvest_tiles.py # enumerate sequences worldwide
355
+ ./scripts/build_dataset.sh # assemble and mirror tasks
356
+ python scripts/verify_offline.py tasks/pool_offline_5k.jsonl
357
+ python scripts/split_tasks.py tasks/pool_offline_5k.jsonl --eval 200
358
+ ```
359
+
360
+ A rebuild will not reproduce these exact tasks — the pool is sampled and
361
+ upstream coverage changes — which is precisely why the split is published rather
362
+ than left to be regenerated.
363
+ """
364
+
365
+
366
+ def verify(hf_token: str) -> int:
367
+ """Exercise a deployed Space the way a client would. Returns an exit code."""
368
+ import urllib.error
369
+ import urllib.request
370
+
371
+ base = f"https://{ORG.lower()}-geoguesser-env.hf.space"
372
+ logger.info("verifying %s", base)
373
+ failures = []
374
+
375
+ def get(path: str) -> object:
376
+ request = urllib.request.Request(
377
+ base + path, headers={"Authorization": f"Bearer {hf_token}"}
378
+ )
379
+ with urllib.request.urlopen(request, timeout=60) as response:
380
+ return json.loads(response.read())
381
+
382
+ try:
383
+ get("/health")
384
+ logger.info(" [PASS] /health")
385
+ except Exception as exc: # noqa: BLE001
386
+ failures.append(f"/health: {exc!r}")
387
+ logger.error(" [FAIL] /health: %r", exc)
388
+ return 1
389
+
390
+ try:
391
+ splits = get("/geoguesser_env/splits")
392
+ names = {s["name"]: s["num_tasks"] for s in splits}
393
+ logger.info(" [PASS] /splits -> %s", names)
394
+ if "eval" not in names:
395
+ failures.append("no eval split; is the bucket mounted?")
396
+ except Exception as exc: # noqa: BLE001
397
+ failures.append(f"/splits: {exc!r}")
398
+ logger.error(" [FAIL] /splits: %r", exc)
399
+
400
+ for failure in failures:
401
+ logger.error(" %s", failure)
402
+ return 1 if failures else 0
403
+
404
+
405
+ def main() -> None:
406
+ """Command-line entry point."""
407
+ parser = argparse.ArgumentParser(description=__doc__)
408
+ parser.add_argument("--all", action="store_true", help="Space and dataset.")
409
+ parser.add_argument("--space", action="store_true")
410
+ parser.add_argument("--dataset", action="store_true")
411
+ parser.add_argument("--verify", action="store_true")
412
+ args = parser.parse_args()
413
+
414
+ if not any((args.all, args.space, args.dataset, args.verify)):
415
+ parser.error("pick at least one of --all, --space, --dataset, --verify")
416
+
417
+ hf_token = token()
418
+ if args.all or args.dataset:
419
+ push_dataset(hf_token)
420
+ if args.all or args.space:
421
+ push_space(hf_token)
422
+ if args.verify:
423
+ sys.exit(verify(hf_token))
424
+
425
+
426
+ if __name__ == "__main__":
427
+ main()
geoguesser_env/scripts/fetch_detail_geo.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Fetch the optional detail layers the guess map draws when zoomed in.
4
+
5
+ Without these the agent's map shows country outlines and major cities only,
6
+ which is enough to place a country but not to choose a point within a city. The
7
+ human's map is street-level, so the two disagree about how precisely a pin can
8
+ be aimed — and precision is exactly what the distance reward measures.
9
+
10
+ The layers are Natural Earth 10m: roads, urban areas, river centrelines and
11
+ populated places. They total ~87 MB as raw GeoJSON, too large to commit, so
12
+ they are fetched once and compacted here into small arrays holding only the
13
+ geometry and the name, with coordinates rounded to 4 decimals (about 11 m,
14
+ finer than the panorama spacing).
15
+
16
+ Usage:
17
+ python scripts/fetch_detail_geo.py
18
+ python scripts/fetch_detail_geo.py --layers roads places
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import pathlib
26
+ import urllib.request
27
+
28
+ BASE = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson"
29
+
30
+ LAYERS: dict[str, tuple[str, str | None]] = {
31
+ # name: (source file, property holding a label)
32
+ "roads": ("ne_10m_roads", None),
33
+ "urban": ("ne_10m_urban_areas", None),
34
+ "rivers": ("ne_10m_rivers_lake_centerlines", None),
35
+ "places": ("ne_10m_populated_places_simple", "name"),
36
+ }
37
+
38
+ PRECISION = 4
39
+
40
+
41
+ def _round(coordinates):
42
+ """Round coordinates in place, at any nesting depth."""
43
+ if isinstance(coordinates[0], (int, float)):
44
+ return [
45
+ round(float(coordinates[0]), PRECISION),
46
+ round(float(coordinates[1]), PRECISION),
47
+ ]
48
+ return [_round(part) for part in coordinates]
49
+
50
+
51
+ def compact(features: list[dict], label: str | None) -> list[dict]:
52
+ """Strip a Natural Earth layer to geometry plus one optional label."""
53
+ out = []
54
+ for feature in features:
55
+ geometry = feature.get("geometry")
56
+ if not geometry or not geometry.get("coordinates"):
57
+ continue
58
+ row = {
59
+ "t": geometry["type"],
60
+ "c": _round(geometry["coordinates"]),
61
+ }
62
+ if label:
63
+ row["n"] = feature.get("properties", {}).get(label) or ""
64
+ out.append(row)
65
+ return out
66
+
67
+
68
+ def fetch(names: list[str], out_dir: pathlib.Path) -> None:
69
+ """Download and compact each requested layer."""
70
+ out_dir.mkdir(parents=True, exist_ok=True)
71
+ for name in names:
72
+ source, label = LAYERS[name]
73
+ url = f"{BASE}/{source}.geojson"
74
+ print(f"{name}: downloading {source}.geojson", flush=True)
75
+ with urllib.request.urlopen(url, timeout=300) as response:
76
+ payload = json.loads(response.read())
77
+ rows = compact(payload["features"], label)
78
+ target = out_dir / f"{name}.json"
79
+ target.write_text(json.dumps(rows, separators=(",", ":")))
80
+ print(
81
+ f"{name}: {len(rows):,} features -> {target.name} "
82
+ f"({target.stat().st_size / 1e6:.1f} MB)",
83
+ flush=True,
84
+ )
85
+
86
+
87
+ def main() -> None:
88
+ """Command-line entry point."""
89
+ root = pathlib.Path(__file__).resolve().parents[1]
90
+ parser = argparse.ArgumentParser(description=__doc__)
91
+ parser.add_argument(
92
+ "--layers", nargs="+", choices=sorted(LAYERS), default=sorted(LAYERS)
93
+ )
94
+ parser.add_argument(
95
+ "--out", type=pathlib.Path, default=root / "data" / "geo" / "detail"
96
+ )
97
+ args = parser.parse_args()
98
+ fetch(args.layers, args.out)
99
+ print(
100
+ "\nThe guess map will now draw roads, towns and urban areas when zoomed in. "
101
+ "Delete the directory to go back to outlines only."
102
+ )
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
geoguesser_env/scripts/harvest_tiles.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Enumerate panorama sequences worldwide from Mapillary coverage tiles.
4
+
5
+ This replaces point probing. Probing a coordinate and searching a small box
6
+ finds a panorama about 5% of the time at 45 m boxes and 26% at city centres,
7
+ and each request answers for one spot. A single z6 tile from the `mly1_public`
8
+ **sequence** layer instead returns every sequence crossing roughly 600 km of
9
+ ground, each feature already carrying `is_pano`, an anchor `image_id`,
10
+ `creator_id` and `quality_score`. Measured, one request each:
11
+
12
+ Denver z6 71,752 sequences 18,587 panoramic 7.3 MB 3.5 s
13
+ Paris z6 85,920 sequences 11,617 panoramic 8.6 MB 4.2 s
14
+ Nairobi z6 8,491 sequences 1,670 panoramic 0.8 MB 1.3 s
15
+
16
+ The world is 4,096 tiles at z6 and 1,059 of them intersect land, so a complete
17
+ pass is about 4 GB and uses 2% of the 50,000/day tile budget — with full recall
18
+ rather than a lottery.
19
+
20
+ Output is a pool of candidate sequences, not tasks. Assembling frames is a
21
+ separate, slower step, and keeping them apart means you can harvest once and
22
+ sample from the pool as often as you like.
23
+
24
+ Usage:
25
+ export MAPILLARY_API_KEY="MLY|..."
26
+ python scripts/harvest_tiles.py # whole world, z6
27
+ python scripts/harvest_tiles.py --zoom 6 --workers 6
28
+ python scripts/harvest_tiles.py --bbox -10 35 30 60 # Europe only
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import concurrent.futures
35
+ import functools
36
+ import json
37
+ import logging
38
+ import math
39
+ import pathlib
40
+ import sys
41
+ import threading
42
+ import time
43
+ import urllib.request
44
+
45
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
46
+
47
+ from build_pano_tasks import _token # noqa: E402
48
+
49
+
50
+ TILE_URL = "https://tiles.mapillary.com/maps/vtp/mly1_public/2/{z}/{x}/{y}"
51
+ SEQUENCE_LAYER = "sequence"
52
+ USER_AGENT = "openenv-geoguesser-env/0.1 (research environment)"
53
+
54
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
55
+ logger = logging.getLogger("harvest_tiles")
56
+
57
+
58
+ def tile_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
59
+ """Return `(west, south, east, north)` in degrees for one tile."""
60
+ n = 2**z
61
+ west = x / n * 360.0 - 180.0
62
+ east = (x + 1) / n * 360.0 - 180.0
63
+ north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
64
+ south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
65
+ return west, south, east, north
66
+
67
+
68
+ @functools.lru_cache(maxsize=1)
69
+ def _land_paths():
70
+ """Country outlines from the bundled Natural Earth file."""
71
+ from matplotlib.path import Path as MplPath
72
+
73
+ root = pathlib.Path(__file__).resolve().parents[1]
74
+ features = json.loads(
75
+ (root / "data" / "geo" / "ne_110m_admin_0_countries.geojson").read_text()
76
+ )["features"]
77
+ paths = []
78
+ for feature in features:
79
+ geometry = feature["geometry"]
80
+ rings = (
81
+ [geometry["coordinates"][0]]
82
+ if geometry["type"] == "Polygon"
83
+ else [poly[0] for poly in geometry["coordinates"]]
84
+ )
85
+ paths.extend(MplPath(ring) for ring in rings)
86
+ return paths
87
+
88
+
89
+ def land_tiles(
90
+ zoom: int, bbox: tuple[float, float, float, float] | None
91
+ ) -> list[tuple[int, int]]:
92
+ """Tiles that intersect land, optionally restricted to a bounding box.
93
+
94
+ A tile counts as land if its centre or any corner falls inside a country
95
+ polygon. Antarctica and the high Arctic are skipped — no imagery, and they
96
+ would be a fifth of the work.
97
+ """
98
+ paths = _land_paths()
99
+ n = 2**zoom
100
+ keep = []
101
+ for x in range(n):
102
+ for y in range(n):
103
+ west, south, east, north = tile_bounds(zoom, x, y)
104
+ if north < -58 or south > 78:
105
+ continue
106
+ if bbox:
107
+ if (
108
+ east < bbox[0]
109
+ or west > bbox[2]
110
+ or north < bbox[1]
111
+ or south > bbox[3]
112
+ ):
113
+ continue
114
+ probes = [
115
+ ((west + east) / 2, (south + north) / 2),
116
+ (west, south),
117
+ (east, south),
118
+ (west, north),
119
+ (east, north),
120
+ ]
121
+ if any(path.contains_point(p) for p in probes for path in paths):
122
+ keep.append((x, y))
123
+ return keep
124
+
125
+
126
+ def fetch_tile(token: str, zoom: int, x: int, y: int, retries: int = 2) -> bytes | None:
127
+ """Fetch one coverage tile, or `None` when it cannot be had."""
128
+ url = TILE_URL.format(z=zoom, x=x, y=y) + f"?access_token={token}"
129
+ for attempt in range(retries + 1):
130
+ try:
131
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
132
+ with urllib.request.urlopen(request, timeout=90) as response:
133
+ return response.read()
134
+ except Exception as exc: # noqa: BLE001 - a lost tile is not fatal
135
+ if attempt == retries:
136
+ logger.warning("tile %d/%d/%d failed: %r", zoom, x, y, exc)
137
+ return None
138
+ time.sleep(1.5 * (attempt + 1))
139
+ return None
140
+
141
+
142
+ def sequences_in_tile(raw: bytes, zoom: int, x: int, y: int) -> list[dict]:
143
+ """Decode a tile and return its panoramic sequence features.
144
+
145
+ Tile geometry is in local integer coordinates, so the first vertex of each
146
+ LineString is converted back to longitude and latitude. That position is
147
+ approximate — low-zoom geometry is simplified — and is used only for
148
+ sampling and country capping; exact coordinates come from the Graph API when
149
+ frames are assembled.
150
+ """
151
+ import mapbox_vector_tile
152
+
153
+ try:
154
+ decoded = mapbox_vector_tile.decode(raw)
155
+ except Exception as exc: # noqa: BLE001
156
+ logger.warning("tile %d/%d/%d did not decode: %r", zoom, x, y, exc)
157
+ return []
158
+
159
+ layer = decoded.get(SEQUENCE_LAYER)
160
+ if not layer:
161
+ return []
162
+ extent = layer.get("extent", 4096)
163
+ west, south, east, north = tile_bounds(zoom, x, y)
164
+
165
+ out = []
166
+ for feature in layer.get("features", []):
167
+ properties = feature.get("properties", {})
168
+ if properties.get("is_pano") not in (True, 1, "true"):
169
+ continue
170
+ geometry = feature.get("geometry", {})
171
+ coords = geometry.get("coordinates") or []
172
+ while coords and isinstance(coords[0], (list, tuple)):
173
+ coords = coords[0]
174
+ if len(coords) < 2:
175
+ continue
176
+ local_x, local_y = float(coords[0]), float(coords[1])
177
+ lon = west + (local_x / extent) * (east - west)
178
+ lat = south + (local_y / extent) * (north - south)
179
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
180
+ continue
181
+ out.append(
182
+ {
183
+ "sequence_id": properties.get("id"),
184
+ "image_id": properties.get("image_id"),
185
+ "creator_id": properties.get("creator_id"),
186
+ "quality_score": properties.get("quality_score"),
187
+ "captured_at": properties.get("captured_at"),
188
+ "lat": round(lat, 5),
189
+ "lon": round(lon, 5),
190
+ "tile": f"{zoom}/{x}/{y}",
191
+ }
192
+ )
193
+ return out
194
+
195
+
196
+ def harvest(args: argparse.Namespace) -> None:
197
+ """Walk the tiles and write the sequence pool."""
198
+ token = _token()
199
+ tiles = land_tiles(args.zoom, tuple(args.bbox) if args.bbox else None)
200
+ if args.limit:
201
+ tiles = tiles[: args.limit]
202
+ logger.info(
203
+ "z%d: %d tiles to fetch, %d workers, budget is 50,000 tiles/day (%.1f%% of it)",
204
+ args.zoom,
205
+ len(tiles),
206
+ args.workers,
207
+ 100 * len(tiles) / 50_000,
208
+ )
209
+
210
+ args.out.parent.mkdir(parents=True, exist_ok=True)
211
+ seen: set[str] = set()
212
+ lock = threading.Lock()
213
+ stats = {"tiles": 0, "bytes": 0, "features": 0, "kept": 0, "failed": 0}
214
+ handle = args.out.open("w")
215
+
216
+ def work(tile):
217
+ x, y = tile
218
+ raw = fetch_tile(token, args.zoom, x, y)
219
+ if raw is None:
220
+ with lock:
221
+ stats["failed"] += 1
222
+ return
223
+ rows = sequences_in_tile(raw, args.zoom, x, y)
224
+ with lock:
225
+ stats["tiles"] += 1
226
+ stats["bytes"] += len(raw)
227
+ stats["features"] += len(rows)
228
+ for row in rows:
229
+ sequence_id = row["sequence_id"]
230
+ # Sequences cross tile boundaries and appear in every tile they
231
+ # touch, so the pool is deduplicated as it is written.
232
+ if not sequence_id or sequence_id in seen:
233
+ continue
234
+ seen.add(sequence_id)
235
+ handle.write(json.dumps(row) + "\n")
236
+ stats["kept"] += 1
237
+
238
+ try:
239
+ from rich.progress import (
240
+ BarColumn,
241
+ MofNCompleteColumn,
242
+ Progress,
243
+ TextColumn,
244
+ TimeElapsedColumn,
245
+ TimeRemainingColumn,
246
+ )
247
+
248
+ progress = Progress(
249
+ TextColumn("[cyan]harvesting tiles"),
250
+ BarColumn(),
251
+ MofNCompleteColumn(),
252
+ TextColumn(
253
+ "sequences={task.fields[kept]} · {task.fields[mb]:.0f} MB · "
254
+ "failed={task.fields[failed]}"
255
+ ),
256
+ TimeElapsedColumn(),
257
+ TimeRemainingColumn(),
258
+ )
259
+ with progress:
260
+ task_id = progress.add_task(
261
+ "tiles", total=len(tiles), kept=0, mb=0.0, failed=0
262
+ )
263
+ with concurrent.futures.ThreadPoolExecutor(
264
+ max_workers=args.workers
265
+ ) as pool:
266
+ for _ in pool.map(work, tiles):
267
+ progress.update(
268
+ task_id,
269
+ advance=1,
270
+ kept=stats["kept"],
271
+ mb=stats["bytes"] / 1e6,
272
+ failed=stats["failed"],
273
+ )
274
+ except ImportError: # pragma: no cover - rich ships with openenv
275
+ logger.info("rich not installed, falling back to plain logging")
276
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
277
+ for index, _ in enumerate(pool.map(work, tiles), 1):
278
+ if index % 25 == 0:
279
+ logger.info(
280
+ " %d/%d tiles, %d sequences, %.0f MB",
281
+ index,
282
+ len(tiles),
283
+ stats["kept"],
284
+ stats["bytes"] / 1e6,
285
+ )
286
+ finally:
287
+ handle.close()
288
+
289
+ logger.info(
290
+ "\n%d unique panorama sequences -> %s (%.1f MB)",
291
+ stats["kept"],
292
+ args.out,
293
+ args.out.stat().st_size / 1e6,
294
+ )
295
+ logger.info(
296
+ "read %d tiles (%d failed), %.1f GB of tiles, %d raw features before dedupe",
297
+ stats["tiles"],
298
+ stats["failed"],
299
+ stats["bytes"] / 1e9,
300
+ stats["features"],
301
+ )
302
+ logger.info("next: python scripts/build_tasks.py --tasks 2500")
303
+
304
+
305
+ def main() -> None:
306
+ """Command-line entry point."""
307
+ root = pathlib.Path(__file__).resolve().parents[1]
308
+ parser = argparse.ArgumentParser(description=__doc__)
309
+ parser.add_argument(
310
+ "--zoom", type=int, default=6, help="Sequence layer is served from z6."
311
+ )
312
+ parser.add_argument("--workers", type=int, default=6)
313
+ parser.add_argument(
314
+ "--limit", type=int, default=0, help="Stop after this many tiles."
315
+ )
316
+ parser.add_argument(
317
+ "--bbox",
318
+ nargs=4,
319
+ type=float,
320
+ metavar=("WEST", "SOUTH", "EAST", "NORTH"),
321
+ help="Restrict to a bounding box, for a quick trial run.",
322
+ )
323
+ parser.add_argument(
324
+ "--out", type=pathlib.Path, default=root / "data" / "pool" / "sequences.jsonl"
325
+ )
326
+ harvest(parser.parse_args())
327
+
328
+
329
+ if __name__ == "__main__":
330
+ main()
geoguesser_env/scripts/merge_task_indexes.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Merge task indexes from several discovery passes into one frozen index.
4
+
5
+ Panorama discovery has a low hit rate — roughly 5% of probe points yield a
6
+ 360-degree panorama — so reaching a target task count usually takes more than
7
+ one pass with different seeds. Merging is not a plain concatenation: sequences
8
+ must not repeat across passes, per-country caps must hold over the union, and
9
+ `task_index` must come out contiguous from zero because the backend requires
10
+ that.
11
+
12
+ Usage:
13
+ python scripts/merge_task_indexes.py pass1.jsonl pass2.jsonl \\
14
+ --out tasks/pano_v1.jsonl --limit 100 --max-per-country 4
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import collections
21
+ import json
22
+ import pathlib
23
+
24
+
25
+ def load(path: pathlib.Path) -> list[dict]:
26
+ """Read one JSONL index."""
27
+ return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
28
+
29
+
30
+ def merge(
31
+ sources: list[pathlib.Path],
32
+ limit: int,
33
+ max_per_country: int,
34
+ require_country: bool,
35
+ ) -> list[dict]:
36
+ """
37
+ Combine indexes, dropping duplicates and re-numbering.
38
+
39
+ Args:
40
+ sources (`list[pathlib.Path]`):
41
+ Index files, in priority order — earlier files win a tie.
42
+ limit (`int`):
43
+ Maximum number of tasks to keep.
44
+ max_per_country (`int`):
45
+ Cap per country across the union, so one well-mapped country
46
+ cannot dominate the set.
47
+ require_country (`bool`):
48
+ Drop tasks whose coordinate fell outside the country polygons.
49
+
50
+ Returns:
51
+ `list[dict]`: Merged rows with contiguous `task_index` and rewritten
52
+ `task_id`.
53
+ """
54
+ seen_sequences: set[str] = set()
55
+ seen_images: set[str] = set()
56
+ per_country: collections.Counter = collections.Counter()
57
+ merged: list[dict] = []
58
+
59
+ for path in sources:
60
+ for row in load(path):
61
+ if len(merged) >= limit:
62
+ break
63
+ country = row.get("country", "unknown")
64
+ if require_country and country == "unknown":
65
+ continue
66
+ sequence = row.get("sequence_id", "")
67
+ if sequence in seen_sequences:
68
+ continue
69
+ start_image = row["frames"][row["start_frame"]]["image_id"]
70
+ if start_image in seen_images:
71
+ continue
72
+ if per_country[country] >= max_per_country:
73
+ continue
74
+
75
+ seen_sequences.add(sequence)
76
+ seen_images.add(start_image)
77
+ per_country[country] += 1
78
+ row["task_index"] = len(merged)
79
+ row["task_id"] = f"mly-{len(merged):04d}"
80
+ merged.append(row)
81
+
82
+ return merged
83
+
84
+
85
+ def main() -> None:
86
+ """Command-line entry point."""
87
+ root = pathlib.Path(__file__).resolve().parents[1]
88
+ parser = argparse.ArgumentParser(description=__doc__)
89
+ parser.add_argument("sources", nargs="+", type=pathlib.Path)
90
+ parser.add_argument(
91
+ "--out", type=pathlib.Path, default=root / "tasks" / "pano_v1.jsonl"
92
+ )
93
+ parser.add_argument("--limit", type=int, default=100)
94
+ parser.add_argument("--max-per-country", type=int, default=4)
95
+ parser.add_argument("--allow-unknown-country", action="store_true")
96
+ args = parser.parse_args()
97
+
98
+ merged = merge(
99
+ args.sources,
100
+ limit=args.limit,
101
+ max_per_country=args.max_per_country,
102
+ require_country=not args.allow_unknown_country,
103
+ )
104
+ args.out.parent.mkdir(parents=True, exist_ok=True)
105
+ with args.out.open("w") as handle:
106
+ for row in merged:
107
+ handle.write(json.dumps(row) + "\n")
108
+
109
+ countries = collections.Counter(row["country"] for row in merged)
110
+ continents = collections.Counter(row["meta"].get("continent") for row in merged)
111
+ print(f"merged {len(merged)} tasks into {args.out}")
112
+ print(f" countries : {len(countries)}")
113
+ print(f" continents: {dict(continents)}")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
geoguesser_env/scripts/readiness_check.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Audit whether the environment is fit to train against.
4
+
5
+ The unit tests cover behaviour on four committed fixtures. This checks the
6
+ properties that only show up at the scale and concurrency of a real run:
7
+
8
+ - every task in the index actually renders, so a rollout cannot die on task 87
9
+ - observations are byte-identical across separate processes, not just repeated
10
+ resets in one, which is what a distributed GRPO group depends on
11
+ - concurrent episodes stay isolated, so parallel rollouts do not interleave
12
+ - a warm cache needs no network, so training can run air-gapped
13
+ - reward is discriminative: a random guesser must score near zero and a
14
+ land-centroid guesser only slightly better, or the signal is not measuring
15
+ geolocation
16
+ - per-step latency, so a rollout budget can be estimated
17
+
18
+ Usage:
19
+ python scripts/readiness_check.py
20
+ python scripts/readiness_check.py --full # render every task
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import concurrent.futures
27
+ import hashlib
28
+ import json
29
+ import math
30
+ import pathlib
31
+ import random
32
+ import statistics
33
+ import subprocess
34
+ import sys
35
+ import time
36
+
37
+ # The environment package uses relative imports, so it has to be imported as
38
+ # `geoguesser_env.*` with the envs directory on the path, exactly as the tests
39
+ # and examples do.
40
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
41
+
42
+ from geoguesser_env.models import ( # noqa: E402
43
+ GuessAction,
44
+ LookAction,
45
+ PinAction,
46
+ to_wire,
47
+ )
48
+ from geoguesser_env.server.geoguesser_environment import ( # noqa: E402
49
+ GeoGuesserEnvironment,
50
+ )
51
+ from geoguesser_env.server.scoring import distance_score, haversine_km # noqa: E402
52
+
53
+
54
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
55
+ _CANDIDATE_INDEXES = (
56
+ ROOT / "tasks" / "eval_pano_v3.jsonl",
57
+ ROOT / "tasks" / "pano_v1.jsonl",
58
+ )
59
+ INDEX = next((p for p in _CANDIDATE_INDEXES if p.exists()), _CANDIDATE_INDEXES[-1])
60
+ """Audit the frozen eval split when it exists.
61
+
62
+ Auditing a stale index is worse than not auditing: the numbers look reassuring
63
+ and describe a set nothing runs against.
64
+ """
65
+ CACHE = ROOT / "data" / "panos"
66
+
67
+ PASS = "PASS"
68
+ FAIL = "FAIL"
69
+ WARN = "WARN"
70
+
71
+ results: list[tuple[str, str, str]] = []
72
+
73
+
74
+ def record(name: str, ok: bool | str, detail: str) -> None:
75
+ """Record one check outcome."""
76
+ status = ok if isinstance(ok, str) else (PASS if ok else FAIL)
77
+ results.append((status, name, detail))
78
+ print(f" [{status}] {name}: {detail}", flush=True)
79
+
80
+
81
+ def make_env(**kwargs) -> GeoGuesserEnvironment:
82
+ options = {"index_path": str(INDEX), "cache_dir": str(CACHE)}
83
+ options.update(kwargs)
84
+ return GeoGuesserEnvironment(**options)
85
+
86
+
87
+ # ---------------------------------------------------------------- the checks
88
+
89
+
90
+ def check_index_integrity() -> None:
91
+ """Every row parses, indices are contiguous, no sequence repeats."""
92
+ rows = [json.loads(line) for line in INDEX.read_text().splitlines() if line.strip()]
93
+ indices = [r["task_index"] for r in rows]
94
+ sequences = {r["sequence_id"] for r in rows}
95
+ countries = {r["country"] for r in rows}
96
+ contiguous = indices == list(range(len(rows)))
97
+ record(
98
+ "index integrity",
99
+ contiguous and len(sequences) == len(rows) and "unknown" not in countries,
100
+ f"{len(rows)} tasks, {len(countries)} countries, "
101
+ f"{len(sequences)} unique sequences, contiguous={contiguous}",
102
+ )
103
+
104
+
105
+ def check_all_tasks_render(sample: int | None) -> None:
106
+ """A rollout must not die because one task cannot produce a view."""
107
+ env = make_env(allow_fetch=True, view_size=256)
108
+ total = env._backend.n_tasks
109
+ order = list(range(total))
110
+ if sample and sample < total:
111
+ order = random.Random(0).sample(order, sample)
112
+ failures, latencies = [], []
113
+ for task_index in order:
114
+ try:
115
+ started = time.time()
116
+ observation = env.reset(task_index=task_index)
117
+ latencies.append(time.time() - started)
118
+ if not observation.image_base64:
119
+ failures.append((task_index, "no image"))
120
+ except Exception as exc: # noqa: BLE001 - report, do not abort the audit
121
+ failures.append((task_index, f"{type(exc).__name__}: {exc}"))
122
+ record(
123
+ "all tasks render",
124
+ not failures,
125
+ f"{len(order) - len(failures)}/{len(order)} rendered, "
126
+ f"median reset {statistics.median(latencies) * 1000:.0f} ms"
127
+ + (f", failures: {failures[:3]}" if failures else ""),
128
+ )
129
+
130
+
131
+ def check_cross_process_determinism() -> None:
132
+ """The same task must produce the same bytes in a fresh interpreter."""
133
+ snippet = (
134
+ "import sys, hashlib, pathlib; "
135
+ f"sys.path.insert(0, {str(ROOT.parent)!r}); "
136
+ "from geoguesser_env.server.geoguesser_environment import "
137
+ "GeoGuesserEnvironment; "
138
+ f"env = GeoGuesserEnvironment(index_path={str(INDEX)!r}, "
139
+ f"cache_dir={str(CACHE)!r}, allow_fetch=False); "
140
+ "print(hashlib.sha256("
141
+ "env.reset(task_index=3).image_base64.encode()).hexdigest())"
142
+ )
143
+ digests = []
144
+ for _ in range(2):
145
+ completed = subprocess.run(
146
+ [sys.executable, "-c", snippet],
147
+ capture_output=True,
148
+ text=True,
149
+ cwd=str(ROOT),
150
+ )
151
+ if completed.returncode != 0:
152
+ record("cross-process determinism", False, completed.stderr[-160:])
153
+ return
154
+ digests.append(completed.stdout.strip())
155
+ same_in_process = hashlib.sha256(
156
+ make_env(allow_fetch=False).reset(task_index=3).image_base64.encode()
157
+ ).hexdigest()
158
+ record(
159
+ "cross-process determinism",
160
+ len(set(digests)) == 1 and digests[0] == same_in_process,
161
+ f"two subprocesses and this process agree: {digests[0][:16]}",
162
+ )
163
+
164
+
165
+ def check_parallel_isolation(workers: int = 4) -> None:
166
+ """Concurrent episodes must not interleave into one another."""
167
+
168
+ def play(task_index: int) -> tuple[int, float, int]:
169
+ env = make_env(allow_fetch=False)
170
+ env.reset(task_index=task_index)
171
+ env.step(to_wire(LookAction(heading_deg=90)))
172
+ env.step(to_wire(PinAction(lat=0.0, lon=0.0)))
173
+ truth = env._task.truth
174
+ result = env.step(to_wire(GuessAction(lat=truth[0], lon=truth[1])))
175
+ return task_index, result.reward, result.metadata["task_index"]
176
+
177
+ tasks = list(range(workers * 2))
178
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
179
+ outcomes = list(pool.map(play, tasks))
180
+
181
+ # A perfect guess scores 1.0 minus 0.03 of gathering, on every episode.
182
+ mismatched = [o for o in outcomes if o[0] != o[2]]
183
+ wrong_reward = [o for o in outcomes if abs(o[1] - 0.97) > 1e-6]
184
+ record(
185
+ f"parallel isolation ({workers} workers)",
186
+ not mismatched and not wrong_reward,
187
+ f"{len(outcomes)} concurrent episodes, all returned their own task "
188
+ f"and reward 0.970"
189
+ + (f", mismatched: {mismatched[:2]}" if mismatched else "")
190
+ + (f", wrong reward: {wrong_reward[:2]}" if wrong_reward else ""),
191
+ )
192
+
193
+
194
+ def check_offline() -> None:
195
+ """A warm cache must need no network at all."""
196
+ env = make_env(allow_fetch=False, hires_zoom=False)
197
+ failures = 0
198
+ for task_index in range(min(12, env._backend.n_tasks)):
199
+ try:
200
+ env.reset(task_index=task_index)
201
+ except Exception: # noqa: BLE001
202
+ failures += 1
203
+ record(
204
+ "offline with warm cache",
205
+ failures == 0,
206
+ f"{12 - failures}/12 start frames served with fetching disabled",
207
+ )
208
+
209
+
210
+ def check_reward_is_discriminative(trials: int = 300) -> None:
211
+ """A random guesser must score near zero, or reward measures nothing."""
212
+ env = make_env(allow_fetch=False)
213
+ rng = random.Random(7)
214
+ truths = []
215
+ for task_index in range(env._backend.n_tasks):
216
+ truths.append(env._backend.task(task_index).truth)
217
+
218
+ def score_for(guess_fn) -> float:
219
+ scores = []
220
+ for _ in range(trials):
221
+ lat, lon = truths[rng.randrange(len(truths))]
222
+ guess_lat, guess_lon = guess_fn()
223
+ scores.append(distance_score(haversine_km(guess_lat, guess_lon, lat, lon)))
224
+ return statistics.mean(scores)
225
+
226
+ uniform = score_for(
227
+ lambda: (
228
+ math.degrees(math.asin(rng.uniform(-1, 1))),
229
+ rng.uniform(-180, 180),
230
+ )
231
+ )
232
+ # Guessing a fixed populous point is the strongest trivial baseline.
233
+ centroid = score_for(lambda: (30.0, 20.0))
234
+ perfect = 1.0
235
+ record(
236
+ "reward is discriminative",
237
+ uniform < 0.10 and centroid < 0.30,
238
+ f"uniform-random {uniform:.3f}, fixed-point {centroid:.3f}, "
239
+ f"perfect {perfect:.3f} (Sonnet measured 0.896)",
240
+ )
241
+
242
+
243
+ def check_step_latency() -> None:
244
+ """Per-step cost, so a rollout budget can be estimated."""
245
+ env = make_env(allow_fetch=False)
246
+ env.reset(task_index=0)
247
+ timings: dict[str, float] = {}
248
+ for label, action in (
249
+ ("look 90deg", LookAction(heading_deg=45, fov_deg=90)),
250
+ ("pin + map", PinAction(lat=10.0, lon=10.0)),
251
+ ):
252
+ started = time.time()
253
+ env.step(to_wire(action))
254
+ timings[label] = (time.time() - started) * 1000
255
+ detail = ", ".join(f"{k} {v:.0f} ms" for k, v in timings.items())
256
+ record("step latency", all(v < 2000 for v in timings.values()), detail)
257
+
258
+
259
+ def check_one_guess_per_episode() -> None:
260
+ """An episode accepts exactly one guess; the second must not score."""
261
+ env = make_env(allow_fetch=False)
262
+ env.reset(task_index=0)
263
+ truth = env._task.truth
264
+ first = env.step(to_wire(GuessAction(lat=truth[0], lon=truth[1])))
265
+ second = env.step(to_wire(GuessAction(lat=0.0, lon=0.0)))
266
+ third = env.step(to_wire(LookAction(heading_deg=0)))
267
+ record(
268
+ "one guess per episode",
269
+ first.done
270
+ and first.reward is not None
271
+ and second.reward is None
272
+ and second.done
273
+ and third.done,
274
+ f"first guess scored {first.reward:.3f} and ended the episode; "
275
+ f"a second returned reward={second.reward} "
276
+ f"({second.feedback.split('.')[0]})",
277
+ )
278
+
279
+
280
+ def check_pin_never_leaks() -> None:
281
+ """Pin feedback must reveal nothing about the target, at any coordinate."""
282
+ env = make_env(allow_fetch=False)
283
+ leaks = []
284
+ for task_index in range(min(20, env._backend.n_tasks)):
285
+ env.reset(task_index=task_index)
286
+ true_lat, true_lon = env._task.truth
287
+ country = env._task.country
288
+ for lat, lon in ((0.0, 0.0), (true_lat, true_lon), (-40.0, 160.0)):
289
+ observation = env.step(to_wire(PinAction(lat=lat, lon=lon)))
290
+ text = f"{observation.feedback} {observation.pins[-1].description}"
291
+ if observation.distance_km is not None or observation.true_lat is not None:
292
+ leaks.append((task_index, "distance or truth field populated"))
293
+ # A pin *at* the truth naturally names that country; the leak we
294
+ # care about is a pin elsewhere revealing it.
295
+ if (lat, lon) != (true_lat, true_lon) and country.lower() in text.lower():
296
+ leaks.append((task_index, f"named {country} from a distant pin"))
297
+ record(
298
+ "pin never leaks the target",
299
+ not leaks,
300
+ "60 pins across 20 tasks revealed nothing"
301
+ + (f"; leaks: {leaks[:2]}" if leaks else ""),
302
+ )
303
+
304
+
305
+ def main() -> None:
306
+ """Run the audit."""
307
+ parser = argparse.ArgumentParser(description=__doc__)
308
+ parser.add_argument(
309
+ "--full", action="store_true", help="Render every task, not a sample."
310
+ )
311
+ parser.add_argument("--sample", type=int, default=25)
312
+ parser.add_argument(
313
+ "--index",
314
+ type=pathlib.Path,
315
+ default=None,
316
+ help="Index to audit. Defaults to the eval split when present.",
317
+ )
318
+ args = parser.parse_args()
319
+
320
+ if args.index is not None:
321
+ if not args.index.exists():
322
+ raise SystemExit(f"no such index: {args.index}")
323
+ global INDEX
324
+ INDEX = args.index
325
+
326
+ print("geoguesser_env readiness audit")
327
+ print("=" * 78)
328
+ print(f" index: {INDEX}")
329
+ check_index_integrity()
330
+ check_all_tasks_render(None if args.full else args.sample)
331
+ check_cross_process_determinism()
332
+ check_parallel_isolation()
333
+ check_offline()
334
+ check_reward_is_discriminative()
335
+ check_step_latency()
336
+ check_one_guess_per_episode()
337
+ check_pin_never_leaks()
338
+ print("=" * 78)
339
+
340
+ failed = [r for r in results if r[0] == FAIL]
341
+ warned = [r for r in results if r[0] == WARN]
342
+ print(
343
+ f"{len(results) - len(failed) - len(warned)} passed, "
344
+ f"{len(warned)} warned, {len(failed)} failed"
345
+ )
346
+ for status, name, detail in failed + warned:
347
+ print(f" {status}: {name} - {detail}")
348
+ sys.exit(1 if failed else 0)
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
geoguesser_env/scripts/render_trace.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Render a rollout trace as a single HTML page you can scroll through.
4
+
5
+ A JSONL trace answers "what was the reward"; it does not show you *why*. This
6
+ lays each episode out as a filmstrip: the view the model was looking at, what it
7
+ said, the action it chose, what the environment replied, and the running cost —
8
+ ending with the reveal and the score.
9
+
10
+ Usage:
11
+ python scripts/render_trace.py rollouts/anthropic_agentic
12
+ python scripts/render_trace.py rollouts/hf_agentic --out qwen.html
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import base64
19
+ import html
20
+ import json
21
+ import pathlib
22
+
23
+
24
+ def _data_uri(path: pathlib.Path) -> str:
25
+ """Inline an image so the report is a single portable file."""
26
+ if not path.exists():
27
+ return ""
28
+ mime = "image/png" if path.suffix == ".png" else "image/jpeg"
29
+ return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode()}"
30
+
31
+
32
+ def _fmt_distance(km: float | None) -> str:
33
+ if km is None:
34
+ return "—"
35
+ return f"{km * 1000:.0f} m" if km < 10 else f"{km:,.0f} km"
36
+
37
+
38
+ def _verdict(km: float | None) -> str:
39
+ if km is None:
40
+ return "no guess"
41
+ if km < 0.025:
42
+ return "perfect"
43
+ if km < 25:
44
+ return "pinpoint"
45
+ if km < 200:
46
+ return "close"
47
+ if km < 1500:
48
+ return "right region"
49
+ return "wrong continent"
50
+
51
+
52
+ def render(trace_dir: pathlib.Path, out: pathlib.Path) -> None:
53
+ """Write one HTML report for a trace directory."""
54
+ records = [
55
+ json.loads(line)
56
+ for line in (trace_dir / "trace.jsonl").read_text().splitlines()
57
+ if line.strip()
58
+ ]
59
+ summary_path = trace_dir / "summary.json"
60
+ summary = json.loads(summary_path.read_text()) if summary_path.exists() else {}
61
+
62
+ episodes = []
63
+ for record in records:
64
+ turns = []
65
+ for row in record.get("trace", []):
66
+ image = ""
67
+ if row.get("image"):
68
+ image = _data_uri(trace_dir / row["image"])
69
+ action = row.get("action") or {}
70
+ kind = (
71
+ action.get("action", "reset")
72
+ if isinstance(action, dict)
73
+ else str(action)
74
+ )
75
+ args = (
76
+ ", ".join(
77
+ f"{key}={value}" for key, value in action.items() if key != "action"
78
+ )
79
+ if isinstance(action, dict)
80
+ else ""
81
+ )
82
+ turns.append(
83
+ {
84
+ "turn": row.get("turn"),
85
+ "kind": kind,
86
+ "args": args,
87
+ "reply": row.get("reply") or "",
88
+ "feedback": row.get("feedback") or "",
89
+ "steps": row.get("steps_remaining"),
90
+ "cost": row.get("action_cost"),
91
+ "image": image,
92
+ "image_kind": row.get("image_kind", "none"),
93
+ }
94
+ )
95
+ episodes.append({"record": record, "turns": turns})
96
+
97
+ parts = [
98
+ _HEAD.replace(
99
+ "__TITLE__", html.escape(str(summary.get("model", trace_dir.name)))
100
+ )
101
+ ]
102
+ parts.append(_summary_block(summary, records))
103
+ for episode in episodes:
104
+ parts.append(_episode_block(episode))
105
+ parts.append("</body></html>")
106
+ out.write_text("".join(parts))
107
+ print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB, {len(episodes)} episodes)")
108
+
109
+
110
+ def _summary_block(summary: dict, records: list[dict]) -> str:
111
+ rows = "".join(
112
+ f"<div class='stat'><span>{html.escape(label)}</span><b>{html.escape(str(value))}</b></div>"
113
+ for label, value in [
114
+ ("model", summary.get("model", "—")),
115
+ ("mode", summary.get("mode", "—")),
116
+ ("episodes", summary.get("episodes", len(records))),
117
+ ("mean reward", f"{summary.get('mean_reward', 0):.3f}"),
118
+ ("median distance", _fmt_distance(summary.get("median_distance_km"))),
119
+ ("within 200 km", f"{summary.get('within_200km', 0)}/{len(records)}"),
120
+ ("parsed", f"{summary.get('parsed', 0)}/{len(records)}"),
121
+ ("wall clock", f"{summary.get('wall_clock_s', 0):.1f}s"),
122
+ ]
123
+ )
124
+ return f"<header><h1>rollout trace</h1><div class='stats'>{rows}</div></header>"
125
+
126
+
127
+ def _episode_block(episode: dict) -> str:
128
+ record = episode["record"]
129
+ km = record.get("distance_km")
130
+ reward = record.get("reward") or 0.0
131
+ head = (
132
+ f"<h2>episode {record.get('task_index')} "
133
+ f"<span class='country'>{html.escape(str(record.get('country', '')))}</span>"
134
+ f"<span class='verdict v-{_verdict(km).replace(' ', '-')}'>{_verdict(km)}</span>"
135
+ f"<span class='num'>{_fmt_distance(km)}</span>"
136
+ f"<span class='num'>reward {reward:.3f}</span>"
137
+ f"<span class='num'>{record.get('turns', 0)} turns</span>"
138
+ f"<span class='num'>{record.get('latency_s', 0):.1f}s</span></h2>"
139
+ )
140
+ cards = []
141
+ for turn in episode["turns"]:
142
+ image = (
143
+ f"<img src='{turn['image']}' alt='observation at turn {turn['turn']}'>"
144
+ if turn["image"]
145
+ else "<div class='noimg'>no image</div>"
146
+ )
147
+ reply = html.escape(turn["reply"][:1400])
148
+ meta = []
149
+ if turn["steps"] is not None:
150
+ meta.append(f"{turn['steps']} steps left")
151
+ if turn["cost"] is not None:
152
+ meta.append(f"cost {turn['cost']:.2f}")
153
+ cards.append(
154
+ f"""<div class='turn {"map" if turn["image_kind"] == "map" else ""}'>
155
+ <div class='thumb'>{image}</div>
156
+ <div class='body'>
157
+ <div class='act'><b>{html.escape(str(turn["kind"]))}</b>
158
+ <span>{html.escape(turn["args"])}</span>
159
+ <span class='meta'>{html.escape(" · ".join(meta))}</span></div>
160
+ <div class='fb'>{html.escape(turn["feedback"])}</div>
161
+ {f"<details><summary>model reply</summary><pre>{reply}</pre></details>" if reply else ""}
162
+ </div>
163
+ </div>"""
164
+ )
165
+ return f"<section>{head}<div class='turns'>{''.join(cards)}</div></section>"
166
+
167
+
168
+ _HEAD = """<!doctype html>
169
+ <html lang="en"><head><meta charset="utf-8">
170
+ <meta name="viewport" content="width=device-width,initial-scale=1">
171
+ <title>__TITLE__ — rollout trace</title>
172
+ <style>
173
+ :root {
174
+ --ground:#eef1f4; --panel:#fff; --edge:#d3dae0; --ink:#1d2328;
175
+ --soft:#5c666e; --faint:#8b959c; --wire:#4a7d99; --pin:#c4332a; --good:#3f7a55;
176
+ }
177
+ @media (prefers-color-scheme:dark){ :root{
178
+ --ground:#12171b; --panel:#191f24; --edge:#2b343b; --ink:#e6ebef;
179
+ --soft:#a3aeb6; --faint:#6e7a83; --wire:#7fb3cc; --pin:#e8695e; --good:#79b892; } }
180
+ *{box-sizing:border-box}
181
+ body{margin:0;background:var(--ground);color:var(--ink);
182
+ font:14px/1.6 ui-monospace,"SF Mono",Menlo,monospace;padding:24px 18px 60px}
183
+ header{max-width:1180px;margin:0 auto 26px}
184
+ h1{font-size:19px;margin:0 0 12px;font-weight:600;letter-spacing:.02em}
185
+ .stats{display:flex;flex-wrap:wrap;gap:18px}
186
+ .stat{background:var(--panel);border:1px solid var(--edge);border-radius:4px;
187
+ padding:7px 12px;min-width:118px}
188
+ .stat span{display:block;font-size:9.5px;letter-spacing:.12em;
189
+ text-transform:uppercase;color:var(--faint)}
190
+ .stat b{font-size:15px;font-weight:500;font-variant-numeric:tabular-nums}
191
+ section{max-width:1180px;margin:0 auto 30px;background:var(--panel);
192
+ border:1px solid var(--edge);border-radius:5px;overflow:hidden}
193
+ h2{margin:0;padding:11px 15px;font-size:13px;font-weight:600;
194
+ border-bottom:1px solid var(--edge);display:flex;align-items:center;
195
+ gap:12px;flex-wrap:wrap}
196
+ h2 .country{color:var(--soft);font-weight:400}
197
+ h2 .num{margin-left:auto;font-weight:400;color:var(--soft);font-size:11.5px;
198
+ font-variant-numeric:tabular-nums}
199
+ h2 .num + .num{margin-left:0}
200
+ .verdict{font-size:10px;letter-spacing:.1em;text-transform:uppercase;
201
+ padding:2px 7px;border-radius:3px;border:1px solid var(--edge);color:var(--soft)}
202
+ .v-pinpoint,.v-perfect{color:var(--good);border-color:var(--good)}
203
+ .v-wrong-continent,.v-no-guess{color:var(--pin);border-color:var(--pin)}
204
+ .turns{display:flex;flex-direction:column}
205
+ .turn{display:flex;gap:14px;padding:12px 15px;border-bottom:1px solid var(--edge)}
206
+ .turn:last-child{border-bottom:none}
207
+ .turn.map{background:color-mix(in srgb,var(--wire) 7%,transparent)}
208
+ .thumb{flex:0 0 200px}
209
+ .thumb img{width:200px;border-radius:3px;display:block;border:1px solid var(--edge)}
210
+ .noimg{width:200px;height:96px;border:1px dashed var(--edge);border-radius:3px;
211
+ display:flex;align-items:center;justify-content:center;color:var(--faint);font-size:11px}
212
+ .body{flex:1;min-width:0}
213
+ .act b{color:var(--wire)}
214
+ .act span{color:var(--soft);font-size:12px;margin-left:6px}
215
+ .act .meta{float:right;color:var(--faint);font-size:11px}
216
+ .fb{margin-top:5px;color:var(--soft);font-size:12.5px}
217
+ details{margin-top:8px}
218
+ summary{cursor:pointer;font-size:11px;color:var(--faint)}
219
+ pre{white-space:pre-wrap;font-size:11.5px;background:var(--ground);
220
+ border:1px solid var(--edge);border-radius:3px;padding:9px;margin:7px 0 0;
221
+ max-height:260px;overflow:auto}
222
+ </style></head><body>
223
+ """
224
+
225
+
226
+ def main() -> None:
227
+ """Command-line entry point."""
228
+ parser = argparse.ArgumentParser(description=__doc__)
229
+ parser.add_argument("trace_dir", type=pathlib.Path)
230
+ parser.add_argument("--out", type=pathlib.Path)
231
+ args = parser.parse_args()
232
+ out = args.out or (args.trace_dir / "trace.html")
233
+ render(args.trace_dir, out)
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()
geoguesser_env/scripts/split_tasks.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Split one harvested pool into a frozen eval set and a training pool.
4
+
5
+ Splitting after harvesting is the safer order. If the two sets are harvested
6
+ separately you have to reason about contamination across two runs; if they come
7
+ from one pool you can enforce disjointness exactly once, here, and check it.
8
+
9
+ Two rules, both from the OSV-5M paper, which built its train/test split from the
10
+ same Mapillary source:
11
+
12
+ - no shared `sequence_id`
13
+ - no eval task within `--buffer-km` of any training task
14
+
15
+ The buffer matters because frames sit about 3.3 m apart. Holding out an image
16
+ while keeping its neighbour holds out nothing at all.
17
+
18
+ Eval is carved first and balanced by country, because at a couple of hundred
19
+ tasks balance decides what the number means. Training takes everything left over
20
+ that clears the buffer.
21
+
22
+ Usage:
23
+ python scripts/split_tasks.py tasks/pool_v1.jsonl --eval 200
24
+ python scripts/split_tasks.py tasks/pool_v1.jsonl --eval 200 --buffer-km 2
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import collections
31
+ import json
32
+ import logging
33
+ import math
34
+ import pathlib
35
+
36
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
37
+ logger = logging.getLogger("split_tasks")
38
+
39
+
40
+ def haversine_km(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
41
+ """Great-circle distance in kilometres."""
42
+ phi_a, phi_b = math.radians(lat_a), math.radians(lat_b)
43
+ d_phi, d_lambda = phi_b - phi_a, math.radians(lon_b - lon_a)
44
+ h = (
45
+ math.sin(d_phi / 2) ** 2
46
+ + math.cos(phi_a) * math.cos(phi_b) * math.sin(d_lambda / 2) ** 2
47
+ )
48
+ return 2 * 6371.0088 * math.asin(math.sqrt(min(1.0, h)))
49
+
50
+
51
+ def start_point(task: dict) -> tuple[float, float]:
52
+ """The coordinate a task is scored against."""
53
+ frame = task["frames"][task["start_frame"]]
54
+ return frame["lat"], frame["lon"]
55
+
56
+
57
+ def renumber(tasks: list[dict], prefix: str) -> list[dict]:
58
+ """Rewrite indices so the backend's contiguity requirement holds."""
59
+ out = []
60
+ for index, task in enumerate(tasks):
61
+ task = dict(task)
62
+ task["task_index"] = index
63
+ task["task_id"] = f"{prefix}-{index:05d}"
64
+ out.append(task)
65
+ return out
66
+
67
+
68
+ def split(args: argparse.Namespace) -> None:
69
+ """Carve the eval set, then hand the remainder to training."""
70
+ pool = [
71
+ json.loads(line) for line in args.pool.read_text().splitlines() if line.strip()
72
+ ]
73
+ logger.info("pool holds %d tasks", len(pool))
74
+
75
+ # Eval first, balanced by country. Ordering is by completeness before
76
+ # quality: a task missing frames is unusable in a set whose whole purpose is
77
+ # to run with the network off, and no quality score compensates for that.
78
+ def eval_rank(task: dict) -> tuple[int, float]:
79
+ complete = 1 if task["meta"].get("offline_ready") else 0
80
+ return (-complete, -(task["meta"].get("quality_score") or 0.0))
81
+
82
+ incomplete = sum(1 for t in pool if not t["meta"].get("offline_ready"))
83
+ if incomplete:
84
+ logger.info(
85
+ "%d pool tasks are not fully mirrored; they sort last for eval "
86
+ "(run scripts/verify_offline.py to repair)",
87
+ incomplete,
88
+ )
89
+ by_quality = sorted(pool, key=eval_rank)
90
+ per_country: collections.Counter = collections.Counter()
91
+ eval_tasks: list[dict] = []
92
+ for task in by_quality:
93
+ if len(eval_tasks) >= args.eval:
94
+ break
95
+ country = task["country"]
96
+ if per_country[country] >= args.eval_per_country:
97
+ continue
98
+ per_country[country] += 1
99
+ eval_tasks.append(task)
100
+
101
+ eval_sequences = {t["sequence_id"] for t in eval_tasks}
102
+ eval_points = [start_point(t) for t in eval_tasks]
103
+
104
+ train_tasks: list[dict] = []
105
+ dropped_sequence = 0
106
+ dropped_buffer = 0
107
+ for task in pool:
108
+ if task["sequence_id"] in eval_sequences:
109
+ dropped_sequence += 1
110
+ continue
111
+ lat, lon = start_point(task)
112
+ if any(
113
+ haversine_km(lat, lon, e_lat, e_lon) < args.buffer_km
114
+ for e_lat, e_lon in eval_points
115
+ ):
116
+ dropped_buffer += 1
117
+ continue
118
+ train_tasks.append(task)
119
+
120
+ eval_out = args.out_dir / args.eval_name
121
+ train_out = args.out_dir / args.train_name
122
+ for path, tasks, prefix in (
123
+ (eval_out, eval_tasks, "eval"),
124
+ (train_out, train_tasks, "train"),
125
+ ):
126
+ with path.open("w") as handle:
127
+ for task in renumber(tasks, prefix):
128
+ handle.write(json.dumps(task) + "\n")
129
+
130
+ # Verify rather than assert in a comment.
131
+ train_sequences = {t["sequence_id"] for t in train_tasks}
132
+ overlap = eval_sequences & train_sequences
133
+ closest = min(
134
+ (
135
+ haversine_km(*start_point(t), *point)
136
+ for t in train_tasks
137
+ for point in eval_points
138
+ ),
139
+ default=float("inf"),
140
+ )
141
+
142
+ countries_eval = collections.Counter(t["country"] for t in eval_tasks)
143
+ logger.info(
144
+ "\neval %4d tasks -> %s (%d countries, max %d per country)",
145
+ len(eval_tasks),
146
+ eval_out.name,
147
+ len(countries_eval),
148
+ max(countries_eval.values()) if countries_eval else 0,
149
+ )
150
+ logger.info(
151
+ "train %4d tasks -> %s (dropped %d on shared sequence, %d on the %.1f km buffer)",
152
+ len(train_tasks),
153
+ train_out.name,
154
+ dropped_sequence,
155
+ dropped_buffer,
156
+ args.buffer_km,
157
+ )
158
+ logger.info(
159
+ "\ncontamination check: %d shared sequences, closest train task is %.2f km "
160
+ "from an eval task",
161
+ len(overlap),
162
+ closest,
163
+ )
164
+ if overlap or closest < args.buffer_km:
165
+ raise SystemExit("split failed its own contamination check")
166
+ mirrored = sum(1 for t in eval_tasks if t["meta"].get("offline_ready"))
167
+ logger.info(
168
+ "eval tasks fully mirrored for offline use: %d/%d", mirrored, len(eval_tasks)
169
+ )
170
+ if mirrored < len(eval_tasks):
171
+ raise SystemExit(
172
+ f"{len(eval_tasks) - mirrored} eval tasks are not fully mirrored. "
173
+ "A frozen eval set must run offline; repair with "
174
+ "scripts/verify_offline.py or lower --eval."
175
+ )
176
+
177
+
178
+ def main() -> None:
179
+ """Command-line entry point."""
180
+ root = pathlib.Path(__file__).resolve().parents[1]
181
+ parser = argparse.ArgumentParser(description=__doc__)
182
+ parser.add_argument("pool", type=pathlib.Path)
183
+ parser.add_argument("--eval", type=int, default=200)
184
+ parser.add_argument("--eval-per-country", type=int, default=4)
185
+ parser.add_argument("--buffer-km", type=float, default=1.0)
186
+ parser.add_argument("--eval-name", default="eval_pano_v3.jsonl")
187
+ parser.add_argument("--train-name", default="train_pano_v3.jsonl")
188
+ parser.add_argument("--out-dir", type=pathlib.Path, default=root / "tasks")
189
+ split(parser.parse_args())
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
geoguesser_env/scripts/verify_offline.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Check that a task index is fully mirrored, and top up whatever is missing.
4
+
5
+ An interrupted build leaves tasks whose start frame is cached but whose later
6
+ frames are not, so the episode is playable but `move` walks into a hole. The
7
+ `offline_ready` flag in the index records what was true at build time, which
8
+ stops being true the moment a build is interrupted or a frame is deleted, so
9
+ this checks the filesystem instead of trusting the flag -- and rewrites the flag
10
+ to match what it found.
11
+
12
+ Run it before syncing to a bucket. `GEOGUESSER_ALLOW_FETCH=0` turns a missing
13
+ frame into a hard error at rollout time, which is the point, but it is a much
14
+ better error to hit here.
15
+
16
+ Usage:
17
+ python scripts/verify_offline.py tasks/pool_offline_5k.jsonl
18
+ python scripts/verify_offline.py tasks/pool_offline_5k.jsonl --check
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import collections
25
+ import concurrent.futures
26
+ import json
27
+ import logging
28
+ import pathlib
29
+ import sys
30
+ import threading
31
+ import urllib.request
32
+
33
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
34
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
35
+
36
+ from build_pano_tasks import _get, _token, TIMEOUT_S # noqa: E402
37
+
38
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
39
+ logger = logging.getLogger("verify_offline")
40
+
41
+
42
+ def missing_frames(tasks: list[dict], cache: pathlib.Path) -> dict[int, list[str]]:
43
+ """
44
+ Map task index to the image ids absent from the cache.
45
+
46
+ Args:
47
+ tasks (`list[dict]`):
48
+ Parsed task index.
49
+ cache (`pathlib.Path`):
50
+ Directory holding `<image_id>.jpg`.
51
+
52
+ Returns:
53
+ `dict[int, list[str]]`: Only tasks with at least one absent frame.
54
+ """
55
+ gaps: dict[int, list[str]] = {}
56
+ for task in tasks:
57
+ absent = [
58
+ frame["image_id"]
59
+ for frame in task["frames"]
60
+ if not (cache / f"{frame['image_id']}.jpg").exists()
61
+ ]
62
+ if absent:
63
+ gaps[task["task_index"]] = absent
64
+ return gaps
65
+
66
+
67
+ def fetch_one(token: str, image_id: str, cache: pathlib.Path) -> bool:
68
+ """Download one frame into the cache. Returns whether it landed."""
69
+ path = cache / f"{image_id}.jpg"
70
+ if path.exists():
71
+ return True
72
+ field = "thumb_2048_url"
73
+ meta = _get(token, image_id, fields=field)
74
+ url = meta.get(field) if "__error" not in meta else None
75
+ if not url:
76
+ return False
77
+ try:
78
+ with urllib.request.urlopen(url, timeout=TIMEOUT_S) as response:
79
+ payload = response.read()
80
+ except Exception as exc: # noqa: BLE001
81
+ logger.warning(" %s: %r", image_id, exc)
82
+ return False
83
+ # Write via a temporary name so an interrupted download cannot leave a
84
+ # truncated JPEG that later looks like a cache hit.
85
+ tmp = path.with_suffix(".partial")
86
+ tmp.write_bytes(payload)
87
+ tmp.replace(path)
88
+ return True
89
+
90
+
91
+ def repair(
92
+ tasks: list[dict], gaps: dict[int, list[str]], cache: pathlib.Path, workers: int
93
+ ) -> set[str]:
94
+ """Fetch every absent frame concurrently. Returns the ids still missing."""
95
+ wanted = sorted({image_id for ids in gaps.values() for image_id in ids})
96
+ token = _token()
97
+ still: set[str] = set()
98
+ lock = threading.Lock()
99
+ done = 0
100
+
101
+ def one(image_id: str) -> None:
102
+ nonlocal done
103
+ ok = fetch_one(token, image_id, cache)
104
+ with lock:
105
+ done += 1
106
+ if not ok:
107
+ still.add(image_id)
108
+ if done % 25 == 0 or done == len(wanted):
109
+ logger.info(" %d/%d fetched", done, len(wanted))
110
+
111
+ logger.info("fetching %d missing frames with %d workers", len(wanted), workers)
112
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
113
+ list(pool.map(one, wanted))
114
+ return still
115
+
116
+
117
+ def main() -> None:
118
+ """Command-line entry point."""
119
+ root = pathlib.Path(__file__).resolve().parents[1]
120
+ parser = argparse.ArgumentParser(description=__doc__)
121
+ parser.add_argument("index", type=pathlib.Path)
122
+ parser.add_argument("--cache", type=pathlib.Path, default=root / "data" / "panos")
123
+ parser.add_argument("--workers", type=int, default=12)
124
+ parser.add_argument(
125
+ "--check",
126
+ action="store_true",
127
+ help="Report only; never fetch and never rewrite the index.",
128
+ )
129
+ args = parser.parse_args()
130
+
131
+ tasks = [
132
+ json.loads(line) for line in args.index.read_text().splitlines() if line.strip()
133
+ ]
134
+ total_frames = sum(len(t["frames"]) for t in tasks)
135
+ gaps = missing_frames(tasks, args.cache)
136
+ absent = sum(len(v) for v in gaps.values())
137
+ logger.info(
138
+ "%d tasks · %d frames · %d tasks incomplete · %d frames absent",
139
+ len(tasks),
140
+ total_frames,
141
+ len(gaps),
142
+ absent,
143
+ )
144
+
145
+ starts = [
146
+ t["task_index"]
147
+ for t in tasks
148
+ if not (
149
+ args.cache / f"{t['frames'][t['start_frame']]['image_id']}.jpg"
150
+ ).exists()
151
+ ]
152
+ if starts:
153
+ logger.warning(
154
+ "%d tasks are missing their START frame and cannot be played "
155
+ "offline at all: %s",
156
+ len(starts),
157
+ starts[:10],
158
+ )
159
+
160
+ if not gaps:
161
+ logger.info("index is fully mirrored")
162
+ return
163
+
164
+ if args.check:
165
+ for index, ids in sorted(gaps.items())[:20]:
166
+ logger.info(" task %5d: %d absent", index, len(ids))
167
+ raise SystemExit(f"{absent} frames absent from {args.cache}")
168
+
169
+ still = repair(tasks, gaps, args.cache, args.workers)
170
+
171
+ # Rewrite offline_ready from what is actually on disk, so downstream
172
+ # consumers -- the eval split especially -- can trust the flag.
173
+ remaining = missing_frames(tasks, args.cache)
174
+ changed = 0
175
+ for task in tasks:
176
+ ready = task["task_index"] not in remaining
177
+ if task["meta"].get("offline_ready") != ready:
178
+ task["meta"]["offline_ready"] = ready
179
+ changed += 1
180
+ args.index.write_text("".join(json.dumps(t) + "\n" for t in tasks))
181
+
182
+ ready_now = sum(1 for t in tasks if t["meta"]["offline_ready"])
183
+ logger.info(
184
+ "\nrepaired: %d frames still absent · %d tasks still incomplete",
185
+ sum(len(v) for v in remaining.values()),
186
+ len(remaining),
187
+ )
188
+ logger.info(
189
+ "offline_ready %d/%d (%d flags corrected)", ready_now, len(tasks), changed
190
+ )
191
+ if still:
192
+ by_task = collections.Counter(
193
+ task["task_index"]
194
+ for task in tasks
195
+ for frame in task["frames"]
196
+ if frame["image_id"] in still
197
+ )
198
+ logger.warning(
199
+ "unfetchable frames belong to tasks %s -- these are usually images "
200
+ "deleted upstream; exclude them from eval",
201
+ sorted(by_task)[:10],
202
+ )
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
geoguesser_env/server/Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ FROM openenv-base:latest
3
+
4
+ WORKDIR /app
5
+
6
+ # The task index and bundled Natural Earth vectors are small and belong in the
7
+ # image. Panorama bytes are not: they are cached at runtime under
8
+ # /app/geoguesser_env/data/panos, which should be a mounted volume so the cache survives
9
+ # container restarts.
10
+ # A package directory: modules inside use `from ..models import ...`, which
11
+ # needs `geoguesser_env` to be the package rather than `server`.
12
+ COPY envs/geoguesser_env /app/geoguesser_env
13
+ RUN mkdir -p /app/geoguesser_env/data/panos
14
+
15
+ ENV PYTHONPATH=/app:/app/src
16
+ # Legacy single-index fallback, used when no split resolves. pano_v1.jsonl is
17
+ # committed, so the image always has something playable.
18
+ ENV GEOGUESSER_INDEX=/app/geoguesser_env/tasks/pano_v1.jsonl
19
+ ENV GEOGUESSER_CACHE=/app/geoguesser_env/data/panos
20
+ # Split indexes as copied into the image. A Space overrides all three to point
21
+ # at a mounted bucket instead, because the imagery cannot fit in the image.
22
+ ENV GEOGUESSER_TASKS_TRAIN=/app/geoguesser_env/tasks/train_pano_v3.jsonl
23
+ ENV GEOGUESSER_TASKS_EVAL=/app/geoguesser_env/tasks/eval_pano_v3.jsonl
24
+
25
+ EXPOSE 8000
26
+ CMD ["uvicorn", "geoguesser_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
geoguesser_env/server/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Server-side implementation of the GeoGuesser environment."""
geoguesser_env/server/app.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """FastAPI application for the GeoGuesser environment.
4
+
5
+ Configuration comes from the process environment so one image can serve every
6
+ variant. `MAPILLARY_API_KEY` is needed only to fill cache misses; with a warm
7
+ cache the server runs with no network access at all.
8
+
9
+ Usage:
10
+ uv run --project . server
11
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ import logging
18
+ import os
19
+ import pathlib
20
+
21
+ from openenv.core.env_server.http_server import create_app
22
+
23
+ try:
24
+ from geoguesser_env.models import GeoGuesserAction, GeoGuesserObservation
25
+ from geoguesser_env.server.geoguesser_environment import GeoGuesserEnvironment
26
+ except ImportError: # running uvicorn from inside envs/geoguesser_env
27
+ from models import GeoGuesserAction, GeoGuesserObservation
28
+
29
+ from .geoguesser_environment import GeoGuesserEnvironment
30
+
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _ROOT = pathlib.Path(__file__).resolve().parents[1]
35
+
36
+ INDEX_PATH = os.getenv("GEOGUESSER_INDEX", str(_ROOT / "tasks" / "pano_v1.jsonl"))
37
+ CACHE_DIR = os.getenv("GEOGUESSER_CACHE", str(_ROOT / "data" / "panos"))
38
+ # Named splits, as (environment variable, repo-relative default). Each is
39
+ # optional and a split whose file is absent is simply not offered, so the same
40
+ # image serves a checkout with the indexes committed, a Space with a bucket
41
+ # mounted at /data, and a deployment carrying only the eval set.
42
+ SPLIT_SOURCES = {
43
+ "train": ("GEOGUESSER_TASKS_TRAIN", "tasks/train_pano_v3.jsonl"),
44
+ "eval": ("GEOGUESSER_TASKS_EVAL", "tasks/eval_pano_v3.jsonl"),
45
+ }
46
+ DEFAULT_SPLIT = os.getenv("GEOGUESSER_DEFAULT_SPLIT", "")
47
+ EPISODE_MODE = os.getenv("GEOGUESSER_EPISODE_MODE", "agentic")
48
+ REWARD_MODE = os.getenv("GEOGUESSER_REWARD_MODE", "coords")
49
+ MAX_STEPS = int(os.getenv("GEOGUESSER_MAX_STEPS", "24"))
50
+ VIEW_SIZE = int(os.getenv("GEOGUESSER_VIEW_SIZE", "640"))
51
+ HIERARCHICAL = os.getenv("GEOGUESSER_HIERARCHICAL", "0") in {"1", "true", "True"}
52
+ ALLOW_FETCH = os.getenv("GEOGUESSER_ALLOW_FETCH", "1") in {"1", "true", "True"}
53
+ HIRES_ZOOM = os.getenv("GEOGUESSER_HIRES_ZOOM", "1") in {"1", "true", "True"}
54
+ REVEAL_MAP = os.getenv("GEOGUESSER_REVEAL_MAP", "1") in {"1", "true", "True"}
55
+ STREET_DETAIL = os.getenv("GEOGUESSER_STREET_DETAIL", "1") in {"1", "true", "True"}
56
+ MAX_CONCURRENT = int(os.getenv("MAX_CONCURRENT_ENVS", "4"))
57
+
58
+
59
+ try:
60
+ from .render.minimap import set_street_detail
61
+ except ImportError: # running uvicorn from inside envs/geoguesser_env
62
+ from render.minimap import set_street_detail
63
+
64
+ # Deliberately independent of ALLOW_FETCH. That flag governs Mapillary imagery,
65
+ # which a mirrored dataset must never reach for; street detail comes from
66
+ # Overpass and caches to the container's own writable disk, so a fully mirrored
67
+ # deployment can still draw labelled streets. Coupling the two silently gave a
68
+ # Space unlabelled agent maps while a local run had labelled ones.
69
+ set_street_detail(STREET_DETAIL)
70
+
71
+
72
+ def resolve_splits() -> tuple[dict[str, str], str]:
73
+ """
74
+ Work out which named splits this deployment actually serves.
75
+
76
+ A split is offered only when its environment variable is set *and* the file
77
+ exists, because a Space that mounts a bucket read-only should degrade to the
78
+ splits it really has rather than failing to start. When none are configured
79
+ the legacy single `GEOGUESSER_INDEX` becomes one `train` split, so existing
80
+ containers behave exactly as before.
81
+
82
+ Returns:
83
+ `tuple` of:
84
+ - `dict[str, str]`: Split name to index path.
85
+ - `str`: Name of the default split.
86
+ """
87
+ splits: dict[str, str] = {}
88
+ for name, (variable, relative) in SPLIT_SOURCES.items():
89
+ override = os.getenv(variable)
90
+ path = override or str(_ROOT / relative)
91
+ if not pathlib.Path(path).exists():
92
+ # Only complain when someone asked for it explicitly. A missing
93
+ # default just means this checkout has not built that split yet.
94
+ if override:
95
+ logger.warning(
96
+ "%s points at %s, which does not exist; "
97
+ "the %r split will not be offered",
98
+ variable,
99
+ path,
100
+ name,
101
+ )
102
+ continue
103
+ splits[name] = path
104
+
105
+ if not splits:
106
+ return {"train": INDEX_PATH}, "train"
107
+
108
+ default = DEFAULT_SPLIT or ("train" if "train" in splits else next(iter(splits)))
109
+ if default not in splits:
110
+ logger.warning(
111
+ "GEOGUESSER_DEFAULT_SPLIT=%r is not among %s; using %r",
112
+ DEFAULT_SPLIT,
113
+ sorted(splits),
114
+ next(iter(splits)),
115
+ )
116
+ default = next(iter(splits))
117
+ return splits, default
118
+
119
+
120
+ SPLITS, ACTIVE_DEFAULT_SPLIT = resolve_splits()
121
+
122
+
123
+ def create_geoguesser_environment() -> GeoGuesserEnvironment:
124
+ """Factory: a fresh environment per WebSocket session."""
125
+ return GeoGuesserEnvironment(
126
+ splits=SPLITS,
127
+ default_split=ACTIVE_DEFAULT_SPLIT,
128
+ cache_dir=CACHE_DIR,
129
+ episode_mode=EPISODE_MODE,
130
+ max_steps=MAX_STEPS,
131
+ reward_mode=REWARD_MODE,
132
+ hierarchical_reward=HIERARCHICAL,
133
+ view_size=VIEW_SIZE,
134
+ allow_fetch=ALLOW_FETCH,
135
+ hires_zoom=HIRES_ZOOM,
136
+ reveal_map=REVEAL_MAP,
137
+ )
138
+
139
+
140
+ def _build_app():
141
+ """Create the app, attaching the Gradio tab when this openenv supports it."""
142
+ kwargs = dict(
143
+ env_name="geoguesser_env",
144
+ max_concurrent_envs=MAX_CONCURRENT,
145
+ )
146
+ signature = inspect.signature(create_app)
147
+ # Land people on the game, not the raw action form.
148
+ if "custom_tab_primary" in signature.parameters:
149
+ kwargs["custom_tab_primary"] = True
150
+ if "custom_tab_name" in signature.parameters:
151
+ kwargs["custom_tab_name"] = "Try Environment"
152
+ if "default_tab_name" in signature.parameters:
153
+ kwargs["default_tab_name"] = "MCP Playground"
154
+ if "title_override" in signature.parameters:
155
+ kwargs["title_override"] = "Geoguesser Environment"
156
+ if "gradio_builder" in signature.parameters:
157
+ try:
158
+ from .gradio_ui import build_geoguesser_gradio_app
159
+
160
+ kwargs["gradio_builder"] = build_geoguesser_gradio_app
161
+ except Exception as exc: # pragma: no cover - optional UI dependency
162
+ logger.warning("Gradio UI unavailable: %r", exc)
163
+ else:
164
+ logger.warning(
165
+ "Installed openenv does not support gradio_builder; "
166
+ "the GeoGuessr-style play tab will not be available."
167
+ )
168
+ return create_app(
169
+ create_geoguesser_environment,
170
+ GeoGuesserAction,
171
+ GeoGuesserObservation,
172
+ **kwargs,
173
+ )
174
+
175
+
176
+ def _attach_play_routes(application) -> None:
177
+ """Serve panoramas and task metadata to the browser-side viewers.
178
+
179
+ The Pannellum viewer needs the raw equirectangular JPEG, which the agent
180
+ never receives — it only ever sees reprojected views. Ground truth is
181
+ exposed here because these routes exist for a human playing a round in
182
+ their own browser; the agent's observations still withhold it until it
183
+ guesses.
184
+ """
185
+ from fastapi import HTTPException, Query
186
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
187
+
188
+ # One environment, reused for metadata only. Its per-split backends share
189
+ # the process-wide parsed index cache, so this is cheap.
190
+ catalog = create_geoguesser_environment()
191
+
192
+ def _backend(split: str | None):
193
+ """Resolve a split name to its backend, as a 404 rather than a 500."""
194
+ try:
195
+ return catalog._backend_for(split or ACTIVE_DEFAULT_SPLIT)
196
+ except KeyError as exc:
197
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
198
+
199
+ @application.get(
200
+ "/geoguesser/task/{task_index}",
201
+ tags=["geoguesser"],
202
+ response_class=JSONResponse,
203
+ )
204
+ async def geoguesser_task(task_index: int, split: str | None = Query(None)):
205
+ """Metadata for one task, for the play UI."""
206
+ try:
207
+ task = _backend(split).task(task_index)
208
+ except IndexError as exc:
209
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
210
+ frame = task.frames[task.start_frame]
211
+ return JSONResponse(
212
+ {
213
+ "task_index": task.task_index,
214
+ "task_id": task.task_id,
215
+ "lat": frame.lat,
216
+ "lon": frame.lon,
217
+ "country": task.country,
218
+ "compass_angle": frame.compass_angle,
219
+ "captured_at": frame.captured_at,
220
+ "attribution": task.attribution,
221
+ "n_frames": len(task.frames),
222
+ "start_frame": task.start_frame,
223
+ # Per-frame headings only. Coordinates are withheld for frames
224
+ # other than the start, which is the one the guess is scored
225
+ # against and therefore already revealed to a human player.
226
+ "frames": [
227
+ {
228
+ "index": i,
229
+ "compass_angle": f.compass_angle,
230
+ "captured_at": f.captured_at,
231
+ }
232
+ for i, f in enumerate(task.frames)
233
+ ],
234
+ }
235
+ )
236
+
237
+ def _pano_response(task_index: int, frame_index: int | None, split: str | None):
238
+ """Resolve one frame's panorama file, fetching it if necessary."""
239
+ resolved = _backend(split)
240
+ try:
241
+ task = resolved.task(task_index)
242
+ except IndexError as exc:
243
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
244
+ position = task.start_frame if frame_index is None else frame_index
245
+ if not 0 <= position < len(task.frames):
246
+ raise HTTPException(
247
+ status_code=404,
248
+ detail=(
249
+ f"frame {position} out of range for task {task_index} "
250
+ f"with {len(task.frames)} frames"
251
+ ),
252
+ )
253
+ frame = task.frames[position]
254
+ try:
255
+ resolved.load_pano(frame.image_id)
256
+ except Exception as exc:
257
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
258
+ return FileResponse(
259
+ pathlib.Path(CACHE_DIR) / f"{frame.image_id}.jpg",
260
+ media_type="image/jpeg",
261
+ )
262
+
263
+ @application.get(
264
+ "/geoguesser/pano/{task_index}",
265
+ tags=["geoguesser"],
266
+ response_class=FileResponse,
267
+ )
268
+ async def geoguesser_pano(task_index: int, split: str | None = Query(None)):
269
+ """The task's starting panorama, for the browser viewer."""
270
+ return _pano_response(task_index, None, split)
271
+
272
+ @application.get(
273
+ "/geoguesser/pano/{task_index}/{frame_index}",
274
+ tags=["geoguesser"],
275
+ response_class=FileResponse,
276
+ )
277
+ async def geoguesser_pano_frame(
278
+ task_index: int, frame_index: int, split: str | None = Query(None)
279
+ ):
280
+ """One specific frame's panorama, so the viewer can follow `move()`."""
281
+ return _pano_response(task_index, frame_index, split)
282
+
283
+ @application.get(
284
+ "/geoguesser/play", tags=["geoguesser"], response_class=HTMLResponse
285
+ )
286
+ async def geoguesser_play(split: str | None = Query(None)):
287
+ """The standalone play page, also embedded in the Gradio tab."""
288
+ from .gradio_ui import play_page_html
289
+
290
+ return HTMLResponse(play_page_html(catalog.list_splits(), split))
291
+
292
+ @application.get(
293
+ "/geoguesser/tasks", tags=["geoguesser"], response_class=JSONResponse
294
+ )
295
+ async def geoguesser_tasks():
296
+ """Which splits exist and how many tasks each holds."""
297
+ splits = catalog.list_splits()
298
+ return JSONResponse(
299
+ {
300
+ "splits": splits,
301
+ "default_split": ACTIVE_DEFAULT_SPLIT,
302
+ # Kept so an older play page still finds a count.
303
+ "n_tasks": next(
304
+ s["num_tasks"] for s in splits if s["name"] == ACTIVE_DEFAULT_SPLIT
305
+ ),
306
+ }
307
+ )
308
+
309
+
310
+ app = _build_app()
311
+
312
+ try:
313
+ _attach_play_routes(app)
314
+ except Exception as exc: # pragma: no cover - index may be absent in CI
315
+ logger.warning("play routes unavailable: %r", exc)
316
+
317
+
318
+ def main(host: str = "0.0.0.0", port: int = 8000) -> None:
319
+ """
320
+ Entry point for running the server without Docker.
321
+
322
+ Args:
323
+ host (`str`, *optional*, defaults to `"0.0.0.0"`):
324
+ Address to bind.
325
+ port (`int`, *optional*, defaults to `8000`):
326
+ Port to listen on.
327
+ """
328
+ import uvicorn
329
+
330
+ uvicorn.run(app, host=host, port=port)
331
+
332
+
333
+ if __name__ == "__main__":
334
+ import argparse
335
+
336
+ parser = argparse.ArgumentParser()
337
+ parser.add_argument("--port", type=int, default=8000)
338
+ parser.add_argument("--host", default="0.0.0.0")
339
+ args = parser.parse_args()
340
+ main(host=args.host, port=args.port)
geoguesser_env/server/backends/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Imagery backends for the GeoGuesser environment."""
4
+
5
+ from .base import Frame, PanoramaBackend, Task
6
+
7
+ __all__ = ["Frame", "PanoramaBackend", "Task"]
geoguesser_env/server/backends/base.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """The contract every imagery backend implements.
4
+
5
+ Backends differ only in where pixels come from and whether the location can be
6
+ walked. Everything above them — scoring, parsing, the pin loop, the map — is
7
+ shared, so a policy trained against one backend runs unmodified against
8
+ another.
9
+
10
+ A backend that cannot do something reports it through `supports_look` or
11
+ `supports_move`; the environment then simply does not register the
12
+ corresponding tools. Registering a tool that always fails would only teach a
13
+ policy to spend its step budget discovering that.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Protocol, runtime_checkable
20
+
21
+ from PIL import Image
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Frame:
26
+ """One captured position within a sequence.
27
+
28
+ Attributes:
29
+ image_id (`str`):
30
+ Provider-side identifier, also the cache filename.
31
+ lat (`float`):
32
+ Latitude in degrees.
33
+ lon (`float`):
34
+ Longitude in degrees.
35
+ compass_angle (`float`):
36
+ Heading the camera faced, in degrees clockwise from true north.
37
+ captured_at (`str`):
38
+ Capture month as `YYYY-MM`.
39
+ is_pano (`bool`, *optional*, defaults to `True`):
40
+ Whether this frame is a full 360-degree panorama.
41
+ """
42
+
43
+ image_id: str
44
+ lat: float
45
+ lon: float
46
+ compass_angle: float
47
+ captured_at: str
48
+ is_pano: bool = True
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Task:
53
+ """One episode's location, frozen at index build time.
54
+
55
+ Attributes:
56
+ task_index (`int`):
57
+ Position in the task list. Stable, and what `reset` selects on.
58
+ task_id (`str`):
59
+ Human-readable identifier, unique within the index.
60
+ frames (`list[Frame]`):
61
+ Ordered frames of the captured sequence, walkable with `move`.
62
+ start_frame (`int`):
63
+ Index into `frames` where the episode begins.
64
+ country (`str`):
65
+ ISO-3166 alpha-2 code of the true location. Ground truth — never
66
+ placed in an observation before the guess.
67
+ sequence_id (`str`):
68
+ Provider-side sequence identifier.
69
+ provider (`str`, *optional*, defaults to `"mapillary"`):
70
+ Which backend can resolve this task's imagery.
71
+ attribution (`dict`, *optional*):
72
+ Creator credit, required by the CC-BY-SA licence on the imagery.
73
+ meta (`dict`, *optional*):
74
+ Anything else the builder recorded — camera make and model,
75
+ quality score, checksums.
76
+ """
77
+
78
+ task_index: int
79
+ task_id: str
80
+ frames: list[Frame]
81
+ start_frame: int
82
+ country: str
83
+ sequence_id: str
84
+ provider: str = "mapillary"
85
+ attribution: dict[str, Any] = field(default_factory=dict)
86
+ meta: dict[str, Any] = field(default_factory=dict)
87
+
88
+ @property
89
+ def truth(self) -> tuple[float, float]:
90
+ """Ground-truth `(lat, lon)` of the starting frame."""
91
+ frame = self.frames[self.start_frame]
92
+ return frame.lat, frame.lon
93
+
94
+
95
+ @runtime_checkable
96
+ class PanoramaBackend(Protocol):
97
+ """Resolves tasks to imagery and answers what the agent may do."""
98
+
99
+ @property
100
+ def n_tasks(self) -> int:
101
+ """Number of tasks in the frozen index."""
102
+ ...
103
+
104
+ @property
105
+ def supports_look(self) -> bool:
106
+ """Whether views can be rendered at arbitrary headings."""
107
+ ...
108
+
109
+ @property
110
+ def supports_move(self) -> bool:
111
+ """Whether the location can be walked along a sequence."""
112
+ ...
113
+
114
+ def task(self, task_index: int) -> Task:
115
+ """Return the task at `task_index`."""
116
+ ...
117
+
118
+ def render_view(
119
+ self,
120
+ task: Task,
121
+ frame_index: int,
122
+ heading_deg: float,
123
+ pitch_deg: float,
124
+ fov_deg: float,
125
+ ) -> Image.Image:
126
+ """Render what the camera sees from one frame."""
127
+ ...
geoguesser_env/server/backends/panorama.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Mapillary-backed panoramas, read from a frozen index and a local cache.
4
+
5
+ The index (`tasks/pano_v1.jsonl`) is self-contained: it carries every frame's
6
+ coordinates, heading and capture date, so the movement graph resolves with no
7
+ network access at all. Only image *bytes* may need fetching, and only on a
8
+ cache miss, because Mapillary's `thumb_*_url` values are expiring signed CDN
9
+ URLs and cannot be stored in the index.
10
+
11
+ Once a task's frames are cached, episodes are byte-identical on repeat — the
12
+ property a GRPO group depends on.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import pathlib
22
+ import urllib.parse
23
+ import urllib.request
24
+
25
+ from PIL import Image
26
+
27
+ from ..render.pano import look
28
+ from .base import Frame, Task
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ GRAPH_API = "https://graph.mapillary.com"
34
+ FETCH_TIMEOUT_S = 60.0
35
+
36
+ # Zooming into a 2048x1024 panorama is resolution-starved: a 30-degree view
37
+ # samples only ~170 source pixels, so narrowing the field of view barely adds
38
+ # detail (measured mean gradient 6.60 at 90 degrees against 7.03 at 30). The
39
+ # 7680x3840 original roughly doubles it instead (10.23 against 14.87), which is
40
+ # what makes reading a distant sign possible at all.
41
+ #
42
+ # So two derivatives are cached per panorama: the 2048 for wide views, which
43
+ # renders in ~30 ms, and the original for zoomed views at ~70 ms and 3.3 MB.
44
+ THUMB_VARIANT = "thumb_2048_url"
45
+ ORIGINAL_VARIANT = "thumb_original_url"
46
+ HIRES_FOV_DEG = 45.0
47
+
48
+
49
+ class MissingImageError(RuntimeError):
50
+ """A frame is absent from the cache and cannot be fetched."""
51
+
52
+
53
+ _INDEX_CACHE: dict[tuple[str, float, int], list[Task]] = {}
54
+ """Parsed indexes keyed by path, mtime and size, so edits invalidate the entry."""
55
+
56
+
57
+ def load_index(index_path: str | pathlib.Path) -> list[Task]:
58
+ """
59
+ Parse a task index, reusing an already-parsed copy when possible.
60
+
61
+ The Task API routes construct a throwaway environment per request and a
62
+ 5,000-task index is roughly 31 MB, so re-parsing it on every call is not
63
+ affordable. Keying on mtime and size means rebuilding an index in place
64
+ invalidates the entry instead of serving stale tasks.
65
+
66
+ Args:
67
+ index_path (`str` or `pathlib.Path`):
68
+ JSONL index written by `scripts/build_tasks.py`.
69
+
70
+ Returns:
71
+ `list[Task]`: Tasks ordered by `task_index`. The list is shared between
72
+ callers, so treat it as read-only.
73
+
74
+ Raises:
75
+ FileNotFoundError: When the index does not exist.
76
+ ValueError: When the index is empty, or its task indices are not
77
+ contiguous from zero.
78
+ """
79
+ path = pathlib.Path(index_path)
80
+ if not path.exists():
81
+ raise FileNotFoundError(
82
+ f"Task index not found: {path}. Build one with scripts/build_tasks.py."
83
+ )
84
+ stat = path.stat()
85
+ key = (str(path.resolve()), stat.st_mtime, stat.st_size)
86
+ cached = _INDEX_CACHE.get(key)
87
+ if cached is not None:
88
+ return cached
89
+
90
+ tasks: list[Task] = []
91
+ for line in path.read_text().splitlines():
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+ row = json.loads(line)
96
+ frames = [
97
+ Frame(
98
+ image_id=str(f["image_id"]),
99
+ lat=float(f["lat"]),
100
+ lon=float(f["lon"]),
101
+ compass_angle=float(f.get("compass_angle", 0.0)),
102
+ captured_at=str(f.get("captured_at", "")),
103
+ is_pano=bool(f.get("is_pano", True)),
104
+ )
105
+ for f in row["frames"]
106
+ ]
107
+ tasks.append(
108
+ Task(
109
+ task_index=int(row["task_index"]),
110
+ task_id=str(row["task_id"]),
111
+ frames=frames,
112
+ start_frame=int(row.get("start_frame", 0)),
113
+ country=str(row.get("country", "")),
114
+ sequence_id=str(row.get("sequence_id", "")),
115
+ provider=str(row.get("provider", "mapillary")),
116
+ attribution=row.get("attribution", {}),
117
+ meta=row.get("meta", {}),
118
+ )
119
+ )
120
+ if not tasks:
121
+ raise ValueError(f"Task index {path} is empty.")
122
+ tasks.sort(key=lambda t: t.task_index)
123
+ for position, task in enumerate(tasks):
124
+ if task.task_index != position:
125
+ raise ValueError(
126
+ "Task indices must be contiguous from 0; found "
127
+ f"{task.task_index} at position {position}."
128
+ )
129
+ _INDEX_CACHE[key] = tasks
130
+ return tasks
131
+
132
+
133
+ class PanoramaBackend:
134
+ """Serve panoramas from a frozen task index plus a disk cache.
135
+
136
+ Args:
137
+ index_path (`str` or `pathlib.Path`):
138
+ JSONL task index produced by `scripts/build_pano_tasks.py`.
139
+ cache_dir (`str` or `pathlib.Path`):
140
+ Directory holding cached JPEGs, named `<image_id>.jpg`.
141
+ access_token (`str`, *optional*):
142
+ Mapillary token, used only to fill cache misses. When absent, a
143
+ miss raises [`MissingImageError`] instead of reaching the network.
144
+ allow_fetch (`bool`, *optional*, defaults to `True`):
145
+ Set `False` to guarantee an episode never touches the network.
146
+ hires_zoom (`bool`, *optional*, defaults to `True`):
147
+ Render fields of view at or below `hires_fov_deg` from the
148
+ full-resolution original, so zooming actually resolves detail.
149
+ Falls back to the 2048 derivative when no original exists.
150
+ hires_fov_deg (`float`, *optional*, defaults to `45.0`):
151
+ Field of view at or below which the original is used.
152
+ verify_checksums (`bool`, *optional*, defaults to `False`):
153
+ Verify each cached start frame against the sha256 recorded at build
154
+ time. Used by frozen evals to detect drift.
155
+
156
+ Examples:
157
+
158
+ ```python
159
+ backend = PanoramaBackend("tasks/pano_v1.jsonl", "data/panos")
160
+ task = backend.task(0)
161
+ view = backend.render_view(task, task.start_frame, 90.0, 0.0, 90.0)
162
+ ```
163
+ """
164
+
165
+ def __init__(
166
+ self,
167
+ index_path: str | pathlib.Path,
168
+ cache_dir: str | pathlib.Path,
169
+ access_token: str | None = None,
170
+ allow_fetch: bool = True,
171
+ verify_checksums: bool = False,
172
+ hires_zoom: bool = True,
173
+ hires_fov_deg: float = HIRES_FOV_DEG,
174
+ ):
175
+ self._index_path = pathlib.Path(index_path)
176
+ self._cache_dir = pathlib.Path(cache_dir)
177
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
178
+ self._token = access_token or os.environ.get("MAPILLARY_API_KEY")
179
+ self._allow_fetch = allow_fetch
180
+ self._verify_checksums = verify_checksums
181
+ self._hires_zoom = hires_zoom
182
+ self._hires_fov_deg = hires_fov_deg
183
+ self._tasks = self._load_index()
184
+ logger.info(
185
+ "loaded %d tasks from %s (cache: %s, fetch: %s)",
186
+ len(self._tasks),
187
+ self._index_path,
188
+ self._cache_dir,
189
+ "on" if self._allow_fetch and self._token else "off",
190
+ )
191
+
192
+ # -- index ------------------------------------------------------------
193
+
194
+ def _load_index(self) -> list[Task]:
195
+ return load_index(self._index_path)
196
+
197
+ # -- capabilities -----------------------------------------------------
198
+
199
+ @property
200
+ def n_tasks(self) -> int:
201
+ """Number of tasks in the frozen index."""
202
+ return len(self._tasks)
203
+
204
+ @property
205
+ def supports_look(self) -> bool:
206
+ """True — every task in this index is a 360-degree panorama."""
207
+ return True
208
+
209
+ @property
210
+ def supports_move(self) -> bool:
211
+ """Whether any task has more than one frame to walk between."""
212
+ return any(len(t.frames) > 1 for t in self._tasks)
213
+
214
+ def task(self, task_index: int) -> Task:
215
+ """
216
+ Return the task at `task_index`.
217
+
218
+ Args:
219
+ task_index (`int`):
220
+ Position in the frozen index.
221
+
222
+ Returns:
223
+ [`Task`]: The task, including its full frame list.
224
+ """
225
+ if not 0 <= task_index < len(self._tasks):
226
+ raise IndexError(
227
+ f"task_index {task_index} out of range for {len(self._tasks)} tasks."
228
+ )
229
+ return self._tasks[task_index]
230
+
231
+ # -- imagery ----------------------------------------------------------
232
+
233
+ def _cache_path(self, image_id: str, hires: bool = False) -> pathlib.Path:
234
+ suffix = ".orig.jpg" if hires else ".jpg"
235
+ return self._cache_dir / f"{image_id}{suffix}"
236
+
237
+ def _fetch(self, image_id: str, hires: bool = False) -> bytes:
238
+ if not self._allow_fetch:
239
+ raise MissingImageError(
240
+ f"Image {image_id} is not cached and fetching is disabled. "
241
+ "Warm the cache with scripts/build_pano_tasks.py --prefetch-frames."
242
+ )
243
+ if not self._token:
244
+ raise MissingImageError(
245
+ f"Image {image_id} is not cached and MAPILLARY_API_KEY is unset, "
246
+ "so it cannot be fetched."
247
+ )
248
+ variant = ORIGINAL_VARIANT if hires else THUMB_VARIANT
249
+ meta_url = f"{GRAPH_API}/{image_id}?" + urllib.parse.urlencode(
250
+ {"fields": variant, "access_token": self._token}
251
+ )
252
+ with urllib.request.urlopen(meta_url, timeout=FETCH_TIMEOUT_S) as response:
253
+ thumb_url = json.loads(response.read()).get(variant)
254
+ if not thumb_url:
255
+ raise MissingImageError(
256
+ f"Mapillary returned no {variant} for {image_id}; its "
257
+ "derivatives may have been removed."
258
+ )
259
+ with urllib.request.urlopen(thumb_url, timeout=FETCH_TIMEOUT_S) as response:
260
+ return response.read()
261
+
262
+ def load_pano(
263
+ self,
264
+ image_id: str,
265
+ expected_sha256: str | None = None,
266
+ hires: bool = False,
267
+ ) -> Image.Image:
268
+ """
269
+ Return a panorama, fetching and caching it if necessary.
270
+
271
+ Args:
272
+ image_id (`str`):
273
+ Provider-side image identifier.
274
+ expected_sha256 (`str`, *optional*):
275
+ Checksum recorded at build time. Verified only when the backend
276
+ was constructed with `verify_checksums=True`, and only for
277
+ the 2048 derivative, which is what the index records.
278
+ hires (`bool`, *optional*, defaults to `False`):
279
+ Load the full-resolution original instead of the 2048
280
+ derivative.
281
+
282
+ Returns:
283
+ `PIL.Image.Image`: The equirectangular panorama.
284
+ """
285
+ path = self._cache_path(image_id, hires=hires)
286
+ if not path.exists():
287
+ payload = self._fetch(image_id, hires=hires)
288
+ path.write_bytes(payload)
289
+ logger.info(
290
+ "cached %s%s (%.0f KB)",
291
+ image_id,
292
+ " at full resolution" if hires else "",
293
+ len(payload) / 1024,
294
+ )
295
+ if self._verify_checksums and expected_sha256 and not hires:
296
+ actual = hashlib.sha256(path.read_bytes()).hexdigest()
297
+ if actual != expected_sha256:
298
+ raise MissingImageError(
299
+ f"Checksum mismatch for {image_id}: index recorded "
300
+ f"{expected_sha256[:12]}, cache holds {actual[:12]}. The "
301
+ "upstream image changed; this task is no longer comparable."
302
+ )
303
+ return Image.open(path)
304
+
305
+ def render_view(
306
+ self,
307
+ task: Task,
308
+ frame_index: int,
309
+ heading_deg: float,
310
+ pitch_deg: float = 0.0,
311
+ fov_deg: float = 90.0,
312
+ ) -> Image.Image:
313
+ """
314
+ Render what the camera sees from one frame of a task.
315
+
316
+ Headings are absolute: `0` is true north, obtained by offsetting the
317
+ request by the frame's own `compass_angle`. That keeps `look(0)`
318
+ meaning the same thing in every task.
319
+
320
+ Args:
321
+ task ([`Task`]):
322
+ The task being played.
323
+ frame_index (`int`):
324
+ Which frame of the sequence the agent stands on.
325
+ heading_deg (`float`):
326
+ Absolute compass heading in degrees.
327
+ pitch_deg (`float`, *optional*, defaults to `0.0`):
328
+ Vertical angle in degrees.
329
+ fov_deg (`float`, *optional*, defaults to `90.0`):
330
+ Horizontal field of view in degrees.
331
+
332
+ Returns:
333
+ `PIL.Image.Image`: The rendered view.
334
+ """
335
+ frame = task.frames[frame_index]
336
+ checksums = task.meta.get("sha256", {})
337
+ want_hires = self._hires_zoom and fov_deg <= self._hires_fov_deg
338
+ try:
339
+ pano = self.load_pano(
340
+ frame.image_id, checksums.get(frame.image_id), hires=want_hires
341
+ )
342
+ except MissingImageError:
343
+ if not want_hires:
344
+ raise
345
+ # A missing original must not end an episode; a soft view beats a
346
+ # failed step.
347
+ logger.warning(
348
+ "no full-resolution original for %s; zooming on the 2048 "
349
+ "derivative instead",
350
+ frame.image_id,
351
+ )
352
+ pano = self.load_pano(frame.image_id, checksums.get(frame.image_id))
353
+ return look(pano, heading_deg + frame.compass_angle, pitch_deg, fov_deg)
354
+
355
+ # -- navigation -------------------------------------------------------
356
+
357
+ def step_along(
358
+ self, task: Task, frame_index: int, direction: str, meters: float
359
+ ) -> tuple[int, float]:
360
+ """
361
+ Walk the sequence and report where the agent actually ended up.
362
+
363
+ Frame spacing is irregular — measured around 3.3 m on Mapillary
364
+ sequences — so the requested distance is consumed frame by frame and
365
+ the realised distance is returned rather than assumed.
366
+
367
+ Args:
368
+ task ([`Task`]):
369
+ The task being played.
370
+ frame_index (`int`):
371
+ Current position in `task.frames`.
372
+ direction (`str`):
373
+ `"forward"` or `"backward"`.
374
+ meters (`float`):
375
+ Requested distance in metres.
376
+
377
+ Returns:
378
+ `tuple[int, float]` with:
379
+ - the new frame index, unchanged at a dead end
380
+ - metres actually travelled
381
+ """
382
+ from ..scoring import haversine_km
383
+
384
+ step = 1 if direction == "forward" else -1
385
+ current = frame_index
386
+ travelled = 0.0
387
+ while travelled < meters:
388
+ nxt = current + step
389
+ if not 0 <= nxt < len(task.frames):
390
+ break
391
+ a, b = task.frames[current], task.frames[nxt]
392
+ travelled += haversine_km(a.lat, a.lon, b.lat, b.lon) * 1000.0
393
+ current = nxt
394
+ return current, travelled
395
+
396
+ def can_move(self, task: Task, frame_index: int, direction: str) -> bool:
397
+ """Whether a frame exists in `direction` from the current position."""
398
+ step = 1 if direction == "forward" else -1
399
+ return 0 <= frame_index + step < len(task.frames)
geoguesser_env/server/geoguesser_environment.py ADDED
@@ -0,0 +1,883 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """The GeoGuesser environment.
4
+
5
+ Exposes its tools over MCP so an agent can look around, walk, and check
6
+ candidate coordinates on a map before committing to a guess. Non-MCP
7
+ structured actions route through `_step_impl`, which is what makes single-shot
8
+ GRPO and the agentic loop the same environment rather than two.
9
+
10
+ Two rules are load-bearing:
11
+
12
+ - Pin feedback describes only where the agent pointed. Any signal about the
13
+ target would make binary search optimal, and the benchmark would measure
14
+ bisection instead of geography.
15
+ - Tools the backend cannot serve are never registered, rather than registered
16
+ and failing, so a policy does not learn to spend steps on dead ends.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import random
23
+ import uuid
24
+ from typing import Any
25
+
26
+ from fastmcp import FastMCP
27
+ from openenv.core.env_server.mcp_environment import MCPEnvironment
28
+ from openenv.core.env_server.types import Action, Observation
29
+
30
+ from ..models import (
31
+ EpisodeMode,
32
+ from_wire,
33
+ GeoGuesserAction,
34
+ GeoGuesserObservation,
35
+ GeoGuesserState,
36
+ GuessAction,
37
+ LookAction,
38
+ MeasureAction,
39
+ MoveAction,
40
+ PanAction,
41
+ Pin,
42
+ PinAction,
43
+ RewardMode,
44
+ TypedAction,
45
+ ViewMapAction,
46
+ ZoomAction,
47
+ )
48
+ from .backends.base import Task
49
+ from .backends.panorama import PanoramaBackend
50
+ from .parser import parse_guess
51
+ from .render.minimap import describe_pin, locate, render_map
52
+ from .render.pano import to_base64
53
+ from .scoring import action_cost, compute_reward, haversine_km, verdict
54
+
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ PROMPT = (
59
+ "You are dropped at an unknown street-level location somewhere in the "
60
+ "world. Work out where you are.\n\n"
61
+ "Available tools: {tools}\n\n"
62
+ "Looking around and checking the map cost a little reward each, so gather "
63
+ "the evidence you need and then commit. You have {steps} actions. "
64
+ "Placing a pin shows you where on the map that coordinate falls - it "
65
+ "tells you nothing about whether you are right. Finish with "
66
+ "submit_guess."
67
+ )
68
+
69
+
70
+ class UnknownSplitError(KeyError, IndexError):
71
+ """A split name that is not configured.
72
+
73
+ Inherits both exception types deliberately. `KeyError` is what a Python
74
+ caller expects from a bad name, while the core Task API dispatcher maps
75
+ only `NotImplementedError` and `IndexError` onto HTTP status codes -- so
76
+ without `IndexError` an unknown split surfaces as a 500 instead of a 400.
77
+ """
78
+
79
+
80
+ class GeoGuesserEnvironment(MCPEnvironment):
81
+ """A GeoGuessr-style geolocation episode.
82
+
83
+ Args:
84
+ index_path (`str`):
85
+ JSONL task index built by `scripts/build_pano_tasks.py`.
86
+ cache_dir (`str`):
87
+ Directory of cached panorama JPEGs.
88
+ episode_mode (`str`, *optional*, defaults to `"agentic"`):
89
+ One of `"agentic"`, `"single_shot"` or `"nmpz"`.
90
+ max_steps (`int`, *optional*, defaults to `24`):
91
+ Actions allowed before the episode is cut off. Twelve is tight
92
+ for an agentic episode: looking in four directions and walking
93
+ a block spends most of it before any reasoning about the map.
94
+ reward_mode (`str`, *optional*, defaults to `"coords"`):
95
+ `"coords"` scores distance; `"country_only"` scores the country.
96
+ hierarchical_reward (`bool`, *optional*, defaults to `False`):
97
+ Add country and region partial credit to the distance score.
98
+ view_size (`int`, *optional*, defaults to `640`):
99
+ Edge length in pixels of rendered views.
100
+ allow_fetch (`bool`, *optional*, defaults to `True`):
101
+ Whether a cache miss may reach the Mapillary API.
102
+ hires_zoom (`bool`, *optional*, defaults to `True`):
103
+ Render zoomed views from the full-resolution original, so a narrow
104
+ field of view actually resolves detail such as distant signage.
105
+ reveal_map (`bool`, *optional*, defaults to `True`):
106
+ Draw a map of the guess against the truth on the terminal
107
+ observation. Costs about 280 ms, which is the single largest cost in
108
+ an episode, and a training run does not read it — the reward and the
109
+ distance are in the observation either way. Leave it on for evals,
110
+ demos and traces; turn it off for throughput.
111
+
112
+ Examples:
113
+
114
+ ```python
115
+ env = GeoGuesserEnvironment("tasks/pano_v1.jsonl", "data/panos")
116
+ observation = env.reset(task_index=0)
117
+ result = env.step(LookAction(heading_deg=90))
118
+ ```
119
+ """
120
+
121
+ SUPPORTS_CONCURRENT_SESSIONS = True
122
+
123
+ def __init__(
124
+ self,
125
+ index_path: str | None = None,
126
+ cache_dir: str = "",
127
+ episode_mode: str = EpisodeMode.AGENTIC.value,
128
+ max_steps: int = 24,
129
+ reward_mode: str = RewardMode.COORDS.value,
130
+ hierarchical_reward: bool = False,
131
+ view_size: int = 640,
132
+ allow_fetch: bool = True,
133
+ hires_zoom: bool = True,
134
+ reveal_map: bool = True,
135
+ splits: dict[str, str] | None = None,
136
+ default_split: str = "train",
137
+ ):
138
+ if splits:
139
+ self._split_paths = {name: str(path) for name, path in splits.items()}
140
+ elif index_path:
141
+ # Single-index callers keep working: one nameless index becomes the
142
+ # default split, so existing tests, harness runs and the play UI
143
+ # need no change.
144
+ self._split_paths = {default_split: str(index_path)}
145
+ else:
146
+ raise ValueError("Provide either splits= or index_path=.")
147
+ if default_split not in self._split_paths:
148
+ raise ValueError(
149
+ f"default_split {default_split!r} is not one of "
150
+ f"{sorted(self._split_paths)}."
151
+ )
152
+ self._default_split = default_split
153
+ self._cache_dir = cache_dir
154
+ self._allow_fetch = allow_fetch
155
+ self._hires_zoom = hires_zoom
156
+ self._backends: dict[str, PanoramaBackend] = {}
157
+ self._split = default_split
158
+ self._backend = self._backend_for(default_split)
159
+ self._reveal_map = reveal_map
160
+ self._mode = EpisodeMode(episode_mode)
161
+ self._max_steps = max_steps
162
+ self._reward_mode = RewardMode(reward_mode)
163
+ self._hierarchical = hierarchical_reward
164
+ self._view_size = (view_size, view_size)
165
+ self._state = GeoGuesserState()
166
+ self._task = None
167
+ self._rng = random.Random()
168
+
169
+ mcp = FastMCP("geoguesser_env")
170
+ self._register_tools(mcp)
171
+ super().__init__(mcp)
172
+
173
+ # -- splits ------------------------------------------------------------
174
+
175
+ def _backend_for(self, split: str) -> PanoramaBackend:
176
+ """
177
+ Return the backend serving one split, building it on first use.
178
+
179
+ Splits are built lazily so a deployment that only mounts the eval index
180
+ is not forced to carry a training one, and because the parsed index is
181
+ cached process-wide anyway.
182
+
183
+ Args:
184
+ split (`str`):
185
+ Split name.
186
+
187
+ Returns:
188
+ [`PanoramaBackend`]: Backend for that split.
189
+
190
+ Raises:
191
+ UnknownSplitError: When the split is not configured.
192
+ """
193
+ if split not in self._split_paths:
194
+ raise UnknownSplitError(
195
+ f"Unknown split {split!r}. Available: {sorted(self._split_paths)}."
196
+ )
197
+ backend = self._backends.get(split)
198
+ if backend is None:
199
+ backend = PanoramaBackend(
200
+ self._split_paths[split],
201
+ self._cache_dir,
202
+ allow_fetch=self._allow_fetch,
203
+ hires_zoom=self._hires_zoom,
204
+ )
205
+ self._backends[split] = backend
206
+ return backend
207
+
208
+ @staticmethod
209
+ def _split_type(split: str) -> str:
210
+ """
211
+ Map a split name onto the type vocabulary the core Task API knows.
212
+
213
+ Core normalises anything outside `{train, validation, test}` to
214
+ `validation`, so `eval` is declared as `test` explicitly rather than
215
+ being silently downgraded.
216
+ """
217
+ if split == "train":
218
+ return "train"
219
+ if split in {"eval", "test"}:
220
+ return "test"
221
+ return "validation"
222
+
223
+ def _task_spec(self, split: str, task: Task) -> dict[str, Any]:
224
+ """
225
+ Describe one task for the Task API.
226
+
227
+ Deliberately truth-free: no coordinates and no country. Task specs
228
+ travel to whatever orchestrates training, and the moment a label sits
229
+ in a spec someone can build a prompt from it. The true location is
230
+ revealed in the observation metadata after the guess, which is the one
231
+ place it belongs.
232
+ """
233
+ return {
234
+ "task_index": task.task_index,
235
+ "task_id": task.task_id,
236
+ "split": split,
237
+ "n_frames": len(task.frames),
238
+ "provider": task.provider,
239
+ "sequence_id": task.sequence_id,
240
+ "offline_ready": bool(task.meta.get("offline_ready", False)),
241
+ }
242
+
243
+ def list_splits(self) -> list[dict[str, Any]]:
244
+ """
245
+ Task API: describe every configured split.
246
+
247
+ Returns:
248
+ `list[dict]` with keys:
249
+ - `name` (`str`):
250
+ Split name, as accepted by `reset(split=)`.
251
+ - `type` (`str`):
252
+ One of `train`, `test` or `validation`.
253
+ - `num_tasks` (`int`):
254
+ Task count in the split.
255
+ - `default` (`bool`):
256
+ Whether `reset()` uses this split when none is given.
257
+ """
258
+ return [
259
+ {
260
+ "name": name,
261
+ "type": self._split_type(name),
262
+ "num_tasks": self._backend_for(name).n_tasks,
263
+ "default": name == self._default_split,
264
+ }
265
+ for name in self._split_paths
266
+ ]
267
+
268
+ def num_tasks(self, split: str) -> int:
269
+ """Task API: how many tasks a split holds."""
270
+ return self._backend_for(split).n_tasks
271
+
272
+ def get_task(self, split: str, index: int) -> dict[str, Any]:
273
+ """Task API: describe one task by split and index."""
274
+ return self._task_spec(split, self._backend_for(split).task(index))
275
+
276
+ def list_tasks(self, split: str) -> list[dict[str, Any]]:
277
+ """Task API: describe every task in a split."""
278
+ backend = self._backend_for(split)
279
+ return [
280
+ self._task_spec(split, backend.task(index))
281
+ for index in range(backend.n_tasks)
282
+ ]
283
+
284
+ def get_task_range(
285
+ self, split: str, start: int | None = None, stop: int | None = None
286
+ ) -> list[dict[str, Any]]:
287
+ """Task API: describe a slice-style range of tasks in a split."""
288
+ backend = self._backend_for(split)
289
+ indices = range(*slice(start, stop).indices(backend.n_tasks))
290
+ return [self._task_spec(split, backend.task(index)) for index in indices]
291
+
292
+ # -- capability-aware tool registration --------------------------------
293
+
294
+ def _navigational(self) -> bool:
295
+ return self._mode is EpisodeMode.AGENTIC and self._backend.supports_move
296
+
297
+ def _can_look(self) -> bool:
298
+ return self._mode is EpisodeMode.AGENTIC and self._backend.supports_look
299
+
300
+ def _can_gather(self) -> bool:
301
+ """Whether the episode has an investigation phase at all.
302
+
303
+ `single_shot` deliberately has none: one view, one guess, which is the
304
+ shape a VLM GRPO run wants. `nmpz` keeps the map but takes the camera
305
+ away, mirroring the game's own hardest mode.
306
+ """
307
+ return self._mode is not EpisodeMode.SINGLE_SHOT
308
+
309
+ def _register_tools(self, mcp: FastMCP) -> None:
310
+ """Register only the tools this configuration can actually serve."""
311
+ if self._can_look():
312
+
313
+ @mcp.tool
314
+ def look(
315
+ heading_deg: float, pitch_deg: float = 0.0, fov_deg: float = 90.0
316
+ ) -> str:
317
+ """Look in a direction. heading_deg is absolute, 0 = true north.
318
+
319
+ Args:
320
+ heading_deg: Compass heading in degrees.
321
+ pitch_deg: Vertical angle; positive looks up.
322
+ fov_deg: Field of view; smaller values zoom in.
323
+ """
324
+ return self._apply(
325
+ LookAction(
326
+ heading_deg=heading_deg, pitch_deg=pitch_deg, fov_deg=fov_deg
327
+ )
328
+ ).feedback
329
+
330
+ @mcp.tool
331
+ def pan(delta_deg: float) -> str:
332
+ """Turn relative to the current heading; positive turns right.
333
+
334
+ Args:
335
+ delta_deg: Degrees to turn.
336
+ """
337
+ return self._apply(PanAction(delta_deg=delta_deg)).feedback
338
+
339
+ @mcp.tool
340
+ def zoom(fov_deg: float) -> str:
341
+ """Change field of view without turning. 30 reads distant signs.
342
+
343
+ Args:
344
+ fov_deg: New field of view in degrees.
345
+ """
346
+ return self._apply(ZoomAction(fov_deg=fov_deg)).feedback
347
+
348
+ if self._navigational():
349
+
350
+ @mcp.tool
351
+ def move(direction: str, meters: float = 10.0) -> str:
352
+ """Walk along the road. Reports how far you actually travelled.
353
+
354
+ Args:
355
+ direction: Either "forward" or "backward".
356
+ meters: Requested distance in metres.
357
+ """
358
+ return self._apply(
359
+ MoveAction(direction=direction, meters=meters)
360
+ ).feedback
361
+
362
+ if self._can_gather():
363
+ self._register_map_tools(mcp)
364
+ self._register_guess_tool(mcp)
365
+
366
+ def _register_map_tools(self, mcp: FastMCP) -> None:
367
+ """Register the map and pin tools, which every gathering mode has."""
368
+
369
+ @mcp.tool
370
+ def place_pin(
371
+ lat: float, lon: float, label: str = "", span_deg: float = 7.0
372
+ ) -> str:
373
+ """Pin a candidate and see where it falls on the map.
374
+
375
+ Tells you what is at that coordinate. Says nothing about whether
376
+ you are right.
377
+
378
+ Args:
379
+ lat: Latitude of the candidate.
380
+ lon: Longitude of the candidate.
381
+ label: Optional note.
382
+ span_deg: Half-width of the map window in degrees. Below about
383
+ 4 the map adds roads, urban areas and town names, which is
384
+ how you aim within a city rather than at its centre.
385
+ """
386
+ return self._apply(
387
+ PinAction(lat=lat, lon=lon, label=label or None, span_deg=span_deg)
388
+ ).feedback
389
+
390
+ @mcp.tool
391
+ def view_map(lat: float, lon: float, span_deg: float = 7.0) -> str:
392
+ """Pan and zoom the map without placing a pin.
393
+
394
+ Args:
395
+ lat: Latitude at the centre of the view.
396
+ lon: Longitude at the centre of the view.
397
+ span_deg: Half-width of the window in degrees.
398
+ """
399
+ return self._apply(
400
+ ViewMapAction(lat=lat, lon=lon, span_deg=span_deg)
401
+ ).feedback
402
+
403
+ @mcp.tool
404
+ def list_pins() -> str:
405
+ """List the candidates pinned so far. Free."""
406
+ if not self._state.pins:
407
+ return "No pins placed yet."
408
+ return "\n".join(
409
+ f"{i}. {p['lat']:.4f}, {p['lon']:.4f} - {p['description']}"
410
+ for i, p in enumerate(self._state.pins, 1)
411
+ )
412
+
413
+ @mcp.tool
414
+ def clear_pins() -> str:
415
+ """Remove all pins. Free."""
416
+ self._state.pins = []
417
+ return "Pins cleared."
418
+
419
+ @mcp.tool
420
+ def measure(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> str:
421
+ """Distance in km between two coordinates of your own choosing. Free.
422
+
423
+ Args:
424
+ lat_a: Latitude of the first point.
425
+ lon_a: Longitude of the first point.
426
+ lat_b: Latitude of the second point.
427
+ lon_b: Longitude of the second point.
428
+ """
429
+ km = haversine_km(lat_a, lon_a, lat_b, lon_b)
430
+ return f"{km:.0f} km between those two points."
431
+
432
+ @mcp.tool
433
+ def reverse_geocode(lat: float, lon: float) -> str:
434
+ """Name the country and nearest city at a coordinate. Free.
435
+
436
+ Args:
437
+ lat: Latitude in degrees.
438
+ lon: Longitude in degrees.
439
+ """
440
+ place = locate(lat, lon)
441
+ where = place.country or "open water"
442
+ return (
443
+ f"{lat:.4f}, {lon:.4f} is in {where}. Nearest major city: "
444
+ f"{place.nearest_city}, ~{place.city_distance_km:.0f} km "
445
+ f"{place.city_bearing}."
446
+ )
447
+
448
+ def _register_guess_tool(self, mcp: FastMCP) -> None:
449
+ """Register the terminal action, which every mode has."""
450
+
451
+ @mcp.tool
452
+ def submit_guess(
453
+ lat: float,
454
+ lon: float,
455
+ country: str = "",
456
+ confidence: float = -1.0,
457
+ reasoning: str = "",
458
+ ) -> str:
459
+ """Commit your final answer. Ends the episode.
460
+
461
+ Args:
462
+ lat: Latitude of your guess.
463
+ lon: Longitude of your guess.
464
+ country: Optional ISO-3166 alpha-2 code or country name.
465
+ confidence: Optional self-reported confidence in [0, 1].
466
+ reasoning: Optional rationale, recorded but not scored.
467
+ """
468
+ observation = self._apply(
469
+ GuessAction(
470
+ lat=lat,
471
+ lon=lon,
472
+ country=country or None,
473
+ confidence=None if confidence < 0 else confidence,
474
+ reasoning=reasoning or None,
475
+ )
476
+ )
477
+ return observation.feedback
478
+
479
+ # -- lifecycle ---------------------------------------------------------
480
+
481
+ def reset(
482
+ self,
483
+ seed: int | None = None,
484
+ episode_id: str | None = None,
485
+ split: str | None = None,
486
+ index: int | None = None,
487
+ task_index: int | None = None,
488
+ **kwargs: Any,
489
+ ) -> GeoGuesserObservation:
490
+ """
491
+ Start an episode.
492
+
493
+ Selection is explicit, because training and demoing want opposite
494
+ things. `index` picks one exact task and is byte-identical on repeat,
495
+ which is what a GRPO group needs. `seed` picks `tasks[seed % n_tasks]`.
496
+ Neither means a random task; omitting both does, and the chosen split
497
+ and index are always reported in the observation metadata so a random
498
+ episode stays replayable.
499
+
500
+ Args:
501
+ seed (`int`, *optional*):
502
+ Deterministic selector, `tasks[seed % n_tasks]`.
503
+ episode_id (`str`, *optional*):
504
+ Caller-supplied episode identifier.
505
+ split (`str`, *optional*):
506
+ Which split to draw from. Defaults to the environment's default
507
+ split.
508
+ index (`int`, *optional*):
509
+ Exact task to play, within `split`. Takes precedence over
510
+ `seed`.
511
+ task_index (`int`, *optional*):
512
+ Deprecated alias for `index`, kept so existing callers and
513
+ saved trajectories keep working.
514
+
515
+ Returns:
516
+ [`GeoGuesserObservation`]: The opening view and prompt.
517
+
518
+ Raises:
519
+ UnknownSplitError: When `split` is not configured.
520
+ """
521
+ self._split = split or self._default_split
522
+ self._backend = self._backend_for(self._split)
523
+
524
+ if index is None:
525
+ index = task_index
526
+ n = self._backend.n_tasks
527
+ if index is not None:
528
+ chosen = int(index) % n
529
+ elif seed is not None:
530
+ chosen = int(seed) % n
531
+ else:
532
+ chosen = self._rng.randrange(n)
533
+
534
+ self._task = self._backend.task(chosen)
535
+ start = self._task.frames[self._task.start_frame]
536
+ self._state = GeoGuesserState(
537
+ episode_id=episode_id or str(uuid.uuid4()),
538
+ task_index=chosen,
539
+ task_id=self._task.task_id,
540
+ frame_index=self._task.start_frame,
541
+ heading_deg=0.0,
542
+ pitch_deg=0.0,
543
+ fov_deg=90.0,
544
+ )
545
+
546
+ observation = self._render_view_observation()
547
+ observation.prompt = PROMPT.format(
548
+ tools=", ".join(self._tool_names()), steps=self._max_steps
549
+ )
550
+ observation.feedback = "Episode started."
551
+ observation.captured_at = start.captured_at
552
+ observation.metadata = self._metadata()
553
+ return observation
554
+
555
+ @property
556
+ def state(self) -> GeoGuesserState:
557
+ """Current internal state."""
558
+ return self._state
559
+
560
+ def _step_impl(self, action: Action, **kwargs: Any) -> Observation:
561
+ """Handle structured, non-MCP actions.
562
+
563
+ Accepts either the flat wire action the HTTP layer delivers or a typed
564
+ action constructed in process, so tests and the harness can bypass
565
+ serialisation without a second code path.
566
+ """
567
+ if isinstance(action, GeoGuesserAction):
568
+ return self._apply(from_wire(action))
569
+ if isinstance(action, TypedAction):
570
+ return self._apply(action)
571
+ raise TypeError(f"Unsupported action type: {type(action).__name__}")
572
+
573
+ # -- the actual mechanics ---------------------------------------------
574
+
575
+ def _tool_names(self) -> list[str]:
576
+ """Names of the tools this configuration actually registered."""
577
+ names: list[str] = []
578
+ if self._can_look():
579
+ names += ["look", "pan", "zoom"]
580
+ if self._navigational():
581
+ names.append("move")
582
+ if self._can_gather():
583
+ names += [
584
+ "place_pin",
585
+ "view_map",
586
+ "list_pins",
587
+ "clear_pins",
588
+ "measure",
589
+ "reverse_geocode",
590
+ ]
591
+ names.append("submit_guess")
592
+ return names
593
+
594
+ def _steps_used(self) -> int:
595
+ s = self._state
596
+ return s.n_looks + s.n_maps + s.n_pins + s.n_moves
597
+
598
+ def _steps_remaining(self) -> int:
599
+ return max(0, self._max_steps - self._steps_used())
600
+
601
+ def _cost(self) -> float:
602
+ s = self._state
603
+ return action_cost(
604
+ n_looks=s.n_looks, n_maps=s.n_maps, n_pins=s.n_pins, n_moves=s.n_moves
605
+ )
606
+
607
+ def _metadata(self) -> dict[str, Any]:
608
+ return {
609
+ "split": self._split,
610
+ "task_index": self._state.task_index,
611
+ "task_id": self._state.task_id,
612
+ "backend": "mapillary",
613
+ "episode_mode": self._mode.value,
614
+ "frame_index": self._state.frame_index,
615
+ "captured_at": self._task.frames[self._state.frame_index].captured_at,
616
+ "attribution": self._task.attribution,
617
+ }
618
+
619
+ def _base_observation(self) -> GeoGuesserObservation:
620
+ s = self._state
621
+ return GeoGuesserObservation(
622
+ heading_deg=s.heading_deg % 360,
623
+ pitch_deg=s.pitch_deg,
624
+ fov_deg=s.fov_deg,
625
+ total_moved_meters=s.total_moved_meters,
626
+ can_move_forward=self._navigational()
627
+ and self._backend.can_move(self._task, s.frame_index, "forward"),
628
+ can_move_backward=self._navigational()
629
+ and self._backend.can_move(self._task, s.frame_index, "backward"),
630
+ available_tools=self._tool_names(),
631
+ steps_remaining=self._steps_remaining(),
632
+ action_cost=self._cost(),
633
+ pins=[Pin(**p) for p in s.pins],
634
+ captured_at=self._task.frames[s.frame_index].captured_at,
635
+ metadata=self._metadata(),
636
+ )
637
+
638
+ def _render_view_observation(self) -> GeoGuesserObservation:
639
+ s = self._state
640
+ view = self._backend.render_view(
641
+ self._task, s.frame_index, s.heading_deg, s.pitch_deg, s.fov_deg
642
+ )
643
+ if view.size != self._view_size:
644
+ view = view.resize(self._view_size)
645
+ observation = self._base_observation()
646
+ observation.image_base64 = to_base64(view, "JPEG")
647
+ observation.image_kind = "view"
648
+ return observation
649
+
650
+ def _render_map_observation(
651
+ self,
652
+ pins: list[tuple[float, float]],
653
+ focus,
654
+ span: float,
655
+ truth: tuple[float, float] | None = None,
656
+ ) -> GeoGuesserObservation:
657
+ image = render_map(pins, focus=focus, span_deg=span, truth=truth)
658
+ observation = self._base_observation()
659
+ observation.image_base64 = to_base64(image, "PNG")
660
+ observation.image_kind = "map"
661
+ return observation
662
+
663
+ def _apply(self, action: Action) -> GeoGuesserObservation:
664
+ """Execute one action and produce the resulting observation."""
665
+ if self._task is None:
666
+ raise RuntimeError("reset() must be called before step().")
667
+
668
+ s = self._state
669
+ s.step_count += 1
670
+
671
+ if s.submitted:
672
+ observation = self._base_observation()
673
+ observation.done = True
674
+ observation.feedback = "The episode is over; the guess was already made."
675
+ return observation
676
+
677
+ if isinstance(action, GuessAction):
678
+ return self._finish(action)
679
+
680
+ if self._steps_remaining() <= 0:
681
+ observation = self._base_observation()
682
+ observation.feedback = (
683
+ "Out of actions. Call submit_guess with your best estimate."
684
+ )
685
+ return observation
686
+
687
+ if isinstance(action, LookAction):
688
+ s.heading_deg = action.heading_deg
689
+ s.pitch_deg = action.pitch_deg
690
+ s.fov_deg = action.fov_deg
691
+ s.n_looks += 1
692
+ observation = self._render_view_observation()
693
+ observation.feedback = (
694
+ f"Facing {s.heading_deg % 360:.0f} deg, {s.fov_deg:.0f} deg field of "
695
+ f"view. {self._steps_remaining()} actions left."
696
+ )
697
+ return observation
698
+
699
+ if isinstance(action, PanAction):
700
+ s.heading_deg = (s.heading_deg + action.delta_deg) % 360
701
+ s.n_looks += 1
702
+ observation = self._render_view_observation()
703
+ observation.feedback = (
704
+ f"Turned to {s.heading_deg:.0f} deg. "
705
+ f"{self._steps_remaining()} actions left."
706
+ )
707
+ return observation
708
+
709
+ if isinstance(action, ZoomAction):
710
+ s.fov_deg = action.fov_deg
711
+ s.n_looks += 1
712
+ observation = self._render_view_observation()
713
+ observation.feedback = (
714
+ f"Field of view now {s.fov_deg:.0f} deg. "
715
+ f"{self._steps_remaining()} actions left."
716
+ )
717
+ return observation
718
+
719
+ if isinstance(action, MoveAction):
720
+ new_index, travelled = self._backend.step_along(
721
+ self._task, s.frame_index, action.direction, action.meters
722
+ )
723
+ s.n_moves += 1
724
+ if new_index == s.frame_index:
725
+ observation = self._render_view_observation()
726
+ observation.feedback = (
727
+ f"Cannot go {action.direction} - the captured road ends here. "
728
+ f"{self._steps_remaining()} actions left."
729
+ )
730
+ return observation
731
+ s.frame_index = new_index
732
+ s.total_moved_meters += travelled
733
+ observation = self._render_view_observation()
734
+ observation.moved_meters = travelled
735
+ observation.feedback = (
736
+ f"Moved {travelled:.0f} m {action.direction} "
737
+ f"({s.total_moved_meters:.0f} m total). "
738
+ f"{self._steps_remaining()} actions left."
739
+ )
740
+ return observation
741
+
742
+ if isinstance(action, PinAction):
743
+ previous = (s.pins[-1]["lat"], s.pins[-1]["lon"]) if s.pins else None
744
+ description = describe_pin(
745
+ len(s.pins) + 1, action.lat, action.lon, previous=previous
746
+ )
747
+ s.pins.append(
748
+ {
749
+ "index": len(s.pins) + 1,
750
+ "lat": action.lat,
751
+ "lon": action.lon,
752
+ "label": action.label,
753
+ "description": description,
754
+ }
755
+ )
756
+ s.n_pins += 1
757
+ pins = [(p["lat"], p["lon"]) for p in s.pins]
758
+ observation = self._render_map_observation(
759
+ pins, (action.lat, action.lon), action.span_deg
760
+ )
761
+ observation.feedback = (
762
+ f"{description} {self._steps_remaining()} actions left."
763
+ )
764
+ return observation
765
+
766
+ if isinstance(action, ViewMapAction):
767
+ s.n_maps += 1
768
+ pins = [(p["lat"], p["lon"]) for p in s.pins]
769
+ observation = self._render_map_observation(
770
+ pins, (action.lat, action.lon), action.span_deg
771
+ )
772
+ place = locate(action.lat, action.lon)
773
+ observation.feedback = (
774
+ f"Map centred on {action.lat:.3f}, {action.lon:.3f} "
775
+ f"({place.country or 'open water'}), "
776
+ f"{action.span_deg * 2:.0f} deg across. "
777
+ f"{self._steps_remaining()} actions left."
778
+ )
779
+ return observation
780
+
781
+ if isinstance(action, MeasureAction):
782
+ km = haversine_km(action.lat_a, action.lon_a, action.lat_b, action.lon_b)
783
+ observation = self._base_observation()
784
+ observation.feedback = f"{km:.0f} km between those two points."
785
+ return observation
786
+
787
+ raise TypeError(f"Unsupported action type: {type(action).__name__}")
788
+
789
+ def _finish(self, action: GuessAction) -> GeoGuesserObservation:
790
+ """Score the final guess and end the episode."""
791
+ s = self._state
792
+ s.submitted = True
793
+ true_lat, true_lon = self._task.truth
794
+
795
+ if action.lat is not None and action.lon is not None:
796
+ lat, lon, parsed_ok, note = action.lat, action.lon, True, ""
797
+ else:
798
+ parsed = parse_guess(action.response or "")
799
+ lat, lon, parsed_ok, note = parsed.lat, parsed.lon, parsed.ok, parsed.note
800
+
801
+ cost = self._cost()
802
+ observation = self._base_observation()
803
+ observation.done = True
804
+ observation.parsed_ok = parsed_ok
805
+ observation.true_lat = true_lat
806
+ observation.true_lon = true_lon
807
+ observation.action_cost = cost
808
+
809
+ if not parsed_ok:
810
+ observation.reward = 0.0
811
+ observation.score = 0.0
812
+ observation.feedback = f"No usable guess. {note} Scored 0."
813
+ observation.metadata = {**self._metadata(), "parse_failure": True}
814
+ return observation
815
+
816
+ distance = haversine_km(lat, lon, true_lat, true_lon)
817
+
818
+ # A guess previously returned no image at all, which left the outcome
819
+ # invisible in a trace and gave a policy nothing to learn the shape of
820
+ # its error from. Truth is only ever drawn here, after scoring.
821
+ if not self._reveal_map:
822
+ observation.image_kind = "none"
823
+ separation = max(abs(lat - true_lat), abs(lon - true_lon))
824
+ if self._reveal_map and separation > 25.0:
825
+ # Framing both points would squash a hemisphere into the panel and
826
+ # tell you nothing. The useful second view is where it actually was.
827
+ reveal_focus = (true_lat, true_lon)
828
+ reveal_span = 12.0
829
+ elif self._reveal_map:
830
+ reveal_focus = ((lat + true_lat) / 2, (lon + true_lon) / 2)
831
+ reveal_span = max(0.05, separation * 0.75 + 0.4)
832
+ if self._reveal_map:
833
+ reveal = self._render_map_observation(
834
+ [(lat, lon)], reveal_focus, reveal_span, truth=(true_lat, true_lon)
835
+ )
836
+ observation.image_base64 = reveal.image_base64
837
+ observation.image_kind = "map"
838
+
839
+ truth_place = locate(true_lat, true_lon)
840
+ guess_place = locate(lat, lon)
841
+ country_hit = bool(
842
+ truth_place.country and truth_place.country == guess_place.country
843
+ )
844
+ region_hit = bool(
845
+ truth_place.subregion and truth_place.subregion == guess_place.subregion
846
+ )
847
+
848
+ if self._reward_mode is RewardMode.COUNTRY_ONLY:
849
+ reward = max(0.0, float(country_hit) - cost)
850
+ score = float(country_hit)
851
+ else:
852
+ score = compute_reward(
853
+ distance,
854
+ cost=0.0,
855
+ country_hit=country_hit,
856
+ region_hit=region_hit,
857
+ hierarchical=self._hierarchical,
858
+ )
859
+ reward = max(0.0, score - cost)
860
+
861
+ observation.distance_km = distance
862
+ observation.score = score
863
+ observation.reward = reward
864
+ observation.feedback = (
865
+ f"{verdict(distance)} - {distance:.0f} km away. True location "
866
+ f"{true_lat:.4f}, {true_lon:.4f} "
867
+ f"({truth_place.country or 'open water'}). "
868
+ f"Score {score:.3f} minus {cost:.2f} action cost = {reward:.3f}."
869
+ )
870
+ observation.metadata = {
871
+ **self._metadata(),
872
+ "distance_km": distance,
873
+ "country_hit": country_hit,
874
+ "region_hit": region_hit,
875
+ "confidence": action.confidence,
876
+ "reasoning": action.reasoning,
877
+ "n_looks": s.n_looks,
878
+ "n_maps": s.n_maps,
879
+ "n_pins": s.n_pins,
880
+ "n_moves": s.n_moves,
881
+ "total_moved_meters": s.total_moved_meters,
882
+ }
883
+ return observation
geoguesser_env/server/gradio_ui.py ADDED
@@ -0,0 +1,1044 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """The human-play page: a GeoGuessr-style game in the browser.
4
+
5
+ Two viewers, both driven by the environment's own data:
6
+
7
+ - Pannellum shows the equirectangular panorama, so a person drags to look
8
+ around exactly where the agent calls `look()`.
9
+ - MapLibre shows the guess map over OpenFreeMap tiles. No API key, no request
10
+ limits, commercial use permitted, and self-hostable if the public instance
11
+ ever goes away.
12
+
13
+ The page is served as its own document at `/geoguesser/play` and embedded in
14
+ the Gradio tab through an iframe, because `gr.HTML` inserts markup without
15
+ executing `<script>` tags — styles apply, but neither viewer initialises, which
16
+ looks like a blank panel and reports no error anywhere.
17
+
18
+ A human sees live tiles; the agent's map stays the offline Natural Earth
19
+ render. They agree on geometry, which is what matters, and the agent keeps a
20
+ determinism the browser does not need.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import random
27
+ import urllib.parse
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ import gradio as gr
31
+
32
+
33
+ MAPLIBRE_JS = "https://cdnjs.cloudflare.com/ajax/libs/maplibre-gl/5.24.0/maplibre-gl.js"
34
+ MAPLIBRE_CSS = (
35
+ "https://cdnjs.cloudflare.com/ajax/libs/maplibre-gl/5.24.0/maplibre-gl.css"
36
+ )
37
+ PANNELLUM_JS = "https://cdnjs.cloudflare.com/ajax/libs/pannellum/2.5.6/pannellum.js"
38
+ PANNELLUM_CSS = "https://cdnjs.cloudflare.com/ajax/libs/pannellum/2.5.6/pannellum.css"
39
+ OPENFREEMAP_STYLE = "https://tiles.openfreemap.org/styles/positron"
40
+
41
+ # The real game scores a round out of 5000 on this curve. Showing points
42
+ # rather than the RL reward makes a score comparable to GeoGuessr intuition;
43
+ # the reward is shown beside it so the two are never confused. There is no
44
+ # multi-round game here, because an episode is exactly one guess.
45
+ MAX_POINTS_PER_ROUND = 5000
46
+
47
+ _TEMPLATE = r"""<!doctype html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="utf-8">
51
+ <meta name="viewport" content="width=device-width,initial-scale=1">
52
+ <title>geoguesser_env - play</title>
53
+ <link rel="stylesheet" href="__MAPLIBRE_CSS__">
54
+ <link rel="stylesheet" href="__PANNELLUM_CSS__">
55
+ <style>
56
+ :root {
57
+ --panel: rgba(18, 23, 28, .92);
58
+ --edge: #2c353d;
59
+ --ink: #e8ecef;
60
+ --ink-soft: #9aa5ad;
61
+ --ink-faint: #6e7a83;
62
+ --pin: #c4332a;
63
+ --good: #5aa06e;
64
+ --wire: #7fb3cc;
65
+ }
66
+ * { box-sizing: border-box; }
67
+ html, body {
68
+ margin: 0; height: 100%; overflow: hidden; background: #10151a;
69
+ color: var(--ink);
70
+ font-family: ui-monospace, "SF Mono", "IBM Plex Mono", Menlo, monospace;
71
+ }
72
+ #stage { position: absolute; inset: 0; }
73
+ #pano { position: absolute; inset: 0; }
74
+ .pnlm-zoom-controls, .pnlm-orientation-button, .pnlm-panorama-info,
75
+ .pnlm-compass { display: none !important; }
76
+ .pnlm-load-box { background: #10151a !important; }
77
+
78
+ .hud {
79
+ position: absolute; z-index: 5; background: var(--panel);
80
+ border: 1px solid var(--edge); border-radius: 4px;
81
+ font-size: 12px; padding: 7px 11px; backdrop-filter: blur(8px);
82
+ line-height: 1.5;
83
+ }
84
+ .hud b { color: #fff; font-weight: 500; }
85
+ .hud span { color: var(--ink-soft); }
86
+ #top { top: 12px; left: 12px; }
87
+ #top .ep { color: var(--wire); }
88
+ #compass { top: 12px; left: 50%; transform: translateX(-50%); letter-spacing: .1em; }
89
+ #score { top: 12px; right: 12px; text-align: right; }
90
+ #score .pts { font-size: 15px; color: #fff; }
91
+ #credit {
92
+ top: 74px; right: 12px; font-size: 10.5px; color: var(--ink-soft);
93
+ max-width: 34vw; text-align: right; z-index: 14;
94
+ }
95
+ #credit a { color: #8fb8cc; text-decoration: none; }
96
+ #actions {
97
+ bottom: 12px; left: 50%; transform: translateX(-50%); display: flex;
98
+ gap: 6px; align-items: center; transition: opacity .3s ease;
99
+ }
100
+ #actions {
101
+ gap: 0; padding: 0; display: flex; align-items: stretch; overflow: hidden;
102
+ bottom: 12px; left: 12px; transform: none;
103
+ }
104
+ /* One group per kind of environment action, each labelled, each showing what
105
+ it costs through its tooltip rather than shouting a number. A group whose
106
+ tool is not registered is removed rather than greyed: a control you cannot
107
+ use is noise. */
108
+ .pad {
109
+ display: flex; flex-direction: column; gap: 4px; padding: 8px 14px;
110
+ justify-content: center;
111
+ }
112
+ .pad + .pad { border-left: 1px solid var(--edge); }
113
+ .pad.gone { display: none; }
114
+ .padlabel {
115
+ font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase;
116
+ color: var(--ink-faint);
117
+ }
118
+ .padlabel b { color: var(--ink); font-weight: 500; letter-spacing: 0; }
119
+ .btns { display: flex; align-items: center; gap: 5px; }
120
+ #actions button {
121
+ font-family: inherit; cursor: pointer; color: var(--ink);
122
+ background: #232c33; border: 1px solid var(--edge); border-radius: 4px;
123
+ display: flex; flex-direction: column; align-items: center; gap: 1px;
124
+ min-width: 46px; padding: 5px 7px; line-height: 1;
125
+ }
126
+ #actions button .glyph { font-size: 12px; }
127
+ #actions button .tag {
128
+ font-size: 8.5px; letter-spacing: .06em; color: var(--ink-soft);
129
+ }
130
+ #actions button:hover {
131
+ border-color: #6d8493; background: #2b353d; color: #fff;
132
+ }
133
+ #actions button:hover .tag { color: var(--ink); }
134
+ #actions button:active { background: #1c242a; }
135
+ #actions button:focus-visible { outline: 2px solid var(--wire); outline-offset: 1px; }
136
+ #actions button.gone { display: none; }
137
+ #actions.working { opacity: .55; }
138
+ #actions.working button { cursor: progress; }
139
+ #actions.working #padBudget b { color: var(--wire); }
140
+ #actions button.preset {
141
+ font-size: 10px; letter-spacing: .04em; min-width: 44px; padding: 7px 8px;
142
+ }
143
+ #actions button.preset.on { border-color: var(--wire); color: #fff; }
144
+ #fovRange { width: 104px; accent-color: var(--wire); margin-left: 4px; }
145
+ .budget { gap: 4px; font-size: 10px; color: var(--ink-faint); white-space: nowrap; }
146
+ .budget .sep { color: var(--edge); margin: 0 3px; }
147
+ .budget b {
148
+ font-size: 14px; color: var(--ink); font-weight: 500;
149
+ font-variant-numeric: tabular-nums;
150
+ }
151
+
152
+ /* ---- rollout trace: the observation stream the agent would receive ---- */
153
+ /* The trace lives on the left and the guess map on the right, so an
154
+ expanded map can never cover the observation stream. */
155
+ #trace {
156
+ position: absolute; top: 58px; left: 12px; width: 310px; z-index: 7;
157
+ max-height: calc(100% - 130px); display: flex; flex-direction: column;
158
+ background: var(--panel); border: 1px solid var(--edge); border-radius: 4px;
159
+ backdrop-filter: blur(8px); overflow: hidden;
160
+ }
161
+ #trace.hidden { display: none; }
162
+ #trace h4 {
163
+ margin: 0; padding: 8px 11px; font-size: 10.5px; font-weight: 500;
164
+ letter-spacing: .12em; text-transform: uppercase; color: var(--wire);
165
+ border-bottom: 1px solid var(--edge); display: flex;
166
+ justify-content: space-between;
167
+ }
168
+ #trace h4 em { color: var(--ink-faint); font-style: normal; letter-spacing: 0; }
169
+ #trace h4 > span:last-child { display: flex; align-items: center; gap: 8px; }
170
+ #collapse {
171
+ font-family: inherit; font-size: 13px; line-height: 1; cursor: pointer;
172
+ background: #232c33; border: 1px solid var(--edge); border-radius: 3px;
173
+ color: var(--ink); width: 22px; height: 19px; padding: 0;
174
+ }
175
+ #expand {
176
+ font-family: inherit; font-size: 11px; line-height: 1; cursor: pointer;
177
+ background: #232c33; border: 1px solid var(--edge); border-radius: 3px;
178
+ color: var(--ink); padding: 5px 9px;
179
+ }
180
+ #collapse:hover, #expand:hover { border-color: #5b6b78; color: #fff; }
181
+ /* With the trace hidden, a small control stays where it was. */
182
+ #expandWrap {
183
+ position: absolute; top: 58px; left: 12px; z-index: 7; display: none;
184
+ padding: 4px 5px;
185
+ }
186
+ #expandWrap.show { display: block; }
187
+ #steps { overflow-y: auto; padding: 4px 0; font-size: 11px; }
188
+ .step { padding: 6px 11px; border-bottom: 1px solid #1e262c; line-height: 1.5; }
189
+ .step:last-child { border-bottom: none; }
190
+ .step .op { color: var(--wire); }
191
+ .step .rw { float: right; color: var(--ink-faint); }
192
+ .step .fb { color: var(--ink-soft); display: block; margin-top: 2px; }
193
+ #agentview { border-top: 1px solid var(--edge); padding: 8px 11px; }
194
+ #agentview .cap {
195
+ font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase;
196
+ color: var(--ink-faint); margin-bottom: 5px;
197
+ }
198
+ #agentview img { width: 100%; display: block; border-radius: 3px; }
199
+
200
+ /* ---- guess map ------------------------------------------------------- */
201
+ #mapwrap {
202
+ position: absolute; right: 12px; bottom: 12px; z-index: 6;
203
+ width: 300px; height: 210px; border: 1px solid var(--edge);
204
+ border-radius: 5px; overflow: hidden; opacity: .9; background: #191f24;
205
+ transition: width .24s ease, height .24s ease, opacity .24s ease,
206
+ right .24s ease, bottom .24s ease;
207
+ }
208
+ #mapwrap:hover, #mapwrap.big {
209
+ width: min(560px, 48vw); height: min(400px, 58vh); opacity: 1;
210
+ }
211
+ #mapwrap.reveal {
212
+ right: 50%; bottom: 50%; transform: translate(50%, 50%);
213
+ width: min(1000px, 86vw); height: min(600px, 72vh); opacity: 1;
214
+ }
215
+ /* The reveal is the whole point of the round, so it may cover the trace. */
216
+ #mapwrap.reveal { z-index: 13; }
217
+ #map { position: absolute; inset: 0; }
218
+ #submit {
219
+ position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; width: 100%;
220
+ font-family: inherit; font-size: 12px; letter-spacing: .07em;
221
+ text-transform: uppercase; padding: 10px; border: none;
222
+ border-top: 1px solid var(--edge); background: #2b3540; color: #7e8b96;
223
+ cursor: not-allowed;
224
+ }
225
+ #submit.ready { background: var(--pin); color: #fff; cursor: pointer; }
226
+ #submit.ready:hover { background: #d64236; }
227
+ #mapwrap.reveal #submit { display: none; }
228
+
229
+ /* ---- result bar ------------------------------------------------------ */
230
+ #result {
231
+ position: absolute; left: 0; right: 0; bottom: 0; z-index: 12; display: none;
232
+ background: rgba(14, 18, 22, .96); border-top: 1px solid var(--edge);
233
+ padding: 13px 20px; backdrop-filter: blur(8px);
234
+ }
235
+ #result.show { display: block; }
236
+ #result .inner {
237
+ max-width: 1180px; margin: 0 auto; display: flex; align-items: center;
238
+ gap: 22px; flex-wrap: wrap;
239
+ }
240
+ #verdict { font-size: 17px; color: #fff; min-width: 140px; }
241
+ .stat { font-size: 10.5px; color: var(--ink-soft); }
242
+ .stat b {
243
+ display: block; font-size: 14px; color: var(--ink); font-weight: 500;
244
+ font-variant-numeric: tabular-nums; margin-top: 2px;
245
+ }
246
+ .stat.env b { color: var(--wire); }
247
+ #bar { flex: 1 1 140px; min-width: 100px; height: 6px; background: #232b32;
248
+ border-radius: 4px; overflow: hidden; }
249
+ #bar i { display: block; height: 100%; background: var(--good); width: 0; }
250
+ #advance {
251
+ font-family: inherit; font-size: 12px; text-transform: uppercase;
252
+ letter-spacing: .06em; padding: 10px 18px; background: #232b32;
253
+ color: var(--ink); border: 1px solid var(--edge); border-radius: 3px;
254
+ cursor: pointer;
255
+ }
256
+ #advance:hover { border-color: #465360; color: #fff; }
257
+
258
+ .dot {
259
+ display: inline-block; width: 7px; height: 7px; border-radius: 50%;
260
+ margin-right: 7px; background: var(--ink-faint);
261
+ vertical-align: 1px;
262
+ }
263
+ .dot.live { background: var(--good); }
264
+ .dot.bad { background: var(--pin); }
265
+ </style>
266
+ </head>
267
+ <body>
268
+ <div id="stage">
269
+ <div id="pano"></div>
270
+
271
+ <div class="hud" id="top">
272
+ <i id="conn" class="dot" title="environment session"></i><b>geoguesser_env</b>
273
+ <span>episode</span> <b class="ep" id="task">-</b>
274
+ <span>· frame</span> <b id="frame">-</b>
275
+ <span>· steps</span> <b id="stepsLeft">-</b>
276
+ </div>
277
+ <div class="hud" id="compass"><span>facing</span> <b id="heading">-</b>
278
+ <span>· fov</span> <b id="fov">-</b>
279
+ <span>· map</span> <b id="mapspan">-</b></div>
280
+ <div class="hud" id="score">
281
+ <div class="pts"><b id="total">0</b><span> pts</span></div>
282
+ <span id="played">0 episodes</span> · <span id="captured"></span>
283
+ </div>
284
+ <div class="hud" id="credit"></div>
285
+ <div class="hud" id="actions">
286
+ <div class="pad" id="padTurn">
287
+ <span class="padlabel">look</span>
288
+ <div class="btns">
289
+ <button id="turn-left" title="Turn 45° left and look (costs 0.01) — key: A or ←">
290
+ <span class="glyph">&#9664;</span><span class="tag">left</span></button>
291
+ <button id="look-now" title="Look again where you are facing (costs 0.01) — key: L">
292
+ <span class="glyph">&#9678;</span><span class="tag">look</span></button>
293
+ <button id="turn-right" title="Turn 45° right and look (costs 0.01) — key: D or →">
294
+ <span class="glyph">&#9654;</span><span class="tag">right</span></button>
295
+ </div>
296
+ </div>
297
+
298
+ <div class="pad" id="padMove">
299
+ <span class="padlabel">walk</span>
300
+ <div class="btns">
301
+ <button id="move-fwd" title="Walk 15 m forward (costs 0.05) — key: W or ↑">
302
+ <span class="glyph">&#9650;</span><span class="tag">forward</span></button>
303
+ <button id="move-back" title="Walk 15 m back (costs 0.05) — key: S or ↓">
304
+ <span class="glyph">&#9660;</span><span class="tag">back</span></button>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="pad" id="padZoom">
309
+ <span class="padlabel">zoom &middot; <b id="fovValue">90&deg;</b></span>
310
+ <div class="btns">
311
+ <button class="preset" data-fov="90" title="Wide view, 90° (costs 0.01)">wide</button>
312
+ <button class="preset" data-fov="50" title="Street level, 50° (costs 0.01)">street</button>
313
+ <button class="preset" data-fov="30" title="Read a sign, 30° (costs 0.01)">sign</button>
314
+ <input id="fovRange" type="range" min="20" max="110" step="5" value="90"
315
+ aria-label="field of view in degrees">
316
+ </div>
317
+ </div>
318
+
319
+ <div class="pad" id="padBudget">
320
+ <span class="padlabel">budget</span>
321
+ <div class="btns budget">
322
+ <b id="stepsBudget">12</b><span>left</span>
323
+ <span class="sep">&middot;</span>
324
+ <b id="costBudget">0.00</b><span>spent</span>
325
+ </div>
326
+ </div>
327
+ </div>
328
+
329
+ <div class="hud" id="expandWrap">
330
+ <button id="expand" title="show the rollout trace (T)">+ trace</button>
331
+ </div>
332
+
333
+ <div id="trace">
334
+ <h4>
335
+ <span>rollout trace</span>
336
+ <span><em id="cost">cost 0.00</em>
337
+ <button id="collapse" title="hide the trace (T)">&#8211;</button></span>
338
+ </h4>
339
+ <div id="steps"></div>
340
+ <div id="agentview" style="display:none">
341
+ <div class="cap">what the agent sees</div>
342
+ <img id="agentimg" alt="the environment's own rendered observation">
343
+ </div>
344
+ </div>
345
+
346
+ <div id="mapwrap">
347
+ <div id="map"></div>
348
+ <button id="submit">click the map to place a pin</button>
349
+ </div>
350
+
351
+ <div id="result">
352
+ <div class="inner">
353
+ <div id="verdict">-</div>
354
+ <div class="stat">distance<b id="dist">-</b></div>
355
+ <div class="stat">points<b id="points">-</b></div>
356
+ <div class="stat env">env reward<b id="reward">-</b></div>
357
+ <div class="stat">score - cost<b id="breakdown2">-</b></div>
358
+ <div class="stat">true location<b id="truth">-</b></div>
359
+ <div id="bar"><i></i></div>
360
+ <button id="advance">load another episode</button>
361
+ </div>
362
+ </div>
363
+
364
+ </div>
365
+
366
+ <script src="__MAPLIBRE_JS__"></script>
367
+ <script src="__PANNELLUM_JS__"></script>
368
+ <script>
369
+ (function () {
370
+ "use strict";
371
+ const N_TASKS = __N_TASKS__;
372
+ const SPLIT = __SPLIT__;
373
+ const MAX_POINTS = __MAX_POINTS__;
374
+ // Every task-scoped request has to name its split, or index 12 of eval and
375
+ // index 12 of train are indistinguishable and the page reveals the wrong
376
+ // ground truth.
377
+ const q = (extra) => "?split=" + encodeURIComponent(SPLIT) + (extra || "");
378
+ const $ = (id) => document.getElementById(id);
379
+
380
+ let socket = null, ready = false, pending = null;
381
+ let viewer = null, map = null, guessMarker = null, truthMarker = null;
382
+ let lineAdded = false, guess = null, taskIndex = 0, compass = 0;
383
+ let taskMeta = null, frameIndex = 0;
384
+ let total = 0, played = 0, busy = false, cost = 0;
385
+
386
+ const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
387
+ const fmt = (la, lo) => la.toFixed(4) + ", " + lo.toFixed(4);
388
+ const yaw = () => (viewer ? ((viewer.getYaw() % 360) + 360) % 360 : 0);
389
+ const hfov = () => (viewer ? viewer.getHfov() : 90);
390
+
391
+ // ---- the environment, over the same WebSocket session API a client uses --
392
+ // Plain REST /step builds a fresh environment per request, so a stateful
393
+ // episode has to run over /ws. This page therefore plays exactly the
394
+ // rollout an agent would: one reset, a few charged steps, one terminal guess.
395
+ function connect() {
396
+ const scheme = location.protocol === "https:" ? "wss:" : "ws:";
397
+ socket = new WebSocket(scheme + "//" + location.host + "/ws");
398
+ socket.onopen = function () {
399
+ ready = true;
400
+ $("conn").className = "dot live";
401
+ $("conn").title = "environment session: connected";
402
+ startRound(requestedTask());
403
+ };
404
+ socket.onclose = function () {
405
+ ready = false;
406
+ $("conn").className = "dot bad";
407
+ $("conn").title = "environment session: disconnected";
408
+ };
409
+ socket.onerror = function () {
410
+ $("conn").className = "dot bad";
411
+ $("conn").title = "environment session: error";
412
+ };
413
+ socket.onmessage = function (event) {
414
+ const message = JSON.parse(event.data);
415
+ if (message.type === "error") {
416
+ addStep("error", message.data ? JSON.stringify(message.data) : "", null);
417
+ setBusy(false);
418
+ return;
419
+ }
420
+ if (message.type !== "observation") return;
421
+ // The wire format nests the observation and carries reward and done as
422
+ // siblings: {observation: {...}, reward, done, metadata}. Flatten it so
423
+ // callers read one object.
424
+ const payload = message.data || {};
425
+ const observation = Object.assign(
426
+ {}, payload.observation || payload,
427
+ { reward: payload.reward, done: payload.done }
428
+ );
429
+ const handler = pending;
430
+ pending = null;
431
+ if (handler) handler(observation);
432
+ };
433
+ }
434
+
435
+ function send(type, data, handler) {
436
+ if (!ready) return;
437
+ pending = handler || null;
438
+ socket.send(JSON.stringify({ type: type, data: data || {} }));
439
+ }
440
+
441
+ // ---- trace ------------------------------------------------------------
442
+ function addStep(op, feedback, observation) {
443
+ const row = document.createElement("div");
444
+ row.className = "step";
445
+ let right = "";
446
+ if (observation && observation.steps_remaining !== undefined) {
447
+ right = "<span class='rw'>" + observation.steps_remaining + " left</span>";
448
+ }
449
+ row.innerHTML = "<span class='op'>" + op + "</span>" + right +
450
+ "<span class='fb'>" + (feedback || "") + "</span>";
451
+ $("steps").appendChild(row);
452
+ $("steps").scrollTop = $("steps").scrollHeight;
453
+ if (observation && observation.image_base64) {
454
+ const mime = observation.image_kind === "map" ? "png" : "jpeg";
455
+ $("agentimg").src = "data:image/" + mime + ";base64," + observation.image_base64;
456
+ $("agentview").style.display = "block";
457
+ }
458
+ }
459
+
460
+ function applyObservation(observation) {
461
+ if (!observation) return;
462
+ if (observation.steps_remaining !== undefined) {
463
+ $("stepsLeft").textContent = observation.steps_remaining;
464
+ $("stepsBudget").textContent = observation.steps_remaining;
465
+ }
466
+ if (observation.action_cost !== null && observation.action_cost !== undefined) {
467
+ cost = observation.action_cost;
468
+ $("cost").textContent = "cost " + cost.toFixed(2);
469
+ $("costBudget").textContent = cost.toFixed(2);
470
+ }
471
+ setControls(observation);
472
+ }
473
+
474
+ // ---- rounds -----------------------------------------------------------
475
+ function loadPano(index) {
476
+ fetch("/geoguesser/task/" + index + q())
477
+ .then((response) => response.json())
478
+ .then((meta) => {
479
+ taskMeta = meta;
480
+ frameIndex = meta.start_frame || 0;
481
+ const who = (meta.attribution || {}).creator_username;
482
+ $("credit").innerHTML =
483
+ "imagery &copy; " + (who ? who : "Mapillary contributor") +
484
+ " via <a href='https://www.mapillary.com' target='_blank' rel='noopener'>Mapillary</a>" +
485
+ ", <a href='https://creativecommons.org/licenses/by-sa/4.0/' target='_blank' rel='noopener'>CC BY-SA 4.0</a>";
486
+ showFrame(frameIndex, 0, 90);
487
+ });
488
+ }
489
+
490
+ /**
491
+ * Point the main viewer at one frame of the sequence.
492
+ *
493
+ * Called on reset and again after every move(), so walking forward actually
494
+ * changes what you are looking at rather than only what the trace shows.
495
+ * Heading and zoom carry over, because losing your orientation on every step
496
+ * would make navigation useless.
497
+ */
498
+ function showFrame(index, keepYaw, keepHfov) {
499
+ frameIndex = index;
500
+ const frames = (taskMeta && taskMeta.frames) || [];
501
+ const frame = frames[index] || {};
502
+ compass = frame.compass_angle || 0;
503
+ if (frame.captured_at) {
504
+ $("captured").textContent = "captured " + frame.captured_at;
505
+ }
506
+ $("frame").textContent = index + "/" + Math.max(0, frames.length - 1);
507
+ if (viewer) { viewer.destroy(); viewer = null; }
508
+ viewer = pannellum.viewer("pano", {
509
+ type: "equirectangular",
510
+ panorama: "/geoguesser/pano/" + taskIndex + "/" + index + q(),
511
+ autoLoad: true, showControls: false, northOffset: compass,
512
+ yaw: keepYaw, hfov: keepHfov,
513
+ minHfov: 20, maxHfov: 110, compass: false, friction: 0.15,
514
+ });
515
+ viewer.on("mouseup", updateHud);
516
+ viewer.on("touchend", updateHud);
517
+ viewer.on("zoomchange", updateHud);
518
+ viewer.on("load", updateHud);
519
+ setTimeout(updateHud, 400);
520
+ }
521
+
522
+ function setControls(observation) {
523
+ if (!observation) return;
524
+ const tools = observation.available_tools || [];
525
+ const canLook = tools.indexOf("look") !== -1;
526
+ const canMove = tools.indexOf("move") !== -1;
527
+ $("padTurn").classList.toggle("gone", !canLook);
528
+ $("padZoom").classList.toggle("gone", !canLook);
529
+ $("look-now").classList.toggle("gone", !canLook);
530
+ $("padMove").classList.toggle(
531
+ "gone",
532
+ !canMove ||
533
+ (!observation.can_move_forward && !observation.can_move_backward)
534
+ );
535
+ $("move-fwd").classList.toggle("gone", !observation.can_move_forward);
536
+ $("move-back").classList.toggle("gone", !observation.can_move_backward);
537
+ if (observation.fov_deg) {
538
+ const fov = Math.round(observation.fov_deg);
539
+ $("fovRange").value = String(fov);
540
+ $("fovValue").textContent = fov + "\u00b0";
541
+ markPreset(fov);
542
+ }
543
+ }
544
+
545
+ function updateHud() {
546
+ if (!viewer) return;
547
+ const y = yaw();
548
+ $("heading").textContent = DIRS[Math.round(y / 45) % 8] + " " + y.toFixed(0) + "°";
549
+ $("fov").textContent = hfov().toFixed(0) + "°";
550
+ }
551
+
552
+ // ---- charged actions, executed by the environment ---------------------
553
+ function setBusy(value) {
554
+ busy = value;
555
+ $("actions").classList.toggle("working", value);
556
+ if (value) {
557
+ $("stepsBudget").textContent = "\u2026";
558
+ }
559
+ }
560
+
561
+ function step(op, data, label) {
562
+ if (busy || !ready) return;
563
+ setBusy(true);
564
+ send("step", Object.assign({ op: op }, data), function (observation) {
565
+ setBusy(false);
566
+ applyObservation(observation);
567
+ addStep(label, observation.feedback, observation);
568
+ const meta = observation.metadata || {};
569
+ if (op === "move" && meta.frame_index !== undefined &&
570
+ meta.frame_index !== frameIndex) {
571
+ // Keep the player facing the same way through the step.
572
+ showFrame(meta.frame_index, yaw(), hfov());
573
+ }
574
+ if (op === "look" && data && data.fov_deg && viewer) {
575
+ // The main view and the agent's view should never disagree.
576
+ viewer.setHfov(data.fov_deg);
577
+ if (data.heading_deg !== undefined) viewer.setYaw(data.heading_deg);
578
+ }
579
+ if (op === "guess") reveal(observation);
580
+ });
581
+ }
582
+
583
+ // The pad is the agent's action set, not a viewer control: every button is a
584
+ // charged environment step, and the panorama follows the result. Dragging the
585
+ // scene stays free, for orientation only.
586
+ function lookAt(heading, fov) {
587
+ const wrapped = ((Math.round(heading) % 360) + 360) % 360;
588
+ step(
589
+ "look",
590
+ { heading_deg: wrapped, pitch_deg: 0, fov_deg: Math.round(fov) },
591
+ "look(heading=" + wrapped + ", fov=" + Math.round(fov) + ")"
592
+ );
593
+ }
594
+
595
+ $("turn-left").onclick = function () { lookAt(yaw() - 45, hfov()); };
596
+ $("turn-right").onclick = function () { lookAt(yaw() + 45, hfov()); };
597
+ $("look-now").onclick = function () { lookAt(yaw(), hfov()); };
598
+
599
+ Array.prototype.forEach.call(
600
+ document.querySelectorAll("#padZoom .preset"),
601
+ function (button) {
602
+ button.onclick = function () {
603
+ const fov = parseInt(button.dataset.fov, 10);
604
+ $("fovRange").value = String(fov);
605
+ $("fovValue").textContent = fov + "\u00b0";
606
+ markPreset(fov);
607
+ lookAt(yaw(), fov);
608
+ };
609
+ }
610
+ );
611
+
612
+ function markPreset(fov) {
613
+ Array.prototype.forEach.call(
614
+ document.querySelectorAll("#padZoom .preset"),
615
+ function (button) {
616
+ button.classList.toggle("on", parseInt(button.dataset.fov, 10) === fov);
617
+ }
618
+ );
619
+ }
620
+ $("move-fwd").onclick = function () {
621
+ step("move", { direction: "forward", meters: 15 }, "move(forward, 15m)");
622
+ };
623
+ $("move-back").onclick = function () {
624
+ step("move", { direction: "backward", meters: 15 }, "move(backward, 15m)");
625
+ };
626
+
627
+ // The slider reads out live but only spends a step on release, so dragging it
628
+ // does not burn the budget.
629
+ $("fovRange").addEventListener("input", function () {
630
+ const fov = parseInt($("fovRange").value, 10);
631
+ $("fovValue").textContent = fov + "\u00b0";
632
+ markPreset(fov);
633
+ });
634
+ $("fovRange").addEventListener("change", function () {
635
+ lookAt(yaw(), parseInt($("fovRange").value, 10));
636
+ });
637
+ function setTrace(visible) {
638
+ $("trace").classList.toggle("hidden", !visible);
639
+ $("expandWrap").classList.toggle("show", !visible);
640
+ }
641
+ $("collapse").onclick = function () { setTrace(false); };
642
+ $("expand").onclick = function () { setTrace(true); };
643
+
644
+ // The picker is the human equivalent of reset(task_index=k): the same call an
645
+ // eval harness makes, so a person can replay exactly the episode an agent saw.
646
+ /** Task index requested in the page URL, when the host supplied one. */
647
+ function requestedTask() {
648
+ const value = new URLSearchParams(location.search).get("task");
649
+ if (value === null || value === "" || value === "random") return undefined;
650
+ const parsed = parseInt(value, 10);
651
+ return Number.isFinite(parsed) ? parsed : undefined;
652
+ }
653
+
654
+ function startRound(index) {
655
+ cost = 0;
656
+ $("cost").textContent = "cost 0.00";
657
+ $("steps").innerHTML = "";
658
+ $("agentview").style.display = "none";
659
+ clearRound();
660
+ const wanted = index === undefined
661
+ ? Math.floor(Math.random() * N_TASKS)
662
+ : ((index % N_TASKS) + N_TASKS) % N_TASKS;
663
+ taskIndex = wanted;
664
+ send("reset", { split: SPLIT, index: wanted }, function (observation) {
665
+ const meta = observation.metadata || {};
666
+ taskIndex = meta.task_index !== undefined ? meta.task_index : wanted;
667
+ $("task").textContent = taskIndex;
668
+ $("captured").textContent = "captured " + (observation.captured_at || "unknown");
669
+ applyObservation(observation);
670
+ addStep(
671
+ "reset(split='" + SPLIT + "', index=" + taskIndex + ")",
672
+ "episode started · " + (observation.available_tools || []).length +
673
+ " tools registered",
674
+ observation
675
+ );
676
+ loadPano(taskIndex);
677
+ });
678
+ }
679
+
680
+ // ---- map --------------------------------------------------------------
681
+ const WORLD = [[-179, -58], [179, 76]];
682
+ map = new maplibregl.Map({
683
+ container: "map", style: "__OPENFREEMAP_STYLE__",
684
+ center: [0, 12], zoom: 0, minZoom: -2,
685
+ attributionControl: { compact: true }, dragRotate: false,
686
+ // Without this the world repeats horizontally, which reads as a rendering
687
+ // bug at the zoom levels a small guess map uses.
688
+ renderWorldCopies: false,
689
+ });
690
+ map.on("load", () => map.fitBounds(WORLD, { padding: 6, duration: 0 }));
691
+ // Exposed so the page can be driven from a test harness or the console.
692
+ window.__ggMap = map;
693
+ window.__ggState = function () {
694
+ return {
695
+ busy: busy,
696
+ ready: ready,
697
+ pendingHandler: !!pending,
698
+ socket: socket ? socket.readyState : null,
699
+ steps: document.querySelectorAll('#steps .step').length,
700
+ };
701
+ };
702
+
703
+ /**
704
+ * Half-width of the visible map, in degrees.
705
+ *
706
+ * The environment renders its own map from this, so a pin dropped while
707
+ * zoomed into a city comes back as a street-level map rather than a
708
+ * continental one. Without it the agent's view and the player's would
709
+ * disagree about how precisely the pin could be aimed.
710
+ */
711
+ function currentSpanDeg() {
712
+ const bounds = map.getBounds();
713
+ const span = Math.abs(bounds.getEast() - bounds.getWest()) / 2;
714
+ return Math.min(180, Math.max(0.03, span));
715
+ }
716
+
717
+ function updateMapSpan() {
718
+ const span = currentSpanDeg();
719
+ $("mapspan").textContent =
720
+ span >= 1 ? span.toFixed(0) + "\u00b0" : (span * 111).toFixed(0) + " km";
721
+ }
722
+ map.on("zoomend", updateMapSpan);
723
+ map.on("moveend", updateMapSpan);
724
+ map.on("load", updateMapSpan);
725
+
726
+ map.on("click", function (event) {
727
+ if (busy || $("result").classList.contains("show")) return;
728
+ // Once you have committed to a pin the map stays open; letting it collapse
729
+ // on mouse-out makes it easy to lose the guess you were adjusting.
730
+ $("mapwrap").classList.add("big");
731
+ map.resize();
732
+ guess = event.lngLat;
733
+ if (guessMarker) guessMarker.remove();
734
+ guessMarker = new maplibregl.Marker({ color: "#c4332a" })
735
+ .setLngLat(guess).addTo(map);
736
+ $("submit").className = "ready";
737
+ $("submit").textContent = "submit guess";
738
+ // A pin is a real, charged environment step, so the map the agent would see
739
+ // comes back in the trace panel — framed at the zoom you are looking at, so
740
+ // the two views agree about how precisely the pin was aimed.
741
+ const span = currentSpanDeg();
742
+ step("pin", { lat: guess.lat, lon: guess.lng, span_deg: span },
743
+ "place_pin(" + guess.lat.toFixed(2) + ", " + guess.lng.toFixed(2) +
744
+ ", span=" + span.toFixed(2) + ")");
745
+ });
746
+
747
+ $("submit").onclick = function () {
748
+ if (!guess) return;
749
+ step("guess", { lat: guess.lat, lon: guess.lng },
750
+ "submit_guess(" + guess.lat.toFixed(2) + ", " + guess.lng.toFixed(2) + ")");
751
+ };
752
+
753
+ function reveal(observation) {
754
+ if (observation.distance_km === null || observation.distance_km === undefined) {
755
+ addStep("guess rejected",
756
+ observation.feedback || "the environment returned no distance",
757
+ observation);
758
+ return;
759
+ }
760
+ const km = observation.distance_km;
761
+ const reward = observation.reward === null ? 0 : observation.reward;
762
+ const score = observation.score === null ? 0 : observation.score;
763
+ const points = Math.round(score * MAX_POINTS);
764
+ const truthLat = observation.true_lat, truthLon = observation.true_lon;
765
+ total += points;
766
+ played += 1;
767
+ $("played").textContent = played + (played === 1 ? " episode" : " episodes");
768
+
769
+ $("verdict").textContent =
770
+ km < 0.025 ? "Perfect." : km < 25 ? "Pinpoint." : km < 200 ? "Close." :
771
+ km < 1500 ? "Right region." : "Wrong continent.";
772
+ $("dist").textContent = km < 10 ? (km * 1000).toFixed(0) + " m" : km.toFixed(0) + " km";
773
+ $("points").textContent = points + " / " + MAX_POINTS;
774
+ $("reward").textContent = reward.toFixed(3);
775
+ $("breakdown2").textContent =
776
+ score.toFixed(3) + " - " + (observation.action_cost || 0).toFixed(2);
777
+ $("truth").textContent = fmt(truthLat, truthLon);
778
+ $("bar").firstElementChild.style.width = (reward * 100).toFixed(1) + "%";
779
+ $("total").textContent = total;
780
+ $("result").classList.add("show");
781
+ document.body.classList.add("revealing");
782
+ $("actions").style.opacity = "0";
783
+
784
+ truthMarker = new maplibregl.Marker({ color: "#5aa06e" })
785
+ .setLngLat([truthLon, truthLat]).addTo(map);
786
+ const line = {
787
+ type: "Feature",
788
+ geometry: {
789
+ type: "LineString",
790
+ coordinates: [[guess.lng, guess.lat], [truthLon, truthLat]],
791
+ },
792
+ };
793
+ if (lineAdded) {
794
+ map.getSource("shot").setData(line);
795
+ } else {
796
+ map.addSource("shot", { type: "geojson", data: line });
797
+ map.addLayer({
798
+ id: "shot", type: "line", source: "shot",
799
+ paint: { "line-color": "#c4332a", "line-width": 2.5, "line-dasharray": [2, 1.6] },
800
+ });
801
+ lineAdded = true;
802
+ }
803
+ $("mapwrap").classList.add("reveal");
804
+ map.resize();
805
+ setTimeout(function () {
806
+ map.fitBounds(
807
+ [[Math.min(guess.lng, truthLon), Math.min(guess.lat, truthLat)],
808
+ [Math.max(guess.lng, truthLon), Math.max(guess.lat, truthLat)]],
809
+ { padding: 80, maxZoom: 7, duration: 900 }
810
+ );
811
+ }, 260);
812
+ }
813
+
814
+ function clearRound() {
815
+ guess = null;
816
+ setBusy(false);
817
+ if (guessMarker) { guessMarker.remove(); guessMarker = null; }
818
+ if (truthMarker) { truthMarker.remove(); truthMarker = null; }
819
+ if (lineAdded) {
820
+ map.getSource("shot").setData({
821
+ type: "Feature", geometry: { type: "LineString", coordinates: [] },
822
+ });
823
+ }
824
+ $("mapwrap").classList.remove("reveal", "big");
825
+ map.resize();
826
+ map.fitBounds(WORLD, { padding: 6, duration: 700 });
827
+ $("submit").className = "";
828
+ $("submit").textContent = "click the map to place a pin";
829
+ $("result").classList.remove("show");
830
+ document.body.classList.remove("revealing");
831
+ $("actions").style.opacity = "1";
832
+ }
833
+
834
+ // An episode is one guess, so there is no round to advance: the terminal
835
+ // control simply starts another episode.
836
+ $("advance").onclick = function () {
837
+ clearRound();
838
+ startRound();
839
+ };
840
+
841
+ document.addEventListener("keydown", function (event) {
842
+ if (event.key === "m" || event.key === "M") {
843
+ $("mapwrap").classList.toggle("big");
844
+ map.resize();
845
+ if (!guess && !busy) map.fitBounds(WORLD, { padding: 6, duration: 250 });
846
+ } else if (event.key === "t" || event.key === "T") {
847
+ setTrace($("trace").classList.contains("hidden"));
848
+ } else if (event.key === "ArrowUp" || event.key === "w") {
849
+ if (!$("move-fwd").classList.contains("gone")) $("move-fwd").click();
850
+ } else if (event.key === "ArrowDown" || event.key === "s") {
851
+ if (!$("move-back").classList.contains("gone")) $("move-back").click();
852
+ } else if (event.key === "l" || event.key === "L") {
853
+ if (!$("padTurn").classList.contains("gone")) $("look-now").click();
854
+ } else if (event.key === "ArrowLeft" || event.key === "a") {
855
+ if (!$("padTurn").classList.contains("gone")) $("turn-left").click();
856
+ } else if (event.key === "ArrowRight" || event.key === "d") {
857
+ if (!$("padTurn").classList.contains("gone")) $("turn-right").click();
858
+ } else if (event.key === "Enter") {
859
+ if ($("result").classList.contains("show")) { $("advance").click(); }
860
+ else { $("submit").click(); }
861
+ }
862
+ });
863
+
864
+ connect();
865
+ })();
866
+ </script>
867
+ </body>
868
+ </html>
869
+ """
870
+
871
+
872
+ def play_page_html(splits: list[dict] | int, split: str | None = None) -> str:
873
+ """
874
+ Return the standalone play page.
875
+
876
+ Served at `/geoguesser/play` and embedded in the Gradio tab through an
877
+ iframe. It is a full document rather than a fragment because `gr.HTML`
878
+ inserts markup without running `<script>` tags.
879
+
880
+ Args:
881
+ splits (`list[dict]` or `int`):
882
+ Split descriptors from [`~GeoGuesserEnvironment.list_splits`]. A
883
+ bare integer is accepted as a task count for callers predating
884
+ splits.
885
+ split (`str`, *optional*):
886
+ Which split the page should play. Defaults to the split marked
887
+ `default`, else the first one.
888
+
889
+ Returns:
890
+ `str`: A complete HTML document.
891
+ """
892
+ if isinstance(splits, int):
893
+ descriptors = [
894
+ {"name": "train", "num_tasks": splits, "default": True, "type": "train"}
895
+ ]
896
+ else:
897
+ descriptors = list(splits) or [
898
+ {"name": "train", "num_tasks": 1, "default": True, "type": "train"}
899
+ ]
900
+ chosen = next(
901
+ (d for d in descriptors if d["name"] == split),
902
+ next((d for d in descriptors if d.get("default")), descriptors[0]),
903
+ )
904
+ replacements = {
905
+ "__SPLIT__": json.dumps(chosen["name"]),
906
+ "__N_TASKS__": str(max(1, int(chosen.get("num_tasks", 1)))),
907
+ "__MAX_POINTS__": str(MAX_POINTS_PER_ROUND),
908
+ "__MAPLIBRE_JS__": MAPLIBRE_JS,
909
+ "__MAPLIBRE_CSS__": MAPLIBRE_CSS,
910
+ "__PANNELLUM_JS__": PANNELLUM_JS,
911
+ "__PANNELLUM_CSS__": PANNELLUM_CSS,
912
+ "__OPENFREEMAP_STYLE__": OPENFREEMAP_STYLE,
913
+ }
914
+ page = _TEMPLATE
915
+ for token, value in replacements.items():
916
+ page = page.replace(token, value)
917
+ return page
918
+
919
+
920
+ def _iframe(task: str | int = "random", split: str = "") -> str:
921
+ """Markup for the play iframe, pointed at one task of one split.
922
+
923
+ Args:
924
+ task (`str` or `int`, *optional*, defaults to `"random"`):
925
+ Task index to open, or `"random"`.
926
+ split (`str`, *optional*):
927
+ Split to play. Empty means the server's default split.
928
+
929
+ Returns:
930
+ `str`: An iframe element. Re-rendering it with a different task is what
931
+ makes the Gradio controls reload the round, since the page reads its
932
+ task from the URL.
933
+ """
934
+ query = f"?task={task}"
935
+ if split:
936
+ query += f"&split={urllib.parse.quote(split)}"
937
+ return (
938
+ f'<iframe src="/geoguesser/play{query}" '
939
+ 'style="width:100%;height:720px;border:1px solid #2c353d;'
940
+ 'border-radius:6px" allow="fullscreen"></iframe>'
941
+ )
942
+
943
+
944
+ def build_geoguesser_gradio_app(
945
+ web_manager: Any,
946
+ action_fields: List[Dict[str, Any]],
947
+ metadata: Optional[Any],
948
+ is_chat_env: bool,
949
+ title: str,
950
+ quick_start_md: str,
951
+ ) -> gr.Blocks:
952
+ """
953
+ Build the human-play tab.
954
+
955
+ The episode controls live here, on the Gradio side, rather than inside the
956
+ page: picking a task is orchestration, the same `reset(task_index=k)` an
957
+ eval harness calls, so it belongs with the host controls and not among the
958
+ in-game HUD.
959
+
960
+ Args:
961
+ web_manager (`Any`):
962
+ The playground's environment manager, unused here.
963
+ action_fields (`list[dict]`):
964
+ Action schema fields, unused here.
965
+ metadata (`Any`, *optional*):
966
+ Environment metadata, unused here.
967
+ is_chat_env (`bool`):
968
+ Whether the env is chat-shaped, unused here.
969
+ title (`str`):
970
+ Playground title.
971
+ quick_start_md (`str`):
972
+ Quick-start markdown, unused here.
973
+
974
+ Returns:
975
+ `gradio.Blocks`: The play tab, hosting `/geoguesser/play` in an iframe.
976
+ """
977
+ # Ask the server which splits it actually serves, rather than re-deriving
978
+ # them here from environment variables and drifting out of step with it.
979
+ descriptors: list[dict] = []
980
+ try:
981
+ from .app import ACTIVE_DEFAULT_SPLIT, create_geoguesser_environment
982
+
983
+ descriptors = create_geoguesser_environment().list_splits()
984
+ default_split = ACTIVE_DEFAULT_SPLIT
985
+ except Exception: # pragma: no cover - the page still works without counts
986
+ default_split = "train"
987
+ if not descriptors:
988
+ descriptors = [
989
+ {"name": default_split, "num_tasks": 1, "default": True, "type": "train"}
990
+ ]
991
+
992
+ counts = {d["name"]: max(1, int(d.get("num_tasks", 1))) for d in descriptors}
993
+ names = list(counts)
994
+ if default_split not in counts:
995
+ default_split = names[0]
996
+
997
+ def _label(split: str) -> str:
998
+ return f"reset(index=) · 0 to {counts[split] - 1}"
999
+
1000
+ with gr.Blocks(title="Geoguesser Environment") as blocks:
1001
+ with gr.Row():
1002
+ split_box = gr.Dropdown(
1003
+ choices=names,
1004
+ value=default_split,
1005
+ label="reset(split=)",
1006
+ scale=1,
1007
+ interactive=len(names) > 1,
1008
+ )
1009
+ task_box = gr.Number(
1010
+ value=0,
1011
+ minimum=0,
1012
+ maximum=counts[default_split] - 1,
1013
+ step=1,
1014
+ precision=0,
1015
+ label=_label(default_split),
1016
+ scale=2,
1017
+ )
1018
+ load_button = gr.Button("load episode", variant="primary", scale=1)
1019
+ random_button = gr.Button("random episode", scale=1)
1020
+ frame = gr.HTML(value=_iframe("random", default_split), show_label=False)
1021
+
1022
+ def _on_split(split: str):
1023
+ """Re-range the index box so it cannot address a missing task."""
1024
+ split = split or default_split
1025
+ return gr.update(maximum=counts[split] - 1, value=0, label=_label(split))
1026
+
1027
+ split_box.change(fn=_on_split, inputs=split_box, outputs=task_box)
1028
+ load_button.click(
1029
+ fn=lambda index, split: _iframe(int(index or 0), split or default_split),
1030
+ inputs=[task_box, split_box],
1031
+ outputs=frame,
1032
+ )
1033
+ random_button.click(
1034
+ fn=lambda split: _iframe(
1035
+ random.randrange(counts[split or default_split]),
1036
+ split or default_split,
1037
+ ),
1038
+ inputs=split_box,
1039
+ outputs=frame,
1040
+ )
1041
+ return blocks
1042
+
1043
+
1044
+ __all__ = ["build_geoguesser_gradio_app", "play_page_html"]
geoguesser_env/server/parser.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """Extract coordinates from a model's free-text reply.
4
+
5
+ Models emit reasoning and coordinates together, in many shapes. The parser
6
+ accepts what they actually produce rather than demanding a schema, and returns
7
+ `None` when nothing usable is present so the failure lands in the reward
8
+ instead of raising.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+
17
+ # 48.8584, 2.2945 | -16.49 / -68.12 | lat: 12.9 lon: 77.5
18
+ _DECIMAL_PAIR = re.compile(
19
+ r"(-?\d{1,3}(?:\.\d+)?)\s*(?:,|/|;|\s+and\s+|\s+)\s*(-?\d{1,3}(?:\.\d+)?)"
20
+ )
21
+
22
+ # 48°51'29"N 2°17'40"E
23
+ _DMS = re.compile(
24
+ r"(\d{1,3})\s*[°d]\s*(\d{1,2})?\s*['′m]?\s*(\d{1,2}(?:\.\d+)?)?"
25
+ r"\s*[\"″s]?\s*([NSEW])",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+ _LABELLED = re.compile(
30
+ r"lat(?:itude)?\s*[:=]\s*(-?\d{1,3}(?:\.\d+)?)"
31
+ r".{0,40}?"
32
+ r"lon(?:g|gitude)?\s*[:=]\s*(-?\d{1,3}(?:\.\d+)?)",
33
+ re.IGNORECASE | re.DOTALL,
34
+ )
35
+
36
+ _TAG = re.compile(r"<guess>(.*?)</guess>", re.IGNORECASE | re.DOTALL)
37
+
38
+ _JSON_ISH = re.compile(
39
+ r"\"lat(?:itude)?\"\s*:\s*(-?\d{1,3}(?:\.\d+)?)"
40
+ r".{0,60}?"
41
+ r"\"lon(?:g|gitude)?\"\s*:\s*(-?\d{1,3}(?:\.\d+)?)",
42
+ re.IGNORECASE | re.DOTALL,
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class ParsedGuess:
48
+ """Outcome of parsing a reply.
49
+
50
+ Attributes:
51
+ lat (`float` or `None`):
52
+ Latitude, or `None` when nothing could be extracted.
53
+ lon (`float` or `None`):
54
+ Longitude, or `None` when nothing could be extracted.
55
+ source (`str`):
56
+ Which pattern matched: `"tag"`, `"json"`, `"labelled"`, `"dms"`,
57
+ `"decimal"` or `"none"`.
58
+ note (`str`):
59
+ Short explanation, safe to show the model as feedback.
60
+ """
61
+
62
+ lat: float | None
63
+ lon: float | None
64
+ source: str
65
+ note: str = ""
66
+
67
+ @property
68
+ def ok(self) -> bool:
69
+ """Whether a usable coordinate pair was extracted."""
70
+ return self.lat is not None and self.lon is not None
71
+
72
+
73
+ def _valid(lat: float, lon: float) -> bool:
74
+ return -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0
75
+
76
+
77
+ def _dms_to_decimal(deg: str, minute: str | None, sec: str | None, hemi: str) -> float:
78
+ value = float(deg) + float(minute or 0) / 60 + float(sec or 0) / 3600
79
+ return -value if hemi.upper() in ("S", "W") else value
80
+
81
+
82
+ def parse_guess(response: str) -> ParsedGuess:
83
+ """
84
+ Pull a coordinate pair out of a model reply.
85
+
86
+ Patterns are tried most explicit first, so a `<guess>` tag or a labelled
87
+ `lat:`/`lon:` pair wins over a bare number pair that might be a date or a
88
+ step count.
89
+
90
+ Args:
91
+ response (`str`):
92
+ The model's unedited reply.
93
+
94
+ Returns:
95
+ [`ParsedGuess`]: The extracted coordinates, or a result whose `ok` is
96
+ `False` with a `note` explaining what was wrong.
97
+
98
+ Examples:
99
+
100
+ ```python
101
+ parse_guess("I think coastal Portugal. <guess>38.72, -9.14</guess>")
102
+ ```
103
+ """
104
+ if not response or not response.strip():
105
+ return ParsedGuess(None, None, "none", "Empty response.")
106
+
107
+ tagged = _TAG.search(response)
108
+ haystacks = [(tagged.group(1), "tag")] if tagged else []
109
+ haystacks.append((response, "body"))
110
+
111
+ for text, origin in haystacks:
112
+ for pattern, name in ((_JSON_ISH, "json"), (_LABELLED, "labelled")):
113
+ m = pattern.search(text)
114
+ if m:
115
+ lat, lon = float(m.group(1)), float(m.group(2))
116
+ if _valid(lat, lon):
117
+ src = name if origin == "body" else "tag"
118
+ return ParsedGuess(lat, lon, src)
119
+ return ParsedGuess(
120
+ None, None, "none", f"Coordinates out of range: {lat}, {lon}."
121
+ )
122
+
123
+ dms = _DMS.findall(text)
124
+ if len(dms) >= 2:
125
+ lat_m = next((d for d in dms if d[3].upper() in ("N", "S")), None)
126
+ lon_m = next((d for d in dms if d[3].upper() in ("E", "W")), None)
127
+ if lat_m and lon_m:
128
+ lat = _dms_to_decimal(*lat_m)
129
+ lon = _dms_to_decimal(*lon_m)
130
+ if _valid(lat, lon):
131
+ return ParsedGuess(lat, lon, "dms")
132
+
133
+ m = _DECIMAL_PAIR.search(text)
134
+ if m:
135
+ lat, lon = float(m.group(1)), float(m.group(2))
136
+ if _valid(lat, lon):
137
+ src = "decimal" if origin == "body" else "tag"
138
+ return ParsedGuess(lat, lon, src)
139
+ return ParsedGuess(
140
+ None, None, "none", f"Coordinates out of range: {lat}, {lon}."
141
+ )
142
+
143
+ return ParsedGuess(
144
+ None,
145
+ None,
146
+ "none",
147
+ "No coordinates found. Reply with a latitude and longitude, for "
148
+ "example <guess>48.8584, 2.2945</guess>.",
149
+ )