""" EyeQC - a clinician-facing retinal image quality-control workbench and FLAIR foundation-model bench. Run: python app.py Deploy: see README.md (Hugging Face Spaces / Docker). """ from __future__ import annotations try: import spaces # HF ZeroGPU GPU = spaces.GPU except Exception: # not on Spaces -> no-op decorator def GPU(*a, **k): if len(a) == 1 and callable(a[0]) and not k: return a[0] return lambda f: f import os import numpy as np import pandas as pd import gradio as gr from src.pipeline import (analyze_image, analyze_batch, results_to_dataframe, metric_table, METRIC_NAMES, degradation_map_from_metrics) from src import visualize as V from src import interactive_viz as IV from src import batch_effects as BE from src import probes as PR from src.conformal import ConformalGradability, synthetic_calibration from src.flair_wrapper import ENGINE, DEFAULT_DISEASES from src.theme import (THEME, CSS, hero_html, verdict_card_html, conformal_chip_html) RUNS = os.path.join(os.getcwd(), "runs") os.makedirs(RUNS, exist_ok=True) # ------------------------------------------------------ deep A/V segmenter (RRWNet) RRWNET = {"active": False, "status": "not attempted"} def try_register_rrwnet(): """Auto-wire RRWNet if weights + torch are present (weights/ or RRWNET_WEIGHTS).""" try: from src.rrwnet_seg import register, _find_weights if _find_weights() is None: RRWNET["status"] = ("no weights found — put rrwnet_RITE_1.pth in ./weights " "or set RRWNET_WEIGHTS") return ok, status = register() RRWNET["active"] = ok; RRWNET["status"] = status except Exception as e: RRWNET["status"] = f"unavailable: {e}" # ------------------------------------------------------ lazy conformal calibrator _CALIB = {"model": None} def _heavy_degrade(rgb): import cv2 return cv2.GaussianBlur(rgb, (0, 0), 7) def get_calibrator(): if _CALIB["model"] is None: sdir = "assets/samples" refs = [] if os.path.isdir(sdir): for f in sorted(os.listdir(sdir))[:6]: try: from PIL import Image arr = np.array(Image.open(os.path.join(sdir, f)).convert("RGB")) refs.append(analyze_image(arr, f, with_vessels=False)) except Exception: pass try: _CALIB["model"] = synthetic_calibration(refs, _heavy_degrade, alpha=0.1) except Exception: _CALIB["model"] = ConformalGradability() # unfitted -> uncalibrated chip return _CALIB["model"] def qc_composite_of(rgb): from src.fov import detect_fov from src.qc_metrics import compute_all_metrics from src.quality_score import composite_score f = detect_fov(rgb) return composite_score(compute_all_metrics(rgb, f))["composite"] # ============================================================ SINGLE IMAGE @GPU(duration=60) def run_single(img): if img is None: return (None, None, None, None, None, None, None, None, "
Upload a fundus image to begin.
", "", [], None) res = analyze_image(img) s = res["summary"] card = verdict_card_html(s) pred = get_calibrator().predict(s["composite"] / 100.0) chip = conformal_chip_html(pred) reason = (f"
Primary driver: {s['primary_reason']}" + (f"
Failing axes: {', '.join(s['failing'])}" if s['failing'] else "") + (f"
Borderline: {', '.join(s['borderline'])}" if s['borderline'] else "") + f"
weighted mean {s['weighted_mean']:.0f} · " f"weakest-link {s['weakest_link']:.0f}
") vess_overlay = vess_stats = None if "vessels" in res: vs = res["vessels"] vess_overlay = (V.av_overlay(res["rgb"], vs) if "artery" in vs else V.vessel_overlay(res["rgb"], vs)) vess_stats = V.vessel_stats_panel(vs) if "avr" in vs and vs["avr"]["avr"] == vs["avr"]["avr"]: # not NaN reason += (f"
" f"Deep vasculature (RRWNet): AVR " f"{vs['avr']['avr']:.2f} " f"(arteriolar {vs['avr']['artery_caliber']:.1f}px / venular " f"{vs['avr']['vein_caliber']:.1f}px). " f"Normal ≈ 0.66; lower suggests " f"arteriolar narrowing.
") return (V.score_gauge(s), V.metric_radar(res["metrics"]), V.metric_bars(res["metrics"]), res["fov_overlay"], res["problem_overlay"], vess_overlay, vess_stats, card, reason, chip, metric_table(res), res) def show_axis_heatmap(res, axis_name): if res is None: return None from src.failure_analysis import per_axis_heatmap metric = next((m for m in res["metrics"] if m["name"] == axis_name), None) if metric is None or metric.get("_map") is None: return res["rgb"] return per_axis_heatmap(res["rgb"], res["fov"], metric) # ============================================================ BATCH QC @GPU(duration=120) def run_batch(files, progress=gr.Progress()): if not files: return (None, None, None, None, pd.DataFrame(), None, None, None) paths = [f.name if hasattr(f, "name") else f for f in files] results = analyze_batch(paths, progress=progress) df = results_to_dataframe(results) ok = [r for r in results if "error" not in r] clean = df[df["verdict"] != "ERROR"] dist = V.cohort_distribution(clean) if len(ok) else None heat = V.axis_heatmap(clean, METRIC_NAMES) if len(ok) >= 2 else None thumbs = [r["rgb"] for r in ok] labels = [f'{r["name"][:16]} {r["summary"]["composite"]:.0f}' for r in ok] verdicts = [r["summary"]["verdict"] for r in ok] panel = V.quality_panel(thumbs, labels, verdicts) if ok else None csv_path = os.path.join(RUNS, "eyeqc_report.csv") df.to_csv(csv_path, index=False) # editable batch-assignment table assign = pd.DataFrame({"image": [r["name"] for r in ok], "batch": ["batch1"] * len(ok)}) return dist, heat, panel, df, assign, results, csv_path, results # ============================================================ BATCH EFFECTS def _features(results, source): ok = [r for r in results if "error" not in r] if source == "FLAIR embedding" and ENGINE.load(): X = np.array([ENGINE.embedding(r["rgb"]) for r in ok]) return X, ok, "FLAIR image embedding (512-d)" X = np.array([r["descriptor"] for r in ok]) src = "interpretable QC + colour descriptor" if source == "FLAIR embedding": src += " (FLAIR unavailable - fell back)" return X, ok, src def _run_be(X, ok, batches, method, fsrc): det_b = BE.detect_batch_effect(X, batches) Xc = BE.combat(X, batches) if method == "ComBat" else BE.zstandardise_by_batch(X, batches) det_a = BE.detect_batch_effect(Xc, batches) emb_b, nm = BE.embed_2d(X, method="pca") emb_a, _ = BE.embed_2d(Xc, method="pca") fig = IV.animated_correction(emb_b, emb_a, batches) def fmt(d): a = "n/a" if np.isnan(d["auc"]) else f'{d["auc"]:.2f}' s = "n/a" if np.isnan(d["silhouette"]) else f'{d["silhouette"]:.2f}' return a, s ab, sb = fmt(det_b); aa, sa = fmt(det_a) html = f"""
Feature space: {fsrc}  ·  Batches: {len(set(batches))}  ·  Images: {len(ok)}
 classifier AUCsilhouetteseverity
before{ab}{sb}{det_b['severity']}
after {method}{aa}{sa}{det_a['severity']}
AUC→0.5 and silhouette→0 mean batches are no longer separable: technical variation harmonised.
""" return html, IV.fig_to_iframe(fig) @GPU(duration=120) def run_be_from_table(results, assign_df, feature_source, method): if not results: return ("
Run a Batch QC analysis first.
", None) ok = [r for r in results if "error" not in r] if len(ok) < 6: return ("
Need ≥6 analysed images.
", None) # map image -> batch from the edited table if isinstance(assign_df, pd.DataFrame): amap = dict(zip(assign_df["image"], assign_df["batch"])) else: amap = {row[0]: row[1] for row in assign_df} batches = [str(amap.get(r["name"], "batch1")) for r in ok] if len(set(batches)) < 2: return ("
Assign images to at least 2 batches in the table.
", None) X, ok, fsrc = _features(results, feature_source) return _run_be(X, ok, batches, method, fsrc) @GPU(duration=120) def run_be_from_uploads(fa, fb, fc, fd, feature_source, method, progress=gr.Progress()): slots = [("A", fa), ("B", fb), ("C", fc), ("D", fd)] all_res, batches = [], [] for letter, files in slots: if not files: continue paths = [f.name if hasattr(f, "name") else f for f in files] rs = analyze_batch(paths, progress=progress) for r in rs: if "error" not in r: all_res.append(r); batches.append(f"batch{letter}") if len(set(batches)) < 2 or len(all_res) < 6: return ("
Upload ≥6 images across at least 2 batch slots.
", None, all_res) X, ok, fsrc = _features(all_res, feature_source) html, fig = _run_be(X, ok, batches, method, fsrc) return html, fig, all_res # ============================================================ FLAIR BENCH def flair_load(): if ENGINE.load(): return "✅ FLAIR loaded and ready." return (f"⚠️ FLAIR unavailable on this host. Status: {ENGINE.status}\n\n" "The QC pipeline works fully without FLAIR. Enable it by installing " "torch + `git+https://github.com/jusiro/FLAIR.git` (see README).") @GPU(duration=60) def flair_disease(img, extra): if img is None: return "Upload a fundus image.", None if not ENGINE.load(): return f"FLAIR unavailable: {ENGINE.status}", None diseases = list(DEFAULT_DISEASES) + [d.strip() for d in (extra or "").split(",") if d.strip()] dz = ENGINE.zero_shot_disease(img, diseases) df = pd.DataFrame([{"finding": d["label"], "probability": round(d["prob"], 4), "logit": round(d["logit"], 2)} for d in dz]) return "Zero-shot findings (ranked):", df @GPU(duration=60) def flair_vqa(img, question, candidates): if img is None or not (question or "").strip(): return "Upload an image and ask a question.", None if not ENGINE.load(): return f"FLAIR unavailable: {ENGINE.status}", None out = ENGINE.vqa_answer(img, question, candidates) msg = (f"**Answer:** {out['answer']} (confidence {out['confidence']:.2f}) \n" f"answer set: {out['answer_set']} — FLAIR is contrastive, " f"so VQA ranks candidate answers by image–text match") df = pd.DataFrame([{"candidate answer": r["answer"], "prob": round(r["prob"], 3), "logit": round(r["logit"], 2)} for r in out["ranked"]]) return msg, df @GPU(duration=120) def flair_disentangle(img, progress=gr.Progress()): """Full disentanglement: QC vs FLAIR-quality, DSP curves, entanglement, occlusion.""" if img is None: return "Upload a fundus image.", None, None, None, None res = analyze_image(img) qc = res["summary"]["composite"] if not ENGINE.load(): html = (f"
Geometric QC composite: {qc:.0f}/100 " f"({res['summary']['verdict']}).
FLAIR unavailable " f"({ENGINE.status}) — load FLAIR for the full disentanglement suite.
") return html, None, None, None, None progress(0.2, desc="FLAIR quality vs disease") d = ENGINE.quality_disentanglement(img, qc) progress(0.4, desc="degradation sensitivity probe") dsp = PR.degradation_sensitivity(ENGINE, res["rgb"], res["fov"], qc_composite_of, levels=6) progress(0.8, desc="occlusion spatial disentanglement") occ = PR.occlusion_disentanglement(ENGINE, res["rgb"], res["fov"], res["degradation_map"], grid=7) html = f"""
Static read-out
Geometric QC composite: {d['qc_composite']:.0f}/100  ·  FLAIR quality read: {d['flair_quality']:.0f}/100
FLAIR top finding: {d['flair_disease']} (p={d['flair_disease_prob']:.2f})

Degradation Sensitivity Probe — {dsp['verdict']}
entanglement index {dsp['entanglement_index']:.2f}, robustness {dsp['robustness']:.2f}

Spatial disentanglement — confound {occ['confound']:.2f}. {occ['note']}
""" dsp_fig = IV.dsp_figure(dsp) dial = IV.entanglement_dial(dsp["entanglement_index"]) # occlusion overlay import cv2 sal = (occ["saliency"] * 255).astype(np.uint8) heat = cv2.applyColorMap(sal, cv2.COLORMAP_INFERNO)[..., ::-1] over = np.clip(res["rgb"] * 0.55 + heat * 0.45, 0, 255).astype(np.uint8) return html, dsp_fig, dial, over, res["degradation_map"] # ============================================================ UI def build(): with gr.Blocks(title="EyeQC") as demo: gr.HTML(hero_html()) res_state = gr.State() batch_state = gr.State() with gr.Tabs(): # ---------------------------------------------------- single with gr.Tab("① Single-image QC"): with gr.Row(): with gr.Column(scale=5): gr.HTML("
Input
") img_in = gr.Image(type="numpy", label="Fundus photograph", height=330) run_btn = gr.Button("Analyse quality", variant="primary") if os.path.isdir("assets/samples"): gr.Examples([["assets/samples/" + f] for f in sorted(os.listdir("assets/samples"))], inputs=img_in, label="Example images") verdict_html = gr.HTML() conf_html = gr.HTML() reason_html = gr.HTML() with gr.Column(scale=4): gr.HTML("
Composite score
") gauge_out = gr.Image(show_label=False, height=300) with gr.Row(): radar_out = gr.Image(label="Quality profile", height=430) bars_out = gr.Image(label="Axes ranked (worst first)", height=430) gr.HTML("
Vascular analysis
") gr.Markdown( ("🟢 **RRWNet deep artery/vein segmentation active** — " + RRWNET["status"]) if RRWNET["active"] else ("⚪ Classical vessel backend. Deep A/V (RRWNet): " + RRWNET["status"]), elem_classes="md-note") with gr.Row(): vessel_over = gr.Image(label="Vessel map (teal=vesselness, amber=skeleton)", height=340) vessel_stat = gr.Image(label="Structural descriptors", height=340) gr.HTML("
Field detection & failure localisation
") with gr.Row(): fov_out = gr.Image(label="Detected retinal field", height=330) problem_out = gr.Image(label="Composite problem map", height=330) with gr.Row(): axis_pick = gr.Dropdown(METRIC_NAMES, value="Vessel Visibility", label="Inspect one axis") axis_heat = gr.Image(label="Per-axis heatmap", height=330) table_out = gr.Dataframe( headers=["Axis", "Measurement", "Score", "Status", "Clinical note"], datatype=["str"] * 5, wrap=True, row_count=(10, "fixed")) run_btn.click(run_single, [img_in], [gauge_out, radar_out, bars_out, fov_out, problem_out, vessel_over, vessel_stat, verdict_html, reason_html, conf_html, table_out, res_state]) axis_pick.change(show_axis_heatmap, [res_state, axis_pick], [axis_heat]) # ---------------------------------------------------- batch with gr.Tab("② Batch QC & cohort"): gr.HTML("
Upload a batch of fundus images
") files_in = gr.File(file_count="multiple", file_types=["image"], label="Drop many images") batch_btn = gr.Button("Analyse batch", variant="primary") dist_out = gr.Image(label="Cohort quality distribution & verdicts", height=330) panel_out = gr.Image(label="Quality panel", height=520) heat_out = gr.Image(label="Per-axis scores across cohort", height=380) table_batch = gr.Dataframe(label="Per-image QC report", wrap=True) gr.HTML("
Assign images to batches " "(edit the batch column, then use tab ③)
") assign_tbl = gr.Dataframe(headers=["image", "batch"], datatype=["str", "str"], interactive=True, label="Batch assignment (editable)") csv_out = gr.File(label="Download CSV report") batch_btn.click(run_batch, [files_in], [dist_out, heat_out, panel_out, table_batch, assign_tbl, batch_state, csv_out, batch_state]) # ---------------------------------------------------- batch effects with gr.Tab("③ Batch effects"): gr.Markdown("Detect and **correct** systematic technical variation across " "cameras / sites / days.", elem_classes="md-note") with gr.Tabs(): with gr.Tab("Use cohort + assignment table"): with gr.Row(): feat1 = gr.Radio(["QC descriptor", "FLAIR embedding"], value="QC descriptor", label="Feature space") meth1 = gr.Radio(["ComBat", "z-standardise"], value="ComBat", label="Correction") be1_btn = gr.Button("Detect & correct batch effects", variant="primary") be1_html = gr.HTML() be1_plot = gr.HTML(label="Batch harmonisation (animated)") be1_btn.click(run_be_from_table, [batch_state, assign_tbl, feat1, meth1], [be1_html, be1_plot]) with gr.Tab("Upload batch-wise"): gr.Markdown("Upload each batch into its own slot.", elem_classes="md-note") with gr.Row(): fa = gr.File(file_count="multiple", file_types=["image"], label="Batch A") fb = gr.File(file_count="multiple", file_types=["image"], label="Batch B") with gr.Row(): fc = gr.File(file_count="multiple", file_types=["image"], label="Batch C") fd = gr.File(file_count="multiple", file_types=["image"], label="Batch D") with gr.Row(): feat2 = gr.Radio(["QC descriptor", "FLAIR embedding"], value="QC descriptor", label="Feature space") meth2 = gr.Radio(["ComBat", "z-standardise"], value="ComBat", label="Correction") be2_btn = gr.Button("Analyse & correct batch effects", variant="primary") be2_html = gr.HTML() be2_plot = gr.HTML(label="Batch harmonisation (animated)") be2_btn.click(run_be_from_uploads, [fa, fb, fc, fd, feat2, meth2], [be2_html, be2_plot, batch_state]) # ---------------------------------------------------- FLAIR with gr.Tab("④ FLAIR foundation-model bench"): gr.Markdown("FLAIR (ResNet-50 + Bio-ClinicalBERT) for zero-shot disease " "read-out, contrastive **VQA**, and quality↔pathology " "**disentanglement** probes.", elem_classes="md-note") flair_status = gr.Markdown() gr.Button("Load FLAIR", variant="primary").click(flair_load, None, [flair_status]) with gr.Row(): flair_img = gr.Image(type="numpy", label="Fundus photograph", height=340) with gr.Column(): with gr.Tab("Zero-shot disease"): extra_dz = gr.Textbox(label="Extra findings (comma-separated)") dz_btn = gr.Button("Run zero-shot") dz_msg = gr.Markdown(); dz_tbl = gr.Dataframe() dz_btn.click(flair_disease, [flair_img, extra_dz], [dz_msg, dz_tbl]) with gr.Tab("VQA"): q_in = gr.Textbox(label="Question", placeholder="e.g. what disease is shown? / is this gradable? / which eye?") cand_in = gr.Textbox(label="Candidate answers (optional, comma-separated)", placeholder="leave blank to auto-pick an answer set") vqa_btn = gr.Button("Answer") vqa_msg = gr.Markdown(); vqa_tbl = gr.Dataframe() vqa_btn.click(flair_vqa, [flair_img, q_in, cand_in], [vqa_msg, vqa_tbl]) gr.HTML("
Quality ↔ pathology disentanglement
") dis_btn = gr.Button("Run disentanglement suite", variant="primary") dis_html = gr.HTML() with gr.Row(): dsp_plot = gr.Plot(label="Degradation sensitivity") dial_plot = gr.Plot(label="Entanglement index") with gr.Row(): occ_out = gr.Image(label="Disease saliency (occlusion)", height=340) dmap_out = gr.Image(label="QC degradation map", height=340) dis_btn.click(flair_disentangle, [flair_img], [dis_html, dsp_plot, dial_plot, occ_out, dmap_out]) # ---------------------------------------------------- methods with gr.Tab("ⓘ Methods"): gr.Markdown(METHODS_MD) gr.HTML("") return demo METHODS_MD = """ ### EyeQC methodology **Field-aware QC.** Every metric is measured inside an automatically detected retinal disc, eroded to exclude the black border, field-edge ring and dark corners. Ten axes (vessel visibility, focus, sharpness, illumination uniformity, exposure, contrast, field definition, artifact burden, clipping, colour balance) each return a physical value, a 0–1 score and a PASS/ACCEPTABLE/FAIL status. **Composite = 65% weighted mean + 35% weakest-link**, so one fatal flaw cannot be masked by strong performance elsewhere. **Robust fundus-circle detection.** The disc centre is *not* assumed to be the image centre. EyeQC segments the foreground, then fits the fundus circle by algebraic least squares to the true boundary arc — excluding points on the image frame, which are truncation edges — with an extent-based radius estimate that is robust even when the circle is heavily cropped. Off-centre, letter-boxed and partially-cropped fundus images are localised correctly before any metric is computed. The same ROI mask feeds the deep segmenter's preprocessing. **Rigorous vascular analysis.** A multi-scale vesselness map yields structural descriptors (density, skeleton length, fractal dimension, fragmentation). The gradability score is anchored to a blur-monotonic top-hat vessel-contrast measure. When **RRWNet** deep artery/vein segmentation is enabled (weights in `./weights`), EyeQC reports true vessel/artery/vein maps and the **arteriolar-to-venular ratio (AVR)** — a validated cardiovascular biomarker. **Conformal gradability.** Split-conformal prediction turns the quality score into a calibrated set — {gradable}, {ungradable}, or {uncertain} — with a finite-sample coverage guarantee, given a labelled (or surrogate) calibration set. **Batch-effect harmonisation.** Per-image features (interpretable QC descriptors or FLAIR embeddings) are tested for batch separability (cross-validated classifier AUC + silhouette) and corrected with ComBat or per-batch z-standardisation; an animated embedding shows the harmonisation. ### Novel foundation-model disentanglement **Degradation Sensitivity Probe (DSP).** Controlled degradations (defocus, illumination, contrast) are swept at increasing severity while FLAIR's confidence in the originally-predicted disease is tracked. The **entanglement index** is the positive correlation between disease confidence and image quality across the sweep: high means the disease call co-moves with quality — evidence the foundation model is conflating degradation with pathology. **Occlusion spatial disentanglement.** Occlusion saliency localises the pixels driving FLAIR's disease call; the **confound score** is the fraction of that evidence sitting on regions the QC pipeline flags as degraded. Together these give a per-image, quantitative answer to *"is this disease read real, or a quality artefact?"* — the question at the centre of trustworthy retinal foundation models. """ def _resolve_port(): os.environ.pop("GRADIO_SERVER_PORT", None) try: return int(os.environ.get("PORT", 7860)) except ValueError: return 7860 def try_load_flair(): try: ENGINE.load() except Exception as e: print(f"[eyeqc] FLAIR eager-load skipped: {e}") if __name__ == "__main__": try_register_rrwnet() try_load_flair() print(f"[eyeqc] RRWNet: {RRWNET['status']}") demo = build() share = os.environ.get("SHARE", "0") == "1" and not os.environ.get("SPACE_ID") app = demo.queue(max_size=24) want = _resolve_port() for port in [want, None]: try: app.launch(server_name="0.0.0.0", server_port=port, share=True, theme=THEME, css=CSS, show_error=True, allowed_paths=[RUNS]) break except OSError as e: if port is None: raise print(f"[eyeqc] port {want} busy ({e}); scanning for a free port…")