@@ -49,8 +49,13 @@ def ecg_delineate(
49
49
sampling_rate : int
50
50
The sampling frequency of ``ecg_signal`` (in Hz, i.e., samples/second). Defaults to 1000.
51
51
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.
54
59
show : bool
55
60
If ``True``, will return a plot to visualizing the delineated waves information.
56
61
show_type: str
@@ -60,7 +65,16 @@ def ecg_delineate(
60
65
Defaults to ``False``. If ``True``, replaces the delineated features with ``np.nan`` if its
61
66
standardized distance from R-peaks is more than 3.
62
67
**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.
64
78
65
79
Returns
66
80
-------
@@ -71,7 +85,7 @@ def ecg_delineate(
71
85
``"ECG_Q_Peaks"``, ``"ECG_S_Peaks"``, ``"ECG_T_Peaks"``, ``"ECG_P_Onsets"``,
72
86
``"ECG_T_Offsets"``, respectively.
73
87
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
75
89
samples at which QRS-onsets and QRS-offsets occur, accessible with the key
76
90
``"ECG_P_Peaks"``, ``"ECG_T_Peaks"``, ``"ECG_P_Onsets"``, ``"ECG_P_Offsets"``,
77
91
``"ECG_Q_Peaks"``, ``"ECG_S_Peaks"``, ``"ECG_T_Onsets"``, ``"ECG_T_Offsets"``,
@@ -114,6 +128,8 @@ def ecg_delineate(
114
128
- Martínez, J. P., Almeida, R., Olmos, S., Rocha, A. P., & Laguna, P. (2004). A wavelet-based
115
129
ECG delineator: evaluation on standard databases. IEEE Transactions on biomedical engineering,
116
130
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.
117
133
118
134
"""
119
135
# Sanitize input for ecg_cleaned
@@ -162,10 +178,12 @@ def ecg_delineate(
162
178
)
163
179
elif method in ["dwt" , "discrete wavelet transform" ]:
164
180
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 )
165
183
166
184
else :
167
185
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', "
169
187
"'cwt' or 'dwt'."
170
188
)
171
189
@@ -739,6 +757,195 @@ def _ecg_delineator_cwt(ecg, rpeaks=None, sampling_rate=1000):
739
757
}
740
758
741
759
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
+
742
949
# Internals
743
950
# ---------------------
744
951
@@ -798,11 +1005,7 @@ def _onset_offset_delineator(ecg, peaks, peak_type="rpeaks", sampling_rate=1000)
798
1005
epsilon_onset = 0.25 * wt_peaks_data ["peak_heights" ][- 1 ]
799
1006
leftbase = wt_peaks_data ["left_bases" ][- 1 ] + index_peak - half_wave_width
800
1007
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
806
1009
elif peak_type in ["tpeaks" , "ppeaks" ]:
807
1010
candidate_onsets = (
808
1011
np .where (- cwtmatr [4 , nfirst - 100 : nfirst ] < epsilon_onset )[0 ]
@@ -1123,6 +1326,7 @@ def _ecg_delineate_plot(
1123
1326
sampling_rate = 1000 ,
1124
1327
window_start = - 0.35 ,
1125
1328
window_end = 0.55 ,
1329
+ ** kwargs
1126
1330
):
1127
1331
"""
1128
1332
import neurokit2 as nk
0 commit comments