mekosotto Claude Opus 4.7 (1M context) commited on
Commit
4a861ef
·
1 Parent(s): 985240b

feat(frontend): MRI tab — Pre/Post ComBat KDE + site-gap KPI strip

Browse files

- Replaces the bare /pipeline/mri call with /pipeline/mri/diagnostics
to surface the harmonization story directly to the jury.
- 3 KPI metrics on the top row: Site-gap (Pre), Site-gap (Post), and
Reduction factor (e.g. ~3290× on the synthetic fixture).
- Faceted altair density chart: per-site KDE before vs after ComBat,
with shared axes so the convergence is visually obvious.
- Local altair/pandas imports keep cold-start light when only the BBB
tab is exercised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. src/frontend/app.py +97 -13
src/frontend/app.py CHANGED
@@ -425,22 +425,31 @@ def _render_mri_tab() -> None:
425
  "Multi-site harmonization via ComBat",
426
  "Loads NIfTI volumes, masks brain tissue, computes per-ROI summary "
427
  "statistics, then harmonizes across acquisition sites with neuroHarmonize "
428
- "to remove scanner-driven domain shift.",
 
429
  )
430
- mri_dir = st.text_input("Input NIfTI directory", "data/raw/mri/", key="mri_dir")
431
- sites_csv = st.text_input("Sites CSV", "data/raw/mri/sites.csv", key="mri_sites")
432
- mri_out = st.text_input("Output Parquet path", "data/processed/mri_features.parquet", key="mri_out")
433
- if st.button("Run MRI pipeline", type="primary", key="mri_run"):
434
- with st.spinner("Masking, ROI extraction, and ComBat harmonization…"):
 
 
 
 
 
435
  try:
436
- _render_result(_post("/pipeline/mri", {
437
- "input_dir": mri_dir,
438
- "sites_csv": sites_csv,
439
- "output_path": mri_out,
440
- }))
441
- st.toast("MRI pipeline complete", icon="✅")
442
  except httpx.HTTPStatusError as e:
443
- st.error(f"Pipeline failed (HTTP {e.response.status_code}): {e.response.text}")
 
 
 
444
  except httpx.RequestError as e:
445
  st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
446
 
@@ -519,6 +528,81 @@ def _render_prediction_card(result: dict) -> None:
519
  )
520
 
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  def main() -> None:
523
  """Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
524
  st.set_page_config(
 
425
  "Multi-site harmonization via ComBat",
426
  "Loads NIfTI volumes, masks brain tissue, computes per-ROI summary "
427
  "statistics, then harmonizes across acquisition sites with neuroHarmonize "
428
+ "to remove scanner-driven domain shift. The diagnostic plot below "
429
+ "compares per-site feature distributions before and after harmonization."
430
  )
431
+ mri_dir = st.text_input(
432
+ "Input NIfTI directory", "tests/fixtures/mri_sample", key="mri_dir",
433
+ help="Path to a directory of .nii(.gz) files + sites.csv",
434
+ )
435
+ sites_csv = st.text_input(
436
+ "Sites CSV", "tests/fixtures/mri_sample/sites.csv", key="mri_sites",
437
+ )
438
+
439
+ if st.button("Run ComBat diagnostics", type="primary", key="mri_diag"):
440
+ with st.spinner("Running pre + post ComBat (×2 the work)…"):
441
  try:
442
+ result = _post(
443
+ "/pipeline/mri/diagnostics",
444
+ {"input_dir": mri_dir, "sites_csv": sites_csv},
445
+ )
446
+ _render_combat_diagnostics(result)
447
+ st.toast("Diagnostics complete", icon="✅")
448
  except httpx.HTTPStatusError as e:
449
+ st.error(
450
+ f"Diagnostics failed (HTTP {e.response.status_code}): "
451
+ f"{e.response.text}"
452
+ )
453
  except httpx.RequestError as e:
454
  st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
455
 
 
528
  )
529
 
530
 
531
+ def _render_combat_diagnostics(result: dict) -> None:
532
+ """Render the Pre/Post-ComBat KDE comparison + site-gap KPI strip."""
533
+ import altair as alt
534
+ import pandas as pd
535
+
536
+ rows = result.get("rows", [])
537
+ if not rows:
538
+ st.info(
539
+ "No data returned. Check that the input directory contains "
540
+ ".nii(.gz) files and a sites.csv with subject_id/site columns."
541
+ )
542
+ return
543
+
544
+ cols = st.columns(3)
545
+ cols[0].metric("Site-gap (Pre-ComBat)", f"{result['site_gap_pre']:.4f}")
546
+ cols[1].metric("Site-gap (Post-ComBat)", f"{result['site_gap_post']:.4f}")
547
+ cols[2].metric(
548
+ "Reduction factor",
549
+ f"{result['reduction_factor']:.0f}×",
550
+ help=(
551
+ "Pre-gap / Post-gap. A 100× reduction means ComBat "
552
+ "removed two orders of magnitude of site-driven domain shift."
553
+ ),
554
+ )
555
+
556
+ df = pd.DataFrame(rows)
557
+ # Pin the chart to the first feature (most recognizable for the audience).
558
+ feat = df["feature"].iloc[0]
559
+ feat_df = df[df["feature"] == feat]
560
+
561
+ # Layered KDE: x = feature_value, color = site, faceted by harmonization_state.
562
+ chart = (
563
+ alt.Chart(feat_df)
564
+ .transform_density(
565
+ density="feature_value",
566
+ groupby=["site", "harmonization_state"],
567
+ as_=["feature_value", "density"],
568
+ )
569
+ .mark_area(opacity=0.55)
570
+ .encode(
571
+ x=alt.X("feature_value:Q", title=f"{feat} (intensity)"),
572
+ y=alt.Y("density:Q", title="Density"),
573
+ color=alt.Color(
574
+ "site:N",
575
+ title="Site",
576
+ scale=alt.Scale(scheme="tableau10"),
577
+ ),
578
+ tooltip=[
579
+ alt.Tooltip("site:N"),
580
+ alt.Tooltip("feature_value:Q", format=".4f"),
581
+ alt.Tooltip("density:Q", format=".3f"),
582
+ ],
583
+ )
584
+ .properties(width=380, height=260)
585
+ .facet(
586
+ column=alt.Column(
587
+ "harmonization_state:N",
588
+ title=None,
589
+ sort=["Pre-ComBat", "Post-ComBat"],
590
+ header=alt.Header(labelFontSize=13, labelFontWeight="bold"),
591
+ )
592
+ )
593
+ .resolve_scale(x="shared", y="shared")
594
+ )
595
+ st.altair_chart(chart, use_container_width=True)
596
+
597
+ st.caption(
598
+ f"Per-site density of `{feat}` before and after ComBat. Each "
599
+ f"colored region is one acquisition site. **Convergence of the "
600
+ f"colored regions in the Post-ComBat panel is the visual proof "
601
+ f"of harmonization** — the same property the {result['reduction_factor']:.0f}× "
602
+ f"site-gap reduction quantifies."
603
+ )
604
+
605
+
606
  def main() -> None:
607
  """Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
608
  st.set_page_config(