从原始数据到清晰波形:心电信号滤波实战与心率估算
2026/7/13 11:40:02 网站建设 项目流程

1. 心电信号处理入门:从噪声中提取生命密码

第一次接触心电信号处理时,我被那些杂乱无章的波形搞得一头雾水。直到后来才发现,这些看似混乱的数据里藏着人体最精妙的生命密码。心电信号(ECG)是心脏电活动在体表的表现,正常频率在0.05-100Hz之间,但实际采集时总会带着各种"杂质"——50Hz工频干扰、肌电噪声、基线漂移...就像在嘈杂的菜市场里听人说话,必须学会"降噪"才能听清关键信息。

我常用的工具链很简单:Python+NumPy+SciPy+Matplotlib,或者MATLAB。这次我们用Python演示,因为它的开源特性更适合分享。假设我们已经从STM32设备获取了采样率500Hz的4096点数据,存储为16位整数(小端序),文件名为serial_data_raw.dat。先看看原始数据的"长相":

import numpy as np import matplotlib.pyplot as plt def read_ecg_data(filename): with open(filename, 'rb') as f: raw = np.frombuffer(f.read(), dtype=np.uint8) data = raw[::2] + raw[1::2] * 256 # 小端序转换 return data.astype(np.int16) ecg_raw = read_ecg_data('serial_data_raw.dat') fs = 500 # 采样率 t = np.arange(len(ecg_raw)) / fs plt.figure(figsize=(12,6)) plt.plot(t, ecg_raw) plt.title('原始心电信号(含噪声)') plt.xlabel('时间(s)') plt.ylabel('幅值') plt.grid()

运行这段代码,你会看到信号可能像被风吹乱的绳子——基线上下飘移,还叠加着细密的"毛刺"。别急,接下来我们一步步解决这些问题。

2. 频谱分析:给心电信号做"CT扫描"

处理信号前,得先知道噪声藏在哪。就像医生用CT定位病灶,我们用傅里叶变换做频谱分析:

from scipy.fft import fft, fftshift n = len(ecg_raw) freq = fftshift(np.fft.fftfreq(n, 1/fs)) spectrum = 20 * np.log10(np.abs(fftshift(fft(ecg_raw))) + 1e-12) # 避免log(0) plt.figure(figsize=(12,6)) plt.plot(freq, spectrum) plt.xlim(0, 100) # 聚焦0-100Hz plt.title('原始信号频谱') plt.xlabel('频率(Hz)') plt.ylabel('幅度(dB)') plt.grid()

典型频谱会显示三个问题区域:

  1. 0Hz附近的尖峰——直流偏移和基线漂移
  2. 50Hz的"高塔"——工频干扰
  3. 35Hz的连续谱——肌电等高频噪声

这就明确了我们的作战目标:先干掉直流分量(2.1节),再清理高频噪声(2.2节)。

2.1 直流陷波器:消除基线漂移的"磁铁"

直流分量就像把整个信号往上推的手,导致波形偏离基线。传统高通滤波器会过度衰减低频ECG成分,而直流陷波器像精准的磁铁,只吸走0Hz附近干扰:

def dc_notch_filter(signal, a=0.99): # 系统函数H(z)=(1-z^-1)/(1-a*z^-1) b = [1, -1] # 分子系数 a_filter = [1, -a] # 分母系数 return lfilter(b, a_filter, signal) ecg_dc_removed = dc_notch_filter(ecg_raw)

参数a控制"磁力"强度:越接近1,过渡带越窄(默认0.99可提供约30dB衰减)。看看效果:

plt.figure(figsize=(12,6)) plt.subplot(211) plt.plot(t, ecg_dc_removed) plt.title('直流陷波后时域信号') spectrum_dc_removed = 20 * np.log10(np.abs(fftshift(fft(ecg_dc_removed))) + 1e-12) plt.subplot(212) plt.plot(freq, spectrum_dc_removed) plt.xlim(0, 100) plt.title('滤波后频谱')

你会看到时域波形不再"漂浮",频谱中0Hz尖峰明显降低。但50Hz干扰依然存在,就像装修时的电钻声,需要下一步处理。

2.2 FIR低通滤波:构建噪声"防火墙"

FIR滤波器就像一道智能防火墙,只放行35Hz以下的有效信号。加窗法设计能避免吉布斯效应导致的振铃:

from scipy.signal import firwin, freqz def design_ecg_lpf(fs, cutoff=35, transition=10, attenuation=40): # 根据过渡带计算阶数 N = int(6.6 * fs / transition) # 汉宁窗系数 taps = firwin(N, cutoff, window='hann', fs=fs) return taps lpf_taps = design_ecg_lpf(fs) ecg_filtered = lfilter(lpf_taps, 1, ecg_dc_removed)

这个设计满足:

  • 截止频率35Hz
  • 过渡带10Hz
  • 阻带衰减>40dB

验证频率响应:

w, h = freqz(lpf_taps, fs=fs) plt.plot(w, 20 * np.log10(np.abs(h))) plt.axvline(35, color='r', linestyle='--') plt.title('FIR滤波器频率响应') plt.xlabel('频率(Hz)') plt.ylabel('增益(dB)')

处理后频谱中,50Hz和高频噪声就像被橡皮擦抹去,只留下干净的ECG频谱。

3. 动态范围调整:让波形完美适配显示屏

嵌入式设备(如320x240像素屏幕)需要优化信号幅值。我的经验法是使峰峰值≈120:

def adjust_dynamic_range(signal, target_pp=120): current_pp = np.max(signal) - np.min(signal) return signal * (target_pp / current_pp) ecg_adjusted = adjust_dynamic_range(ecg_filtered)

绘制最终波形:

plt.figure(figsize=(12,4)) plt.plot(t, ecg_adjusted) plt.title('处理后心电信号') plt.xlabel('时间(s)') plt.ylabel('幅值') plt.grid()

现在信号就像被熨平的衬衫,整齐地显示在屏幕上。但我们的任务还没结束——还要从中读取心跳节奏。

4. 心率估算:捕捉心跳的"鼓点"

心率的本质是R波间隔的倒数。简单峰值检测算法如下:

def estimate_hr(signal, fs, min_gap=0.3): # 寻找R波位置(简化版) peaks = [] threshold = 0.7 * np.max(signal) for i in range(1, len(signal)-1): if signal[i] > threshold and signal[i] > signal[i-1] and signal[i] > signal[i+1]: if len(peaks) == 0 or (i - peaks[-1]) > min_gap * fs: peaks.append(i) # 计算平均心率 intervals = np.diff(peaks) / fs hr = 60 / np.mean(intervals) return hr heart_rate = estimate_hr(ecg_adjusted, fs) print(f"估算心率: {heart_rate:.1f} 次/分钟")

更稳健的做法可以结合:

  1. 移动平均阈值
  2. 形态学滤波
  3. 动态时间规整(DTW)

我曾在一个可穿戴设备项目中发现,加入加速度计数据消除运动伪影,能使心率检测准确率提升18%。

5. 工程实践中的经验之谈

在实际嵌入式实现时,有几点特别需要注意:

  1. 实时性优化:将FIR滤波器转换为循环缓冲区实现,减少计算量
// STM32上的C实现示例 #define FIR_TAP_NUM 166 float fir_filter(float input, const float *coeff, float *buffer) { static int offset = 0; buffer[offset] = input; float output = 0; for (int i=0; i<FIR_TAP_NUM; i++) { output += coeff[i] * buffer[(offset + FIR_TAP_NUM - i) % FIR_TAP_NUM]; } offset = (offset + 1) % FIR_TAP_NUM; return output; }
  1. 定点数处理:STM32等MCU适合Q15格式定点运算,需注意动态范围

  2. 参数微调技巧

    • 直流陷波器的a值每增加0.01,过渡带宽度约减小5%
    • FIR窗函数选择:汉宁窗适合大多数场景,凯撒窗可精确控制衰减
  3. 故障排查

    • 如果出现波形失真,检查滤波器相位响应(建议使用线性相位FIR)
    • 心率检测异常时,先验证R波检测阈值是否适应信号幅度变化

记得第一次在产品中使用这套算法时,因为忘记考虑STM32的字节对齐问题,导致从SD卡读取的数据错位。后来用以下代码解决了问题:

#pragma pack(push, 1) typedef struct { uint8_t low_byte; uint8_t high_byte; } ECG_Point; #pragma pack(pop)

这个经历让我明白:信号处理不仅是数学问题,更是系统工程。

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

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

立即咨询