ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python心电算法实战:从滤波到R波检测与心率计算

Python心电算法实战:从滤波到R波检测与心率计算

简介:面向心电信号处理与算法研究的Python项目,覆盖滤波、R波识别、心率计算、特征提取、心律失常分类、房颤与室颤室速识别等核心环节,并内置了可视化模块与独立测试工程。项目借助SciPy、PyWavelets、scikit-learn等库完成数字滤波、小波变换R波定位以及机器学习模型训练,适合生物医学工程学习者、算法工程师和需要快速验证ECG算法的研究者。资源包共91个文件,主要包括Python脚本、MIT-BIH格式心电数据文件(.dat/.hea/.atr)、算法说明文档与相关论文PDF,压缩包大小30.81MB,目录结构清晰,便于按模块查找。脚本涵盖信号预处理、R波检测、特征选择与分类预测,数据文件可配合脚本在标准数据库上复现实验,PDF与markdown文档则提供理论推导和实现细节。目前已有673人学习下载,既可当作教学案例逐行研读,也能直接作为心电分析原型的开发基础。

1. 心电算法看着像玄学,本质是一条可复现的信号处理链路

第一次用 Python 接触心电算法的人,十有八九会先打开一个深度学习教程,把原始波形直接丢进模型。但真实工程里,滤波、R波识别、心率计算这三件事,90% 是靠成熟的信号处理方法完成的。把一条 ECG 原始记录变成一份能看心率数值的处理流程,通常只需要 NumPy/SciPy,几百行代码就能跑通。这个方向适合三类人:一是在做可穿戴设备数据处理的开发者,二是刚进入医疗信号领域的算法工程师,三是想把手动看波形的分析过程自动化的研究人员。心电算法并不神秘,前提是你先把信号读懂。

2. 读懂心电信号:采样率、导联和滤波的边界

2.1 心电信号里有哪些成分:QRS、基线漂移和工频干扰

心电信号不是一条干净的曲线,它由不同频率成分叠加而成。P 波和 T 波属于慢成分,能量主要集中在 0.5~5 Hz 附近;QRS 波群是心电里最陡峭也最明显的部分,主要能量在 5~20 Hz。基线漂移是身体呼吸、电极移动造成的低频起伏,通常低于 0.5 Hz;工频干扰来自供电线路,在中国大陆一般是 50 Hz,部分设备环境里还有 60 Hz 及其谐波;肌肉抖动带来的肌电干扰频带更宽,从 20 Hz 一直延伸到 100 Hz 以上。

R波识别之所以很少直接找一个全局最大值,就是因为信号里混杂着这些干扰。基线漂移会让整段波形上下缓慢变化,工频干扰会在 QRS 上叠加一层细小的锯齿,肌电干扰会让波峰毛刺变大。滤波的目标不是把所有非 QRS 成分都清掉,而是在压制干扰的同时保留 P 波、T 波和 QRS 的形态。如果滤波过头,R 波会被削平,后面做 R波识别时反而更难。

2.2 用 Python 读取原始心电数据:先确认采样率

写任何心电算法之前,第一件事是确认采样率。采样率不是抄过来的,而是从数据里核对出来的。很多心电数据以 CSV 格式保存,一列是时间,一列是幅值。用 NumPy 读入后,先看时间轴间隔,不要直接写死 250 Hz。

import numpy as np # raw.csv 里第一列是时间(s),第二列是心电幅值(mV) data = np.loadtxt("raw.csv", delimiter=",", skiprows=1) time = data[:, 0] ecg = data[:, 1] # 用时间间隔的中位数估计采样率,避免个别时间戳异常影响结果 dt = np.median(np.diff(time)) fs = int(round(1.0 / dt)) print("采样率:", fs, "Hz") print("信号长度:", len(ecg), "个采样点")

这段代码里,np.diff(time)算的是相邻两个时间点之间的间隔,np.median比np.mean更抗个别跳变。如果时间轴是等间距的,fs就是数据真实的采样率。如果数据源本身给出了采样率,也要用上面的方式校验一遍,因为很多采集设备实际输出频率和配置并不完全一致。采样率一旦算错,后边所有滤波器的截止频率都会偏移。

2.3 滤波前必须先确认的三个参数

滤波不是随便套一个低通或高通就完事。每次拿到新数据,我都会先确认三个参数:采样率、滤波频带、滤波器阶数。这三个参数决定了你能保留什么、滤掉什么。

参数常见做法判断依据
采样率 fs250 Hz 或 500 Hz以数据时间轴实际间隔为准
高通截止频率0.5~1 Hz低于此频率主要是基线漂移
陷波频率50 Hz 或 60 Hz看供电环境或频谱尖峰
滤波器阶数高通 2~4 阶,陷波 Q 值 20~30阶数太高容易产生振铃和相位畸变

心电滤波有一个反直觉的原则:滤波不是越干净越好。能看清 QRS 波、保留 P 波和 T 波,才是合适的滤波结果。如果为了压基线漂移把高通截止频率调到 5 Hz,R 波虽然还在,P 波和 T 波会被明显削弱,后续如果要做心电特征分析,数据就不够用了。

注意:滤波器的截止频率和采样率有关系。同样是 0.5 Hz 高通,在 250 Hz 和 500 Hz 采样率下,传给滤波器的归一化截止频率完全不同,参数不能直接搬。

3. 用 Python 把滤波写稳:基线漂移、工频干扰和参数选择

3.1 去掉基线漂移:高通滤波与中值滤波两种写法

基线漂移是最常见的心电干扰。呼吸造成的胸部起伏会让整条波形以 0.3~0.5 Hz 的频率上下摆动,如果不去除,R 波检测阈值会被抬到一个无效的位置。处理基线漂移有两种常用写法,一种是高通滤波,一种是中值滤波。

高通滤波适合基线漂移频率相对稳定的场景,计算快,参数直观。我一般用二阶 Butterworth 高通,截止频率 0.5 Hz,再用sosfiltfilt做零相位滤波。零相位的意思是滤波后波形没有整体延迟,R 波位置不会发生移动,这对后面 R波识别非常关键。

from scipy.signal import butter, sosfiltfilt def highpass_filter(ecg, fs, cutoff=0.5, order=2): # 归一化截止频率 = 截止频率 / (采样率/2) sos = butter(order, cutoff / (fs / 2), btype="highpass", output="sos") return sosfiltfilt(sos, ecg) ecg_hp = highpass_filter(ecg, fs, cutoff=0.5, order=2)

这里order=2是 Butterworth 滤波器的阶数,sosfiltfilt会正反各滤一次,等效阶数更高,但相位延迟为零。阶数不建议超过 4,因为阶数越高,滤波器在截止频率附近的振铃越明显,R 波前后会出现人为的起伏。

中值滤波的思路不一样,它不是把低频成分滤掉,而是直接从原始信号里把一条基线估计出来,再用原始信号减去这条基线。中值滤波对形态不规则、偶发突变的基线漂移更稳,因为高通滤波在信号边缘容易产生过冲。

from scipy.ndimage import median_filter def remove_baseline_median(ecg, fs, window_seconds=0.2): # 窗口长度取一个 QRS 宽度以上,才能保住 QRS window = int(fs * window_seconds) if window % 2 == 0: window += 1 baseline = median_filter(ecg, size=window, mode="nearest") return ecg - baseline

中值窗口的window_seconds一般取 0.2 秒左右。如果窗口设得太短,比如 0.05 秒,中值会把 QRS 波本身也当成高频细节剔除,R 波幅度会明显下降。如果设得太长,估计出来的基线跟不上漂移速度,去不干净。mode="nearest"是为了处理边界,避免波形两端出现空值。

3.2 抑制工频干扰:陷波器与加权移动平均

工频干扰表现为波形上叠加的细密高频纹波,频率在 50 Hz 附近。判断它是否存在,最直接的办法是看原始波形上有没有一条稳定的细锯齿,或者对信号做频谱分析看 50 Hz 处是否有明显尖峰。确认之后,用陷波器是最常见的做法。

from scipy.signal import iirnotch, filtfilt def notch_ecg(ecg, fs, freq=50.0, q=30.0): b, a = iirnotch(freq, q, fs) return filtfilt(b, a, ecg) ecg_clean = notch_ecg(ecg_hp, fs, freq=50.0, q=30.0)

q是品质因子,决定陷波带宽。Q 值越大,陷波频带越窄,对相邻频率的影响越小;Q 值太小会把 50 Hz 附近的心电成分也削掉。一般取 20~30 比较合适。如果现场供电环境是 60 Hz,把freq改成 60.0 就行,其他参数不用动。

还有一种老式做法是用移动平均做陷波,窗口长度取一个工频周期内的采样点数。比如 50 Hz、250 Hz 采样率下,一个周期是 5 个点,移动平均窗口可以取 5。这个方法实现简单,但会同时模糊 QRS 波形,而且对谐波压不干净。我一般只在采样率很低、陷波器数值不稳定的情况下才用移动平均,能上陷波器就优先陷波器。

3.3 滤波效果怎么验证

滤波做完不能只看一眼感觉“顺眼”就够了。我的验证习惯是时域和频域各看一次。时域看滤波后的 R 波是否还尖锐、P 波和 T 波是否还在;频域看基线漂移频段和工频处的能量是否真的降下来了。

from scipy.signal import welch f, psd = welch(ecg_clean, fs=fs, nperseg=min(4 * fs, len(ecg_clean))) # 检查 0.5 Hz 以下的能量是否被压下去 lowband = np.sum(psd[(f >= 0.01) & (f < 0.5)]) # 检查 50 Hz 附近的能量是否明显低于原始值 power_band = np.sum(psd[(f >= 48) & (f <= 52)]) print("低频能量:", lowband, "工频带能量:", power_band)

welch返回频谱的横轴f和功率谱密度psd,nperseg决定每个分段的长度,取 4 秒左右的窗口比较稳定。只看数值还不够,最好把原始波形和滤波后波形叠在一起对比,重点看 R 波峰值位置是不是没动。如果滤波后 R 波位置和原始波形上的尖峰有明显偏移,说明滤波器相位特性不合格。

滤波顺序也很重要。常见做法是先去基线漂移,再陷波工频,最后做一个很轻的低通平滑。反过来先陷波,往往会让基线漂移段产生额外振荡,后续高通处理又有可能把这些振荡放大。先处理低频,再处理高频,逻辑上更顺。

4. R波识别与心率计算:从阈值检测到RR间期

4.1 为什么只找最大值会翻车

很多人一开始实现 R波识别,就是np.argmax(ecg)找最大的峰。这个写法在安静、波形规范的数据上也许能用,但只要换一段数据就很容易翻车。R 波不一定都是正向的,某些导联或电极贴反时,R 波是负向尖峰,找全局最大值会直接找到高耸的 T 波或基线毛刺上去。即使 R 波是正向的,心电幅值也会随呼吸、运动、导联接触状态变化,固定阈值对幅度变化非常不敏感。

R波识别真正依赖的是 QRS 波的局部特征:它比 P 波、T 波更陡,持续时间更短,且在正常心电周期里有一个生理上的不应期。R 波识别算法可以很简单,但不能只靠一个全局最大值,至少要考虑局部窗口、阈值基准和不应期三个因素。

4.2 一个能跑的R波检测流程:带方向参数、局部窗口和不应期

下面这段是我的常见做法:先确定 R 波极性,再用局部窗口找极大值,最后用不应期跳过相邻的误检。代码不做任何机器学习,只依赖信号滤波后的形态。

import numpy as np def detect_r_peaks(ecg, fs, refractory=0.2, threshold_ratio=0.4, r_polarity=1): # r_polarity=1 表示R波向上,-1表示R波向下 x = ecg if r_polarity > 0 else -ecg x = x - np.median(x) # 用99分位数作为幅值基准,避免单个噪声点把阈值抬得太高 threshold = threshold_ratio * np.percentile(x, 99) window = int(fs * 0.05) # 50ms局部窗口 peaks = [] i = 0 while i < len(x): left = max(0, i - window) right = min(len(x), i + window + 1) local_max = np.argmax(x[left:right]) + left if x[local_max] > threshold: peaks.append(local_max) # R波出现后进入不应期,跳过生理上不可能出现下一次QRS的时间段 i = local_max + int(fs * refractory) else: i += 1 return np.array(peaks, dtype=int) peaks = detect_r_peaks(ecg_clean, fs, refractory=0.2, threshold_ratio=0.4)

这段代码的关键参数有三个。threshold_ratio决定检测灵敏度,取 0.4 表示阈值为信号 99 分位幅值的 40%,如果波形质量差可以降到 0.3,但要接受更多误检的风险。refractory是不应期,默认 0.2 秒,对应人体生理上每分钟最多 300 次心跳的上限,可以在 0.18~0.25 秒之间调。window取 50 ms,用来确定局部最值点,太短会把一个 QRS 波分裂成多个候选峰,太长会把 T 波并进来。

r_polarity参数很容易被忽略,但它很值得单独提一下。处理 II 导联时 R 波通常向上,r_polarity=1;处理某些小导联或反接数据时,把整个信号取负,再走同一套最大值检测逻辑,就避免了负向 R 波检测不到的问题。

4.3 从R波位置到心率计算:RR间期、瞬时心率和平均心率

R 波位置确定后,心率计算只剩简单算术。相邻两个 R 波的时间间隔叫 RR 间期,单位通常用秒。瞬时心率就是 60 除以 RR 间期。平均心率不应该直接对瞬时心率取平均,而应该先对 RR 间期取中位数,再换算成每分钟心跳数。这样做能减少个别异常 RR 间期对平均值的冲击。

rr = np.diff(peaks) / fs # 单位:秒 instant_hr = 60.0 / rr # 每个RR间期对应的瞬时心率 # 用生理边界过滤明显异常值,静息数据一般取0.4~1.5秒 valid_mask = (rr > 0.4) & (rr < 1.5) valid_rr = rr[valid_mask] mean_rr = np.median(valid_rr) avg_hr = 60.0 / mean_rr sdnn = np.std(valid_rr) rmssd = np.sqrt(np.mean(np.diff(valid_rr) ** 2)) print("检测到R波数量:", len(peaks)) print("平均心率: {:.1f} bpm".format(avg_hr)) print("SDNN: {:.3f} s, RMSSD: {:.3f} s".format(sdnn, rmssd))

valid_mask过滤的是明显不符合生理规律的 RR 间期,比如误检造成的 0.1 秒,或者漏检造成的 2 秒。0.4~1.5 秒对应 40~150 bpm,这是一个偏保守的静息范围。如果你处理的是运动负荷或儿童数据,需要把范围放宽,比如 0.3~1.5 秒,否则算法会把 200 bpm 附近的心率直接过滤掉。

SDNN是所有正常 RR 间期的标准差,RMSSD是相邻正常 RR 间期差值的均方根。这两个指标是心率变异性分析里最常用的基础值。如果valid_rr里留下的点数太少,这两个指标几乎不可信,至少要有 30 段有效 RR 间期再谈 HRV。

5. 心电算法避坑指南:5个常见的翻车点与排查方法

5.1 采样率算错:滤波器参数全对,结果全乱

现象:滤波后 R 波形态变得很钝,或者原本 50 Hz 的工频干扰依然存在,R波识别结果时好时坏。

原因:心电数据的时间列不是严格的等差数列,有人用相邻时间差的均值算采样率,某个时间戳异常跳动直接把fs拉偏;更多时候是直接从配置里抄了一个没核对过的采样率。滤波器的截止频率是相对采样率归一化计算的,fs一旦有偏差,真实的滤波频带就跟着错了。

解决:读数据后一定用np.diff(time)核对时间间隔,取中位数,不要取均值。如果数据没有时间列,要问清楚采集设备实际输出频率,不能根据文件名猜。

5.2 滤波顺序反了:陷波器先跑,波形出现振铃

现象:原始波形本来只是基线漂移加一点工频,滤波后 QRS 波前后出现明显的来回振荡,R 波峰值位置也变了。

原因:先做 50 Hz 陷波,再做高通去基线,高通会把陷波器在信号突变处产生的振铃进一步放大。陷波器本质是窄带滤波,在 R 波这种陡峭波形上容易产生瞬态振荡。

解决:把滤波顺序固定为先去基线漂移,再陷波工频。如果滤波后还在某些到访处出现振铃,试着把高通阶数从 2 降到 1,或者把陷波 Q 值从 30 调到 20。不要同时叠加太多滤波器,每加一级就是一次信号变形。

5.3 固定阈值碰上幅度变化:安静段正常,活动段大量漏检

现象:算法在静息数据上准确率很高,一换到走路、握拳、深呼吸的数据,R 波就漏掉一大半,心率显示频繁跳变。

原因:阈值是按整段信号的固定百分比设的。静息段幅值大,运动段基线起伏和噪声让有效 R 波幅值变小,固定阈值落在 R 波峰值之上,自然检不到。心电信号是非平稳的,幅度会持续变化。

解决:把阈值计算从“整段一次”改成“滑动窗口更新”。常见做法是每次检测到有效 R 波后,用最近若干秒的最大幅值重新计算阈值,或者把 R 波幅值做一个滑动中位数作为基准。同时,对静息、深呼吸、轻微运动三段数据分别做验证,不要让一段数据的表现代表整体。

5.4 负向R波和T波过高:最大值检测把T波当R波

现象:R 波位置检测结果里有一半落在 T 波上,相邻 RR 间期忽长忽短,心率计算结果完全不规律。

原因:没有考虑导联极性。某些导联的 T 波幅值比 R 波还高,或者 R 波本身是负向峰,算法仍以正向最大值作为检测目标。T 波虽然比 R 波慢,但局部幅值已经超过了阈值。

解决:先用肉眼或自动方式确认 R 波方向,r_polarity参数设为 -1 就对信号取负再检测。同时把不应期设到 0.2 秒左右,T 波落在 R 波后约 0.3 秒处,不应期天然挡掉一部分 T 波误检。如果 T 波实在太高,可以考虑对信号做一阶差分,利用 R 波的陡峭边界定位,而不是直接看幅值。

5.5 心率计算直接对瞬时心率取平均:一个错检把结果拉到 180

现象:明明只有两三个地方误检了,打印出来的平均心率却 160、180,和真实心率完全对不上。

原因:一个 0.3 秒的误检 RR 间期换算成瞬时心率就是 200 bpm,对这个值取平均会把整体均值拉高。更常见的是漏检产生的超长 RR 间期,算出的瞬时心率低到 20,直接把平均值拉低。

解决:先过滤异常 RR 间期,再取中位数,不要取平均值。中位数对异常值不敏感,这是心率计算里最重要的一步。另外打印结果时顺手打印 R 波数量和有效 RR 数量,如果有效 RR 数量远小于 R 波数量,说明滤波或检测环节有系统性问题,不是心率算法的问题。

6. 从“能跑”变成“能信”:用对照标注做R波检测验证与参数微调

R 波检测写出来很简单,真正难的是证明它可靠。我的习惯是用一小段人工标注的 R 波位置做验证,跑一个灵敏度(sensitivity)和阳性预测率(positive predictive value),再决定要不要改参数。没有量化指标,所有“效果还行”都是主观感受。

def evaluate_detector(ref_peaks, det_peaks, fs, tolerance=0.15): tol = int(tolerance * fs) # 先用广播计算每个检测峰和所有参考峰的距离 diff = np.abs(det_peaks[:, None] - ref_peaks[None, :]) matched_ref = np.zeros(len(ref_peaks), dtype=bool) matched_det = np.zeros(len(det_peaks), dtype=bool) for i in range(len(det_peaks)): hit = diff[i] <= tol if np.any(hit): # 只把最近的那个参考峰标记为匹配 j = np.argmax(diff[i]) if False else np.where(hit)[0][0] if not matched_ref[j]: matched_ref[j] = True matched_det[i] = True tp = matched_ref.sum() fn = len(ref_peaks) - tp fp = len(det_peaks) - matched_det.sum() se = tp / (tp + fn) ppv = tp / (tp + fp) return se, ppv se, ppv = evaluate_detector(ref_peaks, peaks, fs, tolerance=0.15) print("灵敏度: {:.3f}, 阳性预测率: {:.3f}".format(se, ppv))

这里的tolerance=0.15表示检测峰和参考峰落在 ±150 ms 内就算匹配成功。这个容差偏宽松,适合验证整体检测流程;如果你想做更严格的逐拍对比,可以缩到 ±50 ms。matched_ref保证一个参考峰只被匹配一次,避免一个真实 R 波同时被多个候选峰反复匹配。

拿到指标后,参数调整就不是拍脑袋了。灵敏度偏低,说明漏检多,优先把threshold_ratio降下来,或者把局部窗口从 50 ms 调大一点。阳性预测率偏低,说明误检多,优先把不应期调大,或者把阈值提上去。如果两个指标都很差,先回头看滤波,不要死磕检测参数。

我还有一个习惯:验证数据至少分三段,一段安静平躺,一段深呼吸,一段轻微运动。心电算法最怕的就是只在“最好的一段数据”上表现好,换个场景就翻车。我现在拿到任何新数据,第一件事永远是把采样率、滤波参数、R波检测参数写成一个独立配置区,任何改动都重新跑一遍三段数据,保存前后指标做对比。这个习惯让我少改了很多轮。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进