Spaces:
Sleeping
Sleeping
Fix L1 gatekeeper ResNet-50 compatibility, integrate L1-L2-L3 pipeline and return Base64 GradCAMs
Browse files- app.py +34 -71
- data/__pycache__/transforms.cpython-314.pyc +0 -0
- data/transforms.py +288 -0
- models/__pycache__/__init__.cpython-314.pyc +0 -0
- models/__pycache__/level1_gatekeeper.cpython-314.pyc +0 -0
- models/__pycache__/level2_router.cpython-314.pyc +0 -0
- models/__pycache__/level3_specialist.cpython-314.pyc +0 -0
- models/level1_gatekeeper.py +13 -0
- models/level3_specialist.py +236 -0
- requirements.txt +3 -0
- scripts/inference_pipeline.py +258 -0
- utils/__pycache__/gradcam.cpython-314.pyc +0 -0
- utils/gradcam.py +128 -0
- weights/level3_diabetic.pth +3 -0
- weights/level3_fluid.pth +3 -0
- weights/level3_macular.pth +3 -0
- weights/level3_structural.pth +3 -0
- weights/level3_vascular.pth +3 -0
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
|
| 13 |
-
from models.level2_router import DiseaseRouterModel
|
| 14 |
|
| 15 |
app = FastAPI(title="OCT Image Classification Pipeline")
|
| 16 |
|
| 17 |
-
#
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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 |
-
|
| 92 |
-
|
| 93 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
-
|
| 96 |
-
|
| 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
|