Spaces:
Runtime error
Runtime error
| """Re-ID evaluation harness — closed-set + open-set metrics. | |
| Inputs | |
| ------ | |
| /seed_data/eval/{identity}/{photo}.jpg (default; override with --eval-root) | |
| Each subfolder = one dog identity. Need ≥ 2 photos per identity. | |
| For the n-ref=K experiment, an identity needs ≥ K+1 photos. | |
| Outputs | |
| ------- | |
| /seed_data/eval_results/{timestamp}/ | |
| ├── closed_set.csv # R@1, R@5, mAP per (method, n_refs, split) | |
| ├── open_set.csv # sensitivity, specificity, F1 per (method, n_refs, threshold, split) | |
| ├── roc.csv # TPR/FPR per (method, n_refs, threshold) for ROC plotting | |
| └── summary.md # human-readable summary of both | |
| Closed-set: standard R@K and mAP. Always assumes the correct dog is in the gallery. | |
| Open-set: also runs a batch of "out-of-gallery" queries (sampled from your DB's | |
| `source='filler'` rows) — they should be rejected. We sweep a top-1 score | |
| threshold to compute sensitivity / specificity / F1 at each operating point. | |
| Methods compared | |
| ---------------- | |
| flat — rank individual photos, dedupe by identity | |
| centroid — mean of identity refs (re-normalized), one sim per identity | |
| max_sim — max over (query × ref) pairs per identity | |
| max_sim_bonus — max × (1 + 0.5 × strong_hits) (current production) | |
| Run inside the backend container | |
| -------------------------------- | |
| docker compose exec backend python -m scripts.eval | |
| # or, if your data lives in /seed_data/targets: | |
| docker compose exec backend python -m scripts.eval --eval-root /seed_data/targets | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import hashlib | |
| import logging | |
| from collections import defaultdict | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import numpy as np | |
| from PIL import Image | |
| from sqlalchemy import select | |
| from app.db import SessionLocal | |
| from app.models import Sighting | |
| from app.services.detector import NoDogDetectedError | |
| from app.services.pipeline import process | |
| log = logging.getLogger("eval") | |
| VALID_EXT = {".jpg", ".jpeg", ".png", ".webp"} | |
| STRONG_THRESHOLD = 0.7 | |
| CLUSTER_BONUS = 0.5 | |
| Methods = ("flat", "centroid", "max_sim", "max_sim_bonus") | |
| # Sweep these thresholds for open-set classification. | |
| THRESHOLDS = [round(0.30 + 0.05 * i, 2) for i in range(13)] # 0.30 .. 0.90 | |
| def _hash(p: Path) -> str: | |
| h = hashlib.sha1() | |
| h.update(p.read_bytes()) | |
| return f"{p.parent.name}__{p.name}__{h.hexdigest()[:12]}" | |
| def load_eval_embeddings( | |
| eval_root: Path, cache_path: Path | |
| ) -> dict[str, list[np.ndarray]]: | |
| cache: dict[str, np.ndarray] = {} | |
| if cache_path.exists(): | |
| loaded = np.load(cache_path) | |
| for k in loaded.files: | |
| cache[k] = loaded[k] | |
| log.info("Loaded %d cached embeddings", len(cache)) | |
| by_identity: dict[str, list[np.ndarray]] = defaultdict(list) | |
| new_count = 0 | |
| skipped = 0 | |
| for ident_dir in sorted(eval_root.iterdir()): | |
| if not ident_dir.is_dir(): | |
| continue | |
| identity = ident_dir.name | |
| for photo in sorted(ident_dir.iterdir()): | |
| if photo.suffix.lower() not in VALID_EXT: | |
| continue | |
| key = _hash(photo) | |
| if key in cache: | |
| by_identity[identity].append(cache[key]) | |
| continue | |
| try: | |
| img = Image.open(photo) | |
| img.load() | |
| except Exception as exc: # noqa: BLE001 | |
| log.warning("Cannot open %s: %s", photo, exc) | |
| skipped += 1 | |
| continue | |
| try: | |
| emb = process(img).embedding.astype(np.float32) | |
| except NoDogDetectedError: | |
| log.warning("No dog in %s", photo) | |
| skipped += 1 | |
| continue | |
| cache[key] = emb | |
| by_identity[identity].append(emb) | |
| new_count += 1 | |
| if new_count > 0: | |
| np.savez(cache_path, **cache) | |
| log.info("Computed and cached %d new embeddings", new_count) | |
| if skipped: | |
| log.info("Skipped %d images (no dog / decode failure)", skipped) | |
| return {k: v for k, v in by_identity.items() if len(v) >= 2} | |
| def load_filler(limit: int) -> list[np.ndarray]: | |
| session = SessionLocal() | |
| try: | |
| rows = session.scalars( | |
| select(Sighting.embedding) | |
| .where(Sighting.source == "filler") | |
| .limit(limit) | |
| ).all() | |
| return [np.asarray(r, dtype=np.float32) for r in rows] | |
| finally: | |
| session.close() | |
| # ---- Ranking ------------------------------------------------------------ | |
| def _cosine(a: np.ndarray, b: np.ndarray) -> float: | |
| return float(np.dot(a, b)) | |
| def rank_with_scores( | |
| query: np.ndarray, | |
| refs: dict[str, list[np.ndarray]], | |
| distractors: list[np.ndarray], | |
| method: str, | |
| ) -> list[tuple[float, str | None]]: | |
| """Returns descending-score list of (score, identity-or-None) entries. | |
| None = a distractor item beat real identities at this rank.""" | |
| if method == "flat": | |
| items: list[tuple[float, str | None]] = [] | |
| for ident, photos in refs.items(): | |
| for p in photos: | |
| items.append((_cosine(query, p), ident)) | |
| for d in distractors: | |
| items.append((_cosine(query, d), None)) | |
| items.sort(key=lambda x: -x[0]) | |
| seen: set[str | None] = set() | |
| deduped: list[tuple[float, str | None]] = [] | |
| for s, ident in items: | |
| if ident in seen: | |
| continue | |
| seen.add(ident) | |
| deduped.append((s, ident)) | |
| return deduped | |
| items = [] | |
| if method == "centroid": | |
| for ident, photos in refs.items(): | |
| mean = np.mean(np.stack(photos), axis=0) | |
| n = float(np.linalg.norm(mean)) | |
| if n > 0: | |
| mean = mean / n | |
| items.append((_cosine(query, mean), ident)) | |
| elif method == "max_sim": | |
| for ident, photos in refs.items(): | |
| sims = [_cosine(query, p) for p in photos] | |
| items.append((max(sims), ident)) | |
| elif method == "max_sim_bonus": | |
| for ident, photos in refs.items(): | |
| sims = [_cosine(query, p) for p in photos] | |
| top = max(sims) | |
| strong = sum(1 for s in sims if s > STRONG_THRESHOLD) | |
| items.append((top * (1 + CLUSTER_BONUS * strong), ident)) | |
| else: | |
| raise ValueError(f"Unknown method: {method}") | |
| for d in distractors: | |
| items.append((_cosine(query, d), None)) | |
| items.sort(key=lambda x: -x[0]) | |
| return items | |
| def closed_metrics(ranked: list[tuple[float, str | None]], correct: str) -> dict[str, float]: | |
| rank = next((i for i, (_, x) in enumerate(ranked) if x == correct), None) | |
| if rank is None: | |
| return {"r1": 0.0, "r5": 0.0, "rank": float("inf"), "ap": 0.0} | |
| return { | |
| "r1": 1.0 if rank == 0 else 0.0, | |
| "r5": 1.0 if rank < 5 else 0.0, | |
| "rank": float(rank + 1), | |
| "ap": 1.0 / (rank + 1), | |
| } | |
| # ---- Splitting ---------------------------------------------------------- | |
| def split_one_seed( | |
| by_identity: dict[str, list[np.ndarray]], | |
| n_refs: int, | |
| rng: np.random.Generator, | |
| ) -> tuple[dict[str, list[np.ndarray]], list[tuple[str, np.ndarray]]]: | |
| refs: dict[str, list[np.ndarray]] = {} | |
| queries: list[tuple[str, np.ndarray]] = [] | |
| for identity, photos in by_identity.items(): | |
| if len(photos) < n_refs + 1: | |
| continue | |
| idx = rng.permutation(len(photos)) | |
| q_idx = idx[0] | |
| ref_idx = idx[1 : 1 + n_refs] | |
| refs[identity] = [photos[i] for i in ref_idx] | |
| queries.append((identity, photos[q_idx])) | |
| return refs, queries | |
| # ---- Open-set classification -------------------------------------------- | |
| def confusion_at_threshold( | |
| in_gallery: list[tuple[bool, float]], # (top1_correct, top1_score) per positive query | |
| out_gallery_scores: list[float], # top1_score per filler-as-query | |
| threshold: float, | |
| ) -> dict[str, int | float]: | |
| """Compute confusion matrix at a given top-1 score threshold. | |
| A positive query is a TRUE POSITIVE only if BOTH: | |
| - its top-1 score is above the threshold (system says 'match') | |
| - the top-1 identity is the correct one | |
| Otherwise it's a FALSE NEGATIVE (system either rejected, or matched to the | |
| wrong dog, both of which fail the user). | |
| A filler query is FALSE POSITIVE if its top-1 score exceeds the threshold, | |
| TRUE NEGATIVE otherwise. | |
| """ | |
| tp = sum(1 for correct, score in in_gallery if correct and score > threshold) | |
| fn = len(in_gallery) - tp | |
| fp = sum(1 for s in out_gallery_scores if s > threshold) | |
| tn = len(out_gallery_scores) - fp | |
| pos = tp + fn | |
| neg = tn + fp | |
| sensitivity = tp / pos if pos > 0 else 0.0 | |
| specificity = tn / neg if neg > 0 else 0.0 | |
| precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 | |
| f1 = ( | |
| 2 * precision * sensitivity / (precision + sensitivity) | |
| if (precision + sensitivity) > 0 | |
| else 0.0 | |
| ) | |
| youden = sensitivity + specificity - 1.0 | |
| return { | |
| "tp": tp, | |
| "fn": fn, | |
| "fp": fp, | |
| "tn": tn, | |
| "sensitivity": sensitivity, | |
| "specificity": specificity, | |
| "precision": precision, | |
| "f1": f1, | |
| "youden": youden, | |
| } | |
| def auc_trapezoid(roc_points: list[tuple[float, float]]) -> float: | |
| """Approximate AUC from sorted (FPR, TPR) points via trapezoid rule.""" | |
| pts = sorted(set(roc_points)) | |
| pts = [(0.0, 0.0)] + pts + [(1.0, 1.0)] | |
| pts = sorted(set(pts)) | |
| auc = 0.0 | |
| for (x1, y1), (x2, y2) in zip(pts, pts[1:]): | |
| auc += (x2 - x1) * (y1 + y2) / 2.0 | |
| return auc | |
| # ---- Main --------------------------------------------------------------- | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--eval-root", type=Path, default=Path("/seed_data/eval")) | |
| parser.add_argument( | |
| "--out-root", type=Path, default=Path("/seed_data/eval_results") | |
| ) | |
| parser.add_argument( | |
| "--cache-path", | |
| type=Path, | |
| default=Path("/seed_data/eval_embeddings_cache.npz"), | |
| ) | |
| parser.add_argument( | |
| "--n-distractors", type=int, default=100, | |
| help="Filler embeddings included in the GALLERY (alongside identity refs)." | |
| ) | |
| parser.add_argument( | |
| "--n-oog-queries", type=int, default=100, | |
| help="Filler embeddings used as OUT-OF-GALLERY queries (should be rejected)." | |
| ) | |
| parser.add_argument("--n-splits", type=int, default=10) | |
| parser.add_argument( | |
| "--n-refs-list", nargs="+", type=int, default=[1, 2, 3], | |
| ) | |
| parser.add_argument( | |
| "--methods", nargs="+", default=list(Methods), choices=Methods, | |
| ) | |
| args = parser.parse_args() | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") | |
| if not args.eval_root.exists(): | |
| raise SystemExit( | |
| f"No eval data at {args.eval_root}. " | |
| f"Drop {{identity}}/{{photo}}.jpg folders there, or pass --eval-root." | |
| ) | |
| log.info("Loading eval embeddings from %s ...", args.eval_root) | |
| by_identity = load_eval_embeddings(args.eval_root, args.cache_path) | |
| if not by_identity: | |
| raise SystemExit("No usable identities (need ≥ 2 photos per identity).") | |
| total = sum(len(p) for p in by_identity.values()) | |
| log.info( | |
| "%d identities, %d photos (avg %.1f/identity)", | |
| len(by_identity), total, total / len(by_identity), | |
| ) | |
| needed = args.n_distractors + args.n_oog_queries | |
| log.info("Loading %d filler embeddings (split %d distractors + %d OOG queries) ...", | |
| needed, args.n_distractors, args.n_oog_queries) | |
| filler = load_filler(needed) | |
| if len(filler) < needed: | |
| log.warning("Only %d filler available; reducing OOG queries.", len(filler)) | |
| # Prefer keeping distractors, shrink OOG. | |
| oog_count = max(0, len(filler) - args.n_distractors) | |
| else: | |
| oog_count = args.n_oog_queries | |
| distractors_pool = filler[: args.n_distractors] | |
| oog_queries_pool = filler[args.n_distractors : args.n_distractors + oog_count] | |
| log.info("Distractors=%d, OOG queries=%d.", len(distractors_pool), len(oog_queries_pool)) | |
| closed_rows: list[dict] = [] | |
| open_rows: list[dict] = [] | |
| roc_rows: list[dict] = [] | |
| for n_refs in args.n_refs_list: | |
| usable = sum(1 for p in by_identity.values() if len(p) >= n_refs + 1) | |
| if usable < 5: | |
| log.warning("Skipping n_refs=%d — only %d usable identities.", n_refs, usable) | |
| continue | |
| log.info("--- n_refs=%d (%d usable identities) ---", n_refs, usable) | |
| for split_seed in range(args.n_splits): | |
| rng = np.random.default_rng(split_seed * 997 + n_refs) | |
| refs, queries = split_one_seed(by_identity, n_refs, rng) | |
| # Re-shuffle the OOG pool per split for variation. | |
| oog_idx = rng.permutation(len(oog_queries_pool)) | |
| oog_for_split = [oog_queries_pool[i] for i in oog_idx] | |
| for method in args.methods: | |
| # ---- Closed-set metrics ------------------------------- | |
| acc_r1, acc_r5, acc_rank, acc_ap = [], [], [], [] | |
| in_results: list[tuple[bool, float]] = [] | |
| for correct_id, q_emb in queries: | |
| ranked = rank_with_scores(q_emb, refs, distractors_pool, method) | |
| cm = closed_metrics(ranked, correct_id) | |
| acc_r1.append(cm["r1"]) | |
| acc_r5.append(cm["r5"]) | |
| acc_rank.append(cm["rank"]) | |
| acc_ap.append(cm["ap"]) | |
| top1_score, top1_id = ranked[0] | |
| in_results.append((top1_id == correct_id, top1_score)) | |
| closed_rows.append({ | |
| "method": method, | |
| "n_refs": n_refs, | |
| "n_distractors": len(distractors_pool), | |
| "n_identities_used": len(refs), | |
| "n_queries": len(queries), | |
| "split_seed": split_seed, | |
| "r1": float(np.mean(acc_r1)), | |
| "r5": float(np.mean(acc_r5)), | |
| "mean_rank_of_correct": ( | |
| float(np.mean([r for r in acc_rank if r != float("inf")])) | |
| if any(r != float("inf") for r in acc_rank) | |
| else float("inf") | |
| ), | |
| "map": float(np.mean(acc_ap)), | |
| }) | |
| # ---- Open-set scoring --------------------------------- | |
| oog_scores = [] | |
| for q_emb in oog_for_split: | |
| ranked = rank_with_scores(q_emb, refs, distractors_pool, method) | |
| oog_scores.append(ranked[0][0]) | |
| # Per-threshold confusion + accumulate ROC points. | |
| for thr in THRESHOLDS: | |
| cm = confusion_at_threshold(in_results, oog_scores, thr) | |
| open_rows.append({ | |
| "method": method, | |
| "n_refs": n_refs, | |
| "split_seed": split_seed, | |
| "threshold": thr, | |
| **cm, | |
| }) | |
| if not closed_rows: | |
| raise SystemExit("No experiments ran. Check --n-refs-list and your data.") | |
| # ---- Output ------------------------------------------------------------- | |
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%SZ") | |
| out_dir = args.out_root / timestamp | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| closed_csv = out_dir / "closed_set.csv" | |
| with closed_csv.open("w", newline="", encoding="utf-8") as f: | |
| w = csv.DictWriter(f, fieldnames=list(closed_rows[0].keys())) | |
| w.writeheader() | |
| w.writerows(closed_rows) | |
| open_csv = out_dir / "open_set.csv" | |
| with open_csv.open("w", newline="", encoding="utf-8") as f: | |
| w = csv.DictWriter(f, fieldnames=list(open_rows[0].keys())) | |
| w.writeheader() | |
| w.writerows(open_rows) | |
| # Aggregate ROC per (method, n_refs, threshold) — average TPR/FPR across splits | |
| by_roc: dict[tuple[str, int, float], list[dict]] = defaultdict(list) | |
| for r in open_rows: | |
| by_roc[(r["method"], r["n_refs"], r["threshold"])].append(r) | |
| roc_csv = out_dir / "roc.csv" | |
| with roc_csv.open("w", newline="", encoding="utf-8") as f: | |
| w = csv.writer(f) | |
| w.writerow(["method", "n_refs", "threshold", "tpr_mean", "fpr_mean", | |
| "sensitivity_mean", "specificity_mean", "f1_mean"]) | |
| for (method, n_refs, thr), entries in sorted(by_roc.items()): | |
| tpr = np.mean([e["sensitivity"] for e in entries]) | |
| fpr = 1.0 - np.mean([e["specificity"] for e in entries]) | |
| f1 = np.mean([e["f1"] for e in entries]) | |
| sens = np.mean([e["sensitivity"] for e in entries]) | |
| spec = np.mean([e["specificity"] for e in entries]) | |
| w.writerow([method, n_refs, thr, f"{tpr:.4f}", f"{fpr:.4f}", | |
| f"{sens:.4f}", f"{spec:.4f}", f"{f1:.4f}"]) | |
| roc_rows.append({ | |
| "method": method, "n_refs": n_refs, "threshold": thr, | |
| "tpr": tpr, "fpr": fpr, "f1": f1, | |
| "sens": sens, "spec": spec, | |
| }) | |
| # ---- Markdown summary ----------------------------------------------- | |
| md: list[str] = [] | |
| md.append("# Re-ID evaluation\n") | |
| md.append(f"_Generated: {timestamp}_\n") | |
| md.append(f"- Identities: **{len(by_identity)}** ({total} photos, " | |
| f"avg {total/len(by_identity):.1f}/identity)") | |
| md.append(f"- Distractors in gallery: **{len(distractors_pool)}**") | |
| md.append(f"- Out-of-gallery queries (filler-as-query): **{len(oog_queries_pool)}**") | |
| md.append(f"- Random splits per condition: **{args.n_splits}**\n") | |
| # --- Closed-set table --- | |
| md.append("## Closed-set metrics") | |
| md.append("_Assumes the correct dog IS in the gallery._\n") | |
| md.append("| Method | n_refs | n_queries | R@1 | R@5 | mAP |") | |
| md.append("|---|---|---|---|---|---|") | |
| by_closed: dict[tuple[str, int], list[dict]] = defaultdict(list) | |
| for r in closed_rows: | |
| by_closed[(r["method"], r["n_refs"])].append(r) | |
| for (method, n_refs), entries in sorted(by_closed.items(), key=lambda x: (x[0][1], x[0][0])): | |
| r1 = np.array([e["r1"] for e in entries]) * 100 | |
| r5 = np.array([e["r5"] for e in entries]) * 100 | |
| ap = np.array([e["map"] for e in entries]) * 100 | |
| nq = entries[0]["n_queries"] | |
| md.append( | |
| f"| `{method}` | {n_refs} | {nq} | " | |
| f"{r1.mean():.1f}% ± {r1.std():.1f} | " | |
| f"{r5.mean():.1f}% ± {r5.std():.1f} | " | |
| f"{ap.mean():.1f}% ± {ap.std():.1f} |" | |
| ) | |
| md.append("") | |
| # --- Open-set: best operating point per (method, n_refs) --- | |
| md.append("## Open-set — best F1 operating point") | |
| md.append("_Best threshold by mean F1 across splits, with sensitivity / specificity at that point._\n") | |
| md.append("| Method | n_refs | Threshold | Sensitivity (TPR) | Specificity (TNR) | F1 |") | |
| md.append("|---|---|---|---|---|---|") | |
| by_method_n: dict[tuple[str, int], list[dict]] = defaultdict(list) | |
| for r in roc_rows: | |
| by_method_n[(r["method"], r["n_refs"])].append(r) | |
| for (method, n_refs), entries in sorted(by_method_n.items(), key=lambda x: (x[0][1], x[0][0])): | |
| best = max(entries, key=lambda e: e["f1"]) | |
| md.append( | |
| f"| `{method}` | {n_refs} | {best['threshold']:.2f} | " | |
| f"{best['sens']*100:.1f}% | {best['spec']*100:.1f}% | " | |
| f"{best['f1']*100:.1f}% |" | |
| ) | |
| md.append("") | |
| # --- Open-set: AUC per (method, n_refs) --- | |
| md.append("## Open-set — ROC AUC") | |
| md.append("| Method | n_refs | AUC |") | |
| md.append("|---|---|---|") | |
| for (method, n_refs), entries in sorted(by_method_n.items(), key=lambda x: (x[0][1], x[0][0])): | |
| roc_pts = [(e["fpr"], e["tpr"]) for e in entries] | |
| auc = auc_trapezoid(roc_pts) | |
| md.append(f"| `{method}` | {n_refs} | {auc:.3f} |") | |
| md.append("") | |
| # --- Operating-point sweep (a few key thresholds) --- | |
| md.append("## Open-set — sweep across thresholds") | |
| md.append("_Mean values across splits._\n") | |
| md.append("| Method | n_refs | τ | Sens | Spec | F1 |") | |
| md.append("|---|---|---|---|---|---|") | |
| for (method, n_refs), entries in sorted(by_method_n.items(), key=lambda x: (x[0][1], x[0][0])): | |
| for e in entries: | |
| md.append( | |
| f"| `{method}` | {n_refs} | {e['threshold']:.2f} | " | |
| f"{e['sens']*100:.1f}% | {e['spec']*100:.1f}% | {e['f1']*100:.1f}% |" | |
| ) | |
| md.append("| | | | | | |") # blank divider per method | |
| md.append("") | |
| md_path = out_dir / "summary.md" | |
| md_path.write_text("\n".join(md), encoding="utf-8") | |
| log.info("Wrote %s, %s, %s, %s", | |
| closed_csv.name, open_csv.name, roc_csv.name, md_path.name) | |
| print() | |
| print("\n".join(md)) | |
| if __name__ == "__main__": | |
| main() | |