| 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") |
|
|
| |
| |
| |
| 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} |
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
| _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} |
|
|
|
|
| |
| |
| |
| 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}") |
|
|
| |
| 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()]) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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." |
|
|
|
|
| |
| |
| |
| 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) |
| return roi_img |
| except Exception: |
| return None |
|
|
|
|
| |
| |
| |
| def predict(uploaded): |
| t0 = time.time() |
| if uploaded is None: |
| return None, None, None, "<p style='color:var(--body-text-color);padding:20px'>Upload a fundus image to begin.</p>" |
|
|
| 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""" |
| <div class="error-box"> |
| <h3 style="margin:0 0 8px">β οΈ Image Rejected</h3> |
| <p style="margin:0">{reason}</p> |
| <div style="margin-top:12px;font-size:12px;opacity:0.8"> |
| <strong>Validator score:</strong> {p_fundus*100:.1f}% (need β₯75%)<br> |
| <strong>Red dominance:</strong> {rd:.3f} (need β₯{WEAK_HEUR['red_dominance']:.2f}) |
| {'β' if rd >= WEAK_HEUR['red_dominance'] else 'β'}<br> |
| <strong>Disc coverage:</strong> {dc:.3f} (need β₯{WEAK_HEUR['disc_coverage']:.2f}) |
| {'β' if dc >= WEAK_HEUR['disc_coverage'] else 'β'}<br> |
| <strong>Edge density:</strong> {ed:.4f} (need β₯{WEAK_HEUR['edge_density']:.3f}) |
| {'β' if ed >= WEAK_HEUR['edge_density'] else 'β'} |
| </div> |
| </div>""" |
| return None, None, None, html |
|
|
| |
| rgb = np.array(pil_img.convert("RGB")) |
| try: |
| rgb = crop_retinal_disc(rgb) |
| rgb = apply_clahe_lab(rgb) |
| except Exception: |
| pass |
| vis = cv2.resize(rgb, (320, 320)) |
| tensor = inf_tfm(image=cv2.resize(rgb, (IMG_SIZE, IMG_SIZE)))["image"].unsqueeze(0).to(DEVICE) |
|
|
| |
| with torch.no_grad(): |
| views = [tensor, |
| torch.flip(tensor, dims=[-1]), |
| torch.flip(tensor, dims=[-2]), |
| torch.rot90(tensor, 2, dims=(-2, -1))] |
| logits_all, coral_all = [], [] |
| for v in views: |
| out = model(v) |
| logits_all.append(out["logits"].cpu().numpy()) |
| coral_all.append(torch.sigmoid(out["coral"]).cpu().numpy()) |
|
|
| logits_mean = np.mean(logits_all, axis=0) |
| coral_mean = np.mean(coral_all, axis=0) |
|
|
| |
| if calib is not None: |
| try: |
| probs = calib.apply(logits_mean)[0] |
| except Exception: |
| ex = np.exp(logits_mean - logits_mean.max()) |
| probs = (ex / ex.sum())[0] |
| else: |
| ex = np.exp(logits_mean - logits_mean.max()) |
| probs = (ex / ex.sum())[0] |
|
|
| |
| argmax_pred = int(probs.argmax()) |
| coral_pred = int((coral_mean[0] > 0.5).sum()) |
| coral_conf = float(coral_mean[0, coral_pred - 1]) if coral_pred > 0 else 1.0 |
| pred = coral_pred if coral_conf > float(probs[argmax_pred]) else argmax_pred |
| pred = max(0, min(4, pred)) |
| conf = float(probs[pred]) |
|
|
| |
| cam_img = render_cam(vis, tensor) |
| roi_img = render_rois(vis, tensor) |
|
|
| proc_time = time.time() - t0 |
|
|
| |
| bar_html = "" |
| for i, (lbl, col) in enumerate(zip(GRADE_MAP.values(), GRADE_COLORS)): |
| w = float(probs[i]) * 100 |
| weight = "700" if i == pred else "400" |
| bar_html += f""" |
| <div style="margin-bottom:6px"> |
| <div style="display:flex;justify-content:space-between;font-size:13px;font-weight:{weight}"> |
| <span>{SEVERITY_ICONS[i]} {lbl}</span><span>{w:.1f}%</span> |
| </div> |
| <div style="height:6px;background:var(--border-color-primary);border-radius:3px;overflow:hidden"> |
| <div style="width:{w}%;background:{col};height:100%"></div> |
| </div> |
| </div>""" |
|
|
| html = f""" |
| <div class="report-card"> |
| <!-- Grade header --> |
| <div class="report-header"> |
| <span style="font-size:52px">{SEVERITY_ICONS[pred]}</span> |
| <div> |
| <div style="font-size:22px;font-weight:700;color:{GRADE_COLORS[pred]}">{GRADE_MAP[pred]}</div> |
| <div style="font-size:13px;opacity:0.7">Grade {pred} / 4 Β· ICDR Scale</div> |
| </div> |
| </div> |
| |
| <!-- Confidence bars --> |
| <div class="report-bars"> |
| <div style="font-size:13px;font-weight:600;margin-bottom:8px">Grade Probabilities</div> |
| {bar_html} |
| </div> |
| |
| <!-- Clinical action --> |
| <div class="action-box" style="border-left-color: {GRADE_COLORS[pred]}"> |
| <div style="font-size:12px;font-weight:600;margin-bottom:4px;opacity:0.8">CLINICAL RECOMMENDATION</div> |
| <div style="font-size:14px">{CLINICAL_ACTION[pred]}</div> |
| </div> |
| |
| <!-- Metrics footer --> |
| <div style="display:flex;justify-content:space-between;font-size:11px;opacity:0.6; |
| border-top:1px solid var(--border-color-primary);padding-top:10px"> |
| <span>β± Processing: <strong>{proc_time:.2f}s</strong></span> |
| <span>π Validator: <strong>{p_fundus*100:.1f}%</strong></span> |
| <span>π― Confidence: <strong>{conf*100:.1f}%</strong></span> |
| </div> |
| </div>""" |
|
|
| return vis, cam_img, roi_img, html |
|
|
|
|
| def safe_predict(uploaded): |
| """Wrapper to catch network/processing errors and prevent crashes.""" |
| try: |
| return predict(uploaded) |
| except Exception as e: |
| err_msg = f"<div class='error-box'><h3>β οΈ Processing Error</h3><p>{str(e)}</p><p>Please try uploading the image again.</p></div>" |
| return None, None, None, err_msg |
|
|
|
|
| |
| |
| |
| premium_theme = gr.themes.Default( |
| primary_hue="indigo", |
| secondary_hue="blue", |
| neutral_hue="slate", |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], |
| ).set( |
| body_background_fill="var(--background-fill-primary)", |
| body_background_fill_dark="var(--background-fill-primary)", |
| block_background_fill="var(--block-background-fill)", |
| block_border_width="1px", |
| block_shadow="0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", |
| button_primary_background_fill="*primary_600", |
| button_primary_background_fill_hover="*primary_700", |
| button_primary_text_color="white", |
| ) |
|
|
| css = """ |
| .report-card { background: var(--block-background-fill); padding: 20px; border-radius: 14px; box-shadow: 0 4px 16px rgba(0,0,0,.08); font-family: 'Inter', sans-serif; line-height: 1.5; color: var(--body-text-color); } |
| .report-header { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; } |
| .report-bars { background: var(--background-fill-secondary); border-radius: 8px; padding: 12px; margin-bottom: 14px; } |
| .action-box { padding: 12px; background: var(--background-fill-secondary); border-left: 4px solid var(--primary-500); border-radius: 6px; margin-bottom: 14px; } |
| .hero-banner { text-align: center; padding: 28px 20px 22px; background: linear-gradient(135deg, #1A73E8 0%, #0D47A1 100%); color: #fff; border-radius: 16px; margin-bottom: 20px; } |
| .error-box { padding: 20px; border-radius: 10px; background: rgba(220, 38, 38, 0.1); color: #ef4444; border: 1px solid rgba(220, 38, 38, 0.2); font-family: 'Inter', sans-serif; } |
| """ |
|
|
| with gr.Blocks(theme=premium_theme, css=css, title="DR Grading AI β Clinical Decision Support") as demo: |
|
|
| gr.HTML(""" |
| <div class="hero-banner"> |
| <div style="font-size:2.4em;font-weight:800;letter-spacing:-0.5px"> |
| π Diabetic Retinopathy Grading |
| </div> |
| <div style="margin-top:8px;opacity:.88;font-size:1.05em"> |
| EfficientNetV2-M Β· CLAHE Β· CORAL Ordinal Β· Grad-CAM++ Β· Lesion ROIs |
| </div> |
| <div style="margin-top:6px;opacity:.7;font-size:.85em"> |
| For research & educational use only β not a medical device |
| </div> |
| </div> |
| """) |
|
|
| with gr.Row(equal_height=True): |
| |
| with gr.Column(scale=1, min_width=340): |
| inp = gr.Image( |
| label="Upload Retinal Fundus Photograph", |
| type="numpy", |
| height=380, |
| sources=["upload", "clipboard"], |
| ) |
| with gr.Row(): |
| btn_clear = gr.Button("π Clear", variant="secondary", size="sm") |
| btn_analyze = gr.Button("π¬ Analyze", variant="primary", size="lg") |
|
|
| gr.Markdown(""" |
| **How to use** |
| 1. Upload a colour fundus photograph (JPEG / PNG) |
| 2. Click **Analyze** β the AI validates it first |
| 3. Review the grade, heatmap, and recommendation |
| |
| > *Grades 0β4 follow the International Clinical DR (ICDR) severity scale.* |
| """) |
|
|
| |
| with gr.Column(scale=1, min_width=340): |
| with gr.Row(): |
| out_vis = gr.Image(label="Enhanced Fundus (CLAHE)", interactive=False, height=200) |
| out_cam = gr.Image(label="Pathology Heatmap (Grad-CAM++)", interactive=False, height=200) |
| out_roi = gr.Image(label="Detected Lesion ROIs", interactive=False, height=200) |
| out_report = gr.HTML() |
|
|
| |
| btn_analyze.click( |
| fn=safe_predict, |
| inputs=inp, |
| outputs=[out_vis, out_cam, out_roi, out_report], |
| ) |
| btn_clear.click( |
| fn=lambda: (None, None, None, None, ""), |
| inputs=[], |
| outputs=[inp, out_vis, out_cam, out_roi, out_report], |
| ) |
|
|
| if __name__ == "__main__": |
| demo.queue(default_concurrency_limit=5) |
| demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True) |
|
|