Skip to content

Commit adff44f

Browse files
committed
Add session 0008, artifact censoring, smoothing, and EGG survey script
- Bundle session 0008 EGG data and add BOLD to GitHub release - Add boolean mask parameter to PLV functions for artifact censoring - Add artifact_mask_to_volumes helper to map EGG artifacts to volumes - Update fMRI tutorial: session 0008, 6mm smoothing, artifact censoring, 200 surrogates - Add survey_egg_sessions.py script for cross-session EGG quality comparison - Add tests for mask parameter and artifact_mask_to_volumes (208 total)
1 parent 72874e7 commit adff44f

12 files changed

Lines changed: 1097 additions & 288 deletions

docs/tutorials/fmri_coupling_real.ipynb

Lines changed: 349 additions & 197 deletions
Large diffs are not rendered by default.

gastropy/coupling/plv.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import numpy as np
1616

1717

18-
def phase_locking_value(phase_a, phase_b):
18+
def phase_locking_value(phase_a, phase_b, mask=None):
1919
"""Compute the phase-locking value between two phase time series.
2020
2121
PLV measures the consistency of the phase difference between two
@@ -30,6 +30,9 @@ def phase_locking_value(phase_a, phase_b):
3030
phase_b : array_like, shape (n_timepoints,)
3131
Reference phase time series in radians. Broadcast against
3232
columns of ``phase_a`` when ``phase_a`` is 2D.
33+
mask : array_like of bool, shape (n_timepoints,), optional
34+
Boolean mask where ``True`` = include, ``False`` = exclude.
35+
Only the included timepoints contribute to the PLV.
3336
3437
Returns
3538
-------
@@ -51,10 +54,10 @@ def phase_locking_value(phase_a, phase_b):
5154
>>> round(plv, 2)
5255
1.0
5356
"""
54-
return np.abs(phase_locking_value_complex(phase_a, phase_b))
57+
return np.abs(phase_locking_value_complex(phase_a, phase_b, mask=mask))
5558

5659

57-
def phase_locking_value_complex(phase_a, phase_b):
60+
def phase_locking_value_complex(phase_a, phase_b, mask=None):
5861
"""Compute the complex phase-locking value.
5962
6063
Returns the complex mean of the phase difference, from which both
@@ -66,6 +69,9 @@ def phase_locking_value_complex(phase_a, phase_b):
6669
Phase time series in radians.
6770
phase_b : array_like, shape (n_timepoints,)
6871
Reference phase time series in radians.
72+
mask : array_like of bool, shape (n_timepoints,), optional
73+
Boolean mask where ``True`` = include, ``False`` = exclude.
74+
Only the included timepoints contribute to the complex mean.
6975
7076
Returns
7177
-------
@@ -87,23 +93,25 @@ def phase_locking_value_complex(phase_a, phase_b):
8793
phase_a = np.asarray(phase_a, dtype=float)
8894
phase_b = np.asarray(phase_b, dtype=float)
8995

90-
if phase_a.ndim == 1 and phase_b.ndim == 1:
96+
if (phase_a.ndim == 1 and phase_b.ndim == 1) or phase_a.ndim == 2:
9197
if phase_a.shape[0] != phase_b.shape[0]:
9298
raise ValueError(
9399
f"phase_a and phase_b must have the same number of timepoints, "
94100
f"got {phase_a.shape[0]} and {phase_b.shape[0]}"
95101
)
96-
elif phase_a.ndim == 2:
97-
if phase_a.shape[0] != phase_b.shape[0]:
98-
raise ValueError(
99-
f"phase_a and phase_b must have the same number of timepoints, "
100-
f"got {phase_a.shape[0]} and {phase_b.shape[0]}"
101-
)
102-
# Broadcast phase_b to match: (n_timepoints,) -> (n_timepoints, 1)
103-
phase_b = phase_b[:, np.newaxis]
104102
else:
105103
raise ValueError(f"phase_a must be 1D or 2D, got {phase_a.ndim}D")
106104

105+
# Apply mask before computing phase difference
106+
if mask is not None:
107+
mask = np.asarray(mask, dtype=bool)
108+
phase_a = phase_a[mask]
109+
phase_b = phase_b[mask]
110+
111+
# Broadcast phase_b to match 2D phase_a
112+
if phase_a.ndim == 2:
113+
phase_b = phase_b[:, np.newaxis]
114+
107115
phase_diff = phase_a - phase_b
108116
cplv = np.mean(np.exp(1j * phase_diff), axis=0)
109117

gastropy/coupling/surrogate.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .plv import phase_locking_value
2121

2222

23-
def surrogate_plv(phase_a, phase_b, buffer_samples=None, n_surrogates=None, stat="median", seed=None):
23+
def surrogate_plv(phase_a, phase_b, buffer_samples=None, n_surrogates=None, stat="median", seed=None, mask=None):
2424
"""Compute surrogate PLV via circular time-shifting.
2525
2626
Creates a null distribution of PLV by circularly shifting the
@@ -50,6 +50,9 @@ def surrogate_plv(phase_a, phase_b, buffer_samples=None, n_surrogates=None, stat
5050
of surrogate PLV values.
5151
seed : int or np.random.Generator, optional
5252
Random seed for reproducibility when ``n_surrogates`` is set.
53+
mask : array_like of bool, shape (n_timepoints,), optional
54+
Boolean mask where ``True`` = include. Passed through to
55+
``phase_locking_value`` for each surrogate shift.
5356
5457
Returns
5558
-------
@@ -101,7 +104,7 @@ def surrogate_plv(phase_a, phase_b, buffer_samples=None, n_surrogates=None, stat
101104
surr_plvs = []
102105
for shift in shifts:
103106
shifted_b = np.roll(phase_b, int(shift))
104-
plv = phase_locking_value(phase_a, shifted_b)
107+
plv = phase_locking_value(phase_a, shifted_b, mask=mask)
105108
surr_plvs.append(plv)
106109

107110
surr_plvs = np.array(surr_plvs)

gastropy/data/__init__.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
_DATA_DIR = Path(__file__).parent
2626

27-
_FMRI_SESSIONS = ("0001", "0003", "0004")
27+
_FMRI_SESSIONS = ("0001", "0003", "0004", "0008")
2828

2929

3030
def _load_npz(filename):
@@ -44,8 +44,8 @@ def load_fmri_egg(session="0001"):
4444
Parameters
4545
----------
4646
session : str
47-
Session identifier. Available: ``"0001"``, ``"0003"``, ``"0004"``
48-
(three baseline sessions from the semi_precision study).
47+
Session identifier. Available: ``"0001"``, ``"0003"``, ``"0004"``,
48+
``"0008"`` (from the semi_precision study).
4949
5050
Returns
5151
-------
@@ -176,6 +176,20 @@ def list_datasets():
176176
"sha256:7736cee95003ceb8f55a763be94f8130ab356eddbcaef18a8451954a95de53d7",
177177
),
178178
},
179+
"0008": {
180+
"bold": (
181+
"sub-01_ses-20241101_task-rest_run-01_space-MNI152NLin2009cAsym_desc-preproc_bold.nii.gz",
182+
"sha256:f17edfd40e5770bad7e195168fd0009b1d8fb00e80306042b4c0aad2c894cdc8",
183+
),
184+
"mask": (
185+
"sub-01_ses-20241101_task-rest_run-01_space-MNI152NLin2009cAsym_desc-brain_mask.nii.gz",
186+
"sha256:73f538f88fe28515012cadb5e19ee0c1feaf5da8f40de77794d028a218f820fd",
187+
),
188+
"confounds": (
189+
"sub-01_ses-20241101_task-rest_run-01_desc-confounds_timeseries.tsv",
190+
"sha256:d8f6880b41b996f2a68485226d425629d0d3217c65d1aaeeb0cdfd3023346ae9",
191+
),
192+
},
179193
}
180194

181195

@@ -192,7 +206,7 @@ def fetch_fmri_bold(session="0001", data_dir=None):
192206
Parameters
193207
----------
194208
session : str
195-
Session identifier. Available: ``"0001"``.
209+
Session identifier. Available: ``"0001"``, ``"0008"``.
196210
data_dir : str or Path, optional
197211
Directory to store downloaded files. Default uses
198212
``pooch``'s OS-appropriate cache directory.
463 KB
Binary file not shown.

gastropy/neuro/fmri.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ def bold_voxelwise_phases(
315315
return phases
316316

317317

318-
def compute_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None):
318+
def compute_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None, artifact_mask=None):
319319
"""Compute a voxelwise PLV map between EGG and BOLD phases.
320320
321321
Parameters
@@ -332,6 +332,9 @@ def compute_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None):
332332
mask_indices : array_like, optional
333333
Boolean mask or integer indices mapping voxels back to the
334334
3D volume. Required if ``vol_shape`` is provided.
335+
artifact_mask : array_like of bool, shape (n_timepoints,), optional
336+
Boolean mask where ``True`` = clean volume, ``False`` = artifact.
337+
Artifact volumes are excluded from the PLV computation.
335338
336339
Returns
337340
-------
@@ -349,7 +352,7 @@ def compute_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None):
349352
)
350353

351354
# PLV: (n_timepoints, n_voxels) vs (n_timepoints,)
352-
plv = phase_locking_value(bold_phases.T, egg_phase)
355+
plv = phase_locking_value(bold_phases.T, egg_phase, mask=artifact_mask)
353356

354357
if vol_shape is not None and mask_indices is not None:
355358
vol = np.zeros(vol_shape, dtype=float)
@@ -359,7 +362,7 @@ def compute_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None):
359362
return plv
360363

361364

362-
def compute_surrogate_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None, **kwargs):
365+
def compute_surrogate_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indices=None, artifact_mask=None, **kwargs):
363366
"""Compute a surrogate PLV map via circular time-shifting.
364367
365368
Same as ``compute_plv_map`` but uses surrogate PLV to generate
@@ -375,6 +378,9 @@ def compute_surrogate_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indic
375378
3D volume dimensions for reshaping output.
376379
mask_indices : array_like, optional
377380
Mask indices for 3D volume reconstruction.
381+
artifact_mask : array_like of bool, shape (n_timepoints,), optional
382+
Boolean mask where ``True`` = clean volume, ``False`` = artifact.
383+
Artifact volumes are excluded from the surrogate PLV computation.
378384
**kwargs
379385
Additional arguments passed to ``surrogate_plv``
380386
(e.g., ``buffer_samples``, ``n_surrogates``, ``stat``, ``seed``).
@@ -398,7 +404,7 @@ def compute_surrogate_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indic
398404
f"bold_phases has {bold_phases.shape[1]}. They must match."
399405
)
400406

401-
surr = _surrogate_plv(bold_phases.T, egg_phase, **kwargs)
407+
surr = _surrogate_plv(bold_phases.T, egg_phase, mask=artifact_mask, **kwargs)
402408

403409
if vol_shape is not None and mask_indices is not None and surr.ndim == 1:
404410
vol = np.zeros(vol_shape, dtype=float)
@@ -408,6 +414,63 @@ def compute_surrogate_plv_map(egg_phase, bold_phases, vol_shape=None, mask_indic
408414
return surr
409415

410416

417+
def artifact_mask_to_volumes(sample_mask, trigger_times, sfreq, tr, begin_cut=21, end_cut=21):
418+
"""Map a sample-level artifact mask to volume-level.
419+
420+
Converts the per-sample boolean artifact mask (from
421+
``detect_phase_artifacts``) to a per-volume boolean mask
422+
suitable for censoring bad volumes in PLV computation.
423+
424+
A volume is marked as artifact (``False``) if **any** EGG sample
425+
within its time window is flagged in the sample mask.
426+
427+
Parameters
428+
----------
429+
sample_mask : array_like of bool
430+
Per-sample artifact mask where ``True`` = artifact sample.
431+
This is the ``artifact_mask`` field from
432+
``detect_phase_artifacts``, at the EGG sampling rate.
433+
trigger_times : array_like
434+
Scanner trigger onset times in seconds (from
435+
``find_scanner_triggers``).
436+
sfreq : float
437+
EGG sampling frequency in Hz (e.g., 10.0).
438+
tr : float
439+
fMRI repetition time in seconds.
440+
begin_cut : int, optional
441+
Volumes to remove from the start. Default is 21.
442+
end_cut : int, optional
443+
Volumes to remove from the end. Default is 21.
444+
445+
Returns
446+
-------
447+
vol_mask : np.ndarray of bool
448+
Per-volume mask where ``True`` = clean volume (include),
449+
``False`` = artifact volume (exclude). Length equals
450+
``n_volumes - begin_cut - end_cut``.
451+
"""
452+
sample_mask = np.asarray(sample_mask, dtype=bool)
453+
trigger_times = np.asarray(trigger_times, dtype=float)
454+
n_samples = len(sample_mask)
455+
n_volumes = len(trigger_times)
456+
457+
vol_clean = np.ones(n_volumes, dtype=bool)
458+
for k in range(n_volumes):
459+
t_start = trigger_times[k]
460+
t_end = trigger_times[k + 1] if k + 1 < n_volumes else t_start + tr
461+
s = min(int(round(t_start * sfreq)), n_samples)
462+
e = min(int(round(t_end * sfreq)), n_samples)
463+
if s < e and np.any(sample_mask[s:e]):
464+
vol_clean[k] = False
465+
466+
# Apply volume cuts
467+
if begin_cut + end_cut >= n_volumes:
468+
return np.array([], dtype=bool)
469+
if end_cut == 0:
470+
return vol_clean[begin_cut:]
471+
return vol_clean[begin_cut:-end_cut]
472+
473+
411474
def load_bold(bold_path, mask_path):
412475
"""Load fMRIPrep BOLD and brain mask as a masked 2D array.
413476
@@ -529,6 +592,7 @@ def to_nifti(data_3d, affine):
529592
"create_volume_windows",
530593
"phase_per_volume",
531594
"apply_volume_cuts",
595+
"artifact_mask_to_volumes",
532596
"regress_confounds",
533597
"bold_voxelwise_phases",
534598
"compute_plv_map",

scripts/convert_sample_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
# --- Config ---
3131
TARGET_SFREQ = 10.0 # Hz — standard for gastropy
32-
SEMI_PRECISION_SESSIONS = ["0001", "0003", "0004"] # 3 baseline sessions
32+
SEMI_PRECISION_SESSIONS = ["0001", "0003", "0004", "0008"]
3333
TR = 1.856 # seconds
3434

3535

0 commit comments

Comments
 (0)