Tri-Netra-AI / scripts /eval_v8_tta_sweep.py
anannyavyas1's picture
Upload folder using huggingface_hub
1cf3825 verified
Raw
History Blame Contribute Delete
12.4 kB
"""TTA (4-way flip ensemble) + threshold sweep evaluation of v8 ONNX.
For each scan:
1. Run 4 forward passes: identity, hflip, vflip, both
2. Average the per-voxel probabilities (after inverse-transforming each)
3. Evaluate at multiple thresholds in one shot — no re-inference per threshold
Per-threshold metrics aggregated globally + per-source.
Picks the composite-optimal and F1-optimal thresholds.
Usage:
python scripts/eval_v8_tta_sweep.py --onnx model/best_micro.onnx
--data_dir dataset_v8 --splits test val --output_dir model/eval_results
"""
from __future__ import annotations
import argparse, json, time
from pathlib import Path
from collections import defaultdict
from typing import Dict, List
import numpy as np
from PIL import Image
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
THRESHOLDS = [0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]
def load_image_normalized(path: Path, image_size: int) -> np.ndarray:
img = Image.open(path).convert('RGB').resize((image_size, image_size), Image.BILINEAR)
x = np.asarray(img, dtype=np.float32) / 255.0
return ((x - IMAGENET_MEAN) / IMAGENET_STD).transpose(2, 0, 1)
def load_mask(path: Path, image_size: int) -> np.ndarray:
msk = Image.open(path).convert('L').resize((image_size, image_size), Image.NEAREST)
return (np.asarray(msk, dtype=np.uint8) > 127).astype(np.uint8)
def infer_source(filename: str) -> str:
n = filename.lower()
if n.startswith('brats_t1c_') or 'brats' in n: return 'brats_t1c'
if 'lgg' in n: return 'lgg'
if 'figshare' in n: return 'figshare'
if 'no_tumor' in n or 'notumor' in n or 'kaggle' in n: return 'kaggle_negative'
return 'unknown'
def tta_forward(sess, x_batch: np.ndarray) -> np.ndarray:
"""4-way flip TTA. Returns averaged sigmoid probabilities (B, H, W)."""
# id
logits = sess.run(None, {'input': x_batch})[0]
p_id = 1.0 / (1.0 + np.exp(-logits[:, 0]))
# hflip
x_h = x_batch[:, :, :, ::-1].copy()
logits = sess.run(None, {'input': x_h})[0]
p_h = 1.0 / (1.0 + np.exp(-logits[:, 0]))
p_h = p_h[:, :, ::-1] # invert flip on output
# vflip
x_v = x_batch[:, :, ::-1, :].copy()
logits = sess.run(None, {'input': x_v})[0]
p_v = 1.0 / (1.0 + np.exp(-logits[:, 0]))
p_v = p_v[:, ::-1, :]
# both
x_b = x_batch[:, :, ::-1, ::-1].copy()
logits = sess.run(None, {'input': x_b})[0]
p_b = 1.0 / (1.0 + np.exp(-logits[:, 0]))
p_b = p_b[:, ::-1, ::-1]
return (p_id + p_h + p_v + p_b) / 4.0
def eval_split_tta_sweep(sess, img_dir: Path, msk_dir: Path, image_size: int,
batch_size: int) -> Dict:
img_paths = sorted(img_dir.iterdir())
n = len(img_paths)
print(f' TTA-evaluating {n} scans from {img_dir.parent.name}...', flush=True)
# Per-threshold totals (pooled) + per-source totals
pooled = {t: {'tp': 0, 'fp': 0, 'fn': 0, 'tn': 0,
'pos_dices': [], 'pos_recalls': [], 'pos_precs': [],
'pos_fn_rates': [], 'neg_fp_rates': []}
for t in THRESHOLDS}
by_source = defaultdict(lambda: defaultdict(lambda: {
'tp': 0, 'fp': 0, 'fn': 0, 'tn': 0, 'n_pos': 0, 'n_neg': 0,
'pos_dices': [], 'neg_fp_rates': [],
}))
auroc_probs = []
auroc_labels = []
t0 = time.time()
for bi in range(0, n, batch_size):
batch_paths = img_paths[bi:bi + batch_size]
x_batch, y_batch, names, sources = [], [], [], []
for ip in batch_paths:
mp = msk_dir / ip.name
if not mp.exists(): continue
x_batch.append(load_image_normalized(ip, image_size))
y_batch.append(load_mask(mp, image_size))
names.append(ip.name)
sources.append(infer_source(ip.name))
if not x_batch: continue
x_batch = np.stack(x_batch, axis=0).astype(np.float32)
# TTA-averaged probabilities
avg_probs = tta_forward(sess, x_batch) # (B, H, W)
for i in range(len(x_batch)):
yi = y_batch[i]
pri = avg_probs[i]
src = sources[i]
has_tumor = int(yi.sum() > 0)
auroc_probs.append(float(pri.max()))
auroc_labels.append(has_tumor)
for t in THRESHOLDS:
pi = (pri >= t).astype(np.uint8)
tp = int((pi * yi).sum()); fp = int((pi * (1 - yi)).sum())
fn = int(((1 - pi) * yi).sum()); tn = int(((1 - pi) * (1 - yi)).sum())
pooled[t]['tp'] += tp; pooled[t]['fp'] += fp
pooled[t]['fn'] += fn; pooled[t]['tn'] += tn
if has_tumor:
pooled[t]['pos_dices'].append((2 * tp + 1e-6) / (2 * tp + fp + fn + 1e-6))
pooled[t]['pos_recalls'].append((tp + 1e-6) / (tp + fn + 1e-6))
pooled[t]['pos_precs'].append((tp + 1e-6) / (tp + fp + 1e-6))
pooled[t]['pos_fn_rates'].append((fn + 1e-6) / (tp + fn + 1e-6))
by_source[src][t]['pos_dices'].append(
(2 * tp + 1e-6) / (2 * tp + fp + fn + 1e-6))
else:
pooled[t]['neg_fp_rates'].append(fp / (fp + tn + 1e-6))
by_source[src][t]['neg_fp_rates'].append(fp / (fp + tn + 1e-6))
by_source[src][t]['tp'] += tp; by_source[src][t]['fp'] += fp
by_source[src][t]['fn'] += fn; by_source[src][t]['tn'] += tn
if has_tumor:
by_source[src][t]['n_pos'] += 1
else:
by_source[src][t]['n_neg'] += 1
if (bi // batch_size) % 25 == 0:
print(f' [{bi + len(batch_paths)}/{n}] {time.time() - t0:.0f}s elapsed', flush=True)
# Aggregate
summary = {}
for t in THRESHOLDS:
agg = pooled[t]
tp, fp, fn, tn = agg['tp'], agg['fp'], agg['fn'], agg['tn']
micro_dice = (2 * tp + 1e-6) / (2 * tp + fp + fn + 1e-6)
macro_dice = float(np.mean(agg['pos_dices'])) if agg['pos_dices'] else 0.0
macro_recall = float(np.mean(agg['pos_recalls'])) if agg['pos_recalls'] else 0.0
macro_prec = float(np.mean(agg['pos_precs'])) if agg['pos_precs'] else 0.0
macro_fn_rate = float(np.mean(agg['pos_fn_rates'])) if agg['pos_fn_rates'] else 0.0
fp_rate_mean = float(np.mean(agg['neg_fp_rates'])) if agg['neg_fp_rates'] else 0.0
fp_rate_p95 = float(np.percentile(agg['neg_fp_rates'], 95)) if agg['neg_fp_rates'] else 0.0
f1 = 2 * macro_prec * macro_recall / max(macro_prec + macro_recall, 1e-6)
composite = macro_dice - 5.0 * fp_rate_mean
summary[f'{t:.2f}'] = {
'micro_dice': float(micro_dice), 'macro_dice': macro_dice,
'macro_recall': macro_recall, 'macro_precision': macro_prec,
'macro_fn_rate': macro_fn_rate, 'macro_f1': float(f1),
'fp_rate_mean': fp_rate_mean, 'fp_rate_p95': fp_rate_p95,
'composite': float(composite),
'tp': tp, 'fp': fp, 'fn': fn, 'tn': tn,
}
# AUROC (threshold-independent)
auroc = None
try:
from sklearn.metrics import roc_auc_score
if len(set(auroc_labels)) > 1:
auroc = float(roc_auc_score(auroc_labels, auroc_probs))
except ImportError:
pass
# Pick optimal thresholds
best_composite_t = max(summary.keys(), key=lambda t: summary[t]['composite'])
best_f1_t = max(summary.keys(), key=lambda t: summary[t]['macro_f1'])
# Per-source summaries (only at best composite threshold + 0.5 + 0.3 for compactness)
src_summary = {}
for src, td in by_source.items():
src_summary[src] = {}
for show_t in (best_composite_t, '0.50', '0.30'):
if show_t in td:
d = td[show_t]
src_summary[src][show_t] = {
'n_pos': d['n_pos'], 'n_neg': d['n_neg'],
'macro_dice': float(np.mean(d['pos_dices'])) if d['pos_dices'] else 0.0,
'mean_fp_rate': float(np.mean(d['neg_fp_rates'])) if d['neg_fp_rates'] else 0.0,
'recall_pooled': d['tp'] / max(d['tp'] + d['fn'], 1),
'precision_pooled': d['tp'] / max(d['tp'] + d['fp'], 1),
}
return {
'n_scans': n, 'thresholds': summary, 'auroc': auroc,
'best_composite_threshold': best_composite_t,
'best_f1_threshold': best_f1_t,
'by_source': src_summary,
'elapsed_sec': time.time() - t0,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--onnx', default='model/best_micro.onnx')
ap.add_argument('--data_dir', default='dataset_v8')
ap.add_argument('--output_dir', default='model/eval_results')
ap.add_argument('--splits', nargs='+', default=['test', 'val'])
ap.add_argument('--image_size', type=int, default=384)
ap.add_argument('--batch_size', type=int, default=8)
args = ap.parse_args()
out_dir = Path(args.output_dir); out_dir.mkdir(parents=True, exist_ok=True)
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.log_severity_level = 3
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider'] if 'CUDAExecutionProvider' in ort.get_available_providers() else ['CPUExecutionProvider']
sess = ort.InferenceSession(args.onnx, sess_options=so, providers=providers)
print(f'ONNX loaded, providers: {sess.get_providers()}', flush=True)
print(f'TTA: 4-way (id + hflip + vflip + both)', flush=True)
print(f'Threshold sweep: {THRESHOLDS}', flush=True)
results = {}
out_json = out_dir / 'eval_tta_sweep.json'
for split in args.splits:
img_dir = Path(args.data_dir) / split / 'images'
msk_dir = Path(args.data_dir) / split / 'masks'
if not img_dir.exists(): continue
print(f'\n=== TTA + sweep on {split} ===', flush=True)
r = eval_split_tta_sweep(sess, img_dir, msk_dir, args.image_size, args.batch_size)
results[split] = r
# CRITICAL: save JSON immediately, before any print/format that could crash
# on encoding issues (lesson learned from the Unicode star crash).
try:
out_json.write_text(json.dumps(results, indent=2, default=float),
encoding='utf-8')
print(f' [checkpoint] saved partial results to {out_json}', flush=True)
except Exception as exc:
print(f' [checkpoint] save failed: {exc}', flush=True)
# Print per-threshold table (ASCII-only; cp1252-safe)
try:
print(f'\n {split} per-threshold (overall):', flush=True)
print(f' {"thr":>5s} {"macro_d":>8s} {"micro_d":>8s} {"recall":>7s} '
f'{"prec":>6s} {"FN_rate":>8s} {"FP_rate":>8s} {"F1":>6s} '
f'{"comp":>6s} mark', flush=True)
for t_str, d in r['thresholds'].items():
mark = ''
if t_str == r['best_composite_threshold']: mark += '*' # composite-best
if t_str == r['best_f1_threshold']: mark += 'F' # F1-best
print(f' {t_str:>5s} {d["macro_dice"]:>8.4f} {d["micro_dice"]:>8.4f}'
f' {d["macro_recall"]:>7.4f} {d["macro_precision"]:>6.4f}'
f' {d["macro_fn_rate"]:>8.4f} {d["fp_rate_mean"]:>8.5f}'
f' {d["macro_f1"]:>6.4f} {d["composite"]:>6.4f} {mark}',
flush=True)
if r['auroc'] is not None:
print(f' AUROC: {r["auroc"]:.4f}', flush=True)
print(f' best composite threshold: {r["best_composite_threshold"]}', flush=True)
print(f' best F1 threshold: {r["best_f1_threshold"]}', flush=True)
except Exception as exc:
print(f' [print] table render failed (data is safe in JSON): {exc}',
flush=True)
print(f'\nFinal save: {out_json}', flush=True)
if __name__ == '__main__':
raise SystemExit(main())