2025-12-21
实战:心电图信号处理与ESP32心率血氧计
心电图信号处理
做这个小项目的想法来源于 HPC WEEK 比赛中 CT 图像并行计算的那道题。当时虽然我看不懂题目,但是大受震撼。因为不管是各种OJ还是Leetcode上我平时都不太会看到实际数据的处理,一般都是想象出来的场景,选择合适的算法和数据结构解决(毕竟以我们这种水平也做不了什么复杂的现实场景的东西)。
我后来问了下GPT,了解到了信号处理这个方向。打算从最基础的心电图信号处理做一个小项目进行实战。
环境配置
在项目文件夹下用venv创建python3.12环境,然后安装了numpy,scipy,matplotlib,wfdb库。
python
pip install numpy scipy matplotlib wfdb其中:
wfdb:读 MIT-BIH 这类心电数据库
scipy:滤波、信号处理
matplotlib:画图
数据获取
MIT-BIH 数据库的文件通常成对出现(.dat 信号文件和 .hea 头文件)。可以使用 wfdb 库来处理它们。wfdb.rdrecord() 是 WFDB 库的核心函数,用于读取生理信号记录(如 ECG 心电信号)。它返回一个record对象,主要包括:
- 信号数据
record.p_signal:一个多维数组,表示 ECG 的多个导联,通常为(样本数, 通道数)。例如:包含 2 个导联、持续 10 秒、采样率为 360Hz 的信号,返回的数组形状是(3600,2)。 - 元数据字典:包含采样率(
record.fs)、导联名称(record.sig_name)、单位(record.units)等。
python
record.p_signal # 生理信号数据(numpy 数组)
record.fs # 采样频率(Hz),MITDB 通常是 360 Hz
record.sig_name # 信号名称,如 ['MLII', 'V1', 'V2', 'V4', 'V5']
record.n_sig # 通道数
record.units # 信号单位,如 ['mV', 'mV']
record.record_name # 记录名 '100'MLII (Modified Limb Lead II)是第1个通道,是最常用的监测导联。问GPT后说这个最常用,所以我就取了这个。
给原始数据画图
根据传入的start和duration参数创建一个时间轴time_axis,并使用loader数据画图。
去噪
如果观察刚才画出的原始信号,可能会发现两个问题:
基线漂移 (Baseline Wander):波形整体像海浪一样上下起伏。这通常是由于呼吸或身体晃动引起的(属于低频干扰)。
高频噪声 (High-frequency Noise):波形上有细小的锯齿。这可能是肌肉电信号 (EMG) 或设备电子噪声。
带通滤波
带通滤波只保留需要的频段(0.5–40 Hz的频段)。Google后就可以知道,< 0.5 Hz的低频是基线漂移噪声,> 40 Hz的是高频噪声。
我们构建一个4阶巴特沃斯带通滤波器,截止频率设置为0.5 Hz和40 Hz。使用scipy.signal.butter函数设计滤波器,并用scipy.signal.filtfilt函数进行零相位滤波,避免相位失真。
R波检测
R波是心电图中最显著的特征,代表心脏的收缩。检测R波的位置对于计算心率和分析心律非常重要。
ECG 信号即使滤了波,也还包含 P 波、T 波等。为了让计算机只盯着 R 波看,我们需要放大它们之间的差距,进行平方处理。
我们使用scipy.signal.find_peaks函数来检测R波峰值。设置适当的高度和距离参数,以确保只检测到真正的R波峰值。然后将返回的峰值索引转换为时间,并在图上标注出来。
然后在draw.py的draw_signal函数里添加可选参数rwave,如果传入了R波索引,就在图上用蓝色圆点标注出来。
算心率
心率(BPM)可以通过计算连续R波之间的时间间隔(RR间期)来得到。我们计算每对连续R波之间的时间差,并将其转换为心率(BPM)。同时,我们也计算了整个信号段的平均心率。
异常检测
用前面的instant_heartrate计算出的瞬时心率,统计心动过速(>100 BPM)和心动过缓(<60 BPM)的频次。
HRV(心率变异性)是指连续心跳之间时间间隔的变化程度。我计算了RR间期的标准差(SDNN)作为HRV的一个指标。
Claude Code辅助与完善
下载.atr标注文件,新增load_annotations()方法。用wfdb.rdann()读取标注,过滤掉非搏动标注符号(+, ~, | 等),仅保留 19 种 AAMI 搏动类型。
evaluate.py — 评估核心模块,包含:
- match_peaks() — 逐一匹配检测点与标注点(150ms 容差,符合 ANSI/AAMI 标准)
- evaluate_detector() — 计算 Se、+P、F1
- print_evaluation() — 单条记录详细输出
- print_summary() — 多记录汇总表
ESP32 心率血氧计
环境配置
使用PlatformIO作为开发环境,需要在根目录下创建platformio.ini,这里我使用arduino框架。
主程序代码:src/main.cpp(第一阶段版本)
cpp
#include <Arduino.h>
#include <Wire.h> // I2C 通信库(ESP32 内置)
#include "MAX30105.h" // SparkFun MAX30102 驱动
MAX30105 sensor; // 创建传感器对象cpp41 lines
void setup() {
// 1. 初始化串口
Serial.begin(115200);
while (!Serial && millis() < 3000) { delay(10); }
Serial.println("=== ESP32 MAX30102 心率血氧监测 ===");
// 2. 通过 I2C 连接传感器
if (!sensor.begin(Wire, I2C_SPEED_STANDARD)) {
Serial.println("错误:未检测到 MAX30102!请检查接线。");
while (true) { delay(1000); }
}
Serial.println("传感器连接成功!");
// 3. 配置传感器
byte ledBrightness = 60; // LED 亮度 0~255
byte sampleAverage = 4; // 采样平均次数
byte ledMode = 2; // 2 = 红光+红外光(血氧需要双波长)
int sampleRate = 100; // 每秒采样 100 次
int pulseWidth = 411; // 脉冲宽度 411μs(18bit 分辨率)
int adcRange = 4096; // ADC 量程
sensor.setup(ledBrightness, sampleAverage, ledMode,
sampleRate, pulseWidth, adcRange);
Serial.println("请将手指轻放在传感器上...");
Serial.println("红光(Red)\t红外光(IR)");
}
void loop() {
uint32_t redValue = sensor.getRed(); // 红光原始值
uint32_t irValue = sensor.getIR(); // 红外光原始值
if (irValue < 50000) {
Serial.println("未检测到手指");
} else {
Serial.print(redValue);
Serial.print("\t");
Serial.println(irValue);
}
delay(20);
}