Skip to content

Commit b15366d

Browse files
authored
WIP: Add scalp coupling index for NIRS data (#7215)
* Add scalp coupling index for NIRS data * Improve tests for scalp coupling index and referencing * Add scalp coupling index to nirs tutorial * Ensure SCI is run on optical density data * Fix flake errors in scalp coupling index code
1 parent f76c7b3 commit b15366d

File tree

9 files changed

+198
-33
lines changed

9 files changed

+198
-33
lines changed

doc/changes/latest.inc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ Changelog
9999

100100
- Add function to convert events to annotations :func:`mne.annotations_from_events` by `Nicolas Barascud`_
101101

102+
- Add function to calculate scalp coupling index for fNIRS data :func:`mne.preprocessing.nirs.scalp_coupling_index` by `Robert Luke`_
103+
102104
- Add ``item`` argument to :meth:`mne.Epochs.get_data` for faster access to NumPy data arrays compared to :meth:`mne.Epochs.__getitem__` for frequent access on large :class:`mne.Epochs` objects, by `Eric Larson`_
103105

104106
- More accurate coordinate system for Easycap montages in :func:`mne.channels.make_standard_montage` by `Christian Brodbeck`_

doc/python_reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ Projections:
383383
beer_lambert_law
384384
source_detector_distances
385385
short_channels
386+
scalp_coupling_index
386387

387388
EEG referencing:
388389

doc/references.bib

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,16 @@ @article{Pham2001
11721172
year = {2001}
11731173
}
11741174

1175+
@article{pollonini2014auditory,
1176+
title={Auditory cortex activation to natural speech and simulated cochlear implant speech measured with functional near-infrared spectroscopy},
1177+
author={Pollonini, Luca and Olds, Cristen and Abaya, Homer and Bortfeld, Heather and Beauchamp, Michael S and Oghalai, John S},
1178+
journal={Hearing research},
1179+
volume={309},
1180+
pages={84--93},
1181+
year={2014},
1182+
publisher={Elsevier}
1183+
}
1184+
11751185
@article{RidgwayEtAl2012,
11761186
author = {Ridgway, Gerard R. and Litvak, Vladimir and Flandin, Guillaume and Friston, Karl J. and Penny, Will D.},
11771187
doi = {10.1016/j.neuroimage.2011.10.027},

mne/preprocessing/nirs/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#
77
# License: BSD (3-clause)
88

9-
from .nirs import short_channels, source_detector_distances
9+
from .nirs import short_channels, source_detector_distances, _check_channels_ordered, _channel_frequencies
1010
from ._optical_density import optical_density
1111
from ._beer_lambert_law import beer_lambert_law
12+
from ._scalp_coupling_index import scalp_coupling_index

mne/preprocessing/nirs/_beer_lambert_law.py

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@
66

77
import os.path as op
88

9-
import re as re
109
import numpy as np
1110
from scipy import linalg
1211

1312
from ...io import BaseRaw
14-
from ...io.pick import _picks_to_idx
1513
from ...io.constants import FIFF
1614
from ...utils import _validate_type
17-
from ..nirs import source_detector_distances
15+
from ..nirs import source_detector_distances, _channel_frequencies,\
16+
_check_channels_ordered
1817

1918

2019
def beer_lambert_law(raw, ppf=0.1):
@@ -59,35 +58,6 @@ def beer_lambert_law(raw, ppf=0.1):
5958
return raw
6059

6160

62-
def _channel_frequencies(raw):
63-
"""Return the light frequency for each channel."""
64-
picks = _picks_to_idx(raw.info, 'fnirs_od')
65-
freqs = np.empty(picks.size, int)
66-
for ii in picks:
67-
freqs[ii] = raw.info['chs'][ii]['loc'][9]
68-
return freqs
69-
70-
71-
def _check_channels_ordered(raw, freqs):
72-
"""Check channels followed expected fNIRS format."""
73-
# Every second channel should be same SD pair
74-
# and have the specified light frequencies.
75-
picks = _picks_to_idx(raw.info, 'fnirs_od')
76-
for ii in picks[::2]:
77-
ch1_name_info = re.match(r'S(\d+)_D(\d+) (\d+)',
78-
raw.info['chs'][ii]['ch_name'])
79-
ch2_name_info = re.match(r'S(\d+)_D(\d+) (\d+)',
80-
raw.info['chs'][ii + 1]['ch_name'])
81-
82-
if (ch1_name_info.groups()[0] != ch2_name_info.groups()[0]) or \
83-
(ch1_name_info.groups()[1] != ch2_name_info.groups()[1]) or \
84-
(int(ch1_name_info.groups()[2]) != freqs[0]) or \
85-
(int(ch2_name_info.groups()[2]) != freqs[1]):
86-
raise RuntimeError('NIRS channels not ordered correctly')
87-
88-
return picks
89-
90-
9161
def _load_absorption(freqs):
9262
"""Load molar extinction coefficients."""
9363
# Data from https://omlc.org/spectra/hemoglobin/summary.html
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Authors: Robert Luke <[email protected]>
2+
# Eric Larson <[email protected]>
3+
# Alexandre Gramfort <[email protected]>
4+
#
5+
# License: BSD (3-clause)
6+
7+
import numpy as np
8+
9+
from ...io import BaseRaw
10+
from ...utils import _validate_type, verbose
11+
from ..nirs import _channel_frequencies, _check_channels_ordered
12+
from ...filter import filter_data
13+
14+
15+
@verbose
16+
def scalp_coupling_index(raw, l_freq=0.7, h_freq=1.5,
17+
l_trans_bandwidth=0.3, h_trans_bandwidth=0.3,
18+
verbose=False):
19+
r"""Calculate scalp coupling index.
20+
21+
This function calculates the scalp coupling index
22+
:footcite:`pollonini2014auditory`. This is a measure of the quality of the
23+
connection between the optode and the scalp.
24+
25+
Parameters
26+
----------
27+
raw : instance of Raw
28+
The raw data.
29+
%(l_freq)s
30+
%(h_freq)s
31+
%(l_trans_bandwidth)s
32+
%(h_trans_bandwidth)s
33+
%(verbose)s
34+
35+
Returns
36+
-------
37+
sci : array of float
38+
Array containing scalp coupling index for each channel.
39+
40+
References
41+
----------
42+
.. footbibliography::
43+
"""
44+
raw = raw.copy().load_data()
45+
_validate_type(raw, BaseRaw, 'raw')
46+
47+
freqs = np.unique(_channel_frequencies(raw))
48+
picks = _check_channels_ordered(raw, freqs)
49+
50+
if not len(picks):
51+
raise RuntimeError('Scalp coupling index '
52+
'should be run on optical density data.')
53+
54+
filtered_data = filter_data(raw._data, raw.info['sfreq'], l_freq, h_freq,
55+
picks=picks, verbose=verbose,
56+
l_trans_bandwidth=h_trans_bandwidth,
57+
h_trans_bandwidth=l_trans_bandwidth)
58+
59+
sci = np.zeros(picks.shape)
60+
for ii in picks[::2]:
61+
c = np.corrcoef(filtered_data[ii], filtered_data[ii + 1])[0][1]
62+
sci[ii] = c
63+
sci[ii + 1] = c
64+
65+
return sci

mne/preprocessing/nirs/nirs.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#
55
# License: BSD (3-clause)
66

7+
import re
78
import numpy as np
89
from scipy import linalg
910

@@ -53,3 +54,32 @@ def short_channels(info, threshold=0.01):
5354
Of shape equal to number of channels.
5455
"""
5556
return source_detector_distances(info) < threshold
57+
58+
59+
def _channel_frequencies(raw):
60+
"""Return the light frequency for each channel."""
61+
picks = _picks_to_idx(raw.info, 'fnirs_od')
62+
freqs = np.empty(picks.size, int)
63+
for ii in picks:
64+
freqs[ii] = raw.info['chs'][ii]['loc'][9]
65+
return freqs
66+
67+
68+
def _check_channels_ordered(raw, freqs):
69+
"""Check channels followed expected fNIRS format."""
70+
# Every second channel should be same SD pair
71+
# and have the specified light frequencies.
72+
picks = _picks_to_idx(raw.info, 'fnirs_od')
73+
for ii in picks[::2]:
74+
ch1_name_info = re.match(r'S(\d+)_D(\d+) (\d+)',
75+
raw.info['chs'][ii]['ch_name'])
76+
ch2_name_info = re.match(r'S(\d+)_D(\d+) (\d+)',
77+
raw.info['chs'][ii + 1]['ch_name'])
78+
79+
if (ch1_name_info.groups()[0] != ch2_name_info.groups()[0]) or \
80+
(ch1_name_info.groups()[1] != ch2_name_info.groups()[1]) or \
81+
(int(ch1_name_info.groups()[2]) != freqs[0]) or \
82+
(int(ch2_name_info.groups()[2]) != freqs[1]):
83+
raise RuntimeError('NIRS channels not ordered correctly')
84+
85+
return picks
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Authors: Robert Luke <[email protected]>
2+
# Eric Larson <[email protected]>
3+
# Alexandre Gramfort <[email protected]>
4+
#
5+
# License: BSD (3-clause)
6+
7+
import os.path as op
8+
9+
import pytest
10+
import numpy as np
11+
from numpy.testing import assert_allclose, assert_array_less
12+
13+
from mne.datasets.testing import data_path
14+
from mne.io import read_raw_nirx
15+
from mne.preprocessing.nirs import optical_density, scalp_coupling_index
16+
from mne.datasets import testing
17+
18+
fname_nirx_15_0 = op.join(data_path(download=False),
19+
'NIRx', 'nirx_15_0_recording')
20+
fname_nirx_15_2 = op.join(data_path(download=False),
21+
'NIRx', 'nirx_15_2_recording')
22+
fname_nirx_15_2_short = op.join(data_path(download=False),
23+
'NIRx', 'nirx_15_2_recording_w_short')
24+
25+
26+
@testing.requires_testing_data
27+
@pytest.mark.parametrize('fname', ([fname_nirx_15_2_short, fname_nirx_15_2,
28+
fname_nirx_15_0]))
29+
@pytest.mark.parametrize('fmt', ('nirx', 'fif'))
30+
def test_scalp_coupling_index(fname, fmt, tmpdir):
31+
"""Test converting NIRX files."""
32+
assert fmt in ('nirx', 'fif')
33+
raw = read_raw_nirx(fname)
34+
raw = optical_density(raw)
35+
sci = scalp_coupling_index(raw)
36+
37+
# All values should be between -1 and +1
38+
assert_array_less(sci, 1.0)
39+
assert_array_less(sci * -1.0, 1.0)
40+
41+
# Fill in some data with known correlation values
42+
new_data = np.random.rand(raw._data[0].shape[0])
43+
# Set first two channels to perfect correlation
44+
raw._data[0] = new_data
45+
raw._data[1] = new_data
46+
# Set next two channels to perfect correlation
47+
raw._data[2] = new_data
48+
raw._data[3] = new_data * 0.3 # check scale invariance
49+
# Set next two channels to anti correlation
50+
raw._data[4] = new_data
51+
raw._data[5] = new_data * -1.0
52+
# Set next two channels to be uncorrelated
53+
# TODO: this might be a bad idea as sometimes random noise might correlate
54+
raw._data[6] = new_data
55+
raw._data[7] = np.random.rand(raw._data[0].shape[0])
56+
# Check values
57+
sci = scalp_coupling_index(raw)
58+
assert_allclose(sci[0:6], [1, 1, 1, 1, -1, -1], atol=0.01)
59+
assert np.abs(sci[6]) < 0.5
60+
assert np.abs(sci[7]) < 0.5

tutorials/preprocessing/plot_70_fnirs_processing.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import numpy as np
2121
import matplotlib.pyplot as plt
22+
from itertools import compress
2223

2324
import mne
2425

@@ -53,6 +54,31 @@
5354
raw_od.plot(n_channels=len(raw_od.ch_names), duration=500)
5455

5556

57+
###############################################################################
58+
# Evaluating the quality of the data
59+
# ----------------------------------
60+
#
61+
# At this stage we can quantify the quality of the coupling
62+
# between the scalp and the optodes using the scalp coupling index. This
63+
# method looks at the presence of a prominent synchronous signal in the
64+
# frequency range of cardiac signals across both photodetected signals.
65+
#
66+
# As this data is clean and the coupling is good for all channels we will
67+
# not mark any channels as bad based on the scalp coupling index.
68+
69+
sci = mne.preprocessing.nirs.scalp_coupling_index(raw_od)
70+
fig, ax = plt.subplots()
71+
ax.hist(sci)
72+
ax.set(xlabel='Scalp Coupling Index', ylabel='Count', xlim=[0, 1])
73+
74+
75+
###############################################################################
76+
# In this example we will mark all channels with a SCI less than 0.5 as bad
77+
# (this dataset is quite clean, so no channels are marked as bad).
78+
79+
raw_od.info['bads'] = list(compress(raw_od.ch_names, sci < 0.5))
80+
81+
5682
###############################################################################
5783
# Converting from optical density to haemoglobin
5884
# ----------------------------------------------

0 commit comments

Comments
 (0)