vimdhayak commited on
Commit
37be4f0
·
verified ·
1 Parent(s): b88264c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +252 -136
app.py CHANGED
@@ -4,46 +4,55 @@ import html
4
  import traceback
5
 
6
  import gradio as gr
7
- import pandas as pd
8
  from PIL import Image
9
 
10
- 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
  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
 
@@ -53,12 +62,12 @@ CUSTOM_CSS = """
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;
@@ -71,7 +80,7 @@ CUSTOM_CSS = """
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 {
@@ -81,20 +90,26 @@ CUSTOM_CSS = """
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 {
@@ -116,7 +131,7 @@ CUSTOM_CSS = """
116
 
117
  .pred-title {
118
  font-size: 1.02rem;
119
- color: var(--ink);
120
  font-weight: 780;
121
  margin-bottom: 24px;
122
  }
@@ -136,7 +151,7 @@ CUSTOM_CSS = """
136
  }
137
 
138
  .pred-sub b {
139
- color: var(--primary);
140
  }
141
 
142
  .metric-grid {
@@ -149,14 +164,14 @@ CUSTOM_CSS = """
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
  }
@@ -169,14 +184,15 @@ CUSTOM_CSS = """
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;
@@ -186,10 +202,10 @@ CUSTOM_CSS = """
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 {
@@ -200,26 +216,20 @@ CUSTOM_CSS = """
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
 
@@ -227,32 +237,139 @@ CUSTOM_CSS = """
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
 
@@ -270,43 +387,6 @@ HERO_HTML = f"""
270
  """
271
 
272
 
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:
280
- _ok, df, _message = diagnose_checkpoints()
281
- return df
282
-
283
-
284
- def _research_metrics_table() -> pd.DataFrame:
285
- return pd.DataFrame(
286
- [
287
- {"metric": "Validation Macro-F1", "value": "0.994487"},
288
- {"metric": "Test Accuracy", "value": "0.990637"},
289
- {"metric": "Test Macro-F1", "value": "0.990633"},
290
- {"metric": "Test Balanced Accuracy", "value": "0.990640"},
291
- {"metric": "Test Macro AUC OVR", "value": "0.999339"},
292
- {"metric": "Test ECE", "value": "0.008194"},
293
- ]
294
- )
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
 
@@ -353,7 +433,6 @@ def _probabilities_card(probabilities: dict[str, float] | None = None, top_class
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 = []
@@ -380,6 +459,69 @@ def _probabilities_card(probabilities: dict[str, float] | None = None, top_class
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.")
@@ -389,7 +531,8 @@ def run_prediction(image: Image.Image, make_heatmap: bool):
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:
@@ -397,20 +540,9 @@ def run_prediction(image: Image.Image, make_heatmap: bool):
397
  raise gr.Error(f"Prediction failed: {exc}\n\n{detail}") from exc
398
 
399
 
400
- def warmup_status() -> str:
401
- ok, _df, message = diagnose_checkpoints()
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)
@@ -445,30 +577,14 @@ with gr.Blocks(
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
 
 
4
  import traceback
5
 
6
  import gradio as gr
 
7
  from PIL import Image
8
 
9
+ from src.config import CLASS_DISPLAY_NAMES, CLASS_NAMES
10
+ from src.modeling import predict, weighted_ensemble_cam
11
 
12
 
13
  CUSTOM_CSS = """
14
  :root {
15
+ --bg-0: #050816;
16
+ --bg-1: #070b1d;
17
+ --bg-2: #0b1024;
18
+ --card: rgba(15, 23, 42, .82);
19
+ --card-2: rgba(17, 25, 48, .92);
20
+ --ink: #f8fafc;
21
+ --muted: #a9b5cc;
22
+ --soft: #cbd5e1;
23
+ --line: rgba(148, 163, 184, .22);
24
+ --line-strong: rgba(148, 163, 184, .34);
25
+ --primary: #8b5cf6;
26
+ --primary-2: #22d3ee;
27
+ --hot: #f97316;
28
+ --good: #38bdf8;
29
+ --shadow: 0 24px 70px rgba(0, 0, 0, .38);
30
+ --radius-xl: 28px;
31
+ --radius-lg: 20px;
32
+ }
33
+
34
+ html, body, .gradio-container {
35
+ background:
36
+ radial-gradient(circle at 8% 0%, rgba(139, 92, 246, .22), transparent 34%),
37
+ radial-gradient(circle at 92% 3%, rgba(34, 211, 238, .17), transparent 30%),
38
+ radial-gradient(circle at 48% 104%, rgba(59, 130, 246, .13), transparent 42%),
39
+ linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 46%, var(--bg-2) 100%) !important;
40
  }
41
 
42
  .gradio-container {
43
  max-width: 1240px !important;
44
  margin: auto !important;
 
 
 
 
 
45
  color: var(--ink) !important;
46
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
47
  }
48
 
49
  .hero-card {
50
+ padding: 40px 40px 34px;
51
  border-radius: var(--radius-xl);
52
+ background: linear-gradient(135deg, rgba(15,23,42,.92), rgba(17,24,39,.78));
53
+ border: 1px solid var(--line-strong);
54
  box-shadow: var(--shadow);
55
+ backdrop-filter: blur(18px);
56
  margin: 10px 0 24px;
57
  }
58
 
 
62
  font-size: clamp(2.5rem, 5vw, 4.15rem) !important;
63
  line-height: 1.02 !important;
64
  letter-spacing: -0.055em !important;
65
+ font-weight: 930 !important;
66
  }
67
 
68
  .hero-card h2 {
69
  margin: 8px 0 0 !important;
70
+ color: #dbeafe;
71
  font-size: clamp(1.45rem, 2.5vw, 2rem) !important;
72
  line-height: 1.18 !important;
73
  font-weight: 780 !important;
 
80
  gap: 12px;
81
  margin-top: 30px;
82
  padding-top: 24px;
83
+ border-top: 1px solid var(--line);
84
  }
85
 
86
  .class-chip {
 
90
  min-height: 46px;
91
  padding: 0 22px;
92
  border-radius: 16px;
93
+ background: rgba(255,255,255,.06);
94
+ border: 1px solid rgba(148,163,184,.28);
95
+ color: #e5e7eb;
96
  font-size: .98rem;
97
  font-weight: 760;
98
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 8px 22px rgba(0,0,0,.16);
99
+ }
100
+
101
+ .class-chip:first-child {
102
+ background: linear-gradient(135deg, var(--primary), #5b4ff5);
103
+ border-color: rgba(196,181,253,.58);
104
+ color: white;
105
  }
106
 
107
+ .glass-card, .result-card, .prob-card, .details-card {
108
  border-radius: var(--radius-xl) !important;
109
  background: var(--card) !important;
110
+ border: 1px solid var(--line-strong) !important;
111
  box-shadow: var(--shadow) !important;
112
+ backdrop-filter: blur(18px);
113
  }
114
 
115
  .glass-card {
 
131
 
132
  .pred-title {
133
  font-size: 1.02rem;
134
+ color: #dbeafe;
135
  font-weight: 780;
136
  margin-bottom: 24px;
137
  }
 
151
  }
152
 
153
  .pred-sub b {
154
+ color: #c4b5fd;
155
  }
156
 
157
  .metric-grid {
 
164
  .metric {
165
  min-height: 84px;
166
  padding: 18px 14px;
167
+ border-radius: 18px;
168
+ background: rgba(255,255,255,.055);
169
+ border: 1px solid rgba(148,163,184,.22);
170
  text-align: center;
171
  }
172
 
173
  .metric .k {
174
+ color: #a8b3ca;
175
  font-size: .93rem;
176
  font-weight: 650;
177
  }
 
184
  }
185
 
186
  .metric .v.confidence {
187
+ color: #c4b5fd;
188
  }
189
 
190
+ .prob-card, .details-card {
191
  padding: 30px 34px;
192
+ margin-bottom: 24px;
193
  }
194
 
195
+ .prob-title, .details-title {
196
  margin: 0 0 22px;
197
  color: var(--ink);
198
  font-size: 1.25rem;
 
202
 
203
  .prob-item {
204
  padding: 20px 28px 24px;
205
+ border-radius: 20px;
206
+ background: rgba(255,255,255,.055);
207
+ border: 1px solid rgba(148,163,184,.20);
208
+ margin-bottom: 18px;
209
  }
210
 
211
  .prob-head {
 
216
  margin-bottom: 18px;
217
  }
218
 
219
+ .prob-label, .prob-percent {
220
  color: var(--ink);
221
  font-size: 1.08rem;
222
  font-weight: 820;
223
  }
224
 
 
 
 
 
 
 
225
  .prob-item.top .prob-percent {
226
+ color: #c4b5fd;
227
  }
228
 
229
  .prob-track {
230
  height: 10px;
231
  border-radius: 999px;
232
+ background: rgba(148,163,184,.20);
233
  overflow: hidden;
234
  }
235
 
 
237
  height: 100%;
238
  border-radius: 999px;
239
  background: linear-gradient(90deg, var(--primary), var(--primary-2));
240
+ box-shadow: 0 0 20px rgba(139,92,246,.28);
241
+ }
242
+
243
+ .details-subtitle {
244
+ margin: -8px 0 22px;
245
+ color: var(--muted);
246
+ font-size: .96rem;
247
+ line-height: 1.5;
248
+ }
249
+
250
+ .member-row {
251
+ display: grid;
252
+ grid-template-columns: 1.4fr .9fr .75fr .75fr;
253
+ gap: 14px;
254
+ align-items: stretch;
255
+ padding: 16px;
256
+ border-radius: 20px;
257
+ background: rgba(255,255,255,.055);
258
+ border: 1px solid rgba(148,163,184,.20);
259
+ margin-bottom: 16px;
260
+ }
261
+
262
+ .member-name {
263
+ color: var(--ink);
264
+ font-size: 1rem;
265
+ font-weight: 850;
266
+ line-height: 1.3;
267
+ }
268
+
269
+ .member-meta {
270
+ margin-top: 8px;
271
+ color: var(--muted);
272
+ font-size: .88rem;
273
+ font-weight: 650;
274
+ }
275
+
276
+ .detail-pill {
277
+ min-height: 72px;
278
+ padding: 13px 14px;
279
+ border-radius: 16px;
280
+ background: rgba(2,6,23,.38);
281
+ border: 1px solid rgba(148,163,184,.16);
282
  }
283
 
284
+ .detail-key {
285
+ color: #96a3bb;
286
+ font-size: .78rem;
287
+ font-weight: 760;
288
+ text-transform: uppercase;
289
+ letter-spacing: .06em;
290
+ }
291
+
292
+ .detail-value {
293
+ margin-top: 8px;
294
+ color: var(--ink);
295
+ font-size: 1rem;
296
+ font-weight: 850;
297
+ }
298
+
299
+ .detail-value.accent {
300
+ color: #c4b5fd;
301
+ }
302
+
303
+ .vote-track {
304
+ margin-top: 10px;
305
+ height: 8px;
306
+ border-radius: 999px;
307
+ overflow: hidden;
308
+ background: rgba(148,163,184,.20);
309
+ }
310
+
311
+ .vote-fill {
312
+ height: 100%;
313
+ border-radius: 999px;
314
+ background: linear-gradient(90deg, #8b5cf6, #22d3ee);
315
+ }
316
+
317
+ #run_button button, button.primary {
318
  border-radius: 16px !important;
319
+ min-height: 50px !important;
320
+ font-weight: 870 !important;
321
+ color: white !important;
322
+ border: 1px solid rgba(196,181,253,.45) !important;
323
+ background: linear-gradient(135deg, var(--primary), #5b4ff5) !important;
324
+ box-shadow: 0 12px 28px rgba(91,79,245,.26) !important;
325
  }
326
 
327
  .footer-note {
328
+ color: #94a3b8;
329
  font-size: .92rem;
330
+ margin: 6px 0 20px;
331
+ }
332
+
333
+ /* Darken Gradio's native upload/preview controls so the image and heatmap do not sit inside light boxes. */
334
+ .gradio-container .block,
335
+ .gradio-container .form,
336
+ .gradio-container .panel,
337
+ .gradio-container .wrap,
338
+ .gradio-container .gr-box,
339
+ .gradio-container .input-container,
340
+ .gradio-container .output-container,
341
+ .gradio-container .image-container,
342
+ .gradio-container .upload-container,
343
+ .gradio-container [data-testid="image"],
344
+ .gradio-container .gradio-image {
345
+ background: rgba(2, 6, 23, .42) !important;
346
+ border-color: rgba(148, 163, 184, .22) !important;
347
+ color: var(--ink) !important;
348
+ border-radius: 20px !important;
349
  }
350
 
351
+ .gradio-container label,
352
+ .gradio-container label span,
353
+ .gradio-container .checkbox-label,
354
+ .gradio-container .info,
355
+ .gradio-container p,
356
+ .gradio-container span {
357
+ color: var(--soft) !important;
358
+ }
359
 
360
+ .gradio-container input,
361
+ .gradio-container textarea,
362
+ .gradio-container select {
363
+ background: rgba(2,6,23,.72) !important;
364
+ color: var(--ink) !important;
365
+ border-color: rgba(148,163,184,.24) !important;
366
  }
367
 
368
  @media (max-width: 900px) {
369
+ .hero-card { padding: 28px 20px; }
370
  .metric-grid { grid-template-columns: 1fr; }
371
+ .member-row { grid-template-columns: 1fr; }
372
+ .prob-card, .result-card, .details-card { padding: 24px 20px; }
373
  }
374
  """
375
 
 
387
  """
388
 
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  def _pct(value: float) -> str:
391
  return f"{100.0 * float(value):.2f}%"
392
 
 
433
  probability = float(probabilities.get(class_name, 0.0))
434
  rows.append((class_name, display, probability))
435
 
 
436
  rows.sort(key=lambda item: item[2], reverse=True)
437
 
438
  items = []
 
459
  """
460
 
461
 
462
+ def _empty_details_card() -> str:
463
+ return """
464
+ <div class="details-card">
465
+ <div class="details-title">Model Contribution Details</div>
466
+ <div class="details-subtitle">Run prediction to see each deployed checkpoint's prediction, confidence, ensemble weight, and weighted vote strength.</div>
467
+ <div class="member-row">
468
+ <div>
469
+ <div class="member-name">Waiting for image</div>
470
+ <div class="member-meta">EfficientNet-B0 + MobileNetV3-Small ensemble</div>
471
+ </div>
472
+ <div class="detail-pill"><div class="detail-key">Predicts</div><div class="detail-value">—</div></div>
473
+ <div class="detail-pill"><div class="detail-key">Confidence</div><div class="detail-value">—</div></div>
474
+ <div class="detail-pill"><div class="detail-key">Weighted vote</div><div class="detail-value accent">—</div></div>
475
+ </div>
476
+ </div>
477
+ """
478
+
479
+
480
+ def _details_card(member_df) -> str:
481
+ if member_df is None or len(member_df) == 0:
482
+ return _empty_details_card()
483
+
484
+ rows_html = []
485
+ for _, row in member_df.iterrows():
486
+ member = html.escape(str(row.get("member", "Model")))
487
+ weight = float(row.get("weight", 0.0))
488
+ pred = html.escape(str(row.get("member prediction", "—")))
489
+ conf = float(row.get("member confidence", 0.0))
490
+ weighted_vote = max(0.0, min(1.0, weight * conf))
491
+
492
+ rows_html.append(
493
+ f"""
494
+ <div class="member-row">
495
+ <div>
496
+ <div class="member-name">{member}</div>
497
+ <div class="member-meta">Ensemble weight: {weight * 100.0:.2f}%</div>
498
+ </div>
499
+ <div class="detail-pill">
500
+ <div class="detail-key">Predicts</div>
501
+ <div class="detail-value">{pred}</div>
502
+ </div>
503
+ <div class="detail-pill">
504
+ <div class="detail-key">Confidence</div>
505
+ <div class="detail-value">{conf * 100.0:.2f}%</div>
506
+ </div>
507
+ <div class="detail-pill">
508
+ <div class="detail-key">Weighted vote</div>
509
+ <div class="detail-value accent">{weighted_vote * 100.0:.2f}%</div>
510
+ <div class="vote-track"><div class="vote-fill" style="width:{weighted_vote * 100.0:.4f}%"></div></div>
511
+ </div>
512
+ </div>
513
+ """
514
+ )
515
+
516
+ return f"""
517
+ <div class="details-card">
518
+ <div class="details-title">Model Contribution Details</div>
519
+ <div class="details-subtitle">Weighted vote = model confidence for its predicted class × optimized ensemble weight.</div>
520
+ {''.join(rows_html)}
521
+ </div>
522
+ """
523
+
524
+
525
  def run_prediction(image: Image.Image, make_heatmap: bool):
526
  if image is None:
527
  raise gr.Error("Upload an MRI image first.")
 
531
  heatmap = weighted_ensemble_cam(image, result.predicted_class) if make_heatmap else None
532
  prediction_html = _prediction_card(result.predicted_display, result.confidence, image)
533
  probabilities_html = _probabilities_card(result.probabilities, result.predicted_class)
534
+ details_html = _details_card(result.member_df)
535
+ return prediction_html, probabilities_html, details_html, heatmap
536
  except FileNotFoundError as exc:
537
  raise gr.Error(str(exc)) from exc
538
  except Exception as exc:
 
540
  raise gr.Error(f"Prediction failed: {exc}\n\n{detail}") from exc
541
 
542
 
 
 
 
 
 
 
 
 
 
 
 
543
  with gr.Blocks(
544
  css=CUSTOM_CSS,
545
+ theme=gr.themes.Base(primary_hue="violet", secondary_hue="cyan", neutral_hue="slate"),
546
  title="LCVC DeepFuse",
547
  ) as demo:
548
  gr.HTML(HERO_HTML)
 
577
  with gr.Column(scale=7, min_width=420):
578
  prediction_html = gr.HTML(_empty_prediction_card())
579
  probabilities_html = gr.HTML(_probabilities_card())
580
+ details_html = gr.HTML(_empty_details_card())
581
 
582
  run_button.click(
583
  fn=run_prediction,
584
  inputs=[image_input, heatmap_toggle],
585
+ outputs=[prediction_html, probabilities_html, details_html, heatmap_output],
586
  )
587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  gr.HTML('<div class="footer-note">Research prototype only — not for clinical diagnosis.</div>')
589
 
590