vimdhayak commited on
Commit
b88264c
·
verified ·
1 Parent(s): 2dbe7eb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +340 -201
app.py CHANGED
@@ -1,9 +1,9 @@
1
  from __future__ import annotations
2
 
 
3
  import traceback
4
 
5
  import gradio as gr
6
- import matplotlib.pyplot as plt
7
  import pandas as pd
8
  from PIL import Image
9
 
@@ -11,144 +11,261 @@ from src.config import CLASS_DISPLAY_NAMES, CLASS_NAMES, ENSEMBLE_MEMBERS
11
  from src.modeling import diagnose_checkpoints, load_ensemble, predict, weighted_ensemble_cam
12
 
13
 
14
- DISCLAIMER = """
15
- <div class="notice">
16
- <b>Research prototype only.</b> This Space is for demonstrating a trained MRI image classifier from the submitted research workflow. It is not a medical device and must not be used for diagnosis, treatment, or patient triage.
17
- </div>
18
- """
19
-
20
-
21
  CUSTOM_CSS = """
22
  :root {
 
 
 
 
 
 
 
 
 
23
  --radius-xl: 26px;
24
  --radius-lg: 18px;
25
- --glass: rgba(255,255,255,0.80);
26
- --stroke: rgba(148,163,184,0.28);
27
- --shadow: 0 18px 60px rgba(15, 23, 42, .16);
28
  }
 
29
  .gradio-container {
30
- max-width: 1220px !important;
31
  margin: auto !important;
32
  background:
33
- radial-gradient(circle at 10% 8%, rgba(99,102,241,.18), transparent 30%),
34
- radial-gradient(circle at 90% 0%, rgba(14,165,233,.17), transparent 28%),
35
- linear-gradient(180deg, #f8fafc 0%, #eef2ff 52%, #f8fafc 100%) !important;
36
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
 
37
  }
38
- .hero {
39
- padding: 34px 36px;
 
40
  border-radius: var(--radius-xl);
41
- background: linear-gradient(135deg, rgba(15,23,42,.96), rgba(30,64,175,.88) 52%, rgba(2,132,199,.84));
42
- color: white;
43
  box-shadow: var(--shadow);
44
- border: 1px solid rgba(255,255,255,.16);
45
- margin-bottom: 20px;
46
  }
47
- .hero h1 {
48
- margin: 0 0 10px 0 !important;
49
- font-size: clamp(2rem, 5vw, 3.8rem) !important;
 
 
50
  line-height: 1.02 !important;
51
- letter-spacing: -0.05em !important;
 
52
  }
53
- .hero p {
54
- font-size: 1.02rem;
55
- color: rgba(255,255,255,.86);
56
- max-width: 880px;
57
- margin: 0;
 
 
 
58
  }
59
- .badges {
 
60
  display: flex;
61
  flex-wrap: wrap;
62
- gap: 10px;
63
- margin-top: 20px;
64
- }
65
- .badge {
66
- border: 1px solid rgba(255,255,255,.22);
67
- background: rgba(255,255,255,.12);
68
- color: #fff;
69
- padding: 8px 12px;
70
- border-radius: 999px;
71
- font-weight: 650;
72
- font-size: .88rem;
73
  }
74
- .notice {
75
- padding: 14px 16px;
 
 
 
 
 
76
  border-radius: 16px;
77
- background: rgba(254, 243, 199, .82);
78
- border: 1px solid rgba(245, 158, 11, .34);
79
- color: #78350f;
80
- margin: 10px 0 18px 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
 
82
  .result-card {
83
- padding: 22px;
84
- border-radius: var(--radius-xl);
85
- background: var(--glass);
86
- border: 1px solid var(--stroke);
87
- box-shadow: 0 14px 44px rgba(15,23,42,.11);
88
- backdrop-filter: blur(12px);
89
  }
 
90
  .pred-title {
91
- font-size: 1.05rem;
92
- text-transform: uppercase;
93
- letter-spacing: .08em;
94
- color: #475569;
95
- font-weight: 800;
96
- margin-bottom: 4px;
97
  }
 
98
  .pred-label {
99
- font-size: clamp(2rem, 4vw, 3.4rem);
100
- line-height: 1;
101
- font-weight: 900;
102
- letter-spacing: -0.045em;
103
- color: #0f172a;
104
  }
 
105
  .pred-sub {
106
- margin-top: 12px;
107
- color: #475569;
108
- font-size: 1rem;
 
 
 
 
109
  }
 
110
  .metric-grid {
111
  display: grid;
112
  grid-template-columns: repeat(3, minmax(0, 1fr));
113
- gap: 12px;
114
- margin-top: 18px;
115
  }
 
116
  .metric {
117
- padding: 14px;
 
118
  border-radius: 16px;
119
- background: rgba(255,255,255,.76);
120
- border: 1px solid rgba(148,163,184,.28);
 
121
  }
122
- .metric .k { color: #64748b; font-size: .8rem; font-weight: 800; text-transform: uppercase; letter-spacing: .07em; }
123
- .metric .v { color: #0f172a; font-size: 1.35rem; font-weight: 900; margin-top: 4px; }
124
- .status-good { color: #166534; font-weight: 800; }
125
- .status-bad { color: #991b1b; font-weight: 800; }
126
- .footer-note { color: #64748b; font-size: .92rem; }
127
- button.primary, .primary button {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  border-radius: 16px !important;
 
129
  font-weight: 850 !important;
 
130
  }
131
- .block, .form, .panel, .tabitem, .gr-box {
 
 
 
 
 
 
 
 
 
 
132
  border-radius: 18px !important;
133
  }
134
- @media (max-width: 800px) {
135
- .hero { padding: 24px 20px; }
 
136
  .metric-grid { grid-template-columns: 1fr; }
 
137
  }
138
  """
139
 
140
 
141
- HERO_HTML = """
142
- <div class="hero">
143
- <h1>Brain MRI Ensemble Classifier</h1>
144
- <p>Upload a brain MRI image and run the selected lightweight weighted-soft ensemble from the research notebook: EfficientNet-B0 + MobileNetV3-Small.</p>
145
- <div class="badges">
146
- <span class="badge">4 classes</span>
147
- <span class="badge">Weighted soft voting</span>
148
- <span class="badge">EfficientNet-B0</span>
149
- <span class="badge">MobileNetV3-Small</span>
150
- <span class="badge">Optional heatmap</span>
151
- </div>
152
  </div>
153
  """
154
 
@@ -156,7 +273,7 @@ HERO_HTML = """
156
  def _status_markdown() -> str:
157
  ok, _df, message = diagnose_checkpoints()
158
  cls = "status-good" if ok else "status-bad"
159
- return f"<div class='{cls}'>{message}</div>"
160
 
161
 
162
  def _model_table() -> pd.DataFrame:
@@ -178,64 +295,101 @@ def _research_metrics_table() -> pd.DataFrame:
178
 
179
 
180
  def _deployed_members_table() -> pd.DataFrame:
181
- rows = []
182
- for m in ENSEMBLE_MEMBERS:
183
- rows.append(
184
  {
185
  "member": m["display_name"],
186
  "weight": f"{m['weight']:.8f}",
187
  "checkpoint": f"models/{m['checkpoint_file']}",
188
  }
189
- )
190
- return pd.DataFrame(rows)
 
 
191
 
 
 
192
 
193
- def _probability_plot(prob_df: pd.DataFrame):
194
- fig, ax = plt.subplots(figsize=(7.5, 4.2))
195
- labels = prob_df["class"].tolist()[::-1]
196
- values = prob_df["probability"].tolist()[::-1]
197
- ax.barh(labels, values)
198
- ax.set_xlim(0, 1)
199
- ax.set_xlabel("Probability")
200
- ax.set_title("Ensemble class probabilities")
201
- ax.grid(axis="x", alpha=0.28)
202
- for idx, value in enumerate(values):
203
- ax.text(min(value + 0.015, 0.98), idx, f"{value*100:.1f}%", va="center", fontweight="bold")
204
- fig.tight_layout()
205
- return fig
 
206
 
207
 
208
- def _prediction_card(label: str, confidence: float, image: Image.Image, heatmap_enabled: bool) -> str:
209
  width, height = image.size if image is not None else (0, 0)
210
- heatmap_text = "Generated" if heatmap_enabled else "Off"
 
211
  return f"""
212
  <div class="result-card">
213
- <div class="pred-title">Top prediction</div>
214
  <div class="pred-label">{label}</div>
215
- <div class="pred-sub">Weighted-soft ensemble confidence: <b>{confidence*100:.2f}%</b></div>
216
  <div class="metric-grid">
217
- <div class="metric"><div class="k">Input size</div><div class="v">{width}×{height}</div></div>
218
  <div class="metric"><div class="k">Model votes</div><div class="v">3</div></div>
219
- <div class="metric"><div class="k">Heatmap</div><div class="v">{heatmap_text}</div></div>
220
  </div>
221
  </div>
222
  """
223
 
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  def run_prediction(image: Image.Image, make_heatmap: bool):
226
  if image is None:
227
  raise gr.Error("Upload an MRI image first.")
228
 
229
  try:
230
  result = predict(image)
231
- prob_df = result.probability_df.copy()
232
- prob_df["probability"] = prob_df["probability"].map(lambda x: round(float(x), 6))
233
- plot = _probability_plot(result.probability_df)
234
- heatmap = None
235
- if make_heatmap:
236
- heatmap = weighted_ensemble_cam(image, result.predicted_class)
237
- card = _prediction_card(result.predicted_display, result.confidence, image, make_heatmap and heatmap is not None)
238
- return card, prob_df, result.member_df, plot, heatmap
239
  except FileNotFoundError as exc:
240
  raise gr.Error(str(exc)) from exc
241
  except Exception as exc:
@@ -248,90 +402,75 @@ def warmup_status() -> str:
248
  if not ok:
249
  return message
250
  try:
251
- # Load once so the first user prediction is faster.
252
  load_ensemble()
253
  return "✅ Checkpoints found and ensemble loaded successfully."
254
  except Exception as exc:
255
  return f"❌ Checkpoints were found, but model loading failed: {exc}"
256
 
257
 
258
- with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="sky"), title="MRI Ensemble Classifier") as demo:
 
 
 
 
259
  gr.HTML(HERO_HTML)
260
- gr.HTML(DISCLAIMER)
261
-
262
- with gr.Tabs():
263
- with gr.Tab("Predict"):
264
- with gr.Row(equal_height=False):
265
- with gr.Column(scale=5):
266
- image_input = gr.Image(
267
- label="Upload MRI image",
268
- type="pil",
269
- height=390,
270
- sources=["upload", "clipboard"],
271
- )
272
- with gr.Row():
273
- heatmap_toggle = gr.Checkbox(
274
- value=False,
275
- label="Generate ensemble heatmap",
276
- info="Slower on CPU. Use after the basic prediction works.",
277
- )
278
- run_button = gr.Button("Run ensemble prediction", variant="primary", elem_classes="primary")
279
- with gr.Column(scale=7):
280
- prediction_html = gr.HTML(
281
- "<div class='result-card'><div class='pred-title'>Waiting for image</div><div class='pred-label'>—</div><div class='pred-sub'>Upload an MRI image and run the selected ensemble.</div></div>",
282
- label="Prediction",
283
- )
284
- probabilities_output = gr.Dataframe(
285
- label="Class probabilities",
286
- headers=["class", "probability", "percent"],
287
- interactive=False,
288
- )
289
- with gr.Row():
290
- probability_plot = gr.Plot(label="Probability chart")
291
- heatmap_output = gr.Image(label="Optional ensemble heatmap", type="pil")
292
- member_output = gr.Dataframe(label="Per-member predictions", interactive=False)
293
-
294
- run_button.click(
295
- fn=run_prediction,
296
- inputs=[image_input, heatmap_toggle],
297
- outputs=[prediction_html, probabilities_output, member_output, probability_plot, heatmap_output],
298
- )
299
-
300
- with gr.Tab("Model status"):
301
- gr.Markdown("### Checkpoint status")
302
- status_md = gr.Markdown(_status_markdown())
303
- status_table = gr.Dataframe(value=_model_table(), interactive=False, label="Required files")
304
- refresh_btn = gr.Button("Refresh status")
305
- load_btn = gr.Button("Test-load ensemble", variant="secondary")
306
- load_status = gr.Textbox(label="Load result", interactive=False)
307
- refresh_btn.click(fn=_status_markdown, inputs=None, outputs=status_md)
308
- refresh_btn.click(fn=_model_table, inputs=None, outputs=status_table)
309
- load_btn.click(fn=warmup_status, inputs=None, outputs=load_status)
310
-
311
- with gr.Tab("Research summary"):
312
- gr.Markdown(
313
- """
314
- ### Selected ensemble
315
-
316
- The deployed model is the selected **`lightweight_effnet_mobilenet | optimized_val_ce_weighted_soft`** ensemble. It uses only the non-zero-weight members from the ablation result. The zero-weight EfficientNet/MobileNet members are not loaded because they do not affect weighted-soft inference.
317
-
318
- Class order used at inference: **glioma, meningioma, notumor, pituitary**.
319
- """
320
- )
321
- gr.Dataframe(value=_deployed_members_table(), label="Deployed members", interactive=False)
322
- gr.Dataframe(value=_research_metrics_table(), label="Reported evaluation metrics", interactive=False)
323
- gr.Markdown(
324
- """
325
- ### Practical interpretation
326
-
327
- High validation/test scores from the research split do not make this a clinical diagnostic tool. Before any real-world medical use, the model would need independent external validation, bias checks, clinical review, calibration review, privacy/security review, and regulatory evaluation.
328
- """
329
- )
330
-
331
- gr.Markdown(
332
- "<div class='footer-note'>Built for Hugging Face Spaces with Gradio. Put all required checkpoint files inside the repository's <code>models/</code> folder.</div>"
333
  )
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
  if __name__ == "__main__":
337
  demo.queue(max_size=16).launch()
 
1
  from __future__ import annotations
2
 
3
+ import html
4
  import traceback
5
 
6
  import gradio as gr
 
7
  import pandas as pd
8
  from PIL import Image
9
 
 
11
  from src.modeling import diagnose_checkpoints, load_ensemble, predict, weighted_ensemble_cam
12
 
13
 
 
 
 
 
 
 
 
14
  CUSTOM_CSS = """
15
  :root {
16
+ --bg-1: #eef2ff;
17
+ --bg-2: #f8fafc;
18
+ --ink: #101936;
19
+ --muted: #53617d;
20
+ --line: rgba(148,163,184,.28);
21
+ --card: rgba(255,255,255,.84);
22
+ --primary: #5b4ff5;
23
+ --primary-2: #6d5dfc;
24
+ --shadow: 0 18px 54px rgba(15, 23, 42, .13);
25
  --radius-xl: 26px;
26
  --radius-lg: 18px;
 
 
 
27
  }
28
+
29
  .gradio-container {
30
+ max-width: 1240px !important;
31
  margin: auto !important;
32
  background:
33
+ radial-gradient(circle at 8% 3%, rgba(99,102,241,.18), transparent 30%),
34
+ radial-gradient(circle at 92% 0%, rgba(14,165,233,.16), transparent 28%),
35
+ linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 56%, #f8fafc 100%) !important;
36
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
37
+ color: var(--ink) !important;
38
  }
39
+
40
+ .hero-card {
41
+ padding: 38px 38px 34px;
42
  border-radius: var(--radius-xl);
43
+ background: rgba(255,255,255,.88);
44
+ border: 1px solid rgba(219,226,238,.95);
45
  box-shadow: var(--shadow);
46
+ backdrop-filter: blur(16px);
47
+ margin: 10px 0 24px;
48
  }
49
+
50
+ .hero-card h1 {
51
+ margin: 0 !important;
52
+ color: var(--ink);
53
+ font-size: clamp(2.5rem, 5vw, 4.15rem) !important;
54
  line-height: 1.02 !important;
55
+ letter-spacing: -0.055em !important;
56
+ font-weight: 900 !important;
57
  }
58
+
59
+ .hero-card h2 {
60
+ margin: 8px 0 0 !important;
61
+ color: #273250;
62
+ font-size: clamp(1.45rem, 2.5vw, 2rem) !important;
63
+ line-height: 1.18 !important;
64
+ font-weight: 780 !important;
65
+ letter-spacing: -0.03em !important;
66
  }
67
+
68
+ .class-chip-row {
69
  display: flex;
70
  flex-wrap: wrap;
71
+ gap: 12px;
72
+ margin-top: 30px;
73
+ padding-top: 24px;
74
+ border-top: 1px solid rgba(148,163,184,.35);
 
 
 
 
 
 
 
75
  }
76
+
77
+ .class-chip {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ min-height: 46px;
82
+ padding: 0 22px;
83
  border-radius: 16px;
84
+ background: rgba(255,255,255,.72);
85
+ border: 1px solid rgba(148,163,184,.32);
86
+ color: #273250;
87
+ font-size: .98rem;
88
+ font-weight: 760;
89
+ box-shadow: 0 8px 22px rgba(15,23,42,.04);
90
+ }
91
+
92
+ .glass-card, .result-card, .prob-card {
93
+ border-radius: var(--radius-xl) !important;
94
+ background: var(--card) !important;
95
+ border: 1px solid rgba(219,226,238,.95) !important;
96
+ box-shadow: var(--shadow) !important;
97
+ backdrop-filter: blur(16px);
98
+ }
99
+
100
+ .glass-card {
101
+ padding: 24px !important;
102
+ }
103
+
104
+ .card-title {
105
+ margin: 0 0 18px;
106
+ font-size: 1.25rem;
107
+ font-weight: 850;
108
+ letter-spacing: -.025em;
109
+ color: var(--ink);
110
  }
111
+
112
  .result-card {
113
+ padding: 30px 34px;
114
+ margin-bottom: 24px;
 
 
 
 
115
  }
116
+
117
  .pred-title {
118
+ font-size: 1.02rem;
119
+ color: var(--ink);
120
+ font-weight: 780;
121
+ margin-bottom: 24px;
 
 
122
  }
123
+
124
  .pred-label {
125
+ font-size: clamp(2.7rem, 4.6vw, 4rem);
126
+ line-height: .98;
127
+ font-weight: 920;
128
+ letter-spacing: -0.055em;
129
+ color: var(--ink);
130
  }
131
+
132
  .pred-sub {
133
+ margin-top: 22px;
134
+ color: var(--muted);
135
+ font-size: 1.08rem;
136
+ }
137
+
138
+ .pred-sub b {
139
+ color: var(--primary);
140
  }
141
+
142
  .metric-grid {
143
  display: grid;
144
  grid-template-columns: repeat(3, minmax(0, 1fr));
145
+ gap: 16px;
146
+ margin-top: 30px;
147
  }
148
+
149
  .metric {
150
+ min-height: 84px;
151
+ padding: 18px 14px;
152
  border-radius: 16px;
153
+ background: rgba(255,255,255,.72);
154
+ border: 1px solid rgba(148,163,184,.26);
155
+ text-align: center;
156
  }
157
+
158
+ .metric .k {
159
+ color: #34415f;
160
+ font-size: .93rem;
161
+ font-weight: 650;
162
+ }
163
+
164
+ .metric .v {
165
+ color: var(--ink);
166
+ font-size: 1.28rem;
167
+ font-weight: 850;
168
+ margin-top: 10px;
169
+ }
170
+
171
+ .metric .v.confidence {
172
+ color: var(--primary);
173
+ }
174
+
175
+ .prob-card {
176
+ padding: 30px 34px;
177
+ }
178
+
179
+ .prob-title {
180
+ margin: 0 0 22px;
181
+ color: var(--ink);
182
+ font-size: 1.25rem;
183
+ font-weight: 850;
184
+ letter-spacing: -.025em;
185
+ }
186
+
187
+ .prob-item {
188
+ padding: 20px 28px 24px;
189
+ border-radius: 18px;
190
+ background: rgba(255,255,255,.74);
191
+ border: 1px solid rgba(148,163,184,.24);
192
+ margin-bottom: 22px;
193
+ }
194
+
195
+ .prob-head {
196
+ display: flex;
197
+ justify-content: space-between;
198
+ align-items: baseline;
199
+ gap: 20px;
200
+ margin-bottom: 18px;
201
+ }
202
+
203
+ .prob-label {
204
+ color: var(--ink);
205
+ font-size: 1.08rem;
206
+ font-weight: 820;
207
+ }
208
+
209
+ .prob-percent {
210
+ color: var(--ink);
211
+ font-size: 1.03rem;
212
+ font-weight: 820;
213
+ }
214
+
215
+ .prob-item.top .prob-percent {
216
+ color: var(--primary);
217
+ }
218
+
219
+ .prob-track {
220
+ height: 10px;
221
+ border-radius: 999px;
222
+ background: #e8edf5;
223
+ overflow: hidden;
224
+ }
225
+
226
+ .prob-fill {
227
+ height: 100%;
228
+ border-radius: 999px;
229
+ background: linear-gradient(90deg, var(--primary), var(--primary-2));
230
+ }
231
+
232
+ #run_button button {
233
  border-radius: 16px !important;
234
+ min-height: 48px !important;
235
  font-weight: 850 !important;
236
+ background: linear-gradient(135deg, var(--primary), var(--primary-2)) !important;
237
  }
238
+
239
+ .footer-note {
240
+ color: #64748b;
241
+ font-size: .92rem;
242
+ margin-top: 18px;
243
+ }
244
+
245
+ .status-good { color: #166534; font-weight: 800; }
246
+ .status-bad { color: #991b1b; font-weight: 800; }
247
+
248
+ .block, .form, .panel, .tabitem, .gr-box, .gradio-container .wrap {
249
  border-radius: 18px !important;
250
  }
251
+
252
+ @media (max-width: 900px) {
253
+ .hero-card { padding: 26px 20px; }
254
  .metric-grid { grid-template-columns: 1fr; }
255
+ .prob-card, .result-card { padding: 24px 20px; }
256
  }
257
  """
258
 
259
 
260
+ CLASS_CHIPS_HTML = "".join(
261
+ f"<span class='class-chip'>{html.escape(CLASS_DISPLAY_NAMES[name])}</span>" for name in CLASS_NAMES
262
+ )
263
+
264
+ HERO_HTML = f"""
265
+ <div class="hero-card">
266
+ <h1>LCVC DeepFuse</h1>
267
+ <h2>Brain MRI Ensemble Classifier</h2>
268
+ <div class="class-chip-row">{CLASS_CHIPS_HTML}</div>
 
 
269
  </div>
270
  """
271
 
 
273
  def _status_markdown() -> str:
274
  ok, _df, message = diagnose_checkpoints()
275
  cls = "status-good" if ok else "status-bad"
276
+ return f"<div class='{cls}'>{html.escape(message).replace(chr(10), '<br>')}</div>"
277
 
278
 
279
  def _model_table() -> pd.DataFrame:
 
295
 
296
 
297
  def _deployed_members_table() -> pd.DataFrame:
298
+ return pd.DataFrame(
299
+ [
 
300
  {
301
  "member": m["display_name"],
302
  "weight": f"{m['weight']:.8f}",
303
  "checkpoint": f"models/{m['checkpoint_file']}",
304
  }
305
+ for m in ENSEMBLE_MEMBERS
306
+ ]
307
+ )
308
+
309
 
310
+ def _pct(value: float) -> str:
311
+ return f"{100.0 * float(value):.2f}%"
312
 
313
+
314
+ def _empty_prediction_card() -> str:
315
+ return """
316
+ <div class="result-card">
317
+ <div class="pred-title">Top Prediction</div>
318
+ <div class="pred-label">Waiting</div>
319
+ <div class="pred-sub">Upload an MRI image, then run prediction.</div>
320
+ <div class="metric-grid">
321
+ <div class="metric"><div class="k">Input size</div><div class="v">—</div></div>
322
+ <div class="metric"><div class="k">Model votes</div><div class="v">3</div></div>
323
+ <div class="metric"><div class="k">Confidence</div><div class="v confidence">—</div></div>
324
+ </div>
325
+ </div>
326
+ """
327
 
328
 
329
+ def _prediction_card(label: str, confidence: float, image: Image.Image) -> str:
330
  width, height = image.size if image is not None else (0, 0)
331
+ label = html.escape(label)
332
+ confidence_text = _pct(confidence)
333
  return f"""
334
  <div class="result-card">
335
+ <div class="pred-title">Top Prediction</div>
336
  <div class="pred-label">{label}</div>
337
+ <div class="pred-sub">Confidence: <b>{confidence_text}</b></div>
338
  <div class="metric-grid">
339
+ <div class="metric"><div class="k">Input size</div><div class="v">{width} × {height}</div></div>
340
  <div class="metric"><div class="k">Model votes</div><div class="v">3</div></div>
341
+ <div class="metric"><div class="k">Confidence</div><div class="v confidence">{confidence_text}</div></div>
342
  </div>
343
  </div>
344
  """
345
 
346
 
347
+ def _probabilities_card(probabilities: dict[str, float] | None = None, top_class: str | None = None) -> str:
348
+ probabilities = probabilities or {name: 0.0 for name in CLASS_NAMES}
349
+
350
+ rows: list[tuple[str, str, float]] = []
351
+ for class_name in CLASS_NAMES:
352
+ display = CLASS_DISPLAY_NAMES[class_name]
353
+ probability = float(probabilities.get(class_name, 0.0))
354
+ rows.append((class_name, display, probability))
355
+
356
+ # Highest probability first, but labels are always the real four class names.
357
+ rows.sort(key=lambda item: item[2], reverse=True)
358
+
359
+ items = []
360
+ for class_name, display, probability in rows:
361
+ percent = max(0.0, min(100.0, probability * 100.0))
362
+ top_class_name = " top" if class_name == top_class else ""
363
+ items.append(
364
+ f"""
365
+ <div class="prob-item{top_class_name}">
366
+ <div class="prob-head">
367
+ <span class="prob-label">{html.escape(display)}</span>
368
+ <span class="prob-percent">{percent:.2f}%</span>
369
+ </div>
370
+ <div class="prob-track"><div class="prob-fill" style="width:{percent:.4f}%"></div></div>
371
+ </div>
372
+ """
373
+ )
374
+
375
+ return f"""
376
+ <div class="prob-card">
377
+ <div class="prob-title">Class Probabilities</div>
378
+ {''.join(items)}
379
+ </div>
380
+ """
381
+
382
+
383
  def run_prediction(image: Image.Image, make_heatmap: bool):
384
  if image is None:
385
  raise gr.Error("Upload an MRI image first.")
386
 
387
  try:
388
  result = predict(image)
389
+ heatmap = weighted_ensemble_cam(image, result.predicted_class) if make_heatmap else None
390
+ prediction_html = _prediction_card(result.predicted_display, result.confidence, image)
391
+ probabilities_html = _probabilities_card(result.probabilities, result.predicted_class)
392
+ return prediction_html, probabilities_html, heatmap
 
 
 
 
393
  except FileNotFoundError as exc:
394
  raise gr.Error(str(exc)) from exc
395
  except Exception as exc:
 
402
  if not ok:
403
  return message
404
  try:
 
405
  load_ensemble()
406
  return "✅ Checkpoints found and ensemble loaded successfully."
407
  except Exception as exc:
408
  return f"❌ Checkpoints were found, but model loading failed: {exc}"
409
 
410
 
411
+ with gr.Blocks(
412
+ css=CUSTOM_CSS,
413
+ theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="sky"),
414
+ title="LCVC DeepFuse",
415
+ ) as demo:
416
  gr.HTML(HERO_HTML)
417
+
418
+ with gr.Row(equal_height=False):
419
+ with gr.Column(scale=5, min_width=360):
420
+ with gr.Group(elem_classes=["glass-card"]):
421
+ gr.HTML('<h3 class="card-title">Upload MRI Image</h3>')
422
+ image_input = gr.Image(
423
+ label="Upload MRI Image",
424
+ show_label=False,
425
+ type="pil",
426
+ height=430,
427
+ sources=["upload", "clipboard"],
428
+ )
429
+ heatmap_toggle = gr.Checkbox(
430
+ value=False,
431
+ label="Generate optional heatmap",
432
+ info=None,
433
+ )
434
+ run_button = gr.Button("Run Prediction", variant="primary", elem_id="run_button")
435
+
436
+ with gr.Group(elem_classes=["glass-card"]):
437
+ gr.HTML('<h3 class="card-title">Optional Heatmap</h3>')
438
+ heatmap_output = gr.Image(
439
+ label="Optional Heatmap",
440
+ show_label=False,
441
+ type="pil",
442
+ height=390,
443
+ )
444
+
445
+ with gr.Column(scale=7, min_width=420):
446
+ prediction_html = gr.HTML(_empty_prediction_card())
447
+ probabilities_html = gr.HTML(_probabilities_card())
448
+
449
+ run_button.click(
450
+ fn=run_prediction,
451
+ inputs=[image_input, heatmap_toggle],
452
+ outputs=[prediction_html, probabilities_html, heatmap_output],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  )
454
 
455
+ with gr.Accordion("Model status and research details", open=False):
456
+ status_md = gr.HTML(_status_markdown())
457
+ status_table = gr.Dataframe(value=_model_table(), interactive=False, label="Required checkpoint files")
458
+ with gr.Row():
459
+ refresh_btn = gr.Button("Refresh status")
460
+ load_btn = gr.Button("Test-load ensemble")
461
+ load_status = gr.Textbox(label="Load result", interactive=False)
462
+
463
+ gr.Markdown("### Deployed members")
464
+ gr.Dataframe(value=_deployed_members_table(), interactive=False, label="Selected non-zero-weight members")
465
+ gr.Markdown("### Reported evaluation metrics")
466
+ gr.Dataframe(value=_research_metrics_table(), interactive=False, label="Research split metrics")
467
+
468
+ refresh_btn.click(fn=_status_markdown, inputs=None, outputs=status_md)
469
+ refresh_btn.click(fn=_model_table, inputs=None, outputs=status_table)
470
+ load_btn.click(fn=warmup_status, inputs=None, outputs=load_status)
471
+
472
+ gr.HTML('<div class="footer-note">Research prototype only — not for clinical diagnosis.</div>')
473
+
474
 
475
  if __name__ == "__main__":
476
  demo.queue(max_size=16).launch()