Spaces:
Running
Running
| """Interactive 3D Brain Viewer - Publication Quality + Explorer.""" | |
| import streamlit as st | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| from session import init_session, show_analysis_log, upload_npy_widget | |
| from theme import inject_theme, section_header | |
| from brain_mesh import ( | |
| load_fsaverage_mesh, | |
| load_sulcal_map, | |
| generate_sample_activations, | |
| highlight_rois, | |
| blend_with_sulcal, | |
| render_publication_views, | |
| render_interactive_3d, | |
| roi_summary_table, | |
| VIEWS, | |
| ACTIVATION_PATTERNS, | |
| ) | |
| from utils import ROI_GROUPS, make_roi_indices | |
| st.set_page_config(page_title="3D Brain Viewer", page_icon="🧠", layout="wide") | |
| init_session() | |
| inject_theme() | |
| show_analysis_log() | |
| st.title("🧠 Interactive 3D Brain Viewer") | |
| st.markdown("Explore brain activation patterns on the cortical surface. Publication-quality multi-view panels + interactive 3D rotation.") | |
| # --- Sidebar --- | |
| with st.sidebar: | |
| st.header("Brain Viewer") | |
| hemi = st.selectbox("Hemisphere", ["left", "right"], index=0) | |
| resolution = st.selectbox("Mesh resolution", ["fsaverage5", "fsaverage4"], index=0, | |
| help="fsaverage5: 10k vertices (detailed). fsaverage4: 2.5k vertices (fast).") | |
| st.subheader("Data") | |
| data_source = st.radio("Data source", ["Sample activations", "From current analysis", "Upload .npy"]) | |
| if data_source == "Sample activations": | |
| pattern = st.selectbox("Activation pattern", list(ACTIVATION_PATTERNS.keys()), | |
| help="Modality-specific activation: visual lights up V1/V2/MT, language lights up Broca's/Wernicke's, etc.") | |
| seed = st.number_input("Seed", value=42, min_value=0) | |
| st.subheader("Appearance") | |
| cmap = st.selectbox("Colormap", ["Hot", "Inferno", "Plasma", "Viridis", "RdBu_r", "Coolwarm"], index=0) | |
| vmin, vmax = st.slider("Data range", 0.0, 1.0, (0.0, 1.0), 0.05) | |
| bg_color = st.selectbox("Background", ["#0E1117", "#000000", "#1A1A2E"], index=0, | |
| format_func=lambda x: {"#0E1117": "Dark", "#000000": "Black", "#1A1A2E": "Navy"}[x]) | |
| st.subheader("ROI Highlighting") | |
| roi_groups_selected = st.multiselect("Region groups", list(ROI_GROUPS.keys()), default=["Visual"]) | |
| available_rois = [] | |
| for g in roi_groups_selected: | |
| available_rois.extend(ROI_GROUPS[g]) | |
| selected_rois = st.multiselect("Specific ROIs", available_rois, default=available_rois[:4] if available_rois else []) | |
| show_labels = st.checkbox("Show ROI labels", value=True) | |
| # --- Load Mesh --- | |
| with st.spinner(f"Loading {resolution} brain mesh ({hemi} hemisphere)..."): | |
| coords, faces = load_fsaverage_mesh(hemi, resolution) | |
| n_vertices = coords.shape[0] | |
| # --- Load/Generate Data --- | |
| roi_indices, _ = make_roi_indices() | |
| # Map ROI indices to actual mesh vertices (scale to mesh size) | |
| # Since our ROI indices are synthetic (0-580), map them proportionally to actual mesh | |
| mesh_roi_indices = {} | |
| for name, idx in roi_indices.items(): | |
| scaled = (idx * n_vertices // 580).astype(int) | |
| scaled = scaled[scaled < n_vertices] | |
| mesh_roi_indices[name] = scaled | |
| if data_source == "Sample activations": | |
| vertex_data = generate_sample_activations(n_vertices, mesh_roi_indices, pattern, seed) | |
| elif data_source == "Upload .npy": | |
| uploaded = upload_npy_widget(f"Upload vertex data (.npy, {n_vertices} vertices)", "brain_upload") | |
| if uploaded is not None and len(uploaded) == n_vertices: | |
| vertex_data = uploaded | |
| elif uploaded is not None: | |
| st.warning(f"Expected {n_vertices} vertices, got {len(uploaded)}. Using sample data.") | |
| vertex_data = generate_sample_activations(n_vertices, mesh_roi_indices, "visual", 42) | |
| else: | |
| vertex_data = generate_sample_activations(n_vertices, mesh_roi_indices, "visual", 42) | |
| elif data_source == "From current analysis": | |
| preds = st.session_state.get("brain_predictions") | |
| if preds is not None: | |
| # Average across timepoints, take first n_vertices | |
| avg = np.abs(preds).mean(axis=0) | |
| if len(avg) >= n_vertices: | |
| vertex_data = avg[:n_vertices] | |
| else: | |
| vertex_data = np.pad(avg, (0, n_vertices - len(avg))) | |
| # Normalize to [0, 1] | |
| vd_range = vertex_data.max() - vertex_data.min() | |
| if vd_range > 0: | |
| vertex_data = (vertex_data - vertex_data.min()) / vd_range | |
| else: | |
| st.info("No analysis data in session. Go to Home page to generate data, or use sample activations.") | |
| vertex_data = generate_sample_activations(n_vertices, mesh_roi_indices, "visual", 42) | |
| # Apply ROI highlighting | |
| if selected_rois: | |
| vertex_data = highlight_rois(vertex_data, mesh_roi_indices, selected_rois, boost=1.8) | |
| # Blend with sulcal map for anatomical context | |
| try: | |
| sulc = load_sulcal_map(hemi, resolution) | |
| vertex_data_display = blend_with_sulcal(vertex_data, sulc) | |
| except Exception: | |
| vertex_data_display = vertex_data | |
| # --- Publication Views --- | |
| st.subheader("Publication Views") | |
| st.caption("Four standard neuroimaging views. Right-click any panel to save as image.") | |
| fig_pub = render_publication_views(coords, faces, vertex_data_display, cmap, vmin, vmax, bg_color) | |
| st.plotly_chart(fig_pub, use_container_width=True) | |
| # --- Interactive 3D --- | |
| st.divider() | |
| st.subheader("Interactive 3D Explorer") | |
| st.caption("Rotate: drag | Zoom: scroll | Pan: shift+drag") | |
| col_view, col_space = st.columns([1, 3]) | |
| with col_view: | |
| initial_view = st.selectbox("Initial view", list(VIEWS.keys()), index=0) | |
| result = render_interactive_3d( | |
| coords, faces, vertex_data_display, cmap, vmin, vmax, | |
| bg_color, initial_view, mesh_roi_indices, | |
| roi_labels=selected_rois, show_labels=show_labels, | |
| ) | |
| if result is not None: | |
| st.plotly_chart(result, use_container_width=True) | |
| # --- ROI Summary --- | |
| if selected_rois: | |
| st.divider() | |
| col_table, col_hist = st.columns([1, 1]) | |
| with col_table: | |
| st.subheader("ROI Summary") | |
| summary = roi_summary_table(vertex_data, mesh_roi_indices, selected_rois) | |
| if summary is not None: | |
| st.dataframe( | |
| summary.style.format({"Mean": "{:.4f}", "Std": "{:.4f}", "Min": "{:.4f}", "Max": "{:.4f}"}), | |
| use_container_width=True, hide_index=True, | |
| ) | |
| with col_hist: | |
| st.subheader("Activation Distribution") | |
| fig_hist = go.Figure() | |
| fig_hist.add_trace(go.Histogram( | |
| x=vertex_data, nbinsx=50, | |
| marker_color="rgba(108, 92, 231, 0.7)", | |
| name="All vertices", | |
| )) | |
| # Overlay selected ROI distributions | |
| group_colors = {"Visual": "#00D2FF", "Auditory": "#FF6B6B", "Language": "#A29BFE", "Executive": "#FFEAA7"} | |
| for roi in selected_rois[:3]: # limit to 3 for clarity | |
| if roi in mesh_roi_indices: | |
| valid = mesh_roi_indices[roi] | |
| valid = valid[valid < len(vertex_data)] | |
| if len(valid) > 0: | |
| group = "Other" | |
| for g, rois in ROI_GROUPS.items(): | |
| if roi in rois: | |
| group = g | |
| break | |
| fig_hist.add_trace(go.Histogram( | |
| x=vertex_data[valid], nbinsx=20, | |
| marker_color=group_colors.get(group, "#888"), | |
| name=roi, opacity=0.6, | |
| )) | |
| fig_hist.update_layout( | |
| xaxis_title="Activation", yaxis_title="Count", | |
| height=350, template="plotly_dark", | |
| barmode="overlay", | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02), | |
| ) | |
| st.plotly_chart(fig_hist, use_container_width=True) | |
| # --- Stats --- | |
| st.divider() | |
| col1, col2, col3, col4 = st.columns(4) | |
| col1.metric("Vertices", f"{n_vertices:,}") | |
| col2.metric("Mean Activation", f"{vertex_data.mean():.4f}") | |
| col3.metric("Active Vertices", f"{(vertex_data > 0.1).sum():,} ({100 * (vertex_data > 0.1).mean():.0f}%)") | |
| col4.metric("Peak", f"{vertex_data.max():.4f}") | |
| # --- Methodology --- | |
| with st.expander("About the 3D Brain Viewer", expanded=False): | |
| st.markdown(""" | |
| **Surface Mesh**: The brain surface is the fsaverage template from FreeSurfer, loaded via | |
| nilearn. fsaverage5 has 10,242 vertices per hemisphere; fsaverage4 has 2,562. | |
| **Activation Overlay**: Vertex-level scalar data is projected onto the mesh surface as a | |
| colormap. The data is blended with the sulcal depth map (anatomical grooves) to provide | |
| spatial context. | |
| **Sample Activations**: Modality-specific patterns assign activation weights to HCP MMP1.0 | |
| ROIs based on established functional neuroanatomy. Visual stimuli activate V1/V2/MT, | |
| auditory stimuli activate A1/belt areas, language stimuli activate Broca's (area 44/45) | |
| and Wernicke's (TPOJ1/2). | |
| **ROI Highlighting**: Selected ROIs are amplified (1.8x) to make them visually distinct. | |
| The summary table shows descriptive statistics for highlighted regions. | |
| **Publication Views**: Four standard views (lateral left, lateral right, medial, dorsal) | |
| match the conventions used in neuroimaging journals. Right-click to save individual panels. | |
| **Interactive View**: Supports rotation (drag), zoom (scroll), and pan (shift+drag). | |
| Uses PyVista when available, falls back to Plotly mesh3d. | |
| **References**: | |
| - Fischl, 2012, *NeuroImage* (FreeSurfer surface reconstruction) | |
| - Glasser et al., 2016, *Nature* (HCP MMP1.0 parcellation) | |
| """) | |