def __init__(self, fs=250, smoothing_win=20): """Real-time heart Rate Estimator class This class provides the tools for heart rate estimation. It basically detects R-peaks in ECG signal using the method explained in Hamilton 2002 [2]. Args: fs (int): Sampling frequency smoothing_win (int): Length of smoothing window References: [1] Hamilton, P. S. (2002). Open source ECG analysis software documentation. Computers in cardiology, 2002. [2] Hamilton, P. S., & Tompkins, W. J. (1986). Quantitative investigation of QRS detection rules using the MIT/BIH arrhythmia database. IEEE transactions on biomedical engineering. """ self.fs = fs self.threshold = .35 # Generally between 0.3125 and 0.475 self.ns200ms = int(self.fs * .2) self.r_peaks_buffer = [(0., 0.)] self.noise_peaks_buffer = [(0., 0., 0.)] self.prev_samples = np.zeros(smoothing_win) self.prev_diff_samples = np.zeros(smoothing_win) self.prev_times = np.zeros(smoothing_win) self.prev_max_slope = 0 self.bp_filter = ExGFilter(cutoff_freq=(1, 30), filter_type='bandpass', s_rate=fs, n_chan=1, order=3) self.hamming_window = signal.windows.hamming(smoothing_win, sym=True) self.hamming_window /= self.hamming_window.sum()
def _add_filters(self): bp_freq = self._device_info['sampling_rate'] / 4 - 1.5, \ self._device_info['sampling_rate'] / 4 + 1.5 noise_freq = self._device_info['sampling_rate'] / 4 + 2.5, \ self._device_info['sampling_rate'] / 4 + 5.5 self._filters['notch'] = ExGFilter(cutoff_freq=self._notch_freq, filter_type='notch', s_rate=self._device_info['sampling_rate'], n_chan=self._device_info['adc_mask'].count(1)) self._filters['demodulation'] = ExGFilter(cutoff_freq=bp_freq, filter_type='bandpass', s_rate=self._device_info['sampling_rate'], n_chan=self._device_info['adc_mask'].count(1)) self._filters['base_noise'] = ExGFilter(cutoff_freq=noise_freq, filter_type='bandpass', s_rate=self._device_info['sampling_rate'], n_chan=self._device_info['adc_mask'].count(1))
def add_filter(self, cutoff_freq, filter_type): """Add filter to the stream Args: cutoff_freq (Union[float, tuple]): Cut-off frequency (frequencies) for the filter filter_type (str): Filter type ['bandpass', 'lowpass', 'highpass', 'notch'] """ while not self.device_info: print('Waiting for device info packet...') time.sleep(.2) self.filters.append(ExGFilter(cutoff_freq=cutoff_freq, filter_type=filter_type, s_rate=self.device_info['sampling_rate'], n_chan=self.device_info['adc_mask'].count(1)))
def add_filter(self, cutoff_freq, filter_type): """Add filter to the stream Args: cutoff_freq (Union[float, tuple]): Cut-off frequency (frequencies) for the filter filter_type (str): Filter type ['bandpass', 'lowpass', 'highpass', 'notch'] """ logger.info( f"Adding a {filter_type} filter with cut-off freqs of {cutoff_freq}." ) while not self.device_info: logger.warning( 'No device info is available. Waiting for device info packet...' ) time.sleep(.2) self.filters.append( ExGFilter(cutoff_freq=cutoff_freq, filter_type=filter_type, s_rate=self.device_info['sampling_rate'], n_chan=self.device_info['adc_mask'].count(1)))
class HeartRateEstimator: def __init__(self, fs=250, smoothing_win=20): """Real-time heart Rate Estimator class This class provides the tools for heart rate estimation. It basically detects R-peaks in ECG signal using the method explained in Hamilton 2002 [2]. Args: fs (int): Sampling frequency smoothing_win (int): Length of smoothing window References: [1] Hamilton, P. S. (2002). Open source ECG analysis software documentation. Computers in cardiology, 2002. [2] Hamilton, P. S., & Tompkins, W. J. (1986). Quantitative investigation of QRS detection rules using the MIT/BIH arrhythmia database. IEEE transactions on biomedical engineering. """ self.fs = fs self.threshold = .35 # Generally between 0.3125 and 0.475 self.ns200ms = int(self.fs * .2) self.r_peaks_buffer = [(0., 0.)] self.noise_peaks_buffer = [(0., 0., 0.)] self.prev_samples = np.zeros(smoothing_win) self.prev_diff_samples = np.zeros(smoothing_win) self.prev_times = np.zeros(smoothing_win) self.prev_max_slope = 0 self.bp_filter = ExGFilter(cutoff_freq=(1, 30), filter_type='bandpass', s_rate=fs, n_chan=1, order=3) self.hamming_window = signal.windows.hamming(smoothing_win, sym=True) self.hamming_window /= self.hamming_window.sum() @property def average_noise_peak(self): return np.mean([item[0] for item in self.noise_peaks_buffer]) @property def average_qrs_peak(self): return np.mean([item[0] for item in self.r_peaks_buffer]) @property def decision_threshold(self): return self.average_noise_peak + self.threshold * ( self.average_qrs_peak - self.average_noise_peak) @property def average_rr_interval(self): if len(self.r_peaks_buffer) < 7: return 1. return np.mean(np.diff([item[1] for item in self.r_peaks_buffer])) @property def heart_rate(self): if len(self.r_peaks_buffer) < 7: logger.warning('Few peaks to get heart rate! Noisy signal!') return 'NA' else: r_times = [item[1] for item in self.r_peaks_buffer] rr_intervals = np.diff(r_times, 1) if True in (rr_intervals > 3.): logger.warning('Missing peaks! Noisy signal!') return 'NA' else: estimated_heart_rate = int(1. / np.mean(rr_intervals) * 60) if estimated_heart_rate > 140 or estimated_heart_rate < 40: logger.warning( 'Estimated heart rate <40 or >140! Potentially due to noisy signal!' ) estimated_heart_rate = 'NA' return estimated_heart_rate def _push_r_peak(self, val, time): self.r_peaks_buffer.append((val, time)) if len(self.r_peaks_buffer) > 8: self.r_peaks_buffer.pop(0) def _push_noise_peak(self, val, peak_idx, peak_time): self.noise_peaks_buffer.append((val, peak_idx, peak_time)) if len(self.noise_peaks_buffer) > 8: self.noise_peaks_buffer.pop(0) def estimate(self, ecg_sig, time_vector): """ Detection of R-peaks Args: time_vector (np.array): One-dimensional time vector ecg_sig (np.array): One-dimensional ECG signal Returns: List of detected peaks indices """ assert len(ecg_sig.shape) == 1, "Signal must be a vector" # Preprocessing ecg_filtered = self.bp_filter.apply(ecg_sig).squeeze() ecg_sig = np.concatenate((self.prev_samples, ecg_sig)) sig_diff = np.diff(ecg_filtered, 1) sig_abs_diff = np.abs(sig_diff) sig_smoothed = signal.convolve(np.concatenate( (self.prev_diff_samples, sig_abs_diff)), self.hamming_window, mode='same', method='auto')[:len(ecg_filtered)] time_vector = np.concatenate((self.prev_times, time_vector)) self.prev_samples = ecg_sig[-len(self.hamming_window):] self.prev_diff_samples = sig_abs_diff[-len(self.hamming_window):] self.prev_times = time_vector[-len(self.hamming_window):] peaks_idx_list, _ = signal.find_peaks(sig_smoothed) peaks_val_list = sig_smoothed[peaks_idx_list] peaks_time_list = time_vector[peaks_idx_list] detected_peaks_idx = [] detected_peaks_time = [] detected_peaks_val = [] # Decision rules by Hamilton 2002 [1] for peak_idx, peak_val, peak_time in zip(peaks_idx_list, peaks_val_list, peaks_time_list): # 1- Ignore all peaks that precede or follow larger peaks by less than 200 ms. peaks_in_lim = [ a and b and c for a, b, c in zip(( (peak_idx - self.ns200ms) < peaks_idx_list), ( (peak_idx + self.ns200ms) > peaks_idx_list), ( peak_idx != peaks_idx_list)) ] if True in (peak_val < peaks_val_list[peaks_in_lim]): continue # 2- If a peak occurs, check to see whether the ECG signal contained both positive and negative slopes. # TODO: Find a better way of checking this. # if peak_idx == 0: # continue # elif peak_idx < 10: # n_sample = peak_idx # else: # n_sample = 10 # The current n_sample leads to missing some R-peaks as it may have wider/thinner width. # slopes = np.diff(ecg_sig[peak_idx-n_sample:peak_idx]) # if slopes[0] * slopes[-1] >= 0: # continue # check missing peak self.check_missing_peak(peak_time, peak_idx, detected_peaks_idx, ecg_sig, time_vector) # 3- If the peak occurred within 360 ms of a previous detection and had a maximum slope less than half the # maximum slope of the previous detection assume it is a T-wave if (peak_time - self.r_peaks_buffer[-1][1]) < .36: if peak_idx < 15: st_idx = 0 else: st_idx = peak_idx - 15 if (peak_idx + 15) > (len(ecg_sig) - 1): end_idx = len(ecg_sig) - 1 else: end_idx = peak_idx + 15 curr_max_slope = np.abs(np.diff(ecg_sig[st_idx:end_idx])).max() if curr_max_slope < (.5 * self.prev_max_slope): continue # 4- If the peak is larger than the detection threshold call it a QRS complex, otherwise call it noise. if peak_idx < 25: st_idx = 0 else: st_idx = peak_idx - 25 pval = peak_val # ecg_sig[st_idx:peak_idx].max() if pval > self.decision_threshold: temp_idx = st_idx + np.argmax(ecg_sig[st_idx:peak_idx + 1]) temp_time = time_vector[temp_idx] detected_peaks_idx.append(temp_idx) detected_peaks_val.append(ecg_sig[st_idx:peak_idx + 1].max()) detected_peaks_time.append(temp_time) self._push_r_peak(pval, temp_time) if peak_idx < 25: st_idx = 0 else: st_idx = peak_idx - 25 self.prev_max_slope = np.abs( np.diff(ecg_sig[st_idx:peak_idx + 25])).max() else: self._push_noise_peak(pval, peak_idx, peak_time) # TODO: Check lead inversion! # Check for two close peaks occurrence_time = [item[1] for item in self.r_peaks_buffer] close_idx = (np.diff(np.array(occurrence_time), 1) < .05) if (True in close_idx) and len(detected_peaks_idx) > 0: del detected_peaks_time[0] del detected_peaks_val[0] return detected_peaks_time, detected_peaks_val def check_missing_peak(self, peak_time, peak_idx, detected_peaks_idx, ecg_sig, time_vector): # 5- If an interval equal to 1.5 times the average R-to-R interval has elapsed since the most recent # detection, within that interval there was a peak that was larger than half the detection threshold and # the peak followed the preceding detection by at least 360 ms, classify that peak as a QRS complex. if (peak_time - self.r_peaks_buffer[-1][1]) > (1.4 * self.average_rr_interval): last_noise_val, last_noise_idx, last_noise_time = self.noise_peaks_buffer[ -1] if last_noise_val > (.5 * self.decision_threshold): if (last_noise_time - self.r_peaks_buffer[-1][1]) > .36: self.noise_peaks_buffer.pop(-1) if peak_idx > last_noise_idx: if last_noise_idx < 20: st_idx = 0 else: st_idx = last_noise_idx - 20 detected_peaks_idx.append( st_idx + np.argmax(ecg_sig[st_idx:peak_idx])) self._push_r_peak(last_noise_val, time_vector[detected_peaks_idx[-1]]) if peak_idx < 25: st_idx = 0 else: st_idx = peak_idx - 25 self.prev_max_slope = np.abs( np.diff(ecg_sig[st_idx:peak_idx + 25])).max() else: # The peak is in the previous chunk # TODO: return a negative index for it! pass