NMundhra commited on
Commit
194eedd
·
1 Parent(s): 127734b

Fix L1 gatekeeper ResNet-50 compatibility, integrate L1-L2-L3 pipeline and return Base64 GradCAMs

Browse files
app.py CHANGED
@@ -1,53 +1,30 @@
1
- import io
2
  import os
 
3
  import uuid
4
  import tempfile
5
  import subprocess
6
- import torch
7
  from fastapi import FastAPI, File, UploadFile
8
  from fastapi.responses import JSONResponse
9
- from PIL import Image
10
- from torchvision import transforms
11
 
12
- from models.level1_gatekeeper import GatekeeperModel
13
- from models.level2_router import DiseaseRouterModel
14
 
15
  app = FastAPI(title="OCT Image Classification Pipeline")
16
 
17
- # Helper to load state dict properly from checkpoints
18
- def load_checkpoint(model, path):
19
- checkpoint = torch.load(path, map_location='cpu')
20
- if 'model_state_dict' in checkpoint:
21
- model.load_state_dict(checkpoint['model_state_dict'])
22
- else:
23
- model.load_state_dict(checkpoint)
24
-
25
- # Load models globally
26
- print("Loading Level 1 Gatekeeper...")
27
- l1_model = GatekeeperModel(pretrained=False, freeze_backbone=False)
28
- load_checkpoint(l1_model, 'weights/level1.pth')
29
- l1_model.eval()
30
-
31
- print("Loading Level 2 Router...")
32
- l2_model = DiseaseRouterModel(pretrained=False, freeze_backbone=False)
33
- load_checkpoint(l2_model, 'weights/level2.pth')
34
- l2_model.eval()
35
-
36
- # Standard ImageNet transforms for EfficientNet
37
- transform = transforms.Compose([
38
- transforms.Resize((224, 224)),
39
- transforms.ToTensor(),
40
- transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
41
- ])
42
-
43
- L1_CLASSES = {0: "NORMAL", 1: "ABNORMAL"}
44
- L2_CLASSES = {
45
- 0: "Macular_Degeneration",
46
- 1: "Diabetic_Complications",
47
- 2: "Vascular_Occlusions",
48
- 3: "Fluid_Accumulation",
49
- 4: "Structural_Issues"
50
  }
 
 
 
 
 
 
51
 
52
  @app.get("/")
53
  def greet_json():
@@ -57,9 +34,8 @@ def greet_json():
57
  async def predict_image(file: UploadFile = File(...)):
58
  try:
59
  contents = await file.read()
60
- image = Image.open(io.BytesIO(contents)).convert("RGB")
61
 
62
- # Save to bucket
63
  ext = os.path.splitext(file.filename)[1]
64
  if not ext:
65
  ext = ".png"
@@ -70,38 +46,25 @@ async def predict_image(file: UploadFile = File(...)):
70
  tmp_path = tmp.name
71
 
72
  # Upload to HF Bucket via CLI
73
- # The space will use the HF_TOKEN environment variable automatically
74
  bucket_url = f"hf://buckets/NMundhra/OCT-Image-Classifier-Model-storage/uploads/{unique_filename}"
75
  subprocess.Popen(["hf", "buckets", "cp", tmp_path, bucket_url])
76
- # Popen runs it asynchronously so it doesn't block the API response
77
 
78
- except Exception as e:
79
- return JSONResponse(status_code=400, content={"error": f"Invalid image file or upload failed: {str(e)}"})
80
-
81
- input_tensor = transform(image).unsqueeze(0)
82
-
83
- with torch.no_grad():
84
- # Level 1 Inference
85
- l1_logits = l1_model(input_tensor)
86
- l1_probs = torch.softmax(l1_logits, dim=1)[0]
87
- l1_pred_idx = torch.argmax(l1_probs).item()
88
- l1_confidence = l1_probs[l1_pred_idx].item()
89
- l1_label = L1_CLASSES[l1_pred_idx]
90
 
91
- result = {
92
- "level1_prediction": l1_label,
93
- "level1_confidence": float(l1_confidence)
 
 
 
 
 
 
 
94
  }
95
-
96
- # If abnormal, route to Level 2
97
- if l1_label == "ABNORMAL":
98
- l2_logits = l2_model(input_tensor)
99
- l2_probs = torch.softmax(l2_logits, dim=1)[0]
100
- l2_pred_idx = torch.argmax(l2_probs).item()
101
- l2_confidence = l2_probs[l2_pred_idx].item()
102
- l2_label = L2_CLASSES[l2_pred_idx]
103
-
104
- result["level2_prediction"] = l2_label
105
- result["level2_confidence"] = float(l2_confidence)
106
-
107
- return result
 
 
1
  import os
2
+ import io
3
  import uuid
4
  import tempfile
5
  import subprocess
 
6
  from fastapi import FastAPI, File, UploadFile
7
  from fastapi.responses import JSONResponse
 
 
8
 
9
+ from scripts.inference_pipeline import OCTInferencePipeline
 
10
 
11
  app = FastAPI(title="OCT Image Classification Pipeline")
12
 
13
+ # Initialize pipeline once on startup
14
+ print("Initializing OCT Inference Pipeline...")
15
+ l3_ckpts = {
16
+ "Macular": "weights/level3_macular.pth",
17
+ "Diabetic": "weights/level3_diabetic.pth",
18
+ "Vascular": "weights/level3_vascular.pth",
19
+ "Fluid": "weights/level3_fluid.pth",
20
+ "Structural": "weights/level3_structural.pth"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
+ pipeline = OCTInferencePipeline(
23
+ l1_ckpt="weights/level1.pth",
24
+ l2_ckpt="weights/level2.pth",
25
+ l3_ckpts=l3_ckpts,
26
+ device="cpu" # Space typically uses CPU unless upgraded
27
+ )
28
 
29
  @app.get("/")
30
  def greet_json():
 
34
  async def predict_image(file: UploadFile = File(...)):
35
  try:
36
  contents = await file.read()
 
37
 
38
+ # Save to bucket for storage purposes (optional analytics)
39
  ext = os.path.splitext(file.filename)[1]
40
  if not ext:
41
  ext = ".png"
 
46
  tmp_path = tmp.name
47
 
48
  # Upload to HF Bucket via CLI
 
49
  bucket_url = f"hf://buckets/NMundhra/OCT-Image-Classifier-Model-storage/uploads/{unique_filename}"
50
  subprocess.Popen(["hf", "buckets", "cp", tmp_path, bucket_url])
 
51
 
52
+ # Run inference pipeline
53
+ results = pipeline.predict(tmp_path, gradcam=True)
54
+
55
+ if "error" in results:
56
+ return JSONResponse(status_code=400, content=results)
 
 
 
 
 
 
 
57
 
58
+ # Re-format to match the expected legacy format but with new L3 + GradCAMs
59
+ return {
60
+ "level1_prediction": results.get("Level1", {}).get("prediction"),
61
+ "level1_confidence": results.get("Level1", {}).get("confidence"),
62
+ "level2_prediction": results.get("Level2", {}).get("prediction"),
63
+ "level2_confidence": results.get("Level2", {}).get("confidence"),
64
+ "level3_prediction": results.get("Level3", {}).get("prediction"),
65
+ "level3_confidence": results.get("Level3", {}).get("confidence"),
66
+ "final_diagnosis": results.get("Final_Diagnosis"),
67
+ "gradcams": results.get("gradcams", {})
68
  }
69
+ except Exception as e:
70
+ return JSONResponse(status_code=500, content={"error": f"Internal server error: {str(e)}"})
 
 
 
 
 
 
 
 
 
 
 
data/__pycache__/transforms.cpython-314.pyc ADDED
Binary file (11.6 kB). View file
 
data/transforms.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data/transforms.py
3
+
4
+ Augmentation pipelines for the OCT hierarchical classification pipeline.
5
+
6
+ Resolution Strategy (from architectural directives):
7
+ Level 1 (Gatekeeper): 224×224 — maximum throughput for binary screening.
8
+ Level 2 (Router): 224×224 — consistent feature space with L1.
9
+ Level 3 (Specialists): 384×384 — fine-grained structural detail for
10
+ CNV vs DRUSEN, RAO vs RVO, etc.
11
+
12
+ Pipeline Variants:
13
+ - Standard Train: Random crop/flip/rotation + ColorJitter + GaussianBlur +
14
+ RandomErasing. Used for L1, L2, L3_Macular, L3_Diabetic.
15
+ - Heavy Train: Adds RandomAffine + stronger erasing. Used for
16
+ extreme minority L3 specialists (Vascular, Fluid, Structural)
17
+ where RAO has only 22 samples and CSR has 102.
18
+ - Val/Test: Deterministic resize + CenterCrop + normalize only.
19
+
20
+ All pipelines use ImageNet mean/std for pretrained backbone compatibility.
21
+ """
22
+
23
+ import numpy as np
24
+ from torchvision import transforms
25
+ import cv2
26
+
27
+ # ── ImageNet statistics ───────────────────────────────────────────────────────
28
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]
29
+ IMAGENET_STD = [0.229, 0.224, 0.225]
30
+
31
+ # ── Resolution constants ──────────────────────────────────────────────────────
32
+ RES_L1_L2: int = 224 # Level 1 & 2 input resolution
33
+ RES_L3: int = 384 # Level 3 specialist input resolution
34
+
35
+ # Intermediate crop sizes (resize target before random/center crop)
36
+ _CROP_L1_L2: int = 256
37
+ _CROP_L3: int = 416
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────────
41
+ # CLAHE Preprocessing
42
+ # ──────────────────────────────────────────────────────────────────────────────
43
+
44
+ class CLAHETransform:
45
+ """
46
+ Contrast Limited Adaptive Histogram Equalization for OCT images.
47
+
48
+ Applied per-image BEFORE resize/crop to normalise brightness and local
49
+ contrast variation across different OCT scanner manufacturers
50
+ (Zeiss, Heidelberg, Topcon, etc.).
51
+
52
+ Without this step, the model may learn scanner-specific intensity
53
+ distributions rather than pathology — a form of shortcut learning that
54
+ degrades performance on unseen devices.
55
+
56
+ Applied identically at train, val, and test time — this is NOT an
57
+ augmentation, it is a deterministic preprocessing step.
58
+
59
+ Args:
60
+ clip_limit: Contrast clip threshold. 2.0 is standard for OCT.
61
+ Higher values = more contrast, more noise amplification.
62
+ tile_grid: Size of the adaptive tile grid. (8, 8) is standard.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ clip_limit: float = 2.0,
68
+ tile_grid: tuple = (8, 8),
69
+ ) -> None:
70
+ self.clip_limit = clip_limit
71
+ self.tile_grid = tile_grid
72
+ self._clahe = None
73
+
74
+ def __call__(self, img) -> "PIL.Image.Image":
75
+ from PIL import Image as PILImage
76
+ if self._clahe is None:
77
+ self._clahe = cv2.createCLAHE(
78
+ clipLimit=self.clip_limit,
79
+ tileGridSize=self.tile_grid,
80
+ )
81
+ # Convert to numpy grayscale — OCT images carry most diagnostic
82
+ # information in luminance; colour channels are usually redundant
83
+ img_np = np.array(img.convert("L"), dtype=np.uint8)
84
+ equalized = self._clahe.apply(img_np)
85
+ # Stack to 3-channel RGB — required for ImageNet-pretrained backbones
86
+ rgb = np.stack([equalized, equalized, equalized], axis=-1)
87
+ return PILImage.fromarray(rgb, mode="RGB")
88
+
89
+
90
+ # Shared instance used in all transform pipelines
91
+ _CLAHE = CLAHETransform(clip_limit=2.0, tile_grid=(8, 8))
92
+
93
+
94
+ # ──────────────────────────────────────────────────────────────────────────────
95
+ # Transform factory functions
96
+ # ──────────────────────────────────────────────────────────────────────────────
97
+
98
+ def get_train_transforms(resolution: int = RES_L1_L2) -> transforms.Compose:
99
+ """
100
+ Standard training augmentation pipeline.
101
+
102
+ Designed to:
103
+ - Increase geometric diversity (flip, rotate, crop).
104
+ - Simulate OCT scan artefacts (GaussianBlur, ColorJitter).
105
+ - Force the network to ignore local texture via RandomErasing.
106
+
107
+ Args:
108
+ resolution: Target output resolution (224 or 384).
109
+
110
+ Returns:
111
+ Composed torchvision transform.
112
+ """
113
+ crop_size = _CROP_L3 if resolution == RES_L3 else _CROP_L1_L2
114
+ return transforms.Compose([
115
+ _CLAHE, # Scanner normalisation (deterministic)
116
+ transforms.Resize(
117
+ crop_size,
118
+ interpolation=transforms.InterpolationMode.BICUBIC,
119
+ ),
120
+ transforms.RandomCrop(resolution),
121
+ transforms.RandomHorizontalFlip(p=0.5),
122
+ transforms.RandomVerticalFlip(p=0.2),
123
+ transforms.RandomRotation(degrees=15),
124
+ transforms.ColorJitter(
125
+ brightness=0.3,
126
+ contrast=0.3,
127
+ saturation=0.1,
128
+ hue=0.05,
129
+ ),
130
+ transforms.RandomApply(
131
+ [transforms.GaussianBlur(kernel_size=5, sigma=(0.1, 2.0))],
132
+ p=0.3,
133
+ ),
134
+ transforms.ToTensor(),
135
+ # RandomErasing after ToTensor (operates on tensor, not PIL image)
136
+ transforms.RandomErasing(
137
+ p=0.2,
138
+ scale=(0.02, 0.10),
139
+ ratio=(0.3, 3.3),
140
+ value="random",
141
+ ),
142
+ transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
143
+ ])
144
+
145
+
146
+ def get_heavy_train_transforms(resolution: int = RES_L3) -> transforms.Compose:
147
+ """
148
+ Heavy augmentation pipeline for extreme minority classes.
149
+
150
+ Applied to L3_Vascular (RAO=22, RVO=101, MH=102), L3_Fluid (CSR=102),
151
+ and L3_Structural (ERM=155, VID=76) to maximise synthetic variation.
152
+
153
+ Adds on top of the standard pipeline:
154
+ - RandomAffine (translate, scale, shear)
155
+ - Stronger rotation (±30°)
156
+ - Stronger RandomErasing scale
157
+
158
+ Args:
159
+ resolution: Target output resolution (typically 384 for L3).
160
+ """
161
+ crop_size = _CROP_L3 if resolution == RES_L3 else _CROP_L1_L2
162
+ return transforms.Compose([
163
+ _CLAHE, # Scanner normalisation (deterministic)
164
+ transforms.Resize(
165
+ crop_size,
166
+ interpolation=transforms.InterpolationMode.BICUBIC,
167
+ ),
168
+ transforms.RandomCrop(resolution),
169
+ transforms.RandomHorizontalFlip(p=0.5),
170
+ transforms.RandomVerticalFlip(p=0.5),
171
+ transforms.RandomRotation(degrees=30),
172
+ transforms.RandomAffine(
173
+ degrees=20,
174
+ translate=(0.10, 0.10),
175
+ scale=(0.85, 1.15),
176
+ shear=10,
177
+ interpolation=transforms.InterpolationMode.BICUBIC,
178
+ ),
179
+ transforms.ColorJitter(
180
+ brightness=0.4,
181
+ contrast=0.4,
182
+ saturation=0.2,
183
+ hue=0.10,
184
+ ),
185
+ transforms.RandomApply(
186
+ [transforms.GaussianBlur(kernel_size=5, sigma=(0.1, 3.0))],
187
+ p=0.4,
188
+ ),
189
+ transforms.ToTensor(),
190
+ transforms.RandomErasing(
191
+ p=0.35,
192
+ scale=(0.02, 0.15),
193
+ ratio=(0.3, 3.3),
194
+ value="random",
195
+ ),
196
+ transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
197
+ ])
198
+
199
+
200
+ def get_val_transforms(resolution: int = RES_L1_L2) -> transforms.Compose:
201
+ """
202
+ Deterministic validation/test pipeline (no augmentation).
203
+
204
+ Args:
205
+ resolution: Target output resolution (224 or 384).
206
+
207
+ Returns:
208
+ Composed torchvision transform.
209
+ """
210
+ crop_size = _CROP_L3 if resolution == RES_L3 else _CROP_L1_L2
211
+ return transforms.Compose([
212
+ _CLAHE, # Scanner normalisation — must match train pipeline
213
+ transforms.Resize(
214
+ crop_size,
215
+ interpolation=transforms.InterpolationMode.BICUBIC,
216
+ ),
217
+ transforms.CenterCrop(resolution),
218
+ transforms.ToTensor(),
219
+ transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
220
+ ])
221
+
222
+
223
+ # ──────────────────────────────────────────────────────────────────────────────
224
+ # Registry — keyed by (mode, split)
225
+ # ──────────────────────────────────────────────────────────────────────────────
226
+
227
+ #: Complete transform registry. Access via :func:`get_transforms`.
228
+ TRANSFORM_REGISTRY: dict = {
229
+ # Level 1 — 224px, standard augmentation
230
+ "level1": {
231
+ "train": get_train_transforms(RES_L1_L2),
232
+ "val": get_val_transforms(RES_L1_L2),
233
+ },
234
+ # Level 2 — 224px, HEAVY augmentation (minority class collapse prevention)
235
+ "level2": {
236
+ "train": get_heavy_train_transforms(RES_L1_L2),
237
+ "val": get_val_transforms(RES_L1_L2),
238
+ },
239
+ # Level 3 Macular — 384px, standard (large enough dataset)
240
+ "level3_macular": {
241
+ "train": get_train_transforms(RES_L3),
242
+ "val": get_val_transforms(RES_L3),
243
+ },
244
+ # Level 3 Diabetic — 384px, standard (DME=11,495 samples)
245
+ "level3_diabetic": {
246
+ "train": get_train_transforms(RES_L3),
247
+ "val": get_val_transforms(RES_L3),
248
+ },
249
+ # Level 3 Vascular — 384px, HEAVY (MH=102, RVO=101, RAO=22)
250
+ "level3_vascular": {
251
+ "train": get_heavy_train_transforms(RES_L3),
252
+ "val": get_val_transforms(RES_L3),
253
+ },
254
+ # Level 3 Fluid — 384px, HEAVY (CSR=102 only)
255
+ "level3_fluid": {
256
+ "train": get_heavy_train_transforms(RES_L3),
257
+ "val": get_val_transforms(RES_L3),
258
+ },
259
+ # Level 3 Structural — 384px, HEAVY (ERM=155, VID=76)
260
+ "level3_structural": {
261
+ "train": get_heavy_train_transforms(RES_L3),
262
+ "val": get_val_transforms(RES_L3),
263
+ },
264
+ }
265
+
266
+
267
+ def get_transforms(mode: str, split: str = "train") -> transforms.Compose:
268
+ """
269
+ Convenience accessor for the transform registry.
270
+
271
+ Args:
272
+ mode: Dataset mode (e.g., ``'level1'``, ``'level3_vascular'``).
273
+ split: ``'train'`` or ``'val'``.
274
+
275
+ Returns:
276
+ A ``torchvision.transforms.Compose`` instance.
277
+
278
+ Raises:
279
+ ValueError: If mode or split is invalid.
280
+ """
281
+ if mode not in TRANSFORM_REGISTRY:
282
+ raise ValueError(
283
+ f"Unknown mode: '{mode}'. "
284
+ f"Choose from: {sorted(TRANSFORM_REGISTRY.keys())}"
285
+ )
286
+ if split not in ("train", "val"):
287
+ raise ValueError(f"Unknown split: '{split}'. Use 'train' or 'val'.")
288
+ return TRANSFORM_REGISTRY[mode][split]
models/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (222 Bytes). View file
 
models/__pycache__/level1_gatekeeper.cpython-314.pyc ADDED
Binary file (4.49 kB). View file
 
models/__pycache__/level2_router.cpython-314.pyc ADDED
Binary file (7.34 kB). View file
 
models/__pycache__/level3_specialist.cpython-314.pyc ADDED
Binary file (10.9 kB). View file
 
models/level1_gatekeeper.py CHANGED
@@ -59,3 +59,16 @@ class GatekeeperModel(nn.Module):
59
  x = self.avgpool(x)
60
  x = self.classifier(x)
61
  return x
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  x = self.avgpool(x)
60
  x = self.classifier(x)
61
  return x
62
+
63
+ def build_gatekeeper(
64
+ num_classes: int = 2,
65
+ dropout_rate: float = 0.3,
66
+ pretrained: bool = True,
67
+ freeze_backbone: bool = True,
68
+ ) -> GatekeeperModel:
69
+ return GatekeeperModel(
70
+ num_classes=num_classes,
71
+ dropout_rate=dropout_rate,
72
+ pretrained=pretrained,
73
+ freeze_backbone=freeze_backbone,
74
+ )
models/level3_specialist.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/level3_specialist.py
3
+
4
+ Level 3 Specialist Models — EfficientNet-B0 fine-grained classifiers.
5
+
6
+ Input: 384×384 RGB tensors (2.9× more pixels than L1/L2 for structural detail)
7
+ Backbone: EfficientNet-B0 (smaller/faster than B2 — appropriate for specialists
8
+ that operate on a much smaller subset of the total dataset)
9
+
10
+ Specialist Instances:
11
+ ┌───────────────┬──────────────────────────────────────────────┬────────┐
12
+ │ Key │ Classes │ Total │
13
+ ├───────────────┼──────────────────────────────────────────────┼────────┤
14
+ │ Macular │ CNV (Wet AMD) / DRUSEN (Dry AMD) / Generic_AMD│ 47,107│
15
+ │ Diabetic │ DME / DR │ 11,602 │
16
+ │ Vascular │ MH / RVO / RAO │ 225 │
17
+ │ Fluid │ CSR (single-class anomaly) │ 102 │
18
+ │ Structural │ ERM / VID │ 231 │
19
+ └───────────────┴──────────────────────────────────────────────┴────────┘
20
+
21
+ AMD Mapping (as per architectural directive):
22
+ - CNV: Wet AMD / Choroidal Neovascularization (class 0)
23
+ - DRUSEN: Dry AMD / Drusen deposits (class 1)
24
+ - Generic_AMD: Unclassified AMD from OCTID source (class 2)
25
+ These are STRICTLY SEPARATED — never merged.
26
+
27
+ Vascular Mapping (as per architectural directive):
28
+ - L2 routes all Vascular into a single Vascular_Occlusions bucket.
29
+ - L3_Vascular re-separates them: MH (0) / RVO (1) / RAO (2).
30
+
31
+ 384px Justification:
32
+ At 224px, the subtle textural difference between drusen deposits (dry AMD)
33
+ and sub-retinal fluid (wet AMD/CNV) can be lost. 384px preserves the
34
+ fine-grained structural detail needed for specialist discrimination.
35
+ Batch size is reduced to 16 to fit within 32GB MPS unified memory.
36
+ """
37
+
38
+ import logging
39
+ from typing import Dict, List
40
+
41
+ import torch
42
+ import torch.nn as nn
43
+ from torchvision import models
44
+ from torchvision.models import EfficientNet_B0_Weights
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # ──────────────────────────────────────────────────────────────────────────────
49
+ # Registry of all specialist configurations
50
+ # ──────────────────────────────────────────────────────────────────────────────
51
+ SPECIALIST_CONFIGS: Dict[str, Dict] = {
52
+ "Macular": {
53
+ "num_classes": 3,
54
+ "specialist_name": "L3_Macular",
55
+ "description": "CNV (Wet AMD) vs DRUSEN (Dry AMD) vs Generic_AMD — strictly separated",
56
+ "classes": {0: "CNV", 1: "DRUSEN", 2: "Generic_AMD"},
57
+ },
58
+ "Diabetic": {
59
+ "num_classes": 2,
60
+ "specialist_name": "L3_Diabetic",
61
+ "description": "Diabetic Macular Edema (DME) vs Diabetic Retinopathy (DR)",
62
+ "classes": {0: "DME", 1: "DR"},
63
+ },
64
+ "Vascular": {
65
+ "num_classes": 3,
66
+ "specialist_name": "L3_Vascular",
67
+ "description": "Macular Hole (MH) vs RVO vs RAO (re-separated from L2 aggregate)",
68
+ "classes": {0: "MH", 1: "RVO", 2: "RAO"},
69
+ },
70
+ "Fluid": {
71
+ "num_classes": 1,
72
+ "specialist_name": "L3_Fluid",
73
+ "description": "Central Serous Retinopathy — single-class anomaly detection",
74
+ "classes": {0: "CSR"},
75
+ },
76
+ "Structural": {
77
+ "num_classes": 2,
78
+ "specialist_name": "L3_Structural",
79
+ "description": "Epiretinal Membrane (ERM) vs Vitreomacular Interface Disease (VID)",
80
+ "classes": {0: "ERM", 1: "VID"},
81
+ },
82
+ }
83
+
84
+
85
+ class SpecialistModel(nn.Module):
86
+ """
87
+ EfficientNet-B0 fine-grained classifier for Level 3 specialist tasks.
88
+
89
+ Operates on 384×384 input for maximum structural resolution.
90
+
91
+ Architecture:
92
+ EfficientNet-B0 features (1280-d after avgpool)
93
+ → Dropout(dropout_rate)
94
+ → Linear(1280, 512) + SiLU (Swish — native EfficientNet activation)
95
+ → Dropout(dropout_rate / 2)
96
+ → Linear(512, num_classes)
97
+
98
+ Args:
99
+ num_classes: Number of fine-grained classes for this specialist.
100
+ specialist_name: Human-readable name for logging.
101
+ dropout_rate: Dropout probability in classifier head.
102
+ pretrained: Load IMAGENET1K_V1 weights if True.
103
+ freeze_backbone: Start with backbone frozen.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ num_classes: int,
109
+ specialist_name: str = "Specialist",
110
+ dropout_rate: float = 0.4,
111
+ pretrained: bool = True,
112
+ freeze_backbone: bool = True,
113
+ ) -> None:
114
+ super().__init__()
115
+ self.specialist_name = specialist_name
116
+
117
+ weights = EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None
118
+ backbone = models.efficientnet_b0(weights=weights)
119
+
120
+ self.features = backbone.features # MBConv blocks
121
+ self.avgpool = backbone.avgpool # AdaptiveAvgPool2d(1, 1)
122
+
123
+ in_features = backbone.classifier[-1].in_features # 1280 for B0
124
+
125
+ # Richer head than the router — specialists need more discriminative power
126
+ # for subtle inter-class differences (e.g., CNV vs DRUSEN fluid patterns)
127
+ self.classifier = nn.Sequential(
128
+ nn.Dropout(p=dropout_rate),
129
+ nn.Linear(in_features, 512),
130
+ nn.SiLU(inplace=True), # Swish — consistent with EfficientNet internals
131
+ nn.Dropout(p=dropout_rate / 2),
132
+ nn.Linear(512, num_classes),
133
+ )
134
+
135
+ if freeze_backbone:
136
+ self.freeze_backbone()
137
+
138
+ logger.info(
139
+ "%s ready | backbone=EfficientNet-B0 | in_features=%d | "
140
+ "num_classes=%d | input=384×384 | frozen=%s",
141
+ specialist_name, in_features, num_classes, freeze_backbone,
142
+ )
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────
145
+ # Freeze / Unfreeze API
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+
148
+ def freeze_backbone(self) -> None:
149
+ for param in self.features.parameters():
150
+ param.requires_grad = False
151
+
152
+ def unfreeze_backbone(self) -> None:
153
+ for param in self.features.parameters():
154
+ param.requires_grad = True
155
+ logger.info("%s: backbone UNFROZEN.", self.specialist_name)
156
+
157
+ def get_param_groups(
158
+ self,
159
+ backbone_lr: float = 5e-5,
160
+ head_lr: float = 5e-4,
161
+ ) -> List[Dict]:
162
+ """Differential LR groups for Phase 2 fine-tuning."""
163
+ return [
164
+ {"params": self.features.parameters(), "lr": backbone_lr},
165
+ {"params": self.classifier.parameters(), "lr": head_lr},
166
+ ]
167
+
168
+ # ──────────────────────────────────────────────────────────────────────────
169
+ # Forward
170
+ # ──────────────────────────────────────────────────────────────────────────
171
+
172
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
173
+ """
174
+ Args:
175
+ x: Float tensor, shape ``(B, 3, 384, 384)``.
176
+
177
+ Returns:
178
+ Logits tensor, shape ``(B, num_classes)``.
179
+ """
180
+ x = self.features(x) # (B, 1280, H', W')
181
+ x = self.avgpool(x) # (B, 1280, 1, 1)
182
+ x = torch.flatten(x, 1) # (B, 1280)
183
+ x = self.classifier(x) # (B, num_classes)
184
+ return x
185
+
186
+
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+ # Factory
189
+ # ──────────────────────────────────────────────────────────────────────────────
190
+
191
+ def build_specialist(
192
+ specialist_key: str,
193
+ dropout_rate: float = 0.4,
194
+ pretrained: bool = True,
195
+ freeze_backbone: bool = True,
196
+ ) -> SpecialistModel:
197
+ """
198
+ Factory function for Level 3 specialist models.
199
+
200
+ Args:
201
+ specialist_key: One of 'Macular', 'Diabetic', 'Vascular',
202
+ 'Fluid', 'Structural'.
203
+ dropout_rate: Head dropout probability.
204
+ pretrained: Use ImageNet pretrained weights.
205
+ freeze_backbone: Start with frozen backbone (Phase 1 warm-up).
206
+
207
+ Returns:
208
+ Configured :class:`SpecialistModel` instance.
209
+
210
+ Raises:
211
+ ValueError: If specialist_key is not in SPECIALIST_CONFIGS.
212
+
213
+ Example::
214
+
215
+ model = build_specialist('Macular')
216
+ # → L3_Macular: 3 classes (CNV / DRUSEN / Generic_AMD), 384×384 input
217
+ """
218
+ if specialist_key not in SPECIALIST_CONFIGS:
219
+ raise ValueError(
220
+ f"Unknown specialist: '{specialist_key}'. "
221
+ f"Valid keys: {list(SPECIALIST_CONFIGS.keys())}"
222
+ )
223
+
224
+ cfg = SPECIALIST_CONFIGS[specialist_key]
225
+ logger.info(
226
+ "Building specialist [%s]: %s",
227
+ specialist_key, cfg["description"],
228
+ )
229
+
230
+ return SpecialistModel(
231
+ num_classes=cfg["num_classes"],
232
+ specialist_name=cfg["specialist_name"],
233
+ dropout_rate=dropout_rate,
234
+ pretrained=pretrained,
235
+ freeze_backbone=freeze_backbone,
236
+ )
requirements.txt CHANGED
@@ -5,3 +5,6 @@ uvicorn[standard]
5
  python-multipart
6
  Pillow
7
  huggingface_hub[cli]
 
 
 
 
5
  python-multipart
6
  Pillow
7
  huggingface_hub[cli]
8
+ opencv-python-headless
9
+ matplotlib
10
+ numpy
scripts/inference_pipeline.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ scripts/inference_pipeline.py
3
+
4
+ End-to-End Inference Pipeline for Hierarchical OCT Classification.
5
+ Connects L1 -> L2 -> L3 into a single callable function.
6
+ Takes a raw OCT scan and returns a final diagnosis with confidence scores.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Dict, Any, Optional
15
+
16
+ import torch
17
+ import torch.nn.functional as F
18
+ from PIL import Image
19
+
20
+ # Add parent directory to path so we can import from models, data, and utils
21
+ sys.path.append(str(Path(__file__).resolve().parent.parent))
22
+
23
+ from models.level1_gatekeeper import build_gatekeeper
24
+ from models.level2_router import build_router
25
+ from models.level3_specialist import build_specialist, SPECIALIST_CONFIGS
26
+ from data.transforms import get_transforms
27
+ from utils.gradcam import GradCAM
28
+
29
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
30
+ logger = logging.getLogger(__name__)
31
+
32
+ class OCTInferencePipeline:
33
+ def __init__(
34
+ self,
35
+ l1_ckpt: Optional[str] = None,
36
+ l2_ckpt: Optional[str] = None,
37
+ l3_ckpts: Optional[Dict[str, str]] = None,
38
+ device: str = "auto",
39
+ ):
40
+ """
41
+ Initializes the entire L1 -> L2 -> L3 inference pipeline.
42
+
43
+ Args:
44
+ l1_ckpt: Path to Level 1 (Gatekeeper) checkpoint.
45
+ l2_ckpt: Path to Level 2 (Router) checkpoint.
46
+ l3_ckpts: Dict mapping specialist names ('Macular', etc.) to checkpoint paths.
47
+ device: 'cuda', 'mps', 'cpu', or 'auto'.
48
+ """
49
+ if device == "auto":
50
+ if torch.backends.mps.is_available():
51
+ self.device = torch.device("mps")
52
+ elif torch.cuda.is_available():
53
+ self.device = torch.device("cuda")
54
+ else:
55
+ self.device = torch.device("cpu")
56
+ else:
57
+ self.device = torch.device(device)
58
+
59
+ logger.info(f"Initialising OCT Inference Pipeline on device: {self.device}")
60
+
61
+ # 1. Load Transforms (we use 'val' split for deterministic preprocessing)
62
+ self.transform_l1_l2 = get_transforms("level1", "val")
63
+ # For L3, we can just use level3_macular's val transform as all L3 val transforms are identical (384px)
64
+ self.transform_l3 = get_transforms("level3_macular", "val")
65
+
66
+ # 2. Build Models
67
+ logger.info("Building Level 1 Gatekeeper...")
68
+ self.l1_model = build_gatekeeper(pretrained=True).to(self.device)
69
+ self._load_ckpt(self.l1_model, l1_ckpt)
70
+ self.l1_model.eval()
71
+
72
+ logger.info("Building Level 2 Router...")
73
+ self.l2_model = build_router(pretrained=True).to(self.device)
74
+ self._load_ckpt(self.l2_model, l2_ckpt)
75
+ self.l2_model.eval()
76
+
77
+ self.l3_models = {}
78
+ l3_ckpts = l3_ckpts or {}
79
+ for spec_name in SPECIALIST_CONFIGS.keys():
80
+ logger.info(f"Building Level 3 Specialist: {spec_name}...")
81
+ model = build_specialist(spec_name, pretrained=True).to(self.device)
82
+ self._load_ckpt(model, l3_ckpts.get(spec_name))
83
+ model.eval()
84
+ self.l3_models[spec_name] = model
85
+
86
+ # 3. Label Mappings
87
+ self.l1_mapping = {0: "NORMAL", 1: "ABNORMAL"}
88
+ self.l2_mapping = {
89
+ 0: "Macular",
90
+ 1: "Diabetic",
91
+ 2: "Vascular",
92
+ 3: "Fluid",
93
+ 4: "Structural"
94
+ }
95
+
96
+ def _load_ckpt(self, model: torch.nn.Module, ckpt_path: Optional[str]):
97
+ """Helper to load state dict if path is provided."""
98
+ if ckpt_path and os.path.exists(ckpt_path):
99
+ state = torch.load(ckpt_path, map_location=self.device)
100
+ if "model_state_dict" in state:
101
+ model.load_state_dict(state["model_state_dict"])
102
+ else:
103
+ model.load_state_dict(state)
104
+ logger.info(f" -> Loaded weights from {ckpt_path}")
105
+ else:
106
+ logger.warning(f" -> No checkpoint provided for {model.__class__.__name__}. Using random initialization!")
107
+
108
+ def _get_heatmap_base64(self, img_pil, cam_array):
109
+ """Helper to generate a base64 encoded overlay image."""
110
+ import base64
111
+ import io
112
+ overlay = GradCAM.overlay_cam(img_pil, cam_array, alpha=0.5)
113
+ buffered = io.BytesIO()
114
+ overlay.save(buffered, format="JPEG")
115
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
116
+ return f"data:image/jpeg;base64,{img_str}"
117
+
118
+ def predict(
119
+ self,
120
+ image_path: str,
121
+ gradcam: bool = False,
122
+ output_dir: str = "output/explanations"
123
+ ) -> Dict[str, Any]:
124
+ """
125
+ Runs the end-to-end inference pipeline on a single image.
126
+ """
127
+ logger.info(f"Processing image: {image_path}")
128
+
129
+ # Load Image
130
+ try:
131
+ img = Image.open(image_path).convert("RGB")
132
+ except Exception as e:
133
+ return {"error": f"Failed to load image: {e}"}
134
+
135
+ # Prepare Tensors
136
+ tensor_224 = self.transform_l1_l2(img).unsqueeze(0).to(self.device)
137
+ tensor_384 = self.transform_l3(img).unsqueeze(0).to(self.device)
138
+
139
+ results = {
140
+ "Level1": {},
141
+ "Level2": {},
142
+ "Level3": {},
143
+ "Final_Diagnosis": None,
144
+ "Path": [],
145
+ "gradcams": {}
146
+ }
147
+
148
+ if gradcam:
149
+ # Need gradients enabled for Grad-CAM
150
+ grad_context = torch.enable_grad()
151
+ # Also require gradients for input tensors
152
+ tensor_224.requires_grad = True
153
+ tensor_384.requires_grad = True
154
+ else:
155
+ grad_context = torch.no_grad()
156
+
157
+ with grad_context:
158
+ # --- LEVEL 1: Gatekeeper ---
159
+ if gradcam:
160
+ l1_cam_gen = GradCAM(self.l1_model, self.l1_model.features[-1])
161
+
162
+ logits_l1 = self.l1_model(tensor_224)
163
+ probs_l1 = F.softmax(logits_l1, dim=1).squeeze(0)
164
+ pred_l1_idx = torch.argmax(probs_l1).item()
165
+ pred_l1_label = self.l1_mapping[pred_l1_idx]
166
+ conf_l1 = probs_l1[pred_l1_idx].item()
167
+
168
+ results["Level1"] = {
169
+ "prediction": pred_l1_label,
170
+ "confidence": conf_l1,
171
+ "probs": {self.l1_mapping[i]: probs_l1[i].item() for i in range(2)}
172
+ }
173
+ results["Path"].append(f"L1: {pred_l1_label}")
174
+
175
+ if gradcam:
176
+ heatmap = l1_cam_gen.generate_cam(tensor_224, pred_l1_idx)
177
+ results["gradcams"]["L1"] = self._get_heatmap_base64(img, heatmap)
178
+ # Cleanup gradcam to free hooks
179
+ l1_cam_gen.target_layer._forward_hooks.clear()
180
+ l1_cam_gen.target_layer._backward_hooks.clear()
181
+
182
+ if pred_l1_label == "NORMAL":
183
+ results["Final_Diagnosis"] = "NORMAL"
184
+ logger.info("Pipeline terminated at Level 1 (NORMAL)")
185
+ # return results # Disabled for Grad-CAM testing
186
+
187
+ # --- LEVEL 2: Disease Router ---
188
+ if gradcam:
189
+ l2_cam_gen = GradCAM(self.l2_model, self.l2_model.features[-1])
190
+
191
+ logits_l2 = self.l2_model(tensor_224)
192
+ probs_l2 = F.softmax(logits_l2, dim=1).squeeze(0)
193
+ pred_l2_idx = torch.argmax(probs_l2).item()
194
+ pred_l2_label = self.l2_mapping[pred_l2_idx]
195
+ conf_l2 = probs_l2[pred_l2_idx].item()
196
+
197
+ results["Level2"] = {
198
+ "prediction": pred_l2_label,
199
+ "confidence": conf_l2,
200
+ "probs": {self.l2_mapping[i]: probs_l2[i].item() for i in range(5)}
201
+ }
202
+ results["Path"].append(f"L2: {pred_l2_label}")
203
+
204
+ if gradcam:
205
+ heatmap = l2_cam_gen.generate_cam(tensor_224, pred_l2_idx)
206
+ results["gradcams"]["L2"] = self._get_heatmap_base64(img, heatmap)
207
+ l2_cam_gen.target_layer._forward_hooks.clear()
208
+ l2_cam_gen.target_layer._backward_hooks.clear()
209
+
210
+ # --- LEVEL 3: Specialist ---
211
+ specialist_model = self.l3_models[pred_l2_label]
212
+ spec_config = SPECIALIST_CONFIGS[pred_l2_label]
213
+ l3_classes_map = spec_config["classes"]
214
+
215
+ if gradcam:
216
+ l3_cam_gen = GradCAM(specialist_model, specialist_model.features[-1])
217
+
218
+ logits_l3 = specialist_model(tensor_384)
219
+ probs_l3 = F.softmax(logits_l3, dim=1).squeeze(0)
220
+ pred_l3_idx = torch.argmax(probs_l3).item()
221
+ pred_l3_label = l3_classes_map[pred_l3_idx]
222
+ conf_l3 = probs_l3[pred_l3_idx].item()
223
+
224
+ results["Level3"] = {
225
+ "specialist_used": spec_config["specialist_name"],
226
+ "prediction": pred_l3_label,
227
+ "confidence": conf_l3,
228
+ "probs": {l3_classes_map[i]: probs_l3[i].item() for i in range(len(l3_classes_map))}
229
+ }
230
+ results["Path"].append(f"L3: {pred_l3_label}")
231
+ results["Final_Diagnosis"] = pred_l3_label
232
+
233
+ if gradcam:
234
+ heatmap = l3_cam_gen.generate_cam(tensor_384, pred_l3_idx)
235
+ results["gradcams"]["L3"] = self._get_heatmap_base64(img, heatmap)
236
+ l3_cam_gen.target_layer._forward_hooks.clear()
237
+ l3_cam_gen.target_layer._backward_hooks.clear()
238
+
239
+ return results
240
+
241
+ if __name__ == "__main__":
242
+ import argparse
243
+ parser = argparse.ArgumentParser(description="Run OCT Hierarchical Inference")
244
+ parser.add_argument("--image", type=str, required=True, help="Path to raw OCT image")
245
+ parser.add_argument("--l1_ckpt", type=str, default=None, help="L1 model checkpoint path")
246
+ parser.add_argument("--l2_ckpt", type=str, default=None, help="L2 model checkpoint path")
247
+ parser.add_argument("--gradcam", action="store_true", help="Generate Grad-CAM heatmaps")
248
+ parser.add_argument("--output-dir", type=str, default="output/explanations", help="Output directory for heatmaps")
249
+ args = parser.parse_args()
250
+
251
+ pipeline = OCTInferencePipeline(
252
+ l1_ckpt=args.l1_ckpt,
253
+ l2_ckpt=args.l2_ckpt,
254
+ )
255
+
256
+ res = pipeline.predict(args.image, gradcam=args.gradcam, output_dir=args.output_dir)
257
+ print("\n--- INFERENCE RESULTS ---")
258
+ print(json.dumps(res, indent=4))
utils/__pycache__/gradcam.cpython-314.pyc ADDED
Binary file (7.74 kB). View file
 
utils/gradcam.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from PIL import Image
6
+
7
+ class GradCAM:
8
+ """
9
+ Grad-CAM implementation for PyTorch models.
10
+ Supports visualizing the regions of the input image that are most important
11
+ for the model's prediction.
12
+ """
13
+ def __init__(self, model, target_layer):
14
+ self.model = model
15
+ self.target_layer = target_layer
16
+ self.gradients = None
17
+ self.activations = None
18
+
19
+ # Register hooks
20
+ self.target_layer.register_forward_hook(self.save_activation)
21
+ self.target_layer.register_full_backward_hook(self.save_gradient)
22
+
23
+ def save_activation(self, module, input, output):
24
+ if torch.backends.mps.is_available():
25
+ torch.mps.synchronize()
26
+ self.activations = output.clone().detach()
27
+ import logging
28
+ logger = logging.getLogger("GradCAM")
29
+ logger.info(f"[Hook] Captured Activations - Max: {self.activations.max().item():.4f}, Min: {self.activations.min().item():.4f}")
30
+
31
+ def save_gradient(self, module, grad_input, grad_output):
32
+ # grad_output is a tuple; we want the first element
33
+ self.gradients = grad_output[0]
34
+
35
+ def generate_cam(self, input_tensor, target_class=None):
36
+ """
37
+ Generates the Class Activation Map (CAM).
38
+
39
+ Args:
40
+ input_tensor (torch.Tensor): Preprocessed image tensor (1, C, H, W)
41
+ target_class (int, optional): The class to generate CAM for.
42
+ If None, uses the class with highest score.
43
+
44
+ Returns:
45
+ np.ndarray: The normalized CAM (H, W) in range [0, 1].
46
+ """
47
+ # Ensure we have gradients enabled for this forward/backward pass
48
+ self.model.zero_grad()
49
+
50
+ # Forward pass
51
+ output = self.model(input_tensor)
52
+
53
+ if target_class is None:
54
+ target_class = output.argmax(dim=1).item()
55
+
56
+ # Extract the score for the target class
57
+ score = output[0, target_class]
58
+
59
+ # Backward pass
60
+ score.backward()
61
+
62
+ # Get activations and gradients from the hooks
63
+ gradients = self.gradients.detach().cpu().numpy()[0] # (C, H, W)
64
+ activations = self.activations.detach().cpu().numpy()[0] # (C, H, W)
65
+
66
+ # Compute the channel weights (global average pooling of gradients)
67
+ weights = np.mean(gradients, axis=(1, 2)) # (C,)
68
+
69
+ import logging
70
+ logger = logging.getLogger("GradCAM")
71
+ logger.info(f"Gradients - Max: {np.max(gradients):.4e}, Min: {np.min(gradients):.4e}, Sum: {np.sum(gradients):.4e}")
72
+ logger.info(f"Activations - Max: {np.max(activations):.4f}, Min: {np.min(activations):.4f}, Sum: {np.sum(activations):.4f}")
73
+ logger.info(f"Weights - Max: {np.max(weights):.4e}, Min: {np.min(weights):.4e}, Sum: {np.sum(weights):.4e}")
74
+
75
+ # Compute the weighted sum of activations
76
+ cam = np.zeros(activations.shape[1:], dtype=np.float32)
77
+ for i, w in enumerate(weights):
78
+ cam += w * activations[i, :, :]
79
+
80
+ import logging
81
+ logger = logging.getLogger("GradCAM")
82
+ logger.info(f"Raw CAM - Max: {np.max(cam):.4f}, Min: {np.min(cam):.4f}")
83
+
84
+ # Apply ReLU to keep only features that have a positive influence on the target class
85
+ cam = np.maximum(cam, 0)
86
+
87
+ # Normalize the CAM to [0, 1]
88
+ cam = cv2.resize(cam, (input_tensor.shape[3], input_tensor.shape[2]))
89
+
90
+ cam_max = np.max(cam)
91
+ cam_min = np.min(cam)
92
+ logger.info(f"Post-ReLU Resized CAM - Max: {cam_max:.4f}, Min: {cam_min:.4f}")
93
+
94
+ cam = cam - cam_min
95
+ cam = cam / (cam_max + 1e-8)
96
+
97
+ return cam
98
+
99
+ @staticmethod
100
+ def overlay_cam(img_pil: Image.Image, cam: np.ndarray, alpha: float = 0.5) -> Image.Image:
101
+ """
102
+ Overlays the CAM heatmap onto the original image.
103
+
104
+ Args:
105
+ img_pil (PIL.Image.Image): Original image (RGB).
106
+ cam (np.ndarray): Normalized CAM (H, W) in [0, 1].
107
+ alpha (float): Blending factor.
108
+
109
+ Returns:
110
+ PIL.Image.Image: Superimposed image.
111
+ """
112
+ # Ensure cam is same size as image
113
+ if cam.shape != img_pil.size[::-1]:
114
+ cam = cv2.resize(cam, img_pil.size)
115
+
116
+ # Convert PIL to cv2 (RGB to BGR for colormap)
117
+ img_cv2 = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
118
+
119
+ # Convert CAM to 8-bit heatmap
120
+ heatmap = np.uint8(255 * cam)
121
+ heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
122
+
123
+ # Superimpose
124
+ superimposed = cv2.addWeighted(img_cv2, 1 - alpha, heatmap, alpha, 0)
125
+
126
+ # Convert back to PIL
127
+ superimposed = cv2.cvtColor(superimposed, cv2.COLOR_BGR2RGB)
128
+ return Image.fromarray(superimposed)
weights/level3_diabetic.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:487a5efd9faebde7db003ef05380471980ac261d60b87246e1366e3c1cef6e15
3
+ size 18955227
weights/level3_fluid.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e5c681c681b8422af3e03378054a3dff0d45ee7be2df82d7e66a9678c7062a33
3
+ size 18953179
weights/level3_macular.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a426c914029800d55145f3c159519c60d0c01355809c48c35469aa9e0967d86b
3
+ size 18957275
weights/level3_structural.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b9831f160fb3e99904d4669fa62b2219f9c601a95855b75cab6c07b9e71fc280
3
+ size 18955227
weights/level3_vascular.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7af4c43b091c277cab9d55efe9f9991cc7b28d2a741199aba71cd5f03e3bba5
3
+ size 18957275