Spaces:
Running
Running
| # SPDX-License-Identifier: BSD-3-Clause | |
| """Build a frozen evaluation set of 360-degree panorama tasks. | |
| Discovery is city-seeded and adaptive, which measured far better than the | |
| road-point probing in `build_pano_tasks.py`: | |
| 45 m probe boxes at Street View coordinates .......... 5% of seeds hit | |
| adaptive box, jittered 28 km around cities ........... 3% | |
| adaptive box, ±2 km around cities .................... 21% | |
| adaptive box, city centres ........................... 26%, 16 sequences/hit | |
| Two things drive that. Panoramas are extremely clustered — one hit typically | |
| yields a dozen or more sequences from the same contributor's drive — so the | |
| useful unit of discovery is a neighbourhood, not a point. And `is_pano=true` is | |
| a real server-side filter, so one request answers "are there panoramas here" | |
| instead of returning a hundred phone photographs to sift. | |
| An eval set is not a training pool, so this applies three extra rules: | |
| - **balance over size**: capped per country, because at n=100 balance decides | |
| what the number means | |
| - **disjoint from training**: any sequence already in the training index is | |
| rejected, and so is anything within a spatial buffer of it, since frames sit | |
| about 3.3 m apart and holding out an image holds out nothing | |
| - **mirrored and checksummed**: the imagery is downloaded and hashed, because | |
| Mapillary thumbnail URLs expire and uploads get deleted, and an eval whose | |
| images can change is not an eval | |
| Usage: | |
| python scripts/build_eval_set.py --tasks 100 --per-country 2 | |
| python scripts/build_eval_set.py --tasks 100 --exclude tasks/pano_v1.jsonl | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import concurrent.futures | |
| import functools | |
| import hashlib | |
| import json | |
| import logging | |
| import math | |
| import pathlib | |
| import random | |
| import sys | |
| import urllib.request | |
| sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) | |
| sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| from build_pano_tasks import ( # noqa: E402 | |
| _get, | |
| _month, | |
| _token, | |
| frame_detail, | |
| sequence_frames, | |
| TIMEOUT_S, | |
| ) | |
| from matplotlib.path import Path as MplPath # noqa: E402 | |
| from server.render.minimap import locate # noqa: E402 | |
| # Boxes are tried widest first; a dense city rejects the wide ones with a 500 | |
| # because the scan happens before the filter, so the ladder walks down. | |
| BBOX_LADDER = (0.005, 0.002, 0.0006) | |
| logging.basicConfig(level=logging.INFO, format="%(message)s") | |
| logger = logging.getLogger("build_eval_set") | |
| def _box(lat: float, lon: float, half: float) -> str: | |
| return f"{lon - half},{lat - half},{lon + half},{lat + half}" | |
| def _haversine_km(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float: | |
| phi_a, phi_b = math.radians(lat_a), math.radians(lat_b) | |
| d_phi, d_lambda = phi_b - phi_a, math.radians(lon_b - lon_a) | |
| h = ( | |
| math.sin(d_phi / 2) ** 2 | |
| + math.cos(phi_a) * math.cos(phi_b) * math.sin(d_lambda / 2) ** 2 | |
| ) | |
| return 2 * 6371.0088 * math.asin(math.sqrt(min(1.0, h))) | |
| def _country_polygons() -> list[tuple[str, MplPath]]: | |
| """Country name and outline for every admin-0 polygon, loaded once.""" | |
| root = pathlib.Path(__file__).resolve().parents[1] | |
| features = json.loads( | |
| (root / "data" / "geo" / "ne_110m_admin_0_countries.geojson").read_text() | |
| )["features"] | |
| out = [] | |
| for feature in features: | |
| geometry = feature["geometry"] | |
| rings = ( | |
| [geometry["coordinates"][0]] | |
| if geometry["type"] == "Polygon" | |
| else [poly[0] for poly in geometry["coordinates"]] | |
| ) | |
| for ring in rings: | |
| out.append((feature["properties"]["ADMIN"], MplPath(ring))) | |
| return out | |
| def _country_of(lat: float, lon: float) -> str | None: | |
| """Country containing a coordinate, or `None` over water. | |
| Cheaper than `locate()`, which also scans every populated place for the | |
| nearest city — needless when only the country is wanted for seeding. | |
| """ | |
| for name, path in _country_polygons(): | |
| if path.contains_point((lon, lat)): | |
| return name | |
| return None | |
| def load_seeds( | |
| geo_dir: pathlib.Path, limit: int, seed: int, stratify: bool = True | |
| ) -> list[tuple[str, float, float]]: | |
| """ | |
| City seeds, ordered to spread across countries. | |
| Panorama coverage clusters hard: a first run probed 900 cities and found | |
| 1,300 sequences, but from only 30 countries — so a per-country cap ran out | |
| of countries long before it ran out of sequences, and the set stopped at 58 | |
| of 100. Round-robin ordering by country means the first N probes touch N | |
| different countries instead of hammering whichever metros the shuffle picked. | |
| Args: | |
| geo_dir (`pathlib.Path`): | |
| Directory holding the bundled Natural Earth files. | |
| limit (`int`): | |
| How many seeds to return. | |
| seed (`int`): | |
| Seed for the shuffle within each country. | |
| stratify (`bool`, *optional*, defaults to `True`): | |
| Order round-robin by country rather than at random. | |
| Returns: | |
| `list[tuple[str, float, float]]`: `(name, lat, lon)` seeds. | |
| """ | |
| detail = geo_dir / "detail" / "places.json" | |
| if detail.exists(): | |
| rows = json.loads(detail.read_text()) | |
| points = [(r.get("n") or "", r["c"][1], r["c"][0]) for r in rows] | |
| else: | |
| features = json.loads((geo_dir / "ne_50m_populated_places.geojson").read_text()) | |
| points = [ | |
| ( | |
| f["properties"]["name"], | |
| f["geometry"]["coordinates"][1], | |
| f["geometry"]["coordinates"][0], | |
| ) | |
| for f in features["features"] | |
| ] | |
| rng = random.Random(seed) | |
| rng.shuffle(points) | |
| if not stratify: | |
| return points[:limit] | |
| by_country: dict[str | None, list] = {} | |
| for name, lat, lon in points: | |
| by_country.setdefault(_country_of(lat, lon), []).append((name, lat, lon)) | |
| by_country.pop(None, None) | |
| buckets = list(by_country.values()) | |
| rng.shuffle(buckets) | |
| ordered: list[tuple[str, float, float]] = [] | |
| depth = 0 | |
| while len(ordered) < limit and any(len(b) > depth for b in buckets): | |
| for bucket in buckets: | |
| if len(bucket) > depth: | |
| ordered.append(bucket[depth]) | |
| if len(ordered) >= limit: | |
| break | |
| depth += 1 | |
| logger.info( | |
| "seeded %d cities round-robin across %d countries", len(ordered), len(buckets) | |
| ) | |
| return ordered | |
| def discover(token: str, seeds, workers: int) -> dict[str, dict]: | |
| """Probe each seed and return one anchor image per panorama sequence.""" | |
| def probe(seed): | |
| name, lat, lon = seed | |
| for half in BBOX_LADDER: | |
| result = _get( | |
| token, | |
| "images", | |
| bbox=_box(lat, lon, half), | |
| fields="id,sequence,camera_type,quality_score,computed_geometry", | |
| is_pano="true", | |
| ) | |
| if "__error" not in result: | |
| return name, result.get("data", []) | |
| return name, [] | |
| found: dict[str, dict] = {} | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: | |
| for index, (name, images) in enumerate(pool.map(probe, seeds), 1): | |
| for image in images: | |
| sequence = image.get("sequence") | |
| if not sequence or sequence in found: | |
| continue | |
| if image.get("camera_type") != "spherical": | |
| continue | |
| found[sequence] = {**image, "__seed": name} | |
| if index % 50 == 0: | |
| logger.info( | |
| " %d/%d seeds probed, %d sequences", index, len(seeds), len(found) | |
| ) | |
| return found | |
| def mirror_frames( | |
| token: str, image_ids: list[str], cache_dir: pathlib.Path, workers: int | |
| ) -> dict[str, str]: | |
| """ | |
| Download and hash a task's frames, in parallel. | |
| An eval that prefetches all 24 frames so `move()` can be evaluated offline | |
| was spending 24 sequential round trips per task — about 30 s of the 34 s a | |
| task cost. Fetching them concurrently makes mirroring roughly free next to | |
| frame assembly. | |
| Args: | |
| token (`str`): | |
| Mapillary access token. | |
| image_ids (`list[str]`): | |
| Frames to mirror. | |
| cache_dir (`pathlib.Path`): | |
| Where the JPEGs live. | |
| workers (`int`): | |
| Concurrent downloads. | |
| Returns: | |
| `dict[str, str]`: sha256 by image id, for every frame that landed. | |
| """ | |
| def fetch_one(image_id: str) -> tuple[str, str | None]: | |
| path = cache_dir / f"{image_id}.jpg" | |
| if not path.exists(): | |
| meta = _get(token, image_id, fields="thumb_2048_url") | |
| url = meta.get("thumb_2048_url") if "__error" not in meta else None | |
| if not url: | |
| return image_id, None | |
| try: | |
| with urllib.request.urlopen(url, timeout=TIMEOUT_S) as response: | |
| path.write_bytes(response.read()) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning(" fetch failed for %s: %r", image_id, exc) | |
| return image_id, None | |
| return image_id, hashlib.sha256(path.read_bytes()).hexdigest() | |
| checksums: dict[str, str] = {} | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: | |
| for image_id, digest in pool.map(fetch_one, image_ids): | |
| if digest: | |
| checksums[image_id] = digest | |
| return checksums | |
| def load_exclusions( | |
| paths: list[pathlib.Path], | |
| ) -> tuple[set[str], list[tuple[float, float]]]: | |
| """Sequences and coordinates that a new task must stay away from.""" | |
| sequences: set[str] = set() | |
| points: list[tuple[float, float]] = [] | |
| for path in paths: | |
| if not path.exists(): | |
| logger.warning("exclusion file missing, skipping: %s", path) | |
| continue | |
| for line in path.read_text().splitlines(): | |
| if not line.strip(): | |
| continue | |
| row = json.loads(line) | |
| if row.get("sequence_id"): | |
| sequences.add(row["sequence_id"]) | |
| for frame in row.get("frames", []): | |
| points.append((frame["lat"], frame["lon"])) | |
| return sequences, points | |
| def build(args: argparse.Namespace) -> None: | |
| """Discover, filter, mirror and write the eval set.""" | |
| token = _token() | |
| root = pathlib.Path(__file__).resolve().parents[1] | |
| out_dir = args.out | |
| cache_dir = args.cache | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| cache_dir.mkdir(parents=True, exist_ok=True) | |
| excluded_sequences, excluded_points = load_exclusions(args.exclude) | |
| logger.info( | |
| "excluding %d training sequences and staying %.1f km from %d of their frames", | |
| len(excluded_sequences), | |
| args.buffer_km, | |
| len(excluded_points), | |
| ) | |
| seeds = load_seeds( | |
| root / "data" / "geo", args.seeds, args.seed, stratify=not args.no_stratify | |
| ) | |
| logger.info("probing %d city seeds with %d workers", len(seeds), args.workers) | |
| candidates = discover(token, seeds, args.workers) | |
| logger.info("found %d panorama sequences", len(candidates)) | |
| order = sorted( | |
| candidates.items(), | |
| key=lambda kv: -(kv[1].get("quality_score") or 0.0), | |
| ) | |
| per_country: dict[str, int] = {} | |
| tasks: list[dict] = [] | |
| rejected_early = 0 | |
| for sequence_id, anchor in order: | |
| if len(tasks) >= args.tasks: | |
| break | |
| if sequence_id in excluded_sequences: | |
| continue | |
| # Reject before assembling, not after. Discovery already returned the | |
| # anchor's coordinates, so the country cap and the training buffer can | |
| # both be checked for free — and with coverage as clustered as it is, | |
| # most candidates fall in an already-full country. Assembling 25 | |
| # requests' worth of frames first made a task cost 14.4 s instead of 5. | |
| geometry = anchor.get("computed_geometry") or anchor.get("geometry") | |
| if geometry: | |
| anchor_lon, anchor_lat = geometry["coordinates"] | |
| country_guess = _country_of(anchor_lat, anchor_lon) | |
| if ( | |
| country_guess is None | |
| or per_country.get(country_guess, 0) >= args.per_country | |
| ): | |
| rejected_early += 1 | |
| continue | |
| if any( | |
| _haversine_km(anchor_lat, anchor_lon, lat, lon) < args.buffer_km | |
| for lat, lon in excluded_points | |
| ): | |
| rejected_early += 1 | |
| continue | |
| ids = sequence_frames(token, sequence_id) | |
| if len(ids) < args.min_frames: | |
| continue | |
| anchor_id = str(anchor["id"]) | |
| position = ids.index(anchor_id) if anchor_id in ids else 0 | |
| low = max(0, position - args.frames // 2) | |
| window = ids[low : low + args.frames] | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool: | |
| details = list(pool.map(lambda i: frame_detail(token, i), window)) | |
| frames = [] | |
| for detail in details: | |
| if not detail or not detail.get("is_pano"): | |
| continue | |
| geometry = detail.get("computed_geometry") or detail.get("geometry") | |
| if not geometry: | |
| continue | |
| lon, lat = geometry["coordinates"] | |
| frames.append( | |
| { | |
| "image_id": str(detail["id"]), | |
| "lat": lat, | |
| "lon": lon, | |
| "compass_angle": detail.get("computed_compass_angle") | |
| or detail.get("compass_angle") | |
| or 0.0, | |
| "captured_at": _month(detail.get("captured_at")), | |
| "is_pano": True, | |
| } | |
| ) | |
| if len(frames) < args.min_frames: | |
| continue | |
| # A missing captured_at renders as an epoch date, which is harmless for | |
| # scoring but breaks a temporal split and looks wrong in the UI. | |
| # Mapillary launched in 2013. | |
| frames = [ | |
| f for f in frames if not f["captured_at"] or f["captured_at"][:4] >= "2010" | |
| ] | |
| if len(frames) < args.min_frames: | |
| continue | |
| start = min(len(frames) // 2, len(frames) - 1) | |
| start_frame = frames[start] | |
| too_close = any( | |
| _haversine_km(start_frame["lat"], start_frame["lon"], lat, lon) | |
| < args.buffer_km | |
| for lat, lon in excluded_points | |
| ) | |
| if too_close: | |
| continue | |
| place = locate(start_frame["lat"], start_frame["lon"]) | |
| country = place.country or "unknown" | |
| if country == "unknown" or per_country.get(country, 0) >= args.per_country: | |
| continue | |
| anchor_detail = next( | |
| (d for d in details if d and str(d["id"]) == start_frame["image_id"]), {} | |
| ) | |
| creator = anchor_detail.get("creator", {}) or {} | |
| # Mirror the imagery. An eval whose pictures can change upstream is not | |
| # an eval, and Mapillary thumbnail URLs expire. | |
| wanted = [f["image_id"] for f in frames[: args.prefetch]] | |
| if start_frame["image_id"] not in wanted: | |
| wanted.append(start_frame["image_id"]) | |
| checksums = mirror_frames(token, wanted, cache_dir, args.workers) | |
| if start_frame["image_id"] not in checksums: | |
| continue | |
| per_country[country] = per_country.get(country, 0) + 1 | |
| tasks.append( | |
| { | |
| "task_index": len(tasks), | |
| "task_id": f"eval-{len(tasks):04d}", | |
| "country": country, | |
| "sequence_id": sequence_id, | |
| "provider": "mapillary", | |
| "start_frame": start, | |
| "frames": frames, | |
| "attribution": { | |
| "creator_username": creator.get("username", ""), | |
| "creator_id": creator.get("id", ""), | |
| "licence": "CC-BY-SA-4.0", | |
| "source": "Mapillary", | |
| }, | |
| "meta": { | |
| "seed_name": anchor.get("__seed", ""), | |
| "camera_make": anchor_detail.get("make", ""), | |
| "camera_model": anchor_detail.get("model", ""), | |
| "quality_score": anchor_detail.get("quality_score"), | |
| "continent": place.continent, | |
| "subregion": place.subregion, | |
| "nearest_city": place.nearest_city, | |
| "sha256": checksums, | |
| "mirrored_frames": len(checksums), | |
| }, | |
| } | |
| ) | |
| logger.info( | |
| " eval %3d %-24s %-8s %2d frames %d mirrored", | |
| len(tasks) - 1, | |
| country[:24], | |
| start_frame["captured_at"], | |
| len(frames), | |
| len(checksums), | |
| ) | |
| index_path = out_dir / args.name | |
| with index_path.open("w") as handle: | |
| for task in tasks: | |
| handle.write(json.dumps(task) + "\n") | |
| logger.info( | |
| "rejected %d candidates before assembly, on country cap or buffer", | |
| rejected_early, | |
| ) | |
| countries = sorted(per_country.items(), key=lambda kv: -kv[1]) | |
| logger.info( | |
| "\nwrote %d eval tasks to %s (%.0f KB)", | |
| len(tasks), | |
| index_path, | |
| index_path.stat().st_size / 1024, | |
| ) | |
| logger.info("countries: %d -> %s", len(countries), dict(countries[:12])) | |
| mirrored = sum(t["meta"]["mirrored_frames"] for t in tasks) | |
| logger.info("mirrored %d images into %s", mirrored, cache_dir) | |
| def main() -> None: | |
| """Command-line entry point.""" | |
| root = pathlib.Path(__file__).resolve().parents[1] | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--tasks", type=int, default=100) | |
| parser.add_argument("--per-country", type=int, default=2) | |
| parser.add_argument("--frames", type=int, default=24) | |
| parser.add_argument("--min-frames", type=int, default=8) | |
| parser.add_argument("--prefetch", type=int, default=1) | |
| parser.add_argument("--seeds", type=int, default=900) | |
| parser.add_argument("--workers", type=int, default=10) | |
| parser.add_argument("--seed", type=int, default=1234) | |
| parser.add_argument( | |
| "--no-stratify", | |
| action="store_true", | |
| help="Probe cities in random order instead of round-robin by country.", | |
| ) | |
| parser.add_argument("--buffer-km", type=float, default=1.0) | |
| parser.add_argument("--name", default="eval_pano_v1.jsonl") | |
| parser.add_argument( | |
| "--exclude", | |
| nargs="*", | |
| type=pathlib.Path, | |
| default=[root / "tasks" / "pano_v1.jsonl"], | |
| ) | |
| parser.add_argument("--out", type=pathlib.Path, default=root / "tasks") | |
| parser.add_argument("--cache", type=pathlib.Path, default=root / "data" / "panos") | |
| build(parser.parse_args()) | |
| if __name__ == "__main__": | |
| main() | |