geoguesser-env / env /scripts /split_tasks.py
AdithyaSK's picture
AdithyaSK HF Staff
Deploy geoguesser environment with train/eval splits
ee44678 verified
Raw
History Blame Contribute Delete
6.89 kB
# SPDX-License-Identifier: BSD-3-Clause
"""Split one harvested pool into a frozen eval set and a training pool.
Splitting after harvesting is the safer order. If the two sets are harvested
separately you have to reason about contamination across two runs; if they come
from one pool you can enforce disjointness exactly once, here, and check it.
Two rules, both from the OSV-5M paper, which built its train/test split from the
same Mapillary source:
- no shared `sequence_id`
- no eval task within `--buffer-km` of any training task
The buffer matters because frames sit about 3.3 m apart. Holding out an image
while keeping its neighbour holds out nothing at all.
Eval is carved first and balanced by country, because at a couple of hundred
tasks balance decides what the number means. Training takes everything left over
that clears the buffer.
Usage:
python scripts/split_tasks.py tasks/pool_v1.jsonl --eval 200
python scripts/split_tasks.py tasks/pool_v1.jsonl --eval 200 --buffer-km 2
"""
from __future__ import annotations
import argparse
import collections
import json
import logging
import math
import pathlib
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("split_tasks")
def haversine_km(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
"""Great-circle distance in kilometres."""
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 start_point(task: dict) -> tuple[float, float]:
"""The coordinate a task is scored against."""
frame = task["frames"][task["start_frame"]]
return frame["lat"], frame["lon"]
def renumber(tasks: list[dict], prefix: str) -> list[dict]:
"""Rewrite indices so the backend's contiguity requirement holds."""
out = []
for index, task in enumerate(tasks):
task = dict(task)
task["task_index"] = index
task["task_id"] = f"{prefix}-{index:05d}"
out.append(task)
return out
def split(args: argparse.Namespace) -> None:
"""Carve the eval set, then hand the remainder to training."""
pool = [
json.loads(line) for line in args.pool.read_text().splitlines() if line.strip()
]
logger.info("pool holds %d tasks", len(pool))
# Eval first, balanced by country. Ordering is by completeness before
# quality: a task missing frames is unusable in a set whose whole purpose is
# to run with the network off, and no quality score compensates for that.
def eval_rank(task: dict) -> tuple[int, float]:
complete = 1 if task["meta"].get("offline_ready") else 0
return (-complete, -(task["meta"].get("quality_score") or 0.0))
incomplete = sum(1 for t in pool if not t["meta"].get("offline_ready"))
if incomplete:
logger.info(
"%d pool tasks are not fully mirrored; they sort last for eval "
"(run scripts/verify_offline.py to repair)",
incomplete,
)
by_quality = sorted(pool, key=eval_rank)
per_country: collections.Counter = collections.Counter()
eval_tasks: list[dict] = []
for task in by_quality:
if len(eval_tasks) >= args.eval:
break
country = task["country"]
if per_country[country] >= args.eval_per_country:
continue
per_country[country] += 1
eval_tasks.append(task)
eval_sequences = {t["sequence_id"] for t in eval_tasks}
eval_points = [start_point(t) for t in eval_tasks]
train_tasks: list[dict] = []
dropped_sequence = 0
dropped_buffer = 0
for task in pool:
if task["sequence_id"] in eval_sequences:
dropped_sequence += 1
continue
lat, lon = start_point(task)
if any(
haversine_km(lat, lon, e_lat, e_lon) < args.buffer_km
for e_lat, e_lon in eval_points
):
dropped_buffer += 1
continue
train_tasks.append(task)
eval_out = args.out_dir / args.eval_name
train_out = args.out_dir / args.train_name
for path, tasks, prefix in (
(eval_out, eval_tasks, "eval"),
(train_out, train_tasks, "train"),
):
with path.open("w") as handle:
for task in renumber(tasks, prefix):
handle.write(json.dumps(task) + "\n")
# Verify rather than assert in a comment.
train_sequences = {t["sequence_id"] for t in train_tasks}
overlap = eval_sequences & train_sequences
closest = min(
(
haversine_km(*start_point(t), *point)
for t in train_tasks
for point in eval_points
),
default=float("inf"),
)
countries_eval = collections.Counter(t["country"] for t in eval_tasks)
logger.info(
"\neval %4d tasks -> %s (%d countries, max %d per country)",
len(eval_tasks),
eval_out.name,
len(countries_eval),
max(countries_eval.values()) if countries_eval else 0,
)
logger.info(
"train %4d tasks -> %s (dropped %d on shared sequence, %d on the %.1f km buffer)",
len(train_tasks),
train_out.name,
dropped_sequence,
dropped_buffer,
args.buffer_km,
)
logger.info(
"\ncontamination check: %d shared sequences, closest train task is %.2f km "
"from an eval task",
len(overlap),
closest,
)
if overlap or closest < args.buffer_km:
raise SystemExit("split failed its own contamination check")
mirrored = sum(1 for t in eval_tasks if t["meta"].get("offline_ready"))
logger.info(
"eval tasks fully mirrored for offline use: %d/%d", mirrored, len(eval_tasks)
)
if mirrored < len(eval_tasks):
raise SystemExit(
f"{len(eval_tasks) - mirrored} eval tasks are not fully mirrored. "
"A frozen eval set must run offline; repair with "
"scripts/verify_offline.py or lower --eval."
)
def main() -> None:
"""Command-line entry point."""
root = pathlib.Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pool", type=pathlib.Path)
parser.add_argument("--eval", type=int, default=200)
parser.add_argument("--eval-per-country", type=int, default=4)
parser.add_argument("--buffer-km", type=float, default=1.0)
parser.add_argument("--eval-name", default="eval_pano_v3.jsonl")
parser.add_argument("--train-name", default="train_pano_v3.jsonl")
parser.add_argument("--out-dir", type=pathlib.Path, default=root / "tasks")
split(parser.parse_args())
if __name__ == "__main__":
main()