# SPDX-License-Identifier: BSD-3-Clause """Deploy the GeoGuesser environment to the Hugging Face Hub. Three artifacts, split by what each is good at: bucket HuggingEnvs/geoguesser-panos 22 GB of imagery + both indexes dataset HuggingEnvs/geoguesser-tasks the indexes alone, versioned space HuggingEnvs/geoguesser-env the running environment The bucket exists because Space disk is ephemeral and capped well below 22 GB, and because buckets are mutable object storage rather than git. The dataset repo exists because a bucket is *not* versioned: a frozen benchmark needs a place where a change to it is visible in history. The Space mounts the bucket read-only at /data, so the same image serves a local checkout and the Hub with nothing but environment variables between them. `openenv push` is deliberately not used: it cannot attach a bucket volume, and its default excludes would upload 22 GB of panoramas into git. Usage: export HF_TOKEN=hf_... python scripts/deploy_hub.py --all python scripts/deploy_hub.py --space # code only, fast iteration python scripts/deploy_hub.py --verify # check a live deployment """ from __future__ import annotations import argparse import json import logging import os import pathlib import shutil import sys import tempfile logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger("deploy_hub") ROOT = pathlib.Path(__file__).resolve().parents[1] REPO_ROOT = ROOT.parents[1] ORG = os.getenv("GEOGUESSER_HF_ORG", "HuggingEnvs") SPACE_ID = f"{ORG}/geoguesser-env" BUCKET_ID = f"{ORG}/geoguesser-panos" DATASET_ID = f"{ORG}/geoguesser-tasks" MOUNT_PATH = "/data" EVAL_INDEX = "eval_pano_v3.jsonl" TRAIN_INDEX = "train_pano_v3.jsonl" # Anything matching these never reaches the Space repo. The panorama cache and # the sequence pool are the dangerous ones: 22 GB and 254 MB respectively. SPACE_EXCLUDES = ( "env/data/panos/*", "env/data/pool/*", f"env/tasks/{TRAIN_INDEX}", "env/tasks/pool_offline_5k.jsonl", "env/tasks/eval_pano_v1.jsonl", "env/tasks/eval_pano_v2.jsonl", "env/tasks/eval_balanced_trial.jsonl", "env/rollouts/*", "env/tests/*", "env/.venv/*", "**/__pycache__/*", "**/*.pyc", "src/**/__pycache__/*", ) SPACE_VARIABLES = { "ENABLE_WEB_INTERFACE": "true", "GEOGUESSER_TASKS_EVAL": f"{MOUNT_PATH}/tasks/{EVAL_INDEX}", "GEOGUESSER_TASKS_TRAIN": f"{MOUNT_PATH}/tasks/{TRAIN_INDEX}", "GEOGUESSER_DEFAULT_SPLIT": "train", "GEOGUESSER_CACHE": f"{MOUNT_PATH}/panos", # The mirror is complete, so a cache miss is a bug worth hearing about # rather than something to paper over with a network call. "GEOGUESSER_ALLOW_FETCH": "0", # Overpass only, and it caches to the container's own disk, so labelled # streets work even with imagery fetching disabled. "GEOGUESSER_STREET_DETAIL": "1", "GEOGUESSER_MAX_STEPS": "24", } CARD_FRONTMATTER = """--- title: Geoguesser Environment emoji: 🌍 colorFrom: green colorTo: blue sdk: docker pinned: false app_port: 8000 base_path: /web tags: - openenv - reinforcement-learning - geolocation - mcp-server --- """ def token() -> str: """Resolve an HF token from the environment or the repo `.env`.""" for name in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"): value = os.getenv(name) if value: return value env_file = REPO_ROOT / ".env" if env_file.exists(): for line in env_file.read_text().splitlines(): if line.startswith("HF_TOKEN="): return line.split("=", 1)[1].strip().strip('"').strip("'") raise SystemExit("no HF_TOKEN; export it or add it to the repo .env") def stage_space(staging: pathlib.Path) -> None: """ Assemble the Space repository layout. The Space is self-contained: it vendors `src/openenv` rather than installing a release, because the Gradio tab naming this environment uses is newer than the published package. """ shutil.copytree( REPO_ROOT / "src", staging / "src", ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.egg-info"), ) shutil.copytree( ROOT, staging / "env", ignore=shutil.ignore_patterns( "__pycache__", "*.pyc", ".venv", "panos", "pool", "rollouts", "tests", "space", "osm_cache", TRAIN_INDEX, "pool_offline_5k.jsonl", "eval_pano_v1.jsonl", "eval_pano_v2.jsonl", "eval_balanced_trial.jsonl", ), ) shutil.copy(ROOT / "space" / "Dockerfile", staging / "Dockerfile") # The card is the env README with Space frontmatter prepended, so the Hub # page and the repo documentation cannot drift apart. body = (ROOT / "README.md").read_text() if body.startswith("---"): body = body.split("---", 2)[-1].lstrip() (staging / "README.md").write_text(CARD_FRONTMATTER + body) # The detail vectors drive the labelled maps and are gitignored locally, so # confirm they made it rather than shipping a Space with bare maps. detail = staging / "env" / "data" / "geo" / "detail" present = sorted(p.name for p in detail.glob("*.json")) if detail.exists() else [] missing = {"places.json", "roads.json", "rivers.json", "urban.json"} - set(present) if missing: logger.warning( "detail vectors missing from the staged Space: %s -- agent maps will " "have no towns or roads. Run scripts/fetch_detail_geo.py first.", sorted(missing), ) size = sum(p.stat().st_size for p in staging.rglob("*") if p.is_file()) logger.info( "staged %d files, %.1f MB", sum(1 for p in staging.rglob("*") if p.is_file()), size / 1e6, ) if size > 500e6: raise SystemExit( f"staged Space is {size / 1e6:.0f} MB, which means an exclude did " "not match. Refusing to push." ) def push_space(hf_token: str) -> None: """Create the Space if needed, upload the code, and wire up its runtime.""" from huggingface_hub import HfApi, Volume api = HfApi(token=hf_token) api.create_repo( SPACE_ID, repo_type="space", space_sdk="docker", private=False, exist_ok=True ) with tempfile.TemporaryDirectory() as tmp: staging = pathlib.Path(tmp) / "space" staging.mkdir() stage_space(staging) logger.info("uploading to %s", SPACE_ID) api.upload_folder( repo_id=SPACE_ID, repo_type="space", folder_path=str(staging), ignore_patterns=list(SPACE_EXCLUDES), commit_message="Deploy geoguesser environment with train/eval splits", ) for key, value in SPACE_VARIABLES.items(): api.add_space_variable(repo_id=SPACE_ID, key=key, value=value) logger.info("set %d Space variables", len(SPACE_VARIABLES)) # set_space_volumes REPLACES the whole list, so anything already attached # has to be carried forward or it is silently unmounted. existing = [] try: existing = list(api.get_space_runtime(SPACE_ID).volumes or []) except Exception as exc: # noqa: BLE001 logger.warning("could not read current volumes: %r", exc) keep = [ v for v in existing if getattr(v, "mount_path", None) != MOUNT_PATH and getattr(v, "source", None) != BUCKET_ID ] api.set_space_volumes( repo_id=SPACE_ID, volumes=keep + [ Volume( type="bucket", source=BUCKET_ID, mount_path=MOUNT_PATH, read_only=True, ) ], ) logger.info("mounted %s read-only at %s", BUCKET_ID, MOUNT_PATH) logger.info("space: https://huggingface.co/spaces/%s", SPACE_ID) def push_dataset(hf_token: str) -> None: """Publish the task indexes and a card describing how they were built.""" from huggingface_hub import HfApi api = HfApi(token=hf_token) api.create_repo(DATASET_ID, repo_type="dataset", private=False, exist_ok=True) eval_path = ROOT / "tasks" / EVAL_INDEX train_path = ROOT / "tasks" / TRAIN_INDEX stats = {} for name, path in (("eval", eval_path), ("train", train_path)): if not path.exists(): logger.warning("%s missing, skipping: %s", name, path) continue rows = [ json.loads(line) for line in path.read_text().splitlines() if line.strip() ] stats[name] = { "tasks": len(rows), "countries": len({r["country"] for r in rows}), "frames": sum(len(r["frames"]) for r in rows), "offline": sum(1 for r in rows if r["meta"].get("offline_ready")), } with tempfile.TemporaryDirectory() as tmp: staging = pathlib.Path(tmp) for path in (eval_path, train_path): if path.exists(): shutil.copy(path, staging / path.name) (staging / "README.md").write_text(dataset_card(stats)) api.upload_folder( repo_id=DATASET_ID, repo_type="dataset", folder_path=str(staging), commit_message="GeoGuesser task splits", ) logger.info("dataset: https://huggingface.co/datasets/%s", DATASET_ID) def dataset_card(stats: dict) -> str: """Render the dataset card from measured figures, never hardcoded ones.""" rows = "\n".join( f"| `{name}` | {s['tasks']} | {s['countries']} | {s['frames']} | " f"{s['offline']}/{s['tasks']} |" for name, s in stats.items() ) return f"""--- license: cc-by-sa-4.0 task_categories: - image-classification tags: - geolocation - openenv - reinforcement-learning pretty_name: GeoGuesser Task Splits --- # GeoGuesser Task Splits Task indexes for the [GeoGuesser OpenEnv environment](https://huggingface.co/spaces/{SPACE_ID}). Each line is one episode: an ordered list of panorama frames with coordinates, headings and capture dates, plus the sequence and contributor it came from. | Split | Tasks | Countries | Frames | Fully mirrored | |---|---|---|---|---| {rows} ## What a task is These files carry **metadata only**, not imagery. Every frame's coordinates, heading and capture date are here, so the movement graph resolves with no network access; only image bytes need fetching, and Mapillary's `thumb_*_url` values are expiring signed CDN URLs that cannot be stored. Resolve them from `image_id` through the Mapillary Graph API, or mirror them once with `scripts/build_tasks.py` from the environment repository. ## How the split was made Both splits are carved from one 3,673-task pool, so contamination is enforced exactly once rather than reasoned about across two separate harvests. The rules follow the OSV-5M paper, which built its train/test split from the same Mapillary source: - no shared `sequence_id` between splits - no training task within **1 km** of an eval task The buffer matters because frames sit about 3.3 m apart: holding out an image while keeping its neighbour holds out nothing. The split script verifies its own output and exits non-zero if either rule is violated. Eval is carved first, balanced by country and capped at 4 tasks each, because at a couple of hundred tasks the balance decides what the score means. An earlier unbalanced attempt put 28% of the set in one country. ## Provenance Imagery is from [Mapillary](https://www.mapillary.com), CC BY-SA 4.0. Each task records its contributor in `attribution`, which the environment displays. Only 360-degree panoramas are included (`camera_type == "spherical"`; note that the documented value `equirectangular` does not appear in practice). Sequences were discovered by enumerating Mapillary's `mly1_public` vector tiles at zoom 6, where the sequence layer carries `is_pano` — 1.2 million panorama sequences worldwide. Candidates are sampled with weight proportional to local image density raised to **-0.75**, the OSV-5M weighting, then capped per country and per contributor: one contributor alone holds 8% of the pool. ## Reproducing ```bash git clone https://github.com/huggingface/OpenEnv cd OpenEnv/envs/geoguesser_env export MAPILLARY_API_KEY_TRAIN="MLY|..." python scripts/harvest_tiles.py # enumerate sequences worldwide ./scripts/build_dataset.sh # assemble and mirror tasks python scripts/verify_offline.py tasks/pool_offline_5k.jsonl python scripts/split_tasks.py tasks/pool_offline_5k.jsonl --eval 200 ``` A rebuild will not reproduce these exact tasks — the pool is sampled and upstream coverage changes — which is precisely why the split is published rather than left to be regenerated. """ def verify(hf_token: str) -> int: """Exercise a deployed Space the way a client would. Returns an exit code.""" import urllib.error import urllib.request base = f"https://{ORG.lower()}-geoguesser-env.hf.space" logger.info("verifying %s", base) failures = [] def get(path: str) -> object: request = urllib.request.Request( base + path, headers={"Authorization": f"Bearer {hf_token}"} ) with urllib.request.urlopen(request, timeout=60) as response: return json.loads(response.read()) try: get("/health") logger.info(" [PASS] /health") except Exception as exc: # noqa: BLE001 failures.append(f"/health: {exc!r}") logger.error(" [FAIL] /health: %r", exc) return 1 try: splits = get("/geoguesser_env/splits") names = {s["name"]: s["num_tasks"] for s in splits} logger.info(" [PASS] /splits -> %s", names) if "eval" not in names: failures.append("no eval split; is the bucket mounted?") except Exception as exc: # noqa: BLE001 failures.append(f"/splits: {exc!r}") logger.error(" [FAIL] /splits: %r", exc) for failure in failures: logger.error(" %s", failure) return 1 if failures else 0 def main() -> None: """Command-line entry point.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--all", action="store_true", help="Space and dataset.") parser.add_argument("--space", action="store_true") parser.add_argument("--dataset", action="store_true") parser.add_argument("--verify", action="store_true") args = parser.parse_args() if not any((args.all, args.space, args.dataset, args.verify)): parser.error("pick at least one of --all, --space, --dataset, --verify") hf_token = token() if args.all or args.dataset: push_dataset(hf_token) if args.all or args.space: push_space(hf_token) if args.verify: sys.exit(verify(hf_token)) if __name__ == "__main__": main()