| |
| """Evaluate predictions against ground truth and save results. |
| |
| Discovers predictions from predictions/{username}/{method}/split_{fold}/*.geff, |
| loads matching GT from DATASET_PATH/{name}.geff, computes edge and division |
| TP/FP/FN per sample via :func:`biohub_tracking.metrics.evaluate`, and upserts |
| per-sample counts into a shared SQLite DB (WAL mode for concurrency). |
| |
| Per-run summary prints **cumulative (micro-averaged) Jaccard** for edges and |
| divisions — i.e. TP/FP/FN are summed across all samples of the run and Jaccard |
| is computed from those totals. |
| |
| Usage: |
| python scripts/evaluate.py |
| python scripts/evaluate.py --split 0 |
| """ |
|
|
| import argparse |
| import json |
| from pathlib import Path |
|
|
| import tracksdata as td |
| from geff import GeffMetadata |
| from tqdm import tqdm |
|
|
| from biohub_tracking.io import open_dataset |
| from biohub_tracking.metrics import ( |
| evaluate as compute_metric, |
| nan_metrics_row, |
| node_recall, |
| per_sample_metrics, |
| summarise, |
| ) |
|
|
| from dataspec import DATASET_PATH, INTERACTIVE, PREDICTIONS_PATH, USERNAME |
|
|
| PREDICTIONS_DIR = PREDICTIONS_PATH |
| DATA_DIR = DATASET_PATH |
|
|
|
|
| def _read_estimated_n_total(geff_path: Path) -> float: |
| """Read ``estimated_number_of_nodes`` from a GEFF file's metadata extras. |
| |
| Returns NaN when the key is missing or the file can't be read. |
| """ |
| try: |
| meta = GeffMetadata.read(geff_path) |
| except Exception: |
| return float("nan") |
| val = (meta.extra or {}).get("estimated_number_of_nodes") |
| return float(val) if val is not None else float("nan") |
|
|
|
|
| def discover_runs( |
| predictions_dir: Path, |
| username: str | None = None, |
| method: str | None = None, |
| fold: int | None = None, |
| ) -> list[dict]: |
| """Find all (username, method, fold) combinations with predictions. |
| |
| Layout: predictions/{username}/{method}/split_{fold}/*.geff |
| """ |
| runs = [] |
| users = [username] if username else sorted(p.name for p in predictions_dir.iterdir() if p.is_dir()) |
| for u in users: |
| u_dir = predictions_dir / u |
| if not u_dir.is_dir(): |
| continue |
| methods = [method] if method else sorted(p.name for p in u_dir.iterdir() if p.is_dir()) |
| for m in methods: |
| m_dir = u_dir / m |
| if not m_dir.is_dir(): |
| continue |
| splits = [f"split_{fold}"] if fold is not None else sorted(p.name for p in m_dir.iterdir() if p.is_dir() and p.name.startswith("split_")) |
| for s in splits: |
| s_dir = m_dir / s |
| if not s_dir.is_dir(): |
| continue |
| geffs = sorted(s_dir.glob("*.geff")) |
| if geffs: |
| runs.append({"username": u, "method": m, "split": s, "dir": s_dir, "geffs": geffs}) |
| return runs |
|
|
|
|
| def _nan_row(username: str, method: str, split: str, dataset: str) -> dict: |
| return { |
| "username": username, "method": method, |
| "split": split, "dataset": dataset, |
| **nan_metrics_row(), |
| } |
|
|
|
|
| def evaluate_run(run: dict, max_distance: float | None = None) -> list[dict]: |
| """Evaluate all predictions in a single run, return per-sample results.""" |
| username = run["username"] |
| method = run["method"] |
| split = run["split"] |
| results: list[dict] = [] |
|
|
| |
| splits_file = DATA_DIR / "dataset_splits.json" |
| if splits_file.exists(): |
| fold_idx = int(split.split("_")[1]) |
| folds = json.loads(splits_file.read_text()) |
| expected = set(folds[fold_idx]["test"]) |
| found = {p.stem for p in run["geffs"]} |
| missing = sorted(expected - found) |
| if missing: |
| print(f" WARNING: {len(missing)} missing predictions for {username}/{method}/{split}: {missing[:5]}{'...' if len(missing) > 5 else ''}") |
| for name in missing: |
| results.append(_nan_row(username, method, split, name)) |
|
|
| desc = f" {username}/{method}/{split}" |
| for pred_path in tqdm(run["geffs"], desc=desc, leave=False, disable=not INTERACTIVE): |
| name = pred_path.stem |
| gt_path = DATA_DIR / f"{name}.geff" |
|
|
| if not gt_path.exists(): |
| print(f" WARNING: GT not found for {name}, skipping") |
| continue |
|
|
| try: |
| ds = open_dataset(DATA_DIR / name, require_tracks=True) |
| pred_result = td.graph.IndexedRXGraph.from_geff(pred_path) |
| pred_graph = pred_result[0] if isinstance(pred_result, tuple) else pred_result |
|
|
| kwargs: dict = dict(scale=ds.scale) |
| if max_distance is not None: |
| kwargs["max_distance"] = max_distance |
| er = compute_metric(pred_graph, ds.tracks, **kwargs) |
|
|
| |
| |
| if pred_graph.num_edges() > 0 and pred_graph.num_nodes() > 0: |
| recall = node_recall(pred_graph, ds.tracks) |
| else: |
| recall = 0.0 |
|
|
| n_total = _read_estimated_n_total(gt_path) |
| row = { |
| "username": username, "method": method, |
| "split": split, "dataset": name, |
| **per_sample_metrics(er, n_total, recall), |
| } |
| except Exception as e: |
| print(f" ERROR evaluating {name}: {e}") |
| row = _nan_row(username, method, split, name) |
|
|
| results.append(row) |
|
|
| return results |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Evaluate tracking predictions.") |
| parser.add_argument("--method", type=str, default=None, help="Evaluate only this method. Default: all discovered.") |
| parser.add_argument("--split", type=str, default=None, help="Split index (0-4) or 'all'. Default: all discovered.") |
| parser.add_argument("--max-distance", type=float, default=None) |
| args = parser.parse_args() |
|
|
| fold = None if args.split is None or args.split == "all" else int(args.split) |
|
|
| runs = discover_runs(PREDICTIONS_DIR, username=USERNAME, method=args.method, fold=fold) |
| if not runs: |
| print("No predictions found.") |
| return |
|
|
| print(f"Found {len(runs)} run(s)") |
|
|
| for run in runs: |
| results = evaluate_run(run, max_distance=args.max_distance) |
|
|
| s = summarise(results) |
| print( |
| f" {run['username']}/{run['method']}/{run['split']}: " |
| f"score={s['score']:.4f} " |
| f"edge_jaccard={s['edge_jaccard']:.4f} " |
| f"adj_edge_jaccard={s['adj_edge_jaccard']:.4f} (n_adj={s['n_adj']}) " |
| f"division_jaccard={s['division_jaccard']:.4f} " |
| f"(TP={s['division_tp']} FP={s['division_fp']} FN={s['division_fn']}) " |
| f"node_recall={s['node_recall']:.4f} (n={s['n']})" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|