Spaces:
Running
Running
| # 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() | |