rallou's picture
Minor changes
4b6b8ab
Raw History Blame Contribute Delete
23.4 kB
import spaces
import gradio as gr
import cv2
import numpy as np
from PIL import Image as PILImage
import os
from huggingface_hub import login
login(token=os.environ.get("HF_TOKEN"))
from models import (load_yolo, load_unet, load_efficientnet,
load_medgemma, load_fusion, DEVICE)
from pipeline import predict_glaucoma
from visualize import make_visualization_images
from medgemma_report import generate_report
from export import generate_pdf_report
print("Loading models...")
yolo_model = load_yolo("weights/best.pt")
unet_model = load_unet("weights/best_model_final.pth")
eff_model = load_efficientnet("weights/best_classifier_efficientnet.pth")
fusion_model, fusion_threshold = load_fusion("fusion/drishti_fusion_model.pkl", "fusion/drishti_fusion_threshold.pkl")
medgemma_model, processor = load_medgemma("google/medgemma-1.5-4b-it")
print("All models loaded!")
@spaces.GPU
def run_pipeline(image_path: str):
if image_path is None:
return [None] * 9 + [_placeholder_diagnosis_html(), _placeholder_table_html(), "", None, gr.update(interactive=False)]
image_bgr = cv2.imread(image_path)
try:
result = predict_glaucoma(
image_path, yolo_model, unet_model, eff_model,
fusion_model, fusion_threshold, DEVICE
)
except ValueError as e:
return [None] * 9 + [f'<p id="decision-result" style="color:#F87171;">{e}</p>', _placeholder_table_html(), "", None, gr.update(interactive=False)]
viz = make_visualization_images(image_bgr, result, eff_model, DEVICE)
result["crop_rgb"] = viz["crop_rgb"]
result["overlay_rgb"] = viz["overlay_rgb"]
report = generate_report(result, medgemma_model, processor)
metrics = {
"YOLO conf": result["yolo_conf"],
"Seg failed": result["seg_failed"],
"vCDR": result["vCDR"],
"hCDR": result["hCDR"],
"Inferior rim": result["SI_ratios"]["inferior"],
"Superior rim": result["SI_ratios"]["superior"],
"SI compliant": result["SI_compliant"],
"NRR area": result["NRR_area"],
"p holistic": result["p_holistic"],
"p fused": result["p_fused"],
"Threshold": result["fusion_threshold"],
}
export_data = {
"decision": result["decision"],
"metrics": metrics,
"report": report,
"input_image": PILImage.open(image_path),
"images": {
"YOLO Detection": PILImage.fromarray(viz["img_yolo"]),
"Disc Crop": PILImage.fromarray(viz["crop_rgb"]),
"U-Net Input": PILImage.fromarray(viz["img_proc"]),
"U-Net Segmentation": PILImage.fromarray(viz["seg_rgb"]),
"Overlay": PILImage.fromarray(viz["overlay_rgb"]),
"EfficientNet Input": PILImage.fromarray(viz["img_eff_input"]),
"Preprocessed": PILImage.fromarray(viz["img_eff_proc"]),
"GradCAM (preprocessed)": PILImage.fromarray(viz["gradcam_proc"]),
"GradCAM (original)": PILImage.fromarray(viz["gradcam_orig"]),
},
}
return (
PILImage.fromarray(viz["img_yolo"]),
PILImage.fromarray(viz["crop_rgb"]),
PILImage.fromarray(viz["img_proc"]),
PILImage.fromarray(viz["seg_rgb"]),
PILImage.fromarray(viz["overlay_rgb"]),
PILImage.fromarray(viz["img_eff_input"]),
PILImage.fromarray(viz["img_eff_proc"]),
PILImage.fromarray(viz["gradcam_proc"]),
PILImage.fromarray(viz["gradcam_orig"]),
_decision_to_html(result["decision"]),
_table_to_html(metrics),
_report_to_html(report),
export_data,
gr.update(interactive=True),
)
TOOLTIPS = {
"YOLO conf": "Detection confidence score from the YOLOv8 optic disc detector. Reflects the certainty of the disc localization, which affects the quality of all downstream biomarker computations. Lower values may indicate poor image quality.",
"Seg failed": "When flagged, U-Net disc/cup segmentation was unreliable and classifier scores carry more weight in the decision.",
"vCDR": "Vertical Cup-to-Disc Ratio is the ratio between the vertical diameters of the optic cup and disc, measuring the vertical extent of excavation. A key structural biomarker for glaucoma assessment.",
"hCDR": "Horizontal Cup-to-Disc Ratio is the ratio between the horizontal diameters of the optic cup and disc, measuring the horizontal extent of excavation. Used as a complementary structural biomarker alongside vCDR.",
"Inferior rim": "Proportion of the inferior disc quadrant occupied by the neuroretinal rim. The inferior rim should be the thickest sector in a healthy disc. Thinning here is one of the earliest structural glaucoma signs.",
"Superior rim": "Proportion of the superior disc quadrant occupied by the neuroretinal rim. The superior rim is the second thickest in a healthy disc. Thinning in this region, accompanied by inferior rim loss, is an indicator of glaucomatous damage.",
"SI compliant": "Inferior-Superior rim compliance based on the ISNT rule, a clinical guideline stating that in healthy eyes neuroretinal rim thickness follows Inferior > Superior > Nasal > Temporal. Only the Inferior > Superior axis is assessed here since left/right eye laterality is unavailable in the datasets used, making the nasal/temporal distinction impossible.",
"NRR area": "Neuroretinal Rim Area Ratio measures the proportion of the optic disc occupied by the rim tissue (disc minus cup). A smaller ratio indicates a larger cup relative to the disc, which may suggest glaucomatous structural damage.",
"p holistic": "Holistic glaucoma probability estimated by an EfficientNet-B3 classifier trained on the full fundus image. Captures global image features beyond the optic disc region.",
"p fused": "Combined glaucoma probability integrating both the structural branch (segmentation metrics) and the holistic branch (EfficientNet). Provides a single calibrated score for the final decision.",
"Threshold": "The probability threshold used to classify an eye as glaucoma suspected. Predictions above this value are flagged as positive. Selected to optimize the balance between sensitivity and specificity on the validation set.",
}
LABELS = {
"vCDR": "Vertical CDR",
"hCDR": "Horizontal CDR",
"NRR area": "NRR Area Ratio",
"p holistic": "EfficientNet Probability",
"p fused": "Fused Glaucoma Score",
"Threshold": "Decision Threshold",
"SI compliant": "Inferior > Superior Rim Rule",
"Inferior rim": "Inferior Rim Ratio",
"Superior rim": "Superior Rim Ratio",
"Seg failed": "Segmentation Failed",
"YOLO conf": "YOLO Confidence",
}
def _placeholder_diagnosis_html() -> str:
return '<p id="decision-result" style="color:#0C4A6E;">-</p>'
def _decision_to_html(decision: str) -> str:
return f'<p id="decision-result" style="color:#FFFFFF;">{decision}</p>'
def _table_to_html(m: dict) -> str:
def fmt(v):
if v is True: return "Yes"
if v is False: return "No"
if isinstance(v, float): return f"{v:.4f}"
return v
rows = "".join(
f'<tr>'
f'<td class="metric-label">'
f'<span class="tip-anchor">{LABELS.get(k, k)}'
f'<span class="tip-box">{TOOLTIPS.get(k, "")}</span>'
f'</span></td>'
f'<td class="metric-value">{fmt(v)}</td>'
f'</tr>'
for k, v in m.items()
)
return _table_frame(rows)
def _placeholder_table_html() -> str:
rows = "".join(
f'<tr>'
f'<td class="metric-label">'
f'<span class="tip-anchor">{label}'
f'<span class="tip-box">{TOOLTIPS.get(k, "")}</span>'
f'</span></td>'
f'<td class="metric-value placeholder-val">-</td>'
f'</tr>'
for k, label in LABELS.items()
)
return _table_frame(rows)
def _table_frame(rows: str) -> str:
return f"""
<div id="table-frame">
<table id="metrics-table">
<thead><tr>
<th>Biomarker / Score</th>
<th>Value</th>
</tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
"""
def _report_to_html(text: str) -> str:
import html as _html
escaped = _html.escape(text) if text else ""
return f'<pre id="report-pre">{escaped}</pre>'
CUSTOM_CSS = """
html { font-size: 22px !important; }
.gradio-container { width: 100% !important; max-width: 1280px !important; margin: 0 auto !important; padding: 0 1.5rem !important; box-sizing: border-box !important; }
#header-block,
#header-block > div {
padding: 0 !important;
}
#glaunet-header {
background: #080614;
background-image:
radial-gradient(ellipse 80% 60% at 35% -10%, rgba(56,189,248,0.18) 0%, transparent 65%),
radial-gradient(circle at 1px 1px, rgba(56,189,248,0.07) 1px, transparent 0);
background-size: auto, 24px 24px;
border: 1px solid #0C2A3E;
border-radius: 16px;
padding: 1.75rem 2.25rem;
margin-bottom: 3.5rem;
position: relative;
overflow: hidden;
}
#glaunet-header::after {
content: '';
position: absolute;
right: 8%;
top: 50%;
transform: translateY(-50%);
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(56,189,248,0.08) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.hdr-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
position: relative;
z-index: 1;
}
.hdr-left { display: flex; align-items: center; gap: 1.25rem; }
.hdr-icon {
width: 85px;
height: 85px;
background: rgba(56,189,248,0.10);
border: 1px solid rgba(56,189,248,0.35);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 0 28px rgba(56,189,248,0.18), inset 0 1px 0 rgba(255,255,255,0.06);
}
.hdr-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.hdr-title {
font-family: 'Syne', 'IBM Plex Sans', sans-serif;
font-size: 2.6rem;
font-weight: 800;
line-height: 1;
color: #F0F9FF;
letter-spacing: -0.03em;
margin: 0;
transform: translateY(-0.3em);
}
.hdr-accent { color: #38BDF8; }
.hdr-sub {
font-size: 0.68rem;
color: #38BDF8;
margin: 0;
letter-spacing: 0.18em;
text-transform: uppercase;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.hdr-tagline {
font-size: 0.8rem;
color: #7DD3FC;
max-width: 260px;
text-align: right;
line-height: 1.55;
letter-spacing: 0.01em;
font-family: 'IBM Plex Sans', sans-serif;
}
#btn-analyze {
width: 100% !important;
height: 48px !important;
font-size: 1rem !important;
font-weight: 600 !important;
letter-spacing: 0.04em !important;
margin-top: 0.75rem !important;
}
.section-label {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #38BDF8;
padding: 0.3rem 0 0.3rem 0.65rem;
margin: 0 0 1rem;
border-left: 2px solid #38BDF8;
font-family: 'JetBrains Mono', monospace;
display: block;
}
#top-row {
padding-top: 2rem !important;
}
#sec-biomarkers {
padding-top: 2.25rem !important;
}
#sec-segmentation,
#sec-gradcam,
#sec-report {
padding-top: 1.5rem !important;
}
#decision-result {
font-size: 1rem;
font-weight: 700;
margin: 0 0 0.85rem 0;
letter-spacing: 0.02em;
text-align: center;
}
#metrics-wrapper { width: 100%; }
#table-frame {
width: 100% !important;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px;
overflow: visible;
}
#metrics-table thead tr:first-child th:first-child { border-top-left-radius: 9px; }
#metrics-table thead tr:first-child th:last-child { border-top-right-radius: 9px; }
#metrics-table tbody tr:last-child td:first-child { border-bottom-left-radius: 9px; }
#metrics-table tbody tr:last-child td:last-child { border-bottom-right-radius: 9px; }
#metrics-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
background: #080614;
}
#metrics-table th {
background: #060F1A;
color: #38BDF8;
text-align: left;
padding: 0.6rem 1rem;
font-weight: 700;
font-size: 0.59rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-family: 'JetBrains Mono', monospace;
border-bottom: 1px solid rgba(255,255,255,0.15);
}
#metrics-table td {
padding: 0.45rem 1rem;
border-bottom: 1px solid rgba(255,255,255,0.15);
}
td.metric-label {
color: #7DD3FC;
font-size: 0.79rem;
}
td.metric-value {
font-family: 'JetBrains Mono', monospace;
color: #BAE6FD;
font-size: 0.81rem;
}
#metrics-table tr:nth-child(even) td { background: rgba(6,15,26,0.55); }
#metrics-table tr:last-child td { border-bottom: none; }
#metrics-table tr:hover td.metric-label { color: #7DD3FC; }
#metrics-table tr:hover td.metric-value { color: #F0F9FF; }
#metrics-table tr:hover td { background: rgba(56,189,248,0.06) !important; }
.tip-anchor {
position: relative;
cursor: help;
border-bottom: 1px dashed rgba(56,189,248,0.40);
padding-bottom: 1px;
display: inline-block;
}
.tip-box {
display: none;
position: absolute;
left: 105%;
top: 50%;
transform: translateY(-50%);
z-index: 9999;
width: 230px;
background: #080614;
border: 1px solid #0C4A6E;
border-radius: 8px;
padding: 0.5rem 0.75rem;
font-size: 0.71rem;
color: #7DD3FC;
line-height: 1.65;
box-shadow: 0 8px 28px rgba(0,0,0,0.65), 0 0 0 1px rgba(56,189,248,0.10);
pointer-events: none;
white-space: normal;
font-family: 'IBM Plex Sans', sans-serif;
font-style: normal;
font-weight: 400;
border-bottom-width: 1px;
}
.tip-anchor:hover .tip-box { display: block; }
#report-pre {
background: #080614;
border: 1px solid #0C2A3E;
border-radius: 10px;
color: #7DD3FC;
font-family: 'JetBrains Mono', monospace;
font-size: 0.79rem;
line-height: 1.85;
padding: 1.1rem 1.3rem;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
min-height: 200px;
}
#seg-row,
#gradcam-row {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 0.5rem !important;
margin-bottom: 1rem !important;
}
#seg-row .block,
#gradcam-row .block {
min-width: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
#seg-row .block,
#gradcam-row .block {
height: auto !important;
}
#seg-row .image-container,
#gradcam-row .image-container {
height: auto !important;
}
#seg-row .image-frame,
#gradcam-row .image-frame {
width: 100% !important;
height: auto !important;
aspect-ratio: 1 !important;
}
#seg-row .empty,
#gradcam-row .empty {
width: 100% !important;
height: auto !important;
min-height: 0 !important;
aspect-ratio: 1 !important;
}
#seg-row .image-frame img,
#seg-row img,
#gradcam-row .image-frame img,
#gradcam-row img {
object-fit: cover !important;
width: 100% !important;
height: 100% !important;
}
#seg-row .svelte-p3y7hu,
#gradcam-row .svelte-p3y7hu,
#seg-row .wrap > svg,
#gradcam-row .wrap > svg,
#seg-row .icon,
#gradcam-row .icon,
#seg-row .placeholder,
#gradcam-row .placeholder {
display: none !important;
}
#input-col {
flex: 0 0 calc(100% / 3) !important;
max-width: calc(100% / 3) !important;
}
#img-input {
height: auto !important;
}
#img-input .image-container {
height: auto !important;
}
#img-input .upload-container {
width: 100% !important;
height: auto !important;
aspect-ratio: 1 !important;
}
#img-input .image-frame {
width: 100% !important;
height: 100% !important;
}
#img-input .image-frame img,
#img-input img {
object-fit: cover !important;
width: 100% !important;
height: 100% !important;
}
#img-input .wrap {
min-height: 0 !important;
height: 100% !important;
}
#diagnosis-col {
min-width: 0 !important;
}
div:has(> #metrics-html-block),
div:has(> #diagnosis-html-block) {
display: block !important;
width: 100% !important;
min-width: 0 !important;
}
#metrics-html-block {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
margin-bottom: 1rem !important;
}
.placeholder-val {
color: #0C4A6E !important;
font-style: italic;
}
#footer-note {
text-align: center;
font-size: 0.7rem;
color: #0C4A6E;
padding: 1rem 0 0.5rem;
border-top: 1px solid #060F1A;
margin-top: 1.5rem;
letter-spacing: 0.04em;
font-family: 'JetBrains Mono', monospace;
}
#export-row {
margin-top: 1.5rem !important;
margin-bottom: 0.5rem !important;
}
#btn-export {
width: 100% !important;
height: 44px !important;
font-size: 0.9rem !important;
font-weight: 600 !important;
letter-spacing: 0.06em !important;
border: 1px solid rgba(56,189,248,0.35) !important;
background: rgba(56,189,248,0.08) !important;
color: #7DD3FC !important;
transition: background 0.15s, border-color 0.15s !important;
}
#btn-export:not(:disabled):hover {
background: rgba(56,189,248,0.16) !important;
border-color: #38BDF8 !important;
color: #F0F9FF !important;
}
#btn-export:disabled { opacity: 0.35 !important; cursor: not-allowed !important; }
"""
sky = gr.themes.Color(
c50="#F0F9FF", c100="#E0F2FE", c200="#BAE6FD", c300="#7DD3FC",
c400="#38BDF8", c500="#0EA5E9", c600="#0284C7", c700="#0369A1",
c800="#075985", c900="#0C4A6E", c950="#082F49",
)
theme = gr.themes.Base(
primary_hue=sky,
neutral_hue=gr.themes.colors.slate,
font=gr.themes.GoogleFont("IBM Plex Sans"),
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
).set(
button_primary_background_fill="#0284C7",
button_primary_background_fill_hover="#0369A1",
button_primary_text_color="#FFFFFF",
button_primary_shadow="0 4px 14px rgba(2,132,199,0.45)",
body_background_fill="#0A0716",
background_fill_primary="#0F0B1E",
background_fill_secondary="#160F2E",
block_background_fill="#0F0B1E",
block_border_color="#0C2A3E",
block_border_width="1px",
block_radius="12px",
block_shadow="0 2px 12px rgba(0,0,0,0.5)",
block_label_background_fill="#060F1A",
block_label_text_color="#38BDF8",
block_label_text_weight="600",
input_background_fill="#080614",
input_border_color="#0C2A3E",
input_border_color_focus="#38BDF8",
body_text_color="#BAE6FD",
body_text_color_subdued="#0C4A6E",
)
with gr.Blocks(title="GlauNet - Glaucoma Screening", theme=theme) as demo:
gr.HTML("""
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=IBM+Plex+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<div id="glaunet-header">
<div class="hdr-inner">
<div class="hdr-left">
<div class="hdr-icon">
<svg width="32" height="32" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 15C1.5 15 7 4.5 15 4.5C23 4.5 28.5 15 28.5 15C28.5 15 23 25.5 15 25.5C7 25.5 1.5 15 1.5 15Z"
stroke="#38BDF8" stroke-width="1.4" fill="none" stroke-linejoin="round"/>
<circle cx="15" cy="15" r="5" stroke="#38BDF8" stroke-width="1.4" fill="rgba(56,189,248,0.10)"/>
<circle cx="15" cy="15" r="1.8" fill="#38BDF8"/>
<line x1="15" y1="8.5" x2="15" y2="11" stroke="#38BDF8" stroke-width="1" opacity="0.45"/>
<line x1="15" y1="19" x2="15" y2="21.5" stroke="#38BDF8" stroke-width="1" opacity="0.45"/>
<line x1="8.5" y1="15" x2="11" y2="15" stroke="#38BDF8" stroke-width="1" opacity="0.45"/>
<line x1="19" y1="15" x2="21.5" y2="15" stroke="#38BDF8" stroke-width="1" opacity="0.45"/>
</svg>
</div>
<div class="hdr-text">
<h1 class="hdr-title">Glau<span class="hdr-accent">NET</span></h1>
<p class="hdr-sub">Glaucoma Screening</p>
</div>
</div>
<p class="hdr-tagline">Retinal image analysis<br>for early glaucoma detection</p>
</div>
</div>
""", elem_id="header-block")
with gr.Row(equal_height=False, elem_id="top-row"):
with gr.Column(min_width=260, scale=1, elem_id="input-col"):
img_input = gr.Image(type="filepath", label="Fundus image (JPG / PNG)", elem_id="img-input", sources=["upload"], height=260)
btn_analyze = gr.Button("Analyze", variant="primary", size="lg", elem_id="btn-analyze")
with gr.Column(scale=3, elem_id="diagnosis-col"):
gr.HTML('<span class="section-label" style="margin-top:0.5rem;">Diagnosis</span>')
diagnosis_out = gr.HTML(value=_placeholder_diagnosis_html(), elem_id="diagnosis-html-block")
gr.HTML('<span class="section-label">Clinical Biomarkers</span>', elem_id="sec-biomarkers")
metrics_out = gr.HTML(value=_placeholder_table_html(), elem_id="metrics-html-block")
gr.HTML('<span class="section-label">Segmentation Pipeline</span>', elem_id="sec-segmentation")
with gr.Row(elem_id="seg-row"):
out_yolo = gr.Image(label="YOLO Detection", height=260)
out_crop = gr.Image(label="Disc Crop", height=260)
out_proc = gr.Image(label="U-Net Input", height=260)
out_seg = gr.Image(label="U-Net Segmentation", height=260)
out_overlay = gr.Image(label="Overlay", height=260)
gr.HTML('<span class="section-label">GradCAM Analysis</span>', elem_id="sec-gradcam")
with gr.Row(elem_id="gradcam-row"):
out_eff_input = gr.Image(label="EfficientNet Input", height=260)
out_eff_proc = gr.Image(label="Preprocessed", height=260)
out_gc_proc = gr.Image(label="GradCAM (preprocessed)", height=260)
out_gc_orig = gr.Image(label="GradCAM (original)", height=260)
gr.HTML('<span class="section-label">MedGemma Clinical Report</span>', elem_id="sec-report")
report_out = gr.HTML()
result_state = gr.State(None)
with gr.Row(elem_id="export-row"):
btn_export = gr.DownloadButton(
label="Export Report (PDF)",
elem_id="btn-export",
interactive=False,
)
gr.HTML('<div id="footer-note">Research prototype. Not a substitute for diagnosis by a qualified ophthalmologist.</div>')
btn_analyze.click(
fn=run_pipeline,
inputs=[img_input],
outputs=[
out_yolo, out_crop, out_proc, out_seg, out_overlay,
out_eff_input, out_eff_proc, out_gc_proc, out_gc_orig,
diagnosis_out, metrics_out, report_out,
result_state, btn_export,
],
)
def _do_export(export_data):
if export_data is None:
return None
return generate_pdf_report(export_data)
btn_export.click(fn=_do_export, inputs=[result_state], outputs=[btn_export])
if __name__ == "__main__":
demo.launch(css=CUSTOM_CSS)