TheAiCollectiveART's picture
Initial release of Language U Microscopy submission framework
2fe488a verified
Raw
History Blame Contribute Delete
6.84 kB
#!/usr/bin/env python
"""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] = []
# Check for missing predictions against the splits file
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)
# node_recall needs the pred graph to have been matched, which
# evaluate() only does when pred has edges + nodes.
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()