from __future__ import annotations import html import traceback import gradio as gr from PIL import Image from src.config import CLASS_DISPLAY_NAMES, CLASS_NAMES from src.modeling import predict, weighted_ensemble_cam CUSTOM_CSS = """ /* ── Design tokens ─────────────────────────────────────────────────── */ :root { --bg: #080c14; --surface: #0f1520; --surface-2: #151d2c; --border: rgba(255,255,255,.08); --border-2: rgba(255,255,255,.13); --text: #f1f5f9; --text-2: #8a96aa; --text-3: #5a6478; --accent: #7c6fff; --accent-2: #40c4ff; --radius: 14px; --radius-sm: 8px; --font: "DM Sans", ui-sans-serif, system-ui, sans-serif; --font-mono: "DM Mono", ui-monospace, monospace; } /* ── Base ──────────────────────────────────────────────────────────── */ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap'); html, body, .gradio-container { background: var(--bg) !important; color: var(--text) !important; font-family: var(--font) !important; } .gradio-container { max-width: 1180px !important; margin: 0 auto !important; padding: 0 20px !important; } /* Kill Gradio's default chrome */ .gradio-container .block, .gradio-container .form, .gradio-container .panel, .gradio-container .wrap, .gradio-container .gr-box { background: transparent !important; border: none !important; box-shadow: none !important; } .gradio-container label, .gradio-container label span, .gradio-container .info, .gradio-container p, .gradio-container span { color: var(--text-2) !important; font-family: var(--font) !important; } /* ── Hero ──────────────────────────────────────────────────────────── */ .hero { padding: 36px 0 28px; border-bottom: 1px solid var(--border); margin-bottom: 28px; } .hero h1 { margin: 0 !important; font-size: 2rem !important; font-weight: 700 !important; letter-spacing: -0.03em !important; color: var(--text) !important; } .hero p { margin: 4px 0 0 !important; font-size: .95rem !important; color: var(--text-2) !important; } .chip-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } .chip { display: inline-flex; align-items: center; height: 30px; padding: 0 12px; border-radius: 99px; font-size: .8rem; font-weight: 500; background: var(--surface-2); border: 1px solid var(--border-2); color: var(--text-2); } .chip:first-child { background: rgba(124,111,255,.15); border-color: rgba(124,111,255,.4); color: #b3acff; } /* ── Card ──────────────────────────────────────────────────────────── */ .card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: var(--radius) !important; padding: 20px !important; } .card + .card { margin-top: 12px; } .card-label { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-3); margin-bottom: 14px; } /* ── Upload / heatmap images ───────────────────────────────────────── */ #mri_upload, #heatmap_preview { background: transparent !important; border: 0 !important; box-shadow: none !important; width: 100% !important; } #mri_upload > div, #mri_upload .wrap, #mri_upload .block, #mri_upload .form, #heatmap_preview > div, #heatmap_preview .wrap, #heatmap_preview .block, #heatmap_preview .form { background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important; width: 100% !important; overflow: visible !important; } #mri_upload .image-container, #mri_upload .upload-container, #mri_upload [data-testid="image"], #mri_upload .empty, #heatmap_preview .image-container, #heatmap_preview .upload-container, #heatmap_preview [data-testid="image"], #heatmap_preview .empty { width: 100% !important; border-radius: var(--radius) !important; background: var(--surface-2) !important; border: 1px dashed var(--border-2) !important; box-shadow: none !important; } #mri_upload .image-container, #mri_upload .upload-container, #mri_upload [data-testid="image"], #mri_upload .empty { min-height: 420px !important; height: 420px !important; } #heatmap_preview .image-container, #heatmap_preview .upload-container, #heatmap_preview [data-testid="image"], #heatmap_preview .empty { min-height: 240px !important; height: 240px !important; } #mri_upload img, #heatmap_preview img { width: 100% !important; height: 100% !important; object-fit: contain !important; border-radius: var(--radius) !important; background: var(--surface-2) !important; } /* Extra bottom padding on upload card so source buttons are never clipped */ #upload_card { padding-bottom: 32px !important; } /* ── Upload / paste source buttons ─────────────────────────────────── */ /* Tab strip container */ #mri_upload .tabs, #mri_upload [role="tablist"], #mri_upload .tab-nav { display: flex !important; flex-direction: row !important; gap: 8px !important; padding: 0 0 12px 0 !important; background: transparent !important; border: none !important; opacity: 1 !important; visibility: visible !important; position: relative !important; z-index: 30 !important; } /* Each tab / icon button */ #mri_upload [role="tab"], #mri_upload .tab-nav button, #mri_upload .tabs button, #mri_upload button.tab-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; min-width: 44px !important; min-height: 44px !important; padding: 10px 12px !important; border-radius: var(--radius-sm) !important; background: #1e2d45 !important; border: 1px solid rgba(255,255,255,.18) !important; color: #e2e8f0 !important; font-size: .85rem !important; font-weight: 500 !important; opacity: 1 !important; visibility: visible !important; z-index: 30 !important; cursor: pointer !important; box-shadow: 0 2px 8px rgba(0,0,0,.35) !important; transition: background .15s !important; } #mri_upload [role="tab"]:hover, #mri_upload .tabs button:hover { background: #283d57 !important; } /* Force SVG icons inside the buttons to be white/light */ #mri_upload [role="tab"] svg, #mri_upload .tabs button svg, #mri_upload button svg { color: #e2e8f0 !important; fill: #e2e8f0 !important; stroke: #e2e8f0 !important; width: 20px !important; height: 20px !important; opacity: 1 !important; } /* Active / selected tab */ #mri_upload [role="tab"][aria-selected="true"], #mri_upload [role="tab"].selected { background: var(--accent) !important; border-color: var(--accent) !important; color: #fff !important; } #mri_upload [role="tab"][aria-selected="true"] svg, #mri_upload [role="tab"].selected svg { color: #fff !important; fill: #fff !important; stroke: #fff !important; } /* ── Checkbox ──────────────────────────────────────────────────────── */ #heatmap_checkbox { margin-top: 12px !important; } #heatmap_checkbox label { display: flex !important; align-items: center !important; gap: 10px !important; padding: 12px 14px !important; border-radius: var(--radius-sm) !important; background: var(--surface-2) !important; border: 1px solid var(--border) !important; min-height: auto !important; } #heatmap_checkbox input[type="checkbox"] { appearance: none !important; -webkit-appearance: none !important; width: 20px !important; height: 20px !important; min-width: 20px !important; border-radius: 6px !important; background: var(--bg) !important; border: 1.5px solid var(--border-2) !important; cursor: pointer !important; } #heatmap_checkbox input[type="checkbox"]:checked { background-color: var(--accent) !important; border-color: var(--accent) !important; background-image: url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.2 10.4L8.1 14.2L15.9 5.8' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important; background-repeat: no-repeat !important; background-position: center !important; } #heatmap_checkbox span { color: var(--text) !important; font-size: .88rem !important; font-weight: 500 !important; } /* ── Run button ────────────────────────────────────────────────────── */ #run_button button { width: 100% !important; margin-top: 10px !important; height: 44px !important; border-radius: var(--radius-sm) !important; background: var(--accent) !important; color: #fff !important; font-weight: 600 !important; font-size: .92rem !important; border: none !important; box-shadow: 0 0 0 1px rgba(124,111,255,.3), 0 4px 16px rgba(124,111,255,.2) !important; transition: opacity .15s !important; letter-spacing: -.01em !important; } #run_button button:hover { opacity: .88 !important; } /* ── Result card ───────────────────────────────────────────────────── */ .result-card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: var(--radius) !important; padding: 22px 24px !important; margin-bottom: 12px; } .pred-eyebrow { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--text-3); margin-bottom: 8px; } .pred-label { font-size: 2.2rem; font-weight: 700; letter-spacing: -0.045em; color: var(--text); line-height: 1; margin-bottom: 6px; } .pred-sub { font-size: .85rem; color: var(--text-2); margin-bottom: 20px; } .pred-sub b { color: #b3acff; font-weight: 600; } /* Metric row — always 3 columns */ .metric-grid { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 10px !important; } .metric { padding: 12px 14px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); } .metric .k { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--text-3); margin-bottom: 5px; } .metric .v { font-size: 1.05rem; font-weight: 700; color: var(--text); font-family: var(--font-mono); } .metric .v.confidence { color: #b3acff; } /* ── Probability card ──────────────────────────────────────────────── */ .prob-card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: var(--radius) !important; padding: 22px 24px !important; margin-bottom: 12px; } .prob-title { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--text-3); margin-bottom: 16px; } .prob-item { margin-bottom: 14px; } .prob-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; } .prob-label { font-size: .88rem; font-weight: 500; color: var(--text-2); } .prob-item.top .prob-label { color: var(--text); } .prob-percent { font-size: .82rem; font-weight: 600; color: var(--text-3); font-family: var(--font-mono); } .prob-item.top .prob-percent { color: #b3acff; } .prob-track { height: 4px; border-radius: 99px; background: var(--surface-2); overflow: hidden; } .prob-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); } /* ── Details card ──────────────────────────────────────────────────── */ .details-card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: var(--radius) !important; padding: 22px 24px !important; margin-bottom: 12px; } .details-title { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--text-3); margin-bottom: 6px; } .details-subtitle { font-size: .82rem; color: var(--text-3); margin-bottom: 18px; line-height: 1.5; } /* Member row — 4-column grid */ .member-row { display: grid !important; grid-template-columns: 1.8fr 1fr 1fr 1fr !important; gap: 10px !important; align-items: start !important; padding: 14px !important; border-radius: var(--radius-sm) !important; background: var(--surface-2) !important; border: 1px solid var(--border) !important; margin-bottom: 10px !important; } .member-name { font-size: .9rem; font-weight: 600; color: var(--text); line-height: 1.3; } .member-meta { margin-top: 4px; font-size: .78rem; color: var(--text-3); } .detail-pill { padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--border); } .detail-key { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-3); margin-bottom: 4px; } .detail-value { font-size: .9rem; font-weight: 600; color: var(--text); font-family: var(--font-mono); } .detail-value.accent { color: #b3acff; } .vote-track { margin-top: 6px; height: 3px; border-radius: 99px; background: rgba(255,255,255,.08); overflow: hidden; } .vote-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); } /* ── Responsive ────────────────────────────────────────────────────── */ @media (max-width: 860px) { .metric-grid { grid-template-columns: repeat(3, 1fr) !important; } .member-row { grid-template-columns: 1fr 1fr !important; } } @media (max-width: 560px) { .metric-grid { grid-template-columns: 1fr 1fr !important; } .member-row { grid-template-columns: 1fr !important; } .pred-label { font-size: 1.7rem; } } """ CLASS_CHIPS_HTML = "".join( f"{html.escape(CLASS_DISPLAY_NAMES[name])}" for name in CLASS_NAMES ) HERO_HTML = f"""

LCVC DeepFuse

Brain MRI Ensemble Classifier · EfficientNet-B0 + MobileNetV3-Small

{CLASS_CHIPS_HTML}
""" def _pct(value: float) -> str: return f"{100.0 * float(value):.2f}%" def _empty_prediction_card() -> str: return """
Top Prediction
Waiting
Upload an MRI image, then run prediction.
Input size
Model votes
3
Confidence
""" def _prediction_card(label: str, confidence: float, image: Image.Image) -> str: width, height = image.size if image is not None else (0, 0) label = html.escape(label) confidence_text = _pct(confidence) return f"""
Top Prediction
{label}
Confidence: {confidence_text}
Input size
{width}×{height}
Model votes
3
Confidence
{confidence_text}
""" def _probabilities_card(probabilities: dict[str, float] | None = None, top_class: str | None = None) -> str: probabilities = probabilities or {name: 0.0 for name in CLASS_NAMES} rows: list[tuple[str, str, float]] = [] for class_name in CLASS_NAMES: display = CLASS_DISPLAY_NAMES[class_name] probability = float(probabilities.get(class_name, 0.0)) rows.append((class_name, display, probability)) rows.sort(key=lambda item: item[2], reverse=True) items = [] for class_name, display, probability in rows: percent = max(0.0, min(100.0, probability * 100.0)) top_cls = " top" if class_name == top_class else "" items.append( f"""
{html.escape(display)} {percent:.2f}%
""" ) return f"""
Class Probabilities
{''.join(items)}
""" def _empty_details_card() -> str: return """
Model Contributions
Run prediction to see each checkpoint's confidence, weight, and vote strength.
Waiting for image
EfficientNet-B0 + MobileNetV3-Small
Predicts
Confidence
Weighted vote
""" def _details_card(member_df) -> str: if member_df is None or len(member_df) == 0: return _empty_details_card() rows_html = [] for _, row in member_df.iterrows(): member = html.escape(str(row.get("member", "Model"))) weight = float(row.get("weight", 0.0)) pred = html.escape(str(row.get("member prediction", "—"))) conf = float(row.get("member confidence", 0.0)) weighted_vote = max(0.0, min(1.0, weight * conf)) vote_pct = weighted_vote * 100.0 rows_html.append( f"""
{member}
Weight: {weight * 100.0:.1f}%
Predicts
{pred}
Confidence
{conf * 100.0:.1f}%
Weighted vote
{vote_pct:.1f}%
""" ) return f"""
Model Contributions
Weighted vote = confidence × ensemble weight.
{''.join(rows_html)}
""" def run_prediction(image: Image.Image, make_heatmap: bool): if image is None: raise gr.Error("Upload an MRI image first.") try: result = predict(image) heatmap = weighted_ensemble_cam(image, result.predicted_class) if make_heatmap else None prediction_html = _prediction_card(result.predicted_display, result.confidence, image) probabilities_html = _probabilities_card(result.probabilities, result.predicted_class) details_html = _details_card(result.member_df) return prediction_html, probabilities_html, details_html, heatmap except FileNotFoundError as exc: raise gr.Error(str(exc)) from exc except Exception as exc: detail = traceback.format_exc(limit=3) raise gr.Error(f"Prediction failed: {exc}\n\n{detail}") from exc with gr.Blocks( css=CUSTOM_CSS, theme=gr.themes.Base(primary_hue="violet", secondary_hue="cyan", neutral_hue="slate"), title="LCVC DeepFuse", ) as demo: gr.HTML(HERO_HTML) with gr.Row(equal_height=False): with gr.Column(scale=4, min_width=320): with gr.Group(elem_classes=["card", "upload-panel"], elem_id="upload_card"): gr.HTML('
MRI Image
') image_input = gr.Image( label="Upload MRI Image", show_label=False, type="pil", height=420, sources=["upload", "clipboard"], elem_id="mri_upload", ) heatmap_toggle = gr.Checkbox( value=False, label="Generate Grad-CAM heatmap", elem_id="heatmap_checkbox", ) run_button = gr.Button("Run Prediction", variant="primary", elem_id="run_button") with gr.Group(elem_classes=["card", "heatmap-panel"]): gr.HTML('
Grad-CAM Heatmap
') heatmap_output = gr.Image( label="Heatmap", show_label=False, type="pil", height=240, elem_id="heatmap_preview", ) with gr.Column(scale=6, min_width=380): prediction_html_out = gr.HTML(_empty_prediction_card()) probabilities_html_out = gr.HTML(_probabilities_card()) details_html_out = gr.HTML(_empty_details_card()) run_button.click( fn=run_prediction, inputs=[image_input, heatmap_toggle], outputs=[prediction_html_out, probabilities_html_out, details_html_out, heatmap_output], ) if __name__ == "__main__": demo.queue(max_size=16).launch()