542 lines
15 KiB
Python
542 lines
15 KiB
Python
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
import numpy as np
|
|
|
|
from .._fiff.meas_info import create_info
|
|
from .._fiff.pick import _picks_to_idx, pick_channels, pick_types
|
|
from ..annotations import _annotations_starts_stops
|
|
from ..epochs import BaseEpochs, Epochs
|
|
from ..evoked import Evoked
|
|
from ..filter import filter_data
|
|
from ..io import BaseRaw, RawArray
|
|
from ..utils import int_like, logger, sum_squared, verbose, warn
|
|
|
|
|
|
@verbose
|
|
def qrs_detector(
|
|
sfreq,
|
|
ecg,
|
|
thresh_value=0.6,
|
|
levels=2.5,
|
|
n_thresh=3,
|
|
l_freq=5,
|
|
h_freq=35,
|
|
tstart=0,
|
|
filter_length="10s",
|
|
verbose=None,
|
|
):
|
|
"""Detect QRS component in ECG channels.
|
|
|
|
QRS is the main wave on the heart beat.
|
|
|
|
Parameters
|
|
----------
|
|
sfreq : float
|
|
Sampling rate
|
|
ecg : array
|
|
ECG signal
|
|
thresh_value : float | str
|
|
qrs detection threshold. Can also be "auto" for automatic
|
|
selection of threshold.
|
|
levels : float
|
|
number of std from mean to include for detection
|
|
n_thresh : int
|
|
max number of crossings
|
|
l_freq : float
|
|
Low pass frequency
|
|
h_freq : float
|
|
High pass frequency
|
|
%(tstart_ecg)s
|
|
%(filter_length_ecg)s
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
events : array
|
|
Indices of ECG peaks.
|
|
"""
|
|
win_size = int(round((60.0 * sfreq) / 120.0))
|
|
|
|
filtecg = filter_data(
|
|
ecg,
|
|
sfreq,
|
|
l_freq,
|
|
h_freq,
|
|
None,
|
|
filter_length,
|
|
0.5,
|
|
0.5,
|
|
phase="zero-double",
|
|
fir_window="hann",
|
|
fir_design="firwin2",
|
|
)
|
|
|
|
ecg_abs = np.abs(filtecg)
|
|
init = int(sfreq)
|
|
|
|
n_samples_start = int(sfreq * tstart)
|
|
ecg_abs = ecg_abs[n_samples_start:]
|
|
|
|
n_points = len(ecg_abs)
|
|
|
|
maxpt = np.empty(3)
|
|
maxpt[0] = np.max(ecg_abs[:init])
|
|
maxpt[1] = np.max(ecg_abs[init : init * 2])
|
|
maxpt[2] = np.max(ecg_abs[init * 2 : init * 3])
|
|
|
|
init_max = np.mean(maxpt)
|
|
|
|
if thresh_value == "auto":
|
|
thresh_runs = np.arange(0.3, 1.1, 0.05)
|
|
elif isinstance(thresh_value, str):
|
|
raise ValueError('threshold value must be "auto" or a float')
|
|
else:
|
|
thresh_runs = [thresh_value]
|
|
|
|
# Try a few thresholds (or just one)
|
|
clean_events = list()
|
|
for thresh_value in thresh_runs:
|
|
thresh1 = init_max * thresh_value
|
|
numcross = list()
|
|
time = list()
|
|
rms = list()
|
|
ii = 0
|
|
while ii < (n_points - win_size):
|
|
window = ecg_abs[ii : ii + win_size]
|
|
if window[0] > thresh1:
|
|
max_time = np.argmax(window)
|
|
time.append(ii + max_time)
|
|
nx = np.sum(
|
|
np.diff(((window > thresh1).astype(np.int64) == 1).astype(int))
|
|
)
|
|
numcross.append(nx)
|
|
rms.append(np.sqrt(sum_squared(window) / window.size))
|
|
ii += win_size
|
|
else:
|
|
ii += 1
|
|
|
|
if len(rms) == 0:
|
|
rms.append(0.0)
|
|
time.append(0.0)
|
|
time = np.array(time)
|
|
rms_mean = np.mean(rms)
|
|
rms_std = np.std(rms)
|
|
rms_thresh = rms_mean + (rms_std * levels)
|
|
b = np.where(rms < rms_thresh)[0]
|
|
a = np.array(numcross)[b]
|
|
ce = time[b[a < n_thresh]]
|
|
|
|
ce += n_samples_start
|
|
if ce.size > 0: # We actually found an event
|
|
clean_events.append(ce)
|
|
|
|
if clean_events:
|
|
# pick the best threshold; first get effective heart rates
|
|
rates = np.array(
|
|
[60.0 * len(cev) / (len(ecg) / float(sfreq)) for cev in clean_events]
|
|
)
|
|
|
|
# now find heart rates that seem reasonable (infant through adult
|
|
# athlete)
|
|
idx = np.where(np.logical_and(rates <= 160.0, rates >= 40.0))[0]
|
|
if idx.size > 0:
|
|
ideal_rate = np.median(rates[idx]) # get close to the median
|
|
else:
|
|
ideal_rate = 80.0 # get close to a reasonable default
|
|
|
|
idx = np.argmin(np.abs(rates - ideal_rate))
|
|
clean_events = clean_events[idx]
|
|
else:
|
|
clean_events = np.array([])
|
|
|
|
return clean_events
|
|
|
|
|
|
@verbose
|
|
def find_ecg_events(
|
|
raw,
|
|
event_id=999,
|
|
ch_name=None,
|
|
tstart=0.0,
|
|
l_freq=5,
|
|
h_freq=35,
|
|
qrs_threshold="auto",
|
|
filter_length="10s",
|
|
return_ecg=False,
|
|
reject_by_annotation=True,
|
|
verbose=None,
|
|
):
|
|
"""Find ECG events by localizing the R wave peaks.
|
|
|
|
Parameters
|
|
----------
|
|
raw : instance of Raw
|
|
The raw data.
|
|
%(event_id_ecg)s
|
|
%(ch_name_ecg)s
|
|
%(tstart_ecg)s
|
|
%(l_freq_ecg_filter)s
|
|
qrs_threshold : float | str
|
|
Between 0 and 1. qrs detection threshold. Can also be "auto" to
|
|
automatically choose the threshold that generates a reasonable
|
|
number of heartbeats (40-160 beats / min).
|
|
%(filter_length_ecg)s
|
|
return_ecg : bool
|
|
Return the ECG data. This is especially useful if no ECG channel
|
|
is present in the input data, so one will be synthesized. Defaults to
|
|
``False``.
|
|
%(reject_by_annotation_all)s
|
|
|
|
.. versionadded:: 0.18
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
ecg_events : array
|
|
The events corresponding to the peaks of the R waves.
|
|
ch_ecg : int | None
|
|
Index of channel used.
|
|
average_pulse : float
|
|
The estimated average pulse. If no ECG events could be found, this will
|
|
be zero.
|
|
ecg : array | None
|
|
The ECG data of the synthesized ECG channel, if any. This will only
|
|
be returned if ``return_ecg=True`` was passed.
|
|
|
|
See Also
|
|
--------
|
|
create_ecg_epochs
|
|
compute_proj_ecg
|
|
"""
|
|
skip_by_annotation = ("edge", "bad") if reject_by_annotation else ()
|
|
del reject_by_annotation
|
|
idx_ecg = _get_ecg_channel_index(ch_name, raw)
|
|
if idx_ecg is not None:
|
|
logger.info(f"Using channel {raw.ch_names[idx_ecg]} to identify heart beats.")
|
|
ecg = raw.get_data(picks=idx_ecg)
|
|
else:
|
|
ecg, _ = _make_ecg(raw, start=None, stop=None)
|
|
assert ecg.ndim == 2 and ecg.shape[0] == 1
|
|
ecg = ecg[0]
|
|
# Deal with filtering the same way we do in raw, i.e. filter each good
|
|
# segment
|
|
onsets, ends = _annotations_starts_stops(
|
|
raw, skip_by_annotation, "reject_by_annotation", invert=True
|
|
)
|
|
ecgs = list()
|
|
max_idx = (ends - onsets).argmax()
|
|
for si, (start, stop) in enumerate(zip(onsets, ends)):
|
|
# Only output filter params once (for info level), and only warn
|
|
# once about the length criterion (longest segment is too short)
|
|
use_verbose = verbose if si == max_idx else "error"
|
|
ecgs.append(
|
|
filter_data(
|
|
ecg[start:stop],
|
|
raw.info["sfreq"],
|
|
l_freq,
|
|
h_freq,
|
|
[0],
|
|
filter_length,
|
|
0.5,
|
|
0.5,
|
|
1,
|
|
"fir",
|
|
None,
|
|
copy=False,
|
|
phase="zero-double",
|
|
fir_window="hann",
|
|
fir_design="firwin2",
|
|
verbose=use_verbose,
|
|
)
|
|
)
|
|
ecg = np.concatenate(ecgs)
|
|
|
|
# detecting QRS and generating events. Since not user-controlled, don't
|
|
# output filter params here (hardcode verbose=False)
|
|
ecg_events = qrs_detector(
|
|
raw.info["sfreq"],
|
|
ecg,
|
|
tstart=tstart,
|
|
thresh_value=qrs_threshold,
|
|
l_freq=None,
|
|
h_freq=None,
|
|
verbose=False,
|
|
)
|
|
|
|
# map ECG events back to original times
|
|
remap = np.empty(len(ecg), int)
|
|
offset = 0
|
|
for start, stop in zip(onsets, ends):
|
|
this_len = stop - start
|
|
assert this_len >= 0
|
|
remap[offset : offset + this_len] = np.arange(start, stop)
|
|
offset += this_len
|
|
assert offset == len(ecg)
|
|
|
|
if ecg_events.size > 0:
|
|
ecg_events = remap[ecg_events]
|
|
else:
|
|
ecg_events = np.array([])
|
|
|
|
n_events = len(ecg_events)
|
|
duration_sec = len(ecg) / raw.info["sfreq"] - tstart
|
|
duration_min = duration_sec / 60.0
|
|
average_pulse = n_events / duration_min
|
|
logger.info(
|
|
f"Number of ECG events detected : {n_events} "
|
|
f"(average pulse {average_pulse} / min.)"
|
|
)
|
|
|
|
ecg_events = np.array(
|
|
[
|
|
ecg_events + raw.first_samp,
|
|
np.zeros(n_events, int),
|
|
event_id * np.ones(n_events, int),
|
|
]
|
|
).T
|
|
|
|
out = (ecg_events, idx_ecg, average_pulse)
|
|
ecg = ecg[np.newaxis] # backward compat output 2D
|
|
if return_ecg:
|
|
out += (ecg,)
|
|
return out
|
|
|
|
|
|
def _get_ecg_channel_index(ch_name, inst):
|
|
"""Get ECG channel index, if no channel found returns None."""
|
|
if ch_name is None:
|
|
ecg_idx = pick_types(
|
|
inst.info,
|
|
meg=False,
|
|
eeg=False,
|
|
stim=False,
|
|
eog=False,
|
|
ecg=True,
|
|
emg=False,
|
|
ref_meg=False,
|
|
exclude="bads",
|
|
)
|
|
else:
|
|
if ch_name not in inst.ch_names:
|
|
raise ValueError(f"{ch_name} not in channel list ({inst.ch_names})")
|
|
ecg_idx = pick_channels(inst.ch_names, include=[ch_name])
|
|
|
|
if len(ecg_idx) == 0:
|
|
return None
|
|
# raise RuntimeError('No ECG channel found. Please specify ch_name '
|
|
# 'parameter e.g. MEG 1531')
|
|
|
|
if len(ecg_idx) > 1:
|
|
warn(
|
|
f"More than one ECG channel found. Using only {inst.ch_names[ecg_idx[0]]}."
|
|
)
|
|
|
|
return ecg_idx[0]
|
|
|
|
|
|
@verbose
|
|
def create_ecg_epochs(
|
|
raw,
|
|
ch_name=None,
|
|
event_id=999,
|
|
picks=None,
|
|
tmin=-0.5,
|
|
tmax=0.5,
|
|
l_freq=8,
|
|
h_freq=16,
|
|
reject=None,
|
|
flat=None,
|
|
baseline=None,
|
|
preload=True,
|
|
keep_ecg=False,
|
|
reject_by_annotation=True,
|
|
decim=1,
|
|
verbose=None,
|
|
):
|
|
"""Conveniently generate epochs around ECG artifact events.
|
|
|
|
%(create_ecg_epochs)s
|
|
|
|
.. note:: Filtering is only applied to the ECG channel while finding
|
|
events. The resulting ``ecg_epochs`` will have no filtering
|
|
applied (i.e., have the same filter properties as the input
|
|
``raw`` instance).
|
|
|
|
Parameters
|
|
----------
|
|
raw : instance of Raw
|
|
The raw data.
|
|
%(ch_name_ecg)s
|
|
%(event_id_ecg)s
|
|
%(picks_all)s
|
|
tmin : float
|
|
Start time before event.
|
|
tmax : float
|
|
End time after event.
|
|
%(l_freq_ecg_filter)s
|
|
%(reject_epochs)s
|
|
%(flat)s
|
|
%(baseline_epochs)s
|
|
preload : bool
|
|
Preload epochs or not (default True). Must be True if
|
|
keep_ecg is True.
|
|
keep_ecg : bool
|
|
When ECG is synthetically created (after picking), should it be added
|
|
to the epochs? Must be False when synthetic channel is not used.
|
|
Defaults to False.
|
|
%(reject_by_annotation_epochs)s
|
|
|
|
.. versionadded:: 0.14.0
|
|
%(decim)s
|
|
|
|
.. versionadded:: 0.21.0
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
ecg_epochs : instance of Epochs
|
|
Data epoched around ECG R wave peaks.
|
|
|
|
See Also
|
|
--------
|
|
find_ecg_events
|
|
compute_proj_ecg
|
|
|
|
Notes
|
|
-----
|
|
If you already have a list of R-peak times, or want to compute R-peaks
|
|
outside MNE-Python using a different algorithm, the recommended approach is
|
|
to call the :class:`~mne.Epochs` constructor directly, with your R-peaks
|
|
formatted as an :term:`events` array (here we also demonstrate the relevant
|
|
default values)::
|
|
|
|
mne.Epochs(raw, r_peak_events_array, tmin=-0.5, tmax=0.5,
|
|
baseline=None, preload=True, proj=False) # doctest: +SKIP
|
|
"""
|
|
has_ecg = "ecg" in raw or ch_name is not None
|
|
if keep_ecg and (has_ecg or not preload):
|
|
raise ValueError(
|
|
"keep_ecg can be True only if the ECG channel is "
|
|
"created synthetically and preload=True."
|
|
)
|
|
|
|
events, _, _, ecg = find_ecg_events(
|
|
raw,
|
|
ch_name=ch_name,
|
|
event_id=event_id,
|
|
l_freq=l_freq,
|
|
h_freq=h_freq,
|
|
return_ecg=True,
|
|
reject_by_annotation=reject_by_annotation,
|
|
)
|
|
|
|
picks = _picks_to_idx(raw.info, picks, "all", exclude=())
|
|
|
|
# create epochs around ECG events and baseline (important)
|
|
ecg_epochs = Epochs(
|
|
raw,
|
|
events=events,
|
|
event_id=event_id,
|
|
tmin=tmin,
|
|
tmax=tmax,
|
|
proj=False,
|
|
flat=flat,
|
|
picks=picks,
|
|
reject=reject,
|
|
baseline=baseline,
|
|
reject_by_annotation=reject_by_annotation,
|
|
preload=preload,
|
|
decim=decim,
|
|
)
|
|
|
|
if keep_ecg:
|
|
# We know we have created a synthetic channel and epochs are preloaded
|
|
ecg_raw = RawArray(
|
|
ecg,
|
|
create_info(
|
|
ch_names=["ECG-SYN"], sfreq=raw.info["sfreq"], ch_types=["ecg"]
|
|
),
|
|
first_samp=raw.first_samp,
|
|
)
|
|
with ecg_raw.info._unlock():
|
|
ignore = ["ch_names", "chs", "nchan", "bads"]
|
|
for k, v in raw.info.items():
|
|
if k not in ignore:
|
|
ecg_raw.info[k] = v
|
|
syn_epochs = Epochs(
|
|
ecg_raw,
|
|
events=ecg_epochs.events,
|
|
event_id=event_id,
|
|
tmin=tmin,
|
|
tmax=tmax,
|
|
proj=False,
|
|
picks=[0],
|
|
baseline=baseline,
|
|
decim=decim,
|
|
preload=True,
|
|
)
|
|
ecg_epochs = ecg_epochs.add_channels([syn_epochs])
|
|
|
|
return ecg_epochs
|
|
|
|
|
|
@verbose
|
|
def _make_ecg(inst, start, stop, reject_by_annotation=False, verbose=None):
|
|
"""Create ECG signal from cross channel average."""
|
|
if not any(c in inst for c in ["mag", "grad"]):
|
|
raise ValueError(
|
|
"Generating an artificial ECG channel can only be done for MEG data"
|
|
)
|
|
for ch in ["mag", "grad"]:
|
|
if ch in inst:
|
|
break
|
|
logger.info(
|
|
"Reconstructing ECG signal from {}".format(
|
|
{"mag": "Magnetometers", "grad": "Gradiometers"}[ch]
|
|
)
|
|
)
|
|
picks = pick_types(inst.info, meg=ch, eeg=False, ref_meg=False)
|
|
|
|
# Handle start/stop
|
|
msg = (
|
|
"integer arguments for the start and stop parameters are "
|
|
"not supported for Epochs and Evoked objects. Please "
|
|
"consider using float arguments specifying start and stop "
|
|
"time in seconds."
|
|
)
|
|
begin_param_name = "tmin"
|
|
if isinstance(start, int_like):
|
|
if isinstance(inst, BaseRaw):
|
|
# Raw has start param, can just use int
|
|
begin_param_name = "start"
|
|
else:
|
|
raise ValueError(msg)
|
|
|
|
end_param_name = "tmax"
|
|
if isinstance(start, int_like):
|
|
if isinstance(inst, BaseRaw):
|
|
# Raw has stop param, can just use int
|
|
end_param_name = "stop"
|
|
else:
|
|
raise ValueError(msg)
|
|
|
|
kwargs = {begin_param_name: start, end_param_name: stop}
|
|
|
|
if isinstance(inst, BaseRaw):
|
|
reject_by_annotation = "omit" if reject_by_annotation else None
|
|
ecg, times = inst.get_data(
|
|
picks,
|
|
return_times=True,
|
|
**kwargs,
|
|
reject_by_annotation=reject_by_annotation,
|
|
)
|
|
elif isinstance(inst, BaseEpochs):
|
|
ecg = np.hstack(inst.copy().get_data(picks, **kwargs))
|
|
times = inst.times
|
|
elif isinstance(inst, Evoked):
|
|
ecg = inst.get_data(picks, **kwargs)
|
|
times = inst.times
|
|
return ecg.mean(0, keepdims=True), times
|