Spaces:
Running
Running
Deploy geoguesser environment with train/eval splits
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- Dockerfile +11 -6
- geoguesser_env/.gitignore +23 -0
- geoguesser_env/.openenvignore +4 -0
- geoguesser_env/DESIGN.md +147 -0
- geoguesser_env/HARVEST.md +239 -0
- geoguesser_env/README.md +578 -0
- geoguesser_env/__init__.py +49 -0
- geoguesser_env/client.py +143 -0
- geoguesser_env/data/geo/detail/places.json +0 -0
- geoguesser_env/data/geo/detail/rivers.json +0 -0
- geoguesser_env/data/geo/detail/roads.json +3 -0
- geoguesser_env/data/geo/detail/urban.json +3 -0
- geoguesser_env/data/geo/ne_110m_admin_0_countries.geojson +0 -0
- geoguesser_env/data/geo/ne_50m_populated_places.geojson +0 -0
- geoguesser_env/examples/geoguesser_collect.py +120 -0
- geoguesser_env/examples/geoguesser_llm_rollout.py +658 -0
- geoguesser_env/examples/geoguesser_rollout.py +85 -0
- geoguesser_env/harness.py +440 -0
- geoguesser_env/models.py +458 -0
- geoguesser_env/openenv.yaml +22 -0
- geoguesser_env/openenv_geoguesser_env.egg-info/PKG-INFO +233 -0
- geoguesser_env/openenv_geoguesser_env.egg-info/SOURCES.txt +21 -0
- geoguesser_env/openenv_geoguesser_env.egg-info/dependency_links.txt +1 -0
- geoguesser_env/openenv_geoguesser_env.egg-info/entry_points.txt +2 -0
- geoguesser_env/openenv_geoguesser_env.egg-info/requires.txt +14 -0
- geoguesser_env/openenv_geoguesser_env.egg-info/top_level.txt +1 -0
- geoguesser_env/pyproject.toml +34 -0
- geoguesser_env/scripts/benchmark_throughput.py +113 -0
- geoguesser_env/scripts/build_dataset.sh +162 -0
- geoguesser_env/scripts/build_eval_set.py +511 -0
- geoguesser_env/scripts/build_pano_tasks.py +404 -0
- geoguesser_env/scripts/build_tasks.py +560 -0
- geoguesser_env/scripts/deploy_hub.py +427 -0
- geoguesser_env/scripts/fetch_detail_geo.py +106 -0
- geoguesser_env/scripts/harvest_tiles.py +330 -0
- geoguesser_env/scripts/merge_task_indexes.py +117 -0
- geoguesser_env/scripts/readiness_check.py +352 -0
- geoguesser_env/scripts/render_trace.py +237 -0
- geoguesser_env/scripts/split_tasks.py +193 -0
- geoguesser_env/scripts/verify_offline.py +206 -0
- geoguesser_env/server/Dockerfile +26 -0
- geoguesser_env/server/__init__.py +3 -0
- geoguesser_env/server/app.py +340 -0
- geoguesser_env/server/backends/__init__.py +7 -0
- geoguesser_env/server/backends/base.py +127 -0
- geoguesser_env/server/backends/panorama.py +399 -0
- geoguesser_env/server/geoguesser_environment.py +883 -0
- geoguesser_env/server/gradio_ui.py +1044 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
ENV PYTHONPATH=/app
|
| 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/
|
| 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/
|
| 74 |
-
&& chmod -R 777 /app/
|
| 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">◀</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">◎</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">▶</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">▲</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">▼</span><span class="tag">back</span></button>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<div class="pad" id="padZoom">
|
| 309 |
+
<span class="padlabel">zoom · <b id="fovValue">90°</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">·</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)">–</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 © " + (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 |
+
)
|