Skip to content

Commit 874260b

Browse files
Merge pull request #1053 from JonasEmrich/ecg_delineate_prominence
[Feature] Add new ECG delineator method: Peak-Prominence (Emrich et al., 2024)
2 parents 99a3ba8 + 8917f12 commit 874260b

File tree

1 file changed

+214
-10
lines changed

1 file changed

+214
-10
lines changed

neurokit2/ecg/ecg_delineate.py

+214-10
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ def ecg_delineate(
4949
sampling_rate : int
5050
The sampling frequency of ``ecg_signal`` (in Hz, i.e., samples/second). Defaults to 1000.
5151
method : str
52-
Can be one of ``"peak"`` for a peak-based method, ``"cwt"`` for continuous wavelet transform
53-
or ``"dwt"`` (default) for discrete wavelet transform.
52+
Can be one of ``"peak"`` for a peak-based method, ``"prominence"`` for a peak-prominence-based
53+
method (Emrich et al., 2024), ``"cwt"`` for continuous wavelet transform or ``"dwt"`` (default)
54+
for discrete wavelet transform.
55+
The ``"prominence"`` method might be useful to detect the waves, allowing to set individual physiological
56+
limits (see kwargs), while the ``"dwt"`` method might be more precise for detecting the onsets and offsets
57+
of the waves (but might exhibit lower accuracy when there is significant variation in wave morphology).
58+
The ``"peak"`` method, which uses the zero-crossings of the signal derivatives, works best with very clean signals.
5459
show : bool
5560
If ``True``, will return a plot to visualizing the delineated waves information.
5661
show_type: str
@@ -60,7 +65,16 @@ def ecg_delineate(
6065
Defaults to ``False``. If ``True``, replaces the delineated features with ``np.nan`` if its
6166
standardized distance from R-peaks is more than 3.
6267
**kwargs
63-
Other optional arguments.
68+
Other optional arguments:
69+
If using the ``"prominence"`` method, additional parameters (in milliseconds) can be passed to set
70+
individual physiological limits for the search boundaries:
71+
- ``max_qrs_interval``: The maximum allowable QRS complex interval. Defaults to 180 ms.
72+
- ``max_pr_interval``: The maximum PR interval duration. Defaults to 300 ms.
73+
- ``max_r_rise_time``: Maximum duration for the R-wave rise. Defaults to 120 ms.
74+
- ``typical_st_segment``: Typical duration of the ST segment. Defaults to 150 ms.
75+
- ``max_p_basepoint_interval``: The maximum interval between P-wave on- and offset. Defaults to 100 ms.
76+
- ``max_r_basepoint_interval``: The maximum interval between R-wave on- and offset. Defaults to 100 ms.
77+
- ``max_t_basepoint_interval``: The maximum interval between T-wave on- and offset. Defaults to 200 ms.
6478
6579
Returns
6680
-------
@@ -71,7 +85,7 @@ def ecg_delineate(
7185
``"ECG_Q_Peaks"``, ``"ECG_S_Peaks"``, ``"ECG_T_Peaks"``, ``"ECG_P_Onsets"``,
7286
``"ECG_T_Offsets"``, respectively.
7387
74-
For wavelet methods, in addition to the above information, the dictionary contains the
88+
For the wavelet and prominence methods, in addition to the above information, the dictionary contains the
7589
samples at which QRS-onsets and QRS-offsets occur, accessible with the key
7690
``"ECG_P_Peaks"``, ``"ECG_T_Peaks"``, ``"ECG_P_Onsets"``, ``"ECG_P_Offsets"``,
7791
``"ECG_Q_Peaks"``, ``"ECG_S_Peaks"``, ``"ECG_T_Onsets"``, ``"ECG_T_Offsets"``,
@@ -114,6 +128,8 @@ def ecg_delineate(
114128
- Martínez, J. P., Almeida, R., Olmos, S., Rocha, A. P., & Laguna, P. (2004). A wavelet-based
115129
ECG delineator: evaluation on standard databases. IEEE Transactions on biomedical engineering,
116130
51(4), 570-581.
131+
- Emrich, J., Gargano, A., Koka, T., & Muma, M. (2024). Physiology-Informed ECG Delineation Based
132+
on Peak Prominence. 32nd European Signal Processing Conference (EUSIPCO), 1402-1406.
117133
118134
"""
119135
# Sanitize input for ecg_cleaned
@@ -162,10 +178,12 @@ def ecg_delineate(
162178
)
163179
elif method in ["dwt", "discrete wavelet transform"]:
164180
waves = _dwt_ecg_delineator(ecg_cleaned, rpeaks, sampling_rate=sampling_rate)
181+
elif method in ["prominence", "peak-prominence", "emrich", "emrich2024"]:
182+
waves = _prominence_ecg_delineator(ecg_cleaned, rpeaks=rpeaks, sampling_rate=sampling_rate, **kwargs)
165183

166184
else:
167185
raise ValueError(
168-
"NeuroKit error: ecg_delineate(): 'method' should be one of 'peak',"
186+
"NeuroKit error: ecg_delineate(): 'method' should be one of 'peak', 'prominence',"
169187
"'cwt' or 'dwt'."
170188
)
171189

@@ -739,6 +757,195 @@ def _ecg_delineator_cwt(ecg, rpeaks=None, sampling_rate=1000):
739757
}
740758

741759

760+
# =============================================================================
761+
# PROMINENCE METHOD (Emrich et al., 2024)
762+
# =============================================================================
763+
def _prominence_ecg_delineator(ecg, rpeaks=None, sampling_rate=1000, **kwargs):
764+
# pysiology-informed boundaries in milliseconds, adapt if needed
765+
max_qrs_interval = int(kwargs.get("max_qrs_interval", 180) * sampling_rate / 1000)
766+
max_pr_interval = int(kwargs.get("max_pr_interval", 300) * sampling_rate / 1000)
767+
max_r_rise_time = int(kwargs.get("max_r_rise_time", 120) * sampling_rate / 1000)
768+
typical_st_segment = int(kwargs.get("typical_st_segment", 150) * sampling_rate / 1000)
769+
# max basepoint intervals
770+
max_p_basepoint_interval = int(kwargs.get("max_p_basepoint_interval", 100) * sampling_rate / 1000)
771+
max_r_basepoint_interval = int(kwargs.get("max_r_basepoint_interval", 100) * sampling_rate / 1000)
772+
max_t_basepoint_interval = int(kwargs.get("max_t_basepoint_interval", 200) * sampling_rate / 1000)
773+
774+
waves = {
775+
"ECG_P_Onsets": [],
776+
"ECG_P_Peaks": [],
777+
"ECG_P_Offsets": [],
778+
"ECG_Q_Peaks": [],
779+
"ECG_R_Onsets": [],
780+
"ECG_R_Offsets": [],
781+
"ECG_S_Peaks": [],
782+
"ECG_T_Onsets": [],
783+
"ECG_T_Peaks": [],
784+
"ECG_T_Offsets": [],
785+
}
786+
787+
# calculate RR intervals
788+
rr = np.diff(rpeaks)
789+
rr = np.insert(rr, 0, min(rr[0], 2 * rpeaks[0]))
790+
rr = np.insert(rr, -1, min(rr[-1], 2 * rpeaks[-1]))
791+
792+
# iterate over all beats
793+
left = 0
794+
for i in range(len(rpeaks)):
795+
# 1. split signal into segments
796+
rpeak_pos = min(rpeaks[i], rr[i] // 2)
797+
left = rpeaks[i] - rpeak_pos
798+
right = rpeaks[i] + rr[i + 1] // 2
799+
ecg_seg = ecg[left:right]
800+
801+
current_wave = {
802+
"ECG_R_Peaks": rpeak_pos,
803+
}
804+
805+
# 2. find local extrema in signal
806+
local_maxima = scipy.signal.find_peaks(ecg_seg)[0]
807+
local_minima = scipy.signal.find_peaks(-ecg_seg)[0]
808+
local_extrema = np.concatenate((local_maxima, local_minima))
809+
810+
# 3. compute prominence weight
811+
weight_maxima = _calc_prominence(local_maxima, ecg_seg, current_wave["ECG_R_Peaks"])
812+
weight_minima = _calc_prominence(local_minima, ecg_seg, current_wave["ECG_R_Peaks"], minima=True)
813+
814+
if local_extrema.any():
815+
# find waves
816+
_prominence_find_q_wave(weight_minima, current_wave, max_r_rise_time)
817+
_prominence_find_s_wave(ecg_seg, weight_minima, current_wave, max_qrs_interval)
818+
_prominence_find_p_wave(local_maxima, weight_maxima, current_wave, max_pr_interval)
819+
_prominence_find_t_wave(local_extrema, (weight_minima + weight_maxima), current_wave, typical_st_segment)
820+
_prominence_find_on_offsets(
821+
ecg_seg,
822+
sampling_rate,
823+
local_minima,
824+
current_wave,
825+
max_p_basepoint_interval,
826+
max_r_basepoint_interval,
827+
max_t_basepoint_interval,
828+
)
829+
830+
# append waves for current beat / complex
831+
for key in waves:
832+
if key == "ECG_R_Peaks":
833+
waves[key].append(int(rpeaks[i]))
834+
elif key in current_wave:
835+
waves[key].append(int(current_wave[key] + left))
836+
else:
837+
waves[key].append(np.nan)
838+
839+
return waves
840+
841+
842+
def _calc_prominence(peaks, sig, Rpeak=None, minima=False):
843+
"""Returns an array of the same length as sig with prominences computed for the provided peaks.
844+
845+
Prominence of the R-peak is excluded if the R-peak position is given.
846+
847+
"""
848+
w = np.zeros_like(sig)
849+
850+
if len(peaks) < 1:
851+
return w
852+
# get prominence
853+
_sig = -sig if minima else sig
854+
w[peaks] = scipy.signal.peak_prominences(_sig, peaks)[0]
855+
# optional: set rpeak prominence to zero to emphasize other peaks
856+
if Rpeak is not None:
857+
w[Rpeak] = 0
858+
return w
859+
860+
861+
def _prominence_find_q_wave(weight_minima, current_wave, max_r_rise_time):
862+
if "ECG_R_Peaks" not in current_wave:
863+
return
864+
q_bound = max(current_wave["ECG_R_Peaks"] - max_r_rise_time, 0)
865+
866+
current_wave["ECG_Q_Peaks"] = np.argmax(weight_minima[q_bound : current_wave["ECG_R_Peaks"]]) + q_bound
867+
868+
869+
def _prominence_find_s_wave(sig, weight_minima, current_wave, max_qrs_interval):
870+
if "ECG_Q_Peaks" not in current_wave:
871+
return
872+
s_bound = current_wave["ECG_Q_Peaks"] + max_qrs_interval
873+
s_wave = np.argmax(weight_minima[current_wave["ECG_R_Peaks"] : s_bound] > 0) + current_wave["ECG_R_Peaks"]
874+
current_wave["ECG_S_Peaks"] = (
875+
np.argmin(sig[current_wave["ECG_R_Peaks"] : s_bound]) + current_wave["ECG_R_Peaks"]
876+
if s_wave == current_wave["ECG_R_Peaks"]
877+
else s_wave
878+
)
879+
880+
881+
def _prominence_find_p_wave(local_maxima, weight_maxima, current_wave, max_pr_interval):
882+
if "ECG_Q_Peaks" not in current_wave:
883+
return
884+
p_candidates = local_maxima[
885+
(current_wave["ECG_Q_Peaks"] - max_pr_interval <= local_maxima) & (local_maxima <= current_wave["ECG_Q_Peaks"])
886+
]
887+
if p_candidates.any():
888+
current_wave["ECG_P_Peaks"] = p_candidates[np.argmax(weight_maxima[p_candidates])]
889+
890+
891+
def _prominence_find_t_wave(local_extrema, weight_extrema, current_wave, typical_st_segment):
892+
if "ECG_S_Peaks" not in current_wave:
893+
return
894+
t_candidates = local_extrema[(current_wave["ECG_S_Peaks"] + typical_st_segment <= local_extrema)]
895+
if t_candidates.any():
896+
current_wave["ECG_T_Peaks"] = t_candidates[np.argmax(weight_extrema[t_candidates])]
897+
898+
899+
def _prominence_find_on_offsets(
900+
sig,
901+
sampling_rate,
902+
local_minima,
903+
current_wave,
904+
max_p_basepoint_interval,
905+
max_r_basepoint_interval,
906+
max_t_basepoint_interval,
907+
):
908+
if "ECG_P_Peaks" in current_wave:
909+
_, p_on, p_off = scipy.signal.peak_prominences(
910+
sig, [current_wave["ECG_P_Peaks"]], wlen=max_p_basepoint_interval
911+
)
912+
if not np.isnan(p_on):
913+
current_wave["ECG_P_Onsets"] = p_on[0]
914+
if not np.isnan(p_off):
915+
current_wave["ECG_P_Offsets"] = p_off[0]
916+
917+
if "ECG_T_Peaks" in current_wave:
918+
p = -1 if np.isin(current_wave["ECG_T_Peaks"], local_minima) else 1
919+
920+
_, t_on, t_off = scipy.signal.peak_prominences(
921+
p * sig, [current_wave["ECG_T_Peaks"]], wlen=max_t_basepoint_interval
922+
)
923+
if not np.isnan(t_on):
924+
current_wave["ECG_T_Onsets"] = t_on[0]
925+
if not np.isnan(t_off):
926+
current_wave["ECG_T_Offsets"] = t_off[0]
927+
928+
# correct R-peak position towards local maxima (otherwise prominence will be falsely computed)
929+
r_pos = _correct_peak(sig, sampling_rate, current_wave["ECG_R_Peaks"])
930+
_, r_on, r_off = scipy.signal.peak_prominences(sig, [r_pos], wlen=max_r_basepoint_interval)
931+
if not np.isnan(r_on):
932+
current_wave["ECG_R_Onsets"] = r_on[0]
933+
934+
if not np.isnan(r_off):
935+
current_wave["ECG_R_Offsets"] = r_off[0]
936+
937+
938+
def _correct_peak(sig, fs, peak, window=0.02):
939+
"""Correct peak towards local maxima within provided window."""
940+
941+
left = peak - int(window * fs)
942+
right = peak + int(window * fs)
943+
if len(sig[left:right]) > 0:
944+
return np.argmax(sig[left:right]) + left
945+
else:
946+
return peak
947+
948+
742949
# Internals
743950
# ---------------------
744951

@@ -798,11 +1005,7 @@ def _onset_offset_delineator(ecg, peaks, peak_type="rpeaks", sampling_rate=1000)
7981005
epsilon_onset = 0.25 * wt_peaks_data["peak_heights"][-1]
7991006
leftbase = wt_peaks_data["left_bases"][-1] + index_peak - half_wave_width
8001007
if peak_type == "rpeaks":
801-
candidate_onsets = (
802-
np.where(cwtmatr[2, nfirst - 100 : nfirst] < epsilon_onset)[0]
803-
+ nfirst
804-
- 100
805-
)
1008+
candidate_onsets = np.where(cwtmatr[2, nfirst - 100 : nfirst] < epsilon_onset)[0] + nfirst - 100
8061009
elif peak_type in ["tpeaks", "ppeaks"]:
8071010
candidate_onsets = (
8081011
np.where(-cwtmatr[4, nfirst - 100 : nfirst] < epsilon_onset)[0]
@@ -1123,6 +1326,7 @@ def _ecg_delineate_plot(
11231326
sampling_rate=1000,
11241327
window_start=-0.35,
11251328
window_end=0.55,
1329+
**kwargs
11261330
):
11271331
"""
11281332
import neurokit2 as nk

0 commit comments

Comments
 (0)