轻量级生理信号处理工具BioSPPy中的ECG(2)

1 简介

研究ECG算法,除了那些经典变换,最常见的应该就是R波的识别了(或者说QRS波识别,不同人叫法可能不太一样)。这个我在之前的博客中有讲解过,是一个我自己用matlab实现的R波识别算法(该节最后一部分给出了网址)。事实上,BiosPPy工具包中有关ECG的处理基本都是围绕R波检测展开的,它提供了5个不同的R波检测算法如下:

算法 文献来源
biosppy.signals.ecg.christov_segmenter Ivaylo I. Christov, “Real time electrocardiogram QRS detection using combined adaptive threshold”, BioMedical Engineering OnLine 2004, vol. 3:28, 2004
biosppy.signals.ecg.hamilton_segmenter P.S. Hamilton, “Open Source ECG Analysis Software Documentation”, E.P.Limited, 2002
biosppy.signals.ecg.engzee_segmenter A. Lourenco, H. Silva, P. Leite, R. Lourenco and A. Fred, “Real Time Electrocardiogram Segmentation for Finger Based ECG Biometrics”, BIOSIGNALS 2012, pp. 49-54, 2012
biosppy.signals.ecg.gamboa_segmenter 未知
biosppy.signals.ecg.ssf_segmenter 未知

这里我用两种颜色标记了5种算法。其中,红色代表简单接口算法,即接口参数只有两个:信号序列本身和采样率;蓝色代表复杂接口算法,即接口参数除了以上两者外,还有其他的可选参数。

2 R波检测算法

2.1 简单接口算法

根据上面的说明,简单接口算法包括两个,christov_segmenter和hamilton_segmenter。它们的用法是相同的,仅需提供ECG信号序列和它对应的采样率,以christov_segmenter为例,其接口的具体形式为:

biosppy.signals.ecg.christov_segmenter(signal=None, sampling_rate=1000.0)
'''
	入参: * signal(array):ECG 信号.
    	  * sampling_rate(int,float,可选参数):信号采样率,默认为1000.0 Hz.
    返回值:rpeaks(array): R波波峰位置索引.
'''

我们尝试使用一条样例ECG信号,MIT-BIH数据库中的117信号的前30s信号,来使用christov_segmenter,采样率为360 Hz,即信号总长度为 360 * 30 = 10800个采样点:

import sys
import time
import logging
import numpy as np
from biosppy.signals import ecg
from biosppy.storage import load_txt
import matplotlib.pyplot as plt

# 日志打印格式设定
logging.basicConfig(level = logging.DEBUG,format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

data_path = "./data/ecg_records_117.txt"  # 信号txt文件,一行一个浮点数代表幅值,注意路径

signal, mdata = load_txt(data_path) # 使用biosppy.storage提供的load_txt函数载入信号
logging.info("--------------------------------------------------")
logging.info("载入信号-%s, 长度 = %d " % (data_path, len(signal)))
fs = 360  # 采样率为360 Hz
logging.info("调用 christov_segmenter 进行R波检测 ...")
tic = time.time()
rpeaks = ecg.christov_segmenter(signal, sampling_rate=fs) # 调用christov_segmenter
toc = time.time()
logging.info("完成. 用时: %f 秒. " % (toc - tic))

日志打印为:

2020-01-02 23:23:38,184 - root - INFO - --------------------------------------------------
2020-01-02 23:23:38,184 - root - INFO - 载入信号-./data/ecg_records_117.txt, 长度 = 10800 
2020-01-02 23:23:38,184 - root - INFO - 调用 christov_segmenter 进行R波检测 ...
2020-01-02 23:23:38,416 - root - INFO - 完成. 用时: 0.232379 秒. 

一切似乎很顺利,事实也确实如此,但这里却有一个细节需要注意,那就是christov_segmenter返回的并不是一个很直观的,R波位置依次排好的numpy array:

logging.info("直接调用 christov_segmenter 返回类型为 " + str(type(rpeaks)))

输出为:

2020-01-02 23:23:38,416 - root - INFO - 直接调用 christov_segmenter 返回类型为 <class 'biosppy.utils.ReturnTuple'>

可以看出,我们所得到的结果是一个biosppy.utils.ReturnTuple的对象。从名字中看得出来,这是一个biosppy的内置类(https://biosppy.readthedocs.io/en/stable/biosppy.html#biosppy-utils)。因为这个不是我们这里的重点,所以不细讲,有兴趣的可参照上面的网址。这个内置类包含两个方法,分别是as_dict(),能将该内置类对象转化为Python原生类——有序字典OrderedDict;还有keys(),可返回该内置类对象中的键值。而我们所关心的,是如何从这个对象中获取到R波位置索引序列,有两种方法:

# 得到R波位置序列的方法:
# 1) 取返回值的第1项:
logging.info("使用第1种方式取R波位置序列 ... ")
rpeaks_indices_1 = rpeaks[0]
logging.info("完成. 结果类型为 " + str(type(rpeaks_indices_1)))
# 2) 调用ReturnTuple的as_dict()方法,得到Python有序字典(OrderedDict)类型
logging.info("使用第2种方式取R波位置序列 ... ")
rpeaks_indices_2 = rpeaks.as_dict()
#    然后使用说明文档中的参数名(这里是rpeaks)作为key取值。
rpeaks_indices_2 = rpeaks_indices_2["rpeaks"]
logging.info("完成. 结果类型为 " + str(type(rpeaks_indices_2)))

检验一下这两种方式获得的结果是否相同:

# 检验两种方法得到的结果是否相同:
    check_sum = np.sum(rpeaks_indices_1 == rpeaks_indices_2)
    if check_sum == len(rpeaks_indices_1):
        logging.info("两种取值方式结果相同 ... ")
    else:
        logging.info("两种取值方式结果不同,退出 ...")
        sys.exit(1)

日志输出为:

2020-01-02 23:41:41,600 - root - INFO - 使用第1种方式取R波位置序列 ... 
2020-01-02 23:41:41,600 - root - INFO - 完成. 结果类型为 <class 'numpy.ndarray'>
2020-01-02 23:41:41,600 - root - INFO - 使用第2种方式取R波位置序列 ... 
2020-01-02 23:41:41,600 - root - INFO - 完成. 结果类型为 <class 'numpy.ndarray'>
2020-01-02 23:41:41,600 - root - INFO - 两种取值方式结果相同 ... 

这样就得到了预期中的R波位置序列。另外如上所述,hamilton_segmenter的接口与christov_segmenter完全一致:

biosppy.signals.ecg.hamilton_segmenter(signal=None, sampling_rate=1000.0)
'''
	入参: * signal(array):ECG 信号.
    	  * sampling_rate(int,float,可选参数):信号采样率,默认为1000.0 Hz.
    返回值:rpeaks(array): R波波峰位置索引.
'''

在同样的条件下,使用接口一致的hamilton_segmenter检测R波:

logging.info("调用接口一致的 hamilton_segmenter 进行R波检测")
tic = time.time()
rpeaks = ecg.hamilton_segmenter(signal, sampling_rate=fs)
toc = time.time()
logging.info("完成. 用时: %f 秒. " % (toc - tic))
rpeaks_indices_3 = rpeaks.as_dict()["rpeaks"]

日志输出为:

2020-01-02 23:41:41,600 - root - INFO - 调用接口一致的 hamilton_segmenter 进行R波检测
2020-01-02 23:41:41,619 - root - INFO - 完成. 用时: 0.017998 秒. 

似乎hamilton_segmenter比christov_segmenter快不少,同样的信号用时仅为christov_segmenter的7.7%。这里我们把信号前10s的波形(即前3600点)和两种算法检测到的R波峰值点绘制一下,主观上看看效果:

# 绘波形图和R波位置
num_plot_samples = 3600
logging.info("绘制波形图和检测的R波位置 ...")
sig_plot = signal[:num_plot_samples]
rpeaks_plot_1 = rpeaks_indices_1[rpeaks_indices_1 <= num_plot_samples]
plt.figure()
plt.plot(sig_plot, "g", label="ECG")
plt.grid(True)
plt.plot(rpeaks_plot_1, sig_plot[rpeaks_plot_1], "ro", label="christov_segmenter")
rpeaks_plot_3 = rpeaks_indices_3[rpeaks_indices_3 <= num_plot_samples]
plt.plot(rpeaks_plot_3, sig_plot[rpeaks_plot_3], "b^", label="hamilton_segmenter")
plt.legend()
plt.show()
logging.info("完成.")

得到图如下:

figure_1.png

可以看出,两种算法的检测效果还是有不同的。相比之下,christov_segmenter对于R波的定位较准,而hamilton_segmenter有一部分将S波定位为了R波,不过它的优势在于耗时短。

2.2 复杂接口算法

除了上面提到的这两个算法,另外的三个算法因为其方法接口中都含有其他的可选参数,因此我们这里称为“复杂接口算法”。这些可选参数,往往是跟算法原理息息相关,而且由经验设定。原理的探讨已经超出了这个教程的范围,不过好在这几个算法的可选参数都有默认值,如果没有特别需要的话按默认就好,其他的就跟上面的两个算法一致了。这里把这几个算法的接口文档翻译一下供参考:

biosppy.signals.ecg.engzee_segmenter(signal=None, sampling_rate=1000.0, threshold=0.48)
'''
	入参: * signal(array):ECG 信号.
    	  * sampling_rate(int,float,可选参数):信号采样率,默认为1000.0 Hz.
          * threshold(float, 可选参数): 检测阈值,默认为0.48.
    返回值:rpeaks(array): R波波峰位置索引.
'''
biosppy.signals.ecg.gamboa_segmenter(signal=None, sampling_rate=1000.0, tol=0.002)
'''
	入参: * signal(array):ECG 信号.
    	  * sampling_rate(int,float,可选参数):信号采样率,默认为1000.0 Hz.
          * tol(float, 可选参数): 宽容因数,默认为0.002.
    返回值:rpeaks(array): R波波峰位置索引.
'''
biosppy.signals.ecg.ssf_segmenter(signal=None, sampling_rate=1000.0, threshold=20, 
                                  before=0.03, after=0.01)
'''
	基于Slope Sum Function(SSF) 的R波检测算法
	入参: * signal(array):ECG 信号.
    	  * sampling_rate(int,float,可选参数):信号采样率,默认为1000.0 Hz.
          * threshold(float, 可选参数): SSF阈值,默认为20.
          * before(float, 可选参数): 候选R峰之前的搜索窗口大小.
          * after(float, 可选参数): 候选R峰之后的搜索窗口大小.
    返回值:rpeaks(array): R波波峰位置索引.
'''

其中,engzee_segmenter我找到了相关的论文,另外两个原始的英文文档中也没有给出。上面两个简单接口算法按照作者给出的论文题目也是很容易的就可以找到原文。如果有兴趣的话可以读一下英文原文深入研究下原理。

3 心拍截取

如果你看过一些ECG算法方面的论文,就会发现很多方案都是基于心拍来做的,而R波检测的目的,最终也往往落脚到心拍的截取上。通过以上的这些R波检测算法,我们可以得到R峰在给定信号中的具体位置。那么根据这些位置,前后各延伸一定的长度,就能粗略地把心拍截取出来,以供后面的算法使用。
这是一个很基本的操作,而BiosPPy已经帮我们实现了:

biosppy.signals.ecg.extract_heartbeats(signal=None, rpeaks=None, sampling_rate=1000.0, 
                                       before=0.2, after=0.4)
'''
	入参: * signal(array):ECG 信号.
    	  * rpeaks(array): R峰位置索引.
    	  * sampling_rate(int,float,可选参数):信号采样率,默认为1000.0 Hz.
          * before(float, 可选参数): R峰之前心拍所包括的窗口大小,默认为0.2s.
          * after(float, 可选参数): R峰之后心拍所包括的窗口大小,默认为0.4s.
    
    返回:* templates(array):心拍集合,为m*n的二维数组,m为心拍个数,n为心拍长度(n个采样点).
          * rpeaks(array): 所截取的心拍所对应的R峰位置序列.
'''

根据上面的教程,我们尝试使用hamilton_segmenter进行R波检测,样例信号来自MIT-BIH数据库119信号前30s:

import time
import logging
import numpy as np
from biosppy.signals import ecg
from biosppy.storage import load_txt
import matplotlib.pyplot as plt

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

data_path = "./data/ecg_records_119.txt"
signal, mdata = load_txt(data_path)
logging.info("--------------------------------------------------")
logging.info("载入信号-%s, 长度 = %d " % (data_path, len(signal)))
fs = 360  # 信号采样率 360 Hz
logging.info("调用 hamilton_segmenter 进行R波检测 ...")
tic = time.time()
rpeaks = ecg.hamilton_segmenter(signal, sampling_rate=fs)
toc = time.time()
logging.info("完成. 用时: %f 秒. " % (toc - tic))
rpeaks = rpeaks[0]

输出:

2020-01-03 23:37:32,649 - root - INFO - --------------------------------------------------
2020-01-03 23:37:32,649 - root - INFO - 载入信号-./data/ecg_records_119.txt, 长度 = 10800 
2020-01-03 23:37:32,649 - root - INFO - 调用 hamilton_segmenter 进行R波检测 ...
2020-01-03 23:37:32,669 - root - INFO - 完成. 用时: 0.019946 秒. 

得到R峰位置后,我们可以顺便算个平均心率:

heart_rate = 60 / (np.mean(np.diff(rpeaks)) / fs)
logging.info("平均心率: %.3f / 分钟." % (heart_rate))

np.diff 计算相邻R峰之间的距离分别有多少个采样点,np.mean求平均后,除以采样率,将单位转化为秒,然后计算60秒内可以有多少个RR间期作为心率。得到:

2020-01-03 23:44:18,420 - root - INFO - 平均心率: 66.290 / 分钟.

截取心拍:

win_before = 0.2
win_after = 0.4
logging.info("根据R波位置截取心拍, 心拍前窗口:%.3f 秒 ~ 心拍后窗口:%.3f 秒 ..." \
             % (win_before, win_after))
tic = time.time()
beats, rpeaks_beats = ecg.extract_heartbeats(signal, rpeaks, fs, win_before, win_after)
toc = time.time()
logging.info("完成. 用时: %f 秒." % (toc - tic))
logging.info("共截取到 %d 个心拍, 每个心拍长度为 %d 个采样点" % \
             (beats.shape[0], beats.shape[1]))

输出:

2020-01-03 23:44:18,420 - root - INFO - 根据R波位置截取心拍, 心拍前窗口:0.200 秒 ~ 心拍后窗口:0.400 秒 ...
2020-01-03 23:44:18,420 - root - INFO - 完成. 用时: 0.000000 秒.
2020-01-03 23:44:18,420 - root - INFO - 共截取到 32 个心拍, 每个心拍长度为 216 个采样点

当然,我们也可以把这些心拍画出来看看:

plt.figure()
plt.grid(True)
for i in range(beats.shape[0]):
    plt.plot(beats[i])
plt.show()

可以绘制图形:
figure_2.png
可以比较明显的看出来,样例信号中有两种形态的心拍,一种是正常,一种是室性早搏。

4 总结

这一部分讲述了BiosPPy工具包的R波检测和心拍截取,这也是这个轻量化工具包ECG处理的核心了。这里主要关注的是怎么用,对于原理未做过多探讨,有兴趣的可以参考第一部分中给出的论文,或者我之前的博客。另外,需要注意的是,BiosPPy给出的这几个R波检测算法实现不一定特别准确,实际应用中要根据自己的需要,多长个心眼看看使用了这个算法能不能真正达到自己的预期,不要盲目地相信工具包里的实现
本篇论文中的所有代码已开源于本人的github上,下面的附录中有地址。如果觉得有帮助,给个star哦!

5 附录

[1] 本人之前的R波检测博客:https://blog.csdn.net/qq_15746879/article/details/80340671
[2] 开源github地址: 
https://github.com/Aiwiscal/ECG-ML-DL-Algorithm-Python/blob/master/rpeak_seg_simple_v1.0.py
https://github.com/Aiwiscal/ECG-ML-DL-Algorithm-Python/blob/master/extract_beats_v1.0.py

发布了30 篇原创文章 · 获赞 205 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/qq_15746879/article/details/103899668