andreskoenig's picture
Deploy v1.2.0 hub redesign — status, reject, further candidates, add photo, edit
b12d042 verified
Raw
History Blame Contribute Delete
21.7 kB
"""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()