Tri-Netra-AI / scripts /eval_v8_full.py
anannyavyas1's picture
Upload folder using huggingface_hub
1cf3825 verified
Raw
History Blame Contribute Delete
13.2 kB
"""Full evaluation of v8 ONNX checkpoint on dataset_v8/test + samples/.
Computes every clinically relevant metric (macro Dice, micro Dice, IoU,
recall/sensitivity, precision, F1, FN rate, FP rate, specificity, AUROC),
stratified by source dataset (BraTS / LGG / Figshare / Kaggle), and
reports per-scan + pooled summaries.
Usage:
python scripts/eval_v8_full.py --onnx model/best_micro.onnx \\
--data_dir dataset_v8 --output_dir model/eval_results
Required files in model/:
best_micro.onnx (838 KB)
best_micro.onnx.data (121.9 MB, must be alongside .onnx)
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
from collections import defaultdict
from typing import Dict, List, Tuple
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)
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:
"""Classify a sample by filename prefix into source dataset."""
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'
if 'meningioma' in n: return 'figshare_meningioma'
if 'glioma' in n: return 'figshare_glioma'
if 'pituitary' in n: return 'figshare_pituitary'
return 'unknown'
def run_onnx_inference(sess, x_batch: np.ndarray) -> np.ndarray:
"""Returns sigmoid probabilities (B, H, W)."""
logits = sess.run(None, {'input': x_batch})[0]
return 1.0 / (1.0 + np.exp(-logits[:, 0]))
def evaluate_split(sess, img_dir: Path, msk_dir: Path, image_size: int,
threshold: float, batch_size: int = 16,
max_samples: int = None) -> Dict:
img_paths = sorted(img_dir.iterdir())
if max_samples is not None:
img_paths = img_paths[:max_samples]
print(f' evaluating {len(img_paths)} scans from {img_dir.parent.name}...', flush=True)
# Accumulators (per scan)
per_scan = [] # list of dicts
# Pooled tallies for micro metrics
tp_total = fp_total = fn_total = tn_total = 0
# AUROC accumulators
mean_probs = []
has_tumor_labels = []
by_source = defaultdict(lambda: {'dices': [], 'fp_rates': [], 'tp': 0, 'fp': 0, 'fn': 0, 'tn': 0,
'n_pos': 0, 'n_neg': 0})
t0 = time.time()
# Batched loop
for batch_start in range(0, len(img_paths), batch_size):
batch_imgs = img_paths[batch_start:batch_start + batch_size]
x_batch, y_batch, names, sources = [], [], [], []
for ip in batch_imgs:
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)
probs = run_onnx_inference(sess, x_batch)
preds = (probs >= threshold).astype(np.uint8)
for i in range(len(x_batch)):
yi = y_batch[i]; pi = preds[i]; pri = probs[i]
tp = int((pi * yi).sum()); fp = int((pi * (1 - yi)).sum())
fn = int(((1 - pi) * yi).sum()); tn = int(((1 - pi) * (1 - yi)).sum())
has_tumor = int(yi.sum() > 0)
mean_p_in_pred = float(pri[pi > 0].mean()) if pi.sum() > 0 else float(pri.max())
src = sources[i]
d = {
'name': names[i], 'source': src, 'has_tumor': has_tumor,
'tp': tp, 'fp': fp, 'fn': fn, 'tn': tn,
'pred_area': int(pi.sum()), 'true_area': int(yi.sum()),
'mean_prob': mean_p_in_pred, 'max_prob': float(pri.max()),
}
if has_tumor:
d['dice'] = (2 * tp + 1e-6) / (2 * tp + fp + fn + 1e-6)
d['iou'] = (tp + 1e-6) / (tp + fp + fn + 1e-6)
d['recall'] = (tp + 1e-6) / (tp + fn + 1e-6)
d['precision'] = (tp + 1e-6) / (tp + fp + 1e-6)
d['fn_rate'] = (fn + 1e-6) / (tp + fn + 1e-6)
by_source[src]['dices'].append(d['dice'])
by_source[src]['n_pos'] += 1
else:
d['fp_rate'] = fp / (fp + tn + 1e-6)
by_source[src]['fp_rates'].append(d['fp_rate'])
by_source[src]['n_neg'] += 1
by_source[src]['tp'] += tp; by_source[src]['fp'] += fp
by_source[src]['fn'] += fn; by_source[src]['tn'] += tn
per_scan.append(d)
tp_total += tp; fp_total += fp; fn_total += fn; tn_total += tn
mean_probs.append(float(pri.max()))
has_tumor_labels.append(has_tumor)
if (batch_start // batch_size) % 25 == 0:
print(f' [{batch_start + len(batch_imgs)}/{len(img_paths)}] '
f'{time.time() - t0:.0f}s elapsed', flush=True)
# Aggregate
pos_scans = [s for s in per_scan if s['has_tumor']]
neg_scans = [s for s in per_scan if not s['has_tumor']]
macro_dice = float(np.mean([s['dice'] for s in pos_scans])) if pos_scans else 0.0
macro_iou = float(np.mean([s['iou'] for s in pos_scans])) if pos_scans else 0.0
macro_recall = float(np.mean([s['recall'] for s in pos_scans])) if pos_scans else 0.0
macro_precision = float(np.mean([s['precision'] for s in pos_scans])) if pos_scans else 0.0
macro_fn_rate = float(np.mean([s['fn_rate'] for s in pos_scans])) if pos_scans else 0.0
fp_rate_mean = float(np.mean([s['fp_rate'] for s in neg_scans])) if neg_scans else 0.0
fp_rate_p95 = float(np.percentile([s['fp_rate'] for s in neg_scans], 95)) if neg_scans else 0.0
micro_dice = (2 * tp_total + 1e-6) / (2 * tp_total + fp_total + fn_total + 1e-6)
micro_iou = (tp_total + 1e-6) / (tp_total + fp_total + fn_total + 1e-6)
specificity = tn_total / max(tn_total + fp_total, 1)
# AUROC for tumor-vs-no-tumor classification using max-prob as score
try:
from sklearn.metrics import roc_auc_score
auroc = float(roc_auc_score(has_tumor_labels, mean_probs)) if len(set(has_tumor_labels)) > 1 else None
except ImportError:
auroc = None
composite = macro_dice - 5.0 * fp_rate_mean
# Per-source summaries
src_summary = {}
for src, agg in by_source.items():
d = {'n_pos': agg['n_pos'], 'n_neg': agg['n_neg'],
'tp': agg['tp'], 'fp': agg['fp'], 'fn': agg['fn'], 'tn': agg['tn']}
if agg['dices']:
d['macro_dice'] = float(np.mean(agg['dices']))
d['median_dice'] = float(np.median(agg['dices']))
if agg['fp_rates']:
d['mean_fp_rate'] = float(np.mean(agg['fp_rates']))
if agg['tp'] + agg['fn'] > 0:
d['recall_pooled'] = agg['tp'] / (agg['tp'] + agg['fn'])
if agg['tp'] + agg['fp'] > 0:
d['precision_pooled'] = agg['tp'] / (agg['tp'] + agg['fp'])
src_summary[src] = d
return {
'n_scans': len(per_scan), 'n_positive': len(pos_scans), 'n_negative': len(neg_scans),
'macro_dice': macro_dice, 'macro_iou': macro_iou,
'macro_recall': macro_recall, 'macro_precision': macro_precision,
'macro_fn_rate': macro_fn_rate,
'fp_rate_mean': fp_rate_mean, 'fp_rate_p95': fp_rate_p95,
'micro_dice': float(micro_dice), 'micro_iou': float(micro_iou),
'specificity': float(specificity), 'auroc': auroc, 'composite': float(composite),
'tp_total': tp_total, 'fp_total': fp_total, 'fn_total': fn_total, 'tn_total': tn_total,
'by_source': src_summary,
'elapsed_sec': time.time() - t0,
}
def evaluate_samples_folder(sess, samples_root: Path, image_size: int, threshold: float) -> Dict:
"""Eval on samples/{tumor,no_tumor}/*.jpg — the user's manual test set."""
out = {}
for cls in ('tumor', 'no_tumor'):
d = samples_root / cls
if not d.exists(): continue
results = []
for p in sorted(d.iterdir()):
x = load_image_normalized(p, image_size)[None].astype(np.float32)
probs = run_onnx_inference(sess, x)[0]
preds = (probs >= threshold).astype(np.uint8)
results.append({
'name': p.name, 'true_class': cls,
'pred_area_px': int(preds.sum()),
'mean_prob_in_pred': float(probs[preds > 0].mean()) if preds.sum() > 0 else 0.0,
'max_prob': float(probs.max()),
'predicted_tumor': bool(preds.sum() > 16), # match v8 cascade threshold
})
out[cls] = results
return out
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('--samples_dir', default='samples')
ap.add_argument('--output_dir', default='model/eval_results')
ap.add_argument('--image_size', type=int, default=384)
ap.add_argument('--threshold', type=float, default=0.5)
ap.add_argument('--batch_size', type=int, default=16)
ap.add_argument('--max_samples_per_split', type=int, default=None,
help='Cap eval size for quick smoke test')
args = ap.parse_args()
out_dir = Path(args.output_dir); out_dir.mkdir(parents=True, exist_ok=True)
print(f'Loading ONNX: {args.onnx}', flush=True)
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.log_severity_level = 3
providers = []
if 'CUDAExecutionProvider' in ort.get_available_providers():
providers.append('CUDAExecutionProvider')
providers.append('CPUExecutionProvider')
sess = ort.InferenceSession(args.onnx, sess_options=so, providers=providers)
print(f'Providers active: {sess.get_providers()}', flush=True)
print(f'Input: {sess.get_inputs()[0].name} {sess.get_inputs()[0].shape}', flush=True)
print(f'Output: {sess.get_outputs()[0].name} {sess.get_outputs()[0].shape}', flush=True)
print(f'Threshold: {args.threshold} image_size: {args.image_size}', flush=True)
results = {}
data_root = Path(args.data_dir)
for split in ('val', 'test'):
img_dir = data_root / split / 'images'
msk_dir = data_root / split / 'masks'
if not img_dir.exists(): continue
print(f'\n=== Evaluating {split} ===', flush=True)
results[split] = evaluate_split(
sess, img_dir, msk_dir, args.image_size, args.threshold,
batch_size=args.batch_size, max_samples=args.max_samples_per_split,
)
print(f' {split} summary:', flush=True)
print(f' macro_dice = {results[split]["macro_dice"]:.4f}', flush=True)
print(f' micro_dice = {results[split]["micro_dice"]:.4f}', flush=True)
print(f' macro_iou = {results[split]["macro_iou"]:.4f}', flush=True)
print(f' recall = {results[split]["macro_recall"]:.4f}', flush=True)
print(f' precision = {results[split]["macro_precision"]:.4f}', flush=True)
print(f' FN rate = {results[split]["macro_fn_rate"]:.4f}', flush=True)
print(f' FP rate = {results[split]["fp_rate_mean"]:.4f} (p95: {results[split]["fp_rate_p95"]:.4f})', flush=True)
print(f' specificity= {results[split]["specificity"]:.4f}', flush=True)
if results[split]["auroc"] is not None:
print(f' AUROC = {results[split]["auroc"]:.4f}', flush=True)
print(f' composite = {results[split]["composite"]:.4f}', flush=True)
samples_root = Path(args.samples_dir)
if samples_root.exists():
print(f'\n=== Evaluating {samples_root} ===', flush=True)
results['samples'] = evaluate_samples_folder(sess, samples_root, args.image_size, args.threshold)
for cls, rows in results['samples'].items():
n_pred_tumor = sum(1 for r in rows if r['predicted_tumor'])
print(f' {cls}: {n_pred_tumor}/{len(rows)} predicted as tumor', flush=True)
out_json = out_dir / 'eval_full.json'
out_json.write_text(json.dumps(results, indent=2, default=float))
print(f'\nFull results saved to {out_json}', flush=True)
if __name__ == '__main__':
raise SystemExit(main())