Spaces:
Paused
Paused
| # 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"<b>{row['display_label']}</b><br>" | |
| f"ROI code: {row['roi_name']}<br>" | |
| f"System: {row['functional_system']}<br>" | |
| f"Sub-system: {row['sub_system']}<br>" | |
| f"Hemisphere: {row['hemisphere']}<br>" | |
| 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="<b>Regions</b>", | |
| 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( | |
| "<div class='section-label'>Navigation</div>" | |
| ) | |
| system_dd = gr.Dropdown( | |
| choices=systems, | |
| value=None, | |
| label="Functional System", | |
| ) | |
| subsystem_dd = gr.Dropdown( | |
| choices=[SUBSYSTEM_ALL], | |
| value=SUBSYSTEM_ALL, | |
| label="Sub-system", | |
| ) | |
| # Section 2: Selection | |
| with gr.Column(elem_classes=["section-block"]): | |
| gr.Markdown( | |
| "<div class='section-label'>Regions</div>" | |
| ) | |
| roi_checklist = gr.CheckboxGroup( | |
| choices=[], | |
| value=[], | |
| label=None, | |
| show_label=False, | |
| elem_classes=["roi-checklist"], | |
| ) | |
| # Section 3: Render | |
| with gr.Column(elem_classes=["section-block"]): | |
| render_btn = gr.Button( | |
| "Render", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| status_box = gr.Textbox( | |
| label=None, | |
| show_label=False, | |
| interactive=False, | |
| lines=1, | |
| placeholder="Awaiting selectionβ¦", | |
| elem_classes=["status-bar"], | |
| ) | |
| # Section 4: Advanced | |
| with gr.Accordion("Advanced", open=False): | |
| indices_input = gr.Textbox( | |
| placeholder="e.g. 446, 447", | |
| label="Extra Indices", | |
| info="Comma-separated", | |
| ) | |
| legend_mode_dd = gr.Dropdown( | |
| choices=LEGEND_MODE_CHOICES, | |
| value=DEFAULT_LEGEND_MODE, | |
| label="Legend Mode", | |
| ) | |
| cmap_dd = gr.Dropdown( | |
| choices=CMAP_CHOICES, | |
| value=DEFAULT_CMAP, | |
| label="Colormap", | |
| ) | |
| alpha_slider = gr.Slider( | |
| minimum=0.1, | |
| maximum=1.0, | |
| step=0.05, | |
| value=DEFAULT_ALPHA, | |
| label="Opacity", | |
| ) | |
| brain_toggle = gr.Checkbox( | |
| value=True, | |
| label="Brain Mesh", | |
| ) | |
| # ββ FOOTER ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| gr.Markdown( | |
| f"<div class='app-footer'>" | |
| f"{n_rois} ROIs Β· {len(systems)} Systems Β· " | |
| f"Max {MAX_ROIS_RENDER} per render" | |
| f"</div>" | |
| ) | |
| # ββ EVENT WIRING ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| system_dd.change( | |
| fn=on_system_change, | |
| inputs=[system_dd], | |
| outputs=[subsystem_dd, roi_checklist], | |
| ) | |
| subsystem_dd.change( | |
| fn=on_subsystem_change, | |
| inputs=[system_dd, subsystem_dd], | |
| outputs=[roi_checklist], | |
| ) | |
| render_btn.click( | |
| fn=on_render, | |
| inputs=[ | |
| roi_checklist, | |
| indices_input, | |
| legend_mode_dd, | |
| cmap_dd, | |
| alpha_slider, | |
| brain_toggle, | |
| ], | |
| outputs=[plot_output, status_box], | |
| ) | |
| return app | |
| # --------------------------------------------------------------------------- | |
| # Global Initialization (HF Spaces / Render) | |
| # --------------------------------------------------------------------------- | |
| logger.info("Initializing CIMT Explorer...") | |
| load_atlas_data() | |
| logger.info("Building interface...") | |
| demo = create_app() | |
| # Theme must be defined at module level so launch() can reference it | |
| _APP_THEME = ( | |
| gr.themes.Base( | |
| primary_hue="slate", | |
| secondary_hue="slate", | |
| neutral_hue="slate", | |
| font=gr.themes.GoogleFont("Inter"), | |
| font_mono=gr.themes.GoogleFont("JetBrains Mono"), | |
| ) | |
| .set( | |
| body_background_fill=_CLR_BG, | |
| body_text_color=_CLR_TEXT, | |
| block_background_fill=_CLR_SURFACE, | |
| block_border_width="1px", | |
| block_border_color=_CLR_BORDER, | |
| block_title_text_color=_CLR_TEXT, | |
| block_label_text_color=_CLR_TEXT_SECONDARY, | |
| input_background_fill=_CLR_BG, | |
| input_border_color=_CLR_BORDER, | |
| input_border_color_focus=_CLR_BORDER_FOCUS, | |
| button_primary_background_fill=_CLR_ACCENT, | |
| button_primary_background_fill_hover=_CLR_ACCENT_HOVER, | |
| button_primary_text_color="#ffffff", | |
| button_secondary_background_fill="#ffffff", | |
| button_secondary_background_fill_hover=_CLR_HOVER_BG, | |
| button_secondary_border_color=_CLR_BORDER, | |
| button_secondary_text_color=_CLR_TEXT_SECONDARY, | |
| ) | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| theme=_APP_THEME, | |
| css=APP_CSS, | |
| ) | |