# app.py
"""
CIMT Volumetric 3D Explorer — Gradio Application
=================================================
2-level cascade (System -> Sub-system) narrows 448 ROIs to a small
checklist. The user manually ticks the ROIs to render.
Extra indices can be added via Advanced.
Layout: Minimalist, modern. White background. 3D viewer dominates the
left; a compact control sidebar sits on the right. Clean hierarchy,
generous whitespace, single accent colour.
Deployment:
Place alongside a `data/` folder containing:
- CIMT_448ROIs_atlas.nii.gz
- cimt_atlas_labels.csv
Compatible with HuggingFace Spaces (Gradio SDK).
Usage:
python app.py
"""
import os
import logging
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import gradio as gr
import matplotlib.pyplot as plt
import nibabel as nib
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from matplotlib.colors import to_hex
from skimage import measure
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("cimt_explorer")
SCRIPT_DIR = Path(__file__).parent.resolve()
DATA_DIR = SCRIPT_DIR / "data"
ATLAS_FILENAME: str = "CIMT_448ROIs_atlas.nii.gz"
LABELS_FILENAME: str = "cimt_atlas_labels.csv"
MAX_ROIS_RENDER: int = 80
DEFAULT_ALPHA: float = 0.65
DEFAULT_CMAP: str = "plasma"
DEFAULT_LEGEND_MODE: str = "auto"
BRAIN_OPACITY: float = 0.08
BRAIN_COLOR: str = "#a8b0ba"
SUBSYSTEM_ALL: str = "All Sub-systems"
AUTO_FULL_LEGEND_THRESHOLD: int = 12
CMAP_CHOICES: List[str] = [
"plasma", "viridis", "inferno", "coolwarm", "tab20", "Set1", "Set2",
]
LEGEND_MODE_CHOICES: List[str] = [
"auto", "full", "region_full_name", "roi_name",
]
# ---------------------------------------------------------------------------
# Design Tokens
# ---------------------------------------------------------------------------
_CLR_BG: str = "#ffffff"
_CLR_SURFACE: str = "#ffffff"
_CLR_BORDER: str = "#e8eaed"
_CLR_BORDER_FOCUS: str = "#2c3e50"
_CLR_ACCENT: str = "#2c3e50"
_CLR_ACCENT_HOVER: str = "#3d5266"
_CLR_TEXT: str = "#1a1a2e"
_CLR_TEXT_SECONDARY: str = "#6b7280"
_CLR_TEXT_MUTED: str = "#9ca3af"
_CLR_SCENE_BG: str = "#f8f9fa"
_CLR_HOVER_BG: str = "#f3f4f6"
# ---------------------------------------------------------------------------
# Application CSS
# ---------------------------------------------------------------------------
APP_CSS: str = """
/* ── Fonts ──────────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ── Global ─────────────────────────────────────────────────────────────── */
.gradio-container {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
background: #ffffff !important;
max-width: 1520px !important;
margin: 0 auto !important;
padding: 0 24px !important;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.app-header {
padding: 28px 0 16px;
margin-bottom: 0;
}
.app-header h1 {
font-size: 22px !important;
font-weight: 700 !important;
color: #1a1a2e !important;
letter-spacing: -0.5px;
margin: 0 !important;
}
.app-header p {
font-size: 13px !important;
color: #6b7280 !important;
font-weight: 400;
margin: 4px 0 0 0 !important;
}
/* ── Main Layout ────────────────────────────────────────────────────────── */
.main-layout {
gap: 20px !important;
align-items: stretch;
}
/* ── Viewer ─────────────────────────────────────────────────────────────── */
.viewer-panel {
border: 1px solid #e8eaed;
border-radius: 12px;
overflow: hidden;
background: #f8f9fa;
display: flex;
flex-direction: column;
}
.viewer-panel .plot-container {
flex: 1;
min-height: 0;
}
.viewer-panel .plot-container > div {
height: 100% !important;
}
.viewer-panel .js-plotly-plot,
.viewer-panel .plotly-graph-div {
height: 100% !important;
width: 100% !important;
}
/* ── Sidebar ────────────────────────────────────────────────────────────── */
.sidebar {
display: flex;
flex-direction: column;
gap: 0;
}
/* ── Section Blocks ─────────────────────────────────────────────────────── */
.section-block {
padding: 20px 0;
border-bottom: 1px solid #e8eaed;
}
.section-block:last-child {
border-bottom: none;
}
.section-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.6px;
color: #9ca3af;
margin-bottom: 14px;
}
/* ── Dropdowns ──────────────────────────────────────────────────────────── */
.gradio-container select,
.gradio-container .wrap.svelte-1ipelgc {
background: #ffffff !important;
border: 1px solid #e8eaed !important;
border-radius: 8px !important;
color: #1a1a2e !important;
font-size: 13px !important;
font-family: 'Inter', sans-serif !important;
padding: 10px 14px !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.gradio-container select:focus,
.gradio-container select:hover {
border-color: #2c3e50 !important;
box-shadow: 0 0 0 3px rgba(44, 62, 80, 0.06) !important;
outline: none;
}
.gradio-container label > span {
color: #6b7280 !important;
font-size: 12px !important;
font-weight: 500 !important;
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.gradio-container button.primary {
background: #2c3e50 !important;
border: none !important;
border-radius: 8px !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 13px !important;
font-family: 'Inter', sans-serif !important;
padding: 12px 24px !important;
letter-spacing: 0.2px;
transition: all 0.2s ease;
width: 100%;
}
.gradio-container button.primary:hover {
background: #3d5266 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.15);
}
.gradio-container button.primary:active {
transform: translateY(0);
box-shadow: none;
}
/* ── ROI Checklist (compact: ~4 visible rows) ───────────────────────────── */
.roi-checklist > div {
max-height: 120px;
overflow-y: auto;
border: 1px solid #e8eaed;
border-radius: 8px;
padding: 6px 10px;
background: #ffffff;
}
.roi-checklist label {
font-size: 12px;
color: #1a1a2e;
padding: 4px 8px;
border-radius: 5px;
transition: background 0.1s ease;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.3;
}
.roi-checklist label:hover {
background: #f3f4f6;
}
.roi-checklist input[type="checkbox"] {
accent-color: #2c3e50;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.roi-checklist input[type="checkbox"]:checked + span {
color: #2c3e50;
font-weight: 600;
}
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
.roi-checklist > div::-webkit-scrollbar {
width: 4px;
}
.roi-checklist > div::-webkit-scrollbar-track {
background: transparent;
}
.roi-checklist > div::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 2px;
}
.roi-checklist > div::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* ── Status ─────────────────────────────────────────────────────────────── */
.status-bar textarea {
font-family: 'JetBrains Mono', monospace !important;
font-size: 11px !important;
background: #f8f9fa !important;
border: 1px solid #e8eaed !important;
border-radius: 6px !important;
color: #6b7280 !important;
padding: 8px 12px !important;
}
/* ── Accordion ──────────────────────────────────────────────────────────── */
.gradio-container .accordion {
border: 1px solid #e8eaed !important;
border-radius: 8px !important;
background: #ffffff !important;
}
.gradio-container .accordion .label-wrap span {
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 1px;
color: #9ca3af !important;
}
/* ── Sliders ────────────────────────────────────────────────────────────── */
.gradio-container input[type="range"] {
accent-color: #2c3e50;
}
/* ── Checkboxes (general) ───────────────────────────────────────────────── */
.gradio-container input[type="checkbox"] {
accent-color: #2c3e50;
}
/* ── Footer ─────────────────────────────────────────────────────────────── */
.app-footer {
font-size: 11px;
color: #9ca3af;
text-align: center;
padding: 20px 0 28px;
margin-top: 8px;
letter-spacing: 0.3px;
}
/* ── Plotly Overrides ───────────────────────────────────────────────────── */
.js-plotly-plot .plotly .modebar {
background: rgba(255, 255, 255, 0.95) !important;
border-radius: 6px;
padding: 4px;
border: 1px solid #e8eaed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.js-plotly-plot .plotly .modebar-btn path {
fill: #6b7280 !important;
}
.js-plotly-plot .plotly .modebar-btn:hover path {
fill: #2c3e50 !important;
}
/* ── Misc ───────────────────────────────────────────────────────────────── */
.gradio-container .prose {
color: #1a1a2e !important;
}
.gradio-container .info-text,
.gradio-container .info {
color: #9ca3af !important;
font-size: 11px !important;
}
"""
# ---------------------------------------------------------------------------
# Module-Level Singletons
# ---------------------------------------------------------------------------
_ATLAS_IMG: Optional[nib.Nifti1Image] = None
_ATLAS_DATA: Optional[np.ndarray] = None
_LABELS_DF: Optional[pd.DataFrame] = None
_BRAIN_VERTS: Optional[np.ndarray] = None
_BRAIN_FACES: Optional[np.ndarray] = None
_LABEL_TO_INDEX: Dict[str, int] = {}
# ---------------------------------------------------------------------------
# Display Label Builder
# ---------------------------------------------------------------------------
def build_display_labels(labels_df: pd.DataFrame) -> pd.Series:
"""Create unique, human-readable labels for the ROI checklist.
Rules:
1. Use region_full_name as the primary label.
2. Append hemisphere abbreviation unless the full name already
ends with '(Left)' or '(Right)' (e.g., cerebellar ROIs).
3. If the resulting label is duplicated, append the ROI code.
4. If still duplicated, append the atlas index.
Args:
labels_df: Atlas labels DataFrame with required columns.
Returns:
Series of unique display labels aligned with labels_df index.
Raises:
ValueError: If labels cannot be made unique.
"""
full_name = labels_df["region_full_name"].fillna("Unknown").astype(str)
roi_name = labels_df["roi_name"].fillna("unknown").astype(str)
index_col = labels_df["index"].astype(int)
hemisphere_abbr = labels_df["hemisphere"].fillna("?").str[0].str.upper()
already_has_side = full_name.str.endswith(("(Left)", "(Right)"))
base_label = full_name.where(
already_has_side,
full_name + " (" + hemisphere_abbr + ")",
)
counts = base_label.value_counts()
is_duplicated = base_label.map(counts) > 1
display_label = base_label.where(
~is_duplicated,
base_label + " [" + roi_name + "]",
)
still_duplicated = display_label.duplicated(keep=False)
display_label = display_label.where(
~still_duplicated,
display_label + " [#" + index_col.astype(str) + "]",
)
if not display_label.is_unique:
raise ValueError(
"Display labels are not unique after all disambiguation steps."
)
return display_label
# ---------------------------------------------------------------------------
# Data Loading
# ---------------------------------------------------------------------------
def load_atlas_data() -> None:
"""Load NIfTI volume, labels CSV, and brain mesh into singletons."""
global _ATLAS_IMG, _ATLAS_DATA, _LABELS_DF
global _BRAIN_VERTS, _BRAIN_FACES, _LABEL_TO_INDEX
atlas_path = DATA_DIR / ATLAS_FILENAME
labels_path = DATA_DIR / LABELS_FILENAME
if not atlas_path.exists():
raise FileNotFoundError(
f"Atlas not found: {atlas_path}\n"
f"Place '{ATLAS_FILENAME}' in '{DATA_DIR}/'."
)
if not labels_path.exists():
raise FileNotFoundError(
f"Labels not found: {labels_path}\n"
f"Place '{LABELS_FILENAME}' in '{DATA_DIR}/'."
)
logger.info("Loading NIfTI atlas...")
_ATLAS_IMG = nib.load(str(atlas_path))
_ATLAS_DATA = _ATLAS_IMG.get_fdata()
logger.info("Loading labels CSV...")
_LABELS_DF = pd.read_csv(labels_path)
expected = np.arange(len(_LABELS_DF))
actual = _LABELS_DF["index"].astype(int).to_numpy()
if not np.array_equal(actual, expected):
raise ValueError(
"Atlas 'index' column must be contiguous starting at 0. "
f"Got range [{actual.min()}, {actual.max()}] with {len(actual)} rows."
)
_LABELS_DF["display_label"] = build_display_labels(_LABELS_DF)
_LABEL_TO_INDEX = dict(
zip(_LABELS_DF["display_label"], _LABELS_DF["index"].astype(int))
)
_BRAIN_VERTS, _BRAIN_FACES = _load_brain_mesh()
logger.info(
"Ready: %d ROIs, %d systems, brain mesh=%s",
len(_LABELS_DF),
_LABELS_DF["functional_system"].nunique(),
_BRAIN_VERTS is not None,
)
def _load_brain_mesh() -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
"""Load fsaverage5 pial surface. Non-fatal on failure."""
try:
from nilearn.datasets import load_fsaverage
fsavg = load_fsaverage(mesh="fsaverage5")
pial = fsavg.pial
verts = np.vstack([
pial.parts["left"].coordinates,
pial.parts["right"].coordinates,
])
faces = np.vstack([
pial.parts["left"].faces,
pial.parts["right"].faces + len(pial.parts["left"].coordinates),
])
return verts, faces
except Exception as exc:
logger.warning("Brain mesh unavailable: %s", exc)
return None, None
# ---------------------------------------------------------------------------
# Cascade Logic
# ---------------------------------------------------------------------------
def get_systems() -> List[str]:
"""Sorted unique functional systems."""
assert _LABELS_DF is not None
return sorted(_LABELS_DF["functional_system"].dropna().unique().tolist())
def get_subsystems(system: Optional[str]) -> List[str]:
"""Sub-system options filtered by system."""
assert _LABELS_DF is not None
df = _LABELS_DF
if system:
df = df[df["functional_system"] == system]
subs = sorted(df["sub_system"].dropna().unique().tolist())
return [SUBSYSTEM_ALL] + subs
def get_filtered_roi_labels(
system: Optional[str],
subsystem: Optional[str],
) -> List[str]:
"""Return sorted display labels for ROIs matching the cascade filters."""
assert _LABELS_DF is not None
df = _LABELS_DF
if system:
df = df[df["functional_system"] == system]
if subsystem and subsystem != SUBSYSTEM_ALL:
df = df[df["sub_system"] == subsystem]
return sorted(df["display_label"].tolist())
# ---------------------------------------------------------------------------
# Mesh Extraction
# ---------------------------------------------------------------------------
def extract_roi_mesh(label_value: int) -> Optional[Dict[str, np.ndarray]]:
"""Extract isosurface for one ROI using the cached volume data."""
assert _ATLAS_DATA is not None and _ATLAS_IMG is not None
mask = (_ATLAS_DATA == label_value).astype(np.float32)
if not np.any(mask):
return None
try:
verts, faces, normals, _ = measure.marching_cubes(mask, level=0.5)
except Exception as exc:
logger.warning("Marching cubes failed for label %d: %s", label_value, exc)
return None
verts_mni = nib.affines.apply_affine(_ATLAS_IMG.affine, verts)
return {"vertices": verts_mni, "faces": faces, "normals": normals}
# ---------------------------------------------------------------------------
# Legend Label Resolution
# ---------------------------------------------------------------------------
def resolve_plot_label(
row: pd.Series,
legend_mode: str,
n_traces: int,
) -> str:
"""Resolve the label shown in the Plotly legend."""
mode = legend_mode
if mode == "auto":
mode = "full" if n_traces <= AUTO_FULL_LEGEND_THRESHOLD else "roi_name"
if mode == "full":
return str(row["display_label"])
if mode == "region_full_name":
return str(row["region_full_name"])
return str(row["roi_name"])
# ---------------------------------------------------------------------------
# Figure Builder
# ---------------------------------------------------------------------------
def _apply_scene_layout(fig: go.Figure) -> go.Figure:
"""Apply consistent scene styling to a figure (responsive sizing)."""
fig.update_layout(
autosize=True,
paper_bgcolor=_CLR_SCENE_BG,
scene=dict(
domain=dict(x=[0.0, 1.0], y=[0.0, 1.0]),
xaxis_visible=False,
yaxis_visible=False,
zaxis_visible=False,
camera=dict(
eye=dict(x=0.0, y=1.8, z=0.4),
up=dict(x=0.0, y=0.0, z=1.0),
center=dict(x=0.0, y=0.0, z=0.0),
),
bgcolor=_CLR_SCENE_BG,
aspectmode="data",
),
margin=dict(t=12, b=12, l=12, r=12),
)
return fig
def build_figure(
selected_indices: List[int],
legend_mode: str = DEFAULT_LEGEND_MODE,
cmap: str = DEFAULT_CMAP,
alpha: float = DEFAULT_ALPHA,
show_brain: bool = True,
) -> go.Figure:
"""Construct Plotly 3D figure from ROI indices."""
assert _LABELS_DF is not None
if len(selected_indices) > MAX_ROIS_RENDER:
logger.warning(
"Capping ROIs: %d -> %d", len(selected_indices), MAX_ROIS_RENDER
)
selected_indices = selected_indices[:MAX_ROIS_RENDER]
cmap_func = plt.get_cmap(cmap)
fig = go.Figure()
# Brain reference mesh.
if show_brain and _BRAIN_VERTS is not None and _BRAIN_FACES is not None:
fig.add_trace(go.Mesh3d(
x=_BRAIN_VERTS[:, 0],
y=_BRAIN_VERTS[:, 1],
z=_BRAIN_VERTS[:, 2],
i=_BRAIN_FACES[:, 0],
j=_BRAIN_FACES[:, 1],
k=_BRAIN_FACES[:, 2],
color=BRAIN_COLOR,
opacity=BRAIN_OPACITY,
showlegend=False,
hoverinfo="skip",
))
# Extract meshes.
meshes: Dict[int, Dict[str, np.ndarray]] = {}
display_names: Dict[int, str] = {}
for idx in selected_indices:
row = _LABELS_DF.iloc[idx]
mesh = extract_roi_mesh(idx + 1)
if mesh is not None:
meshes[idx] = mesh
display_names[idx] = str(row["display_label"])
if not meshes:
raise RuntimeError("No meshes extracted for selected ROIs.")
# Add ROI traces.
n_traces = len(meshes)
for rank, (idx, mesh) in enumerate(meshes.items()):
color = to_hex(cmap_func(rank / max(n_traces - 1, 1)))
row = _LABELS_DF.iloc[idx]
legend_label = resolve_plot_label(row, legend_mode, n_traces)
hover_text = (
f"{row['display_label']}
"
f"ROI code: {row['roi_name']}
"
f"System: {row['functional_system']}
"
f"Sub-system: {row['sub_system']}
"
f"Hemisphere: {row['hemisphere']}
"
f"Index: {idx}"
)
fig.add_trace(go.Mesh3d(
x=mesh["vertices"][:, 0],
y=mesh["vertices"][:, 1],
z=mesh["vertices"][:, 2],
i=mesh["faces"][:, 0],
j=mesh["faces"][:, 1],
k=mesh["faces"][:, 2],
color=color,
opacity=alpha,
name=legend_label,
hovertext=hover_text,
hoverinfo="text",
showlegend=True,
))
_apply_scene_layout(fig)
fig.update_layout(
legend=dict(
yanchor="top",
y=0.95,
xanchor="left",
x=0.01,
bgcolor="rgba(255, 255, 255, 0.92)",
bordercolor=_CLR_BORDER,
borderwidth=1,
font=dict(size=12, color=_CLR_TEXT, family="Inter, sans-serif"),
title=dict(
text="Regions",
font=dict(size=13, color=_CLR_TEXT_SECONDARY),
),
itemsizing="constant",
),
)
return fig
def build_initial_figure() -> go.Figure:
"""Build the initial brain-only figure shown on load."""
fig = go.Figure()
if _BRAIN_VERTS is not None and _BRAIN_FACES is not None:
fig.add_trace(go.Mesh3d(
x=_BRAIN_VERTS[:, 0],
y=_BRAIN_VERTS[:, 1],
z=_BRAIN_VERTS[:, 2],
i=_BRAIN_FACES[:, 0],
j=_BRAIN_FACES[:, 1],
k=_BRAIN_FACES[:, 2],
color=BRAIN_COLOR,
opacity=BRAIN_OPACITY,
showlegend=False,
hoverinfo="skip",
))
fig.update_layout(
width=860,
height=780,
paper_bgcolor=_CLR_SCENE_BG,
scene=dict(
domain=dict(x=[0.0, 1.0], y=[0.0, 1.0]),
xaxis_visible=False,
yaxis_visible=False,
zaxis_visible=False,
camera=dict(
eye=dict(x=0.0, y=1.8, z=0.4),
up=dict(x=0.0, y=0.0, z=1.0),
center=dict(x=0.0, y=0.0, z=0.0),
),
bgcolor=_CLR_SCENE_BG,
aspectmode="data",
),
margin=dict(t=12, b=12, l=12, r=12),
)
return fig
# ---------------------------------------------------------------------------
# Gradio Callbacks
# ---------------------------------------------------------------------------
def on_system_change(
system: Optional[str],
) -> Tuple[gr.Dropdown, gr.CheckboxGroup]:
"""Cascade: System changed -> update Sub-system + checklist."""
sys_val = system if system else None
subs = get_subsystems(sys_val)
roi_labels = get_filtered_roi_labels(sys_val, None)
return (
gr.Dropdown(choices=subs, value=SUBSYSTEM_ALL),
gr.CheckboxGroup(choices=roi_labels, value=[]),
)
def on_subsystem_change(
system: Optional[str],
subsystem: Optional[str],
) -> gr.CheckboxGroup:
"""Cascade: Sub-system changed -> update checklist."""
sys_val = system if system else None
sub_val = subsystem if subsystem else SUBSYSTEM_ALL
roi_labels = get_filtered_roi_labels(sys_val, sub_val)
return gr.CheckboxGroup(choices=roi_labels, value=[])
def on_render(
checked_rois: Optional[List[str]],
indices_text: Optional[str],
legend_mode: Optional[str],
cmap: Optional[str],
alpha: Optional[float],
show_brain: Optional[bool],
) -> Tuple[go.Figure, str]:
"""Main render callback: combine checked ROIs + explicit indices."""
t0 = time.time()
info_parts: List[str] = []
if checked_rois is None:
checked_rois = []
if indices_text is None:
indices_text = ""
if legend_mode is None:
legend_mode = DEFAULT_LEGEND_MODE
if cmap is None:
cmap = DEFAULT_CMAP
if alpha is None:
alpha = DEFAULT_ALPHA
if show_brain is None:
show_brain = True
try:
selected: set = set()
for label in checked_rois:
if label in _LABEL_TO_INDEX:
selected.add(_LABEL_TO_INDEX[label])
else:
logger.warning("Unknown checklist label: %s", label)
if selected:
info_parts.append(f"Checked: {len(selected)} ROIs")
if indices_text.strip():
tokens = [
token.strip()
for token in indices_text.replace(";", ",").split(",")
if token.strip()
]
explicit: set = set()
for token in tokens:
if token.isdigit():
idx = int(token)
if 0 <= idx < len(_LABELS_DF):
explicit.add(idx)
else:
info_parts.append(f"Out-of-range: {idx}")
else:
info_parts.append(f"Non-numeric skipped: '{token}'")
if explicit:
info_parts.append(f"Explicit: +{len(explicit)} ROIs")
selected |= explicit
if not selected:
return go.Figure(), "Nothing selected. Tick ROIs or enter indices."
final_indices = sorted(selected)
info_parts.insert(0, f"Total: {len(final_indices)} ROIs")
fig = build_figure(
selected_indices=final_indices,
legend_mode=legend_mode,
cmap=cmap,
alpha=alpha,
show_brain=show_brain,
)
elapsed = time.time() - t0
info_parts.append(f"Rendered in {elapsed:.2f}s")
return fig, " | ".join(info_parts)
except (ValueError, RuntimeError) as exc:
return go.Figure(), f"Error: {exc}"
except Exception as exc:
logger.exception("Render failed")
return go.Figure(), f"Unexpected error: {exc}"
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
def create_app() -> gr.Blocks:
"""Build the Gradio Blocks interface — minimalist, viewer-left / controls-right."""
systems = get_systems()
n_rois = len(_LABELS_DF) if _LABELS_DF is not None else 0
with gr.Blocks(
title="CIMT Volumetric 3D Explorer",
) as app:
# ── HEADER ──────────────────────────────────────────────────────
with gr.Row(elem_classes=["app-header"]):
gr.Markdown(
"# CIMT Volumetric 3D Explorer\n"
"448-ROI Atlas · Interactive Mesh Rendering"
)
# ── MAIN: Viewer (left) + Sidebar (right) ───────────────────────
with gr.Row(equal_height=True, elem_classes=["main-layout"]):
# LEFT — 3D Viewer
with gr.Column(scale=4, elem_classes=["viewer-panel"]):
plot_output = gr.Plot(
label=None,
value=build_initial_figure(),
show_label=False,
elem_classes=["plot-container"],
)
# RIGHT — Control Sidebar
with gr.Column(scale=1, min_width=320, elem_classes=["sidebar"]):
# Section 1: Navigation
with gr.Column(elem_classes=["section-block"]):
gr.Markdown(
"