Gokul-G1 commited on
Commit
e997fec
Β·
verified Β·
1 Parent(s): a53c617

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +271 -216
app.py CHANGED
@@ -1,17 +1,11 @@
1
  import os
2
  import time
3
- import io
4
- import json
5
  import pickle
6
- import hashlib
7
- import random
8
  import warnings
9
- from pathlib import Path
10
- from typing import Dict, List, Optional, Tuple, Any
11
 
12
  import cv2
13
  import numpy as np
14
- import pandas as pd
15
  import torch
16
  import torch.nn as nn
17
  import torch.nn.functional as F
@@ -20,6 +14,7 @@ import timm
20
  import albumentations as A
21
  from albumentations.pytorch import ToTensorV2
22
  from pytorch_grad_cam import GradCAMPlusPlus
 
23
  import gradio as gr
24
 
25
  warnings.filterwarnings("ignore")
@@ -33,14 +28,7 @@ NUM_CLASSES = 5
33
  BACKBONE = "tf_efficientnetv2_m"
34
  FV_BACKBONE = "convnext_tiny.fb_in22k"
35
 
36
- GRADE_MAP = {
37
- 0: "No DR",
38
- 1: "Mild DR",
39
- 2: "Moderate DR",
40
- 3: "Severe DR",
41
- 4: "Proliferative DR",
42
- }
43
-
44
  GRADE_COLORS = ["#2ecc71", "#f1c40f", "#e67e22", "#e74c3c", "#8e44ad"]
45
  SEVERITY_ICONS = ["🟒", "🟑", "🟠", "πŸ”΄", "🟣"]
46
  CLINICAL_ACTION = [
@@ -54,6 +42,9 @@ CLINICAL_ACTION = [
54
  IMAGENET_MEAN = [0.485, 0.456, 0.406]
55
  IMAGENET_STD = [0.229, 0.224, 0.225]
56
 
 
 
 
57
  # =============================================================================
58
  # Model Definitions
59
  # =============================================================================
@@ -63,36 +54,34 @@ class GeM(nn.Module):
63
  super().__init__()
64
  self.p = nn.Parameter(torch.tensor(p))
65
  self.eps = eps
 
66
  def forward(self, x: torch.Tensor) -> torch.Tensor:
67
  return F.adaptive_avg_pool2d(x.clamp(min=self.eps).pow(self.p), 1).pow(1.0 / self.p)
68
 
 
69
  class CoralHead(nn.Module):
70
  def __init__(self, in_features: int, num_classes: int):
71
  super().__init__()
72
- self.num_classes = num_classes
73
  self.linear = nn.Linear(in_features, 1, bias=False)
74
  self.bias = nn.Parameter(torch.zeros(num_classes - 1))
 
75
  def forward(self, x: torch.Tensor) -> torch.Tensor:
76
- logit = self.linear(x)
77
- return logit + self.bias
78
 
79
  class DRHead(nn.Module):
80
  def __init__(self, in_features: int, num_classes: int = 5, dropout: float = 0.3):
81
  super().__init__()
82
  self.net = nn.Sequential(
83
- nn.Linear(in_features, 512),
84
- nn.BatchNorm1d(512),
85
- nn.SiLU(inplace=True),
86
- nn.Dropout(dropout),
87
- nn.Linear(512, 256),
88
- nn.BatchNorm1d(256),
89
- nn.SiLU(inplace=True),
90
- nn.Dropout(dropout / 2),
91
  nn.Linear(256, num_classes),
92
  )
 
93
  def forward(self, x: torch.Tensor) -> torch.Tensor:
94
  return self.net(x)
95
 
 
96
  class DRModel(nn.Module):
97
  def __init__(self, backbone: str = BACKBONE, num_classes: int = 5, pretrained: bool = False):
98
  super().__init__()
@@ -101,303 +90,369 @@ class DRModel(nn.Module):
101
  self.pool = GeM(p=3.0)
102
  self.head_cls = DRHead(in_feat, num_classes)
103
  self.head_coral = CoralHead(in_feat, num_classes)
 
104
  def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]:
105
- feat = self.backbone(x)
106
- feat = self.pool(feat).flatten(1)
107
  return {"logits": self.head_cls(feat), "coral": self.head_coral(feat)}
108
 
 
109
  class FundusValidator(nn.Module):
110
- def __init__(self, pretrained: bool = False, backbone_name: str = FV_BACKBONE):
111
  super().__init__()
112
- self.backbone = timm.create_model(backbone_name, pretrained=pretrained, num_classes=0, global_pool="avg")
113
- for p in self.backbone.parameters(): p.requires_grad = False
 
114
  with torch.no_grad():
115
  feat_dim = self.backbone(torch.zeros(1, 3, 224, 224)).shape[1]
116
- self.head = nn.Sequential(
117
- nn.Linear(feat_dim, 256),
118
- nn.GELU(),
119
- nn.Dropout(0.3),
120
- nn.Linear(256, 2),
121
- )
122
- def extract_features(self, x: torch.Tensor) -> torch.Tensor:
123
- with torch.no_grad(): return self.backbone(x)
124
  def forward(self, x: torch.Tensor) -> torch.Tensor:
125
- feats = self.extract_features(x)
 
126
  return self.head(feats)
127
 
 
128
  # =============================================================================
129
- # Preprocessing Utilities
130
  # =============================================================================
131
-
132
  _clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
133
 
 
134
  def apply_clahe_lab(rgb: np.ndarray) -> np.ndarray:
135
  lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
136
  lab[:, :, 0] = _clahe.apply(lab[:, :, 0])
137
  return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
138
 
 
139
  def retinal_mask(rgb: np.ndarray) -> np.ndarray:
140
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
141
  _, m = cv2.threshold(gray, 15, 255, cv2.THRESH_BINARY)
142
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
143
- m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel)
144
- m = cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel)
145
- return m
146
 
147
  def crop_retinal_disc(rgb: np.ndarray, pad: int = 10) -> np.ndarray:
148
- m = retinal_mask(rgb)
149
- contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
150
- if not contours: return rgb
151
  x, y, w, h = cv2.boundingRect(max(contours, key=cv2.contourArea))
152
  x, y = max(0, x - pad), max(0, y - pad)
153
  x2, y2 = min(rgb.shape[1], x + w + 2 * pad), min(rgb.shape[0], y + h + 2 * pad)
154
  return rgb[y:y2, x:x2]
155
 
156
- def ben_graham(rgb: np.ndarray, sigma_frac: float = 10.0, weight: float = 4.0, bias: int = 128) -> np.ndarray:
157
- h, w = rgb.shape[:2]
158
- sigma = max(1, int(min(h, w) / sigma_frac))
159
- blurred = cv2.GaussianBlur(rgb, (0, 0), sigmaX=sigma, sigmaY=sigma)
160
- out = cv2.addWeighted(rgb, weight, blurred, -weight, bias)
161
- return np.clip(out, 0, 255).astype(np.uint8)
 
 
 
 
 
 
 
162
 
163
  # =============================================================================
164
- # Inference Helpers
165
  # =============================================================================
166
-
167
- def load_models():
168
- # DR Model
169
- model = DRModel(BACKBONE, NUM_CLASSES, pretrained=False).to(DEVICE)
170
  ckpt = torch.load("final_model.pt", map_location=DEVICE)
171
- model.load_state_dict(ckpt["model_state"], strict=False)
172
- model.eval()
 
 
173
 
174
- # Fundus Validator
175
- fv_model = FundusValidator(pretrained=False, backbone_name=FV_BACKBONE).to(DEVICE)
176
- fv_ckpt = torch.load("fundus_validator.pt", map_location=DEVICE)
177
- fv_model.head.load_state_dict(fv_ckpt["head_state_dict"])
178
- fv_model.eval()
 
179
 
180
- # Calibration
181
- calib = None
182
  if os.path.exists("calibration.pkl"):
183
  with open("calibration.pkl", "rb") as f:
184
- calib = pickle.load(f)
 
185
 
186
- return model, fv_model, calib
187
 
188
- model, fv_model, calib = load_models()
 
 
 
 
189
 
190
- inf_tfm = A.Compose([
191
- A.Resize(IMG_SIZE, IMG_SIZE),
192
- A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
193
- ToTensorV2(),
194
- ])
195
 
196
- fv_tfm = A.Compose([
197
- A.Resize(224, 224),
198
- A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
199
- ToTensorV2(),
200
- ])
201
 
202
- def fundus_heuristics(rgb: np.ndarray) -> Dict[str, float]:
203
- r, g, b = rgb[..., 0].astype(np.float32), rgb[..., 1].astype(np.float32), rgb[..., 2].astype(np.float32)
204
- bright = (r > 15) | (g > 15) | (b > 15)
205
- n_bright = int(bright.sum())
206
- red_dominance = float(((r > g) & (r > b))[bright].mean()) if n_bright > 100 else float(((r > g) & (r > b)).mean())
207
- mask = retinal_mask(rgb)
208
- disc_coverage = float(mask.mean() / 255.0)
209
- gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
210
- gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
211
- gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
212
- mag = np.sqrt(gx**2 + gy**2)
213
- edge_density = float(mag[bright].mean() / 255.0) if n_bright > 100 else float(mag.mean() / 255.0)
214
- return {"red_dominance": red_dominance, "disc_coverage": disc_coverage, "edge_density": edge_density}
215
 
216
- STRONG_HEUR = {"red_dominance": 0.55, "disc_coverage": 0.25, "edge_density": 0.008}
217
- WEAK_HEUR = {"red_dominance": 0.30, "disc_coverage": 0.12, "edge_density": 0.006}
218
 
219
- def gate_fundus(pil_img: PILImage.Image) -> Tuple[bool, float, Dict[str, float], str]:
 
 
 
220
  rgb_raw = np.array(pil_img.convert("RGB"))
221
  try:
222
  rgb = apply_clahe_lab(crop_retinal_disc(rgb_raw))
223
- except:
224
  rgb = rgb_raw
 
225
  h = fundus_heuristics(rgb)
226
  x = fv_tfm(image=rgb)["image"].unsqueeze(0).to(DEVICE)
227
  with torch.no_grad():
228
- p_fundus = float(F.softmax(fv_model(x), dim=1).cpu().numpy()[0][1])
229
-
230
  strong_ok = all(h[k] >= v for k, v in STRONG_HEUR.items())
231
- weak_ok = all(h[k] >= v for k, v in WEAK_HEUR.items())
 
 
 
 
 
 
 
232
 
233
- if strong_ok: return True, p_fundus, h, "Accepted (strong visual signatures)."
234
- if p_fundus >= 0.75: return True, p_fundus, h, f"Accepted (validator: {p_fundus*100:.1f}%)."
235
- if p_fundus >= 0.50 and weak_ok: return True, p_fundus, h, "Accepted (validator + heuristics)."
236
- if h["disc_coverage"] >= 0.25 and p_fundus >= 0.35: return True, p_fundus, h, "Accepted (clear retinal disc)."
237
- if p_fundus >= 0.90: return True, p_fundus, h, "Accepted (high validator confidence)."
238
- return False, p_fundus, h, "Image rejected. Not a valid fundus photograph."
239
 
240
  # =============================================================================
241
- # Grad-CAM++ Integration
242
  # =============================================================================
243
-
244
- class _SingleLogit(nn.Module):
245
- def __init__(self, inner):
246
- super().__init__()
247
- self.inner = inner
248
- def forward(self, x):
249
- return self.inner(x)["logits"]
250
-
251
- cam_model = _SingleLogit(model).to(DEVICE).eval()
252
- target_layers = [model.backbone.blocks[-1]] # Last block
253
- cam_obj = GradCAMPlusPlus(model=cam_model, target_layers=target_layers)
254
-
255
- def render_cam(rgb: np.ndarray, tensor: torch.Tensor, target_cls: int) -> np.ndarray:
256
  try:
257
- grayscale_cam = cam_obj(input_tensor=tensor, targets=None)[0, :]
258
- cam_image = show_cam_on_image(rgb.astype(np.float32) / 255, grayscale_cam, use_rgb=True)
259
- return (cam_image * 255).astype(np.uint8)
260
- except:
261
- return rgb
262
 
263
- from pytorch_grad_cam.utils.image import show_cam_on_image
264
 
265
  # =============================================================================
266
- # Main Prediction Logic
267
  # =============================================================================
268
-
269
  def predict(uploaded):
270
- start_time = time.time()
271
- if uploaded is None: return None, None, None, "Please upload an image."
 
272
 
273
  pil_img = PILImage.fromarray(uploaded.astype(np.uint8))
274
-
275
- # Gating
276
  accepted, p_fundus, heur, reason = gate_fundus(pil_img)
277
- if not accepted:
278
- return None, None, None, f"<div style='color:red; font-weight:bold;'>{reason}</div>"
279
 
280
- # Preprocess
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  rgb = np.array(pil_img.convert("RGB"))
282
- rgb = apply_clahe_lab(crop_retinal_disc(rgb))
283
- vis = cv2.resize(rgb, (320, 320))
 
 
 
284
  tensor = inf_tfm(image=cv2.resize(rgb, (IMG_SIZE, IMG_SIZE)))["image"].unsqueeze(0).to(DEVICE)
285
 
286
- # Inference (4-view TTA)
287
  with torch.no_grad():
288
- views = [tensor, torch.flip(tensor, dims=[-1]), torch.flip(tensor, dims=[-2]), torch.rot90(tensor, 2, dims=(-2, -1))]
289
- logits_list, coral_list = [], []
 
 
 
290
  for v in views:
291
  out = model(v)
292
- logits_list.append(out["logits"].cpu().numpy())
293
- coral_list.append(torch.sigmoid(out["coral"]).cpu().numpy())
294
-
295
- logits_mean = np.mean(logits_list, axis=0)
296
- coral_mean = np.mean(coral_list, axis=0)
297
-
298
- # Calibration
299
- if calib:
300
- probs = calib.apply(logits_mean)[0]
 
 
 
 
301
  else:
302
  ex = np.exp(logits_mean - logits_mean.max())
303
  probs = (ex / ex.sum())[0]
304
 
305
- # Fusion
306
  argmax_pred = int(probs.argmax())
307
- coral_pred = int((coral_mean > 0.5).sum())
308
- pred = coral_pred if (coral_mean[0, coral_pred-1] if coral_pred > 0 else 1.0) > probs[argmax_pred] else argmax_pred
 
 
309
  conf = float(probs[pred])
310
 
311
  # Grad-CAM
312
- cam_img = render_cam(vis, tensor, pred)
313
-
314
- proc_time = time.time() - start_time
315
-
316
- # Result HTML
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  html = f"""
318
- <div style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
319
- <div style="display: flex; align-items: center; gap: 15px;">
320
- <span style="font-size: 48px;">{SEVERITY_ICONS[pred]}</span>
321
- <div>
322
- <h2 style="margin: 0; color: {GRADE_COLORS[pred]}; font-size: 24px;">{GRADE_MAP[pred]}</h2>
323
- <p style="margin: 0; color: #666;">Grade {pred}/4 (ICDR Scale)</p>
324
- </div>
 
 
 
325
  </div>
326
- <div style="margin-top: 15px;">
327
- <div style="display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 5px;">
328
- <span>Confidence</span>
329
- <span style="font-weight: bold;">{conf*100:.1f}%</span>
330
- </div>
331
- <div style="height: 8px; background: #eee; border-radius: 4px; overflow: hidden;">
332
- <div style="width: {conf*100}%; background: {GRADE_COLORS[pred]}; height: 100%; transition: width 0.5s;"></div>
333
- </div>
334
- </div>
335
- <div style="margin-top: 15px; padding: 12px; background: #f8f9fa; border-left: 4px solid {GRADE_COLORS[pred]}; border-radius: 4px;">
336
- <p style="margin: 0; font-size: 14px; color: #333;">{CLINICAL_ACTION[pred]}</p>
337
- </div>
338
- <div style="margin-top: 15px; font-size: 12px; color: #888; border-top: 1px solid #eee; padding-top: 10px; display: flex; justify-content: space-between;">
339
- <span>Processing time: {proc_time:.2f}s</span>
340
- <span>Validator: {p_fundus*100:.1f}%</span>
341
- </div>
342
- </div>
343
- """
 
 
 
 
 
 
344
  return vis, cam_img, html
345
 
 
346
  # =============================================================================
347
- # UI Definition
348
  # =============================================================================
349
-
350
- m3_theme = gr.themes.Soft(
351
  primary_hue="blue",
352
  secondary_hue="indigo",
353
  neutral_hue="slate",
354
- font=["Outfit", "Inter", "system-ui"],
355
  ).set(
356
  button_primary_background_fill="*primary_600",
357
  button_primary_background_fill_hover="*primary_700",
358
  block_title_text_weight="600",
359
  block_border_width="1px",
360
- block_shadow="0 2px 4px rgba(0,0,0,0.05)",
361
  )
362
 
363
- with gr.Blocks(theme=m3_theme, title="DR Grading AI") as demo:
 
364
  gr.HTML("""
365
- <div style="text-align: center; padding: 30px 20px; background: linear-gradient(135deg, #1A73E8, #0D47A1); color: white; border-radius: 16px; margin-bottom: 25px;">
366
- <h1 style="margin: 0; font-size: 2.5em; font-weight: 700;">Diabetic Retinopathy Grading</h1>
367
- <p style="margin: 10px 0 0; opacity: 0.9; font-size: 1.1em;">AI-Powered Clinical Diagnosis Support</p>
 
 
 
 
 
 
 
 
 
368
  </div>
369
  """)
370
 
371
- with gr.Row():
372
- with gr.Column(scale=1):
373
- input_img = gr.Image(label="Upload Retina Fundus Image", type="numpy", height=400)
 
 
 
 
 
 
374
  with gr.Row():
375
- clear_btn = gr.Button("Clear", variant="secondary")
376
- submit_btn = gr.Button("Analyze Image", variant="primary")
377
-
378
  gr.Markdown("""
379
- ### Instructions
380
- 1. Upload a clear, high-resolution color fundus photograph.
381
- 2. Click **Analyze Image**.
382
- 3. The AI will validate the image and provide a grading based on the ICDR scale.
383
-
384
- *Note: This tool is for research purposes only.*
385
  """)
386
 
387
- with gr.Column(scale=1):
388
- with gr.Group():
389
- with gr.Row():
390
- out_vis = gr.Image(label="Enhanced View", interactive=False)
391
- out_cam = gr.Image(label="Pathology Heatmap", interactive=False)
392
- out_html = gr.HTML(label="Diagnostic Report")
393
 
394
- submit_btn.click(
 
395
  fn=predict,
396
- inputs=input_img,
397
- outputs=[out_vis, out_cam, out_html]
 
 
 
 
 
398
  )
399
-
400
- clear_btn.click(lambda: (None, None, None, ""), None, [input_img, out_vis, out_cam, out_html])
401
 
402
  if __name__ == "__main__":
403
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
  import os
2
  import time
 
 
3
  import pickle
 
 
4
  import warnings
5
+ from typing import Dict, Optional, Tuple
 
6
 
7
  import cv2
8
  import numpy as np
 
9
  import torch
10
  import torch.nn as nn
11
  import torch.nn.functional as F
 
14
  import albumentations as A
15
  from albumentations.pytorch import ToTensorV2
16
  from pytorch_grad_cam import GradCAMPlusPlus
17
+ from pytorch_grad_cam.utils.image import show_cam_on_image
18
  import gradio as gr
19
 
20
  warnings.filterwarnings("ignore")
 
28
  BACKBONE = "tf_efficientnetv2_m"
29
  FV_BACKBONE = "convnext_tiny.fb_in22k"
30
 
31
+ GRADE_MAP = {0: "No DR", 1: "Mild DR", 2: "Moderate DR", 3: "Severe DR", 4: "Proliferative DR"}
 
 
 
 
 
 
 
32
  GRADE_COLORS = ["#2ecc71", "#f1c40f", "#e67e22", "#e74c3c", "#8e44ad"]
33
  SEVERITY_ICONS = ["🟒", "🟑", "🟠", "πŸ”΄", "🟣"]
34
  CLINICAL_ACTION = [
 
42
  IMAGENET_MEAN = [0.485, 0.456, 0.406]
43
  IMAGENET_STD = [0.229, 0.224, 0.225]
44
 
45
+ STRONG_HEUR = {"red_dominance": 0.55, "disc_coverage": 0.25, "edge_density": 0.008}
46
+ WEAK_HEUR = {"red_dominance": 0.30, "disc_coverage": 0.12, "edge_density": 0.006}
47
+
48
  # =============================================================================
49
  # Model Definitions
50
  # =============================================================================
 
54
  super().__init__()
55
  self.p = nn.Parameter(torch.tensor(p))
56
  self.eps = eps
57
+
58
  def forward(self, x: torch.Tensor) -> torch.Tensor:
59
  return F.adaptive_avg_pool2d(x.clamp(min=self.eps).pow(self.p), 1).pow(1.0 / self.p)
60
 
61
+
62
  class CoralHead(nn.Module):
63
  def __init__(self, in_features: int, num_classes: int):
64
  super().__init__()
 
65
  self.linear = nn.Linear(in_features, 1, bias=False)
66
  self.bias = nn.Parameter(torch.zeros(num_classes - 1))
67
+
68
  def forward(self, x: torch.Tensor) -> torch.Tensor:
69
+ return self.linear(x) + self.bias
70
+
71
 
72
  class DRHead(nn.Module):
73
  def __init__(self, in_features: int, num_classes: int = 5, dropout: float = 0.3):
74
  super().__init__()
75
  self.net = nn.Sequential(
76
+ nn.Linear(in_features, 512), nn.BatchNorm1d(512), nn.SiLU(inplace=True), nn.Dropout(dropout),
77
+ nn.Linear(512, 256), nn.BatchNorm1d(256), nn.SiLU(inplace=True), nn.Dropout(dropout / 2),
 
 
 
 
 
 
78
  nn.Linear(256, num_classes),
79
  )
80
+
81
  def forward(self, x: torch.Tensor) -> torch.Tensor:
82
  return self.net(x)
83
 
84
+
85
  class DRModel(nn.Module):
86
  def __init__(self, backbone: str = BACKBONE, num_classes: int = 5, pretrained: bool = False):
87
  super().__init__()
 
90
  self.pool = GeM(p=3.0)
91
  self.head_cls = DRHead(in_feat, num_classes)
92
  self.head_coral = CoralHead(in_feat, num_classes)
93
+
94
  def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]:
95
+ feat = self.pool(self.backbone(x)).flatten(1)
 
96
  return {"logits": self.head_cls(feat), "coral": self.head_coral(feat)}
97
 
98
+
99
  class FundusValidator(nn.Module):
100
+ def __init__(self, backbone_name: str = FV_BACKBONE):
101
  super().__init__()
102
+ self.backbone = timm.create_model(backbone_name, pretrained=False, num_classes=0, global_pool="avg")
103
+ for p in self.backbone.parameters():
104
+ p.requires_grad = False
105
  with torch.no_grad():
106
  feat_dim = self.backbone(torch.zeros(1, 3, 224, 224)).shape[1]
107
+ self.head = nn.Sequential(nn.Linear(feat_dim, 256), nn.GELU(), nn.Dropout(0.3), nn.Linear(256, 2))
108
+
 
 
 
 
 
 
109
  def forward(self, x: torch.Tensor) -> torch.Tensor:
110
+ with torch.no_grad():
111
+ feats = self.backbone(x)
112
  return self.head(feats)
113
 
114
+
115
  # =============================================================================
116
+ # Preprocessing
117
  # =============================================================================
 
118
  _clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
119
 
120
+
121
  def apply_clahe_lab(rgb: np.ndarray) -> np.ndarray:
122
  lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
123
  lab[:, :, 0] = _clahe.apply(lab[:, :, 0])
124
  return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
125
 
126
+
127
  def retinal_mask(rgb: np.ndarray) -> np.ndarray:
128
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
129
  _, m = cv2.threshold(gray, 15, 255, cv2.THRESH_BINARY)
130
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
131
+ return cv2.morphologyEx(cv2.morphologyEx(m, cv2.MORPH_CLOSE, k), cv2.MORPH_OPEN, k)
132
+
 
133
 
134
  def crop_retinal_disc(rgb: np.ndarray, pad: int = 10) -> np.ndarray:
135
+ contours, _ = cv2.findContours(retinal_mask(rgb), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
136
+ if not contours:
137
+ return rgb
138
  x, y, w, h = cv2.boundingRect(max(contours, key=cv2.contourArea))
139
  x, y = max(0, x - pad), max(0, y - pad)
140
  x2, y2 = min(rgb.shape[1], x + w + 2 * pad), min(rgb.shape[0], y + h + 2 * pad)
141
  return rgb[y:y2, x:x2]
142
 
143
+
144
+ def fundus_heuristics(rgb: np.ndarray) -> Dict[str, float]:
145
+ r, g, b = rgb[..., 0].astype(np.float32), rgb[..., 1].astype(np.float32), rgb[..., 2].astype(np.float32)
146
+ bright = (r > 15) | (g > 15) | (b > 15)
147
+ n_bright = int(bright.sum())
148
+ red_dom = float(((r > g) & (r > b))[bright].mean()) if n_bright > 100 else float(((r > g) & (r > b)).mean())
149
+ disc_cov = float(retinal_mask(rgb).mean() / 255.0)
150
+ gx = cv2.Sobel(cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY), cv2.CV_32F, 1, 0, ksize=3)
151
+ gy = cv2.Sobel(cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY), cv2.CV_32F, 0, 1, ksize=3)
152
+ mag = np.sqrt(gx**2 + gy**2)
153
+ edge_den = float(mag[bright].mean() / 255.0) if n_bright > 100 else float(mag.mean() / 255.0)
154
+ return {"red_dominance": red_dom, "disc_coverage": disc_cov, "edge_density": edge_den}
155
+
156
 
157
  # =============================================================================
158
+ # Model Loading
159
  # =============================================================================
160
+ def load_dr_model():
161
+ m = DRModel(BACKBONE, NUM_CLASSES, pretrained=False).to(DEVICE)
 
 
162
  ckpt = torch.load("final_model.pt", map_location=DEVICE)
163
+ state = ckpt.get("model_state", ckpt)
164
+ m.load_state_dict({k: v for k, v in state.items() if k in m.state_dict()}, strict=False)
165
+ return m.eval()
166
+
167
 
168
+ def load_fv_model():
169
+ m = FundusValidator(FV_BACKBONE).to(DEVICE)
170
+ ckpt = torch.load("fundus_validator.pt", map_location=DEVICE)
171
+ head_state = ckpt.get("head_state_dict", ckpt)
172
+ m.head.load_state_dict(head_state, strict=False)
173
+ return m.eval()
174
 
175
+
176
+ def load_calib():
177
  if os.path.exists("calibration.pkl"):
178
  with open("calibration.pkl", "rb") as f:
179
+ return pickle.load(f)
180
+ return None
181
 
 
182
 
183
+ print("Loading models…")
184
+ model = load_dr_model()
185
+ fv_model = load_fv_model()
186
+ calib = load_calib()
187
+ print(f" DR model loaded | FV model loaded | calib={'yes' if calib else 'no'} | device={DEVICE}")
188
 
189
+ # Transforms
190
+ inf_tfm = A.Compose([A.Resize(IMG_SIZE, IMG_SIZE), A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ToTensorV2()])
191
+ fv_tfm = A.Compose([A.Resize(224, 224), A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ToTensorV2()])
 
 
192
 
193
+ # Grad-CAM setup
194
+ class _SingleLogit(nn.Module):
195
+ def __init__(self, inner): super().__init__(); self.inner = inner
196
+ def forward(self, x): return self.inner(x)["logits"]
 
197
 
198
+ _cam_model = _SingleLogit(model).to(DEVICE).eval()
199
+ try:
200
+ _target_layers = [model.backbone.blocks[-1]]
201
+ cam_obj = GradCAMPlusPlus(model=_cam_model, target_layers=_target_layers)
202
+ GRADCAM_OK = True
203
+ except Exception as e:
204
+ print(f"Grad-CAM init failed: {e}")
205
+ GRADCAM_OK = False
 
 
 
 
 
206
 
 
 
207
 
208
+ # =============================================================================
209
+ # Fundus Gating
210
+ # =============================================================================
211
+ def gate_fundus(pil_img: PILImage.Image) -> Tuple[bool, float, Dict, str]:
212
  rgb_raw = np.array(pil_img.convert("RGB"))
213
  try:
214
  rgb = apply_clahe_lab(crop_retinal_disc(rgb_raw))
215
+ except Exception:
216
  rgb = rgb_raw
217
+
218
  h = fundus_heuristics(rgb)
219
  x = fv_tfm(image=rgb)["image"].unsqueeze(0).to(DEVICE)
220
  with torch.no_grad():
221
+ p = float(F.softmax(fv_model(x), dim=1).cpu().numpy()[0][1])
222
+
223
  strong_ok = all(h[k] >= v for k, v in STRONG_HEUR.items())
224
+ weak_ok = all(h[k] >= v for k, v in WEAK_HEUR.items())
225
+
226
+ if strong_ok: return True, p, h, "Accepted (strong visual signatures)."
227
+ if p >= 0.75: return True, p, h, f"Accepted (validator: {p*100:.1f}%)."
228
+ if p >= 0.50 and weak_ok: return True, p, h, "Accepted (validator + heuristics)."
229
+ if h["disc_coverage"] >= 0.25 and p >= 0.35: return True, p, h, "Accepted (clear retinal disc)."
230
+ if p >= 0.90: return True, p, h, "Accepted (high validator confidence)."
231
+ return False, p, h, "Not a fundus image. Please upload a colour retinal fundus photograph."
232
 
 
 
 
 
 
 
233
 
234
  # =============================================================================
235
+ # Grad-CAM Rendering
236
  # =============================================================================
237
+ def render_cam(vis: np.ndarray, tensor: torch.Tensor) -> Optional[np.ndarray]:
238
+ if not GRADCAM_OK:
239
+ return None
 
 
 
 
 
 
 
 
 
 
240
  try:
241
+ gc = cam_obj(input_tensor=tensor, targets=None)[0, :]
242
+ overlay = show_cam_on_image(vis.astype(np.float32) / 255.0, gc, use_rgb=True)
243
+ return (overlay * 255).astype(np.uint8)
244
+ except Exception:
245
+ return None
246
 
 
247
 
248
  # =============================================================================
249
+ # Inference
250
  # =============================================================================
 
251
  def predict(uploaded):
252
+ t0 = time.time()
253
+ if uploaded is None:
254
+ return None, None, "<p style='color:#666;padding:20px'>Upload a fundus image to begin.</p>"
255
 
256
  pil_img = PILImage.fromarray(uploaded.astype(np.uint8))
 
 
257
  accepted, p_fundus, heur, reason = gate_fundus(pil_img)
 
 
258
 
259
+ if not accepted:
260
+ rd = heur.get("red_dominance", 0)
261
+ dc = heur.get("disc_coverage", 0)
262
+ ed = heur.get("edge_density", 0)
263
+ html = f"""
264
+ <div style="padding:20px;border-radius:10px;background:#fdecea;color:#8a1a14;
265
+ border:1px solid #f5c6c2;font-family:sans-serif">
266
+ <h3 style="margin:0 0 8px">⚠️ Image Rejected</h3>
267
+ <p style="margin:0">{reason}</p>
268
+ <div style="margin-top:12px;font-size:12px;color:#555">
269
+ <strong>Validator score:</strong> {p_fundus*100:.1f}% (need β‰₯75%)<br>
270
+ <strong>Red dominance:</strong> {rd:.3f} (need β‰₯{WEAK_HEUR['red_dominance']:.2f})
271
+ {'βœ“' if rd >= WEAK_HEUR['red_dominance'] else 'βœ—'}<br>
272
+ <strong>Disc coverage:</strong> {dc:.3f} (need β‰₯{WEAK_HEUR['disc_coverage']:.2f})
273
+ {'βœ“' if dc >= WEAK_HEUR['disc_coverage'] else 'βœ—'}<br>
274
+ <strong>Edge density:</strong> {ed:.4f} (need β‰₯{WEAK_HEUR['edge_density']:.3f})
275
+ {'βœ“' if ed >= WEAK_HEUR['edge_density'] else 'βœ—'}
276
+ </div>
277
+ </div>"""
278
+ return None, None, html
279
+
280
+ # Pre-process
281
  rgb = np.array(pil_img.convert("RGB"))
282
+ try:
283
+ rgb = apply_clahe_lab(crop_retinal_disc(rgb))
284
+ except Exception:
285
+ pass
286
+ vis = cv2.resize(rgb, (320, 320))
287
  tensor = inf_tfm(image=cv2.resize(rgb, (IMG_SIZE, IMG_SIZE)))["image"].unsqueeze(0).to(DEVICE)
288
 
289
+ # 4-view TTA
290
  with torch.no_grad():
291
+ views = [tensor,
292
+ torch.flip(tensor, dims=[-1]),
293
+ torch.flip(tensor, dims=[-2]),
294
+ torch.rot90(tensor, 2, dims=(-2, -1))]
295
+ logits_all, coral_all = [], []
296
  for v in views:
297
  out = model(v)
298
+ logits_all.append(out["logits"].cpu().numpy())
299
+ coral_all.append(torch.sigmoid(out["coral"]).cpu().numpy())
300
+
301
+ logits_mean = np.mean(logits_all, axis=0) # (1, 5)
302
+ coral_mean = np.mean(coral_all, axis=0) # (1, 4)
303
+
304
+ # Probabilities
305
+ if calib is not None:
306
+ try:
307
+ probs = calib.apply(logits_mean)[0]
308
+ except Exception:
309
+ ex = np.exp(logits_mean - logits_mean.max())
310
+ probs = (ex / ex.sum())[0]
311
  else:
312
  ex = np.exp(logits_mean - logits_mean.max())
313
  probs = (ex / ex.sum())[0]
314
 
315
+ # Fusion: CORAL ordinal + softmax
316
  argmax_pred = int(probs.argmax())
317
+ coral_pred = int((coral_mean[0] > 0.5).sum())
318
+ coral_conf = float(coral_mean[0, coral_pred - 1]) if coral_pred > 0 else 1.0
319
+ pred = coral_pred if coral_conf > float(probs[argmax_pred]) else argmax_pred
320
+ pred = max(0, min(4, pred))
321
  conf = float(probs[pred])
322
 
323
  # Grad-CAM
324
+ cam_img = render_cam(vis, tensor)
325
+
326
+ proc_time = time.time() - t0
327
+
328
+ # Build probability bars
329
+ bar_html = ""
330
+ for i, (lbl, col) in enumerate(zip(GRADE_MAP.values(), GRADE_COLORS)):
331
+ w = float(probs[i]) * 100
332
+ weight = "700" if i == pred else "400"
333
+ bar_html += f"""
334
+ <div style="margin-bottom:6px">
335
+ <div style="display:flex;justify-content:space-between;font-size:13px;font-weight:{weight}">
336
+ <span>{SEVERITY_ICONS[i]} {lbl}</span><span>{w:.1f}%</span>
337
+ </div>
338
+ <div style="height:6px;background:#eee;border-radius:3px;overflow:hidden">
339
+ <div style="width:{w}%;background:{col};height:100%"></div>
340
+ </div>
341
+ </div>"""
342
+
343
  html = f"""
344
+ <div style="background:#fff;padding:20px;border-radius:14px;
345
+ box-shadow:0 4px 16px rgba(0,0,0,.08);
346
+ font-family:'Segoe UI',Tahoma,sans-serif;line-height:1.5">
347
+
348
+ <!-- Grade header -->
349
+ <div style="display:flex;align-items:center;gap:14px;margin-bottom:16px">
350
+ <span style="font-size:52px">{SEVERITY_ICONS[pred]}</span>
351
+ <div>
352
+ <div style="font-size:22px;font-weight:700;color:{GRADE_COLORS[pred]}">{GRADE_MAP[pred]}</div>
353
+ <div style="font-size:13px;color:#888">Grade {pred} / 4 &nbsp;Β·&nbsp; ICDR Scale</div>
354
  </div>
355
+ </div>
356
+
357
+ <!-- Confidence bars -->
358
+ <div style="background:#f8f9fa;border-radius:8px;padding:12px;margin-bottom:14px">
359
+ <div style="font-size:13px;font-weight:600;color:#444;margin-bottom:8px">Grade Probabilities</div>
360
+ {bar_html}
361
+ </div>
362
+
363
+ <!-- Clinical action -->
364
+ <div style="padding:12px;background:#f0f4ff;border-left:4px solid {GRADE_COLORS[pred]};
365
+ border-radius:6px;margin-bottom:14px">
366
+ <div style="font-size:12px;font-weight:600;color:#555;margin-bottom:4px">CLINICAL RECOMMENDATION</div>
367
+ <div style="font-size:14px;color:#222">{CLINICAL_ACTION[pred]}</div>
368
+ </div>
369
+
370
+ <!-- Metrics footer -->
371
+ <div style="display:flex;justify-content:space-between;font-size:11px;color:#aaa;
372
+ border-top:1px solid #eee;padding-top:10px">
373
+ <span>⏱ Processing: <strong>{proc_time:.2f}s</strong></span>
374
+ <span>πŸ” Validator: <strong>{p_fundus*100:.1f}%</strong></span>
375
+ <span>🎯 Confidence: <strong>{conf*100:.1f}%</strong></span>
376
+ </div>
377
+ </div>"""
378
+
379
  return vis, cam_img, html
380
 
381
+
382
  # =============================================================================
383
+ # Gradio UI β€” Material Design 3
384
  # =============================================================================
385
+ m3 = gr.themes.Soft(
 
386
  primary_hue="blue",
387
  secondary_hue="indigo",
388
  neutral_hue="slate",
389
+ font=["Inter", "Outfit", "system-ui"],
390
  ).set(
391
  button_primary_background_fill="*primary_600",
392
  button_primary_background_fill_hover="*primary_700",
393
  block_title_text_weight="600",
394
  block_border_width="1px",
395
+ block_shadow="0 2px 8px rgba(0,0,0,0.06)",
396
  )
397
 
398
+ with gr.Blocks(theme=m3, title="DR Grading AI β€” Clinical Decision Support") as demo:
399
+
400
  gr.HTML("""
401
+ <div style="text-align:center;padding:28px 20px 22px;
402
+ background:linear-gradient(135deg,#1A73E8 0%,#0D47A1 100%);
403
+ color:#fff;border-radius:16px;margin-bottom:20px">
404
+ <div style="font-size:2.4em;font-weight:800;letter-spacing:-0.5px">
405
+ πŸ‘ Diabetic Retinopathy Grading
406
+ </div>
407
+ <div style="margin-top:8px;opacity:.88;font-size:1.05em">
408
+ EfficientNetV2-M Β· CORAL Ordinal Β· Grad-CAM++ Β· 4-View TTA
409
+ </div>
410
+ <div style="margin-top:6px;opacity:.7;font-size:.85em">
411
+ For research & educational use only β€” not a medical device
412
+ </div>
413
  </div>
414
  """)
415
 
416
+ with gr.Row(equal_height=True):
417
+ # ── LEFT PANEL ──────────────────────────────────────────────────────
418
+ with gr.Column(scale=1, min_width=340):
419
+ inp = gr.Image(
420
+ label="Upload Retinal Fundus Photograph",
421
+ type="numpy",
422
+ height=380,
423
+ sources=["upload", "clipboard"],
424
+ )
425
  with gr.Row():
426
+ btn_clear = gr.Button("πŸ—‘ Clear", variant="secondary", size="sm")
427
+ btn_analyze = gr.Button("πŸ”¬ Analyze", variant="primary", size="lg")
428
+
429
  gr.Markdown("""
430
+ **How to use**
431
+ 1. Upload a colour fundus photograph (JPEG / PNG)
432
+ 2. Click **Analyze** β€” the AI validates it first
433
+ 3. Review the grade, heatmap, and recommendation
434
+
435
+ > *Grades 0–4 follow the International Clinical DR (ICDR) severity scale.*
436
  """)
437
 
438
+ # ── RIGHT PANEL ─────────────────────────────────────────────────────
439
+ with gr.Column(scale=1, min_width=340):
440
+ with gr.Row():
441
+ out_vis = gr.Image(label="Enhanced Fundus", interactive=False, height=200)
442
+ out_cam = gr.Image(label="Pathology Heatmap (Grad-CAM++)", interactive=False, height=200)
443
+ out_report = gr.HTML()
444
 
445
+ # ── Events ──────────────────────────────────────────────────────────────
446
+ btn_analyze.click(
447
  fn=predict,
448
+ inputs=inp,
449
+ outputs=[out_vis, out_cam, out_report],
450
+ )
451
+ btn_clear.click(
452
+ fn=lambda: (None, None, None, ""),
453
+ inputs=[],
454
+ outputs=[inp, out_vis, out_cam, out_report],
455
  )
 
 
456
 
457
  if __name__ == "__main__":
458
  demo.launch(server_name="0.0.0.0", server_port=7860)