File size: 5,038 Bytes
1cf3825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""Run the same v8 inference pipeline the deployed dashboard uses against

every sample under samples/ood/, then print a verdict table.



Pipeline mirrors dashboard.py:

  - load model/best_micro.onnx (ConvNeXt-Tiny U-Net, 384 px, Tversky)

  - resize -> 384, ImageNet normalise, batched 4-way flip TTA in one ORT call

  - per-pixel mean probability, threshold 0.20 -> binary mask

  - report tumor area (px), max prob, classifier verdict, image source

"""
from __future__ import annotations

import csv
import os
import sys
import time
from pathlib import Path

import numpy as np
import onnxruntime as ort
from PIL import Image

ROOT = Path(__file__).resolve().parent.parent
ONNX = ROOT / 'model' / 'best_micro.onnx'
SAMPLES_DIR = ROOT / 'samples' / 'ood'
SIZE = 384
THRESH = 0.20
MIN_TUMOR_AREA = 50  # match dashboard's 50-pixel minimum

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_v8() -> ort.InferenceSession:
    providers = ['CPUExecutionProvider']
    sess = ort.InferenceSession(str(ONNX), providers=providers)
    print(f'[init] ONNX session: {ONNX.name} '
          f'(input={sess.get_inputs()[0].name}, output={sess.get_outputs()[0].name})')
    return sess


def preprocess(img: Image.Image) -> np.ndarray:
    """PIL -> (3, SIZE, SIZE) float32 normalised."""
    img = img.convert('RGB').resize((SIZE, SIZE), Image.BILINEAR)
    arr = np.asarray(img, dtype=np.float32) / 255.0
    arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
    return arr.transpose(2, 0, 1)  # CHW


def tta_predict(sess: ort.InferenceSession, chw: np.ndarray) -> np.ndarray:
    """Batched 4-way TTA: id, hflip, vflip, hvflip — single ORT call.

    Returns mean tumor probability map at SIZE x SIZE.

    """
    base = chw
    h = base[:, :, ::-1].copy()
    v = base[:, ::-1, :].copy()
    hv = base[:, ::-1, ::-1].copy()
    batch = np.stack([base, h, v, hv], axis=0)  # (4, 3, SIZE, SIZE)
    in_name = sess.get_inputs()[0].name
    logits = sess.run(None, {in_name: batch})[0]  # (4, 1, SIZE, SIZE)
    if logits.shape[1] > 1:  # 2-channel models -> take fg
        logits = logits[:, 1:2]
    prob = 1.0 / (1.0 + np.exp(-logits))  # sigmoid
    # Undo flips before averaging.
    prob[1] = prob[1, :, :, ::-1]
    prob[2] = prob[2, :, ::-1, :]
    prob[3] = prob[3, :, ::-1, ::-1]
    return prob.mean(axis=0)[0]  # (SIZE, SIZE)


def main():
    if not ONNX.exists():
        print(f'ERROR: {ONNX} missing — download via dashboard or upload script.')
        sys.exit(2)
    sess = load_v8()
    rows: list[dict] = []
    samples = sorted(p for p in SAMPLES_DIR.rglob('*.png'))
    if not samples:
        print(f'ERROR: no PNGs under {SAMPLES_DIR}')
        sys.exit(2)
    print(f'\n[eval] {len(samples)} OOD samples\n')
    t0 = time.perf_counter()
    for p in samples:
        try:
            img = Image.open(p)
            chw = preprocess(img)
            prob = tta_predict(sess, chw)
            area = int((prob >= THRESH).sum())
            verdict = 'TUMOR' if area >= MIN_TUMOR_AREA else 'no_tumor'
            source = p.parent.name
            row = {
                'source': source,
                'file': p.name,
                'prob_max': float(prob.max()),
                'prob_mean_fg': float(prob[prob >= THRESH].mean()) if area else 0.0,
                'tumor_area_px': area,
                'verdict': verdict,
            }
            rows.append(row)
        except Exception as exc:
            print(f'  [fail] {p.name}: {type(exc).__name__}: {exc}')
    elapsed = time.perf_counter() - t0

    # Per-source summary
    print(f'\n=== per-image verdicts (threshold={THRESH}) ===')
    hdr = f'{"source":36s}  {"file":48s}  {"pmax":>5s}  {"area":>6s}  verdict'
    print(hdr)
    print('-' * len(hdr))
    for r in rows:
        print(f'{r["source"][:36]:36s}  {r["file"][:48]:48s}  '
              f'{r["prob_max"]:.3f}  {r["tumor_area_px"]:6d}  {r["verdict"]}')

    # Aggregate per source
    print('\n=== per-source summary ===')
    by_src: dict[str, list[dict]] = {}
    for r in rows:
        by_src.setdefault(r['source'], []).append(r)
    for src in sorted(by_src):
        rs = by_src[src]
        n_tum = sum(1 for r in rs if r['verdict'] == 'TUMOR')
        avg_pmax = np.mean([r['prob_max'] for r in rs])
        print(f'  {src:46s}  n={len(rs):3d}  tumor_called={n_tum:3d}  '
              f'mean(pmax)={avg_pmax:.3f}')
    print(f'\n[done] {len(rows)} samples in {elapsed:.1f}s '
          f'({elapsed/max(1,len(rows)):.2f} s/sample)')

    # Persist for inspection.
    out_csv = SAMPLES_DIR / 'eval_results.csv'
    with out_csv.open('w', newline='', encoding='utf-8') as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)
    print(f'[csv] wrote {out_csv}')


if __name__ == '__main__':
    main()