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>
- 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(
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
try:
|
| 436 |
-
|
| 437 |
-
"
|
| 438 |
-
"sites_csv": sites_csv,
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
st.toast("
|
| 442 |
except httpx.HTTPStatusError as e:
|
| 443 |
-
st.error(
|
|
|
|
|
|
|
|
|
|
| 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(
|