import os import time import pickle import warnings from typing import Dict, Optional, Tuple from huggingface_hub import hf_hub_download import cv2 import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from PIL import Image as PILImage import timm import albumentations as A from albumentations.pytorch import ToTensorV2 from pytorch_grad_cam import GradCAMPlusPlus from pytorch_grad_cam.utils.image import show_cam_on_image import gradio as gr warnings.filterwarnings("ignore") # ============================================================================= # Constants & Config # ============================================================================= DEVICE = "cuda" if torch.cuda.is_available() else "cpu" IMG_SIZE = 512 NUM_CLASSES = 5 BACKBONE = "tf_efficientnetv2_m" FV_BACKBONE = "convnext_tiny.fb_in22k" GRADE_MAP = {0: "No DR", 1: "Mild DR", 2: "Moderate DR", 3: "Severe DR", 4: "Proliferative DR"} GRADE_COLORS = ["#2ecc71", "#f1c40f", "#e67e22", "#e74c3c", "#8e44ad"] SEVERITY_ICONS = ["π’", "π‘", "π ", "π΄", "π£"] CLINICAL_ACTION = [ "No DR detected. Routine annual screening recommended.", "Mild NPDR. Optimise glycaemic and blood-pressure control. Follow up in 12 months.", "Moderate NPDR. Ophthalmology referral within 3β6 months.", "Severe NPDR. Urgent ophthalmology referral. Consider anti-VEGF or laser assessment.", "Proliferative DR. URGENT referral β high blindness risk. Same-week appointment required.", ] IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] STRONG_HEUR = {"red_dominance": 0.55, "disc_coverage": 0.25, "edge_density": 0.008} WEAK_HEUR = {"red_dominance": 0.30, "disc_coverage": 0.12, "edge_density": 0.006} # ============================================================================= # Model Definitions # ============================================================================= class GeM(nn.Module): def __init__(self, p: float = 3.0, eps: float = 1e-6): super().__init__() self.p = nn.Parameter(torch.tensor(p)) self.eps = eps def forward(self, x: torch.Tensor) -> torch.Tensor: return F.adaptive_avg_pool2d(x.clamp(min=self.eps).pow(self.p), 1).pow(1.0 / self.p) class CoralHead(nn.Module): def __init__(self, in_features: int, num_classes: int): super().__init__() self.linear = nn.Linear(in_features, 1, bias=False) self.bias = nn.Parameter(torch.zeros(num_classes - 1)) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.linear(x) + self.bias class DRHead(nn.Module): def __init__(self, in_features: int, num_classes: int = 5, dropout: float = 0.3): super().__init__() self.net = nn.Sequential( nn.Linear(in_features, 512), nn.BatchNorm1d(512), nn.SiLU(inplace=True), nn.Dropout(dropout), nn.Linear(512, 256), nn.BatchNorm1d(256), nn.SiLU(inplace=True), nn.Dropout(dropout / 2), nn.Linear(256, num_classes), ) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.net(x) class DRModel(nn.Module): def __init__(self, backbone: str = BACKBONE, num_classes: int = 5, pretrained: bool = False): super().__init__() self.backbone = timm.create_model(backbone, pretrained=pretrained, num_classes=0, global_pool="") in_feat = self.backbone.num_features self.pool = GeM(p=3.0) self.head_cls = DRHead(in_feat, num_classes) self.head_coral = CoralHead(in_feat, num_classes) def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: feat = self.pool(self.backbone(x)).flatten(1) return {"logits": self.head_cls(feat), "coral": self.head_coral(feat)} class FundusValidator(nn.Module): def __init__(self, backbone_name: str = FV_BACKBONE): super().__init__() self.backbone = timm.create_model(backbone_name, pretrained=False, num_classes=0, global_pool="avg") for p in self.backbone.parameters(): p.requires_grad = False with torch.no_grad(): feat_dim = self.backbone(torch.zeros(1, 3, 224, 224)).shape[1] self.head = nn.Sequential(nn.Linear(feat_dim, 256), nn.GELU(), nn.Dropout(0.3), nn.Linear(256, 2)) def forward(self, x: torch.Tensor) -> torch.Tensor: with torch.no_grad(): feats = self.backbone(x) return self.head(feats) # ============================================================================= # Preprocessing # ============================================================================= _clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) def apply_clahe_lab(rgb: np.ndarray) -> np.ndarray: lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB) lab[:, :, 0] = _clahe.apply(lab[:, :, 0]) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) def retinal_mask(rgb: np.ndarray) -> np.ndarray: gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) _, m = cv2.threshold(gray, 15, 255, cv2.THRESH_BINARY) k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) return cv2.morphologyEx(cv2.morphologyEx(m, cv2.MORPH_CLOSE, k), cv2.MORPH_OPEN, k) def crop_retinal_disc(rgb: np.ndarray, pad: int = 10) -> np.ndarray: contours, _ = cv2.findContours(retinal_mask(rgb), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return rgb x, y, w, h = cv2.boundingRect(max(contours, key=cv2.contourArea)) x, y = max(0, x - pad), max(0, y - pad) x2, y2 = min(rgb.shape[1], x + w + 2 * pad), min(rgb.shape[0], y + h + 2 * pad) return rgb[y:y2, x:x2] def fundus_heuristics(rgb: np.ndarray) -> Dict[str, float]: r, g, b = rgb[..., 0].astype(np.float32), rgb[..., 1].astype(np.float32), rgb[..., 2].astype(np.float32) bright = (r > 15) | (g > 15) | (b > 15) n_bright = int(bright.sum()) red_dom = float(((r > g) & (r > b))[bright].mean()) if n_bright > 100 else float(((r > g) & (r > b)).mean()) disc_cov = float(retinal_mask(rgb).mean() / 255.0) gx = cv2.Sobel(cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY), cv2.CV_32F, 1, 0, ksize=3) gy = cv2.Sobel(cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY), cv2.CV_32F, 0, 1, ksize=3) mag = np.sqrt(gx**2 + gy**2) edge_den = float(mag[bright].mean() / 255.0) if n_bright > 100 else float(mag.mean() / 255.0) return {"red_dominance": red_dom, "disc_coverage": disc_cov, "edge_density": edge_den} # ============================================================================= # Model Loading β downloads weights from HF repo at runtime # ============================================================================= SPACE_REPO = "Gokul-G1/Project2-Models" def _get_weight(filename: str) -> str: """Return local path if available, otherwise download from HF Space repo.""" if os.path.exists(filename): return filename print(f" Downloading {filename} from {SPACE_REPO}β¦") return hf_hub_download( repo_id=SPACE_REPO, filename=filename, repo_type="model", ) def load_dr_model(): m = DRModel(BACKBONE, NUM_CLASSES, pretrained=False).to(DEVICE) path = _get_weight("best_model.pt") ckpt = torch.load(path, map_location=DEVICE, weights_only=False) state = ckpt.get("model_state", ckpt) m.load_state_dict({k: v for k, v in state.items() if k in m.state_dict()}, strict=False) return m.eval() def load_fv_model(): m = FundusValidator(FV_BACKBONE).to(DEVICE) path = _get_weight("fundus_validator.pt") ckpt = torch.load(path, map_location=DEVICE, weights_only=False) head_state = ckpt.get("head_state_dict", ckpt) m.head.load_state_dict(head_state, strict=False) return m.eval() def load_calib(): path = _get_weight("calibration.pkl") if os.path.exists("calibration.pkl") or True else None try: path = _get_weight("calibration.pkl") with open(path, "rb") as f: return pickle.load(f) except Exception: return None print("Loading modelsβ¦") model = load_dr_model() fv_model = load_fv_model() calib = load_calib() print(f" DR model loaded | FV model loaded | calib={'yes' if calib else 'no'} | device={DEVICE}") # Transforms inf_tfm = A.Compose([A.Resize(IMG_SIZE, IMG_SIZE), A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ToTensorV2()]) fv_tfm = A.Compose([A.Resize(224, 224), A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ToTensorV2()]) # Grad-CAM setup class _SingleLogit(nn.Module): def __init__(self, inner): super().__init__(); self.inner = inner def forward(self, x): return self.inner(x)["logits"] _cam_model = _SingleLogit(model).to(DEVICE).eval() try: _target_layers = [model.backbone.blocks[-1]] cam_obj = GradCAMPlusPlus(model=_cam_model, target_layers=_target_layers) GRADCAM_OK = True except Exception as e: print(f"Grad-CAM init failed: {e}") GRADCAM_OK = False # ============================================================================= # Fundus Gating # ============================================================================= def gate_fundus(pil_img: PILImage.Image) -> Tuple[bool, float, Dict, str]: rgb_raw = np.array(pil_img.convert("RGB")) try: rgb = apply_clahe_lab(crop_retinal_disc(rgb_raw)) except Exception: rgb = rgb_raw h = fundus_heuristics(rgb) x = fv_tfm(image=rgb)["image"].unsqueeze(0).to(DEVICE) with torch.no_grad(): p = float(F.softmax(fv_model(x), dim=1).cpu().numpy()[0][1]) strong_ok = all(h[k] >= v for k, v in STRONG_HEUR.items()) weak_ok = all(h[k] >= v for k, v in WEAK_HEUR.items()) if strong_ok: return True, p, h, "Accepted (strong visual signatures)." if p >= 0.75: return True, p, h, f"Accepted (validator: {p*100:.1f}%)." if p >= 0.50 and weak_ok: return True, p, h, "Accepted (validator + heuristics)." if h["disc_coverage"] >= 0.25 and p >= 0.35: return True, p, h, "Accepted (clear retinal disc)." if p >= 0.90: return True, p, h, "Accepted (high validator confidence)." return False, p, h, "Not a fundus image. Please upload a colour retinal fundus photograph." # ============================================================================= # Grad-CAM Rendering & Lesion ROIs # ============================================================================= def render_cam(vis: np.ndarray, tensor: torch.Tensor) -> Optional[np.ndarray]: if not GRADCAM_OK: return None try: gc = cam_obj(input_tensor=tensor, targets=None)[0, :] gc = cv2.resize(gc, (vis.shape[1], vis.shape[0])) overlay = show_cam_on_image(vis.astype(np.float32) / 255.0, gc, use_rgb=True) return (overlay * 255).astype(np.uint8) except Exception: return None def render_rois(vis: np.ndarray, tensor: torch.Tensor) -> Optional[np.ndarray]: if not GRADCAM_OK: return None try: gc = cam_obj(input_tensor=tensor, targets=None)[0, :] gc = cv2.resize(gc, (vis.shape[1], vis.shape[0])) _, thresh = cv2.threshold((gc * 255).astype(np.uint8), 127, 255, cv2.THRESH_BINARY) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) roi_img = vis.copy() cv2.drawContours(roi_img, contours, -1, (255, 0, 0), 2) # Highlight in red return roi_img except Exception: return None # ============================================================================= # Inference # ============================================================================= def predict(uploaded): t0 = time.time() if uploaded is None: return None, None, None, "
Upload a fundus image to begin.
" pil_img = PILImage.fromarray(uploaded.astype(np.uint8)) accepted, p_fundus, heur, reason = gate_fundus(pil_img) if not accepted: rd = heur.get("red_dominance", 0) dc = heur.get("disc_coverage", 0) ed = heur.get("edge_density", 0) html = f"""{reason}
{str(e)}
Please try uploading the image again.