initial commit
This commit is contained in:
22
mne/preprocessing/nirs/__init__.py
Normal file
22
mne/preprocessing/nirs/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""NIRS specific preprocessing functions."""
|
||||
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
from .nirs import (
|
||||
short_channels,
|
||||
source_detector_distances,
|
||||
_check_channels_ordered,
|
||||
_channel_frequencies,
|
||||
_fnirs_spread_bads,
|
||||
_channel_chromophore,
|
||||
_validate_nirs_info,
|
||||
_fnirs_optode_names,
|
||||
_optode_position,
|
||||
_reorder_nirx,
|
||||
)
|
||||
from ._optical_density import optical_density
|
||||
from ._beer_lambert_law import beer_lambert_law
|
||||
from ._scalp_coupling_index import scalp_coupling_index
|
||||
from ._tddr import temporal_derivative_distribution_repair, tddr
|
||||
115
mne/preprocessing/nirs/_beer_lambert_law.py
Normal file
115
mne/preprocessing/nirs/_beer_lambert_law.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import os.path as op
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import interp1d
|
||||
from scipy.io import loadmat
|
||||
|
||||
from ..._fiff.constants import FIFF
|
||||
from ...io import BaseRaw
|
||||
from ...utils import _validate_type, pinv, warn
|
||||
from ..nirs import _validate_nirs_info, source_detector_distances
|
||||
|
||||
|
||||
def beer_lambert_law(raw, ppf=6.0):
|
||||
r"""Convert NIRS optical density data to haemoglobin concentration.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw : instance of Raw
|
||||
The optical density data.
|
||||
ppf : tuple | float
|
||||
The partial pathlength factors for each wavelength.
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
Support for different factors for the two wavelengths.
|
||||
|
||||
Returns
|
||||
-------
|
||||
raw : instance of Raw
|
||||
The modified raw instance.
|
||||
"""
|
||||
raw = raw.copy().load_data()
|
||||
_validate_type(raw, BaseRaw, "raw")
|
||||
_validate_type(ppf, ("numeric", "array-like"), "ppf")
|
||||
ppf = np.array(ppf, float)
|
||||
if ppf.ndim == 0: # upcast single float to shape (2,)
|
||||
ppf = np.array([ppf, ppf])
|
||||
if ppf.shape != (2,):
|
||||
raise ValueError(
|
||||
f"ppf must be float or array-like of shape (2,), got shape {ppf.shape}"
|
||||
)
|
||||
ppf = ppf[:, np.newaxis] # shape (2, 1)
|
||||
picks = _validate_nirs_info(raw.info, fnirs="od", which="Beer-lambert")
|
||||
# This is the one place we *really* need the actual/accurate frequencies
|
||||
freqs = np.array([raw.info["chs"][pick]["loc"][9] for pick in picks], float)
|
||||
abs_coef = _load_absorption(freqs)
|
||||
distances = source_detector_distances(raw.info, picks="all")
|
||||
bad = ~np.isfinite(distances[picks])
|
||||
bad |= distances[picks] <= 0
|
||||
if bad.any():
|
||||
warn(
|
||||
"Source-detector distances are zero on NaN, some resulting "
|
||||
"concentrations will be zero. Consider setting a montage "
|
||||
"with raw.set_montage."
|
||||
)
|
||||
distances[picks[bad]] = 0.0
|
||||
if (distances[picks] > 0.1).any():
|
||||
warn(
|
||||
"Source-detector distances are greater than 10 cm. "
|
||||
"Large distances will result in invalid data, and are "
|
||||
"likely due to optode locations being stored in a "
|
||||
" unit other than meters."
|
||||
)
|
||||
rename = dict()
|
||||
for ii, jj in zip(picks[::2], picks[1::2]):
|
||||
EL = abs_coef * distances[ii] * ppf
|
||||
iEL = pinv(EL)
|
||||
|
||||
raw._data[[ii, jj]] = iEL @ raw._data[[ii, jj]] * 1e-3
|
||||
|
||||
# Update channel information
|
||||
coil_dict = dict(hbo=FIFF.FIFFV_COIL_FNIRS_HBO, hbr=FIFF.FIFFV_COIL_FNIRS_HBR)
|
||||
for ki, kind in zip((ii, jj), ("hbo", "hbr")):
|
||||
ch = raw.info["chs"][ki]
|
||||
ch.update(coil_type=coil_dict[kind], unit=FIFF.FIFF_UNIT_MOL)
|
||||
new_name = f'{ch["ch_name"].split(" ")[0]} {kind}'
|
||||
rename[ch["ch_name"]] = new_name
|
||||
raw.rename_channels(rename)
|
||||
|
||||
# Validate the format of data after transformation is valid
|
||||
_validate_nirs_info(raw.info, fnirs="hb")
|
||||
return raw
|
||||
|
||||
|
||||
def _load_absorption(freqs):
|
||||
"""Load molar extinction coefficients."""
|
||||
# Data from https://omlc.org/spectra/hemoglobin/summary.html
|
||||
# The text was copied to a text file. The text before and
|
||||
# after the table was deleted. The the following was run in
|
||||
# matlab
|
||||
# extinct_coef=importdata('extinction_coef.txt')
|
||||
# save('extinction_coef.mat', 'extinct_coef')
|
||||
#
|
||||
# Returns data as [[HbO2(freq1), Hb(freq1)],
|
||||
# [HbO2(freq2), Hb(freq2)]]
|
||||
extinction_fname = op.join(
|
||||
op.dirname(__file__), "..", "..", "data", "extinction_coef.mat"
|
||||
)
|
||||
a = loadmat(extinction_fname)["extinct_coef"]
|
||||
|
||||
interp_hbo = interp1d(a[:, 0], a[:, 1], kind="linear")
|
||||
interp_hb = interp1d(a[:, 0], a[:, 2], kind="linear")
|
||||
|
||||
ext_coef = np.array(
|
||||
[
|
||||
[interp_hbo(freqs[0]), interp_hb(freqs[0])],
|
||||
[interp_hbo(freqs[1]), interp_hb(freqs[1])],
|
||||
]
|
||||
)
|
||||
abs_coef = ext_coef * 0.2303
|
||||
|
||||
return abs_coef
|
||||
53
mne/preprocessing/nirs/_optical_density.py
Normal file
53
mne/preprocessing/nirs/_optical_density.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..._fiff.constants import FIFF
|
||||
from ...io import BaseRaw
|
||||
from ...utils import _validate_type, verbose, warn
|
||||
from ..nirs import _validate_nirs_info
|
||||
|
||||
|
||||
@verbose
|
||||
def optical_density(raw, *, verbose=None):
|
||||
r"""Convert NIRS raw data to optical density.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw : instance of Raw
|
||||
The raw data.
|
||||
%(verbose)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
raw : instance of Raw
|
||||
The modified raw instance.
|
||||
"""
|
||||
raw = raw.copy().load_data()
|
||||
_validate_type(raw, BaseRaw, "raw")
|
||||
picks = _validate_nirs_info(raw.info, fnirs="cw_amplitude")
|
||||
|
||||
# The devices measure light intensity. Negative light intensities should
|
||||
# not occur. If they do it is likely due to hardware or movement issues.
|
||||
# Set all negative values to abs(x), this also has the benefit of ensuring
|
||||
# that the means are all greater than zero for the division below.
|
||||
if np.any(raw._data[picks] <= 0):
|
||||
warn("Negative intensities encountered. Setting to abs(x)")
|
||||
min_ = np.inf
|
||||
for pi in picks:
|
||||
np.abs(raw._data[pi], out=raw._data[pi])
|
||||
min_ = min(min_, raw._data[pi].min() or min_)
|
||||
# avoid == 0
|
||||
for pi in picks:
|
||||
np.maximum(raw._data[pi], min_, out=raw._data[pi])
|
||||
|
||||
for pi in picks:
|
||||
data_mean = np.mean(raw._data[pi])
|
||||
raw._data[pi] /= data_mean
|
||||
np.log(raw._data[pi], out=raw._data[pi])
|
||||
raw._data[pi] *= -1
|
||||
raw.info["chs"][pi]["coil_type"] = FIFF.FIFFV_COIL_FNIRS_OD
|
||||
|
||||
return raw
|
||||
69
mne/preprocessing/nirs/_scalp_coupling_index.py
Normal file
69
mne/preprocessing/nirs/_scalp_coupling_index.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ...io import BaseRaw
|
||||
from ...utils import _validate_type, verbose
|
||||
from ..nirs import _validate_nirs_info
|
||||
|
||||
|
||||
@verbose
|
||||
def scalp_coupling_index(
|
||||
raw,
|
||||
l_freq=0.7,
|
||||
h_freq=1.5,
|
||||
l_trans_bandwidth=0.3,
|
||||
h_trans_bandwidth=0.3,
|
||||
verbose=False,
|
||||
):
|
||||
r"""Calculate scalp coupling index.
|
||||
|
||||
This function calculates the scalp coupling index
|
||||
:footcite:`pollonini2014auditory`. This is a measure of the quality of the
|
||||
connection between the optode and the scalp.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw : instance of Raw
|
||||
The raw data.
|
||||
%(l_freq)s
|
||||
%(h_freq)s
|
||||
%(l_trans_bandwidth)s
|
||||
%(h_trans_bandwidth)s
|
||||
%(verbose)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
sci : array of float
|
||||
Array containing scalp coupling index for each channel.
|
||||
|
||||
References
|
||||
----------
|
||||
.. footbibliography::
|
||||
"""
|
||||
_validate_type(raw, BaseRaw, "raw")
|
||||
picks = _validate_nirs_info(raw.info, fnirs="od", which="Scalp coupling index")
|
||||
|
||||
raw = raw.copy().pick(picks).load_data()
|
||||
zero_mask = np.std(raw._data, axis=-1) == 0
|
||||
filtered_data = raw.filter(
|
||||
l_freq,
|
||||
h_freq,
|
||||
l_trans_bandwidth=l_trans_bandwidth,
|
||||
h_trans_bandwidth=h_trans_bandwidth,
|
||||
verbose=verbose,
|
||||
).get_data()
|
||||
|
||||
sci = np.zeros(picks.shape)
|
||||
for ii in range(0, len(picks), 2):
|
||||
with np.errstate(invalid="ignore"):
|
||||
c = np.corrcoef(filtered_data[ii], filtered_data[ii + 1])[0][1]
|
||||
if not np.isfinite(c): # someone had std=0
|
||||
c = 0
|
||||
sci[ii] = c
|
||||
sci[ii + 1] = c
|
||||
sci[zero_mask] = 0
|
||||
sci = sci[np.argsort(picks)] # restore original order
|
||||
return sci
|
||||
155
mne/preprocessing/nirs/_tddr.py
Normal file
155
mne/preprocessing/nirs/_tddr.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
|
||||
import numpy as np
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
from ...io import BaseRaw
|
||||
from ...utils import _validate_type, verbose
|
||||
from ..nirs import _validate_nirs_info
|
||||
|
||||
|
||||
@verbose
|
||||
def temporal_derivative_distribution_repair(raw, *, verbose=None):
|
||||
"""Apply temporal derivative distribution repair to data.
|
||||
|
||||
Applies temporal derivative distribution repair (TDDR) to data
|
||||
:footcite:`FishburnEtAl2019`. This approach removes baseline shift
|
||||
and spike artifacts without the need for any user-supplied parameters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw : instance of Raw
|
||||
The raw data.
|
||||
%(verbose)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
raw : instance of Raw
|
||||
Data with TDDR applied.
|
||||
|
||||
Notes
|
||||
-----
|
||||
TDDR was initially designed to be used on optical density fNIRS data but
|
||||
has been enabled to be applied on hemoglobin concentration fNIRS data as
|
||||
well in MNE. We recommend applying the algorithm to optical density fNIRS
|
||||
data as intended by the original author wherever possible.
|
||||
|
||||
There is a shorter alias ``mne.preprocessing.nirs.tddr`` that can be used
|
||||
instead of this function (e.g. if line length is an issue).
|
||||
|
||||
References
|
||||
----------
|
||||
.. footbibliography::
|
||||
"""
|
||||
raw = raw.copy().load_data()
|
||||
_validate_type(raw, BaseRaw, "raw")
|
||||
picks = _validate_nirs_info(raw.info)
|
||||
|
||||
if not len(picks):
|
||||
raise RuntimeError("TDDR should be run on optical density or hemoglobin data.")
|
||||
for pick in picks:
|
||||
raw._data[pick] = _TDDR(raw._data[pick], raw.info["sfreq"])
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
# provide a short alias
|
||||
tddr = temporal_derivative_distribution_repair
|
||||
|
||||
|
||||
# Taken from https://github.com/frankfishburn/TDDR/ (MIT license).
|
||||
# With permission https://github.com/frankfishburn/TDDR/issues/1.
|
||||
# The only modification is the name, scipy signal import and flake fixes.
|
||||
def _TDDR(signal, sample_rate):
|
||||
# This function is the reference implementation for the TDDR algorithm for
|
||||
# motion correction of fNIRS data, as described in:
|
||||
#
|
||||
# Fishburn F.A., Ludlum R.S., Vaidya C.J., & Medvedev A.V. (2019).
|
||||
# Temporal Derivative Distribution Repair (TDDR): A motion correction
|
||||
# method for fNIRS. NeuroImage, 184, 171-179.
|
||||
# https://doi.org/10.1016/j.neuroimage.2018.09.025
|
||||
#
|
||||
# Usage:
|
||||
# signals_corrected = TDDR( signals , sample_rate );
|
||||
#
|
||||
# Inputs:
|
||||
# signals: A [sample x channel] matrix of uncorrected optical density or
|
||||
# hemoglobin data
|
||||
# sample_rate: A scalar reflecting the rate of acquisition in Hz
|
||||
#
|
||||
# Outputs:
|
||||
# signals_corrected: A [sample x channel] matrix of corrected optical
|
||||
# density data
|
||||
signal = np.array(signal)
|
||||
if len(signal.shape) != 1:
|
||||
for ch in range(signal.shape[1]):
|
||||
signal[:, ch] = _TDDR(signal[:, ch], sample_rate)
|
||||
return signal
|
||||
|
||||
# Preprocess: Separate high and low frequencies
|
||||
filter_cutoff = 0.5
|
||||
filter_order = 3
|
||||
Fc = filter_cutoff * 2 / sample_rate
|
||||
signal_mean = np.mean(signal)
|
||||
signal -= signal_mean
|
||||
if Fc < 1:
|
||||
fb, fa = butter(filter_order, Fc)
|
||||
signal_low = filtfilt(fb, fa, signal, padlen=0)
|
||||
else:
|
||||
signal_low = signal
|
||||
|
||||
signal_high = signal - signal_low
|
||||
|
||||
# Initialize
|
||||
tune = 4.685
|
||||
D = np.sqrt(np.finfo(signal.dtype).eps)
|
||||
mu = np.inf
|
||||
|
||||
# Step 1. Compute temporal derivative of the signal
|
||||
deriv = np.diff(signal_low)
|
||||
|
||||
# Step 2. Initialize observation weights
|
||||
w = np.ones(deriv.shape)
|
||||
|
||||
# Step 3. Iterative estimation of robust weights
|
||||
for _ in range(50):
|
||||
mu0 = mu
|
||||
|
||||
# Step 3a. Estimate weighted mean
|
||||
mu = np.sum(w * deriv) / np.sum(w)
|
||||
|
||||
# Step 3b. Calculate absolute residuals of estimate
|
||||
dev = np.abs(deriv - mu)
|
||||
|
||||
# Step 3c. Robust estimate of standard deviation of the residuals
|
||||
sigma = 1.4826 * np.median(dev)
|
||||
|
||||
# Step 3d. Scale deviations by standard deviation and tuning parameter
|
||||
if sigma == 0:
|
||||
break
|
||||
r = dev / (sigma * tune)
|
||||
|
||||
# Step 3e. Calculate new weights according to Tukey's biweight function
|
||||
w = ((1 - r**2) * (r < 1)) ** 2
|
||||
|
||||
# Step 3f. Terminate if new estimate is within
|
||||
# machine-precision of old estimate
|
||||
if abs(mu - mu0) < D * max(abs(mu), abs(mu0)):
|
||||
break
|
||||
|
||||
# Step 4. Apply robust weights to centered derivative
|
||||
new_deriv = w * (deriv - mu)
|
||||
|
||||
# Step 5. Integrate corrected derivative
|
||||
signal_low_corrected = np.cumsum(np.insert(new_deriv, 0, 0.0))
|
||||
|
||||
# Postprocess: Center the corrected signal
|
||||
signal_low_corrected = signal_low_corrected - np.mean(signal_low_corrected)
|
||||
|
||||
# Postprocess: Merge back with uncorrected high frequency component
|
||||
signal_corrected = signal_low_corrected + signal_high + signal_mean
|
||||
|
||||
return signal_corrected
|
||||
336
mne/preprocessing/nirs/nirs.py
Normal file
336
mne/preprocessing/nirs/nirs.py
Normal file
@@ -0,0 +1,336 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..._fiff.pick import _picks_to_idx, pick_types
|
||||
from ...utils import _check_option, _validate_type, fill_doc
|
||||
|
||||
# Standardized fNIRS channel name regexs
|
||||
_S_D_F_RE = re.compile(r"S(\d+)_D(\d+) (\d+\.?\d*)")
|
||||
_S_D_H_RE = re.compile(r"S(\d+)_D(\d+) (\w+)")
|
||||
|
||||
|
||||
@fill_doc
|
||||
def source_detector_distances(info, picks=None):
|
||||
r"""Determine the distance between NIRS source and detectors.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
%(info_not_none)s
|
||||
%(picks_all_data)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
dists : array of float
|
||||
Array containing distances in meters.
|
||||
Of shape equal to number of channels, or shape of picks if supplied.
|
||||
"""
|
||||
return np.array(
|
||||
[
|
||||
np.linalg.norm(
|
||||
np.diff(info["chs"][pick]["loc"][3:9].reshape(2, 3), axis=0)[0]
|
||||
)
|
||||
for pick in _picks_to_idx(info, picks, exclude=[])
|
||||
],
|
||||
float,
|
||||
)
|
||||
|
||||
|
||||
@fill_doc
|
||||
def short_channels(info, threshold=0.01):
|
||||
r"""Determine which NIRS channels are short.
|
||||
|
||||
Channels with a source to detector distance of less than
|
||||
``threshold`` are reported as short. The default threshold is 0.01 m.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
%(info_not_none)s
|
||||
threshold : float
|
||||
The threshold distance for what is considered short in meters.
|
||||
|
||||
Returns
|
||||
-------
|
||||
short : array of bool
|
||||
Array indicating which channels are short.
|
||||
Of shape equal to number of channels.
|
||||
"""
|
||||
return source_detector_distances(info) < threshold
|
||||
|
||||
|
||||
def _channel_frequencies(info):
|
||||
"""Return the light frequency for each channel."""
|
||||
# Only valid for fNIRS data before conversion to haemoglobin
|
||||
picks = _picks_to_idx(
|
||||
info, ["fnirs_cw_amplitude", "fnirs_od"], exclude=[], allow_empty=True
|
||||
)
|
||||
freqs = list()
|
||||
for pick in picks:
|
||||
freqs.append(round(float(_S_D_F_RE.match(info["ch_names"][pick]).groups()[2])))
|
||||
return np.array(freqs, int)
|
||||
|
||||
|
||||
def _channel_chromophore(info):
|
||||
"""Return the chromophore of each channel."""
|
||||
# Only valid for fNIRS data after conversion to haemoglobin
|
||||
picks = _picks_to_idx(info, ["hbo", "hbr"], exclude=[], allow_empty=True)
|
||||
chroma = []
|
||||
for ii in picks:
|
||||
chroma.append(info["ch_names"][ii].split(" ")[1])
|
||||
return chroma
|
||||
|
||||
|
||||
def _check_channels_ordered(info, pair_vals, *, throw_errors=True, check_bads=True):
|
||||
"""Check channels follow expected fNIRS format.
|
||||
|
||||
If the channels are correctly ordered then an array of valid picks
|
||||
will be returned.
|
||||
|
||||
If throw_errors is True then any errors in fNIRS formatting will be
|
||||
thrown to inform the user. If throw_errors is False then an empty array
|
||||
will be returned if the channels are not sufficiently formatted.
|
||||
"""
|
||||
# Every second channel should be same SD pair
|
||||
# and have the specified light frequencies.
|
||||
|
||||
# All wavelength based fNIRS data.
|
||||
picks_wave = _picks_to_idx(
|
||||
info, ["fnirs_cw_amplitude", "fnirs_od"], exclude=[], allow_empty=True
|
||||
)
|
||||
# All chromophore fNIRS data
|
||||
picks_chroma = _picks_to_idx(info, ["hbo", "hbr"], exclude=[], allow_empty=True)
|
||||
|
||||
if (len(picks_wave) > 0) & (len(picks_chroma) > 0):
|
||||
picks = _throw_or_return_empty(
|
||||
"MNE does not support a combination of amplitude, optical "
|
||||
"density, and haemoglobin data in the same raw structure.",
|
||||
throw_errors,
|
||||
)
|
||||
|
||||
# All continuous wave fNIRS data
|
||||
if len(picks_wave):
|
||||
error_word = "frequencies"
|
||||
use_RE = _S_D_F_RE
|
||||
picks = picks_wave
|
||||
else:
|
||||
error_word = "chromophore"
|
||||
use_RE = _S_D_H_RE
|
||||
picks = picks_chroma
|
||||
|
||||
pair_vals = np.array(pair_vals)
|
||||
if pair_vals.shape != (2,):
|
||||
raise ValueError(
|
||||
f"Exactly two {error_word} must exist in info, got {list(pair_vals)}"
|
||||
)
|
||||
# In principle we do not need to require that these be sorted --
|
||||
# all we need to do is change our sorted() below to make use of a
|
||||
# pair_vals.index(...) in a sort key -- but in practice we always want
|
||||
# (hbo, hbr) or (lower_freq, upper_freq) pairings, both of which will
|
||||
# work with a naive string sort, so let's just enforce sorted-ness here
|
||||
is_str = pair_vals.dtype.kind == "U"
|
||||
pair_vals = list(pair_vals)
|
||||
if is_str:
|
||||
if pair_vals != ["hbo", "hbr"]:
|
||||
raise ValueError(
|
||||
f'The {error_word} in info must be ["hbo", "hbr"], but got '
|
||||
f"{pair_vals} instead"
|
||||
)
|
||||
elif not np.array_equal(np.unique(pair_vals), pair_vals):
|
||||
raise ValueError(
|
||||
f"The {error_word} in info must be unique and sorted, but got "
|
||||
f"got {pair_vals} instead"
|
||||
)
|
||||
|
||||
if len(picks) % 2 != 0:
|
||||
picks = _throw_or_return_empty(
|
||||
"NIRS channels not ordered correctly. An even number of NIRS "
|
||||
f"channels is required. {len(info.ch_names)} channels were"
|
||||
f"provided",
|
||||
throw_errors,
|
||||
)
|
||||
|
||||
# Ensure wavelength info exists for waveform data
|
||||
all_freqs = [info["chs"][ii]["loc"][9] for ii in picks_wave]
|
||||
if np.any(np.isnan(all_freqs)):
|
||||
picks = _throw_or_return_empty(
|
||||
f"NIRS channels is missing wavelength information in the "
|
||||
f'info["chs"] structure. The encoded wavelengths are {all_freqs}.',
|
||||
throw_errors,
|
||||
)
|
||||
|
||||
# Validate the channel naming scheme
|
||||
for pick in picks:
|
||||
ch_name_info = use_RE.match(info["chs"][pick]["ch_name"])
|
||||
if not bool(ch_name_info):
|
||||
picks = _throw_or_return_empty(
|
||||
"NIRS channels have specified naming conventions. "
|
||||
"The provided channel name can not be parsed: "
|
||||
f"{repr(info.ch_names[pick])}",
|
||||
throw_errors,
|
||||
)
|
||||
break
|
||||
value = ch_name_info.groups()[2]
|
||||
if len(picks_wave):
|
||||
value = value
|
||||
else: # picks_chroma
|
||||
if value not in ["hbo", "hbr"]:
|
||||
picks = _throw_or_return_empty(
|
||||
"NIRS channels have specified naming conventions."
|
||||
"Chromophore data must be labeled either hbo or hbr. "
|
||||
f"The failing channel is {info['chs'][pick]['ch_name']}",
|
||||
throw_errors,
|
||||
)
|
||||
break
|
||||
|
||||
# Reorder to be paired (naive sort okay here given validation above)
|
||||
picks = picks[np.argsort([info["ch_names"][pick] for pick in picks])]
|
||||
|
||||
# Validate our paired ordering
|
||||
for ii, jj in zip(picks[::2], picks[1::2]):
|
||||
ch1_name = info["chs"][ii]["ch_name"]
|
||||
ch2_name = info["chs"][jj]["ch_name"]
|
||||
ch1_re = use_RE.match(ch1_name)
|
||||
ch2_re = use_RE.match(ch2_name)
|
||||
ch1_S, ch1_D, ch1_value = ch1_re.groups()[:3]
|
||||
ch2_S, ch2_D, ch2_value = ch2_re.groups()[:3]
|
||||
if len(picks_wave):
|
||||
ch1_value, ch2_value = float(ch1_value), float(ch2_value)
|
||||
if (
|
||||
(ch1_S != ch2_S)
|
||||
or (ch1_D != ch2_D)
|
||||
or (ch1_value != pair_vals[0])
|
||||
or (ch2_value != pair_vals[1])
|
||||
):
|
||||
picks = _throw_or_return_empty(
|
||||
"NIRS channels not ordered correctly. Channels must be "
|
||||
"ordered as source detector pairs with alternating"
|
||||
f" {error_word} {pair_vals[0]} & {pair_vals[1]}, but got "
|
||||
f"S{ch1_S}_D{ch1_D} pair "
|
||||
f"{repr(ch1_name)} and {repr(ch2_name)}",
|
||||
throw_errors,
|
||||
)
|
||||
break
|
||||
|
||||
if check_bads:
|
||||
for ii, jj in zip(picks[::2], picks[1::2]):
|
||||
want = [info.ch_names[ii], info.ch_names[jj]]
|
||||
got = list(set(info["bads"]).intersection(want))
|
||||
if len(got) == 1:
|
||||
raise RuntimeError(
|
||||
f"NIRS bad labelling is not consistent, found {got} but "
|
||||
f"needed {want}"
|
||||
)
|
||||
return picks
|
||||
|
||||
|
||||
def _throw_or_return_empty(msg, throw_errors):
|
||||
if throw_errors:
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
def _validate_nirs_info(
|
||||
info,
|
||||
*,
|
||||
throw_errors=True,
|
||||
fnirs=None,
|
||||
which=None,
|
||||
check_bads=True,
|
||||
allow_empty=True,
|
||||
):
|
||||
"""Apply all checks to fNIRS info. Works on all continuous wave types."""
|
||||
_validate_type(fnirs, (None, str), "fnirs")
|
||||
kinds = dict(
|
||||
od="optical density",
|
||||
cw_amplitude="continuous wave",
|
||||
hb="chromophore",
|
||||
)
|
||||
_check_option("fnirs", fnirs, (None,) + tuple(kinds))
|
||||
if fnirs is not None:
|
||||
kind = kinds[fnirs]
|
||||
fnirs = ["hbo", "hbr"] if fnirs == "hb" else f"fnirs_{fnirs}"
|
||||
if not len(pick_types(info, fnirs=fnirs)):
|
||||
raise RuntimeError(
|
||||
f"{which} must operate on {kind} data, but none was found."
|
||||
)
|
||||
freqs = np.unique(_channel_frequencies(info))
|
||||
if freqs.size > 0:
|
||||
pair_vals = freqs
|
||||
else:
|
||||
pair_vals = np.unique(_channel_chromophore(info))
|
||||
out = _check_channels_ordered(
|
||||
info, pair_vals, throw_errors=throw_errors, check_bads=check_bads
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _fnirs_spread_bads(info):
|
||||
"""Spread bad labeling across fnirs channels."""
|
||||
# For an optode pair if any component (light frequency or chroma) is marked
|
||||
# as bad, then they all should be. This function will find any pairs marked
|
||||
# as bad and spread the bad marking to all components of the optode pair.
|
||||
picks = _validate_nirs_info(info, check_bads=False)
|
||||
new_bads = set(info["bads"])
|
||||
for ii, jj in zip(picks[::2], picks[1::2]):
|
||||
ch1_name, ch2_name = info.ch_names[ii], info.ch_names[jj]
|
||||
if ch1_name in new_bads:
|
||||
new_bads.add(ch2_name)
|
||||
elif ch2_name in new_bads:
|
||||
new_bads.add(ch1_name)
|
||||
info["bads"] = sorted(new_bads)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _fnirs_optode_names(info):
|
||||
"""Return list of unique optode names."""
|
||||
picks_wave = _picks_to_idx(
|
||||
info, ["fnirs_cw_amplitude", "fnirs_od"], exclude=[], allow_empty=True
|
||||
)
|
||||
picks_chroma = _picks_to_idx(info, ["hbo", "hbr"], exclude=[], allow_empty=True)
|
||||
|
||||
if len(picks_wave) > 0:
|
||||
regex = _S_D_F_RE
|
||||
elif len(picks_chroma) > 0:
|
||||
regex = _S_D_H_RE
|
||||
else:
|
||||
return [], []
|
||||
|
||||
sources = np.unique([int(regex.match(ch).groups()[0]) for ch in info.ch_names])
|
||||
detectors = np.unique([int(regex.match(ch).groups()[1]) for ch in info.ch_names])
|
||||
|
||||
src_names = [f"S{s}" for s in sources]
|
||||
det_names = [f"D{d}" for d in detectors]
|
||||
|
||||
return src_names, det_names
|
||||
|
||||
|
||||
def _optode_position(info, optode):
|
||||
"""Find the position of an optode."""
|
||||
idx = [optode in a for a in info.ch_names].index(True)
|
||||
|
||||
if "S" in optode:
|
||||
loc_idx = range(3, 6)
|
||||
elif "D" in optode:
|
||||
loc_idx = range(6, 9)
|
||||
|
||||
return info["chs"][idx]["loc"][loc_idx]
|
||||
|
||||
|
||||
def _reorder_nirx(raw):
|
||||
# Maybe someday we should make this public like
|
||||
# mne.preprocessing.nirs.reorder_standard(raw, order='nirx')
|
||||
info = raw.info
|
||||
picks = pick_types(info, fnirs=True, exclude=[])
|
||||
prefixes = [info["ch_names"][pick].split()[0] for pick in picks]
|
||||
nirs_names = [info["ch_names"][pick] for pick in picks]
|
||||
nirs_sorted = sorted(
|
||||
nirs_names,
|
||||
key=lambda name: (prefixes.index(name.split()[0]), name.split(maxsplit=1)[1]),
|
||||
)
|
||||
raw.reorder_channels(nirs_sorted)
|
||||
Reference in New Issue
Block a user