oceanproc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oceanproc/__init__.py +1 -0
- oceanproc/bids_wrapper.py +331 -0
- oceanproc/events_long.py +153 -0
- oceanproc/firstlevel/__init__.py +0 -0
- oceanproc/firstlevel/run_glm.py +923 -0
- oceanproc/firstlevel/tests/__init__.py +0 -0
- oceanproc/firstlevel/tests/test_run_glm.py +165 -0
- oceanproc/fmriprep_wrapper.py +222 -0
- oceanproc/group_series.py +228 -0
- oceanproc/grouplevel/__init__.py +0 -0
- oceanproc/grouplevel/computations.py +80 -0
- oceanproc/grouplevel/design_mat_utils.py +54 -0
- oceanproc/grouplevel/dscalar_utils.py +51 -0
- oceanproc/grouplevel/run.py +18 -0
- oceanproc/grouplevel/utils.py +4 -0
- oceanproc/ocean_proc.py +215 -0
- oceanproc/oceanparse.py +107 -0
- oceanproc/utils.py +224 -0
- oceanproc/web/TODO.md +4 -0
- oceanproc/web/__init__.py +0 -0
- oceanproc/web/app.py +78 -0
- oceanproc/web/config.json +7 -0
- oceanproc/web/get_parser_json.py +58 -0
- oceanproc/web/static/components/ArgparseForm.js +9 -0
- oceanproc/web/static/components/ArgparseFormEntry.js +52 -0
- oceanproc/web/static/components/DirIcon.js +11 -0
- oceanproc/web/static/components/FileBrowser.js +85 -0
- oceanproc/web/static/components/FileBrowserOpenBtn.js +12 -0
- oceanproc/web/static/components/FileEntry.js +32 -0
- oceanproc/web/static/components/FileIcon.js +11 -0
- oceanproc/web/static/components/OceanprocForm.js +53 -0
- oceanproc/web/static/components/styles.css +44 -0
- oceanproc/web/templates/filesystem.html +13 -0
- oceanproc/web/templates/filesystem_fail.html +2 -0
- oceanproc/web/templates/oceanproc_form.html +23 -0
- oceanproc/web/templates/react-test.html +0 -0
- oceanproc/web/templates/username_card_component_test.html +39 -0
- oceanproc-0.1.0.dist-info/METADATA +29 -0
- oceanproc-0.1.0.dist-info/RECORD +41 -0
- oceanproc-0.1.0.dist-info/WHEEL +4 -0
- oceanproc-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from glob import glob
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from sklearn.preprocessing import StandardScaler
|
|
10
|
+
import nibabel as nib
|
|
11
|
+
# from nilearn.glm.first_level import FirstLevelModel
|
|
12
|
+
# from nilearn.plotting import plot_design_matrix
|
|
13
|
+
# import matplotlib.pyplot as plt
|
|
14
|
+
import nilearn.masking as nmask
|
|
15
|
+
from nilearn.signal import clean
|
|
16
|
+
import json
|
|
17
|
+
from scipy import signal
|
|
18
|
+
from scipy.stats import gamma
|
|
19
|
+
from ..oceanparse import OceanParser
|
|
20
|
+
from ..utils import exit_program_early, add_file_handler, default_log_format, export_args_to_file, flags, debug_logging, log_linebreak
|
|
21
|
+
import logging
|
|
22
|
+
import datetime
|
|
23
|
+
from textwrap import dedent
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
TODO:
|
|
27
|
+
* Find good way to pass hrf peak and undershoot variables
|
|
28
|
+
* Save final noise df for each run
|
|
29
|
+
* Debug Mode - save intermediate outputs
|
|
30
|
+
* Options for highpass and lowpass filters
|
|
31
|
+
* Function documentation and testing
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
logging.basicConfig(level=logging.INFO,
|
|
35
|
+
handlers=[logging.StreamHandler(stream=sys.stdout)],
|
|
36
|
+
format=default_log_format)
|
|
37
|
+
logger = logging.getLogger()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@debug_logging
|
|
41
|
+
def load_data(func_file: str|Path,
|
|
42
|
+
brain_mask: str = None,
|
|
43
|
+
need_tr: bool = False) -> np.ndarray:
|
|
44
|
+
tr = None
|
|
45
|
+
func_file = str(func_file)
|
|
46
|
+
if need_tr:
|
|
47
|
+
sidecar_file = func_file.split(".")[0] + ".json"
|
|
48
|
+
assert os.path.isfile(sidecar_file), f"Cannot find the .json sidecar file for bold run: {func_file}"
|
|
49
|
+
with open(sidecar_file, "r") as f:
|
|
50
|
+
jd = json.load(f)
|
|
51
|
+
tr = jd["RepetitionTime"]
|
|
52
|
+
|
|
53
|
+
if func_file.endswith(".dtseries.nii") or func_file.endswith(".dscalar.nii"):
|
|
54
|
+
img = nib.load(func_file)
|
|
55
|
+
return (img.get_fdata(), tr, img.header)
|
|
56
|
+
elif func_file.endswith(".nii") or func_file.endswith(".nii.gz"):
|
|
57
|
+
if brain_mask:
|
|
58
|
+
return (nmask.apply_mask(func_file, brain_mask), tr, None)
|
|
59
|
+
else:
|
|
60
|
+
raise Exception("Volumetric data must also have an accompanying brain mask")
|
|
61
|
+
# return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@debug_logging
|
|
65
|
+
def create_image(data: npt.ArrayLike,
|
|
66
|
+
brain_mask: str = None,
|
|
67
|
+
tr: float = None,
|
|
68
|
+
header: nib.cifti2.cifti2.Cifti2Header = None):
|
|
69
|
+
img = None
|
|
70
|
+
suffix = ".nii"
|
|
71
|
+
d32k = 32492
|
|
72
|
+
if brain_mask:
|
|
73
|
+
img = nmask.unmask(data, brain_mask)
|
|
74
|
+
else:
|
|
75
|
+
ax0 = None
|
|
76
|
+
if data.shape[0] > 1 and tr:
|
|
77
|
+
ax0 = nib.cifti2.cifti2_axes.SeriesAxis(
|
|
78
|
+
start=0.0,
|
|
79
|
+
step=tr,
|
|
80
|
+
size=data.shape[0]
|
|
81
|
+
)
|
|
82
|
+
suffix = ".dtseries" + suffix
|
|
83
|
+
elif data.shape[0] == 1:
|
|
84
|
+
ax0 = nib.cifti2.cifti2_axes.ScalarAxis(
|
|
85
|
+
name=["beta"]
|
|
86
|
+
)
|
|
87
|
+
suffix = ".dscalar" + suffix
|
|
88
|
+
else:
|
|
89
|
+
raise RuntimeError("TR not supplied or data shape is incorrect")
|
|
90
|
+
ax1 = None
|
|
91
|
+
if header:
|
|
92
|
+
ax1 = header.get_axis(1)
|
|
93
|
+
else:
|
|
94
|
+
# Need to change this default behavior to create a correct Brain Model axis
|
|
95
|
+
ax1 = nib.cifti2.cifti2_axes.BrainModelAxis(
|
|
96
|
+
name=(['CIFTI_STRUCTURE_CORTEX_LEFT']*d32k)+(['CIFTI_STRUCTURE_CORTEX_RIGHT']*d32k),
|
|
97
|
+
vertex=np.concatenate((np.arange(d32k), np.arange(d32k))),
|
|
98
|
+
nvertices={'CIFTI_STRUCTURE_CORTEX_LEFT':d32k, 'CIFTI_STRUCTURE_CORTEX_RIGHT':d32k}
|
|
99
|
+
)
|
|
100
|
+
img = nib.cifti2.cifti2.Cifti2Image(data, (ax0, ax1))
|
|
101
|
+
return (img ,suffix)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def demean_detrend(func_data: npt.ArrayLike) -> np.ndarray:
|
|
105
|
+
"""
|
|
106
|
+
Subtracts the mean and a least-squares-fit line from each timepoint at every vertex/voxel.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
|
|
111
|
+
func_data: npt.ArrayLike
|
|
112
|
+
array containing functional timeseries data
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
data_dd: np.ndarray
|
|
117
|
+
A demeaned/detrended copy of the input array
|
|
118
|
+
"""
|
|
119
|
+
data_dd = signal.detrend(func_data, axis=0, type = 'linear')
|
|
120
|
+
return data_dd
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_hrf(time, time_to_peak=5, undershoot_dur=12):
|
|
124
|
+
"""
|
|
125
|
+
This function creates a hemodynamic response function timeseries.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
time: numpy array
|
|
130
|
+
a 1D numpy array that makes up the x-axis (time) of our HRF in seconds
|
|
131
|
+
time_to_peak: int
|
|
132
|
+
Time to HRF peak in seconds. Default is 5 seconds.
|
|
133
|
+
undershoot_dur: int
|
|
134
|
+
Duration of the post-peak undershoot. Default is 12 seconds.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
hrf_timeseries: numpy array
|
|
139
|
+
The y-values for the HRF at each time point
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
peak = gamma.pdf(time, time_to_peak)
|
|
143
|
+
undershoot = gamma.pdf(time, undershoot_dur)
|
|
144
|
+
hrf_timeseries = peak - 0.35 * undershoot
|
|
145
|
+
return hrf_timeseries
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@debug_logging
|
|
149
|
+
def hrf_convolve_features(features: pd.DataFrame,
|
|
150
|
+
column_names: list = None,
|
|
151
|
+
time_col: str = 'index',
|
|
152
|
+
units: str = 's',
|
|
153
|
+
time_to_peak: int = 5,
|
|
154
|
+
undershoot_dur: int = 12):
|
|
155
|
+
"""
|
|
156
|
+
This function convolves a hemodynamic response function with each column in a timeseries dataframe.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
features: DataFrame
|
|
161
|
+
A Pandas dataframe with the feature signals to convolve.
|
|
162
|
+
column_names: list
|
|
163
|
+
List of columns names to use; if it is None, use all columns. Default is None.
|
|
164
|
+
time_col: str
|
|
165
|
+
The name of the time column to use if not the index. Default is "index".
|
|
166
|
+
units: str
|
|
167
|
+
Must be 'ms','s','m', or 'h' to denote milliseconds, seconds, minutes, or hours respectively.
|
|
168
|
+
time_to_peak: int
|
|
169
|
+
Time to peak for HRF model. Default is 5 seconds.
|
|
170
|
+
undershoot_dur: int
|
|
171
|
+
Undershoot duration for HRF model. Default is 12 seconds.
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
convolved_features: DataFrame
|
|
176
|
+
The HRF-convolved feature timeseries
|
|
177
|
+
"""
|
|
178
|
+
if not column_names:
|
|
179
|
+
column_names = features.columns
|
|
180
|
+
|
|
181
|
+
if time_col == 'index':
|
|
182
|
+
time = features.index.to_numpy()
|
|
183
|
+
else:
|
|
184
|
+
time = features[time_col]
|
|
185
|
+
features.index = time
|
|
186
|
+
|
|
187
|
+
if units == 'm' or units == 'minutes':
|
|
188
|
+
features.index = features.index * 60
|
|
189
|
+
time = features.index.to_numpy()
|
|
190
|
+
if units == 'h' or units == 'hours':
|
|
191
|
+
features.index = features.index * 3600
|
|
192
|
+
time = features.index.to_numpy()
|
|
193
|
+
if units == 'ms' or units == 'milliseconds':
|
|
194
|
+
features.index = features.index / 1000
|
|
195
|
+
time = features.index.to_numpy()
|
|
196
|
+
|
|
197
|
+
convolved_features = pd.DataFrame(index=time)
|
|
198
|
+
hrf_sig = create_hrf(time, time_to_peak=time_to_peak, undershoot_dur=undershoot_dur)
|
|
199
|
+
for a in column_names:
|
|
200
|
+
convolved_features[a] = np.convolve(features[a], hrf_sig)[:len(time)]
|
|
201
|
+
|
|
202
|
+
return convolved_features
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def find_nearest(array, value):
|
|
206
|
+
"""
|
|
207
|
+
Finds the smallest difference in 'value' and one of the
|
|
208
|
+
elements of 'array', and returns the index of the element
|
|
209
|
+
|
|
210
|
+
:param array: a list of elements to compare value to
|
|
211
|
+
:type array: a list or list-like object
|
|
212
|
+
:param value: a value to compare to elements of array
|
|
213
|
+
:type value: integer or float
|
|
214
|
+
:return: integer index of array
|
|
215
|
+
:rtype: int
|
|
216
|
+
"""
|
|
217
|
+
array = np.asarray(array)
|
|
218
|
+
idx = (np.abs(array - value)).argmin()
|
|
219
|
+
return(array[idx])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@debug_logging
|
|
223
|
+
def make_noise_ts(confounds_file: str,
|
|
224
|
+
confounds_columns: list,
|
|
225
|
+
demean: bool = False,
|
|
226
|
+
linear_trend: bool = False,
|
|
227
|
+
spike_threshold: float = None,
|
|
228
|
+
volterra_expansion: int = None,
|
|
229
|
+
volterra_columns: list = None):
|
|
230
|
+
select_columns = set(confounds_columns)
|
|
231
|
+
if volterra_columns:
|
|
232
|
+
select_columns.update(volterra_columns)
|
|
233
|
+
if spike_threshold:
|
|
234
|
+
select_columns.add(fd)
|
|
235
|
+
nuisance = pd.read_csv(confounds_file, delimiter='\t').loc[:,list(select_columns)]
|
|
236
|
+
if "framewise_displacement" in select_columns:
|
|
237
|
+
nuisance.loc[0, "framewise_displacement"] = 0
|
|
238
|
+
|
|
239
|
+
if demean:
|
|
240
|
+
nuisance["mean"] = 1
|
|
241
|
+
|
|
242
|
+
if linear_trend:
|
|
243
|
+
nuisance["trend"] = np.arange(0, len(nuisance))
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
Add a new column denoting indices where a frame
|
|
247
|
+
is censored for each row in the framewise_displacement
|
|
248
|
+
that is larger than spike_threshold.
|
|
249
|
+
"""
|
|
250
|
+
if spike_threshold:
|
|
251
|
+
b = 0
|
|
252
|
+
for a in range(len(nuisance)):
|
|
253
|
+
if nuisance.loc[a,"framewise_displacement"] > spike_threshold:
|
|
254
|
+
nuisance[f"spike{b}"] = 0
|
|
255
|
+
nuisance.loc[a, f"spike{b}"] = 1
|
|
256
|
+
b += 1
|
|
257
|
+
if fd not in confound_columns:
|
|
258
|
+
nuisance.drop(columns=fd, inplace=True)
|
|
259
|
+
|
|
260
|
+
if volterra_expansion and volterra_columns:
|
|
261
|
+
for vc in volterra_columns:
|
|
262
|
+
for lag in range(volterra_expansion):
|
|
263
|
+
nuisance.loc[:, f"{vc}_{lag+1}"] = nuisance.loc[:, vc].shift(lag+1)
|
|
264
|
+
nuisance.fillna(0, inplace=True)
|
|
265
|
+
elif volterra_expansion:
|
|
266
|
+
raise RuntimeError("You must specify which columns you'd like to apply Volterra expansion to.")
|
|
267
|
+
elif volterra_columns:
|
|
268
|
+
raise RuntimeError("You must specify the lag applied in Volterra expansion.")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
return nuisance
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
#TODO: maybe write a validator for the input task file?
|
|
276
|
+
@debug_logging
|
|
277
|
+
def events_to_design(func_data: npt.ArrayLike,
|
|
278
|
+
tr: float,
|
|
279
|
+
event_file: str | Path,
|
|
280
|
+
fir: int = None,
|
|
281
|
+
hrf: tuple[int] = None,
|
|
282
|
+
fir_list: list[str] = None,
|
|
283
|
+
hrf_list: list[str] = None,
|
|
284
|
+
output_path: str = None):
|
|
285
|
+
"""
|
|
286
|
+
Builds an initial design matrix from an event file. You can
|
|
287
|
+
convolve specified event types with an hemodynamic response function
|
|
288
|
+
(HRF).
|
|
289
|
+
|
|
290
|
+
The events are expected to be in a .tsv file, with the following columns:
|
|
291
|
+
|
|
292
|
+
trial_type: a string representing the unique trial type. oceanfla gives you the
|
|
293
|
+
freedom to arrange these trial types in any way you want; they can represent
|
|
294
|
+
events on their own, combinations of concurrent events, etc.
|
|
295
|
+
onset: the onset time of an event
|
|
296
|
+
duration: the duration of an event
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
func_data: npt.ArrayLike
|
|
301
|
+
A numpy array-like object representing functional data
|
|
302
|
+
tr: float
|
|
303
|
+
A float representing repetition time, the rate at
|
|
304
|
+
which single brain images are captured following
|
|
305
|
+
a radio frequency (RF) pulse
|
|
306
|
+
event_file: str | Path
|
|
307
|
+
.tsv file containing information about events, their onsets, and their durations (view the formatting of it above)
|
|
308
|
+
fir: int = None
|
|
309
|
+
An int denoting the order of an FIR filter
|
|
310
|
+
hrf: tuple[int] = None
|
|
311
|
+
A 2-length tuple, where hrf[0] denotes the time to the peak of an HRF, and hrf[1] denotes the duration of its "undershoot" after the peak.
|
|
312
|
+
fir_list: list[str] = None
|
|
313
|
+
A list of column names denoting which columns should have an FIR filter applied.
|
|
314
|
+
hrf_list: list[str] = None
|
|
315
|
+
A list of column names denoting which columns should be convolved with the HRF function defined in the hrf tuple.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
(events_long, conditions): tuple
|
|
320
|
+
A tuple containing the DataFrame with filtered/convolved columns and a list of unique trial names.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
if tr and tr <= 0:
|
|
324
|
+
raise ValueError(f"tr must be greater than 0. Current tr: {tr}")
|
|
325
|
+
if fir and fir <= 0:
|
|
326
|
+
raise ValueError(f"fir value must be greater than 0. Current fir: {fir}")
|
|
327
|
+
if hrf and not (len(hrf) == 2 and hrf[0] > 0 and hrf[1] > 0):
|
|
328
|
+
raise ValueError(f"hrf tuple must contain two integers greater than 0. Current hrf tuple: {hrf}")
|
|
329
|
+
# If both FIR and HRF are specified, we should have at least one list
|
|
330
|
+
# of columns for one of the categories specified.
|
|
331
|
+
if (fir and hrf) and not (fir_list or hrf_list):
|
|
332
|
+
raise RuntimeError("Both FIR and HRF were specified, but you need to specify at least one list of columns (fir_list or hrf_list)")
|
|
333
|
+
# fir_list and hrf_list must not have overlapping columns
|
|
334
|
+
if (fir_list and hrf_list) and not set(fir_list).isdisjoint(hrf_list):
|
|
335
|
+
raise RuntimeError("Both FIR and HRF lists of columns were specified, but they overlap.")
|
|
336
|
+
duration = tr * func_data.shape[0]
|
|
337
|
+
events_df = pd.read_csv(event_file, index_col=None, delimiter='\t')
|
|
338
|
+
conditions = [s for s in np.unique(events_df.trial_type)] # unique trial types
|
|
339
|
+
events_long = pd.DataFrame(0, columns=conditions, index=np.arange(0, duration, tr))
|
|
340
|
+
residual_conditions = conditions
|
|
341
|
+
if (fir and hrf) and (bool(fir_list) ^ bool(hrf_list)): # Create other list if only one is specified
|
|
342
|
+
if fir_list:
|
|
343
|
+
hrf_list = [c for c in residual_conditions if c not in fir_list]
|
|
344
|
+
elif hrf_list:
|
|
345
|
+
fir_list = [c for c in residual_conditions if c not in hrf_list]
|
|
346
|
+
assert set(hrf_list).isdisjoint(fir_list)
|
|
347
|
+
|
|
348
|
+
for e in events_df.index:
|
|
349
|
+
i = find_nearest(events_long.index, events_df.loc[e,'onset'])
|
|
350
|
+
events_long.loc[i, events_df.loc[e,'trial_type']] = 1
|
|
351
|
+
if events_df.loc[e,'duration'] > tr:
|
|
352
|
+
offset = events_df.loc[e,'onset'] + events_df.loc[e,'duration']
|
|
353
|
+
j = find_nearest(events_long.index, offset)
|
|
354
|
+
events_long.loc[i:j, events_df.loc[e,'trial_type']] = 1
|
|
355
|
+
|
|
356
|
+
if fir:
|
|
357
|
+
fir_conditions = residual_conditions
|
|
358
|
+
if fir_list and len(fir_list) > 0:
|
|
359
|
+
fir_conditions = [c for c in residual_conditions if c in fir_list]
|
|
360
|
+
residual_conditions = [c for c in residual_conditions if c not in fir_conditions]
|
|
361
|
+
|
|
362
|
+
col_names = {c:c+"_00" for c in fir_conditions}
|
|
363
|
+
events_long = events_long.rename(columns=col_names)
|
|
364
|
+
fir_cols_to_add = dict()
|
|
365
|
+
for c in fir_conditions:
|
|
366
|
+
for i in range(1, fir):
|
|
367
|
+
fir_cols_to_add[f"{c}_{i:02d}"] = np.array(np.roll(events_long.loc[:,col_names[c]], shift=i, axis=0))
|
|
368
|
+
# so events do not roll back around to the beginnin
|
|
369
|
+
fir_cols_to_add[f"{c}_{i:02d}"][:i] = 0
|
|
370
|
+
events_long = pd.concat([events_long, pd.DataFrame(fir_cols_to_add, index=events_long.index)], axis=1)
|
|
371
|
+
events_long = events_long.astype(int)
|
|
372
|
+
if hrf:
|
|
373
|
+
hrf_conditions = residual_conditions
|
|
374
|
+
if hrf_list and len(hrf_list) > 0:
|
|
375
|
+
hrf_conditions = [c for c in residual_conditions if c in hrf_list]
|
|
376
|
+
residual_conditions = [c for c in residual_conditions if c not in hrf_conditions]
|
|
377
|
+
|
|
378
|
+
cfeats = hrf_convolve_features(features=events_long,
|
|
379
|
+
column_names=hrf_conditions,
|
|
380
|
+
time_to_peak=hrf[0],
|
|
381
|
+
undershoot_dur=hrf[1])
|
|
382
|
+
for c in hrf_conditions:
|
|
383
|
+
events_long[c] = cfeats[c]
|
|
384
|
+
|
|
385
|
+
if len(residual_conditions) > 0 and logger:
|
|
386
|
+
logger.warning(dedent(f"""The following trial types were not selected under either of the specified models
|
|
387
|
+
and will not be included in the design matrix: {residual_conditions}"""))
|
|
388
|
+
events_long = events_long.drop(columns=residual_conditions)
|
|
389
|
+
|
|
390
|
+
if output_path:
|
|
391
|
+
logger.debug(f" saving events matrix to file: {output_path}")
|
|
392
|
+
events_long.to_csv(output_path)
|
|
393
|
+
|
|
394
|
+
return (events_long, conditions)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@debug_logging
|
|
398
|
+
def bandpass_filter(func_data: npt.ArrayLike,
|
|
399
|
+
tr: float,
|
|
400
|
+
high_cut: float = 0.1,
|
|
401
|
+
low_cut: float = 0.008,
|
|
402
|
+
order: int = 2 ):
|
|
403
|
+
"""
|
|
404
|
+
Apply a bandpass filter to the functional data, between two frequencies.
|
|
405
|
+
|
|
406
|
+
Parameters
|
|
407
|
+
----------
|
|
408
|
+
func_data: npt.ArrayLike
|
|
409
|
+
A numpy array representing BOLD data
|
|
410
|
+
tr: float
|
|
411
|
+
Repetition time at the scanner
|
|
412
|
+
high_cut: float
|
|
413
|
+
Frequency above which the bandpass filter will be applied
|
|
414
|
+
low_cut: float
|
|
415
|
+
Frequency below which the bandpass filter will be applied
|
|
416
|
+
order: int
|
|
417
|
+
Order of the filter
|
|
418
|
+
|
|
419
|
+
Returns
|
|
420
|
+
-------
|
|
421
|
+
|
|
422
|
+
filtered_data: npt.ArrayLike
|
|
423
|
+
A numpy array representing BOLD data with the bandpass filter applied
|
|
424
|
+
"""
|
|
425
|
+
fs = 1/tr
|
|
426
|
+
nyquist = 1/(2*tr)
|
|
427
|
+
high = high_cut/nyquist
|
|
428
|
+
low = low_cut/nyquist
|
|
429
|
+
# sos = signal.butter(order, [low, high], btype="band", fs=fs, output="sos")
|
|
430
|
+
b, a = signal.butter(order, [low, high], btype="band", fs=fs)
|
|
431
|
+
|
|
432
|
+
# filtered_data = signal.sosfiltfilt(sos=sos, x=func_data, axis=0)
|
|
433
|
+
filtered_data = signal.filtfilt(b=b, a=a, x=func_data, axis=0)
|
|
434
|
+
|
|
435
|
+
return filtered_data
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@debug_logging
|
|
439
|
+
def nuisance_regression(func_data: npt.ArrayLike,
|
|
440
|
+
noise_matrix: pd.DataFrame,
|
|
441
|
+
**kwargs):
|
|
442
|
+
"""
|
|
443
|
+
Regresses out given nuisance variables from functional data
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
|
|
448
|
+
func_data: npt.ArrayLike
|
|
449
|
+
A numpy array representing BOLD data
|
|
450
|
+
noise_matrix: pd.DataFrame
|
|
451
|
+
Matrix containing nuisance regressors
|
|
452
|
+
|
|
453
|
+
Returns
|
|
454
|
+
-------
|
|
455
|
+
|
|
456
|
+
Returns a numpy array representing BOLD data with given nuisance regressors regressed away.
|
|
457
|
+
"""
|
|
458
|
+
ss = StandardScaler()
|
|
459
|
+
# designmat = ss.fit_transform(noise_matrix[noise_matrix["framewise_displacement"]<fd_thresh].to_numpy())
|
|
460
|
+
designmat = ss.fit_transform(noise_matrix.to_numpy().astype(float))
|
|
461
|
+
neuro_data = ss.fit_transform(func_data)
|
|
462
|
+
inv_designmat = np.linalg.pinv(designmat)
|
|
463
|
+
beta_data = np.dot(inv_designmat, neuro_data)
|
|
464
|
+
est_values = np.dot(designmat, beta_data)
|
|
465
|
+
|
|
466
|
+
return func_data - est_values
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@debug_logging
|
|
470
|
+
def create_final_design(data_list: list[npt.ArrayLike],
|
|
471
|
+
design_list: list[pd.DataFrame],
|
|
472
|
+
noise_list: list[pd.DataFrame] = None,
|
|
473
|
+
exclude_global_mean: bool = False):
|
|
474
|
+
"""
|
|
475
|
+
Creates a final, concatenated design matrix for all functional runs in a session
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
|
|
480
|
+
data_list: list[npt.ArrayLike]
|
|
481
|
+
List of numpy arrays representing BOLD data
|
|
482
|
+
design_list: list[pd.DataFrame]
|
|
483
|
+
List of created design matrices corresponding to each respective BOLD run in data_list
|
|
484
|
+
noise_list: list[pd.DataFrame]
|
|
485
|
+
List of DataFrame objects corresponding to models of noise for each respective BOLD run in data_list
|
|
486
|
+
exclude_global_mean: bool
|
|
487
|
+
Flag to indicate that a global mean should not be included into the final design matrix
|
|
488
|
+
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
|
|
492
|
+
Returns a tuple containing the final concatenated data in index 0, and the
|
|
493
|
+
final concatenated design matrix in index 1.
|
|
494
|
+
"""
|
|
495
|
+
num_runs = len(data_list)
|
|
496
|
+
assert num_runs == len(design_list), "There should be the same number of design matrices and functional runs"
|
|
497
|
+
|
|
498
|
+
if noise_list:
|
|
499
|
+
assert num_runs == len(noise_list), "There should be the same number of noise matrices and functional runs"
|
|
500
|
+
for i in range(num_runs):
|
|
501
|
+
noise_df = noise_list[i]
|
|
502
|
+
assert len(noise_df) == len(design_list[i])
|
|
503
|
+
rename_dict = dict()
|
|
504
|
+
for c in noise_df.columns:
|
|
505
|
+
if ("trend" in c) or ("mean" in c) or ("spike" in c):
|
|
506
|
+
rename_dict[c] = f"run-{i+1}_{c}"
|
|
507
|
+
noise_df = noise_df.rename(columns=rename_dict)
|
|
508
|
+
noise_list[i] = noise_df
|
|
509
|
+
design_list[i] = pd.concat([design_list[i].reset_index(drop=True), noise_df.reset_index(drop=True)], axis=1)
|
|
510
|
+
|
|
511
|
+
final_design = pd.concat(design_list, axis=0, ignore_index=True).fillna(0)
|
|
512
|
+
if not exclude_global_mean:
|
|
513
|
+
final_design.loc[:, "global_mean"] = 1
|
|
514
|
+
final_data = np.concat(data_list, axis=0)
|
|
515
|
+
return (final_data, final_design)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@debug_logging
|
|
519
|
+
def massuni_linGLM(func_data: npt.ArrayLike,
|
|
520
|
+
design_matrix: pd.DataFrame):
|
|
521
|
+
"""
|
|
522
|
+
Compute the mass univariate GLM.
|
|
523
|
+
|
|
524
|
+
Parameters
|
|
525
|
+
----------
|
|
526
|
+
|
|
527
|
+
func_data: npt.ArrayLike
|
|
528
|
+
Numpy array representing BOLD data
|
|
529
|
+
design_matrix: pd.DataFrame
|
|
530
|
+
DataFrame representing a design matrix for the GLM
|
|
531
|
+
"""
|
|
532
|
+
ss = StandardScaler()
|
|
533
|
+
design_matrix = ss.fit_transform(design_matrix.to_numpy())
|
|
534
|
+
neuro_data = ss.fit_transform(func_data)
|
|
535
|
+
|
|
536
|
+
inv_mat = np.linalg.pinv(design_matrix)
|
|
537
|
+
beta_data = np.dot(inv_mat, neuro_data)
|
|
538
|
+
return beta_data
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def main():
|
|
543
|
+
parser = OceanParser(
|
|
544
|
+
prog="oceanfla",
|
|
545
|
+
description="Ocean Labs first level analysis",
|
|
546
|
+
fromfile_prefix_chars="@",
|
|
547
|
+
epilog="An arguments file can be accepted with @FILEPATH"
|
|
548
|
+
)
|
|
549
|
+
session_arguments = parser.add_argument_group("Session Specific")
|
|
550
|
+
config_arguments = parser.add_argument_group("Configuration Arguments", "These arguments are saved to a file if the '--export_args' option is used")
|
|
551
|
+
|
|
552
|
+
session_arguments.add_argument("--subject", "-su", required=True,
|
|
553
|
+
help="The subject ID")
|
|
554
|
+
session_arguments.add_argument("--session", "-se", required=True,
|
|
555
|
+
help="The session ID")
|
|
556
|
+
session_arguments.add_argument("--export_args", "-ea", type=Path,
|
|
557
|
+
help="Path to a file to save the current arguments.")
|
|
558
|
+
session_arguments.add_argument("--debug", action="store_true",
|
|
559
|
+
help="Use this flag to save intermediate outputs for a chance to debug inputs")
|
|
560
|
+
|
|
561
|
+
config_arguments.add_argument("--task", "-t", required=True,
|
|
562
|
+
help="The name of the task to analyze.")
|
|
563
|
+
config_arguments.add_argument("--bold_file_type", "-ft", required=True,
|
|
564
|
+
help="The file type of the functional runs to use.")
|
|
565
|
+
config_arguments.add_argument("--brain_mask", "-bm", type=Path,
|
|
566
|
+
help="If the bold file type is volumetric data, a brain mask must also be supplied.")
|
|
567
|
+
config_arguments.add_argument("--derivs_dir", "-d", type=Path, required=True,
|
|
568
|
+
help="Path to the BIDS formatted derivatives directory containing preprocessed outputs.")
|
|
569
|
+
config_arguments.add_argument("--raw_bids", "-r", type=Path, required=True,
|
|
570
|
+
help="Path to the BIDS formatted raw data directory for this dataset.")
|
|
571
|
+
config_arguments.add_argument("--derivs_subfolder", "-ds", default="first_level",
|
|
572
|
+
help="The name of the subfolder in the derivatives directory where bids style outputs should be stored. The default is 'first_level'.")
|
|
573
|
+
config_arguments.add_argument("--output_dir", "-o", type=Path,
|
|
574
|
+
help="Alternate Path to a directory to store the results of this analysis. Default is '[derivs_dir]/first_level/'")
|
|
575
|
+
config_arguments.add_argument("--fir", "-ff", type=int,
|
|
576
|
+
help="The number of frames to use in an FIR model.")
|
|
577
|
+
config_arguments.add_argument("--fir_vars", nargs="*",
|
|
578
|
+
help="""A list of the task regressors to apply this FIR model to. The default is to apply it to all regressors if no
|
|
579
|
+
value is specified. A list must be specified if both types of models are being used""")
|
|
580
|
+
config_arguments.add_argument("--hrf", nargs=2, type=int, metavar=("PEAK", "UNDER"),
|
|
581
|
+
help="""Two values to describe the hrf function that will be convolved with the task events.
|
|
582
|
+
The first value is the time to the peak, and the second is the undershoot duration. Both in units of seconds.""")
|
|
583
|
+
config_arguments.add_argument("--hrf_vars", nargs="*",
|
|
584
|
+
help="""A list of the task regressors to apply this HRF model to. The default is to apply it to all regressors if no
|
|
585
|
+
value is specifed. A list must be specified if both types of models are being used""")
|
|
586
|
+
config_arguments.add_argument("--confounds", "-c", nargs="+", default=[],
|
|
587
|
+
help="A list of confounds to include from each confound timeseries tsv file.")
|
|
588
|
+
config_arguments.add_argument("--fd_threshold", "-fd", type=float, default=0.9,
|
|
589
|
+
help="The framewise displacement threshold used when censoring high-motion frames")
|
|
590
|
+
config_arguments.add_argument("--repetition_time", "-tr", type=float,
|
|
591
|
+
help="Repetition time of the function runs in seconds. If it is not supplied, an attempt will be made to read it from the JSON sidecar file.")
|
|
592
|
+
config_arguments.add_argument("--detrend_data", "-dd", action="store_true",
|
|
593
|
+
help="""Flag to demean and detrend the data before modeling. The default is to include
|
|
594
|
+
a mean and trend line into the nuisance matrix instead.""")
|
|
595
|
+
config_arguments.add_argument("--no_global_mean", action="store_true",
|
|
596
|
+
help="Flag to indicate that you do not want to include a global mean into the model.")
|
|
597
|
+
high_motion_params = config_arguments.add_mutually_exclusive_group()
|
|
598
|
+
high_motion_params.add_argument("--spike_regression", "-sr", action="store_true",
|
|
599
|
+
help="Flag to indicate that framewise displacement spike regression should be included in the nuisance matrix.")
|
|
600
|
+
high_motion_params.add_argument("--fd_censoring", "-fc", action="store_true",
|
|
601
|
+
help="Flag to indicate that frames above the framewise displacement threshold should be censored before the glm.")
|
|
602
|
+
config_arguments.add_argument("--nuisance_regression", "-nr", action="store_true",
|
|
603
|
+
help="Flag to indicate that nuisance regression should be performed before performing the GLM for event-related activation.")
|
|
604
|
+
config_arguments.add_argument("--highpass", "-hp", type=float, nargs="?", const=0.008,
|
|
605
|
+
help="""The high pass cutoff frequency for signal filtering. Frequencies below this value (Hz) will be filtered out. If the argument
|
|
606
|
+
is supplied but no value is given, then the value will default to 0.008 Hz""")
|
|
607
|
+
config_arguments.add_argument("--lowpass", "-lp", type=float, nargs="?", const=0.1,
|
|
608
|
+
help="""The low pass cutoff frequency for signal filtering. Frequencies above this value (Hz) will be filtered out. If the argument
|
|
609
|
+
is supplied but no value is given, then the value will default to 0.1 Hz""")
|
|
610
|
+
config_arguments.add_argument("--volterra_lag", "-vl", nargs="?", const=2, type=int,
|
|
611
|
+
help="""The amount of frames to lag for a volterra expansion. If no value is specified
|
|
612
|
+
the default of 2 will be used. Must be specifed with the '--volterra_columns' option.""")
|
|
613
|
+
config_arguments.add_argument("--volterra_columns", "-vc", nargs="+", default=[],
|
|
614
|
+
help="The confound columns to include in the expansion. Must be specifed with the '--volterra_lag' option.")
|
|
615
|
+
|
|
616
|
+
args = parser.parse_args()
|
|
617
|
+
|
|
618
|
+
if args.hrf != None and args.fir != None:
|
|
619
|
+
if not args.fir_vars or not args.hrf_vars:
|
|
620
|
+
parser.error("Must specify variables to apply each model to if using both types of models")
|
|
621
|
+
elif args.hrf == None and args.fir == None:
|
|
622
|
+
parser.error("Must include model parameters for at least one of the models, fir or hrf.")
|
|
623
|
+
|
|
624
|
+
if args.bold_file_type[0] != ".":
|
|
625
|
+
args.bold_file_type = "." + args.bold_file_type
|
|
626
|
+
if (args.bold_file_type == ".nii" or args.bold_file_type == ".nii.gz") and (not args.brain_mask or not args.brain_mask.is_file()):
|
|
627
|
+
parser.error("If the bold file type is volumetric data, a valid '--brain_mask' option must also be supplied")
|
|
628
|
+
|
|
629
|
+
if (args.volterra_lag and not args.volterra_columns) or (not args.volterra_lag and args.volterra_columns):
|
|
630
|
+
parser.error("The options '--volterra_lag' and '--volterra_columns' must be specifed together, or neither of them specified.")
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
assert args.derivs_dir.is_dir(), "Derivatives directory must exist but is not found"
|
|
634
|
+
assert args.raw_bids.is_dir(), "Raw data directory must exist but is not found"
|
|
635
|
+
except AssertionError as e:
|
|
636
|
+
logger.exception(e)
|
|
637
|
+
exit_program_early(e)
|
|
638
|
+
|
|
639
|
+
##### Export the current arguments to a file #####
|
|
640
|
+
if args.export_args:
|
|
641
|
+
try:
|
|
642
|
+
assert args.export_args.parent.exists() and args.export_args.suffix, "Argument export path must be a file path in a directory that exists"
|
|
643
|
+
log_linebreak()
|
|
644
|
+
logger.info(f"####### Exporting Configuration Arguments to: '{args.export_args}' #######\n")
|
|
645
|
+
export_args_to_file(args, config_arguments, args.export_args)
|
|
646
|
+
except Exception as e:
|
|
647
|
+
logger.exception(e)
|
|
648
|
+
exit_program_early(e)
|
|
649
|
+
|
|
650
|
+
if not hasattr(args, "output_dir") or args.output_dir == None:
|
|
651
|
+
args.output_dir = args.derivs_dir / f"{args.derivs_subfolder}/sub-{args.subject}/ses-{args.session}/func"
|
|
652
|
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
653
|
+
|
|
654
|
+
log_dir = args.output_dir.parent / "logs"
|
|
655
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
656
|
+
log_path = log_dir / f"sub-{args.subject}_ses-{args.session}_task-{args.task}_desc-{datetime.datetime.now().strftime('%m-%d-%y_%I-%M%p')}.log"
|
|
657
|
+
add_file_handler(logger, log_path)
|
|
658
|
+
|
|
659
|
+
if args.debug:
|
|
660
|
+
flags.debug = True
|
|
661
|
+
logger.setLevel(logging.DEBUG)
|
|
662
|
+
|
|
663
|
+
logger.info("Starting oceanfla...")
|
|
664
|
+
logger.info(f"Log will be stored at {log_path}")
|
|
665
|
+
|
|
666
|
+
# log the arguments used for this run
|
|
667
|
+
for k,v in (dict(args._get_kwargs())).items():
|
|
668
|
+
logger.info(f" {k} : {v}")
|
|
669
|
+
|
|
670
|
+
model_type = "Mixed" if args.fir and args.hrf else "FIR" if args.fir else "HRF"
|
|
671
|
+
file_map_list = []
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
bold_files = sorted(args.derivs_dir.glob(f"**/*sub-{args.subject}_ses-{args.session}*task-{args.task}*bold{args.bold_file_type}"))
|
|
675
|
+
assert len(bold_files) > 0, "Did not find any bold files in the given derivatives directory for the specified task and file type"
|
|
676
|
+
|
|
677
|
+
for bold_path in bold_files:
|
|
678
|
+
bold_base = bold_path.name.split("_space")[0]
|
|
679
|
+
bold_base = bold_base.split("_desc")[0]
|
|
680
|
+
# confound_name = bold_base + "_desc-confounds_timeseries.tsv"
|
|
681
|
+
confound_path = bold_path.parent / f"{bold_base}_desc-confounds_timeseries.tsv"
|
|
682
|
+
assert confound_path.is_file(), f"Cannot find a confounds file for bold run: {str(bold_path)} seach path {confound_path.as_posix()}"
|
|
683
|
+
event_search_path = f"{bold_base}*_events.tsv"
|
|
684
|
+
event_files = list(args.raw_bids.glob("**/" + event_search_path))
|
|
685
|
+
assert len(event_files) == 1, f"Found more or less than one event file for bold run: {str(bold_path)} search path {event_search_path} len: {len(event_files)}"
|
|
686
|
+
events_path = event_files[0]
|
|
687
|
+
file_map_list.append({
|
|
688
|
+
"bold": bold_path,
|
|
689
|
+
"confounds": confound_path,
|
|
690
|
+
"events": events_path
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
tr = args.repetition_time
|
|
694
|
+
img_header = None
|
|
695
|
+
trial_types = set()
|
|
696
|
+
func_data_list = []
|
|
697
|
+
design_df_list = []
|
|
698
|
+
noise_df_list = []
|
|
699
|
+
|
|
700
|
+
for i, run_map in enumerate(file_map_list):
|
|
701
|
+
log_linebreak()
|
|
702
|
+
logger.info(f"processing bold file: {run_map['bold']}")
|
|
703
|
+
logger.info(f" loading in BOLD data")
|
|
704
|
+
|
|
705
|
+
run_info = len(str(run_map['bold']).split('run-')) > 1
|
|
706
|
+
if run_info:
|
|
707
|
+
run_info = f"run-{str(run_map['bold']).split('run-')[-1].split('_')[0]}_"
|
|
708
|
+
else:
|
|
709
|
+
run_info = f'run-{(i+1):02d}_'
|
|
710
|
+
|
|
711
|
+
func_data, read_tr, read_header = load_data(
|
|
712
|
+
func_file=run_map['bold'],
|
|
713
|
+
brain_mask=args.brain_mask,
|
|
714
|
+
need_tr=(not tr),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
tr = tr if tr else read_tr
|
|
718
|
+
img_header = img_header if img_header else read_header
|
|
719
|
+
|
|
720
|
+
logger.info(" reading events file and creating design matrix")
|
|
721
|
+
events_df, run_conditions = events_to_design(
|
|
722
|
+
func_data=func_data,
|
|
723
|
+
tr=tr,
|
|
724
|
+
event_file=run_map["events"],
|
|
725
|
+
fir=args.fir,
|
|
726
|
+
fir_list=args.fir_vars if args.fir_vars else None,
|
|
727
|
+
hrf=args.hrf,
|
|
728
|
+
hrf_list=args.hrf_vars if args.hrf_vars else None,
|
|
729
|
+
output_path=args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_{run_info}desc-{model_type}_events-long.csv" if flags.debug else None
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
logger.info(" reading confounds file and creating nuisance matrix")
|
|
733
|
+
noise_df = make_noise_ts(
|
|
734
|
+
confounds_file=run_map["confounds"],
|
|
735
|
+
confounds_columns=args.confounds,
|
|
736
|
+
demean=(not args.detrend_data),
|
|
737
|
+
linear_trend=(not args.detrend_data),
|
|
738
|
+
spike_threshold=args.fd_threshold if args.spike_regression else None,
|
|
739
|
+
volterra_expansion=args.volterra_lag,
|
|
740
|
+
volterra_columns=args.volterra_columns
|
|
741
|
+
)
|
|
742
|
+
noise_df_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_{run_info}desc-{model_type}_nuisance.csv"
|
|
743
|
+
logger.info(f" saving nuisance matrix to file: {noise_df_filename}")
|
|
744
|
+
noise_df.to_csv(noise_df_filename)
|
|
745
|
+
|
|
746
|
+
if args.nuisance_regression:
|
|
747
|
+
logger.info(" performing nuisance regression")
|
|
748
|
+
func_data_residuals = nuisance_regression(
|
|
749
|
+
func_data=func_data,
|
|
750
|
+
noise_matrix=noise_df,
|
|
751
|
+
fd_thresh=args.fd_threshold
|
|
752
|
+
)
|
|
753
|
+
run_map["data_resids"] = func_data_residuals
|
|
754
|
+
func_data = func_data_residuals
|
|
755
|
+
|
|
756
|
+
if flags.debug:
|
|
757
|
+
nrimg, img_suffix = create_image(
|
|
758
|
+
data=func_data,
|
|
759
|
+
brain_mask=args.brain_mask,
|
|
760
|
+
tr=tr,
|
|
761
|
+
header=img_header
|
|
762
|
+
)
|
|
763
|
+
nr_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_{run_info}desc-nuisance-regress{img_suffix}"
|
|
764
|
+
logger.debug(f" saving BOLD data after nuisance regression to file: {nr_filename}")
|
|
765
|
+
nib.save(
|
|
766
|
+
nrimg,
|
|
767
|
+
nr_filename
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
sample_mask = np.ones(shape=(func_data.shape[0],))
|
|
772
|
+
if args.fd_censoring:
|
|
773
|
+
logger.info(f" censoring timepoints using a high motion mask with a framewise displacement threshold of {args.fd_threshold}")
|
|
774
|
+
confounds_df = pd.read_csv(run_map["confounds"], sep="\t")
|
|
775
|
+
sample_mask = confounds_df.loc[:, "framewise_displacement"].to_numpy()
|
|
776
|
+
sample_mask = sample_mask < args.fd_threshold
|
|
777
|
+
events_df = events_df.loc[sample_mask, :]
|
|
778
|
+
noise_df = noise_df.loc[sample_mask, :]
|
|
779
|
+
|
|
780
|
+
if args.lowpass or args.highpass:
|
|
781
|
+
logger.info(f" detrending and filtering the BOLD data with a highpass of {args.highpass} and a lowpass of {args.lowpass}")
|
|
782
|
+
func_data_filtered = clean(
|
|
783
|
+
signals=func_data,
|
|
784
|
+
detrend=args.detrend_data,
|
|
785
|
+
sample_mask=sample_mask,
|
|
786
|
+
filter="butterworth",
|
|
787
|
+
low_pass=args.lowpass if args.lowpass else None,
|
|
788
|
+
high_pass=args.highpass if args.highpass else None,
|
|
789
|
+
t_r=tr,
|
|
790
|
+
)
|
|
791
|
+
run_map["data_filtered"] = func_data_filtered
|
|
792
|
+
func_data = func_data_filtered
|
|
793
|
+
if flags.debug:
|
|
794
|
+
cleanimg, img_suffix = create_image(
|
|
795
|
+
data=func_data,
|
|
796
|
+
brain_mask=args.brain_mask,
|
|
797
|
+
tr=tr,
|
|
798
|
+
header=img_header
|
|
799
|
+
)
|
|
800
|
+
cleaned_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_{run_info}desc-cleaned{img_suffix}"
|
|
801
|
+
logger.debug(f" saving BOLD data after cleaning to file: {cleaned_filename}")
|
|
802
|
+
nib.save(
|
|
803
|
+
cleanimg,
|
|
804
|
+
cleaned_filename
|
|
805
|
+
)
|
|
806
|
+
elif args.detrend_data:
|
|
807
|
+
logger.info(" detrending the BOLD data")
|
|
808
|
+
func_data_detrend = demean_detrend(
|
|
809
|
+
func_data=func_data
|
|
810
|
+
)
|
|
811
|
+
run_map["data_detrend"] = func_data_detrend
|
|
812
|
+
func_data = func_data_detrend
|
|
813
|
+
if args.fd_censoring:
|
|
814
|
+
func_data = func_data[sample_mask, :]
|
|
815
|
+
if flags.debug:
|
|
816
|
+
cleanimg, img_suffix = create_image(
|
|
817
|
+
data=func_data,
|
|
818
|
+
brain_mask=args.brain_mask,
|
|
819
|
+
tr=tr,
|
|
820
|
+
header=img_header
|
|
821
|
+
)
|
|
822
|
+
cleaned_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_{run_info}desc-cleaned{img_suffix}"
|
|
823
|
+
logger.debug(f" saving BOLD data after detrending to file: {cleaned_filename}")
|
|
824
|
+
nib.save(
|
|
825
|
+
cleanimg,
|
|
826
|
+
cleaned_filename
|
|
827
|
+
)
|
|
828
|
+
elif args.fd_censoring:
|
|
829
|
+
func_data = func_data[sample_mask, :]
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
assert func_data.shape[0] == len(noise_df), "The functional data and the nuisance matrix have a different number of timepoints"
|
|
833
|
+
if not args.nuisance_regression:
|
|
834
|
+
logger.info(" appending nuisance matrix to the design matrix")
|
|
835
|
+
noise_df_list.append(noise_df)
|
|
836
|
+
|
|
837
|
+
logger.info(" appending BOLD data and design matrix to run list")
|
|
838
|
+
trial_types.update(run_conditions)
|
|
839
|
+
|
|
840
|
+
assert func_data.shape[0] == len(events_df), "The functional data and the design matrix have a different number of timepoints"
|
|
841
|
+
func_data_list.append(func_data)
|
|
842
|
+
design_df_list.append(events_df)
|
|
843
|
+
|
|
844
|
+
log_linebreak()
|
|
845
|
+
logger.info("concatenating run level BOLD data and design matrices for GLM")
|
|
846
|
+
final_func_data, final_design_df = create_final_design(
|
|
847
|
+
data_list=func_data_list,
|
|
848
|
+
design_list=design_df_list,
|
|
849
|
+
noise_list=noise_df_list if len(noise_df_list) == len(func_data_list) else None,
|
|
850
|
+
exclude_global_mean=args.no_global_mean
|
|
851
|
+
)
|
|
852
|
+
final_design_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_desc-{model_type}-final-design.csv"
|
|
853
|
+
logger.info(f"saving the final design matrix to file: {final_design_filename}")
|
|
854
|
+
final_design_df.to_csv(final_design_filename)
|
|
855
|
+
|
|
856
|
+
logger.info("running GLM on concatenated BOLD data with final design matrix")
|
|
857
|
+
activation_betas = massuni_linGLM(
|
|
858
|
+
func_data=final_func_data,
|
|
859
|
+
design_matrix=final_design_df
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
logger.info("saving betas from GLM into files")
|
|
863
|
+
fir_betas_to_combine = set()
|
|
864
|
+
for i, c in enumerate(final_design_df.columns):
|
|
865
|
+
if args.fir and c[-3] == "_" and c[-2:].isnumeric() and c[:-3] in trial_types:
|
|
866
|
+
fir_betas_to_combine.add(c[:-3])
|
|
867
|
+
continue
|
|
868
|
+
elif c in trial_types:
|
|
869
|
+
beta_img, img_suffix = create_image(
|
|
870
|
+
data=np.expand_dims(activation_betas[i,:], axis=0),
|
|
871
|
+
brain_mask=args.brain_mask,
|
|
872
|
+
tr=tr,
|
|
873
|
+
header=img_header
|
|
874
|
+
)
|
|
875
|
+
beta_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_desc-model-{model_type}-beta-{c}-frame-0{img_suffix}"
|
|
876
|
+
logger.info(f" saving betas for variable {c} to file: {beta_filename}")
|
|
877
|
+
nib.save(
|
|
878
|
+
beta_img,
|
|
879
|
+
beta_filename
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
if args.fir:
|
|
883
|
+
for condition in fir_betas_to_combine:
|
|
884
|
+
beta_frames = np.zeros(shape=(args.fir, activation_betas.shape[1]))
|
|
885
|
+
for f in range(args.fir):
|
|
886
|
+
beta_column = final_design_df.columns.get_loc(f"{condition}_{f:02d}")
|
|
887
|
+
beta_frames[f,:] = activation_betas[beta_column,:]
|
|
888
|
+
beta_img, img_suffix = create_image(
|
|
889
|
+
data=np.expand_dims(activation_betas[beta_column,:], axis=0),
|
|
890
|
+
brain_mask=args.brain_mask,
|
|
891
|
+
tr=tr,
|
|
892
|
+
header=img_header
|
|
893
|
+
)
|
|
894
|
+
beta_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_desc-model-{model_type}-beta-{condition}-frame-{f+1}{img_suffix}"
|
|
895
|
+
logger.info(f" saving betas for variable {condition} frame {f+1} to file: {beta_filename}")
|
|
896
|
+
nib.save(
|
|
897
|
+
beta_img,
|
|
898
|
+
beta_filename
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
beta_img, img_suffix = create_image(
|
|
902
|
+
data=beta_frames,
|
|
903
|
+
brain_mask=args.brain_mask,
|
|
904
|
+
tr=tr,
|
|
905
|
+
header=img_header
|
|
906
|
+
)
|
|
907
|
+
beta_filename = args.output_dir/f"sub-{args.subject}_ses-{args.session}_task-{args.task}_desc-model-{model_type}-beta-{condition}-concatenated{img_suffix}"
|
|
908
|
+
logger.info(f" saving betas for variable {condition} (all {args.fir} modeled frames) to file: {beta_filename}")
|
|
909
|
+
nib.save(
|
|
910
|
+
beta_img,
|
|
911
|
+
beta_filename
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
logger.info("oceanfla complete!")
|
|
915
|
+
|
|
916
|
+
except Exception as e:
|
|
917
|
+
logger.exception(e, stack_info=True)
|
|
918
|
+
exit_program_early(str(e))
|
|
919
|
+
|
|
920
|
+
if __name__ == "__main__":
|
|
921
|
+
main()
|
|
922
|
+
|
|
923
|
+
|