bs01338's picture
Upload 15 files
3469608 verified
Raw
History Blame Contribute Delete
5.67 kB
"""DR grader: loads the trained EfficientNet-B4 + Swin (CORN) model and grades
a fundus image into one of five diabetic-retinopathy severity classes.
The model architecture and weights are reused from the original DR Grading
project; the weights are downloaded from the public HuggingFace Space on first
use. Inference uses the training-matched preprocessing (crop + CLAHE).
"""
from __future__ import annotations
import torch
import torch.nn as nn
import timm
from torchvision import transforms
from huggingface_hub import hf_hub_download
from src.config import (
DR_WEIGHTS_REPO, DR_WEIGHTS_REPO_TYPE, DR_WEIGHTS_FILE,
EFFICIENTNET_NAME, SWIN_NAME, NUM_CLASSES, FUSION_HIDDEN_DIM, FUSION_DROPOUT,
IMAGE_SIZE, NORM_MEAN, NORM_STD, CLASS_NAMES,
)
from src.preprocessing import preprocess_fundus
class ParallelEfficientNetSwinCORN(nn.Module):
"""EfficientNet-B4 + Swin Transformer parallel backbones with a CORN ordinal head.
Must match the trained architecture exactly (layer names included) so the
saved weights load with strict=True.
"""
def __init__(self, efficientnet_name, swin_name, num_classes,
fusion_hidden_dim=1024, fusion_dropout=0.3, pretrained=False):
super().__init__()
self.efficientnet = timm.create_model(efficientnet_name, pretrained=pretrained, num_classes=0)
self.swin = timm.create_model(swin_name, pretrained=pretrained, num_classes=0)
eff_dim = getattr(self.efficientnet, "num_features", None)
swin_dim = getattr(self.swin, "num_features", None)
if eff_dim is None or swin_dim is None:
raise ValueError("Could not determine backbone feature dimensions.")
fusion_dim = eff_dim + swin_dim
self.fusion = nn.Sequential(
nn.LayerNorm(fusion_dim),
nn.Linear(fusion_dim, fusion_hidden_dim),
nn.GELU(),
nn.Dropout(fusion_dropout),
nn.Linear(fusion_hidden_dim, num_classes - 1), # CORN: K-1 outputs
)
@staticmethod
def _to_vector(feat):
if feat.ndim == 2:
return feat
if feat.ndim == 3:
return feat.mean(dim=1)
if feat.ndim == 4:
if feat.shape[1] > feat.shape[-1] and feat.shape[1] > feat.shape[-2]:
return feat.mean(dim=(2, 3))
return feat.mean(dim=(1, 2))
raise ValueError(f"Unexpected feature shape: {feat.shape}")
def forward(self, x):
eff = self._to_vector(self.efficientnet(x))
swin = self._to_vector(self.swin(x))
return self.fusion(torch.cat([eff, swin], dim=1))
def _clean_state_dict(state_dict):
"""Strip any 'module.' prefixes left by DataParallel during training."""
return {(k[len("module."):] if k.startswith("module.") else k): v
for k, v in state_dict.items()}
_model = None
_device = None
_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(NORM_MEAN, NORM_STD),
])
def load_grader():
"""Load the model + weights once (cached for repeated calls)."""
global _model, _device
if _model is not None:
return _model, _device
_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Downloading DR weights from {DR_WEIGHTS_REPO} (first run only) ...")
weights_path = hf_hub_download(
repo_id=DR_WEIGHTS_REPO, filename=DR_WEIGHTS_FILE, repo_type=DR_WEIGHTS_REPO_TYPE,
)
model = ParallelEfficientNetSwinCORN(
efficientnet_name=EFFICIENTNET_NAME, swin_name=SWIN_NAME,
num_classes=NUM_CLASSES, fusion_hidden_dim=FUSION_HIDDEN_DIM,
fusion_dropout=FUSION_DROPOUT, pretrained=False,
)
state_dict = _clean_state_dict(torch.load(weights_path, map_location=_device))
model.load_state_dict(state_dict, strict=True)
model.to(_device).eval()
_model = model
return _model, _device
def grade_fundus(pil_image):
"""Grade one fundus image.
Returns the grade (0-4), its label, and the cumulative ordinal
probabilities P(y > k). CORN decoding: the K-1 logits are conditional
probabilities, so the cumulative P(y > k) is the cumulative product of
their sigmoids, and the predicted grade is the count of cumulative
probabilities above 0.5 (equivalent to coral_pytorch.corn_label_from_logits).
"""
model, device = load_grader()
processed = preprocess_fundus(pil_image) # training-matched crop + CLAHE
x = _transform(processed).unsqueeze(0).to(device)
with torch.inference_mode():
logits = model(x)
# CORN decoding: the K-1 outputs are CONDITIONAL probabilities
# P(y>k | y>k-1), so the cumulative P(y>k) is the cumulative PRODUCT of
# the sigmoids. The grade is the number of cumulative probs above 0.5.
# This matches coral_pytorch.corn_label_from_logits exactly.
probs = torch.cumprod(torch.sigmoid(logits), dim=1) # P(grade > 0..3)
grade = int((probs > 0.5).sum(dim=1).item())
return {
"grade": grade,
"label": CLASS_NAMES[grade],
"ordinal_probabilities": [round(p, 4) for p in probs.squeeze(0).cpu().tolist()],
"processed_image": processed,
}
if __name__ == "__main__":
# Usage: python -m src.dr_grader <path-to-fundus-image>
import sys
from PIL import Image
if len(sys.argv) < 2:
print("Usage: python -m src.dr_grader <path-to-fundus-image>")
raise SystemExit(1)
result = grade_fundus(Image.open(sys.argv[1]))
print(f"\nGrade {result['grade']} - {result['label']}")
print(f"Ordinal probabilities P(>0..>3): {result['ordinal_probabilities']}")