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()典型频谱会显示三个问题区域:
- 0Hz附近的尖峰——直流偏移和基线漂移
- 50Hz的"高塔"——工频干扰
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} 次/分钟")更稳健的做法可以结合:
- 移动平均阈值
- 形态学滤波
- 动态时间规整(DTW)
我曾在一个可穿戴设备项目中发现,加入加速度计数据消除运动伪影,能使心率检测准确率提升18%。
5. 工程实践中的经验之谈
在实际嵌入式实现时,有几点特别需要注意:
- 实时性优化:将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; }定点数处理:STM32等MCU适合Q15格式定点运算,需注意动态范围
参数微调技巧:
- 直流陷波器的
a值每增加0.01,过渡带宽度约减小5% - FIR窗函数选择:汉宁窗适合大多数场景,凯撒窗可精确控制衰减
- 直流陷波器的
故障排查:
- 如果出现波形失真,检查滤波器相位响应(建议使用线性相位FIR)
- 心率检测异常时,先验证R波检测阈值是否适应信号幅度变化
记得第一次在产品中使用这套算法时,因为忘记考虑STM32的字节对齐问题,导致从SD卡读取的数据错位。后来用以下代码解决了问题:
#pragma pack(push, 1) typedef struct { uint8_t low_byte; uint8_t high_byte; } ECG_Point; #pragma pack(pop)这个经历让我明白:信号处理不仅是数学问题,更是系统工程。