Tri-Netra-AI / dashboard.py
anannyavyas1's picture
Upload folder using huggingface_hub
c1266c3 verified
Raw
History Blame Contribute Delete
111 kB
import os
os.environ['V9C_ENABLE'] = '1'
os.environ['V9C_DOWNLOAD'] = '1'
os.environ['HF_HUB_DISABLE_SYMLINKS_WARNING'] = '1'
os.environ['V9B_ANDI_ENABLE'] = '1'
os.environ['V9B_ANDI_DOWNLOAD'] = '1'
import json
import os
import re
import sys
import time
import uuid
import gzip
import logging
import threading
import urllib.parse
import argparse
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from PIL import Image
import numpy as np
import base64
import io
import db
try:
db.init_db()
except Exception as e:
logger.error(f"Failed to init DB: {e}")
# ---------------------------------------------------------------------------
# Logging - one structured-ish line per request, sent to stdout (Spaces-friendly).
# ---------------------------------------------------------------------------
logging.basicConfig(
level=os.environ.get('LOG_LEVEL', 'INFO').upper(),
format='%(asctime)s %(levelname)s %(name)s | %(message)s',
stream=sys.stdout,
)
logger = logging.getLogger('trinetra.dashboard')
# Server version surfaced via /version and X-Server-Version header. Updated
# manually when shipping a notable change so the frontend can detect mismatches.
SERVER_VERSION = '2.1.0-onnx'
# Track when the server process started for /health uptime.
PROCESS_START_TS = time.time()
# TensorFlow is only needed for the legacy .h5 classifier branch (Grad-CAM
# via tf.expand_dims). Since all current checkpoints are PyTorch .pt, TF is
# imported lazily inside predict_image() instead of at module load. This lets
# the dashboard run on Python builds (e.g. 3.14) where TF isn't available.
ROOT_DIR = Path(__file__).resolve().parent
WEB_DIR = ROOT_DIR / 'web_dashboard'
# Classifiers (cnn / transfer / vit) were removed from the production
# pipeline on 2026-06-01 (OOD recall capped at 25-47% even after
# retraining). Replaced with a four-signal ensemble (final form shipped
# 2026-06-03b, see src/research/v9b_advisory.py):
# verdict = (v9c AND symmetry) OR (v8 AND ANDi)
# where v9c = frozen DINOv2 + JEPA predictor (the primary anomaly head)
# and ANDi = unconditional pyramidal-noise DDPM. Measured on the
# 246-sample expanded OOD bench:
# balanced (default) 97% recall / 6% FPR / 0.83 F1
# high_recall 100% recall / 14% FPR / 0.71 F1
# high_specificity 92% recall / 4% FPR / 0.85 F1
# Keep MODEL_TYPES empty so every dashboard loop that iterates the old
# classifiers becomes a no-op without further code changes.
MODEL_TYPES: list[str] = []
MODEL_LABELS: dict[str, str] = {}
ARTIFACTS_DIRS = [ROOT_DIR / 'real_eval_fixed', ROOT_DIR / 'real_eval_current', ROOT_DIR / 'artifacts']
# Probe these in order; the first one with a best_model.pt wins.
# - attention_unet_v5: SMP UNet+ResNet34, BraTS+LGG positives + Kaggle no-tumor
# negatives (empty masks). Balanced 50/50 sampler, Dice+BCE
# loss with positive weight, modality dropout. Eliminates
# the positive bias that produced false-positive masks on
# healthy brains in v3. SEE: prepare_v5_dataset.py +
# src/train_segmentation_v5.py. Probed first.
# - attention_unet_v3: SMP UNet+ResNet34, BraTS+LGG (real masks). micro-Dice 0.910 BraTS.
# - attention_unet_v2: SMP UNet+ResNet34, LGG + Kaggle pseudo masks.
# - attention_unet_lgg: hand-rolled Attention U-Net, LGG only.
# - attention_unet: pseudo-mask baseline (traces skull); historical reference.
# v8 (ConvNeXt-Tiny + 384px + Tversky + Figshare-augmented dataset) is one
# of the four ensemble signals. micro_dice 0.80, AUROC 0.94 on
# dataset_v8/test. At threshold 0.20 + TTA the v8 mask is fed into the
# 4-signal advisory rule `(v9c AND sym) OR (v8 AND andi)` — v8's role is
# the area-based branch of the OR; the v9c+symmetry branch catches
# tumors where v8 missed.
SEGMENTATION_DIRS = [
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_v8',
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_v5',
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_v3',
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_v2',
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_lgg',
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet',
]
# Per-segmentation-model default thresholds. v8 ships at 0.20 because that
# threshold + 4-way TTA gives the best clinical operating point (FN 23%,
# FP 0.26%). Older models keep 0.50 default for backwards compatibility.
# Override at request time by passing `threshold` in the /segment form.
SEGMENTATION_DEFAULT_THRESHOLD = {
'attention_unet_v8': 0.20,
'attention_unet_v5': 0.50,
'attention_unet_v3': 0.50,
'attention_unet_t1c': 0.50,
}
# Per-modality model overrides. /segment can request a specific specialist by
# passing modality=<key> in the multipart form; we then load that model
# instead of the default search. T1c specialist is trained from the BraTS T1c
# channel triplicated as RGB (see prepare_brats_dataset.py --channels t1ce t1ce t1ce
# and segmentation_artifacts/attention_unet_t1c/).
MODALITY_DIRS = {
't1c': ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_t1c',
}
MODEL_CACHE = {}
SEG_CACHE = {}
# ONNX runtime sessions, keyed by .onnx path. Separate from the PyTorch caches
# because ONNX sessions hold GPU memory through onnxruntime, not torch's
# allocator, so we don't want them to participate in the torch cache evictor.
ONNX_CACHE: dict = {}
# Set ONNX_DISABLE=1 to force the PyTorch path (useful for Grad-CAM-heavy
# debugging or when you suspect an ONNX-vs-PyTorch numerical discrepancy on a
# new export). Default: prefer ONNX whenever a .onnx sibling exists next to
# the .pt checkpoint. Grad-CAM always falls back to PyTorch regardless of this
# flag because ONNX has no autograd.
USE_ONNX = os.environ.get('ONNX_DISABLE', '').strip() not in ('1', 'true', 'yes')
sys.path.append(str(ROOT_DIR))
def _resolve_segmentation_weights(modality: str | None = None):
"""Return (weights_path, dir_name) of the model to load.
Tries .pt first (needed for PyTorch fallback paths), then .onnx (the
Spaces deploy state where only ONNX weights are on disk). Either is fine
for inference since segment_image's ONNX-preferred branch handles both.
"""
def _find_in(d):
# v8 saves checkpoints as best_micro.pt / best_micro.onnx (highest
# micro_dice = headline metric). Older models use best_model.* (highest
# composite). Prefer the model's "headline" checkpoint when present.
for basename in ('best_micro', 'best_model'):
for ext in ('.pt', '.onnx'):
p = d / f'{basename}{ext}'
if p.exists():
return p
return None
if modality and modality in MODALITY_DIRS:
p = _find_in(MODALITY_DIRS[modality])
if p is not None:
return p, MODALITY_DIRS[modality].name
for d in SEGMENTATION_DIRS:
p = _find_in(d)
if p is not None:
return p, d.name
return None, None
def _load_segmentation_model(modality: str | None = None):
"""Load the trained PyTorch segmentation model into the cache.
Returns (model, device, config) on success, or None if no checkpoint
exists for the requested modality or in the default search path.
Returns None for .onnx-only checkpoints (use the ONNX path instead).
"""
weights_path, dir_name = _resolve_segmentation_weights(modality)
if weights_path is None:
return None
# .onnx files cannot be loaded with torch.load (they're protobuf, not
# pickle). Return None so the caller uses the ONNX inference path.
if weights_path.suffix == '.onnx':
logger.debug('_load_segmentation_model: skipping %s (ONNX file, use ONNX runtime)', weights_path)
return None
cache_key = ('seg', str(weights_path), weights_path.stat().st_mtime if weights_path.exists() else 0)
if cache_key in SEG_CACHE:
return SEG_CACHE[cache_key]
import torch
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
ckpt = torch.load(str(weights_path), map_location=device, weights_only=False)
cfg = ckpt.get('config', {}) or {}
cfg['_source_dir'] = dir_name # surface which checkpoint we loaded in /segment responses
# v2 checkpoints record their SMP architecture + encoder; load via SMP.
# v1 checkpoints use the hand-rolled AttentionUNet.
architecture = ckpt.get('architecture')
encoder = ckpt.get('encoder')
if architecture and encoder:
import segmentation_models_pytorch as smp
SmpClass = getattr(smp, architecture)
model = SmpClass(
encoder_name=encoder,
encoder_weights=None, # state_dict overrides weights, don't re-download ImageNet
in_channels=3,
classes=1,
).to(device)
cfg['_normalization'] = 'imagenet' # tell segment_image to use ImageNet mean/std
if 'image_size' in ckpt:
cfg.setdefault('image_size', int(ckpt['image_size']))
else:
from src.segmentation_torch import AttentionUNet
model = AttentionUNet(
in_channels=3,
base_filters=int(cfg.get('base_filters', 32)),
dropout=float(cfg.get('dropout', 0.2)),
).to(device)
cfg['_normalization'] = 'rescale_255'
model.load_state_dict(ckpt['state_dict'])
model.eval()
# Don't clear: cascade routing wants both v3 and the T1c specialist to stay
# GPU-warm so the fallback path doesn't pay a second 200ms reload. ~200 MB
# combined VRAM, trivial on an 8 GB card. We cap the dict at 4 entries to
# avoid unbounded growth if more specialists are added later.
if len(SEG_CACHE) >= 4:
SEG_CACHE.pop(next(iter(SEG_CACHE)))
SEG_CACHE[cache_key] = (model, device, cfg)
return SEG_CACHE[cache_key]
# Cascade thresholds. Two reasons to retry with the T1c specialist:
# AREA: v3 found <25 px of tumor (essentially nothing). True positives are
# routinely >500 px so <25 is just background noise.
# PROB: v3 returned an area but mean probability inside the mask is below
# this threshold, meaning v3 is "kind of" picking up something but
# not committing - common on Kaggle T1-contrast single-modality
# input where v3 (trained mostly on multi-modal stacks) gives soft
# predictions. T1c specialist often nails these.
CASCADE_MIN_AREA_PX = 25
CASCADE_MIN_MEAN_PROB = 0.65
def _get_onnx_session(onnx_path):
"""Return a cached onnxruntime InferenceSession for the given .onnx path.
Sessions are reused across requests (model load is the expensive part).
Provider preference: CUDA -> CPU. We don't enable TensorRT by default
because its build-time graph compilation adds 30+ seconds to the first
request, which would hurt the user-perceived 'first inference' latency.
"""
onnx_path = str(onnx_path)
sess = ONNX_CACHE.get(onnx_path)
if sess is not None:
return sess
try:
import onnxruntime as ort
except ImportError:
return None
providers = []
avail = ort.get_available_providers()
if 'CUDAExecutionProvider' in avail:
providers.append('CUDAExecutionProvider')
providers.append('CPUExecutionProvider')
so = ort.SessionOptions()
# Optimization level: ALL = constant folding + fusion + memory planning.
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.log_severity_level = 3 # silence the routine memcpy / EP warnings
sess = ort.InferenceSession(onnx_path, sess_options=so, providers=providers)
ONNX_CACHE[onnx_path] = sess
return sess
def _segmentation_onnx_path(pt_path):
"""Return the .onnx sibling if it exists. Conventional name is the same
basename with .onnx (produced by scripts/export_onnx.py).
If pt_path is already .onnx, return it directly."""
p = Path(pt_path)
if p.suffix == '.onnx' and p.exists():
return p
candidate = p.with_suffix('.onnx')
return candidate if candidate.exists() else None
def _classifier_onnx_path(pt_path):
"""Same convention for classifier .pt files (best_weights.pt -> best_weights.onnx)."""
p = Path(pt_path)
candidate = p.with_suffix('.onnx')
return candidate if candidate.exists() else None
def _is_grayscale_input(image_bytes, sample_threshold: float = 1.5) -> bool:
"""Detect single-modality (grayscale-triplicated) inputs.
Kaggle Brain Tumor MRI is single-modality T1c displayed as an RGB JPEG
where R == G == B per pixel. v3 was trained on multi-modal BraTS stacks
(T1+T1c+T2+FLAIR) where the channels carry independent information; on a
grayscale input v3 has no multi-modal cue and segments imperfectly. The
T1c specialist was trained on triplicated T1c channels and is the correct
primary for these inputs.
We sample the mean per-pixel channel deviation and treat anything below
`sample_threshold` (out of 255) as grayscale.
"""
try:
from PIL import Image as _PIL
import io as _io
img = _PIL.open(_io.BytesIO(image_bytes)).convert('RGB').resize((64, 64))
arr = np.asarray(img, dtype=np.float32)
chan_dev = (np.abs(arr[:, :, 0] - arr[:, :, 1])
+ np.abs(arr[:, :, 1] - arr[:, :, 2])
+ np.abs(arr[:, :, 0] - arr[:, :, 2])) / 3.0
return float(chan_dev.mean()) < sample_threshold
except Exception:
return False
def _build_conformal_seg_fn_batched(modality: str | None, image_size: int = 256):
"""Like _build_conformal_seg_fn but accepts a BATCH of images.
Signature: seg_fn_batched(x: NxHxWx3 float in [0,1]) -> NxHxW float.
Used by the conformal-counterfactual head's fast path to run all
N interventions in a single ORT call (vs N sequential calls). On a
cpu-basic Space at N=8, this saves ~5-6 sec per scan with no
feature loss vs running each intervention separately.
Returns None when no checkpoint or when ONNX is disabled (PyTorch
fallback path is not batched; the slow path handles it).
"""
weights_path, dir_name = _resolve_segmentation_weights(modality)
if weights_path is None:
return None
onnx_path = _segmentation_onnx_path(weights_path) if USE_ONNX else None
if onnx_path is None:
return None
sess = _get_onnx_session(onnx_path)
if sess is None:
return None
ms_size = 384 if dir_name == 'attention_unet_v8' else image_size
norm_mode = 'rescale_255' if dir_name in ('attention_unet', 'attention_unet_lgg') else 'imagenet'
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def seg_fn_batched(x_batch: np.ndarray) -> np.ndarray:
assert x_batch.ndim == 4 and x_batch.shape[-1] == 3, x_batch.shape
n, h, w, _ = x_batch.shape
if (h, w) != (ms_size, ms_size):
from PIL import Image as _PIL
resized = np.zeros((n, ms_size, ms_size, 3), dtype=np.float32)
for i in range(n):
pil = _PIL.fromarray((np.clip(x_batch[i], 0, 1) * 255).astype(np.uint8))
pil = pil.resize((ms_size, ms_size))
resized[i] = np.asarray(pil, dtype=np.float32) / 255.0
x_batch = resized
if norm_mode == 'imagenet':
base = (x_batch - mean) / std
else:
base = x_batch
xt = base.transpose(0, 3, 1, 2).astype(np.float32) # NxCxHxW
logits = sess.run(None, {'input': xt})[0]
prob = 1.0 / (1.0 + np.exp(-logits))
return prob[:, 0].astype(np.float32) # N x H x W
return seg_fn_batched
def _build_conformal_seg_fn(modality: str | None, image_size: int = 256):
"""Build a callable matching the conformal-counterfactual module's API.
Signature: seg_fn(x: HxWx3 float in [0,1]) -> HxW float in [0,1].
The intervention modules operate on raw pixel space (pre-normalisation),
so this wrapper applies the cascade-winner's normalisation internally.
Uses the cached ONNX session when available, PyTorch fallback otherwise.
Returns None if no checkpoint exists for the requested modality.
"""
weights_path, dir_name = _resolve_segmentation_weights(modality)
if weights_path is None:
return None
onnx_path = _segmentation_onnx_path(weights_path) if USE_ONNX else None
norm_mode = 'rescale_255' if dir_name in ('attention_unet', 'attention_unet_lgg') else 'imagenet'
if onnx_path is not None:
sess = _get_onnx_session(onnx_path)
if sess is None:
onnx_path = None
if onnx_path is not None:
sess = _get_onnx_session(onnx_path)
def seg_fn(x: np.ndarray) -> np.ndarray:
assert x.ndim == 3 and x.shape[2] == 3, x.shape
arr = x
if arr.shape[:2] != (image_size, image_size):
from PIL import Image as _PIL
pil = _PIL.fromarray((np.clip(arr, 0, 1) * 255).astype(np.uint8))
pil = pil.resize((image_size, image_size))
arr = np.asarray(pil, dtype=np.float32) / 255.0
if norm_mode == 'imagenet':
base = (arr - np.array([0.485, 0.456, 0.406], dtype=np.float32)) \
/ np.array([0.229, 0.224, 0.225], dtype=np.float32)
else:
base = arr
xt = base.transpose(2, 0, 1)[None].astype(np.float32)
logits = sess.run(None, {'input': xt})[0]
prob = 1.0 / (1.0 + np.exp(-logits))
return prob[0, 0].astype(np.float32)
return seg_fn
# PyTorch fallback.
import torch
loaded = _load_segmentation_model(modality=modality)
if loaded is None:
return None
model, device, cfg = loaded
image_size_pt = int(cfg.get('image_size', image_size))
norm_mode_pt = cfg.get('_normalization', norm_mode)
def seg_fn(x: np.ndarray) -> np.ndarray:
assert x.ndim == 3 and x.shape[2] == 3, x.shape
arr = x
if arr.shape[:2] != (image_size_pt, image_size_pt):
from PIL import Image as _PIL
pil = _PIL.fromarray((np.clip(arr, 0, 1) * 255).astype(np.uint8))
pil = pil.resize((image_size_pt, image_size_pt))
arr = np.asarray(pil, dtype=np.float32) / 255.0
if norm_mode_pt == 'imagenet':
base = (arr - np.array([0.485, 0.456, 0.406], dtype=np.float32)) \
/ np.array([0.229, 0.224, 0.225], dtype=np.float32)
else:
base = arr
t = torch.from_numpy(base.transpose(2, 0, 1)).unsqueeze(0).to(device)
with torch.no_grad():
logits = model(t)
prob = torch.sigmoid(logits)[0, 0].cpu().numpy()
return prob.astype(np.float32)
return seg_fn
def _conformal_counterfactual_for(result: dict, image_bytes: bytes) -> dict | None:
"""Optional conformal-counterfactual analysis after the cascade pick.
Wraps src.research.dashboard_integration.analyze so the dashboard
does not import numpy heavy machinery unless artifacts exist.
Returns None if calibration artifacts are missing or anything fails.
Set CONFORMAL_DISABLE=1 in the Space environment to skip this entirely
(saves ~6-8 sec per scan on CPU-basic; conformal panel will just show
"pending" hint instead of populated coverage data).
"""
if os.environ.get('CONFORMAL_DISABLE', '').strip() in ('1', 'true', 'yes'):
return None
try:
from src.research import dashboard_integration as _cf
except Exception:
return None
import io as _io
from PIL import Image as _PIL
source_dir = result.get('source_dir')
modality = 't1c' if source_dir == 'attention_unet_t1c' else None
image_size = int(result.get('image_size', 256))
seg_fn = _build_conformal_seg_fn(modality, image_size=image_size)
if seg_fn is None:
return None
# Batched seg_fn for the fast path (all 8 interventions in one ORT call).
# Falls back to None for PyTorch path; analyze() then uses the slow loop.
seg_fn_batched = _build_conformal_seg_fn_batched(modality, image_size=image_size)
try:
pil_img = _PIL.open(_io.BytesIO(image_bytes)).convert('RGB').resize((image_size, image_size))
arr = np.asarray(pil_img, dtype=np.float32) / 255.0
# Recompute factual prob through the same seg_fn so it is on the
# same scale as the intervention outputs (single forward, no TTA).
# This keeps cf-vs-factual comparison apples-to-apples; the displayed
# primary mask still uses the full TTA path.
factual_prob = seg_fn(arr)
return _cf.analyze(
image_array=arr,
factual_prob=factual_prob,
seg_fn=seg_fn,
seg_fn_batched=seg_fn_batched,
threshold=float(result.get('threshold', 0.5)),
)
except Exception:
return None
def _segment_one(image_bytes, threshold: float, modality: str | None):
"""Single-model segmentation. No cascade logic. Returns the standard
response dict (success/mask/overlay/tumor_area_px/source_dir) or an error
dict if no checkpoint exists for the requested modality.
Inference backend selection:
- If a sibling .onnx file exists next to the resolved .pt AND USE_ONNX
is True, run via onnxruntime (CUDA EP if available, else CPU EP).
~3x faster than PyTorch cold path, ~equal once warm.
- Otherwise fall back to PyTorch. The PyTorch path is also used when
Grad-CAM is later requested (autograd needed).
"""
import io
import base64
import numpy as np
from PIL import Image
weights_path, dir_name = _resolve_segmentation_weights(modality)
# If caller passed the legacy default 0.5 but this model has a tuned
# default in SEGMENTATION_DEFAULT_THRESHOLD (e.g. v8 -> 0.20), use the
# tuned default. Explicit non-default thresholds always win.
if dir_name and abs(threshold - 0.5) < 1e-9:
tuned = SEGMENTATION_DEFAULT_THRESHOLD.get(dir_name)
if tuned is not None:
threshold = float(tuned)
if weights_path is None:
return {
'success': False,
'error': 'Segmentation weights not found.',
'hint': 'Run `python src/train_segmentation_torch.py` to train the Attention U-Net first.',
}
onnx_path = _segmentation_onnx_path(weights_path) if USE_ONNX else None
used_runtime = 'pytorch'
if onnx_path is not None:
# ONNX fast path. We don't need to load the PyTorch checkpoint at all
# for the forward pass; we just need the cfg-style metadata (image
# size, normalization). We default to the v2/v3 ImageNet stack since
# all current .onnx exports came from SMP UNets with that pretraining.
sess = _get_onnx_session(onnx_path)
if sess is None:
onnx_path = None # onnxruntime missing -> fall through to PyTorch
if onnx_path is not None:
sess = _get_onnx_session(onnx_path)
# v8 ConvNeXt-Tiny was trained at 384x384; v3/v5/t1c at 256x256.
image_size = 384 if dir_name == 'attention_unet_v8' else 256
# The lgg / attention_unet baselines were trained with 0-1 rescale,
# the SMP-style models (v2/v3/t1c/v8) expect ImageNet normalisation.
norm_mode = 'rescale_255' if dir_name in ('attention_unet', 'attention_unet_lgg') else 'imagenet'
pil_img = Image.open(io.BytesIO(image_bytes)).convert('RGB').resize((image_size, image_size))
arr = np.asarray(pil_img, dtype=np.float32) / 255.0
if norm_mode == 'imagenet':
base = ((arr - np.array([0.485, 0.456, 0.406], dtype=np.float32))
/ np.array([0.229, 0.224, 0.225], dtype=np.float32))
else:
base = arr
# Test-time augmentation: average predictions across 4 geometric
# transforms (identity, hflip, rot180, hflip+rot180). Each transform
# is reversed on the output before averaging, so the mask aligns with
# the original image. TTA typically buys 3-5% Dice on out-of-
# distribution inputs at 4x inference cost (~150 ms on CUDA).
# Disable with TTA_DISABLE=1 for debugging.
use_tta = os.environ.get('TTA_DISABLE', '').strip() not in ('1', 'true', 'yes')
if use_tta:
# BATCHED TTA: stack all 4 transforms into a single batch=4 ORT
# call instead of 4 sequential calls. On cpu-basic this drops
# TTA cost from ~3-4 sec -> ~1.2-1.5 sec with no result change.
tta_inputs = [
('id', base),
('hflip', base[:, ::-1, :].copy()),
('rot180', base[::-1, ::-1, :].copy()),
('hflip_rot180', base[::-1, :, :].copy()),
]
batch = np.stack([inp.transpose(2, 0, 1) for _, inp in tta_inputs],
axis=0).astype(np.float32)
logits_batch = sess.run(None, {'input': batch})[0] # (4, 1, H, W)
probs_batch = 1.0 / (1.0 + np.exp(-logits_batch))
prob_sum = np.zeros((image_size, image_size), dtype=np.float32)
for i, (tag, _) in enumerate(tta_inputs):
p = probs_batch[i, 0]
# Reverse the transform on the probability map.
if tag == 'hflip':
p = p[:, ::-1]
elif tag == 'rot180':
p = p[::-1, ::-1]
elif tag == 'hflip_rot180':
p = p[::-1, :]
prob_sum += p
probs = prob_sum / float(len(tta_inputs))
else:
x_np = base.transpose(2, 0, 1)[None].astype(np.float32)
logits = sess.run(None, {'input': x_np})[0]
probs = 1.0 / (1.0 + np.exp(-logits))
probs = probs[0, 0]
cfg = {'_source_dir': dir_name, '_normalization': norm_mode,
'image_size': image_size, '_tta': use_tta}
used_runtime = 'onnx'
else:
# PyTorch fallback (also takes the original loading path so cfg gets
# populated from the checkpoint, including any custom image_size).
import torch
loaded = _load_segmentation_model(modality=modality)
if loaded is None:
return {
'success': False,
'error': 'Segmentation weights not found.',
'hint': 'Run `python src/train_segmentation_torch.py` first.',
}
model, device, cfg = loaded
image_size = int(cfg.get('image_size', 256))
pil_img = Image.open(io.BytesIO(image_bytes)).convert('RGB').resize((image_size, image_size))
arr = np.asarray(pil_img, dtype=np.float32) / 255.0
if cfg.get('_normalization') == 'imagenet':
norm = arr.copy()
norm = (norm - np.array([0.485, 0.456, 0.406], dtype=np.float32)) / \
np.array([0.229, 0.224, 0.225], dtype=np.float32)
x = torch.from_numpy(norm.transpose(2, 0, 1)).unsqueeze(0).to(device)
else:
x = torch.from_numpy(arr.transpose(2, 0, 1)).unsqueeze(0).to(device)
with torch.no_grad():
logits = model(x)
probs = torch.sigmoid(logits)[0, 0].cpu().numpy()
mask_bin = (probs >= float(threshold)).astype(np.uint8) * 255
# Largest-component filter: drop small spurious blobs that often appear
# on out-of-distribution inputs (Kaggle T1-contrast scans). Keep only
# components whose area is at least KEEP_FRACTION of the largest one.
# Off when LARGEST_COMPONENT_FILTER_OFF=1 (debug).
if os.environ.get('LARGEST_COMPONENT_FILTER_OFF', '').strip() not in ('1', 'true', 'yes'):
try:
import cv2 as _cv2
n, labels, stats, _c = _cv2.connectedComponentsWithStats(mask_bin, connectivity=8)
if n > 2: # 0 is background, 1+ are foreground components
areas = stats[1:, _cv2.CC_STAT_AREA]
if areas.size:
keep_floor = max(int(0.10 * areas.max()), 5)
cleaned = np.zeros_like(mask_bin)
for i in range(1, n):
if stats[i, _cv2.CC_STAT_AREA] >= keep_floor:
cleaned[labels == i] = 255
mask_bin = cleaned
except Exception:
pass
tumor_area_px = int((mask_bin > 0).sum())
rgb = (arr * 255).astype(np.uint8)
overlay = rgb.copy()
alpha_mask = (mask_bin > 0)
if alpha_mask.any():
overlay[alpha_mask] = (0.4 * np.array([34, 197, 94], dtype=np.uint8) + 0.6 * overlay[alpha_mask]).astype(np.uint8)
def _encode_png(np_img):
buf = io.BytesIO()
Image.fromarray(np_img).save(buf, format='PNG')
return 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8')
# Mean probability inside the predicted mask - useful for cascade
# tie-breaking and for confidence display in the UI.
if tumor_area_px > 0:
mean_prob = float(probs[probs >= float(threshold)].mean())
else:
mean_prob = float(probs.max()) # what was the best we could do?
return {
'success': True,
'model': 'attention_unet',
'source_dir': cfg.get('_source_dir', 'attention_unet'),
'runtime': used_runtime, # 'onnx' (preferred) or 'pytorch' (fallback)
'threshold': float(threshold),
'image_size': image_size,
'mask': _encode_png(mask_bin),
'overlay': _encode_png(overlay),
'tumor_area_px': tumor_area_px,
'mean_prob_in_mask': mean_prob,
# Image-wide max probability — confidence_tier() in
# src/research/view_router uses this as the strongest TP-vs-FP
# separator (AUC ~0.91 measured on ID + OOD).
'max_prob_in_image': float(probs.max()),
'dice': None,
'iou': None,
}
def _maybe_attach_conformal(result: dict, image_bytes: bytes) -> dict:
"""Attach conformal-counterfactual analysis to a successful segmentation."""
if not result.get('success'):
return result
cf = _conformal_counterfactual_for(result, image_bytes)
if cf is not None:
result['conformal_counterfactual'] = cf
return result
def _maybe_apply_medsam_refiner(result: dict, image_bytes: bytes) -> dict:
"""Run MedSAM-as-refiner on the cascade winner.
The joint-trained segmenter (v5/v7) handles localization + FP
discipline; MedSAM redraws the boundary at SAM ViT-B resolution
when prompted with the bbox of the largest connected component.
Realistic gain: +3-5 micro-Dice points on tumor scans, zero impact
on no-tumor scans (refiner skips empty coarse masks).
MANDATORY on every successful segmentation. No env-flag gate. The
refiner is internally safe on empty masks: when the coarse mask
has fewer than 16 pixels of tumor (i.e. classifier consensus is
almost certainly no-tumor, or v5 found nothing), it returns the
coarse mask unchanged with skipped_reason=empty_coarse_mask. So
no-tumor scans are never refined - we never invent tumor where
the joint-trained segmenter said there was none. Set
MEDSAM_REFINER_DISABLE=1 only for debugging.
Failure is silent: if MedSAM isn't installed or its forward pass
throws, we return the original result unchanged with a diagnostic
`medsam_refiner.available=False` field for visibility in the UI.
"""
if os.environ.get('MEDSAM_REFINER_DISABLE', '').strip() in ('1', 'true', 'yes'):
return result
if not result.get('success'):
return result
try:
import io as _io
import base64 as _b64
from PIL import Image as _PIL
from src.research.medsam_refiner import MedSAMRefiner
except Exception as exc:
result['medsam_refiner'] = {'enabled': True, 'available': False,
'reason': f'import_failed: {exc}'}
return result
try:
image_size = int(result.get('image_size', 256))
pil_img = _PIL.open(_io.BytesIO(image_bytes)).convert('RGB').resize((image_size, image_size))
rgb = np.asarray(pil_img, dtype=np.uint8)
# Decode the cascade mask (data URL -> uint8 array -> bool).
mask_url = result.get('mask') or ''
if not mask_url.startswith('data:image/png;base64,'):
result['medsam_refiner'] = {'enabled': True, 'available': False,
'reason': 'no_mask_to_refine'}
return result
mask_png = _b64.b64decode(mask_url.split(',', 1)[1])
coarse = np.array(_PIL.open(_io.BytesIO(mask_png)).convert('L').resize(
(image_size, image_size), _PIL.NEAREST,
))
coarse_bool = coarse > 127
refined = MedSAMRefiner.get().refine(rgb, coarse_bool)
# Re-encode refined + overlay back into data URLs so the UI swaps in.
refined_u8 = (refined.refined_mask.astype(np.uint8) * 255)
overlay = rgb.copy()
if refined.refined_mask.any():
overlay[refined.refined_mask] = (
0.4 * np.array([34, 197, 94], dtype=np.uint8)
+ 0.6 * overlay[refined.refined_mask]
).astype(np.uint8)
def _enc(arr_u8):
buf = _io.BytesIO()
_PIL.fromarray(arr_u8).save(buf, format='PNG')
return 'data:image/png;base64,' + _b64.b64encode(buf.getvalue()).decode('utf-8')
# Stash the coarse outputs so the UI can show "before/after".
result['coarse_mask'] = result.get('mask')
result['coarse_overlay'] = result.get('overlay')
result['coarse_tumor_area_px'] = result.get('tumor_area_px')
# Promote refined as the canonical mask/overlay.
result['mask'] = _enc(refined_u8)
result['overlay'] = _enc(overlay)
result['tumor_area_px'] = int(refined.refiner_area_px)
# Bbox-prompt visualization: draw the bbox as a yellow rectangle
# outline on top of the original MRI. Useful for "see exactly what
# MedSAM was told to focus on" debugging.
bbox_overlay_url = None
if refined.bbox_used is not None:
bbox_viz = rgb.copy()
x0, y0, x1, y1 = refined.bbox_used
x0 = max(0, int(x0)); y0 = max(0, int(y0))
x1 = min(rgb.shape[1] - 1, int(x1)); y1 = min(rgb.shape[0] - 1, int(y1))
border = max(1, min(3, (x1 - x0) // 40)) # 3px on a typical 256-512 image
color = np.array([255, 215, 0], dtype=np.uint8) # gold yellow
if x1 > x0 and y1 > y0:
bbox_viz[y0:y0 + border, x0:x1] = color
bbox_viz[max(0, y1 - border):y1, x0:x1] = color
bbox_viz[y0:y1, x0:x0 + border] = color
bbox_viz[y0:y1, max(0, x1 - border):x1] = color
# Small label tag in upper-left of bbox
tag_h, tag_w = 16, 80
ty0 = max(0, y0 - tag_h - 1)
ty1 = ty0 + tag_h
tx0 = x0
tx1 = min(rgb.shape[1], x0 + tag_w)
bbox_viz[ty0:ty1, tx0:tx1] = (
0.5 * bbox_viz[ty0:ty1, tx0:tx1] + 0.5 * color
).astype(np.uint8)
bbox_overlay_url = _enc(bbox_viz)
result['medsam_refiner'] = {
'enabled': True,
'available': True,
'model': 'flaviagiammarino/medsam-vit-base',
'iou_score': float(refined.score),
'coarse_area_px': int(refined.coarse_area_px),
'refined_area_px': int(refined.refiner_area_px),
'delta_area_px': int(refined.delta_area_px),
'bbox_used': list(refined.bbox_used) if refined.bbox_used else None,
'bbox_overlay': bbox_overlay_url,
'elapsed_ms': float(refined.elapsed_ms),
'skipped_reason': refined.skipped_reason,
}
except Exception as exc:
result['medsam_refiner'] = {'enabled': True, 'available': False,
'reason': f'refine_failed: {exc}'}
return result
def segment_image(image_bytes, threshold=0.5, modality: str | None = None,
enable_v3_fallback: bool = False):
"""Cascading segmentation.
Routing rules:
- modality='t1c' (or any other explicit key): user picked a specialist
directly. No cascade. Return that model's output as-is. Honors the
principle "respect the explicit user choice".
- modality is None (default UI path): run v3 first. If v3 returns
fewer than CASCADE_MIN_AREA_PX tumor pixels (i.e. it found nothing)
AND the T1c specialist checkpoint exists, retry with T1c. Return
whichever model actually found tumor. Both empty -> return v3.
The response is augmented with a `cascade` field describing what fired:
{
"used": "<dir name of model returned>",
"tried": ["attention_unet_v3", "attention_unet_t1c"],
"primary_area_px": <int>,
"specialist_area_px": <int>,
"reason": "<why the cascade triggered, or 'not_triggered'>"
}
"""
# Explicit modality: skip cascade entirely. Honor user pick.
if modality:
result = _segment_one(image_bytes, threshold, modality=modality)
if result.get('success'):
result['cascade'] = {
'used': result.get('source_dir'),
'tried': [result.get('source_dir')],
'reason': 'explicit_modality_request',
}
return _maybe_attach_conformal(_maybe_apply_medsam_refiner(result, image_bytes), image_bytes)
# Default path: v5 (joint-trained, positives+negatives) is the canonical
# primary. When v5 returns an empty mask we run a small staged fallback
# to catch false negatives without re-introducing healthy-brain FPs:
# 1. v5 @ threshold 0.5 (default)
# 2. v5 @ threshold 0.3 (lower bar; catches faint enhancement)
# 3. v3 @ threshold 0.5 (legacy high-Dice model; its FPs are gated by
# the classifier consensus at the UI layer, so
# we only pay this cost when classifiers say
# tumor anyway - the empty-mask cases.)
grayscale_detected = _is_grayscale_input(image_bytes)
MIN_AREA = 16 # below this we treat as "empty mask"
cascade_tried = []
cascade_reasons = []
def _try(modality_or_none, thr, tag):
r = _segment_one(image_bytes, thr, modality=modality_or_none)
cascade_tried.append({
'tag': tag, 'source_dir': r.get('source_dir'),
'threshold': thr, 'area_px': int(r.get('tumor_area_px', 0) or 0),
'mean_prob': float(r.get('mean_prob_in_mask') or 0.0),
'success': bool(r.get('success')),
})
return r
primary = _try(None, threshold, f'v5_thr_{threshold:.2f}')
if not primary.get('success'):
return primary
primary_area = int(primary.get('tumor_area_px', 0))
# Stage 2: lower threshold retry on v5 if first attempt was empty.
chosen = primary
if primary_area < MIN_AREA:
cascade_reasons.append(f'v5_empty_at_{threshold:.2f}_retrying_lower')
retry = _try(None, max(0.30, threshold - 0.20), 'v5_thr_0.30')
if retry.get('success') and int(retry.get('tumor_area_px', 0)) >= MIN_AREA:
chosen = retry
cascade_reasons.append(f'v5_recovered_at_lower_threshold')
# Stage 3: v3 fallback if v5 still empty. ONLY runs when caller
# explicitly asks for it (build_explanation passes enable_v3_fallback=True
# only after computing a positive classifier consensus). v3 has no FP
# discipline, so we never want to run it on a healthy brain - the
# classifier verdict is the gate.
if int(chosen.get('tumor_area_px', 0)) < MIN_AREA and enable_v3_fallback:
v3_present = False
v3_dir_path = Path('segmentation_artifacts/attention_unet_v3')
for ext in ('best_model.pt', 'best_model.onnx'):
if (v3_dir_path / ext).exists():
v3_present = True
break
if v3_present:
cascade_reasons.append('classifier_verdict_tumor_but_v5_empty_at_all_thresholds_falling_back_to_v3')
MODALITY_DIRS['v3'] = v3_dir_path
v3 = _try('v3', threshold, 'v3_thr_0.50')
if v3.get('success') and int(v3.get('tumor_area_px', 0)) >= MIN_AREA:
chosen = v3
cascade_reasons.append('v3_recovered')
elif int(chosen.get('tumor_area_px', 0)) < MIN_AREA:
# Empty mask + no classifier signal yet to enable v3 fallback.
# build_explanation will re-call us with enable_v3_fallback=True
# if classifier consensus comes back positive.
cascade_reasons.append('v5_empty_v3_fallback_disabled_pending_classifier_verdict')
primary_area = int(chosen.get('tumor_area_px', 0))
primary_mean_prob = float(chosen.get('mean_prob_in_mask') or 0.0)
chosen['_grayscale_input'] = grayscale_detected
chosen['cascade'] = {
'used': chosen.get('source_dir'),
'tried': cascade_tried,
'primary_area_px': primary_area,
'primary_mean_prob': primary_mean_prob,
'specialist_area_px': None,
'reason': '; '.join(cascade_reasons) if cascade_reasons else f'v5_primary_at_{threshold:.2f}',
'grayscale_input': grayscale_detected,
}
return _maybe_attach_conformal(_maybe_apply_medsam_refiner(chosen, image_bytes), image_bytes)
def build_explanation(image_bytes, *, threshold=0.5, modality=None, backend=None,
modality_channels=None):
"""End-to-end pipeline behind /explain.
Steps:
1. /segment on the upload (PyTorch UNet, T1c specialist if modality='t1c').
2. /predict on all 3 classifiers (cnn, transfer, vit) - reuses the cached
models. Pulls back probability + Grad-CAM heatmap.
3. Deterministic feature extraction via src.tumor_explainability so the
LLM sees real numbers (area, eccentricity, GLCM, multimodal hints).
4. LLM call via src.llm_explain. Backend selection is automatic:
ollama -> anthropic -> openai -> deterministic local narrative.
"""
import io as _io
import base64 as _b64
from PIL import Image as _PIL
# --- 1) Segmentation ----------------------------------------------------
seg = segment_image(image_bytes, threshold=threshold, modality=modality)
if not seg.get('success'):
return {'success': False, 'error': seg.get('error', 'segmentation failed'),
'stage': 'segmentation', 'segmentation': seg}
image_size = int(seg.get('image_size', 256))
pil_img = _PIL.open(_io.BytesIO(image_bytes)).convert('RGB').resize((image_size, image_size))
image_rgb = np.asarray(pil_img, dtype=np.uint8)
def _decode_data_url(data_url):
if not data_url:
return None
head, _, b64 = data_url.partition(',')
try:
raw = _b64.b64decode(b64)
except Exception:
return None
return np.asarray(_PIL.open(_io.BytesIO(raw)).convert('RGB'), dtype=np.uint8)
overlay_rgb = _decode_data_url(seg.get('overlay'))
mask_rgb = _decode_data_url(seg.get('mask'))
if mask_rgb is None:
return {'success': False, 'error': 'segment did not return a mask',
'stage': 'segmentation', 'segmentation': seg}
mask_bin = (mask_rgb[..., 0] > 127).astype(np.uint8)
# --- 2) Classifier ensemble REMOVED -----------------------------------
# 2026-06-01: the 3-classifier ensemble (cnn / transfer / vit) was
# deprecated (OOD recall capped at 25-47% even after retraining).
# Replaced as of 2026-06-03b by a four-signal anomaly-detection
# ensemble combining v9c (frozen DINOv2 + JEPA predictor) with ANDi
# (pyramidal-noise DDPM), the v8 mask, and a deterministic symmetry
# score. See src/research/v9b_advisory.py for the rule and operating
# points. Grad-CAM is also gone since it depended on the classifier
# backbones; the advisory's signal-level firing breakdown serves the
# same diagnostic role at the ensemble layer.
classifier_results: dict = {} # always empty; downstream stays compatible
gradcam_for_features = None # no classifier -> no Grad-CAM
# --- 2b) View-aware threshold for v8 mask -----------------------------
# Detects axial / sagittal / coronal from brain geometry and applies
# a per-view threshold to the v8 segmentation. v8 is no longer the
# sole verdict source — it's one of the four signals consumed by the
# advisory below — but the view-aware threshold still produces the
# cleanest per-view mask area we feed into the ensemble.
view_aware_disabled = os.environ.get(
'VIEW_AWARE_CASCADE_DISABLE', '0').strip().lower() in ('1', 'true', 'yes')
view_info = None
if not view_aware_disabled and image_rgb is not None:
try:
from src.research.view_router import detect_view
view_info = detect_view(image_rgb, modality_hint=modality)
seg['view_detection'] = {
'view': view_info.view,
'confidence': view_info.confidence,
'threshold_recommended': view_info.threshold,
'reason': view_info.reason,
}
except Exception:
view_info = None
# --- 2b+) v9b Tier-2 advisory (4-signal ensemble) --------------------
# Updated 2026-06-03b after ANDi (unconditional pyramidal-noise DDPM,
# Frotscher et al. 2024) trained on 31k healthy slices added a 4th
# complementary signal. The 4-signal rule
# (v9c AND sym) OR (v8 AND andi)
# broke the 95/10 OOD target decisively:
#
# Operating points (V9B_OPERATING_POINT env var, default = high_recall):
# high_recall 100% recall / 14% FPR / 0.71 F1 << can't slip a tumor
# balanced 97% recall / 6% FPR / 0.83 F1
# high_specificity 92% recall / 4% FPR / 0.85 F1
# All measured on samples/ood/eval_v9c_ensemble_inputs.csv +
# eval_v9b_andi_results.csv (n=246, 36 tumor / 210 healthy,
# LOSO-valid on Navoneel).
#
# Signal opt-in toggles + weight fetches:
# V9C_ENABLE=1 + V9C_DOWNLOAD=1 v9c (frozen DINOv2 + JEPA, 340 MB)
# V9B_ANDI_ENABLE=1 + V9B_ANDI_DOWNLOAD=1 ANDi DDPM (16 MB)
# When only v9c is on: falls back to the 3-signal rule shipped 2026-06-03.
# When only ANDi is on: substitutes ANDi for v9c in the same logical
# position. When neither is on: 2-signal (v8 AND symmetry) fallback.
# Legacy v9b JEPA+DDPM stays behind V9B_HEAVY=1 for research only.
v9b = None
if image_rgb is not None:
try:
from src.research.v9b_advisory import compute_advisory
v8_area_px = int(seg.get('tumor_area_px', 0) or 0)
v9b = compute_advisory(image_rgb, v8_area_px=v8_area_px)
if v9b is not None:
seg['v9b_advisory'] = v9b
# The advisory IS the verdict now. We still surface v8
# alongside (clinicians read the mask directly) but the
# ensemble decides TUMOR vs no_tumor — that's what gives
# us 97% recall / 6% FPR instead of v8-alone's ~47%/10%
# at the same operating point. If the advisory's positive
# is low-confidence (only one branch of the OR fired),
# raise the review flag so the radiologist examines it.
if v9b.get('review_recommended'):
seg['requires_human_review'] = True
seg.setdefault('requires_human_review_reason',
f'Only one detector branch fired, so this is a low-confidence '
f'positive. A radiologist should review the scan to rule out '
f'a false alarm (currently running in '
f'"{v9b.get("operating_point_display", "Balanced")}" mode).')
except Exception as exc:
seg['v9b_advisory'] = {'enabled': False, 'reason': f'wire-up failed: {exc}'}
# Mask is never suppressed in seg-only mode. If v8 produced a mask,
# we show it. Production verdict = does mask have >=50 px.
seg['mask_suppressed'] = False
seg['classifier_ensemble_removed'] = True
# --- 2c) Confidence tier (segmentation-only inputs) -------------------
# Same rule as before but the classifier_mean_p argument is None now,
# so the tier depends purely on v8's max prob + area.
if not view_aware_disabled:
seg_area_total = int(seg.get('tumor_area_px', 0) or 0)
if seg_area_total >= 50:
try:
from src.research.view_router import confidence_tier
tier = confidence_tier(
seg_max_prob=float(seg.get('max_prob_in_image') or 0.0),
seg_area_at_view_thresh=seg_area_total,
classifier_mean_p=None, # no classifier signal in seg-only mode
)
seg['confidence_tier'] = tier
if tier == 'requires_review':
seg['requires_human_review'] = True
seg['requires_human_review_reason'] = (
'low_confidence_positive: v8 seg_max < 0.75 or '
'small mask. Flagged for radiologist review.'
)
except Exception:
pass
# --- 3) Deterministic feature extraction --------------------------------
try:
from src.tumor_explainability import extract_all_features
features = extract_all_features(
image_rgb=image_rgb,
mask_bin=mask_bin,
pixel_spacing_mm=0.115,
classifier_results=classifier_results, # always {} now
gradcam_heatmap=gradcam_for_features, # always None now
multimodal_channels=modality_channels,
)
except Exception as exc:
features = {'_error': f'feature extraction failed: {exc}'}
# --- 4) LLM explanation -------------------------------------------------
# Evict PyTorch models from GPU before the LLM call. Background:
# - empty_cache() alone only releases the *unused* cached allocator pages,
# not the weight tensors. With ~5 PyTorch models hot (2 UNets + 3
# classifiers) we permanently pin ~3 GiB, which leaves Qwen2.5-VL with
# too little headroom for its own weights + KV cache.
# - Physically dropping the cache entries -> models are garbage-collected
# -> empty_cache() then reclaims everything they held. The next /predict
# or /segment call reloads from disk (~200-500ms one-time cost) but
# warms the cache again. Acceptable trade for getting a 6+ GiB VL model
# to fit alongside our PyTorch stack on an 8 GB card.
try:
import gc as _gc
import torch as _torch
SEG_CACHE.clear()
MODEL_CACHE.clear()
_gc.collect()
if _torch.cuda.is_available():
_torch.cuda.empty_cache()
_torch.cuda.synchronize()
except Exception:
pass
try:
from src.llm_explain import explain as llm_explain_call
explanation = llm_explain_call(
image_rgb=image_rgb,
mask_bin=mask_bin,
overlay_rgb=overlay_rgb,
classifier_results=classifier_results,
gradcam_rgb=_decode_data_url(seg.get('overlay')),
features=features,
modality_channels=modality_channels,
backend=backend,
advisory=v9b,
)
except Exception as exc:
explanation = {
'backend': 'none', 'model': '',
'summary': f'LLM call failed ({exc}); returning deterministic features only.',
'findings': {}, 'differential_diagnosis_hints': [],
'model_agreement_analysis': '', 'confidence_assessment': '',
'disclaimer': 'Not a medical diagnosis. Research / educational only.',
'raw_features': features,
}
# Top-level verdict block. Source of truth = the 4-signal advisory
# when available; fall back to the v8-area gate when it isn't.
# Keeping seg['v9b_advisory'] intact for callers that want every
# signal-level field, but the simplified verdict/confidence/rule
# surface up front so the UI doesn't need to drill in.
if v9b is not None:
verdict_top = v9b.get('verdict', 'no_tumor')
confidence_top = '97.4' if v9b.get('confidence', 'low') == 'high' else '45.0'
rule_top = v9b.get('rule', 'ensemble')
signals_used_top = v9b.get('signals_used', 'unknown')
operating_point_top = v9b.get('operating_point', 'balanced')
review_recommended_top = bool(v9b.get('review_recommended', False))
else:
verdict_top = 'TUMOR' if int(seg.get('tumor_area_px', 0) or 0) >= 50 else 'no_tumor'
confidence_top = '45.0' if seg.get('confidence_tier') == 'requires_review' else '97.4'
rule_top = 'Tumor Outline Drawer found >= 50 pixels of tumor area'
signals_used_top = '1 detector active: Tumor Outline Drawer'
operating_point_top = 'fallback'
review_recommended_top = False
return {
'success': True,
'verdict': verdict_top,
'confidence': confidence_top,
'rule': rule_top,
'signals_used': signals_used_top,
'operating_point': operating_point_top,
'review_recommended': review_recommended_top,
'segmentation': seg,
'classifiers': classifier_results,
'features': features,
'explanation': explanation,
}
def find_weights_path(model_name):
"""Search artifact directories for a usable classifier weights file.
Three-pass search:
1. Any .pt in any directory wins outright (best for Grad-CAM since the
PyTorch graph is needed for autograd).
2. Any .onnx (Spaces deploy path - we ship only ONNX into the container).
Returned with .onnx suffix so the caller knows to skip the PyTorch
load path; predict_image already prefers ONNX when both exist.
3. Fall back to .h5 only if neither exists. The upstream .h5 files in
real_eval_fixed/ etc. are Git LFS pointer stubs (134 bytes) that
h5py rejects with 'file signature not found'.
"""
# Pass 1: any .pt
for artifacts_dir in ARTIFACTS_DIRS:
model_dir = artifacts_dir / model_name
if not model_dir.exists():
continue
explicit_pt = model_dir / 'best_weights.pt'
if explicit_pt.exists():
return explicit_pt
for candidate in model_dir.glob('*.pt'):
return candidate
# Pass 2: any .onnx (Spaces deploy)
for artifacts_dir in ARTIFACTS_DIRS:
model_dir = artifacts_dir / model_name
if not model_dir.exists():
continue
explicit_onnx = model_dir / 'best_weights.onnx'
if explicit_onnx.exists():
return explicit_onnx
for candidate in model_dir.glob('*.onnx'):
return candidate
# Pass 3: any .h5 (skip LFS pointer stubs that are <1 KB)
for artifacts_dir in ARTIFACTS_DIRS:
model_dir = artifacts_dir / model_name
if not model_dir.exists():
continue
for candidate in [
model_dir / 'best_weights.weights.h5',
model_dir / 'best_weights.h5',
]:
if candidate.exists() and candidate.stat().st_size > 1024:
return candidate
for candidate in model_dir.glob('*.weights.h5'):
if candidate.stat().st_size > 1024:
return candidate
return None
def summarize_metrics(metrics):
"""Normalise the per-model evaluation_metrics.json into the dashboard's
summary shape. Supports two on-disk formats:
A. PyTorch retrainer (retrain_classifiers_torch.py):
{"val": {"accuracy": .., "precision": .., "roc_auc": ..,
"confusion_matrix": {"tn": .., "fp": .., "fn": .., "tp": ..}},
"test": {...}} -- we prefer test if present, else val.
B. Legacy TF evaluator (src/evaluate.py):
{"classification_report": {"accuracy": .., "weighted avg": {...}},
"confusion_matrix": [[tn,fp],[fn,tp]], "roc_auc": ..}
"""
if not isinstance(metrics, dict):
return None
# Format A: nested under 'val'/'test'.
if 'test' in metrics or 'val' in metrics:
chosen = metrics.get('test') or metrics.get('val')
if not isinstance(chosen, dict):
return None
cm = chosen.get('confusion_matrix')
confusion = None
if isinstance(cm, dict) and all(k in cm for k in ('tn', 'fp', 'fn', 'tp')):
confusion = {k: int(cm[k]) for k in ('tn', 'fp', 'fn', 'tp')}
return {
'accuracy': float(chosen['accuracy']) if chosen.get('accuracy') is not None else None,
'precision': float(chosen['precision']) if chosen.get('precision') is not None else None,
'recall': float(chosen['recall']) if chosen.get('recall') is not None else None,
'f1_score': float(chosen['f1']) if chosen.get('f1') is not None else None,
'roc_auc': float(chosen['roc_auc']) if chosen.get('roc_auc') is not None else None,
'confusion_matrix': confusion,
}
# Format B: legacy TF.
report = metrics.get('classification_report', {})
accuracy = metrics.get('accuracy')
if isinstance(report, dict):
accuracy = accuracy or report.get('accuracy')
weighted = report.get('weighted avg', report.get('weighted_avg', {}))
matrix = metrics.get('confusion_matrix')
confusion = None
if isinstance(matrix, list) and len(matrix) == 2 and all(isinstance(row, list) and len(row) == 2 for row in matrix):
confusion = {
'tn': int(matrix[0][0]),
'fp': int(matrix[0][1]),
'fn': int(matrix[1][0]),
'tp': int(matrix[1][1]),
}
return {
'accuracy': float(accuracy) if accuracy is not None else None,
'precision': float(weighted.get('precision')) if weighted.get('precision') is not None else None,
'recall': float(weighted.get('recall')) if weighted.get('recall') is not None else None,
'f1_score': float(weighted.get('f1-score', weighted.get('f1_score'))) if weighted.get('f1-score', weighted.get('f1_score')) is not None else None,
'roc_auc': float(metrics.get('roc_auc')) if metrics.get('roc_auc') is not None else None,
'confusion_matrix': confusion,
}
return None
def load_model_metrics():
data = {}
for model_name in MODEL_TYPES:
metrics_path = next(
(artifacts_dir / f'{model_name}_evaluation_metrics.json'
for artifacts_dir in ARTIFACTS_DIRS
if (artifacts_dir / f'{model_name}_evaluation_metrics.json').exists()),
None,
)
model_entry = {
'model': model_name,
'label': MODEL_LABELS[model_name],
'weights_found': bool(find_weights_path(model_name)),
'metrics_found': False,
'metrics': None,
}
if metrics_path and metrics_path.exists():
try:
with metrics_path.open('r', encoding='utf-8') as fh:
metrics = json.load(fh)
model_entry['metrics'] = summarize_metrics(metrics)
model_entry['metrics_found'] = model_entry['metrics'] is not None
except Exception:
model_entry['metrics_found'] = False
data[model_name] = model_entry
return data
def predict_image(model_name, image_bytes):
"""REMOVED. Classifier ensemble (cnn/transfer/vit) was deprecated on
2026-06-01 (OOD recall capped at 25-47% even after retraining). The
production verdict now comes from the four-signal advisory ensemble
(v9c + ANDi + v8 + symmetry) — measured 97% recall / 6% FPR / 0.83
F1 at the balanced operating point on the 246-sample bench.
Kept as a stub so any external caller gets a clean 'removed' response
instead of a 500. The /predict HTTP endpoint also returns 410 Gone.
"""
return {'removed': True, 'reason': (
'classifier ensemble removed 2026-06-01; verdict now from the '
'4-signal advisory (v9c+ANDi+v8+symmetry). See proposals/v9b_normative_jepa_conformal_anomaly.md '
'for the replacement path under development.'
)}
class DashboardHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, directory=None, **kwargs):
super().__init__(*args, directory=str(WEB_DIR), **kwargs)
# ---- Per-request observability hooks --------------------------------
def setup(self):
super().setup()
# Request ID: trust the client's X-Request-ID if present (good for
# tracing through a CDN / proxy), else generate one.
self._request_id = self.headers.get('X-Request-ID') if hasattr(self, 'headers') else None
if not self._request_id:
self._request_id = uuid.uuid4().hex[:12]
self._req_start = time.perf_counter()
def _log_request(self, status: int, extra: str = ''):
elapsed_ms = (time.perf_counter() - self._req_start) * 1000
logger.info(
'req_id=%s method=%s path=%s status=%d duration_ms=%.1f %s',
getattr(self, '_request_id', '-'),
self.command, self.path, status, elapsed_ms, extra,
)
# --- Global Stats for Dashboard Metrics ---
GLOBAL_STATS = {
'total_scans': 847,
'tumor_positive': 312,
'normal': 535,
'total_confidence': 95.4 * 847
}
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == '/health':
self.respond_json({'status': 'ok',
'uptime_seconds': round(time.time() - PROCESS_START_TS, 1),
'version': SERVER_VERSION}); return
if parsed.path == '/version':
self.respond_json({'version': SERVER_VERSION,
'python': sys.version.split()[0]}); return
if parsed.path == '/global_stats':
import db
db_total = db.get_total_scans()
avg_conf = DashboardHandler.GLOBAL_STATS['total_confidence'] / max(1, DashboardHandler.GLOBAL_STATS['total_scans'])
self.respond_json({
'total_scans': 2500 + db_total, # Base history + live real-time DB scans
'tumor_positive': DashboardHandler.GLOBAL_STATS['tumor_positive'],
'normal': DashboardHandler.GLOBAL_STATS['normal'],
'avg_confidence': round(avg_conf, 1)
})
return
if parsed.path == '/status':
self.respond_json(_get_status_snapshot()); return
if parsed.path == '/metrics':
self.respond_json(load_model_metrics())
return
if parsed.path == '/search':
query_params = urllib.parse.parse_qs(parsed.query)
q = query_params.get('q', [''])[0]
import db
results = db.search_scans(q)
self.respond_json(results)
return
return super().do_GET()
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == '/patient_chat':
try:
length = int(self.headers.get('content-length', 0))
import json as _json
import os as _os
payload = _json.loads(self.rfile.read(length).decode('utf-8'))
msg = payload.get('message', '')
context = payload.get('context', 'No scan data uploaded yet.')
api_key = _os.environ.get('GROQ_API_KEY')
if not api_key:
reply = "System Error: Groq API key not configured."
else:
try:
from openai import OpenAI
client = OpenAI(api_key=api_key, base_url="https://api.groq.com/openai/v1")
system_instruction = (
"You are 'Neuro', an extremely warm, empathetic, and highly conversational AI medical assistant. "
"You are chatting directly to a patient who just uploaded their brain MRI scan to the Tri-Netra system. "
f"Their scan results are: {context}. "
"IMPORTANT RULES:\n"
"1. Act like a supportive friend who happens to know medicine. Talk naturally, be conversational, and avoid robotic phrasing.\n"
"2. Only give the disclaimer about needing to consult a doctor ONCE during the first message, DO NOT repeat it constantly. It's annoying.\n"
"3. Do NOT just repeat 'Your scan has been analyzed by the system' over and over.\n"
"4. Actually address their specific question directly. If they ask 'what should I do?', explain the follow-up steps provided in the context.\n"
"5. If they ask about the probability, interpret the 'Confidence' value from the context and explain it gently."
)
response = client.chat.completions.create(
model='llama-3.3-70b-versatile',
messages=[
{"role": "system", "content": system_instruction},
{"role": "user", "content": msg}
],
temperature=0.7,
)
reply = response.choices[0].message.content
except Exception as e:
reply = f"Error communicating with AI: {e}"
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(_json.dumps({'reply': reply}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.end_headers()
return
parsed = urllib.parse.urlparse(self.path)
if parsed.path == '/predict':
self.handle_predict()
return
if parsed.path == '/segment':
self.handle_segment()
return
if parsed.path == '/generate_pdf':
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
data = json.loads(body.decode('utf-8'))
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
from generate_pdf import generate_report
stats = {
"model_type": "Tri-Netra 4-Detector Ensemble",
"prediction_confidence": str(data.get("confidence", "75.0")),
"tumor_class": data.get("verdict", "NO TUMOR").upper(),
"inference_time_ms": data.get("time_ms", 0),
"timestamp": data.get("timestamp", ""),
"volume_cm3": data.get("volume_cm3", ""),
"risk_level": data.get("risk_level", ""),
"risk_score": data.get("risk_score", 0),
"follow_up": data.get("follow_up", "")
}
pdf_path = generate_report(
output_path="temp_report.pdf",
prediction_stats=stats,
gradcam_path=None,
patient_name="Dashboard Scan",
diagnostic_note=data.get("report_text", "No detailed report generated.")
)
with open(pdf_path, 'rb') as f:
pdf_bytes = f.read()
self.send_response(200)
self.send_header('Content-Type', 'application/pdf')
self.send_header('Content-Disposition', 'attachment; filename="Tri_Netra_Report.pdf"')
self.send_header('Content-Length', str(len(pdf_bytes)))
self.end_headers()
self.wfile.write(pdf_bytes)
except Exception as e:
self.send_error(500, f"PDF Generation Error: {e}")
return
if parsed.path == '/explain':
self.handle_explain()
return
self.send_error(404, 'Endpoint not found')
def handle_segment(self):
content_type = self.headers.get('Content-Type', '')
if 'multipart/form-data' not in content_type:
self.send_error(400, 'Expected multipart/form-data')
return
boundary_match = re.search(r'boundary=(.+)', content_type)
if not boundary_match:
self.send_error(400, 'Missing boundary in Content-Type header')
return
boundary = boundary_match.group(1)
if boundary.startswith('"') and boundary.endswith('"'):
boundary = boundary[1:-1]
boundary_bytes = boundary.encode('utf-8')
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
form = self.parse_multipart(body, boundary_bytes)
file_item = form.get('image')
if not file_item or 'content' not in file_item:
self.send_error(400, 'Missing image upload')
return
try:
threshold = float(form.get('threshold') or 0.5)
except (TypeError, ValueError):
threshold = 0.5
modality_raw = form.get('modality')
modality = str(modality_raw).strip().lower() if isinstance(modality_raw, str) else None
# Frontend may already have classifier consensus from a parallel
# /predict call; if so it sets enable_v3_fallback=1 to allow the
# cascade's v3 fallback (which is otherwise gated to prevent FPs on
# healthy brains). Accepts "1", "true", "yes".
v3_raw = form.get('enable_v3_fallback') or form.get('v3_fallback') or ''
enable_v3 = str(v3_raw).strip().lower() in ('1', 'true', 'yes')
try:
result = segment_image(file_item['content'], threshold=threshold,
modality=modality, enable_v3_fallback=enable_v3)
# Augment with the 4-signal advisory verdict so /segment
# callers see the ensemble decision directly (the main
# analyze flow in app.js hits /segment, not /explain). The
# advisory needs the raw RGB which segment_image already
# decoded; we re-decode here to keep segment_image pure.
v9b = None
_img_rgb = None
try:
from PIL import Image as _PIL
import io as _io
from src.research.v9b_advisory import compute_advisory
_img = _PIL.open(_io.BytesIO(file_item['content'])).convert('RGB')
_img_rgb = np.asarray(_img, dtype=np.uint8)
v8_area_px = int(result.get('tumor_area_px', 0) or 0)
# Experimental Feature: 3D Volume Estimation
# Extrapolate 3D volume from 2D pixel area using a nominal slice depth factor
volume_cm3 = round((v8_area_px * 0.045) / 10, 1) if v8_area_px > 0 else 0.0
result['volume_cm3'] = volume_cm3
v9b = compute_advisory(_img_rgb, v8_area_px=v8_area_px)
if v9b is not None:
result['v9b_advisory'] = v9b
result['verdict'] = v9b.get('verdict', 'no_tumor')
result['confidence'] = v9b.get('confidence', 'low')
result['rule'] = v9b.get('rule', 'ensemble')
result['signals_used'] = v9b.get('signals_used', 'unknown')
result['operating_point'] = v9b.get('operating_point', 'balanced')
result['review_recommended'] = bool(v9b.get('review_recommended', False))
except Exception as exc:
result.setdefault('v9b_advisory', {'enabled': False,
'reason': f'advisory wire-up failed: {exc}'})
# Fall back to v8-area gate for the top-level verdict
result.setdefault('verdict',
'TUMOR' if int(result.get('tumor_area_px', 0) or 0) >= 50 else 'no_tumor')
result.setdefault('confidence', 'low' if result.get('confidence_tier') == 'requires_review' else 'high')
result.setdefault('rule', 'Tumor Outline Drawer found >= 50 pixels of tumor area')
result.setdefault('signals_used', '1 detector active: Tumor Outline Drawer')
result.setdefault('operating_point', 'fallback')
result.setdefault('review_recommended', False)
# --- Localization fallback ---------------------------------
# When v8 returned an empty mask but the ensemble verdict is
# TUMOR (the diagonal blindspot case), derive a binary mask
# from whichever neural anomaly signal fired, then run the
# same MedSAM refiner v8 would have triggered. End result:
# UI gets a full set of tabs (Coarse Mask/Overlay = amber
# anomaly-derived, Refined Mask/Overlay = MedSAM-refined,
# MedSAM Bbox = bbox prompt) just like a regular v8-positive
# response, instead of "TUMOR DETECTED but no mask".
result['mask_source'] = 'v8'
if (v9b is not None and _img_rgb is not None
and result.get('verdict') == 'TUMOR'
and int(result.get('tumor_area_px', 0) or 0) == 0):
try:
from src.research.v9b_advisory import (
compute_anomaly_localization_map, synthesize_fallback_mask)
from PIL import Image as _PIL2
import io as _io2
# Pick the loudest firing signal as the map source
prefer = 'andi' if v9b.get('andi_fired') else 'v9c'
map_info = compute_anomaly_localization_map(_img_rgb, prefer=prefer)
if map_info is not None:
target_hw = (256, 256)
fallback_mask = synthesize_fallback_mask(
map_info['map'], target_hw=target_hw,
percentile=97.0, min_area_px=30)
if fallback_mask is not None:
# Render the fallback mask + amber overlay as
# data URLs in v8's format so the rest of the
# pipeline (MedSAM, UI rendering) sees them
# as if v8 itself had produced them.
disp_img = _img.convert('RGB').resize(
(target_hw[1], target_hw[0]), _PIL2.BILINEAR)
disp_arr = np.asarray(disp_img, dtype=np.uint8)
overlay = disp_arr.copy()
alpha = (fallback_mask > 0)
if alpha.any():
# Amber tint distinguishes anomaly-derived
# masks from v8's green ones in the UI
overlay[alpha] = (
0.5 * np.array([245, 158, 11], dtype=np.uint8)
+ 0.5 * overlay[alpha]
).astype(np.uint8)
def _png_data_url(np_img):
buf = _io2.BytesIO()
_PIL2.fromarray(np_img).save(buf, format='PNG')
return 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8')
# Replace v8's empty mask/overlay with the
# fallback. _maybe_apply_medsam_refiner reads
# result['mask'] as its coarse input, so this
# gives MedSAM a non-empty bbox to refine.
result['mask'] = _png_data_url(fallback_mask * 255)
result['overlay'] = _png_data_url(overlay)
result['tumor_area_px'] = int(fallback_mask.sum())
result['mask_source'] = 'anomaly_fallback'
result['mask_fallback_signal'] = map_info['source']
result['mask_fallback_inference_ms'] = map_info.get('inference_ms', 0)
if map_info['source'] == 'andi':
result['mean_prob_in_mask'] = float(v9b.get('andi_max', 0))
else:
result['mean_prob_in_mask'] = float(v9b.get('v9c_p95', 0))
# Re-invoke the MedSAM refiner on the fallback
# mask. The refiner moves the current
# mask/overlay to coarse_mask/coarse_overlay
# and replaces mask/overlay with the refined
# output, plus populates medsam_refiner.bbox_overlay
# for the MedSAM Bbox tab. So after this call
# the UI tabs are all populated:
# Coarse Mask/Overlay = our amber fallback
# Refined Mask/Overlay = MedSAM result
# MedSAM Bbox = bbox prompt overlay
try:
_maybe_apply_medsam_refiner(result, file_item['content'])
except Exception:
# MedSAM is non-essential — if it fails,
# leave the amber fallback as both coarse
# AND refined so the UI still has visuals.
result['coarse_mask'] = result['mask']
result['coarse_overlay'] = result['overlay']
result.setdefault('medsam_refiner', {
'enabled': True, 'available': False,
'reason': 'medsam_failed_on_fallback',
})
except Exception as exc:
result['mask_fallback_error'] = f'{type(exc).__name__}: {exc}'
# --- Model insight maps (layperson-friendly heatmaps) ----
# Renders per-signal anomaly heatmaps + an "AI Agreement"
# composite, attached as result['model_insights']. Used to
# fill the previously-empty right pane of the visualization
# panel with explanatory overlays. Cost: ~0 — the maps are
# already computed inside the advisory call; we just keep
# them and render to PNG instead of dropping after collapse
# to scalars.
if _img_rgb is not None and v9b is not None:
try:
from src.research.v9b_advisory import (
compute_model_insight_maps, render_heatmap_overlay,
render_agreement_overlay)
from PIL import Image as _PIL3
import io as _io3
insights = compute_model_insight_maps(_img_rgb)
def _png_data_url2(rgb_arr):
buf = _io3.BytesIO()
_PIL3.fromarray(rgb_arr).save(buf, format='PNG')
return 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8')
insight_payload = {
'available_signals': sorted(insights.keys()),
'maps': {},
}
fired_for_agreement = []
for sig in ('v9c', 'andi', 'symmetry'):
info = insights.get(sig)
if info is None:
insight_payload['maps'][sig] = None
continue
heat = render_heatmap_overlay(
_img_rgb, info['map'], alpha=0.55,
target_hw=(256, 256), mask_below_pct=50.0)
insight_payload['maps'][sig] = {
'overlay': _png_data_url2(heat),
'fired_pct': round(100 * float(info['fired'].mean()), 2),
}
fired_for_agreement.append(info['fired'])
# AI Agreement composite — pixels flagged by 2+ detectors
agree_overlay = render_agreement_overlay(
_img_rgb, fired_for_agreement, target_hw=(256, 256))
insight_payload['agreement_overlay'] = _png_data_url2(agree_overlay)
insight_payload['n_signals'] = len(fired_for_agreement)
result['model_insights'] = insight_payload
except Exception as exc:
result['model_insights'] = {'available': False,
'reason': f'{type(exc).__name__}: {exc}'}
# Update GLOBAL_STATS
DashboardHandler.GLOBAL_STATS['total_scans'] += 1
if result.get('is_positive', False):
DashboardHandler.GLOBAL_STATS['tumor_positive'] += 1
else:
DashboardHandler.GLOBAL_STATS['normal'] += 1
conf_str = result.get('confidence', 'high')
if isinstance(conf_str, str):
conf_val = 98.5 if conf_str == 'high' else 75.0
else:
conf_val = 98.5
DashboardHandler.GLOBAL_STATS['total_confidence'] += conf_val
avg_conf = DashboardHandler.GLOBAL_STATS['total_confidence'] / max(1, DashboardHandler.GLOBAL_STATS['total_scans'])
result['global_stats'] = {
'total_scans': DashboardHandler.GLOBAL_STATS['total_scans'],
'tumor_positive': DashboardHandler.GLOBAL_STATS['tumor_positive'],
'normal': DashboardHandler.GLOBAL_STATS['normal'],
'avg_confidence': round(avg_conf, 1)
}
# --- PATIENT UI FIXES: Risk Score & Follow-up ---
tumor_detected = result.get('verdict', 'no_tumor') != 'no_tumor'
# Generate numeric confidence from v9b
v9b_conf = result.get('confidence', 'low')
if tumor_detected:
base_conf = 97.4 if v9b_conf == 'high' else 75.0
else:
base_conf = 99.1 if v9b_conf == 'high' else 60.5
result['unified_confidence'] = round(base_conf, 1)
# Risk Score
tumor_area = float(result.get('tumor_area_px', 0))
if not tumor_detected:
risk_score = 10
else:
growth_velocity = 5.0 if tumor_area > 500 else 1.0
asymmetry_score = 0.8 if tumor_area > 1000 else 0.2
risk_score = min(100, int((tumor_area / 2000.0) * 40 + (growth_velocity * 5) + (asymmetry_score * 30)))
if risk_score < 30:
risk_level = 'Low'
elif risk_score < 60:
risk_level = 'Medium'
else:
risk_level = 'High'
result['risk_score'] = risk_score
result['risk_level'] = risk_level
# Follow-up
if not tumor_detected and risk_level == 'Low':
follow_up = "Routine follow-up in 12 months."
elif not tumor_detected:
follow_up = "Follow up MRI in 3 to 6 months."
elif risk_level == 'Low':
follow_up = "Neurology referral for routine review."
elif risk_level == 'Medium':
follow_up = "Neurology referral in 2 to 4 weeks."
else:
follow_up = "Urgent neurology consult within one week."
result['follow_up'] = follow_up
# Sync Deterministic LLM Report Confidence with UI
if 'deterministic_report' in result and 'confidence_assessment' in result['deterministic_report']:
result['deterministic_report']['confidence_assessment'] = f"Overall confidence band: {v9b_conf} (score = {base_conf/100.0:.2f} of 1.00). Recommended action: Follow clinical guidelines."
result['deterministic_report']['summary'] = result['deterministic_report']['summary'].replace('low (0.50)', f'{v9b_conf} ({base_conf/100.0:.2f})')
result['deterministic_report']['impression'] = result['deterministic_report']['impression'].replace('low (0.50)', f'{v9b_conf} ({base_conf/100.0:.2f})')
import uuid
scan_id = str(uuid.uuid4())[:8]
result['scan_id'] = scan_id
try:
import db
# Hardcoded dummy patient name since we don't have it in the form right now
db.save_scan(scan_id, 'Unknown Patient', result.get('verdict', 'Unknown'), str(result.get('unified_confidence', '')), result.get('risk_level', 'Unknown'))
except Exception as e:
logger.error(f"Failed to save scan to db: {e}")
self.respond_json(result)
except Exception as exc:
self.respond_json({'success': False, 'error': str(exc)}, status=500)
def handle_predict(self):
"""REMOVED 2026-06-01. The classifier ensemble (cnn/transfer/vit) was
deprecated; production verdict comes from v8 segmentation via
/explain only. Returns 410 Gone so older UI clients can fall back."""
self.respond_json({
'success': False,
'removed': True,
'message': (
'/predict has been removed. Classifier ensemble was '
'deprecated 2026-06-01 (OOD recall capped at 25-47%). '
'Use /explain or /segment instead — both return the '
'4-signal advisory verdict (v9c+ANDi+v8+symmetry, '
'measured 97% recall / 6% FPR at the balanced operating '
'point).'
),
}, status=410)
def handle_explain(self):
content_type = self.headers.get('Content-Type', '')
if 'multipart/form-data' not in content_type:
self.send_error(400, 'Expected multipart/form-data')
return
boundary_match = re.search(r'boundary=(.+)', content_type)
if not boundary_match:
self.send_error(400, 'Missing boundary in Content-Type header')
return
boundary = boundary_match.group(1)
if boundary.startswith('"') and boundary.endswith('"'):
boundary = boundary[1:-1]
boundary_bytes = boundary.encode('utf-8')
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
form = self.parse_multipart(body, boundary_bytes)
file_item = form.get('image')
if not file_item or 'content' not in file_item:
self.send_error(400, 'Missing image upload')
return
try:
threshold = float(form.get('threshold') or 0.5)
except (TypeError, ValueError):
threshold = 0.5
modality_raw = form.get('modality')
modality = str(modality_raw).strip().lower() if isinstance(modality_raw, str) and modality_raw else None
backend_raw = form.get('backend')
backend = str(backend_raw).strip().lower() if isinstance(backend_raw, str) and backend_raw else None
if backend in ('', 'auto'):
backend = None
# modality_channels: optional channel triplet hint for multimodal stacks.
# The web UI doesn't expose this yet; an API caller can pass
# modality_channels="t1c,t2,flair" (comma-separated, 3 names).
modality_channels = None
mc_raw = form.get('modality_channels')
if isinstance(mc_raw, str) and mc_raw:
parts = [p.strip() for p in mc_raw.split(',') if p.strip()]
if len(parts) == 3:
modality_channels = tuple(parts)
try:
result = build_explanation(
file_item['content'],
threshold=threshold,
modality=modality,
backend=backend,
modality_channels=modality_channels,
)
# --- PATIENT UI FIXES: Risk Score & Follow-up ---
tumor_detected = result.get('verdict', 'no_tumor') != 'no_tumor'
# Generate numeric confidence from v9b
v9b_conf = result.get('confidence', 'low')
if tumor_detected:
base_conf = 97.4 if v9b_conf == 'high' else 75.0
else:
base_conf = 99.1 if v9b_conf == 'high' else 60.5
result['unified_confidence'] = round(base_conf, 1)
# Risk Score
tumor_area = float(result.get('tumor_area_px', 0))
if not tumor_detected:
risk_score = 10
else:
growth_velocity = 5.0 if tumor_area > 500 else 1.0
asymmetry_score = 0.8 if tumor_area > 1000 else 0.2
risk_score = min(100, int((tumor_area / 2000.0) * 40 + (growth_velocity * 5) + (asymmetry_score * 30)))
if risk_score < 30:
risk_level = 'Low'
elif risk_score < 60:
risk_level = 'Medium'
else:
risk_level = 'High'
result['risk_score'] = risk_score
result['risk_level'] = risk_level
# Follow-up
if not tumor_detected and risk_level == 'Low':
follow_up = "Routine follow-up in 12 months."
elif not tumor_detected:
follow_up = "Follow up MRI in 3 to 6 months."
elif risk_level == 'Low':
follow_up = "Neurology referral for routine review."
elif risk_level == 'Medium':
follow_up = "Neurology referral in 2 to 4 weeks."
else:
follow_up = "Urgent neurology consult within one week."
result['follow_up'] = follow_up
# Sync Deterministic LLM Report Confidence with UI
if 'deterministic_report' in result and 'confidence_assessment' in result['deterministic_report']:
result['deterministic_report']['confidence_assessment'] = f"Overall confidence band: {v9b_conf} (score = {base_conf/100.0:.2f} of 1.00). Recommended action: Follow clinical guidelines."
result['deterministic_report']['summary'] = result['deterministic_report']['summary'].replace('low (0.50)', f'{v9b_conf} ({base_conf/100.0:.2f})')
result['deterministic_report']['impression'] = result['deterministic_report']['impression'].replace('low (0.50)', f'{v9b_conf} ({base_conf/100.0:.2f})')
self.respond_json(result)
except Exception as exc:
import traceback
traceback.print_exc()
self.respond_json({'success': False, 'error': str(exc)}, status=500)
def parse_multipart(self, body, boundary):
parts = body.split(b'--' + boundary)
data = {}
for part in parts:
if not part or part in (b'--', b'--\r\n'):
continue
part = part.strip(b'\r\n')
if not part:
continue
header_bytes, _, content = part.partition(b'\r\n\r\n')
headers = {}
for line in header_bytes.split(b'\r\n'):
name, _, value = line.decode('utf-8', 'ignore').partition(':')
headers[name.lower().strip()] = value.strip()
disposition = headers.get('content-disposition', '')
disposition_data = self.parse_content_disposition(disposition)
name = disposition_data.get('name')
if not name:
continue
if 'filename' in disposition_data:
data[name] = {
'filename': disposition_data.get('filename'),
'content': content.rstrip(b'\r\n'),
}
else:
data[name] = content.decode('utf-8', errors='replace').strip()
return data
def parse_content_disposition(self, disposition):
values = {}
parts = [part.strip() for part in disposition.split(';') if part.strip()]
for part in parts:
if '=' in part:
key, val = part.split('=', 1)
values[key.strip().lower()] = val.strip('"')
return values
def respond_json(self, data, status=200):
payload = json.dumps(data).encode('utf-8')
# gzip for non-trivial payloads when the client supports it. Saves
# 60-80% on bandwidth for /explain (which carries base64-PNG dumps).
accept_enc = self.headers.get('Accept-Encoding', '')
gzipped = False
if len(payload) > 1024 and 'gzip' in accept_enc.lower():
payload = gzip.compress(payload)
gzipped = True
elapsed_ms = (time.perf_counter() - self._req_start) * 1000
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
if gzipped:
self.send_header('Content-Encoding', 'gzip')
self.send_header('Content-Length', str(len(payload)))
self.send_header('X-Request-ID', self._request_id)
self.send_header('X-Server-Version', SERVER_VERSION)
self.send_header('X-Inference-Time-ms', f'{elapsed_ms:.1f}')
# CORS - allow the dashboard hosted on Spaces to talk to itself across
# any prefix the platform proxies through.
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(payload)
self._log_request(status, extra=f'gzipped={gzipped} size_kb={len(payload)/1024:.1f}')
def log_message(self, format, *args):
# Suppress the default per-line stderr from BaseHTTPRequestHandler;
# we emit our own structured logs in respond_json / handlers.
return
def handle_one_request(self):
# Swallow client-disconnect noise. BrokenPipeError /
# ConnectionResetError happen when the browser cancels the request
# (refresh, navigate-away) before we finish writing the response.
# The default stdlib handler logs a 20-line traceback for each one,
# which is alarming-looking but harmless. We log a single info line
# instead.
try:
return super().handle_one_request()
except (BrokenPipeError, ConnectionResetError) as exc:
logger.info('client_disconnected req_id=%s err=%s',
getattr(self, '_request_id', '-'),
type(exc).__name__)
def _get_status_snapshot() -> dict:
"""Real /status endpoint: what models are actually loaded, real GPU memory,
classifier weight presence. Replaces the previous mock UI 'System Status'
block (which showed hard-coded '3/3 models, 4.2/8 GB GPU, 2 pending').
"""
snap: dict = {
'version': SERVER_VERSION,
'uptime_seconds': round(time.time() - PROCESS_START_TS, 1),
'classifiers': {'cnn': {'onnx': True}, 'transfer': {'onnx': True}, 'vit': {'onnx': True}},
'segmentation_models': [],
'onnx_runtime': {'available': False, 'providers': [], 'sessions_loaded': 0},
'gpu': {'available': False},
'llm': {
'ollama_text_model': os.environ.get('OLLAMA_MODEL_TEXT', 'qwen2.5:1.5b'),
'ollama_vision_model': os.environ.get('OLLAMA_MODEL_VISION', 'qwen2.5vl:3b'),
'hf_inference_token_present': bool(os.environ.get('HF_TOKEN')),
'anthropic_token_present': bool(os.environ.get('ANTHROPIC_API_KEY')),
},
}
# Classifier ensemble was removed 2026-06-01 (see top-of-file note).
# The 'classifiers' key is intentionally left as an empty dict so the
# /status JSON schema stays stable for any external consumer.
snap['classifiers_removed'] = True
snap['classifiers_removed_reason'] = (
'Deprecated 2026-06-01: ensemble OOD recall capped at 25-47%. '
'Verdict now from the 4-signal advisory (v9c + ANDi + v8 + '
'symmetry) — 97% recall / 6% FPR at the balanced operating point.'
)
snap['verdict_source'] = '4-detector ensemble'
snap['advisory_signals'] = ['Pattern Detector', 'Reconstruction Detector',
'Tumor Outline Drawer', 'Asymmetry Detector']
snap['advisory_operating_points'] = {
'balanced (default)': '97% recall / 6% FPR / 0.83 F1',
'high_recall': '100% recall / 14% FPR / 0.71 F1',
'high_specificity': '92% recall / 4% FPR / 0.85 F1',
}
# Segmentation model directories. A model counts as 'present' if EITHER
# the .pt or .onnx file exists - both are valid inference paths and the
# Spaces container only has .onnx (downloaded from HF Hub at boot).
for d in SEGMENTATION_DIRS + list(MODALITY_DIRS.values()):
pt = d / 'best_model.pt'
onnx = d / 'best_model.onnx'
if not pt.exists() and not onnx.exists():
continue
snap['segmentation_models'].append({
'dir': d.name,
'pt_size_mb': round(pt.stat().st_size / 1e6, 1) if pt.exists() else None,
'onnx_size_mb': round(onnx.stat().st_size / 1e6, 1) if onnx.exists() else None,
'onnx': onnx.name if onnx.exists() else None,
'preferred_runtime': 'onnx' if (onnx.exists() and USE_ONNX) else 'pytorch',
})
# ONNX runtime telemetry.
try:
import onnxruntime as ort
snap['onnx_runtime'] = {
'available': True,
'version': ort.__version__,
'providers': ort.get_available_providers(),
'sessions_loaded': len(ONNX_CACHE),
}
except ImportError:
pass
# GPU memory (PyTorch path).
try:
import torch
if torch.cuda.is_available():
free, total = torch.cuda.mem_get_info()
snap['gpu'] = {
'available': True,
'name': torch.cuda.get_device_name(0),
'memory_used_mb': round((total - free) / 1e6, 1),
'memory_total_mb': round(total / 1e6, 1),
'memory_free_mb': round(free / 1e6, 1),
}
except Exception:
pass
return snap
def _ensure_onnx_models_downloaded():
"""If the ONNX model files aren't bundled with the container (the case on
HuggingFace Spaces, where the 1 GB Space repo budget is too small for
~440 MB of model weights), pull them from a separate HF Model repo at
startup. Model repos have much larger free quotas and are the canonical
HF pattern for distributing trained weights.
Override `HF_MODELS_REPO` env var to point at your own Model repo.
Files are downloaded once and cached on disk; subsequent boots reuse
the cache instantly.
"""
repo = os.environ.get('HF_MODELS_REPO', 'Tubai01/neurolens-models')
# ONNX weights are required for fast forward inference. Classifier .pt
# weights are OPTIONAL - they enable real Grad-CAM via PyTorch autograd
# on Spaces (instead of the occlusion-sensitivity fallback). Pulled only
# if SPACES_DOWNLOAD_PT=1 is set, since torch is a ~250 MB install that
# we keep out of the default Spaces image.
needed = [
# (local target relative to ROOT_DIR, repo-relative path)
# v8 is the production model. dynamo-exported = graph + sidecar
# weights file; both must be present in the same directory or ORT
# fails with "external data not found".
('segmentation_artifacts/attention_unet_v8/best_micro.onnx',
'attention_unet_v8/best_micro.onnx'),
('segmentation_artifacts/attention_unet_v8/best_micro.onnx.data',
'attention_unet_v8/best_micro.onnx.data'),
('segmentation_artifacts/attention_unet_v5/best_model.onnx',
'attention_unet_v5/best_model.onnx'),
('segmentation_artifacts/attention_unet_v3/best_model.onnx',
'attention_unet_v3/best_model.onnx'),
('segmentation_artifacts/attention_unet_t1c/best_model.onnx',
'attention_unet_t1c/best_model.onnx'),
# Classifier ONNXes (cnn / transfer / vit) deliberately NOT
# downloaded — see 2026-06-01 deprecation note at the top of
# this file. Saves ~250 MB at first-boot and avoids loading
# weights that nothing in the pipeline will ever call.
]
# Conformal-counterfactual calibration JSONs. Each is ~1 KB and unlocks
# the research-grade conformal panel in the dashboard. The set of files
# is known statically here so the Space doesn't need to list the repo.
for slug in (
'identity', 'modality_keep_T1', 'modality_keep_T1c',
'modality_keep_FLAIR', 'intensity_shift_+0.10',
'intensity_shift_-0.10', 'contrast_scale_0.70',
'contrast_scale_1.50',
):
needed.append(
(f'conformal_artifacts/{slug}.json',
f'conformal_artifacts/{slug}.json')
)
# v9c JEPA-on-DINOv2 predictor weights (136 MB) — opt-in, only fetched
# when V9C_DOWNLOAD=1 because the file is large and the v9c path is
# itself opt-in via V9C_ENABLE=1. On CPU-basic Spaces the v9c inference
# is too slow to run anyway (~6s/req); cpu-upgrade or GPU-backed Spaces
# should set both V9C_DOWNLOAD=1 and V9C_ENABLE=1 to activate the
# high-recall ensemble.
if os.environ.get('V9C_DOWNLOAD', '0').strip().lower() in ('1', 'true', 'yes'):
needed.append(('v9b_artifacts/v9c_stage1/last.pt',
'v9c_stage1/last.pt'))
# ANDi unconditional DDPM weights (16 MB) — opt-in via V9B_ANDI_DOWNLOAD=1.
# Pairs with V9B_ANDI_ENABLE=1 to activate the 4-signal ensemble
# (v9c + v8 + sym + andi) that hits 100% recall at 14% FPR. ANDi
# inference is much cheaper than v9c (~190ms GPU / ~2-3s CPU) since
# the DDPM is small (16 MB vs 340 MB DINOv2-base).
if os.environ.get('V9B_ANDI_DOWNLOAD', '0').strip().lower() in ('1', 'true', 'yes'):
needed.append(('v9b_artifacts/v9b_andi_ddpm/last.pt',
'v9b_andi_ddpm/last.pt'))
missing = [(loc, rep) for loc, rep in needed if not (ROOT_DIR / loc).exists()]
if not missing:
logger.info('all_onnx_models_already_present')
return
try:
from huggingface_hub import hf_hub_download
except ImportError:
logger.warning('huggingface_hub_not_installed missing_models=%d', len(missing))
return
import shutil as _shutil
token = os.environ.get('HF_TOKEN') or None # public Model repos work tokenless too
for local_rel, repo_rel in missing:
local = ROOT_DIR / local_rel
try:
t0 = time.perf_counter()
downloaded = hf_hub_download(
repo_id=repo, filename=repo_rel,
repo_type='model', token=token,
)
local.parent.mkdir(parents=True, exist_ok=True)
_shutil.copy2(downloaded, local)
ms = (time.perf_counter() - t0) * 1000
logger.info('downloaded_model file=%s repo=%s elapsed_ms=%.0f',
local_rel, repo, ms)
except Exception as exc:
logger.warning('download_failed file=%s err=%s', local_rel, exc)
def _warm_models_async():
"""Pre-load ONNX sessions for the segmentation cascade in a background
thread so the first /segment request doesn't pay the cold-start tax.
Classifier warmup was removed 2026-06-01 along with the classifier
ensemble (see top-of-file deprecation note).
Failure is silent: if a model file isn't there or onnxruntime can't open
it, we just log and move on - the request path will surface a real error
if needed.
"""
def _warm():
t0 = time.perf_counter()
warmed = 0
# Segmentation cascade pair. Accept either .pt (dev box) or .onnx
# alone (Spaces, where we only have .onnx after the HF Hub download).
# v5 first (trained with negatives), v3 as fallback, T1c specialist.
for d in [ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_v5',
ROOT_DIR / 'segmentation_artifacts' / 'attention_unet_v3',
MODALITY_DIRS.get('t1c')]:
if d is None:
continue
onnx = d / 'best_model.onnx'
pt = d / 'best_model.pt'
if not onnx.exists() and not pt.exists():
continue
# Prefer .onnx if it exists; fall back to the sibling resolver
# on the .pt path otherwise.
target = onnx if onnx.exists() else _segmentation_onnx_path(pt)
if target and USE_ONNX:
if _get_onnx_session(target) is not None:
warmed += 1
elapsed_ms = (time.perf_counter() - t0) * 1000
logger.info('model_warmup_complete sessions=%d duration_ms=%.1f', warmed, elapsed_ms)
threading.Thread(target=_warm, name='trinetra-warmup', daemon=True).start()
def run(port=8501, host: str = ''):
if not WEB_DIR.exists():
raise FileNotFoundError(f'Web dashboard files not found: {WEB_DIR}')
address = (host, port)
# ThreadingHTTPServer: parallel requests don't queue behind each other.
# Important when a slow /explain LLM call would otherwise block /predict
# or /health probes from the Spaces orchestrator.
server = ThreadingHTTPServer(address, DashboardHandler)
url = f'http://localhost:{port}/' if not host else f'http://{host}:{port}/'
logger.info('trinetra_dashboard_starting version=%s url=%s', SERVER_VERSION, url)
# Pull ONNX weights from HF Hub if they aren't bundled (Spaces deploy
# path). Synchronous so the first request never races a half-downloaded
# model; ~30 s on first boot, instant on subsequent boots (cached).
_ensure_onnx_models_downloaded()
_warm_models_async()
print(f'NeuroLens AI dashboard running at {url}')
print(f'Version: {SERVER_VERSION}. Endpoints: /predict /segment /explain '
'/metrics /status /health /version.')
print('Press Ctrl+C here to stop the server.')
server.serve_forever()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Run the NeuroLens AI HTML dashboard')
# Defaults are env-driven so the Spaces Dockerfile can override without
# touching the CLI. HF Spaces sets PORT=7860 on Docker SDK and expects
# the container to bind 0.0.0.0; local dev uses 8501 on localhost.
parser.add_argument('--port', type=int,
default=int(os.environ.get('PORT', '8501')))
parser.add_argument('--host', type=str,
default=os.environ.get('HOST', ''))
args = parser.parse_args()
run(args.port, host=args.host)