Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -7,6 +7,7 @@ import torchvision
|
|
| 7 |
import numpy as np
|
| 8 |
import scipy.ndimage as ndimage
|
| 9 |
import gradio as gr
|
|
|
|
| 10 |
from monai.transforms import (
|
| 11 |
Compose, LoadImaged, EnsureChannelFirstd,
|
| 12 |
Resized, ScaleIntensityd, ToTensord, Lambdad,
|
|
@@ -47,19 +48,14 @@ class GatekeeperService:
|
|
| 47 |
else:
|
| 48 |
print(f"⚠️ Model file '{model_path}' not found.")
|
| 49 |
|
| 50 |
-
# --- AI
|
| 51 |
-
# We revert to ScaleIntensityd (Min/Max) because that is what the model learned.
|
| 52 |
self.transforms = Compose([
|
| 53 |
LoadImaged(keys=["image"]),
|
| 54 |
EnsureChannelFirstd(keys=["image"]),
|
| 55 |
Orientationd(keys=["image"], axcodes="RAS"),
|
| 56 |
-
# We enforce the same spacing used in training (likely 1.5mm iso)
|
| 57 |
Spacingd(keys=["image"], pixdim=(1.5, 1.5, 1.5), mode=("bilinear")),
|
| 58 |
Lambdad(keys=["image"], func=lambda x: x[0:1]),
|
| 59 |
-
|
| 60 |
-
# FIX: Reverted to Simple Scaling to match Training Data distribution
|
| 61 |
ScaleIntensityd(keys=["image"]),
|
| 62 |
-
|
| 63 |
Resized(keys=["image"], spatial_size=(96, 96, 96)),
|
| 64 |
ToTensord(keys=["image"])
|
| 65 |
])
|
|
@@ -69,7 +65,6 @@ class GatekeeperService:
|
|
| 69 |
swi_path = None
|
| 70 |
if not file_paths: return None, None
|
| 71 |
|
| 72 |
-
print(f"Scanning {len(file_paths)} uploaded files...")
|
| 73 |
for path in file_paths:
|
| 74 |
filename = os.path.basename(path).lower()
|
| 75 |
if "flair" in filename or "t2_flair" in filename:
|
|
@@ -80,32 +75,51 @@ class GatekeeperService:
|
|
| 80 |
|
| 81 |
def physics_check_hemorrhage(self, swi_path):
|
| 82 |
"""
|
| 83 |
-
|
| 84 |
-
separate from the AI pipeline.
|
| 85 |
"""
|
| 86 |
if not swi_path: return False, 0.0
|
| 87 |
try:
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
vol =
|
| 91 |
|
| 92 |
-
#
|
| 93 |
-
#
|
| 94 |
p99 = np.percentile(vol, 99)
|
| 95 |
if p99 > 0: vol = vol / p99
|
| 96 |
-
vol = np.clip(vol, 0, 1)
|
| 97 |
|
| 98 |
-
# Physics Rule
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
labeled_array, num_features = ndimage.label(dark_mask)
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
for i in range(1, num_features+1):
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return False, 0.0
|
|
|
|
| 109 |
except Exception as e:
|
| 110 |
print(f"Physics Check Failed: {e}")
|
| 111 |
return False, 0.0
|
|
@@ -125,15 +139,17 @@ class GatekeeperService:
|
|
| 125 |
response["reasoning"].append("CRITICAL: No FLAIR sequence found.")
|
| 126 |
return response
|
| 127 |
|
| 128 |
-
# 1. Physics Check
|
| 129 |
if swi_path:
|
| 130 |
is_bleed, size = self.physics_check_hemorrhage(swi_path)
|
| 131 |
if is_bleed:
|
| 132 |
response["status"] = "COMPLETED"
|
| 133 |
response["triage_category"] = "HEMORRHAGE"
|
| 134 |
response["next_agent"] = "REPORT_AGENT"
|
| 135 |
-
response["reasoning"].append(f"Physics Rule: Signal void ({size:.0f}mm3) detected on SWI.")
|
| 136 |
return response
|
|
|
|
|
|
|
| 137 |
|
| 138 |
# 2. AI Prediction
|
| 139 |
try:
|
|
@@ -144,21 +160,20 @@ class GatekeeperService:
|
|
| 144 |
output = self.model(tensor_data)
|
| 145 |
probs = torch.softmax(output, dim=1)
|
| 146 |
|
| 147 |
-
#
|
| 148 |
-
# If
|
| 149 |
-
# we should prioritize Ischemic for safety (Stroke Alert).
|
| 150 |
-
ischemic_idx = self.classes.index("ischemic")
|
| 151 |
-
ischemic_prob = probs[0, ischemic_idx].item()
|
| 152 |
-
|
| 153 |
pred_idx = torch.argmax(probs, dim=1).item()
|
| 154 |
confidence = probs[0, pred_idx].item()
|
| 155 |
|
| 156 |
-
#
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
| 160 |
diagnosis = "ischemic"
|
| 161 |
-
response["reasoning"].append(f"AI Alert:
|
| 162 |
else:
|
| 163 |
diagnosis = self.classes[pred_idx]
|
| 164 |
|
|
@@ -184,7 +199,7 @@ class GatekeeperService:
|
|
| 184 |
return response
|
| 185 |
|
| 186 |
# ==========================================
|
| 187 |
-
# 3. INITIALIZATION
|
| 188 |
# ==========================================
|
| 189 |
MODEL_FILE = "gatekeeper_hybrid_trained.pth"
|
| 190 |
agent = GatekeeperService(MODEL_FILE)
|
|
@@ -192,9 +207,6 @@ agent = GatekeeperService(MODEL_FILE)
|
|
| 192 |
def predict_mri_protocol(file_list):
|
| 193 |
return agent.process_batch(file_list)
|
| 194 |
|
| 195 |
-
# ==========================================
|
| 196 |
-
# 4. GRADIO UI
|
| 197 |
-
# ==========================================
|
| 198 |
with gr.Blocks() as demo:
|
| 199 |
gr.Markdown("# 🧠 Neuro-Triage: Gatekeeper Agent")
|
| 200 |
|
|
|
|
| 7 |
import numpy as np
|
| 8 |
import scipy.ndimage as ndimage
|
| 9 |
import gradio as gr
|
| 10 |
+
import nibabel as nib # Added for direct raw loading
|
| 11 |
from monai.transforms import (
|
| 12 |
Compose, LoadImaged, EnsureChannelFirstd,
|
| 13 |
Resized, ScaleIntensityd, ToTensord, Lambdad,
|
|
|
|
| 48 |
else:
|
| 49 |
print(f"⚠️ Model file '{model_path}' not found.")
|
| 50 |
|
| 51 |
+
# --- AI PIPELINE (PREPROCESSED) ---
|
|
|
|
| 52 |
self.transforms = Compose([
|
| 53 |
LoadImaged(keys=["image"]),
|
| 54 |
EnsureChannelFirstd(keys=["image"]),
|
| 55 |
Orientationd(keys=["image"], axcodes="RAS"),
|
|
|
|
| 56 |
Spacingd(keys=["image"], pixdim=(1.5, 1.5, 1.5), mode=("bilinear")),
|
| 57 |
Lambdad(keys=["image"], func=lambda x: x[0:1]),
|
|
|
|
|
|
|
| 58 |
ScaleIntensityd(keys=["image"]),
|
|
|
|
| 59 |
Resized(keys=["image"], spatial_size=(96, 96, 96)),
|
| 60 |
ToTensord(keys=["image"])
|
| 61 |
])
|
|
|
|
| 65 |
swi_path = None
|
| 66 |
if not file_paths: return None, None
|
| 67 |
|
|
|
|
| 68 |
for path in file_paths:
|
| 69 |
filename = os.path.basename(path).lower()
|
| 70 |
if "flair" in filename or "t2_flair" in filename:
|
|
|
|
| 75 |
|
| 76 |
def physics_check_hemorrhage(self, swi_path):
|
| 77 |
"""
|
| 78 |
+
FIXED: Uses RAW nibabel loading to avoid interpolation artifacts.
|
|
|
|
| 79 |
"""
|
| 80 |
if not swi_path: return False, 0.0
|
| 81 |
try:
|
| 82 |
+
# 1. Load RAW Data (No Monai interpolation)
|
| 83 |
+
img_obj = nib.load(swi_path)
|
| 84 |
+
vol = img_obj.get_fdata()
|
| 85 |
|
| 86 |
+
# 2. Robust Normalization
|
| 87 |
+
# Map 0-99th percentile to 0-1
|
| 88 |
p99 = np.percentile(vol, 99)
|
| 89 |
if p99 > 0: vol = vol / p99
|
|
|
|
| 90 |
|
| 91 |
+
# 3. Physics Rule
|
| 92 |
+
# Since we made a "Black Hole", pixels are exactly 0.0 or close to it.
|
| 93 |
+
# We assume anything < 2% intensity is a void (Blood or Air).
|
| 94 |
+
dark_mask = vol < 0.02
|
| 95 |
+
|
| 96 |
+
# 4. Cluster Analysis
|
| 97 |
labeled_array, num_features = ndimage.label(dark_mask)
|
| 98 |
+
|
| 99 |
+
# Use raw voxel size from header for accuracy
|
| 100 |
+
zooms = img_obj.header.get_zooms()
|
| 101 |
+
voxel_vol = zooms[0] * zooms[1] * zooms[2]
|
| 102 |
+
|
| 103 |
+
largest_bleed_size = 0
|
| 104 |
|
| 105 |
for i in range(1, num_features+1):
|
| 106 |
+
# Count voxels in this cluster
|
| 107 |
+
voxel_count = np.sum(labeled_array == i)
|
| 108 |
+
size_mm3 = voxel_count * voxel_vol
|
| 109 |
+
|
| 110 |
+
# Logic:
|
| 111 |
+
# - Background Air is huge (> 500,000 mm3)
|
| 112 |
+
# - Noise is small (< 500 mm3)
|
| 113 |
+
# - Bleed is in between (500 mm3 - 300,000 mm3)
|
| 114 |
+
if 500 < size_mm3 < 300000:
|
| 115 |
+
if size_mm3 > largest_bleed_size:
|
| 116 |
+
largest_bleed_size = size_mm3
|
| 117 |
+
|
| 118 |
+
if largest_bleed_size > 0:
|
| 119 |
+
return True, largest_bleed_size
|
| 120 |
+
|
| 121 |
return False, 0.0
|
| 122 |
+
|
| 123 |
except Exception as e:
|
| 124 |
print(f"Physics Check Failed: {e}")
|
| 125 |
return False, 0.0
|
|
|
|
| 139 |
response["reasoning"].append("CRITICAL: No FLAIR sequence found.")
|
| 140 |
return response
|
| 141 |
|
| 142 |
+
# 1. Physics Check (Priority 1)
|
| 143 |
if swi_path:
|
| 144 |
is_bleed, size = self.physics_check_hemorrhage(swi_path)
|
| 145 |
if is_bleed:
|
| 146 |
response["status"] = "COMPLETED"
|
| 147 |
response["triage_category"] = "HEMORRHAGE"
|
| 148 |
response["next_agent"] = "REPORT_AGENT"
|
| 149 |
+
response["reasoning"].append(f"Physics Rule: Signal void ({size:.0f}mm3) detected on SWI. Thrombolysis Contraindicated.")
|
| 150 |
return response
|
| 151 |
+
else:
|
| 152 |
+
response["reasoning"].append("Physics Rule: SWI scan clear (No active bleed detected).")
|
| 153 |
|
| 154 |
# 2. AI Prediction
|
| 155 |
try:
|
|
|
|
| 160 |
output = self.model(tensor_data)
|
| 161 |
probs = torch.softmax(output, dim=1)
|
| 162 |
|
| 163 |
+
# BIAS CORRECTION:
|
| 164 |
+
# If model is unsure between Tumor/Ischemic, prioritize Ischemic for safety.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
pred_idx = torch.argmax(probs, dim=1).item()
|
| 166 |
confidence = probs[0, pred_idx].item()
|
| 167 |
|
| 168 |
+
# Get probabilities
|
| 169 |
+
p_ischemic = probs[0, self.classes.index("ischemic")].item()
|
| 170 |
+
p_tumor = probs[0, self.classes.index("tumor")].item()
|
| 171 |
+
|
| 172 |
+
# Override: If Tumor is predicted but Ischemic is > 20%, force Ischemic Alert.
|
| 173 |
+
# Reason: Better to send a Tumor to Vision Agent (Safety) than miss a Stroke.
|
| 174 |
+
if self.classes[pred_idx] == "tumor" and p_ischemic > 0.20:
|
| 175 |
diagnosis = "ischemic"
|
| 176 |
+
response["reasoning"].append(f"AI Alert: Complex pattern. Prioritizing Stroke (Prob: {p_ischemic:.2%}) over Tumor to ensure safety.")
|
| 177 |
else:
|
| 178 |
diagnosis = self.classes[pred_idx]
|
| 179 |
|
|
|
|
| 199 |
return response
|
| 200 |
|
| 201 |
# ==========================================
|
| 202 |
+
# 3. INITIALIZATION & UI
|
| 203 |
# ==========================================
|
| 204 |
MODEL_FILE = "gatekeeper_hybrid_trained.pth"
|
| 205 |
agent = GatekeeperService(MODEL_FILE)
|
|
|
|
| 207 |
def predict_mri_protocol(file_list):
|
| 208 |
return agent.process_batch(file_list)
|
| 209 |
|
|
|
|
|
|
|
|
|
|
| 210 |
with gr.Blocks() as demo:
|
| 211 |
gr.Markdown("# 🧠 Neuro-Triage: Gatekeeper Agent")
|
| 212 |
|