基于ESP32与光电传感器的心率监测系统:从信号处理到实时显示
1. 项目缘起从“免费试用”到心率监测实战最近在DFRobot的社区活动里申请到了他们9月份的免费试用名额拿到了一块心率传感器模块。说实话这类传感器在创客圈和健康监测领域一直挺火的但很多人拿到手可能就测个数值看看波形然后就放一边吃灰了。我这次的目标不一样我想把它用起来做成一个能实际跑起来、数据能看、能分析的小项目看看从开箱到出结果到底会踩哪些坑又能挖出哪些实用的玩法。心率传感器听起来是个医疗设备但在我们电子爱好者和开发者手里它更像一个通往生物信号世界的窗口。通过它我们能捕捉到指尖或耳垂处微弱的血流变化这些变化对应着心脏的搏动。市面上常见的光电式心率传感器比如我手头这款原理上都是利用血液对特定波长光线通常是绿光的吸收率会随着心跳周期变化的特性。心脏收缩时血液充盈吸收的光多反射回来的光就少心脏舒张时则相反。传感器里的光电二极管捕捉到这个反射光强度的周期性变化再经过一系列模拟和数字信号处理最终换算成我们看到的“心率值”也就是每分钟心跳次数BPM。这个过程听起来简单但真要把它稳定、准确地用起来里面门道不少。环境光干扰怎么处理运动伪影比如手抖怎么滤除传感器贴合的松紧度对数据有多大影响原始数据怎么处理才能得到更可靠的BPM这些都是实操中必须面对的问题。我这次就以DFRobot的这款传感器为例子把从硬件连接到数据处理、再到可视化呈现的完整链条走一遍分享其中具体的步骤、关键的代码、遇到的麻烦以及最终的解决方案。2. 硬件开箱与核心电路连接要点我拿到的是DFRobot的一款常见的光电式心率传感器模块型号通常是SEN0203或类似。模块不大正面最显眼的是那个凸起的、半圆形的感应区域里面集成了绿光LED和光电接收器。模块通过标准的Gravity接口PH2.0-3P引出三根线VCC电源正极、GND电源地、S信号线。2.1 供电与接口选择首先说供电。这类传感器对电源的稳定性比较敏感电压波动或噪声容易直接反映在信号上。模块的工作电压一般是3.3V或5V兼容具体要看型号说明。我建议优先使用3.3V供电因为很多微控制器如ESP32、STM32的模拟输入引脚参考电压也是3.3V这样能避免额外的电平转换问题信号匹配更直接。如果使用5V系统比如经典的Arduino Uno需要注意其模拟输入引脚能承受5V电压但传感器模块本身是否支持5V供电务必查阅数据手册。稳妥起见用一个LDO低压差线性稳压器从5V降压到3.3V给传感器供电是更专业的做法。信号线S输出的是模拟信号。这意味着它的电压值会在一个范围内比如0-3.3V随着心跳引起的血流变化而波动。我们需要用微控制器的模拟输入引脚ADC来读取这个电压值。2.2 与微控制器的连接我选择了ESP32开发板作为主控原因有几个一是它自带Wi-Fi和蓝牙方便后续做无线数据传输和手机APP显示二是它的ADC精度相对较高并且有丰富的库和社区支持三是它有多核心可以一个核心专用于高频采集传感器数据另一个核心处理逻辑和通信互不干扰。连接非常简单传感器VCC - ESP32的3.3V引脚。传感器GND - ESP32的GND引脚。传感器S信号线 - ESP32的某个ADC引脚我选择了GPIO34ADC1_CH6。这里有个细节ESP32的ADC在默认情况下可能存在非线性问题尤其是在电压接近0V或3.3V时。为了获得更好的精度可以在代码里进行校准或者使用一个简单的分压电路将传感器输出信号限制在ADC的最佳线性区间内例如1.0V-2.0V。不过对于心率这种相对变化量初始阶段我们可以先接受一定的绝对误差重点先捕捉波形形态。2.3 佩戴与信号质量基础硬件连接好下一步就是佩戴。光电心率传感器的信号质量极度依赖测量部位的血流灌注程度和贴合稳定性。最佳部位指尖、耳垂是传统选择血流丰富信号强。手腕内侧桡动脉附近也可以但需要更紧的贴合和更精密的算法来对抗运动噪声。贴合压力压力要适中。太松环境光会漏进去干扰太紧会把血管压瘪反而导致血流信号减弱甚至消失。以指尖为例轻轻按住传感器感觉到有压力但指尖不觉得发白、发麻为宜。初始状态保持静止刚开始测量的几十秒身体和传感器处于“磨合期”信号可能很不稳定这是正常的。耐心等待波形变得有规律。连接好后我们可以先写一段最简单的代码仅仅是把ADC读取的原始值通过串口打印出来用串口绘图器看看波形。这是验证硬件连接和获取第一手信号的最直接方法。// ESP32 心率传感器原始信号读取示例 const int heartRatePin 34; // 信号线连接的GPIO void setup() { Serial.begin(115200); analogReadResolution(12); // 设置ESP32 ADC为12位精度 (0-4095) // 注意ESP32的ADC参考电压默认为3.3V但实际可能有偏差 } void loop() { int sensorValue analogRead(heartRatePin); Serial.println(sensorValue); // 将原始值输出到串口绘图器 delay(10); // 约100Hz的采样率对于心率初步观察足够 }打开Arduino IDE的串口绘图器工具 - 串口绘图器你应该能看到一个上下波动的曲线。如果曲线是平直的检查连接和供电如果曲线杂乱无章但有一定频率恭喜你心跳信号很可能就在里面只是被噪声淹没了。3. 从原始噪声到清晰波形的信号处理链路直接从ADC读出来的数据我们称之为原始信号Raw Signal。它通常包含三种主要成分心率信号我们想要的、频率大约在0.8Hz到3Hz对应48到180 BPM的准周期信号。基线漂移由于呼吸、身体轻微移动、传感器温度变化等引起的缓慢变化的低频干扰通常低于0.5Hz。高频噪声包括电源的50/60Hz工频干扰、肌肉电噪声、环境光突变等。我们的任务就是设计一个“滤波器链”把心率信号尽可能地提取出来。3.1 第一步直流分量去除与信号归一化原始信号有一个固定的直流偏置比如稳定在1500左右。我们更关心的是围绕这个偏置的波动。因此第一步是计算一小段时间窗口比如2秒内信号的平均值然后用每个瞬时值减去这个移动平均值。这能有效消除基线漂移的一部分并将信号中心化到0附近。// 简单的移动平均去直流示例非最优用于理解原理 const int windowSize 200; // 假设采样率100Hz窗口为2秒 int dataWindow[windowSize]; int windowIndex 0; long windowSum 0; int removeDCOffset(int rawValue) { // 减去旧的窗口值加上新的值 windowSum windowSum - dataWindow[windowIndex] rawValue; dataWindow[windowIndex] rawValue; windowIndex (windowIndex 1) % windowSize; int movingAverage windowSum / windowSize; return rawValue - movingAverage; // 中心化后的信号 }3.2 第二步数字带通滤波——抓住心跳的频率范围这是最关键的一步。我们需要一个数字带通滤波器只允许0.8Hz到3Hz或更宽一些如0.7Hz到4Hz的频率成分通过同时强力抑制更低频的漂移和更高频的噪声。在嵌入式系统上IIR无限脉冲响应滤波器因其计算效率高而被广泛使用。一个二阶或四阶的巴特沃斯带通滤波器是常见选择。这里不展开复杂的滤波器系数计算我们可以利用一个优秀的开源库 “ArduinoFilter” byluni64。下面展示如何用它构建一个带通滤波器。首先需要在Arduino库管理中搜索并安装 “ArduinoFilter”。#include Arduino_Filters.h // 设计一个带通滤波器低截止频率0.7Hz高截止频率4.0Hz采样率100Hz const float samplingRate 100.0; const float lowCutoffHz 0.7; const float highCutoffHz 4.0; // 创建一个二阶带通滤波器 IIR::BandPass2 bandPassFilter; // 2表示二阶 void setup() { Serial.begin(115200); // 配置滤波器参数 bandPassFilter.setBandPass(IIR::BUTTERWORTH, samplingRate, lowCutoffHz, highCutoffHz); } void loop() { int rawValue analogRead(heartRatePin); int centeredValue removeDCOffset(rawValue); // 使用上文提到的函数 // 应用带通滤波 float filteredValue bandPassFilter.filter((float)centeredValue); // 将滤波后的值输出给串口绘图器观察 Serial.print(rawValue); Serial.print(,); Serial.println(filteredValue); // 可以在绘图器中看到两条曲线对比 delay(10); // 维持约100Hz采样 }应用带通滤波后你会看到串口绘图器里的波形变得“干净”很多呈现出清晰的、周期性的尖峰每个尖峰大致对应一次心跳。3.3 第三步动态阈值与峰值检测得到干净的波形后下一步是检测波峰或波谷从而计算心跳间隔RR间期。直接找最大值在噪声面前很脆弱。更鲁棒的方法是使用动态阈值法。信号整流如果波形有正有负先取绝对值或平方确保所有峰值朝上。计算动态阈值维护一个移动平均值MA作为阈值基线。这个移动平均值的计算窗口要远大于心跳周期比如5秒这样它能跟随信号的整体能量水平变化但又不会对单个峰值立即响应。峰值判定当整流后的信号值超过“动态阈值 * 一个系数如1.5”时认为检测到一个候选峰值。检测到峰值后设置一个“不应期”Refractory Period例如300毫秒在这段时间内即使信号再高也不认为是新峰值以避免一个心跳产生多个误报。记录时间戳每当确认一个有效峰值就记录当前时间微秒或毫秒。连续两个峰值的时间差就是一个RR间期单位毫秒。// 峰值检测状态变量 unsigned long lastPeakTime 0; const unsigned long refractoryPeriod 300; // 毫秒 float threshold 0.0; const float thresholdMultiplier 1.5; const int thresholdWindow 500; // 用于计算动态阈值的窗口大小样本数 float thresholdBuffer[thresholdWindow]; int threshIndex 0; float threshSum 0; bool detectPeak(float filteredSignal) { float rectified abs(filteredSignal); // 整流 // 更新动态阈值移动平均 threshSum threshSum - thresholdBuffer[threshIndex] rectified; thresholdBuffer[threshIndex] rectified; threshIndex (threshIndex 1) % thresholdWindow; threshold threshSum / thresholdWindow; unsigned long now millis(); // 检查是否在不应期内 if (now - lastPeakTime refractoryPeriod) { return false; } // 检查是否超过阈值 if (rectified threshold * thresholdMultiplier) { lastPeakTime now; return true; } return false; }4. 心率计算、优化与实时显示系统搭建检测到峰值拿到了RR间期计算心率BPM就很简单了BPM 60000 / RR_interval_ms。但是单次RR间期计算出的BPM会随着每次心跳的微小变化而剧烈跳动这显然不是我们想要的。我们需要的是稳定、平滑的心率值。4.1 心率平滑与异常值剔除常用的方法是维护一个最近N个比如8个RR间期的缓冲区计算它们的平均值或中位数然后用这个统计值来计算BPM。中位数比平均值更能抵抗偶然的异常值漏检或多检的峰值。const int rrBufferSize 8; unsigned long rrBuffer[rrBufferSize]; int rrIndex 0; void updateBPM(unsigned long rrInterval) { // 1. 将新的RR间期存入循环缓冲区 rrBuffer[rrIndex] rrInterval; rrIndex (rrIndex 1) % rrBufferSize; // 2. 复制缓冲区并排序求中位数 unsigned long sortedBuffer[rrBufferSize]; for (int i 0; i rrBufferSize; i) sortedBuffer[i] rrBuffer[i]; // 这里应实现一个简单的排序算法如插入排序代码略 // 假设 sortedBuffer 已排序 unsigned long medianRR; if (rrBufferSize % 2 0) { medianRR (sortedBuffer[rrBufferSize/2 - 1] sortedBuffer[rrBufferSize/2]) / 2; } else { medianRR sortedBuffer[rrBufferSize/2]; } // 3. 由中位数RR间期计算BPM float currentBPM 60000.0 / medianRR; // 4. 生理范围限制例如30-200 BPM if (currentBPM 30.0 || currentBPM 200.0) { // 视为无效数据可能保持上一次的有效值或显示“--” return; } // 5. 进一步平滑可以与上一次的有效BPM进行加权平均 static float smoothedBPM 0; const float alpha 0.2; // 平滑因子越小越平滑响应越慢 smoothedBPM alpha * currentBPM (1 - alpha) * smoothedBPM; // 最终smoothedBPM 就是可以显示或上报的心率值 Serial.print(HR: ); Serial.println(smoothedBPM, 1); // 打印一位小数 }4.2 构建本地实时显示系统有了稳定的BPM值我们可以把它显示出来。最直接的方式是使用一块OLED屏幕如SSD1306驱动的0.96寸屏。将ESP32、心率传感器和OLED屏连接起来就能做一个独立的可穿戴心率监测原型。接线增加OLED SDA - ESP32 GPIO21OLED SCL - ESP32 GPIO22需要安装Adafruit SSD1306和Adafruit GFX库。#include Wire.h #include Adafruit_GFX.h #include Adafruit_SSD1306.h #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, Wire, -1); void setup() { // ... 其他初始化代码 ... if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F(SSD1306 allocation failed)); for(;;); } display.clearDisplay(); display.setTextSize(2); display.setTextColor(SSD1306_WHITE); } void loop() { // ... 信号采集、滤波、峰值检测、计算BPM的代码 ... // 在OLED上显示 display.clearDisplay(); display.setCursor(0, 0); display.print(Heart Rate:); display.setCursor(0, 30); display.setTextSize(4); if (smoothedBPM 30 smoothedBPM 200) { display.print(smoothedBPM, 0); // 显示整数BPM display.setTextSize(2); display.print( BPM); } else { display.print(--); } display.display(); }4.3 数据无线传输与云端可视化进阶如果想在手机或电脑上远程查看甚至记录历史数据就需要用到无线传输。ESP32的Wi-Fi能力可以派上用场。Web服务器模式ESP32作为一个微型Web服务器手机或电脑连接其热点或同一局域网后用浏览器访问一个IP地址就能看到一个实时刷新心率数据的网页。MQTT推送模式ESP32作为客户端将心率数据发布到MQTT代理服务器如本地搭建的Mosquitto或云服务如EMQX Cloud、阿里云IoT。然后在任何能连接MQTT的设备上电脑、手机APP、Node-RED仪表盘订阅这个主题就能收到实时数据并做可视化。蓝牙串口SPP最简单的方式将ESP32设置为蓝牙串口手机通过蓝牙APP如Serial Bluetooth Terminal连接后就能接收文本格式的心率数据。这里以Web服务器模式为例给出一个极简的框架#include WiFi.h #include WebServer.h const char* ssid Your_SSID; const char* password Your_PASSWORD; WebServer server(80); float currentHR 0.0; void handleRoot() { String html !DOCTYPE htmlhtmlheadmeta http-equivrefresh content2/headbody; // 每2秒刷新 html h1Live Heart Rate/h1; html p stylefont-size:48px; color:red;; html String(currentHR, 1); html BPM/p; html /body/html; server.send(200, text/html, html); } void setup() { // ... 传感器、滤波器初始化 ... WiFi.begin(ssid, password); while (WiFi.status() ! WL_CONNECTED) delay(500); server.on(/, handleRoot); server.begin(); } void loop() { server.handleClient(); // 处理Web客户端请求 // ... 原有的心率测量循环 ... // 在计算出 smoothedBPM 后赋值给 currentHR currentHR smoothedBPM; delay(10); }5. 实测中的典型问题、调试技巧与优化方向理论很美好实测总会遇到各种问题。下面是我在调试过程中遇到的几个典型情况及其应对策略。5.1 信号弱或无信号症状串口绘图器波形幅度很小 10个ADC单位或近乎直线。排查佩戴首先检查佩戴。换一个手指调整按压力度。确保传感器发光面紧贴皮肤没有缝隙。环境光在强光下测试用手或遮光物完全罩住传感器部位看信号是否有变化。光电传感器对环境光非常敏感必须在遮光条件下工作。供电用万用表测量传感器VCC和GND之间的电压确保是稳定的3.3V。电压不足会导致LED亮度不够信号弱。代码检查ADC引脚配置和读取代码是否正确。尝试用analogReadMillivolts()函数直接读取电压值看是否在合理范围内变化。5.2 波形杂乱峰值难以辨认症状有波动但噪声很大看不出明显周期。排查与解决运动伪影这是最大的干扰源。确保测试时手臂和手指完全静止。将传感器固定在手指上而不是手持能极大改善。滤波器参数检查带通滤波器的截止频率设置是否正确。如果低截止频率太高比如设成了2Hz可能会滤掉部分心率信号如果高截止频率太低则滤除高频噪声不够。可以尝试调整带宽例如从(0.7, 4.0)调到(0.5, 5.0)Hz。采样率采样率太低奈奎斯特定理无法有效捕捉信号太高则会引入更多高频噪声。对于心率100-250Hz的采样率是合适的。确保delay()或定时器中断设置的采样间隔稳定。电源噪声如果使用开发板的USB供电且电脑接地不良可能会引入工频干扰。尝试用电池供电或者给传感器的电源线上并联一个10uF和0.1uF的电容进行退耦。5.3 心率值跳变剧烈不稳定症状BPM数值在几秒内从70跳到90又跳回60没有规律。排查与解决峰值检测算法动态阈值的系数thresholdMultiplier可能需要调整。如果系数太小噪声可能被误判为峰值系数太大可能漏检微弱的真实峰值。可以尝试在串口输出原始信号、滤波后信号和阈值线在绘图器中观察调整。不应期Refractory Period这个值必须设置。它应略小于可能的最短RR间期对应最高心率。对于180 BPMRR间期约333ms所以不应期设为250-300ms是安全的。确保它在代码中生效。平滑算法RR间期缓冲区大小rrBufferSize和平滑因子alpha是关键。缓冲区太小如4平滑效果差太大如20响应会非常迟钝。通常8-12是个不错的起点。平滑因子alpha在0.1到0.3之间调整找到响应速度和稳定性的平衡点。生理范围过滤务必加入BPM的合理范围检查如30-200 BPM将明显超出范围的瞬时计算结果丢弃不参与平滑。5.4 长期漂移与自动增益调整在长时间测量中可能会发现信号的整体幅度即血流灌注强度会缓慢变化导致之前设定的固定阈值失效。一个更高级的技巧是引入自动增益控制AGC的思想不仅阈值是动态的判断峰值所用的乘数系数也可以动态调整。例如当连续一段时间检测不到峰值时可以逐步降低阈值系数当检测到的峰值间隔方差过大时可以适当提高系数。这能让算法在不同人、不同状态下有更好的适应性。5.5 进阶优化方向当基本功能稳定后可以考虑以下方向提升项目的实用性和可靠性心率变异性HRV分析RR间期序列本身包含丰富的信息。计算SDNN相邻RR间期差值的标准差、RMSSD等指标可以粗略评估压力或恢复状态。这需要更精确的峰值检测和更长的数据记录通常需要5分钟以上。利用PPG信号形态光电体积脉搏波PPG的波形有其特征主波、潮波、重搏波。通过分析波形的特征点结合机器学习如在小内存设备上运行的TinyML可以实现更鲁棒的峰值检测甚至血压趋势的粗略估计研究阶段。低功耗设计如果用于可穿戴设备功耗是关键。可以周期性地测量如每10秒测5秒其余时间让ESP32和传感器进入深度睡眠。使用峰值检测中断来唤醒系统而不是持续采样。多传感器融合结合加速度计数据可以识别运动状态静止、行走、跑步并针对不同运动状态采用不同的滤波参数或算法这就是所谓的“运动抗干扰”算法是消费级心率手环的核心技术之一。折腾完这一整套从硬件连接到算法调试再到数据显示你会发现一个简单的心率传感器背后涉及了模拟电路、数字信号处理、嵌入式编程、无线通信等多个领域的知识。它不再是一个只能输出数字的黑盒子而是一个你可以完全掌控、观察并优化的生物信号采集系统。这种从底层信号开始亲手搭建起一个可用的生理参数监测原型的过程其收获远大于仅仅调用一个现成的库函数。希望我的这些踩坑经验和实现细节能帮你更顺利地把手头的心率传感器用活、用好。