"""Re-run v9b full eval with the retrained SDF tower (sdf_v2). Loads V9BModel from original Stage 2 ckpt, then OVERRIDES the SDF tower weights with v9b_stage2_sdf_v2/last.pt. Same OOD eval as eval_ood_v9b_full.py — direct apples-to-apples comparison. Reports new AUC for v9b_geo / v9b_combo to confirm whether the per-image SDF target moved the geometry tower from the AUC=0.10 (anti-correlated) zone toward something useful. """ from __future__ import annotations import csv import sys import time from pathlib import Path import numpy as np import torch from PIL import Image ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from src.research.v9b_model import V9BModel from scripts.eval_ood_cascade import ( SEG_ONNX, MIN_TUMOR_AREA, _sess, _preprocess_seg, seg_tta, GT, ) JEPA_CKPT = ROOT / 'v9b_artifacts' / 'v9b_jepa' / 'last.pt' STAGE2_CKPT = ROOT / 'v9b_artifacts' / 'v9b_stage2' / 'last.pt' SDF_V2_CKPT = ROOT / 'v9b_artifacts' / 'v9b_stage2_sdf_v2' / 'last.pt' CONFORMAL = ROOT / 'v9b_artifacts' / 'v9b_conformal.json' SAMPLES_DIR = ROOT / 'samples' / 'ood' IMAGE_SIZE = 256 def preprocess(img_pil, device): img = img_pil.convert('RGB').resize((IMAGE_SIZE, IMAGE_SIZE), Image.BILINEAR) arr = np.asarray(img, dtype=np.float32) / 255.0 return torch.from_numpy(arr.transpose(2, 0, 1)).unsqueeze(0).to(device) def _stats(rows, key, t): TP = sum(1 for r in rows if r['gt']=='tumor' and r[key] > t) FN = sum(1 for r in rows if r['gt']=='tumor' and r[key] <= t) FP = sum(1 for r in rows if r['gt']=='no_tumor' and r[key] > t) TN = sum(1 for r in rows if r['gt']=='no_tumor' and r[key] <= t) re = TP/(TP+FN) if TP+FN else 0 fp = FP/(FP+TN) if FP+TN else 0 acc = (TP+TN)/len(rows) f1 = 2*TP/(2*TP+FP+FN) if 2*TP+FP+FN else 0 return TP, FN, FP, TN, re, fp, acc, f1 def _auc(rows, key): pos = [r[key] for r in rows if r['gt']=='tumor'] neg = [r[key] for r in rows if r['gt']=='no_tumor'] if not pos or not neg: return float('nan') wins = ties = total = 0 for sp in pos: for sn in neg: if sp > sn: wins += 1 elif sp == sn: ties += 1 total += 1 return (wins + 0.5*ties) / total def _best_threshold(rows, key, metric='f1'): cands = sorted(set(round(r[key], 4) for r in rows)) best = (None, -1) for t in cands: TP, FN, FP, TN, re, fp, acc, f1 = _stats(rows, key, t) m = f1 if metric == 'f1' else acc if m > best[1]: best = (t, m, re, fp, acc, f1) return best def main(): for p in (JEPA_CKPT, STAGE2_CKPT, SDF_V2_CKPT, CONFORMAL): if not p.exists(): sys.exit(f'missing {p}') device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f'[init] device={device}') print(f'[init] loading V9BModel ...') model = V9BModel.from_checkpoints(str(JEPA_CKPT), str(STAGE2_CKPT), str(CONFORMAL), image_size=IMAGE_SIZE, device=device) print(f' loaded (JEPA={model.jepa is not None}, ' f'DDPM={model.ddpm is not None}, SDF={model.sdf_tower is not None})') # SWAP IN the retrained SDF tower weights sdf_ck = torch.load(str(SDF_V2_CKPT), map_location=device, weights_only=False) sdf_sd = sdf_ck.get('sdf_state_dict', sdf_ck) miss, unexp = model.sdf_tower.load_state_dict(sdf_sd, strict=False) print(f' SDF v2 swapped in (missing={len(miss)} unexpected={len(unexp)})') seg = _sess(SEG_ONNX) samples = sorted(p for p in SAMPLES_DIR.rglob('*') if p.suffix.lower() in ('.png','.jpg','.jpeg') and p.parent.name in GT) print(f'[init] {len(samples)} OOD samples\n') rows = [] t0 = time.perf_counter() last = t0 for i, p in enumerate(samples): img = Image.open(p) x = preprocess(img, device) out = model.infer(x, combine_mode='weighted_sum', lambda_app=0.6, lambda_geo=0.4, ddpm_num_steps=0) app = out['appearance_anomaly'].squeeze().cpu().numpy() geo = out['geometry_anomaly'].squeeze().cpu().numpy() if out['geometry_anomaly'] is not None else None combo = out['combined_anomaly'].squeeze().cpu().numpy() rec = { 'source': p.parent.name, 'file': p.name, 'gt': GT[p.parent.name], 'v9b_app_p95': float(np.percentile(app, 95)), 'v9b_geo_p95': float(np.percentile(geo, 95)) if geo is not None else 0.0, 'v9b_combo_p95': float(np.percentile(combo, 95)), } prob = seg_tta(seg, _preprocess_seg(img)) rec['v8_area_020'] = int((prob >= 0.20).sum()) rec['v8_verdict_020'] = 'TUMOR' if rec['v8_area_020'] >= MIN_TUMOR_AREA else 'no_tumor' rows.append(rec) if time.perf_counter() - last > 20: last = time.perf_counter() print(f' [{i+1}/{len(samples)}] elapsed={time.perf_counter()-t0:.0f}s') print(f'\n[done] {(time.perf_counter()-t0)/60:.1f} min\n') # ============ AUC comparison ============ print('='*84) print('AUC: SDF v2 vs original SDF (recall on OOD tumor-vs-healthy)') print('='*84) OLD_AUC = {'app': 0.857, 'geo': 0.100, 'combo': 0.333} # from previous eval for key, old_auc in (('v9b_app_p95', OLD_AUC['app']), ('v9b_geo_p95', OLD_AUC['geo']), ('v9b_combo_p95', OLD_AUC['combo'])): new_auc = _auc(rows, key) delta = new_auc - old_auc flag = ' [BIG WIN]' if delta > 0.20 else (' [improved]' if delta > 0.05 else (' [same]' if abs(delta) <= 0.05 else ' [worse]')) print(f' {key:18s} old={old_auc:.3f} new={new_auc:.3f} delta={delta:+.3f}{flag}') # ============ best F1 per variant ============ print('\n' + '='*84) print('BEST F1 OPERATING POINT (with new SDF v2)') print('='*84) print(f' {"variant":18s} thr recall FPR acc F1') for key in ('v9b_app_p95', 'v9b_geo_p95', 'v9b_combo_p95'): t, _, re, fp, acc, f1 = _best_threshold(rows, key, 'f1') print(f' {key:18s} {t:.3f} {re:.0%} {fp:.0%} {acc:.0%} {f1:.2f}') # v8 baseline for reference TP, FN, FP, TN, re_v8, fp_v8, acc_v8, f1_v8 = _stats(rows, 'v8_area_020', MIN_TUMOR_AREA - 1) print(f' {"v8 seg @ 0.20":18s} ---- {re_v8:.0%} {fp_v8:.0%} {acc_v8:.0%} {f1_v8:.2f}') # ============ per-source ============ print('\n' + '='*84) print('PER-SOURCE on v9b_combo @ best-F1 threshold (with SDF v2)') print('='*84) best_t = _best_threshold(rows, 'v9b_combo_p95', 'f1')[0] by_src = {} for r in rows: by_src.setdefault(r['source'], []).append(r) print(f'\nthreshold = {best_t:.3f}') print(f'{"source":48s} GT n combo app geo') for src in sorted(by_src): rs = by_src[src]; gt = rs[0]['gt']; n = len(rs) kind = 'recall' if gt=='tumor' else 'FPR' combo_hits = sum(1 for r in rs if r['v9b_combo_p95'] > best_t) app_t = _best_threshold(rows, 'v9b_app_p95', 'f1')[0] app_hits = sum(1 for r in rs if r['v9b_app_p95'] > app_t) geo_t = _best_threshold(rows, 'v9b_geo_p95', 'f1')[0] geo_hits = sum(1 for r in rs if r['v9b_geo_p95'] > geo_t) print(f' {src:46s} {gt[:6]:6s} {n:3d} {combo_hits/n:.0%} ' f'{app_hits/n:.0%} {geo_hits/n:.0%} <- {kind}') # ============ persist ============ out_csv = SAMPLES_DIR / 'eval_v9b_sdf_v2_results.csv' fields = list(rows[0].keys()) with out_csv.open('w', newline='', encoding='utf-8') as f: w = csv.DictWriter(f, fieldnames=fields) w.writeheader() for r in rows: w.writerow(r) print(f'\n[csv] {out_csv}') if __name__ == '__main__': main()