JayLacoma commited on
Commit
0f26861
·
verified ·
1 Parent(s): ecdd126

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +343 -0
app.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UNI Task PSD Explorer: EC / EO / SM condition comparison."""
2
+ import numpy as np
3
+ import pandas as pd
4
+ import plotly.graph_objects as go
5
+ from scipy import signal
6
+ import gradio as gr
7
+ import lcmv_xtra as lx
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import Dict, List, Tuple
11
+
12
+ logging.basicConfig(level=logging.INFO)
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # =============================================================================
16
+ # 1. CONFIGURATION & CONSTANTS
17
+ # =============================================================================
18
+
19
+ TENSOR_DIR = Path("./data")
20
+
21
+ CONDITION_LABELS = {
22
+ "ec": "Eyes Closed",
23
+ "eo": "Eyes Open",
24
+ "sm": "Motor Task",
25
+ }
26
+
27
+ CONDITION_COLORS = {
28
+ "ec": "#1F77B4", # Blue
29
+ "eo": "#2CA02C", # Green
30
+ "sm": "#D62728", # Red
31
+ }
32
+
33
+ PSD_WINDOW_SECONDS: float = 4.0
34
+ PSD_OVERLAP_FRACTION: float = 0.75
35
+ PSD_EPSILON: float = 1e-15
36
+ REFERENCE_BAND_HZ: Tuple[float, float] = (1.0, 4.0)
37
+ FREQ_MAX_PLOT_HZ: float = 40.0
38
+
39
+ PLOT_BANDS = [
40
+ (1, 4, 'Delta', '#90B3F9'),
41
+ (4, 8, 'Theta', '#FFF9B2'),
42
+ (8, 13, 'Alpha', '#AAFCD2'),
43
+ (13, 20, 'Low Beta', '#97C2F9'),
44
+ (20, 30, 'High Beta', '#90BEF5'),
45
+ ]
46
+
47
+ BAND_OPTIONS = {
48
+ 'Delta (1-4 Hz)': (1, 4),
49
+ 'Theta (4-8 Hz)': (4, 8),
50
+ 'Alpha (8-13 Hz)': (8, 13),
51
+ 'Low Beta (13-20 Hz)': (13, 20),
52
+ 'High Beta (20-30 Hz)': (20, 30),
53
+ 'Low Gamma (30-50 Hz)': (30, 50),
54
+ }
55
+
56
+ # =============================================================================
57
+ # 2. DATA LOADING & ATLAS MANAGEMENT
58
+ # =============================================================================
59
+
60
+ def load_psd_cache() -> dict:
61
+ """Load single precomputed PSD cache file."""
62
+ cache_path = TENSOR_DIR / "psd_cache.npz"
63
+ cache = np.load(cache_path, allow_pickle=True)
64
+ logger.info(
65
+ f"Loaded PSD cache: {len(cache['entries'])} entries × "
66
+ f"{cache['n_rois']} ROIs × {len(cache['freqs'])} freq bins"
67
+ )
68
+ return cache
69
+
70
+
71
+ def build_cascading_roi_map(atlas_df: pd.DataFrame) -> Tuple[Dict[str, List[str]], Dict[str, int]]:
72
+ """Parse CIMT atlas DataFrame into cascading dropdown structures."""
73
+ required_cols = ['index', 'region_full_name', 'hemisphere', 'functional_system']
74
+ assert all(col in atlas_df.columns for col in required_cols), \
75
+ f"Atlas missing required columns: {set(required_cols) - set(atlas_df.columns)}"
76
+
77
+ atlas_df = atlas_df.copy()
78
+ atlas_df['display_label'] = atlas_df['region_full_name'] + " (" + atlas_df['hemisphere'].str[0] + ")"
79
+
80
+ system_to_rois: Dict[str, List[str]] = {}
81
+ for system in sorted(atlas_df['functional_system'].unique()):
82
+ labels = atlas_df[atlas_df['functional_system'] == system]['display_label'].tolist()
83
+ system_to_rois[system] = sorted(labels)
84
+
85
+ label_to_index: Dict[str, int] = dict(
86
+ zip(atlas_df['display_label'], atlas_df['index'].astype(int))
87
+ )
88
+
89
+ logger.info(f"Built cascading map: {len(system_to_rois)} systems, {len(label_to_index)} ROIs")
90
+ return system_to_rois, label_to_index
91
+
92
+
93
+ def get_default_roi_state(
94
+ system_to_rois: Dict[str, List[str]],
95
+ label_to_index: Dict[str, int]
96
+ ) -> Tuple[str, str, int]:
97
+ """Return (default_system, default_roi_label, default_roi_index)."""
98
+ systems = sorted(system_to_rois.keys())
99
+ assert len(systems) > 0, "No functional systems found in atlas"
100
+ default_system = systems[0]
101
+ rois = system_to_rois[default_system]
102
+ assert len(rois) > 0, f"No ROIs found in system '{default_system}'"
103
+ default_roi = rois[0]
104
+ default_index = label_to_index[default_roi]
105
+ return default_system, default_roi, default_index
106
+
107
+ # =============================================================================
108
+ # 3. CORE COMPUTATION (REMOVED — now served from cache)
109
+ # =============================================================================
110
+
111
+ # compute_aligned_psd is no longer needed at runtime.
112
+ # All PSDs are precomputed in psd_cache.npz.
113
+
114
+ # =============================================================================
115
+ # 4. VISUALIZATION
116
+ # =============================================================================
117
+
118
+ def build_psd_figure(
119
+ freqs: np.ndarray,
120
+ psd_ec_db: np.ndarray,
121
+ psd_eo_db: np.ndarray,
122
+ psd_sm_db: np.ndarray,
123
+ roi_label: str,
124
+ freq_max: float = FREQ_MAX_PLOT_HZ
125
+ ) -> go.Figure:
126
+ """Construct PSD Plotly figure with band shading (original visual style)."""
127
+ fig = go.Figure()
128
+
129
+ # Band shading with annotations (identical to original)
130
+ for f_lo, f_hi, name, color in PLOT_BANDS:
131
+ if f_hi <= freq_max:
132
+ fig.add_vrect(x0=f_lo, x1=f_hi, fillcolor=color, opacity=0.08, layer="below", line_width=0)
133
+ fig.add_annotation(
134
+ x=(f_lo + f_hi) / 2, y=0.97, xref="x", yref="paper",
135
+ text=f"<b>{name}</b>", showarrow=False,
136
+ font=dict(size=10, color='#1E3A5F'), opacity=0.8
137
+ )
138
+
139
+ mask = freqs <= freq_max
140
+ traces = [
141
+ (CONDITION_LABELS["ec"], psd_ec_db, CONDITION_COLORS["ec"]),
142
+ (CONDITION_LABELS["eo"], psd_eo_db, CONDITION_COLORS["eo"]),
143
+ (CONDITION_LABELS["sm"], psd_sm_db, CONDITION_COLORS["sm"]),
144
+ ]
145
+
146
+ for label, psd_db, color in traces:
147
+ fig.add_trace(go.Scatter(
148
+ x=freqs[mask], y=psd_db[mask], mode='lines',
149
+ name=label, line=dict(color=color, width=2.5),
150
+ ))
151
+
152
+ # Identical layout to original
153
+ fig.update_layout(
154
+ legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99, font=dict(size=12)),
155
+ template='plotly_white', margin=dict(t=80, b=60, l=70, r=30), height=500,
156
+ )
157
+ fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='rgba(0,0,0,0.08)')
158
+ fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='rgba(0,0,0,0.08)')
159
+ return fig
160
+
161
+
162
+ def build_ratio_figure(
163
+ roi_label: str,
164
+ band_label: str,
165
+ ec_mean: float,
166
+ eo_mean: float,
167
+ sm_mean: float,
168
+ eps: float = PSD_EPSILON
169
+ ) -> go.Figure:
170
+ """Horizontal bar chart: modulation index relative to EC baseline (original visual style)."""
171
+ comparisons = ['Motor Task', 'Eyes Open']
172
+
173
+ ratio_sm = ((sm_mean - ec_mean) / (sm_mean + ec_mean + eps)) * 100
174
+ ratio_eo = ((eo_mean - ec_mean) / (eo_mean + ec_mean + eps)) * 100
175
+
176
+ values = [ratio_sm, ratio_eo]
177
+ colors = [
178
+ CONDITION_COLORS["sm"] if ratio_sm >= 0 else CONDITION_COLORS["ec"],
179
+ CONDITION_COLORS["eo"] if ratio_eo >= 0 else CONDITION_COLORS["ec"],
180
+ ]
181
+
182
+ fig = go.Figure()
183
+ fig.add_trace(go.Bar(
184
+ y=comparisons, x=values, orientation='h', marker_color=colors,
185
+ text=[f'{v:+.1f}%' for v in values], textposition='inside',
186
+ textfont=dict(size=12, family='monospace', color='white'), insidetextanchor='middle',
187
+ ))
188
+
189
+ # Baseline reference annotations (mirrors original Drug annotation style)
190
+ fig.add_annotation(x=1.02, y='MT', xref='paper', yref='y',
191
+ text='<b>EC</b>', showarrow=False, font=dict(size=11, color='#333'), xanchor='left')
192
+ fig.add_annotation(x=1.02, y='EO', xref='paper', yref='y',
193
+ text='<b>EC</b>', showarrow=False, font=dict(size=11, color='#333'), xanchor='left')
194
+
195
+ # Identical axis/layout styling to original
196
+ fig.update_layout(
197
+ xaxis=dict(tickfont=dict(size=10), zeroline=True, zerolinewidth=1,
198
+ zerolinecolor='#999', showgrid=True, gridwidth=1, gridcolor='rgba(0,0,0,0.06)'),
199
+ yaxis=dict(tickfont=dict(size=11, weight='bold'), showgrid=False, zeroline=False, side='left'),
200
+ template='plotly_white', height=200, margin=dict(t=50, b=30, l=80, r=60),
201
+ )
202
+ return fig
203
+
204
+
205
+ # =============================================================================
206
+ # 5. GRADIO CALLBACKS (ZERO COMPUTATION — pure cache lookup)
207
+ # =============================================================================
208
+
209
+ def update_psd(roi_label, entry_name, label_to_index, cache):
210
+ """Callback for PSD plot update from precomputed cache."""
211
+ if roi_label not in label_to_index:
212
+ raise ValueError(f"ROI label '{roi_label}' not found in index map")
213
+ idx = label_to_index[roi_label]
214
+ entry_idx = np.where(cache['entries'] == entry_name)[0][0]
215
+
216
+ freqs = cache['freqs']
217
+ ec_db = np.nan_to_num(cache['ec_db'][entry_idx, idx, :], nan=0.0)
218
+ eo_db = np.nan_to_num(cache['eo_db'][entry_idx, idx, :], nan=0.0)
219
+ sm_db = np.nan_to_num(cache['sm_db'][entry_idx, idx, :], nan=0.0)
220
+
221
+ return build_psd_figure(freqs, ec_db, eo_db, sm_db, roi_label)
222
+
223
+
224
+ def update_ratio(roi_label, band_label, entry_name, label_to_index, cache):
225
+ """Callback for ratio plot from precomputed cache."""
226
+ if roi_label not in label_to_index:
227
+ raise ValueError(f"ROI label '{roi_label}' not found in index map")
228
+ if band_label not in BAND_OPTIONS:
229
+ raise ValueError(f"Band label '{band_label}' not found in BAND_OPTIONS")
230
+
231
+ idx = label_to_index[roi_label]
232
+ f_lo, f_hi = BAND_OPTIONS[band_label]
233
+ entry_idx = np.where(cache['entries'] == entry_name)[0][0]
234
+
235
+ freqs = cache['freqs']
236
+ ec_db = np.nan_to_num(cache['ec_db'][entry_idx, idx, :], nan=0.0)
237
+ eo_db = np.nan_to_num(cache['eo_db'][entry_idx, idx, :], nan=0.0)
238
+ sm_db = np.nan_to_num(cache['sm_db'][entry_idx, idx, :], nan=0.0)
239
+
240
+ band_mask = (freqs >= f_lo) & (freqs <= f_hi)
241
+ ec_mean = float(np.mean(10 ** (ec_db[band_mask] / 10)))
242
+ eo_mean = float(np.mean(10 ** (eo_db[band_mask] / 10)))
243
+ sm_mean = float(np.mean(10 ** (sm_db[band_mask] / 10)))
244
+
245
+ return build_ratio_figure(roi_label, band_label, ec_mean, eo_mean, sm_mean)
246
+
247
+
248
+ def on_system_change(system, system_to_rois):
249
+ """Update ROI dropdown choices when functional system changes."""
250
+ rois = system_to_rois.get(system, [])
251
+ new_default = rois[0] if rois else None
252
+ return gr.update(choices=rois, value=new_default)
253
+
254
+
255
+ # =============================================================================
256
+ # 6. APP INITIALIZATION
257
+ # =============================================================================
258
+
259
+ def create_app():
260
+ """Build and return the Gradio Blocks app. Importable entry point."""
261
+ cache = load_psd_cache()
262
+
263
+ # Load CIMT labels from bundled atlas
264
+ import lcmv_xtra
265
+ labels_path = Path(lcmv_xtra.__file__).parent / 'data' / 'cimt_atlas' / 'cimt_atlas_labels.csv'
266
+ atlas_df = pd.read_csv(labels_path)
267
+
268
+ SYSTEM_TO_ROIS, LABEL_TO_INDEX = build_cascading_roi_map(atlas_df)
269
+ DEFAULT_SYS, DEFAULT_ROI, _ = get_default_roi_state(SYSTEM_TO_ROIS, LABEL_TO_INDEX)
270
+
271
+ entries = list(cache['entries']) # ["Group Average", "sub-01", "sub-02", ...]
272
+
273
+ initial_fig = update_psd(DEFAULT_ROI, "Group Average", LABEL_TO_INDEX, cache)
274
+ initial_ratio = update_ratio(DEFAULT_ROI, 'Alpha (8-13 Hz)', "Group Average", LABEL_TO_INDEX, cache)
275
+
276
+ with gr.Blocks(title="UNI Task Atlas Explorer") as app:
277
+ gr.Markdown(
278
+ "# UNI Task: Full Atlas PSD Explorer\n"
279
+ "Interactive delta-aligned PSD analysis across Eyes Closed / Eyes Open / Motor Task conditions"
280
+ )
281
+
282
+ with gr.Row():
283
+ with gr.Column(scale=1):
284
+ subject_dropdown = gr.Dropdown(
285
+ choices=entries,
286
+ value="Group Average",
287
+ label="Subject",
288
+ info="Select individual subject or group average"
289
+ )
290
+ sys_dropdown = gr.Dropdown(
291
+ choices=sorted(SYSTEM_TO_ROIS.keys()),
292
+ value=DEFAULT_SYS,
293
+ label="Functional System",
294
+ info="Select brain network to filter ROIs"
295
+ )
296
+ roi_dropdown = gr.Dropdown(
297
+ choices=SYSTEM_TO_ROIS[DEFAULT_SYS],
298
+ value=DEFAULT_ROI,
299
+ label="Region of Interest",
300
+ info="Select specific anatomical region"
301
+ )
302
+ band_dropdown = gr.Dropdown(
303
+ choices=list(BAND_OPTIONS.keys()),
304
+ value='Alpha (8-13 Hz)',
305
+ label="Frequency Band",
306
+ info="Band-averaged power comparison"
307
+ )
308
+ ratio_output = gr.Plot(label="Condition Modulation", value=initial_ratio)
309
+
310
+ with gr.Column(scale=2):
311
+ psd_plot = gr.Plot(label="Delta-Aligned PSD", value=initial_fig)
312
+
313
+ sys_dropdown.change(
314
+ fn=lambda s: on_system_change(s, SYSTEM_TO_ROIS),
315
+ inputs=sys_dropdown, outputs=roi_dropdown
316
+ )
317
+ roi_dropdown.change(
318
+ fn=lambda r, subj: update_psd(r, subj, LABEL_TO_INDEX, cache),
319
+ inputs=[roi_dropdown, subject_dropdown], outputs=psd_plot
320
+ )
321
+ subject_dropdown.change(
322
+ fn=lambda r, subj: update_psd(r, subj, LABEL_TO_INDEX, cache),
323
+ inputs=[roi_dropdown, subject_dropdown], outputs=psd_plot
324
+ )
325
+ roi_dropdown.change(
326
+ fn=lambda r, b, subj: update_ratio(r, b, subj, LABEL_TO_INDEX, cache),
327
+ inputs=[roi_dropdown, band_dropdown, subject_dropdown], outputs=ratio_output
328
+ )
329
+ band_dropdown.change(
330
+ fn=lambda r, b, subj: update_ratio(r, b, subj, LABEL_TO_INDEX, cache),
331
+ inputs=[roi_dropdown, band_dropdown, subject_dropdown], outputs=ratio_output
332
+ )
333
+ subject_dropdown.change(
334
+ fn=lambda r, b, subj: update_ratio(r, b, subj, LABEL_TO_INDEX, cache),
335
+ inputs=[roi_dropdown, band_dropdown, subject_dropdown], outputs=ratio_output
336
+ )
337
+
338
+ return app
339
+
340
+
341
+ if __name__ == "__main__":
342
+ app = create_app()
343
+ app.launch(theme=gr.themes.Soft(), css=".gradio-container { max-width: 1200px !important; }")