initial commit

This commit is contained in:
2025-08-19 09:13:22 -07:00
parent 28464811d6
commit 0977a3e14d
820 changed files with 1003358 additions and 2 deletions

View 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

View 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

View 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

View 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

View 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

View 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)