Spaces:
Running on Zero
Running on Zero
Download app.py from egyx/RetinaFlow: direct link, hf CLI and curl.
- Browser
- Download file 43.7 kB
-
https://huggingface.co/spaces/egyx/RetinaFlow/resolve/main/app.py
- Command line
-
hf download hf://spaces/egyx/RetinaFlow/app.py
-
curl -L -o app.py https://huggingface.co/spaces/egyx/RetinaFlow/resolve/main/app.py
43.7 kB
| """ | |
| RetinaFlow — Dual-Model Retinal Disease Diagnostic System | |
| Author: Mohamed Mostafa Elbasyouni (markegyptian55-cloud) | |
| Repository: https://github.com/markegyptian55-cloud/RetinaFlow | |
| Space: https://huggingface.co/spaces/egyx/RetinaFlow | |
| License: MIT | |
| """ | |
| import os | |
| import sys | |
| import time | |
| import hashlib | |
| from typing import Dict, Tuple, Optional, Any | |
| import numpy as np | |
| from PIL import Image | |
| try: | |
| import spaces | |
| except ImportError: | |
| class _MockSpaces: | |
| def GPU(fn=None, **kwargs): | |
| if fn is not None and callable(fn): | |
| return fn | |
| return lambda f: f | |
| spaces = _MockSpaces() | |
| import onnxruntime as ort | |
| import gradio as gr | |
| # ============================================================================ | |
| # CONFIGURATION & METADATA | |
| # ============================================================================ | |
| HF_REPO_ID = "egyx/RetinaFlow" | |
| CLASS_NAMES = [ | |
| 'AMD', 'Cataract', 'Diabetic Retinopathy', 'Glaucoma', | |
| 'Hypertension', 'Myopia', 'Normal', 'Others' | |
| ] | |
| # Clinical data dictionary with full English & Arabic definitions | |
| CLINICAL_DATA = { | |
| 'Normal': { | |
| 'en': { | |
| 'name': 'Normal Retinal Fundus', | |
| 'badge': '🟢 ROUTINE / LOW RISK', | |
| 'desc': 'Optic nerve head, macula lutea, and retinal microvasculature exhibit physiological anatomical features with no visible signs of retinopathy or optic neuropathy.' | |
| }, | |
| 'ar': { | |
| 'name': 'شبكية سليمة وطبيعية', | |
| 'badge': '🟢 روتيني / طبيعي (منخفض الخطورة)', | |
| 'desc': 'المظهر العام لقاع العين سليم. القرص البصري، البقعة الصفراء، والتروية الشبكية ضمن المعايير التشريحية السليمة تماماً.' | |
| }, | |
| 'color': '#10b981', | |
| 'icon': '✅', | |
| }, | |
| 'AMD': { | |
| 'en': { | |
| 'name': 'Age-Related Macular Degeneration (AMD)', | |
| 'badge': '🟡 SPECIALIST REVIEW (MODERATE RISK)', | |
| 'desc': 'Degeneration of the central macular region impacting fine visual acuity. Prompt vitreoretinal specialist referral recommended to evaluate dry vs. wet progression.' | |
| }, | |
| 'ar': { | |
| 'name': 'ضمور البقعة الصفراء المرتبط بالعمر (AMD)', | |
| 'badge': '🟡 متابعة تخصصية (متوسط الخطورة)', | |
| 'desc': 'تنكس في منطقة اللطاخة الصفراء المسؤولة عن حدة الرؤية المركزية. يستدعي فحصاً دورياً مع أخصائي الشبكية لتفادي تطور الحالة.' | |
| }, | |
| 'color': '#f59e0b', | |
| 'icon': '👁️', | |
| }, | |
| 'Cataract': { | |
| 'en': { | |
| 'name': 'Cataract', | |
| 'badge': '🟡 OPHTHALMIC EVALUATION (MODERATE RISK)', | |
| 'desc': 'Progressive opacification of the ocular crystalline lens leading to visual degradation. Evaluated for surgical phacoemulsification and intraocular lens implantation.' | |
| }, | |
| 'ar': { | |
| 'name': 'المياه البيضاء (عتامة العدسة)', | |
| 'badge': '🟡 تقييم بصري وجراحي (متوسط الخطورة)', | |
| 'desc': 'عتامة في عدسة العين الطبيعية تؤدي لضبابية الرؤية وصعوبة فحص القاع بوضوح. تُعالج بنجاح عبر استبدال العدسة جراحياً.' | |
| }, | |
| 'color': '#f59e0b', | |
| 'icon': '🔍', | |
| }, | |
| 'Diabetic Retinopathy': { | |
| 'en': { | |
| 'name': 'Diabetic Retinopathy', | |
| 'badge': '🔴 URGENT CLINICAL REVIEW (HIGH RISK)', | |
| 'desc': 'Retinal microvascular pathology secondary to diabetes mellitus, with signs of microaneurysms, hemorrhages, hard exudates, or neovascularization. Dilated ophthalmic exam indicated.' | |
| }, | |
| 'ar': { | |
| 'name': 'اعتلال الشبكية السكري (DR)', | |
| 'badge': '🔴 تقييم سريري عاجل (عالي الخطورة)', | |
| 'desc': 'أضرار وعائية دقيقة ناجمة عن السكري (نزيف شبكي، ارتشاحات دهنية، أو نمو أوعية دموية جديدة). يتطلب فحصاً وتدخلاً عاجلاً.' | |
| }, | |
| 'color': '#ef4444', | |
| 'icon': '🩸', | |
| }, | |
| 'Glaucoma': { | |
| 'en': { | |
| 'name': 'Glaucoma (Optic Neuropathy)', | |
| 'badge': '🔴 URGENT CLINICAL REVIEW (HIGH RISK)', | |
| 'desc': 'Progressive optic neuropathy with neuroretinal rim thinning and increased optic disc cupping. Urgent intraocular pressure (IOP) tonometry and visual field testing required.' | |
| }, | |
| 'ar': { | |
| 'name': 'المياه الزرقاء (اعتلال العصب البصري)', | |
| 'badge': '🔴 تقييم سريري عاجل (عالي الخطورة)', | |
| 'desc': 'اعتلال تدريجي في ألياف العصب البصري وزيادة تقعر القرص البصري. يتطلب قياس ضغط العين وفحص المجال البصري لتفادي فقدان الرؤية.' | |
| }, | |
| 'color': '#ef4444', | |
| 'icon': '⚠️', | |
| }, | |
| 'Hypertension': { | |
| 'en': { | |
| 'name': 'Hypertensive Retinopathy', | |
| 'badge': '🚨 RARE CRITICAL FINDING (URGENT)', | |
| 'desc': 'Retinal vascular alterations caused by systemic arterial hypertension (arteriolar narrowing, AV nicking, or flame hemorrhages). Immediate cardiovascular and BP triage required.' | |
| }, | |
| 'ar': { | |
| 'name': 'اعتلال الشبكية الناتج عن ضغط الدم (HT)', | |
| 'badge': '🚨 حالة حرجة ونادرة (أولوية عاجلة)', | |
| 'desc': 'تغيرات وعائية شريانية ناتجة عن ارتفاع ضغط الدم (تصلب شرياني، نزف لهبي). يستدعي ضبطاً فورياً لضغط الدم ومتابعة قلبية وبصرية.' | |
| }, | |
| 'color': '#ff7849', | |
| 'icon': '❤️🩹', | |
| }, | |
| 'Myopia': { | |
| 'en': { | |
| 'name': 'Pathological Myopia', | |
| 'badge': '🟡 PERIODIC MONITORING (MODERATE RISK)', | |
| 'desc': 'High axial myopia associated with posterior staphyloma and chorioretinal thinning. Periodic peripheral fundus examination advised to monitor retinal detachment risk.' | |
| }, | |
| 'ar': { | |
| 'name': 'قصر النظر المرضي / الشديد', | |
| 'badge': '🟡 فحص شبكية دوري (متوسط الخطورة)', | |
| 'desc': 'استطالة محورية في مقلة العين مع ترقق الغشاء الشبكي وتغيرات في المشيمية. يستلزم مراقبة دورية لتجنب مخاطر تمزق أو انفصال الشبكية.' | |
| }, | |
| 'color': '#38bdf8', | |
| 'icon': '👓', | |
| }, | |
| 'Others': { | |
| 'en': { | |
| 'name': 'Other Retinal Lesions', | |
| 'badge': '🟡 CLINICAL CORRELATION (MODERATE RISK)', | |
| 'desc': 'Features suggestive of secondary or alternative fundus anomalies (e.g., epiretinal membrane, retinal vein occlusion, drusen). Direct ophthalmological examination recommended.' | |
| }, | |
| 'ar': { | |
| 'name': 'آفات / اعتلالات شبكية أخرى', | |
| 'badge': '🟡 فحص سريري إضافي (مراجعة مطلوبة)', | |
| 'desc': 'تم رصد شذوذ أو علامات لآفات شبكية أخرى (مثل الأغشية التليفية، أو انسداد وريدي، أو دروزن). يُوصى بالمطابقة السريرية المباشرة.' | |
| }, | |
| 'color': '#a855f7', | |
| 'icon': '🔬', | |
| } | |
| } | |
| MODEL_CONFIGS = { | |
| "General-Purpose (ResNet-50)": { | |
| "rel_path": "models/resnet50_m5a.onnx", | |
| "input_size": 224, | |
| "name": "ResNet-50 (M5a)", | |
| "badge": "🏆 Highest Overall Accuracy", | |
| "badge_ar": "🏆 أعلى دقة عامة", | |
| "blurb_en": "Optimized for overall classification across all 8 classes (71.7% Accuracy, Macro-F1 0.60).", | |
| "blurb_ar": "الأفضل للدقة الإجمالية الشاملة عبر كافة الفئات الـ 8 (دقة 71.7%، Macro-F1 0.60).", | |
| }, | |
| "High-Sensitivity (EfficientNet-B5)": { | |
| "rel_path": "models/efficientnet_b5_m6.onnx", | |
| "input_size": 300, | |
| "name": "EfficientNet-B5 (M6)", | |
| "badge": "🎯 High Hypertension Recall", | |
| "badge_ar": "🎯 حساسية فائقة للضغط", | |
| "blurb_en": "Specialized to screen rare, high-risk conditions — 38.5% Hypertension recall (vs 15.4% for general model).", | |
| "blurb_ar": "مخصص لالتقاط الحالات الحرجة النادرة مثل اعتلال ضغط الدم (38.5% Recall مقابل 15.4%).", | |
| }, | |
| } | |
| UI_TEXTS = { | |
| "en": { | |
| "lang_btn": "🌐 العربية", | |
| "top_status": "● Models ready · ONNX CPU", | |
| "title": "Retina<span style='color: #ff7849;'>Flow</span>", | |
| "subtitle": "Dual-Model Deep Learning Diagnostic System for Retinal Fundus Photography", | |
| "acc_val": "71.7%", | |
| "acc_lbl": "TEST ACCURACY", | |
| "ht_val": "38.5%", | |
| "ht_lbl": "HT RECALL (2.5×)", | |
| "accordion_title": "ℹ️ Model Architecture & Clinical Trade-Off Guide", | |
| "tradeoff_md": """### ⚖️ Why RetinaFlow Uses a Dual-Champion Architecture | |
| Our clinical dataset aggregates **11,839 fundus images** with extreme class skew: | |
| * **Hypertension** is ultra-rare (only 88 total images, a 53.4× imbalance vs Normal). | |
| * **ResNet-50 (M5a):** General-purpose champion with highest overall accuracy (**71.7%**) and balanced Macro-F1 (0.60), but 15.4% recall on Hypertension. | |
| * **EfficientNet-B5 (M6):** High-sensitivity champion achieving **38.5% recall on Hypertension** (2.5× higher), catching critical rare cases at the expense of lower overall accuracy (62.6%). | |
| * **Dual Consensus Mode:** Evaluates both engines concurrently and alerts the practitioner if a rare condition is detected. | |
| *Disclaimer: For research and educational decision-support only. Not an FDA/CE-certified diagnostic device.*""", | |
| "card1_num": "01", | |
| "card1_title": "Fundus image", | |
| "upload_label": "Upload Retinal Fundus Photograph", | |
| "mode_label": "Diagnostic Engine Mode", | |
| "mode_choices": [ | |
| "⚡ Dual Consensus (Both Models)", | |
| "General-Purpose (ResNet-50)", | |
| "High-Sensitivity (EfficientNet-B5)", | |
| ], | |
| "mode_blurb": "**Dual Consensus Mode:** Runs both ResNet-50 and EfficientNet-B5 in parallel. ResNet-50 provides overall diagnostic precision, while EfficientNet-B5 screens for rare, high-risk pathologies.", | |
| "btn_analyze": "⛶ Analyze image", | |
| "btn_reset": "🔄 Reset", | |
| "privacy_note": "🔒 Runs locally on device via ONNX. The image never leaves your machine.", | |
| "examples_label": "Select a sample fundus image to test", | |
| "card2_num": "02", | |
| "card2_title": "Model assessment", | |
| "placeholder_html": """<div style="text-align: center; padding: 48px 20px; color: #808593;"> | |
| <div style="font-size: 2.8rem; margin-bottom: 12px; opacity: 0.6;">👁️</div> | |
| <h3 style="color: #ffffff; font-size: 1.15rem; margin-bottom: 6px; font-weight: 600;">Awaiting Fundus Image</h3> | |
| <p style="font-size: 0.88rem; max-width: 320px; margin: 0 auto; color: #5a5e6b;"> | |
| Upload a fundus photo or pick one of the sample images, then click <strong>Analyze image</strong>. | |
| </p> | |
| </div>""", | |
| "label_m1": "Primary Probabilities (ResNet-50)", | |
| "label_m2": "High-Sensitivity Probabilities (EfficientNet-B5)", | |
| "footer_text": "RetinaFlow Research Platform • Built by Mohamed Mostafa Elbasyouni • GitHub: <a href='https://github.com/markegyptian55-cloud/RetinaFlow' target='_blank' style='color:#ff7849;text-decoration:none;'>markegyptian55-cloud/RetinaFlow</a> • Research & Educational Demonstration Only." | |
| }, | |
| "ar": { | |
| "lang_btn": "🌐 English", | |
| "top_status": "● النماذج جاهزة · ONNX CPU", | |
| "title": "Retina<span style='color: #ff7849;'>Flow</span>", | |
| "subtitle": "نظام الذكاء الاصطناعي السريري لتشخيص أمراض شبكية العين عبر صور قاع العين", | |
| "acc_val": "71.7%", | |
| "acc_lbl": "دقة الاختبار الإجمالية", | |
| "ht_val": "38.5%", | |
| "ht_lbl": "حساسية التقاط الضغط (2.5×)", | |
| "accordion_title": "ℹ️ دليل بنية النماذج والمفاضلة السريرية (Model Trade-Off Guide)", | |
| "tradeoff_md": """### ⚖️ لماذا يدمج RetinaFlow نموذجين بطلين معاً؟ | |
| تم تدريب النماذج على **11,839 صورة سريرية حقيقية**، حيث تعاني البيانات من اختلال حاد في التوزيع: | |
| * فئة **ارتفاع ضغط الدم (Hypertension)** نادرة جداً (88 حالة فقط في كامل قاعدة البيانات). | |
| * **ResNet-50 (M5a):** بطل الدقة الإجمالية (**71.7%**)، ولكنه يحقق 15.4% فقط في التقاط فئة الضغط. | |
| * **EfficientNet-B5 (M6):** بطل الحساسية العالية بحساسية تفوق **38.5%** للضغط (2.5 ضعف). | |
| * **وضع المقارنة المزدوج:** يُشغل النموذجين بالتوازي ويرصد أي تباين تشخيصي للحالات النادرة. | |
| *إخلاء مسؤولية: للأغراض البحثية والتعليمية والدعم التشخيصي فقط. ليس جهازاً طبياً معتمداً.*""", | |
| "card1_num": "01", | |
| "card1_title": "صورة قاع العين", | |
| "upload_label": "رفع صورة قاع العين (Fundus Image)", | |
| "mode_label": "وضع المحرك التشخيصي", | |
| "mode_choices": [ | |
| "⚡ مقارنة النموذجين معاً (Dual Consensus)", | |
| "General-Purpose (ResNet-50)", | |
| "High-Sensitivity (EfficientNet-B5)", | |
| ], | |
| "mode_blurb": "**الوضع المزدوج التوافقي:** يشغل النموذجين معاً بالتوازي؛ حيث يقدم ResNet-50 الدقة العامة بينما يدقق EfficientNet-B5 في الحالات النادرة والحرجة.", | |
| "btn_analyze": "⛶ بدء فحص وتحليل الصورة", | |
| "btn_reset": "🔄 إعادة ضبط", | |
| "privacy_note": "🔒 يتم الاستدلال محلياً عبر محرك ONNX. الصور لا تغادر جهازك.", | |
| "examples_label": "اختر إحدى عينات الفحص الجاهزة للاختبار", | |
| "card2_num": "02", | |
| "card2_title": "التقييم التشخيصي للنموذج", | |
| "placeholder_html": """<div style="text-align: center; padding: 48px 20px; color: #808593;"> | |
| <div style="font-size: 2.8rem; margin-bottom: 12px; opacity: 0.6;">👁️</div> | |
| <h3 style="color: #ffffff; font-size: 1.15rem; margin-bottom: 6px; font-weight: 600;">في انتظار تحميل الصورة وبدء الفحص</h3> | |
| <p style="font-size: 0.88rem; max-width: 320px; margin: 0 auto; color: #5a5e6b;"> | |
| قم برفع صورة لقاع العين أو اختر إحدى العينات الجاهزة ثم اضغط على <strong>بدء فحص وتحليل الصورة</strong>. | |
| </p> | |
| </div>""", | |
| "label_m1": "احتمالات النموذج العام (ResNet-50)", | |
| "label_m2": "احتمالات نموذج الحساسية العالية (EfficientNet-B5)", | |
| "footer_text": "منصة RetinaFlow البحثية • تطوير: محمد مصطفى البسيوني • GitHub: <a href='https://github.com/markegyptian55-cloud/RetinaFlow' target='_blank' style='color:#ff7849;text-decoration:none;'>markegyptian55-cloud/RetinaFlow</a> • للأغراض البحثية والتعليمية فقط." | |
| } | |
| } | |
| IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) | |
| IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) | |
| # ============================================================================ | |
| # STANDALONE MODEL RESOLUTION & CACHING | |
| # ============================================================================ | |
| _loaded_sessions: Dict[str, ort.InferenceSession] = {} | |
| _result_cache: Dict[Tuple[str, str], Dict[str, Any]] = {} | |
| def resolve_model_file(rel_path: str) -> str: | |
| """ | |
| Checks if model exists locally. If not found (e.g., when app.py is uploaded | |
| alone to a new Hugging Face Space), downloads it on-demand from the official Space. | |
| """ | |
| if os.path.exists(rel_path): | |
| return rel_path | |
| print(f"[*] Local model '{rel_path}' not found. Downloading from Space '{HF_REPO_ID}'...") | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| cached_file = hf_hub_download( | |
| repo_id=HF_REPO_ID, | |
| repo_type="space", | |
| filename=rel_path | |
| ) | |
| print(f"[✓] Retrieved: {cached_file}") | |
| return cached_file | |
| except Exception as e: | |
| raise FileNotFoundError( | |
| f"Could not load '{rel_path}' locally and auto-download from '{HF_REPO_ID}' failed: {e}" | |
| ) | |
| def get_session(model_key: str) -> ort.InferenceSession: | |
| if model_key not in _loaded_sessions: | |
| cfg = MODEL_CONFIGS[model_key] | |
| resolved_path = resolve_model_file(cfg["rel_path"]) | |
| _loaded_sessions[model_key] = ort.InferenceSession( | |
| resolved_path, providers=['CPUExecutionProvider'] | |
| ) | |
| return _loaded_sessions[model_key] | |
| def _hash_image(image: Image.Image) -> str: | |
| return hashlib.sha256(image.tobytes()).hexdigest() | |
| def preprocess(image: Image.Image, size: int) -> np.ndarray: | |
| image = image.convert('RGB').resize((size, size), Image.BILINEAR) | |
| arr = np.asarray(image, dtype=np.float32) / 255.0 | |
| arr = (arr - IMAGENET_MEAN) / IMAGENET_STD | |
| arr = arr.transpose(2, 0, 1) # HWC -> CHW | |
| return np.expand_dims(arr, axis=0).astype(np.float32) | |
| def softmax(x: np.ndarray) -> np.ndarray: | |
| e_x = np.exp(x - np.max(x)) | |
| return e_x / e_x.sum() | |
| def run_inference_raw(image: Image.Image, model_key: str) -> Tuple[Dict[str, float], float]: | |
| config = MODEL_CONFIGS[model_key] | |
| session = get_session(model_key) | |
| input_tensor = preprocess(image, config["input_size"]) | |
| input_name = session.get_inputs()[0].name | |
| t0 = time.perf_counter() | |
| logits = session.run(None, {input_name: input_tensor})[0][0] | |
| lat_ms = (time.perf_counter() - t0) * 1000 | |
| probs = softmax(logits) | |
| prob_dict = {CLASS_NAMES[i]: float(probs[i]) for i in range(len(CLASS_NAMES))} | |
| return prob_dict, lat_ms | |
| # ============================================================================ | |
| # MCP & ZERO-GPU ENDPOINT | |
| # ============================================================================ | |
| def classify(image: Image.Image, model_choice: str = "General-Purpose (ResNet-50)") -> Dict[str, float]: | |
| """ | |
| Public tool endpoint for Model Context Protocol (MCP) and programmatic APIs. | |
| Preserves exact backward-compatible signature. | |
| """ | |
| if image is None: | |
| raise gr.Error("Please upload a fundus image before classifying.") | |
| # Normalize model choice string if passed from Arabic or alias | |
| if "EfficientNet" in model_choice: | |
| model_choice = "High-Sensitivity (EfficientNet-B5)" | |
| else: | |
| model_choice = "General-Purpose (ResNet-50)" | |
| cache_key = (_hash_image(image), model_choice) | |
| if cache_key in _result_cache: | |
| return _result_cache[cache_key]["probs"] | |
| probs, lat_ms = run_inference_raw(image, model_choice) | |
| _result_cache[cache_key] = {"probs": probs, "lat": lat_ms} | |
| return probs | |
| # ============================================================================ | |
| # CLINICAL REPORT GENERATION (BILINGUAL) | |
| # ============================================================================ | |
| def build_clinical_card_html(top_class: str, top_prob: float, lat_ms: float, model_name: str, consensus_note: str = "", lang: str = "en") -> str: | |
| data = CLINICAL_DATA.get(top_class, CLINICAL_DATA['Others']) | |
| lang_data = data[lang] | |
| pct = top_prob * 100 | |
| lbl_heading = "PREDICTED SEVERITY & FINDING" if lang == "en" else "التشخيص السريري الأرجح" | |
| lbl_conf = "CONFIDENCE" if lang == "en" else "درجة الثقة" | |
| lbl_latency = "LATENCY" if lang == "en" else "سرعة المعالجة" | |
| lbl_engine = "ACTIVE ENGINE" if lang == "en" else "المحرك التشخيصي" | |
| lbl_clinical = "CLINICAL INSIGHT:" if lang == "en" else "الدلالة السريرية:" | |
| consensus_section = "" | |
| if consensus_note: | |
| consensus_section = f""" | |
| <div style="margin-top: 14px; padding: 12px 14px; background: #181920; | |
| border-radius: 8px; border-left: 3px solid {data['color']}; font-size: 0.88rem; line-height: 1.5;"> | |
| {consensus_note} | |
| </div> | |
| """ | |
| html = f""" | |
| <div class="rf-card" style="border-left: 4px solid {data['color']};"> | |
| <div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 8px;"> | |
| <div> | |
| <div style="font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1.2px; color: #808593; font-weight: 600;"> | |
| {lbl_heading} | |
| </div> | |
| <h2 style="margin: 4px 0 2px 0; color: #ffffff; font-size: 1.55rem; font-weight: 700; display: flex; align-items: center; gap: 8px;"> | |
| <span>{data['icon']}</span> | |
| <span>{lang_data['name']}</span> | |
| </h2> | |
| {f'<div style="font-size: 0.88rem; color: #808593;">{top_class}</div>' if lang == 'ar' else ''} | |
| </div> | |
| <div> | |
| <span style="display: inline-block; background: {data['color']}18; color: {data['color']}; | |
| border: 1px solid {data['color']}44; padding: 5px 12px; border-radius: 6px; | |
| font-size: 0.75rem; font-weight: 700; letter-spacing: 0.5px;"> | |
| {lang_data['badge']} | |
| </span> | |
| </div> | |
| </div> | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin: 14px 0; | |
| background: #0d0e12; padding: 12px 14px; border-radius: 8px; border: 1px solid #1a1b22;"> | |
| <div> | |
| <div style="font-size: 0.7rem; color: #808593; font-weight: 600; text-transform: uppercase;">{lbl_conf}</div> | |
| <div style="font-size: 1.4rem; font-weight: 800; color: {data['color']}; margin-top: 2px;">{pct:.1f}%</div> | |
| </div> | |
| <div> | |
| <div style="font-size: 0.7rem; color: #808593; font-weight: 600; text-transform: uppercase;">{lbl_latency}</div> | |
| <div style="font-size: 1.4rem; font-weight: 800; color: #ff7849; margin-top: 2px;">{lat_ms:.0f} <span style="font-size: 0.75rem; font-weight: 500; color: #808593;">ms</span></div> | |
| </div> | |
| <div> | |
| <div style="font-size: 0.7rem; color: #808593; font-weight: 600; text-transform: uppercase;">{lbl_engine}</div> | |
| <div style="font-size: 0.92rem; font-weight: 700; color: #e5e7eb; margin-top: 6px;">{model_name}</div> | |
| </div> | |
| </div> | |
| <div style="font-size: 0.88rem; line-height: 1.55; color: #9ca3af; background: #0d0e12; | |
| padding: 12px 14px; border-radius: 8px; border: 1px solid #1a1b22;"> | |
| <strong style="color: #ffffff;">{lbl_clinical}</strong> {lang_data['desc']} | |
| </div> | |
| {consensus_section} | |
| </div> | |
| """ | |
| return html | |
| # ============================================================================ | |
| # INTERACTIVE UI HANDLER | |
| # ============================================================================ | |
| def predict_ui(image: Optional[Image.Image], mode: str, lang: str): | |
| if image is None: | |
| err_msg = "Please upload a fundus image before running analysis." if lang == "en" else "يرجى رفع صورة لقاع العين أولاً قبل بدء الفحص." | |
| raise gr.Error(err_msg) | |
| # Detect if Dual Mode is selected in either language | |
| is_dual = "Dual" in mode or "مقارنة" in mode | |
| if is_dual: | |
| probs_m1, lat_m1 = run_inference_raw(image, "General-Purpose (ResNet-50)") | |
| probs_m2, lat_m2 = run_inference_raw(image, "High-Sensitivity (EfficientNet-B5)") | |
| top1_m1 = max(probs_m1, key=probs_m1.get) | |
| top1_m2 = max(probs_m2, key=probs_m2.get) | |
| # Clinical consensus logic | |
| if top1_m1 == top1_m2: | |
| if lang == "en": | |
| note = f""" | |
| <span style="color: #10b981; font-weight: 700;">🤝 Model Consensus:</span> | |
| Both engines independently agreed on <strong>{top1_m1}</strong> | |
| ({probs_m1[top1_m1]*100:.1f}% ResNet-50 · {probs_m2[top1_m2]*100:.1f}% EfficientNet-B5). | |
| """ | |
| else: | |
| note = f""" | |
| <span style="color: #10b981; font-weight: 700;">🤝 توافق تشخيصي تام:</span> | |
| كلا النموذجين متفقان على أرجحية تشخيص <strong>{top1_m1}</strong> ({CLINICAL_DATA[top1_m1]['ar']['name']}) | |
| بثقة {probs_m1[top1_m1]*100:.1f}% (ResNet) و {probs_m2[top1_m2]*100:.1f}% (EfficientNet). | |
| """ | |
| primary_class = top1_m1 | |
| primary_prob = probs_m1[top1_m1] | |
| else: | |
| if lang == "en": | |
| note = f""" | |
| <span style="color: #ff7849; font-weight: 700;">⚖️ Model Divergence Detected:</span> | |
| General model favors <strong>{top1_m1}</strong> ({probs_m1[top1_m1]*100:.1f}%), | |
| while High-Sensitivity model flags <strong>{top1_m2}</strong> ({probs_m2[top1_m2]*100:.1f}%). | |
| <em>Notice: Useful for alerting clinicians to rare high-risk cases like Hypertension.</em> | |
| """ | |
| else: | |
| note = f""" | |
| <span style="color: #ff7849; font-weight: 700;">⚖️ تباين في التشخيص (Divergence):</span> | |
| النموذج العام يرجح <strong>{top1_m1}</strong> ({probs_m1[top1_m1]*100:.1f}%)، | |
| بينما نموذج الحساسية العالية يرصد <strong>{top1_m2}</strong> ({probs_m2[top1_m2]*100:.1f}%). | |
| <em>تنبيه: يفيد هذا التباين في لفت نظر الطبيب للحالات النادرة كضغط الدم.</em> | |
| """ | |
| if top1_m2 in ['Hypertension', 'Glaucoma', 'Diabetic Retinopathy'] and probs_m2[top1_m2] > 0.30: | |
| primary_class = top1_m2 | |
| primary_prob = probs_m2[top1_m2] | |
| else: | |
| primary_class = top1_m1 | |
| primary_prob = probs_m1[top1_m1] | |
| total_lat = lat_m1 + lat_m2 | |
| engine_label = "Dual Consensus (ResNet-50 + EfficientNet-B5)" if lang == "en" else "المحرك المزدوج التوافقي" | |
| card_html = build_clinical_card_html(primary_class, primary_prob, total_lat, engine_label, note, lang=lang) | |
| return card_html, probs_m1, probs_m2, gr.update(visible=True) | |
| else: | |
| model_key = "High-Sensitivity (EfficientNet-B5)" if "EfficientNet" in mode else "General-Purpose (ResNet-50)" | |
| probs, lat = run_inference_raw(image, model_key) | |
| top1 = max(probs, key=probs.get) | |
| cfg = MODEL_CONFIGS[model_key] | |
| card_html = build_clinical_card_html(top1, probs[top1], lat, cfg["name"], lang=lang) | |
| return card_html, probs, None, gr.update(visible=False) | |
| def update_model_blurb(mode: str, lang: str) -> str: | |
| texts = UI_TEXTS[lang] | |
| if "Dual" in mode or "مقارنة" in mode: | |
| return texts["mode_blurb"] | |
| if "EfficientNet" in mode: | |
| return MODEL_CONFIGS["High-Sensitivity (EfficientNet-B5)"]["blurb_en" if lang == "en" else "blurb_ar"] | |
| return MODEL_CONFIGS["General-Purpose (ResNet-50)"]["blurb_en" if lang == "en" else "blurb_ar"] | |
| def clear_all(lang: str): | |
| texts = UI_TEXTS[lang] | |
| default_mode = texts["mode_choices"][0] | |
| return ( | |
| None, | |
| default_mode, | |
| texts["mode_blurb"], | |
| texts["placeholder_html"], | |
| None, | |
| None, | |
| gr.update(visible=False) | |
| ) | |
| # Language Toggle Function | |
| def toggle_language(current_lang: str): | |
| new_lang = "ar" if current_lang == "en" else "en" | |
| t = UI_TEXTS[new_lang] | |
| # Header HTML | |
| header_html = f""" | |
| <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 14px;"> | |
| <div> | |
| <div style="display: flex; align-items: center; gap: 10px;"> | |
| <span style="font-size: 1.8rem;">👁️</span> | |
| <h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.5px; margin: 0; color: #ffffff;"> | |
| {t['title']} | |
| </h1> | |
| <span class="rf-status-pill">{t['top_status']}</span> | |
| </div> | |
| <p style="margin: 4px 0 0 0; color: #808593; font-size: 0.95rem;"> | |
| {t['subtitle']} | |
| </p> | |
| </div> | |
| <div style="display: flex; align-items: center; gap: 20px;"> | |
| <div style="text-align: right; border-right: 1px solid #22242c; padding-right: 18px;"> | |
| <div style="font-size: 1.25rem; font-weight: 800; color: #ffffff;">{t['acc_val']}</div> | |
| <div style="font-size: 0.68rem; font-weight: 600; color: #808593; letter-spacing: 0.5px;">{t['acc_lbl']}</div> | |
| </div> | |
| <div style="text-align: right;"> | |
| <div style="font-size: 1.25rem; font-weight: 800; color: #ff7849;">{t['ht_val']}</div> | |
| <div style="font-size: 0.68rem; font-weight: 600; color: #808593; letter-spacing: 0.5px;">{t['ht_lbl']}</div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| card1_title = f"""<span style="color: #ff7849; font-weight: 700; margin-right: 6px;">{t['card1_num']}</span> <strong style="color: #ffffff; font-size: 1.1rem;">{t['card1_title']}</strong>""" | |
| card2_title = f"""<span style="color: #ff7849; font-weight: 700; margin-right: 6px;">{t['card2_num']}</span> <strong style="color: #ffffff; font-size: 1.1rem;">{t['card2_title']}</strong>""" | |
| return ( | |
| new_lang, | |
| gr.update(value=t['lang_btn']), | |
| header_html, | |
| gr.update(label=t['accordion_title']), | |
| t['tradeoff_md'], | |
| card1_title, | |
| gr.update(label=t['upload_label']), | |
| gr.update(label=t['mode_label'], choices=t['mode_choices'], value=t['mode_choices'][0]), | |
| t['mode_blurb'], | |
| gr.update(value=t['btn_analyze']), | |
| gr.update(value=t['btn_reset']), | |
| f"""<div style="font-size: 0.78rem; color: #5a5e6b; text-align: center; margin-top: 8px;">{t['privacy_note']}</div>""", | |
| card2_title, | |
| t['placeholder_html'], | |
| gr.update(label=t['label_m1']), | |
| gr.update(label=t['label_m2']), | |
| f"""<div style="text-align: center; margin-top: 24px; padding-top: 14px; border-top: 1px solid #1a1b22; color: #525666; font-size: 0.8rem;">{t['footer_text']}</div>""" | |
| ) | |
| # Discover examples | |
| EXAMPLES_DIR = "assets/samples" | |
| def discover_samples(): | |
| if not os.path.isdir(EXAMPLES_DIR): | |
| return [] | |
| valid = ('.jpg', '.jpeg', '.png') | |
| try: | |
| return [[os.path.join(EXAMPLES_DIR, f)] for f in sorted(os.listdir(EXAMPLES_DIR)) if f.lower().endswith(valid)] | |
| except Exception: | |
| return [] | |
| SAMPLE_LIST = discover_samples() | |
| # ============================================================================ | |
| # RETINAGRADE ULTRA-CLEAN MATTE OBSIDIAN DESIGN SYSTEM | |
| # ============================================================================ | |
| CUSTOM_CSS = """ | |
| :root { | |
| --bg-base: #090a0d; | |
| --bg-surface: #131418; | |
| --bg-container: #0d0e12; | |
| --border-color: #1e2027; | |
| --border-hover: #2d303b; | |
| --text-primary: #ffffff; | |
| --text-secondary: #808593; | |
| --text-dim: #525666; | |
| --accent-orange: #ff7849; | |
| --accent-orange-hover: #ff895e; | |
| --accent-teal: #10b981; | |
| } | |
| body, .gradio-container { | |
| background-color: var(--bg-base) !important; | |
| color: var(--text-primary) !important; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif !important; | |
| } | |
| /* Outer Card Containers */ | |
| .rf-card { | |
| background: var(--bg-surface) !important; | |
| border: 1px solid var(--border-color) !important; | |
| border-radius: 12px !important; | |
| padding: 16px !important; | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45) !important; | |
| } | |
| /* Header Banner */ | |
| #rf-top-header { | |
| background: #0d0e12 !important; | |
| border: 1px solid var(--border-color) !important; | |
| border-radius: 14px !important; | |
| padding: 16px 22px !important; | |
| margin-bottom: 12px !important; | |
| } | |
| /* Status Pill */ | |
| .rf-status-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| background: rgba(16, 185, 129, 0.08); | |
| border: 1px solid rgba(16, 185, 129, 0.25); | |
| color: #10b981; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| padding: 3px 10px; | |
| border-radius: 9999px; | |
| letter-spacing: 0.3px; | |
| } | |
| /* Language Toggle Button */ | |
| #rf-lang-btn { | |
| background: #14151a !important; | |
| border: 1px solid #2a2d38 !important; | |
| color: #ff7849 !important; | |
| border-radius: 8px !important; | |
| font-size: 0.85rem !important; | |
| font-weight: 700 !important; | |
| padding: 6px 14px !important; | |
| transition: all 0.15s ease !important; | |
| } | |
| #rf-lang-btn:hover { | |
| background: #1e2027 !important; | |
| border-color: #ff7849 !important; | |
| } | |
| /* Analyze Button (RetinaGrade Coral-Orange) */ | |
| #rf-btn-analyze { | |
| background: var(--accent-orange) !important; | |
| color: #090a0d !important; | |
| font-weight: 700 !important; | |
| font-size: 0.98rem !important; | |
| border: none !important; | |
| border-radius: 9px !important; | |
| padding: 11px 20px !important; | |
| box-shadow: 0 4px 18px rgba(255, 120, 73, 0.32) !important; | |
| transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1) !important; | |
| } | |
| #rf-btn-analyze:hover { | |
| background: var(--accent-orange-hover) !important; | |
| transform: translateY(-1px) !important; | |
| box-shadow: 0 6px 24px rgba(255, 120, 73, 0.45) !important; | |
| } | |
| /* Reset Button */ | |
| #rf-btn-reset { | |
| background: #1a1b22 !important; | |
| color: var(--text-secondary) !important; | |
| border: 1px solid var(--border-color) !important; | |
| border-radius: 9px !important; | |
| font-weight: 600 !important; | |
| transition: all 0.15s ease !important; | |
| } | |
| #rf-btn-reset:hover { | |
| background: #22242c !important; | |
| color: #ffffff !important; | |
| } | |
| /* Image Upload Container */ | |
| #rf-upload-box { | |
| background: var(--bg-container) !important; | |
| border: 1px solid var(--border-color) !important; | |
| border-radius: 10px !important; | |
| } | |
| /* Radio Selection */ | |
| .gr-radio .wrap { | |
| gap: 8px !important; | |
| } | |
| /* Scrollbars */ | |
| ::-webkit-scrollbar { width: 7px; height: 7px; } | |
| ::-webkit-scrollbar-track { background: var(--bg-base); } | |
| ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #323542; } | |
| """ | |
| THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.orange, | |
| secondary_hue=gr.themes.colors.neutral, | |
| neutral_hue=gr.themes.colors.neutral, | |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], | |
| ).set( | |
| body_background_fill="#090a0d", | |
| background_fill_primary="#131418", | |
| background_fill_secondary="#0d0e12", | |
| border_color_primary="#1e2027", | |
| block_background_fill="#131418", | |
| block_border_color="#1e2027", | |
| block_label_text_color="#808593", | |
| body_text_color="#ffffff", | |
| body_text_color_subdued="#808593", | |
| ) | |
| # ============================================================================ | |
| # APP LAYOUT (ENGLISH BY DEFAULT) | |
| # ============================================================================ | |
| t_init = UI_TEXTS["en"] | |
| with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="RetinaFlow — Retinal AI Classifier") as demo: | |
| # Global Language State | |
| lang_state = gr.State("en") | |
| # Top Control Bar (Language Toggle + Quick Links) | |
| with gr.Row(): | |
| with gr.Column(scale=9): | |
| pass | |
| with gr.Column(scale=3, min_width=160): | |
| lang_toggle_btn = gr.Button(t_init["lang_btn"], elem_id="rf-lang-btn", size="sm") | |
| # Top Header Banner | |
| with gr.Column(elem_id="rf-top-header"): | |
| header_banner_html = gr.HTML(f""" | |
| <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 14px;"> | |
| <div> | |
| <div style="display: flex; align-items: center; gap: 10px;"> | |
| <span style="font-size: 1.8rem;">👁️</span> | |
| <h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.5px; margin: 0; color: #ffffff;"> | |
| {t_init['title']} | |
| </h1> | |
| <span class="rf-status-pill">{t_init['top_status']}</span> | |
| </div> | |
| <p style="margin: 4px 0 0 0; color: #808593; font-size: 0.95rem;"> | |
| {t_init['subtitle']} | |
| </p> | |
| </div> | |
| <div style="display: flex; align-items: center; gap: 20px;"> | |
| <div style="text-align: right; border-right: 1px solid #22242c; padding-right: 18px;"> | |
| <div style="font-size: 1.25rem; font-weight: 800; color: #ffffff;">{t_init['acc_val']}</div> | |
| <div style="font-size: 0.68rem; font-weight: 600; color: #808593; letter-spacing: 0.5px;">{t_init['acc_lbl']}</div> | |
| </div> | |
| <div style="text-align: right;"> | |
| <div style="font-size: 1.25rem; font-weight: 800; color: #ff7849;">{t_init['ht_val']}</div> | |
| <div style="font-size: 0.68rem; font-weight: 600; color: #808593; letter-spacing: 0.5px;">{t_init['ht_lbl']}</div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| # Trade-off Advisory Collapsible Card | |
| with gr.Accordion(t_init["accordion_title"], open=False) as guide_accordion: | |
| guide_content = gr.Markdown(t_init["tradeoff_md"]) | |
| # Main Two-Column Workflow (Matching RetinaGrade Mockup) | |
| with gr.Row(equal_height=False): | |
| # Left Panel: 01 Fundus image | |
| with gr.Column(scale=5): | |
| with gr.Column(elem_classes=["rf-card"]): | |
| card1_title = gr.HTML( | |
| f"""<span style="color: #ff7849; font-weight: 700; margin-right: 6px;">{t_init['card1_num']}</span> <strong style="color: #ffffff; font-size: 1.1rem;">{t_init['card1_title']}</strong>""" | |
| ) | |
| image_input = gr.Image( | |
| type="pil", | |
| label=t_init["upload_label"], | |
| elem_id="rf-upload-box" | |
| ) | |
| model_selector = gr.Radio( | |
| choices=t_init["mode_choices"], | |
| value=t_init["mode_choices"][0], | |
| label=t_init["mode_label"], | |
| elem_id="rf-model-radio" | |
| ) | |
| model_info = gr.Markdown( | |
| t_init["mode_blurb"], | |
| elem_id="rf-model-info" | |
| ) | |
| with gr.Row(): | |
| submit_btn = gr.Button(t_init["btn_analyze"], variant="primary", elem_id="rf-btn-analyze", scale=3) | |
| reset_btn = gr.Button(t_init["btn_reset"], elem_id="rf-btn-reset", scale=1) | |
| privacy_html = gr.HTML( | |
| f"""<div style="font-size: 0.78rem; color: #5a5e6b; text-align: center; margin-top: 8px;">{t_init['privacy_note']}</div>""" | |
| ) | |
| # Sample Gallery | |
| if SAMPLE_LIST: | |
| gr.Examples( | |
| examples=SAMPLE_LIST, | |
| inputs=image_input, | |
| label=t_init["examples_label"] | |
| ) | |
| # Right Panel: 02 Model assessment | |
| with gr.Column(scale=6): | |
| with gr.Column(elem_classes=["rf-card"]): | |
| card2_title = gr.HTML( | |
| f"""<span style="color: #ff7849; font-weight: 700; margin-right: 6px;">{t_init['card2_num']}</span> <strong style="color: #ffffff; font-size: 1.1rem;">{t_init['card2_title']}</strong>""" | |
| ) | |
| clinical_report_box = gr.HTML( | |
| value=t_init["placeholder_html"], | |
| elem_id="rf-clinical-box" | |
| ) | |
| probs_m1_label = gr.Label( | |
| num_top_classes=8, | |
| label=t_init["label_m1"], | |
| elem_id="rf-probs-m1" | |
| ) | |
| with gr.Column(visible=True) as secondary_col: | |
| probs_m2_label = gr.Label( | |
| num_top_classes=8, | |
| label=t_init["label_m2"], | |
| elem_id="rf-probs-m2" | |
| ) | |
| # Invisible MCP bridge button | |
| mcp_bridge_btn = gr.Button(visible=False) | |
| mcp_bridge_btn.click( | |
| fn=classify, | |
| inputs=[image_input, model_selector], | |
| outputs=probs_m1_label, | |
| api_name="classify" | |
| ) | |
| # Event Handlers: Language Toggle | |
| lang_toggle_btn.click( | |
| fn=toggle_language, | |
| inputs=[lang_state], | |
| outputs=[ | |
| lang_state, | |
| lang_toggle_btn, | |
| header_banner_html, | |
| guide_accordion, | |
| guide_content, | |
| card1_title, | |
| image_input, | |
| model_selector, | |
| model_info, | |
| submit_btn, | |
| reset_btn, | |
| privacy_html, | |
| card2_title, | |
| clinical_report_box, | |
| probs_m1_label, | |
| probs_m2_label, | |
| footer_html := gr.HTML( | |
| f"""<div style="text-align: center; margin-top: 24px; padding-top: 14px; border-top: 1px solid #1a1b22; color: #525666; font-size: 0.8rem;">{t_init['footer_text']}</div>""" | |
| ) | |
| ] | |
| ) | |
| # Event Handlers: Model Selector Change | |
| model_selector.change( | |
| fn=update_model_blurb, | |
| inputs=[model_selector, lang_state], | |
| outputs=model_info, | |
| api_name=False | |
| ) | |
| # Event Handlers: Submit | |
| submit_btn.click( | |
| fn=predict_ui, | |
| inputs=[image_input, model_selector, lang_state], | |
| outputs=[clinical_report_box, probs_m1_label, probs_m2_label, secondary_col], | |
| api_name=False | |
| ) | |
| # Event Handlers: Reset | |
| reset_btn.click( | |
| fn=clear_all, | |
| inputs=[lang_state], | |
| outputs=[image_input, model_selector, model_info, clinical_report_box, probs_m1_label, probs_m2_label, secondary_col], | |
| api_name=False | |
| ) | |
| if __name__ == "__main__": | |
| try: | |
| sys.stdout.reconfigure(encoding='utf-8', errors='replace') | |
| sys.stderr.reconfigure(encoding='utf-8', errors='replace') | |
| except Exception: | |
| pass | |
| launch_kwargs = { | |
| "server_name": "0.0.0.0", | |
| "server_port": 7860, | |
| } | |
| import inspect | |
| if "mcp_server" in inspect.signature(demo.launch).parameters: | |
| launch_kwargs["mcp_server"] = True | |
| demo.launch(**launch_kwargs) |