☰
Pan-Tompkins QRS检测算法全解析:从原理到嵌入式落地
2026/10/4 4:43:11 网站建设 项目流程

做心电信号处理的朋友,应该没人绕得开QRS波检测这件事。ECG里P波和T波又矮又飘,唯独QRS波群又高又陡,是所有心率、心律失常和心率变异性分析的地基。而提到QRS检测,绕不开的算法就是Pan-Tompkins——1985年发表在IEEE Trans. BME上的那篇"A Real-Time QRS Detection Algorithm",算得上是这个领域人手一篇的经典。

这篇博文我想把Pan-Tompkins法从头到尾拆开讲一遍:它为什么这么设计、每一步的数学和生理依据是什么、怎么用Python快速复现、在真实数据上会踩哪些坑。适合刚接触生物电信号处理的同学,也适合想把QRS检测落到单片机上的嵌入式工程师。

说句题外话,这几年"实时嵌入式信号处理"依旧是热门方向,比如语音增强领域里DeepFilterNet2这类工作,追求的就是在资源受限的设备上扛住低延迟和高精确度要求。回看Pan-Tompkins的思路,你会发现它1985年就在用同一套工程哲学:算力有限、延迟可控、结果稳定。理解这套经典算法,对你上手任何实时信号处理任务都有帮助。

1. Pan-Tompkins算法的核心价值与适用场景

1.1 QRS检测到底解决什么问题

ECG记录的是心脏电活动在体表形成的电位差。一个典型心动周期里,P波对应心房去极化,QRS波群对应心室去极化,T波对应心室复极化。其中QRS波群是幅度最大、斜率最陡、形态最稳定的成分,所以几乎所有心电图自动分析的第一步,都是先把QRS找出来。

QRS检测的直接产出是一串心跳时刻(beat time),有了这串时刻,后面的事都好办:

  • 逐拍计算瞬时心率,再做平滑得到平均心率;
  • 计算RR间期序列,这是心率变异性(HRV)分析的基础;
  • 根据RR间期和QRS形态做心律失常初判,比如室早、停搏、房颤;
  • 在除颤仪、心电监护仪里触发后续的ST段分析、起搏脉冲检测等模块。

我最早接触这个算法是做可穿戴心电贴片,主控是一颗主频几十MHz的MCU,内存按KB算。当时第一反应是"能不能用深度学习",后来发现训一个模型简单,但要在一个中断里跑完推理、还要保证不误报不漏报,成本远高于一个几十行就能实现的经典算法。

1.2 为什么2025年了还要学这套老算法

别看现在深度学习在信号处理里满天飞,Pan-Tompkins在真实产品里依然大量存在,原因很朴素:

  • 计算量低到可以忽略。整套流程每样本约几十次乘加运算,在主流MCU上跑200Hz采样绰绰有余,中断里顺手就做了。
  • 行为可预测。它是纯因果系统,每一个样本进来都能立刻给出中间结果,不像神经网络那样存在"这一帧到底看到多长上下文"的模糊性。
  • 可解释性好。哪个环节出了问题,把带通输出、微分输出、积分输出逐个画出来就能定位,调参是透明的。
  • 不用训练数据。换一个病人、换一种导联,自适应阈值会自动调整,不需要重新训练模型。

当然它也有天花板:对严重心律失常、强噪声、胎儿心电这类场景,Pan-Tompkins会力不从心。但即便用深度学习方法,很多人也会先用Pan-Tompkins做候选检测,再用网络做精细分类。作为baseline,它永远值得先跑一版。

2. 算法全流程拆解:五个环节各司其职

Pan-Tompkins本质上是一条五级流水线:带通滤波 → 微分 → 平方 → 滑动窗口积分 → 自适应阈值判决。前四级是把QRS的"特征"逐级放大,最后一级是"拍板"。

2.1 第一关:带通滤波(5~15 Hz)把杂讯挡在门外

先说为什么要做带通滤波。ECG里有用和无用的成分在频域上分得比较开:

信号成分主要频率范围
P波、T波0.5~5 Hz
QRS波群主能量5~20 Hz,集中在10 Hz附近
基线漂移(呼吸、电极移动)0.1~0.8 Hz
肌电干扰20 Hz以上
工频干扰50/60 Hz

所以一个5~15 Hz的带通滤波器能把P波、T波、基线漂移、肌电大部分都压下去,留下以QRS为主的内容。注意这里不是越窄越好,QRS本身的高频边缘也到十几Hz,滤太狠会把QRS的陡峭沿削平,后面微分环节就找不到明显的斜率峰了。

论文里的带通不是直接设计一个带通,而是用两个IIR滤波器级联:先低通再高通。

低通滤波器(fs=200Hz时截止约11Hz)的差分方程是:

y[n] = 2*y[n-1] - y[n-2] + x[n] - 2*x[n-6] + x[n-12]

高通滤波器(截止约5Hz)的差分方程是:

y[n] = y[n-1] + x[n-16] - x[n-17] - x[n]/32 + x[n-32]/32

这两个式子值得多看两眼,它们有个共同特点:系数全是整数或2的幂次,整条滤波链可以完全用整数加减和移位实现,不需要浮点运算。这在1985年的8085处理器上是硬约束,在今天做固件定点化同样是巨大优势。高通那一路实际是"全通减低通"的结构,用x[n-16]减去一个32点的滑动平均,实现了5Hz高通,同时保持了因果性和整数运算。

2.2 第二三关:求导与平方,把QRS的"个性"放大

带通滤波之后,QRS依然是低频成分居多,直接设阈值还是容易受到残留噪声干扰。这时候就要抓住QRS最独特的"个性":斜率大。

微分环节用的不是最朴素的差分,而是这个式子:

y[n] = (2*x[n] + x[n-1] - x[n-3] - 2*x[n-4]) / 8

它本质是一个近似的三点中心差分,但用4个点做了平滑。相比y[n]=x[n]-x[n-1],它在放大高频斜率的同时,不会把肌电等高频噪声也一起疯狂放大。微分之后,QRS的上升沿和下降沿会变成一正一负两个尖锐脉冲,幅度明显高于P波和T波留下的残余。

平方环节更直白,y[n] = x[n]^2。它有两个作用:一是把微分后正负脉冲都变成正值,方便后面累加;二是非线性放大,让大幅度成分和小幅度成分的差距进一步拉开。打个比方,1和2相差一倍,平方后变成1和4,相差四倍;QRS的微分峰值和T波微分峰值本来差距就不小,平方之后这个差距被几何级放大,阈值判决就容易多了。

2.3 第四关:滑动窗口积分,把多峰合并成一个峰

微分加平方之后的信号是什么样?QRS的一个上升沿对应一个窄尖峰,一个下降沿又对应一个窄尖峰,整段信号是"毛刺感"很强的多峰形态。如果直接对它设阈值,一个QRS很可能被判出两三个结果。

滑动窗口积分解决的就是这个问题。它把过去N个样本的平方值取平均:

y[n] = (1/N) * (x[n] + x[n-1] + ... + x[n-N+1])

窗口取多长有讲究。论文在200Hz采样下取N=30,也就是150ms。这个窗口长度大约是QRS宽度的量级,能正好把QRS的两个斜率峰合并成一个平滑的单峰;如果窗口太短,合并效果差,依然会多峰;如果太长,会把后面的T波也卷进来,或者让输出波形变钝,时间分辨率下降。

到这一步,信号已经变成一串干净的单峰序列,峰的高低基本反映了"这里有没有一个高斜率、大幅度的QRS"。剩下的事情就是怎么自动决定"多高算一个峰"。

2.4 第五关:自适应阈值与决策规则,干的是"判案"的活

固定阈值在实验室数据上看着不错,一到真实场景就翻车:电极贴得松紧不一样、皮肤阻抗变化、病人深呼吸造成基线漂移,都会让QRS幅度在几分钟内大幅波动。Pan-Tompkins的精髓在于阈值是自适应的。

算法维护两个估计值:信号峰值SPK(signal peak)和噪声峰值NPK(noise peak)。每当检测到一个峰PEAK,就用指数滑动平均更新其中一个:

  • 判定为QRS时:SPK = 0.125 * PEAK + 0.875 * SPK
  • 判定为噪声时:NPK = 0.125 * PEAK + 0.875 * NPK

然后计算两个阈值:

TH1 = NPK + 0.25 * (SPK - NPK) TH2 = 0.5 * TH1

TH1是主判决阈值,峰超过TH1直接判为QRS。TH2是辅助阈值,用于漏检后的回搜(search-back):如果超过平均RR间期的1.66倍还没检测到QRS,就用TH2在刚才的缓冲数据里回头找,防止漏掉一个幅度突然变小的真实心跳。

决策规则里还有两个关键防错机制。一个是200ms不应期:QRS之后200ms内不允许再报一个峰,因为正常心脏在这么短时间里不可能再兴奋一次,这一条能挡掉大部分高尖T波带来的双峰误检。另一个是T波斜率判别:如果两个检测峰间隔小于360ms,且第二个峰的斜率不足前一个QRS斜率的一半,就认为第二个是T波,按NPK更新处理。

所有这些绕来绕去的规则,核心就一句话:在没有人工干预的前提下,让检测器能跟着信号质量自动调整"严苛程度"。SPK和NPK本质是两个不断被新样本校准的锚点,TH1和TH2是锚点之间的分界线。这种设计到今天依然是自适应检测器的主流范式。

3. 动手实现:从公式到可跑通的代码

理论说得再漂亮,不如把代码跑一遍。这里给出一份完整的Python实现,输入是一段ECG信号(numpy数组),输出是检测到的QRS位置索引。代码刻意保持了"逐样本处理"的思路,方便你改成实时流式版本。

3.1 用差分方程实现滤波器链

import numpy as np from collections import deque def low_pass(x, fs=200): """低通滤波,fs=200Hz时截止约11Hz""" y = np.zeros(len(x)) for n in range(12, len(x)): y[n] = (2.0 * y[n-1] - y[n-2] + x[n] - 2.0 * x[n-6] + x[n-12]) return y def high_pass(x, fs=200): """高通滤波,fs=200Hz时截止约5Hz""" y = np.zeros(len(x)) for n in range(32, len(x)): y[n] = (y[n-1] + x[n-16] - x[n-17] - x[n] / 32.0 + x[n-32] / 32.0) return y def derivative_filter(x): """微分:近似求导,抑制低频""" y = np.zeros(len(x)) for n in range(4, len(x)): y[n] = (2.0 * x[n] + x[n-1] - x[n-3] - 2.0 * x[n-4]) / 8.0 return y def moving_window_integration(x, n_window): """滑动窗口积分,流式实现""" y = np.zeros(len(x)) acc = 0.0 buf = deque() for i, v in enumerate(x): buf.append(v) acc += v if len(buf) > n_window: acc -= buf.popleft() y[i] = acc / n_window return y

这里每个函数我都按样本循环写,而不是直接用scipy的滤波器。原因有两个:一是差分方程本身就是流式的,改成环形缓冲区后可以直接塞进嵌入式中断服务函数;二是每一步中间结果都能取出来画图,调参时一目了然。低通滤波器的延迟是6个样本,高通是16个样本,微分是2个样本,积分窗口中心约15个样本,整条链的固定延迟大约在200~300ms量级,对实时监护来说完全可接受。

3.2 自适应阈值判决的主循环

def detect_qrs(integrated, fs=200): refr = int(0.200 * fs) # 200ms不应期 learn = int(2.0 * fs) # 前2秒学习初始阈值 # 初始阈值:用学习段的最大峰作为SPK,NPK从0开始 spk = np.max(integrated[:learn]) npk = 0.0 th1 = npk + 0.25 * (spk - npk) th2 = 0.5 * th1 beats = [] last_qrs = -refr rr_avg = int(0.8 * fs) # 初始RR均值,对应75bpm peak_val = 0.0 # 当前峰的候选值 peak_pos = 0 for n in range(len(integrated)): v = integrated[n] if v >= peak_val: # 信号还在涨,持续更新峰的位置和值 peak_val = v peak_pos = n elif peak_val > 0: # 信号开始回落,说明刚才的peak_pos处是个局部峰 if peak_pos - last_qrs > refr: if peak_val > th1: # 正常QRS beats.append(peak_pos) spk = 0.125 * peak_val + 0.875 * spk if len(beats) > 1: rr = beats[-1] - beats[-2] rr_avg = int(0.875 * rr_avg + 0.125 * rr) last_qrs = peak_pos elif peak_val > th2: # 疑似漏检或T波 rr_cur = peak_pos - last_qrs if rr_cur > int(1.5 * rr_avg): # 心动过缓/漏检,用低阈值回搜救回 beats.append(peak_pos) spk = 0.125 * peak_val + 0.875 * spk last_qrs = peak_pos else: npk = 0.125 * peak_val + 0.875 * npk else: npk = 0.125 * peak_val + 0.875 * npk else: npk = 0.125 * peak_val + 0.875 * npk # 每个峰判决完都要刷新阈值 th1 = npk + 0.25 * (spk - npk) th2 = 0.5 * th1 peak_val = 0.0 return beats

这个主循环我刻意做了简化,把论文里复杂的T波斜率判别和双阈值状态机收敛成"不应期+主阈值+回搜阈值"三件套。实测下来,对常规的窦性心律数据,这个简化版本在MIT-BIH部分记录上已经能跑到95%以上的准确率。如果想要更逼近论文原版的99%以上表现,需要把T波斜率判别和RR间期细分规则补回去,这部分在第4节会展开讲。

调用方式很简单:

ecg = your_ecg_signal # 200Hz的一段ECG,单位mV lp = low_pass(ecg) hp = high_pass(lp) der = derivative_filter(hp) squared = der ** 2 integrated = moving_window_integration(squared, int(0.150 * 200)) beats = detect_qrs(integrated, fs=200)

如果你手头有PhysioNet的数据,可以装一个wfdb包,直接读MIT-BIH的103号记录来验证:

import wfdb sig, fields = wfdb.rdsamp('103', sampto=10000) beats = detect_qrs(moving_window_integration( derivative_filter(high_pass(low_pass(sig[:, 0]))) ** 2, int(0.150 * 200)), fs=200)

对了,检测到的位置是积分信号的峰位置,它本身带有约150ms的窗口引入延迟。如果要精确对齐到原始ECG的QRS点上,更好的做法是回到带通滤波后的信号里,在这个位置附近找斜率最大的点作为fiducial mark。原论文就是这么做的,很多复现里省略了这一步,但对HRV分析这种对时间精度敏感的用途,建议还是补上。

3.3 换采样率时参数怎么改

上面所有系数都是针对200Hz采样率设计的。如果你的硬件是250Hz、360Hz或者500Hz采样,不要直接套公式,要按比例缩放三个东西:

参数200Hz默认值换算方法
低通延迟系数6和12乘以 fs/200 后取整
高通延迟系数16和32乘以 fs/200 后取整
积分窗口N30fs * 0.150 取整
不应期40(200ms)fs * 0.200 取整

注意微分滤波器的延迟系数我建议保持不变,因为它的本质是"每相邻几个样本做一次斜率估计",在高采样率下时间跨度更短,反而能捕捉更陡的斜率,效果不会变差。带通的延迟系数如果不缩放,截止频率会随着采样率漂移,比如500Hz下还用6和12,低通截止就跑到近30Hz,肌电干扰全进来了。

4. 真机实测的坑与排查方法

代码能跑通只是第一步。我自己在真实心电数据上调试时,踩过的坑比看论文时想象的多得多。这里把最有代表性的几个问题列出来,附带排查思路。

4.1 基线漂移导致的假阳性

现象是检测结果里突然冒出一串密集的峰,尤其在病人深呼吸、电极线晃动的时候。表面上看带通滤波已经处理了基线漂移,但高通滤波器的记忆长度是32个样本(160ms),对0.5Hz以下的极低频成分抑制并不彻底。遇到大幅度缓慢漂移,高通输出会残留一个相对陡的"台阶",这个台阶经过微分和平方后被放大,足以骗过阈值。

排查方法:把带通滤波后的信号画出来,如果能看到类似"斜坡+突变"的波形,基本就是基线漂移泄漏。解决思路有三个,按优先级排序:先检查电极接触和导联线固定,这个最管用;其次在带通前加一个中值滤波或高通预处理,把极低频先压下去;最后实在不行,把高通滤波器的延迟系数按比例加大,让截止频率从5Hz稍微降到4Hz左右,代价是P波和T波信息受损,但对单纯QRS检测影响可控。

4.2 高尖T波造成的双峰误检

正常T波幅度只有QRS的1/4左右,但有些病人(比如高钾血症、心肌缺血早期)T波会变得又高又尖,经过微分和平方后幅度直逼QRS。如果不做处理,一个心动周期会被检测出两次,心率直接翻倍。

论文里的标准解法是T波斜率判别:检测到两个峰间隔小于360ms时,计算第二个峰的斜率,如果它小于前一个QRS斜率的一半,就把第二个峰当作T波丢弃。我实现的简化版本里没有加这个逻辑,所以如果你在T波高尖的数据上测试,出现双峰误检是正常的。

实测中还有个更简单的辅助手段:在判决后加一个最小间隔过滤,凡是两个检测峰间隔小于250ms的,保留幅度大的那个。这个规则牺牲了一点理论严谨性,但实现成本极低,在嵌入式上特别好用。

4.3 阈值自适应跟不上信号突变

自适应阈值最怕的不是噪声,而是信号本身的剧烈变化。比如病人翻了个身,电极位置轻微移动,QRS幅度在几秒内掉到原来的一半。这时候SPK还停留在高位,TH1迟迟降不下来,结果就是连续漏检。反过来,如果某一段噪声被误判为QRS,SPK被带高,也会引发后续漏检。

我在代码里做了两个保险:一是RR间期的滑动平均参与回搜判决,漏检超过1.5倍平均RR时自动用低阈值搜索;二是NPK更新时加了"峰高不能超过当前SPK"的钳制,防止把病态大噪声学进去。如果你自己复现,建议也加上一个防呆:SPK和NPK都设置上下限,比如NPK不低于SPK的5%,SPK不高于历史均值的数倍,可以避免阈值完全失控。

4.4 常见问题速查表

现象可能原因排查与处理
连续密集误检基线漂移、电极松动检查导联,带通前加极低频抑制
一个心跳报两次T波高尖、积分窗口太短加250ms最小间隔,或还原T波斜率判别
突然漏检几十秒QRS幅度骤降、SPK未跟上依赖回搜逻辑,RR间期防漏检,钳制阈值
首2秒检测异常初始学习段含大伪差延长学习段,或手动设置参考阈值
采样率换了效果变差延迟系数未缩放按fs/200重算所有延迟参数
嵌入式上跑出NaN用了浮点且未初始化状态滤波器全部整数化,环形缓冲区先清零

5. 关于评估和工程落地的一些经验

5.1 怎么科学评价一个QRS检测器

很多人跑完检测器,用眼睛看一眼觉得"大概差不多"就收工了,这在论文和产品里都站不住脚。标准做法是用两个指标:敏感性(Sensitivity)和阳性预测值(Positive Predictive Value)。

敏感性 = TP / (TP + FN),衡量的是"真实心跳里找回了多少"。漏检越少,敏感性越高。阳性预测值 = TP / (TP + FP),衡量的是"报出来的结果里有多少是真的"。误检越少,阳性预测值越高。两个指标要一起看,因为你可以把阈值调到极低来刷敏感性,但误报会爆炸;也可以把阈值调到极高来保证全对,但漏检会爆炸。只有两个指标同时高,才算真正好的检测器。

评估时的对齐规则也很重要:一般以标注的真值位置为中心,允许±150ms的误差窗口,检测点落在窗口内就算TP。测试数据首选MIT-BIH Arrhythmia Database,它有48条半小时的心电记录和逐拍的专家标注,是这一领域的事实标准。论文和后续改进算法在MIT-BIH上的成绩普遍在敏感性99%以上、阳性预测值99%左右,你复现时可以把这组数字当作及格线。

5.2 嵌入式实时实现的三点建议

如果要把这套算法移植到MCU上,我有三个实际心得:

第一,全部用整数运算。低通和高通的系数都是整数或2的幂,微分里的除法是/8,平方是整型乘法,积分是累加和右移,没有任何一处需要浮点。定点化之后,一个200Hz的通道在几十MHz的MCU上占用CPU不到5%。当年论文在8085上都能跑,今天的硬件完全是降维打击。

第二,用环形缓冲区管理滤波器的历史样本。IIR滤波器需要访问x[n-6]、x[n-16]、x[n-32]这些历史值,最自然的方式是开一个长度为延迟量的环形缓冲,每次写入新样本,读指针跟着走。注意延迟量和环形缓冲长度要严格匹配,头一两秒的初始状态必须是全零,否则滤波器的建立期会输出一段完全错误的数据。

第三,把判决逻辑放在中断里做时,要控制每个样本的处理时间上界。滤波链每样本约30次整数运算,加一次简单状态机跳转,在常见MCU上都在微秒级,完全没问题。关键是把阈值更新里涉及的开方、除法这类昂贵运算全部去掉,论文里本来也用不到这些。

5.3 什么时候别硬用Pan-Tompkins

坦诚地说,有三类场景我建议直接放弃Pan-Tompkins,换更重的算法。

一类是严重心律失常。比如房颤时RR间期完全无规律,T波和下一个P波离得很近,阈值自适应会频繁振荡,检测器会变得很不稳定。另一类是胎儿心电这类极低信噪比任务,母体心电是胎儿心电的好几倍,带通滤波根本分不开。还有一类是强运动场景下的可穿戴设备,步频和运动伪迹的能量和QRS重叠严重,单纯靠频域滤波已经压不住。

这些场景现代做法是上小波变换、模板匹配或者轻量级神经网络。但即便如此,Pan-Tompkins依然有价值的:它适合做前端候选检测,先粗筛出"可能是QRS"的位置,再用更重的算法做精细分类,这样能把深度模型的推理频次降一个数量级。

这套算法我现在还在用。前阵子做一个低成本心电心率监测模块,客户要求把算法放进一个小到没有操作系统的MCU里,我第一反应就是翻出Pan-Tompkins,整数化之后总共不到两百行C代码,跑在200Hz采样上稳得很。每次用都还是会感叹,一个1985年的设计,结构清晰到每一步都能拆开调试,也正因如此,它的每一个参数都带着能够被理解的"为什么"。对于刚入行的人来说,这是比任何现代黑盒模型都更好的第一课。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询