
简介针对原始ECG信号噪声干扰大、心跳特征难提取的问题这份资源提供了一套完整的Python处理脚本可直接用于读取原始波形、抑制噪声并估算心跳数适合生物医学工程学生、算法工程师及可穿戴健康监测开发人员参考。压缩包采用zip格式共含7个文件1个Python主脚本实现数据读取与处理1个CSV文件提供示例心电数据4张PNG图片展示时域、频域和心率等分析结果1个Markdown文档说明整体流程。整个包仅211KB轻量便于快速上手。目前已有273人学习下载。脚本基于numpy、scipy与matplotlib等工具涵盖滤波降噪、基线漂移去除和QRS波群峰值检测等关键环节用户可据此复现一套可扩展的ECG分析流程为后续心率变异性计算或异常心律分类等研究奠定基础。同时代码结构清晰阈值与滤波器参数均可调整便于二次开发。1. 一个Python脚本把原始ECG变成可用的心率数据它到底做了什么适合谁用做可穿戴设备的人都知道从硬件导出的原始ECG信号从来不是书上的光滑曲线——基线会漂工频会串肌肉一抖R峰直接淹没在噪声里。标题里这个Python脚本就是干一件事把原始ECG信号读进来用数字滤波把噪声压下去再从干净信号里估计出峰值和心跳最终输出一份处理后的ECG和心率序列。它适合谁如果你在调心电传感器、在做心率算法的原型验证或者手头有一堆没标注的ECG数据想快速算个心率这条路径基本是最短的那条。下面按实际跑通的顺序拆解噪声抑制、峰值检测、心跳估算三块核心逻辑并给出能直接改参数复现的代码。2. 先把原始ECG洗干净三类噪声来源与零相位滤波方案滤波不是可有可无的预处理而是整个脚本的地基。滤波参数错了后面峰值检测再聪明也没用。2.1 原始ECG里最常见的三类噪声基线漂移、工频干扰和肌电先说噪声。处理原始ECG信号第一件事不是想算法而是搞清楚你对抗的是什么。根据我的经验90%的翻车来自三类噪声。第一类是基线漂移频率通常低于0.5Hz来源是呼吸、电极位移和皮肤阻抗变化表现是信号整体上下缓慢波动严重时会把S波和T波抬到离谱的位置让后面的峰值检测误判。第二类是工频干扰国内电网50Hz部分设备在60Hz地区它叠加在信号上表现为规则的细密纹波幅度可能达到R峰的十分之一甚至更高。第三类是肌电噪声频率范围从10Hz到几百上千赫兹都有形态随机、突发性强比工频更难滤。要快速判断噪声类型不要只靠肉眼看波形直接把信号做一次FFT看频谱。基线漂移对应频谱最左侧的低频能量工频干扰在50Hz或60Hz处会出现一根尖峰肌电噪声则是从几十赫兹往上的一片“地板”。用频谱引导滤波参数比对着波形瞎调要快得多。2.2 滤波方案选型为什么是带通而不是高通加陷波最常见的做法是带通滤波。ECG里诊断信息集中在0.05-100Hz但能量最集中的QRS波群在5-30HzP波和T波在1-10Hz。做心率估算时我们其实只关心R峰位置对波形保真要求没那么高所以把通带收窄到0.5-40Hz是最稳妥的折中。为什么我不推荐只做高通加50Hz陷波因为高通只能解决基线漂移工频干扰还得靠陷波。但陷波器Q值设不好会在工频附近产生振铃滤波后出现很假的波纹这些波纹在峰值检测时会变成伪峰。带通相当于把高通、低通和一部分工频压制合并到一个环节参数少调试快。如果工频特别重可以在带通后再级联一个50Hz陷波但Q值不要太大我一般控制在30左右避免把周围有用的QRS能量一起压掉。医疗级采集设备通常硬件已经滤过工频软件端再做一次意义不大。噪声类型频率范围常见处理手段基线漂移0.5Hz高通或带通工频干扰50/60Hz及整数倍谐波陷波器或带通肌电噪声10Hz-1kHz低通或带通2.3 用SciPy实现0.5-40Hz零相位带通步骤与参数说明下面是一段可以直接复制到脚本里的滤波函数我用SciPy的butter和filtfilt实现。import numpy as np from scipy.signal import butter, filtfilt def bandpass_filter_ecg(signal, fs, low0.5, high40.0, order4): 对原始ECG信号做零相位带通滤波。 signal: 一维ndarray原始ECG序列 fs: 采样率单位Hz low / high: 通带截止频率单位Hz order: 巴特沃斯滤波器阶数 nyq 0.5 * fs # 奈奎斯特频率 low_n low / nyq high_n high / nyq b, a butter(order, [low_n, high_n], btypeband) # filtfilt是做前向-反向滤波输出没有相位偏移 filtered filtfilt(b, a, signal) return filtered这段代码的核心是filtfilt不是butter。butter负责生成滤波器系数filtfilt会先把信号正向滤一遍再把结果反向滤一遍最终相位偏移为零。如果换成lfilter滤波后的R峰会整体向右移动几十个毫秒这个偏移在波形对比时极其隐蔽却会让峰值位置和原始信号对不上。对于离线处理场景filtfilt是首选。参数方面low和high我一般按用途调只做心率估算low放到0.5-1Hzhigh放到40Hz。如果还想保留P波和T波做后续分析high可以放到50Hz以上但不要超过100Hz。order取4就够更高只会让工频附近衰减更陡计算量变大对ECG这种低频信号没有实际收益。调用前务必确认fs是真实采样率否则所有截止频率都会错位。还有一个容易被忽略的坑filtfilt在信号首尾会因为边界效应产生瞬时波动。如果数据很短建议先用np.pad对信号做对称延拓滤波后再截掉。处理长信号时可以忽略但处理单个心搏片段时必须加这一步。3. 从滤波后的信号找R峰峰值检测算法与阈值设计滤波干净了下一步是把每个心搏的R峰位置找出来。这一步做不好后面算出来的心率就是“看似精确的垃圾”。3.1 为什么简单取max会翻车T波和噪声峰值干扰很多第一次写峰值检测的人会想我直接np.argmax不就完了理想情况下确实可以但真实ECG里有两个巨大的干扰。第一个是T波形态比QRS宽幅度通常只有R峰的三分之一某些导联或心率偏慢时T波会明显增高甚至接近R峰。如果用全局最大值T波会被当成额外的心跳心率直接翻倍。第二个是肌电和运动伪迹的突发尖峰带通滤波去掉了一部分高频但不能完全消除突发噪声尖峰持续时间短、能量集中很容易穿透简单的固定阈值。所以成熟的检测算法都会先对信号做变换取绝对值再用滑动窗口积分或平滑让R峰变成一个突出的单峰然后在这个增强信号上找局部最大值。这个方法能把噪声尖峰的风险降到很低的水平。3.2 基于滑动窗口的自适应峰值检测Python实现我用的是一个简化但足够实用的自适应峰值检测对滤波信号取绝对值用2秒滑动均值作为噪声基线把基线乘以一个系数作为动态阈值然后扫描阈值之上的连续区域并记录区域内最大值。import numpy as np def detect_peaks_adaptive(filtered_ecg, fs, refractory0.25, threshold_factor0.6): 在带通滤波后的ECG上检测R峰位置。 filtered_ecg: 滤波后的一维信号 fs: 采样率单位Hz refractory: 最小不应期单位秒防止同一心搏重复计数 threshold_factor: 阈值系数乘以滑动窗口内信号绝对值的均值 abs_ecg np.abs(filtered_ecg) window_size int(fs * 2) # 2秒窗口用来跟随幅度变化 kernel np.ones(window_size) / window_size # 用滑动均值近似局部能量基线比固定阈值抗幅度波动 baseline np.convolve(abs_ecg, kernel, modesame) threshold threshold_factor * baseline 1e-10 peaks [] min_distance int(fs * refractory) candidate -1 # 当前候选峰位置 candidate_val 0.0 # 当前候选峰幅值 ref_until 0 # 不应期截止位置 for i, val in enumerate(abs_ecg): if i ref_until: continue if val threshold[i]: # 进入峰值区域持续追踪最大值 if val candidate_val: candidate i candidate_val val else: # 峰值区域结束确认候选峰 if candidate ! -1: last_peak peaks[-1] if peaks else -min_distance if candidate - last_peak min_distance: peaks.append(candidate) else: # 间距太近保留幅值更高的峰 if peaks and candidate_val abs_ecg[peaks[-1]]: peaks[-1] candidate ref_until candidate min_distance candidate -1 candidate_val 0.0 # 循环结束时可能还有未确认的候选峰 if candidate ! -1: last_peak peaks[-1] if peaks else -min_distance if candidate - last_peak min_distance: peaks.append(candidate) return np.array(peaks)这段代码的逻辑分四步第一对信号取绝对值并计算滑动均值基线第二动态阈值是基线的threshold_factor倍第三扫描过程中持续记录候选峰的最大值第四信号跌回阈值以下时确认候选峰并启动一个不应期。这样即使信号基线因为运动缓慢波动阈值也会跟着一起动不会出现固定阈值那种“前半段漏检、后半段误检”的问题。3.3 两个必调参数最小峰间距与幅度阈值这个检测器的两个关键参数如下。第一个是refractory最小峰间距单位秒。生理上正常心跳不会快于每分钟300次也就是0.2秒一个周期我默认设0.25秒对应240bpm的上限。如果你想捕捉早搏或某些快速心律失常可以降到0.2秒但再低就容易把T波也算进去。第二个是threshold_factor阈值系数常用范围0.5-0.9。信号干净时调到0.5能提高灵敏度肌电噪声多时要往0.8以上调。这里有个血泪经验threshold_factor不要超过1.0。一旦超过1阈值就高过滑动均值R峰幅度低的时候会被完全漏检。有些导联QRS幅度只有T波的一半我在脚本里默认0.6并且检测之后额外做一轮RR间期校验。4. 从峰值序列估算心跳RR间期、瞬时心率与异常值处理峰值检测完脚本就拿到了心跳的位置列表。接下来的任务是把这些离散位置换算成有生理意义的心率序列并处理漏检和误检。4.1 RR间期与瞬时心率换算一个公式和一个边界情况心率估算本质是小学算术。RR间期 (peak[i1] - peak[i]) / fs单位秒瞬时心率 60 / RR单位bpm。至少两个峰才能算出一个值。边界情况是只有一个峰或者首尾峰缺失如果信号只覆盖了很短时间或者第一个峰前有漏检第一个RR间期可能被算成两倍长。我的处理方式是先算出所有RR间期不急着转成心率而是先对RR序列做离群值清洗。因为离群值在RR上比在心率上更容易识别伪峰产生极短RR漏检产生极长RR而在心率序列里这些异常值会被“倒数”放大干扰反而更明显。4.2 过滤伪峰和补漏检中值滤波与回退阈值伪峰和漏检都会让RR间期分布出现异常值。伪峰的RR通常极短比如0.1秒左右漏检的RR会突然变成正常值的2倍甚至3倍。处理策略要用中值滤波不要用均值原因很简单均值会被极端值拉偏中位数对个体差异更鲁棒。def clean_rr_intervals(rr_intervals, median_factor_low0.6, median_factor_high1.6): 清理RR间期序列中的离群值。 返回布尔maskTrue表示该RR间期可信。 rr np.asarray(rr_intervals, dtypefloat) median_rr np.median(rr) mask np.ones(len(rr), dtypebool) mask (rr median_factor_low * median_rr) mask (rr median_factor_high * median_rr) return mask这套阈值含义是把某个RR与整体中位数比小于0.6倍的高概率是T波或噪声误检大于1.6倍的高概率是漏检。为什么用中位数而不是直接用绝对阈值因为运动中整体心率会从70升到130RR中位数跟着变化不会拿静止状态的标准去卡运动后的数据。对于被标记为漏检的长RR我的恢复思路是在可疑RR内部把threshold_factor临时调低一半重新检测一次峰值。如果找到了一个新峰并且它与两边峰的间距都落在合理范围就把它插入峰值列表。插入后要重新计算RR间期再跑一遍清理。如果搜不到新峰就保留这个长RR但要在输出里标记出来不要硬补一个不存在的峰。4.3 输出处理后的ECG和心率表文件格式与字段设计脚本最后一步是把结果落盘。我最常用的输出是两个文件一个是处理后的ECG波形保存成npy同时把时间轴单独存一份csv另一个是心率表每行包含峰索引、峰时间、RR间期、瞬时心率以及一个corrected标记位表示该峰是否由漏检补偿插入。如果只保存一个心率数值后续分析时你完全无法追踪这个数是怎么来的等于黑匣子我吃过这个亏。def export_results(filtered_ecg, peaks, fs, output_prefix): t np.arange(len(filtered_ecg)) / fs # 波形文件 np.save(f{output_prefix}_filtered.npy, filtered_ecg) np.savetxt(f{output_prefix}_time.csv, t, delimiter,, headertime_s, comments) # 心率表 peak_times peaks / fs rec [] for i, p in enumerate(peaks): if i 0: rr_val np.nan hr_val np.nan else: rr_val peak_times[i] - peak_times[i - 1] hr_val 60.0 / rr_val if rr_val 0 else np.nan rec.append((p, peak_times[i], rr_val, hr_val, False)) header peak_index,peak_time_s,rr_interval_s,instant_hr_bpm,corrected np.savetxt( f{output_prefix}_beats.csv, rec, delimiter,, headerheader, comments, fmt%d,%.4f,%.4f,%.2f,%d )注意np.savetxt的fmt必须和字段个数一致峰索引用整数%d时间和RR间期用浮点%.4f心率用%.2fcorrected用%d布尔转整数。导出后要肉眼检查一遍峰索引必须落在滤波后信号的R波上升沿附近心率值不能有超过30-220bpm这种离谱范围。如果发现心率序列中出现一个孤立尖峰基本都是误检或漏检没清干净。5. 脚本落地的避坑指南从采样率到T波误检的五个踩坑记录前面几章讲的是主线这一章是配角坑。以下五条我基本都在真实数据上踩过按出现频率排序。5.1 采样率不统一导致检测结果错乱现象两个设备各导出一份ECG一个250Hz一个1000Hz同一段数据检测出的心率对不上滤波后波形也长得不一样。原因带通截止频率、滑动窗口大小、最小峰间距全部依赖fsfs不对所有参数都失效。解决脚本入口统一重采样到250Hz或500Hz。离线处理我一般用scipy.signal.resample_poly因为它比fft重采样更稳不容易在信号两端制造振铃。from math import gcd from scipy.signal import resample_poly def resample_ecg(signal, orig_fs, target_fs250): g gcd(orig_fs, target_fs) up target_fs // g down orig_fs // g return resample_poly(signal, up, down), target_fs重采样后记得把fs当作全局变量统一管理不要在每步函数里重新传入不一致的值。一次批量处理里如果混着多台设备数据我建议处理前先打印每条数据的原始fs确认重采样后的长度符合预期。5.2 固定振幅阈值在低幅导联下漏检现象I导联检测正常换到V1导联就只检测到一半心跳心率掉到真实值的一半。原因不同导联R峰幅度相差10倍很正常固定阈值对这组合适对另一组就过高。解决用自适应阈值也就是第3章的滑动基线方案。如果还想进一步统一振幅可以把滤波信号除以绝对值的第98百分位数再乘以一个固定增益。但注意归一化窗口不能太短否则早搏那段会把整体幅度瞬间拉低导致后面阈值跟着变低反而误检。5.3 滤波相位偏移让R峰位置对不上原始信号现象滤波后峰位置和原始信号上的R峰差了20多个采样点人工标注对不上。原因用了lfilter而不是filtfilt或者用了带通但没做前向-反向滤波。滤波器有群延迟信号经过因果滤波器后会整体向右平移。解决离线分析一律用filtfilt。如果必须做在线实时处理只能用因果滤波器那就先算清楚群延迟再对峰位置做固定补偿。群延迟可以用scipy.signal.group_delay直接测出来。5.4 T波幅度高过阈值时心率翻倍现象静息状态下脚本输出心率120但实际只有60。原因某些导联心率慢时T波幅度很高与R峰形成“双峰”峰值检测把T波也当成一次心跳。解决第一道防线是refractory最小间距第二道防线是RR间期离群值清理第三道防线是斜率检查。以候选峰为中心左右各取20ms计算最大一阶差分R峰的斜率通常远大于T波。如果斜率小于R峰中位斜率的一半就拒绝这个候选峰。前两道防线能挡掉大部分斜率检查留给特别顽固的数据。5.5 原始信号和滤波信号混在一起输出后面没法复用现象脚本跑完只输出一个filtered.npy原始信号没有保留后发现分析需要原始信号做频域验证但原文件已经被覆盖。原因输出阶段偷懒没有区分层级。解决每次导出都保留原始信号、滤波信号、峰值位置三个独立文件并把采样率、滤波器参数、阈值参数写进一个同名的json或txt。文件名用规范化命名比如subject_a_lead1_raw.npy、subject_a_lead1_filtered.npy不要用processed_这种模糊前缀。处理日志虽然啰嗦但三个月后你自己都会感谢当时的记录。6. 把脚本接进你的数据管道批量处理、可视化验证和一个小习惯最后这一章讲怎么把脚本从“单条数据能跑”变成“一批数据能跑”以及怎么确认结果可信。6.1 批量处理多份ECG用glob把脚本变成管道入口import glob for file in glob.glob(data/*.npz): data np.load(file) fs int(data[fs]) ecg data[ecg].astype(float) filtered bandpass_filter_ecg(ecg, fs, low0.5, high40.0, order4) peaks detect_peaks_adaptive(filtered, fs, refractory0.25, threshold_factor0.6) export_results(filtered, peaks, fs, file.replace(data/, out/))批量处理时我会在外层包一个try-except把每个文件的报错信息写进一个fail_log.txt而不是让脚本中途退出。这样哪条数据参数不合适、哪个文件重采样失败都能集中排查而不是一遍遍重新跑全量。6.2 验证检测质量一张图看过滤波前后和峰位置数值指标再漂亮都要先用眼睛确认一遍。我会在每批数据里随机抽几份画出前10秒的原始信号、滤波信号并用红色虚线标出检测到的R峰位置。import matplotlib.pyplot as plt def plot_check(raw_ecg, filtered_ecg, peaks, fs, start0, end10): t np.arange(len(raw_ecg)) / fs plt.figure(figsize(12, 4)) plt.plot(t[start*fs:end*fs], raw_ecg[start*fs:end*fs], alpha0.6, labelraw) plt.plot(t[start*fs:end*fs], filtered_ecg[start*fs:end*fs], labelfiltered) for p in peaks: if start * fs p end * fs: plt.axvline(p / fs, colorred, linestyle--, alpha0.7) plt.legend() plt.show()检查重点是看R峰线是否正好压在滤波信号的最高点上以及有没有一段信号里红色虚线数量明显异常。如果错误集中在某一段放大后对着峰值检测的阈值曲线看基本就是那道题的答案。我现在每次跑完一批ECG都会先出10张这样的预览图再决定要不要信任后面的统计结果。这个过程救过我很多次有一次T波误检就是从图上发现的而数值指标完全看不出异常。希望帮到你。本文还有配套的精品资源点击获取