0 引言

年前买了一个MAX30102模块,在家无聊做了这个demo对一些相关的知识进行学习。

主要学习的内容:

  1. 光体积变化描记图(Photoplethysmogram, PPG)测量原理学习。
  2. ESP32 IDF平台的MAX30102驱动开发,主要是初始化配置与FIFO数据读取。
  3. Pyqt 利用 pyqtgraph 进行数据绘制。

实现的效果:

实现的思路:

  • ESP32 完成 MAX30102 的初始化配置与 红光/红外光 数据采集。
  • Pyqt上位机完成数据显示与简单的解析,得到心率与血氧。
  • 由于解析算法非常简单暴力,而且运行逻辑也不完善,所以只有手指位置比较好才能测量得到结果。

心率基本上正确,血氧图一乐。

ESP-IDF平台的学习记录可以参考 ESP32学习专栏

文章目录

  • 0 引言
  • 1 PPG测心率与血氧原理学习
    • 1.1 光体积变化描记图(Photoplethysmogram, PPG)
      • 1.1.1 PPG测量原理
      • 1.1.2 组织和血液对不同波长吸收系数
      • 1.1.3 PPG的影响因素
    • 1.2 PPG计算心率、血氧原理
      • 1.2.1 心率
      • 1.2.2 血氧
  • 2 ESP32 IDF平台的MAX30102驱动
    • 2.1 MAX30102 模块
    • 2.2 MAX30102 传感器
      • 2.2.1 MAX30102 的工作模式
      • 2.2.2 MAX30102 的中断类型
      • 2.2.3 MAX30102 的通信协议
    • 2.3 ESP32 IDF平台 MAX30102 驱动
      • 2.3.1 ESP32硬件初始化
      • 2.3.2 MAX30102 初始化配置
      • 2.3.3 MAX30102 数据读取
  • 3 基于Pyqt的上位机开发
    • 3.1 UI
    • 3.2 程序逻辑
  • 4 程序源码
  • 参考:

1 PPG测心率与血氧原理学习

1.1 光体积变化描记图(Photoplethysmogram, PPG)

1.1.1 PPG测量原理

LED发出光→心脏泵送血液、呼吸、体温等因素影响光的投射/反射→光电二极管采集光量转换成电信号→ADC采集得到PPG

PPG名义上仅用于确定心率,,可以通过透射吸收(如在指尖)或反射(如在前额)获得。

PPG波形的DC分量对应于来自组织的透射或反射光信号,并且取决于组织的结构以及动脉( artery )和静脉血液( venous blood )的平均体积。直流分量随呼吸而缓慢变化,而交流分量随心跳周期收缩期和舒张期之间的血容量变化而波动。交流分量的基频取决于心率(HR),并叠加在直流分量上。

1.1.2 组织和血液对不同波长吸收系数

脱氧血红蛋白Deoxy(RHb)、氧性血红蛋白Oxy(O2Hb)、羧性血红蛋白(COHb)和高铁血红蛋白(MetHb)在不同波长下的光吸收。

1.1.3 PPG的影响因素

PPG信号会受到光的波长、测量位置、接触力度、运动伪影、环境光强和环境温度的影响。

  1. 光的波长:可穿戴式PPG通常使用绿光,红外光穿透皮肤更深,但强度低.
  2. 测量位置:手指、耳朵,鼻中隔和前额等。
  3. 接触力度:在反射式PPG和接触式rPPG中,PPG信号波形可能受到传感器与测量部位接触力的影响。压力不足导致接触不足,从而导致交流信号幅值低。然而,在压力过大的条件下记录PPG信号,也会导致PPG探头外动脉闭塞导致交流信号幅值低,波形失真。
  4. 运动伪影:就是测量过程中不能乱动。

1.2 PPG计算心率、血氧原理

1.2.1 心率

统计出脉冲间隔就可以计算出心率。

1.2.2 血氧

血氧的概念

血红蛋白可分为正常血红蛋白和异常血红蛋白,正常血红蛋白能结合氧气,而异常血红蛋白不能结合氧气。正常血红蛋白包括RHb和O2Hb,而异常血红蛋白包括羧基血红蛋白(COHb)、高铁血红蛋白(MetHb)和硫血红蛋白(SHb)(图1)。总血红蛋白浓度(tHb)表示为:

tHb=O2Hb+RHb+MetHb+COHb+SHbtHb = O2Hb + RHb + Met Hb + COHb + SHb tHb=O2Hb+RHb+MetHb+COHb+SHb

SHb很少,可以从计算中省略。在正常情况下,只考虑能携带氧气的血红蛋白。因此,血红蛋白氧饱和度S为:

S=O2Hb∕(O2Hb+RHb)S = O2Hb∕(O2Hb + RHb)S=O2Hb∕(O2Hb+RHb)

血氧计算公式由来

用660nm (Red)和940nm(IR)的光测量动脉血液的传输信号振幅。

光吸收A为:

A≡log(I0I)=E∗C∗DA \equiv log(\frac{I_0}{I}) = E*C*DA≡log(II0​​)=E∗C∗D

I0I_0I0​: 入射光强度; III: 透射光强度
EEE: 吸光系数(dL/g/cm)
CCC: 浓度(g / dL)
DDD: 厚度(cm)

透射光强度差 ΔI\Delta IΔI为:

ΔA≡log(II−ΔI)=Eh∗Hb∗ΔD\Delta A \equiv log(\frac{I}{I-\Delta I}) = Eh*Hb*\Delta DΔA≡log(I−ΔII​)=Eh∗Hb∗ΔD
=ΔII−ΔI2=ACDC=\frac{\Delta I}{I - \frac{\Delta I}{2}} = \frac{AC}{DC}=I−2ΔI​ΔI​=DCAC​

HbHbHb: 血红蛋白浓度(g/dL)
EhEhEh: Hb的吸光系数 (dL/g/cm)
ΔD\Delta DΔD: 动脉血液厚度的变化(cm)

在用于脉搏血氧计测量的660和940nm处,除了氧和脱氧血红蛋白(Oxy 和 Deoxy)外,血液中其他组织的吸光度被忽略

660nm 吸收系数 Deoxy(约4) > Oxy(约0)
940nm 吸收系数 Oxy(约1.2) > Deoxy(约0.7)

所以Red和IR的吸光度差比值为

Φ=ΔARedΔAIR=ACRed/DCRedACIR/DCIR\Phi = \frac{\Delta A_{Red}}{\Delta A_{IR}}=\frac{AC_{Red}/DC_{Red}}{AC_{IR}/DC_{IR}}Φ=ΔAIR​ΔARed​​=ACIR​/DCIR​ACRed​/DCRed​​

这个等式清楚地表达了脉搏血氧计的特点,它测量的是动脉血液,而不考虑血液脉动或血红蛋白浓度的变化。

总血红蛋白浓度 tHb (RHb + O2Hb) 的吸光系数Eh可计算为Eo和Er的加权平均,对应于浓度比:

Eh=(Eo∗S+Er∗(1−S))Eh = (Eo*S + Er*(1-S))Eh=(Eo∗S+Er∗(1−S))

EoEoEo: 氧血红蛋白O2Hb吸光系数
ErErEr: 脱氧血红蛋白RHb吸光系数

又有
S=O2Hb∕(O2Hb+RHb)S = O2Hb∕(O2Hb + RHb)S=O2Hb∕(O2Hb+RHb)
O2Hb+RHb=1O2Hb + RHb=1O2Hb+RHb=1

所以有

Φ=ΔARedΔAIR=ACRed/DCRedACIR/DCIR=EoRed∗S+ErRed∗(1−S)EoIR∗S+ErIR∗(1−S)\Phi = \frac{\Delta A_{Red}}{\Delta A_{IR}}=\frac{AC_{Red}/DC_{Red}}{AC_{IR}/DC_{IR}}=\frac{Eo_{Red}*S + Er_{Red}*(1-S)}{Eo_{IR}*S + Er_{IR}*(1-S)}Φ=ΔAIR​ΔARed​​=ACIR​/DCIR​ACRed​/DCRed​​=EoIR​∗S+ErIR​∗(1−S)EoRed​∗S+ErRed​∗(1−S)​

EoRedEo_{Red}EoRed​、ErRedEr_{Red}ErRed​、EoIREo_{IR}EoIR​、ErIREr_{IR}ErIR​是常数,所以可以使用Φ\PhiΦ根据S对标准曲线进行校准,这样就可以从Φ\PhiΦ计算出S,可以测量S。

而MAX30102的厂家美信公司拟合的曲线为:

S=−45.060∗Φ∗Φ+30.354∗Φ+94.845S = -45.060 * \Phi * \Phi + 30.354 * \Phi + 94.845S=−45.060∗Φ∗Φ+30.354∗Φ+94.845

这就是采用这个公式计算的原因。

2 ESP32 IDF平台的MAX30102驱动

2.1 MAX30102 模块

网上MAX30102 模块的型号还是挺多的。

我都买的时候看着第一型设计的好像比较合理,将稳压电阻电容等元件都放在了PCB背面,正面只有一个传感器。所以选择了这个型号,几款型号的原理都是差不多的,都是几个稳压电路得到元件需要的5v和1.8v电压,然后引出传感器的IIC接口与一个中断信号引脚。

2.2 MAX30102 传感器

从上面的芯片结构图可以看到,MAX30102 分别有一个 红光RED 和 红外IR 发光二极管,按照一定的时序顺序的点亮这两个LED,投过手指后通过可见光+红外光光电二极管完成光强的采集,并且将光电信号通过一个ADC完成模数转换,另外温度数据也可以采集。这些数据采集好后按一定的时序通过IIC接口传输到控制器。

2.2.1 MAX30102 的工作模式

MAX30102 有三种工作模式:

  1. 血氧SpO2模式
    RED 和 IR 两个LED通道间隔工作并完成数据采集,将数据采集存入芯片内部FIFO中,芯片内部的FIFO可以存储32个采样点,每个采样点共 6byte 数据(RED 和 IR ADC值分别占用3Bytes)。完成一个采样点、或者FIFO达到配置的满条件后就会触发相关中断(相应中断使能需要配置为Enable)。
  2. 心率HR模式
    只有 IR 通道工作并完成数据采集。
  3. Multi-LED 模式
    按照配置的开关时长间隔的打开两个LED通道。

数据手册给出了血氧SpO2模式下的工作时序,这个是每次FIFO将满就读取一次数据的时序,后面具体实现的时候我用的是每次完成采样就读取一次的时序。

  • 事件1:配置工作模式为SpO2模式,同时使能寄存器Die Temperature ConfigTEMP_EN字段使能一次温度采集。
  • 事件2:完成温度数据采集,寄存器Interrupt Status 2DIE_TEMP_RDY中断标志字段被芯片拉高。
  • 事件3:读取温度数据,中断标志自动清除。
  • 事件4:FIFO将满,触发寄存器Interrupt Status 2DIE_TEMP_RDY中断标志字段
  • 事件5:读取FIFO数据,中断标志自动清除。
  • 事件6:新的采样周期。

本demo设计的工作时序如下图所示:

  • 事件1:配置工作模式为SpO2模式,同时使能寄存器Die Temperature ConfigTEMP_EN字段使能一次温度采集。
  • 事件2:完成温度数据读取。
  • 事件3:完成本次采样的FIFO数据读取。

即每隔10s获取一次温度数据,但是为了简化中断信号的处理,所以温度信息的采集不使用中断信号进行触发,而是间隔足够的时间(100ms)后直接读取温度数据。并初始化FIFO指针开始采集光电数据,后续每次收到中断信号,都代表本次数据采集完成待读取。

为什么要这样处理呢,因为如果需要每隔一段时间获取温度数据的话,配置温度采集使能的过程可能还会收到上一次光电数据采集完成的中断信号。使得中断信号不好区分。

2.2.2 MAX30102 的中断类型

介绍几个常用的中断:

  1. A_FULL: FIFO Almost Full Flag:
    SpO2 或 HR模式,如果FIFO中剩余空间达到 寄存器FIFO_A_FULL[3:0]配置的值,就会触发此中断标志,通过读取寄存器Interrupt Status 1(0x00)可以清除此中断标志。
  2. PPG_RDY: New FIFO Data Ready
    SpO2 或 HR模式,如果新的采样值写入了FIFO就会触发此中断,通过读取寄存器Interrupt Status 1(0x00)或读取FIFO数据可以清除此中断标志。
  3. DIE_TEMP_RDY: Internal Temperature Ready Flag
    温度数据采集完成触发此中断标志,通过读取寄存器Interrupt Status 2(0x01)或读取温度数据寄存器Die Temp Fraction(0x20)可以清除此中断标志。

值得注意的是,当通过配置寄存器Die Temperature Config(0x21)中的TEMP_EN字段使能一次温度采样后,DIE_TEMP_RDY中断会覆盖PPG_RDY中断。这样能确保开启温度采集后下一个中断表示的是温度数据采集完成。

2.2.3 MAX30102 的通信协议

数据手册说 MAX30102 的 IIC 接口最高支持 400kHz 速率。但我实测设为100KHz都工作异常,具体是工作时序还是数据传输时序问题没有深入研究。本demo设置为50kHz。

器件地址:

所以驱动中定义 MAX30102 的7bit器件地址为0x57。

写数据时序:

标准IIC时序,调用IDF平台的API函数i2c_master_write_to_device即可

esp_err_t i2c_master_write_to_device(i2c_port_t i2c_num, uint8_t device_address, const uint8_t *write_buffer, size_t write_size, TickType_t ticks_to_wait)参数:
i2c_num – I2C port number to perform the transfer on
device_address – I2C device's 7-bit address
write_buffer – Bytes to send on the bus
write_size – Size, in bytes, of the write buffer
ticks_to_wait – Maximum ticks to wait before issuing a timeout.

读数据时序:

标准IIC时序,调用IDF平台的API函数i2c_master_write_read_device即可

esp_err_t i2c_master_write_read_device(i2c_port_t i2c_num, uint8_t device_address, const uint8_t *write_buffer, size_t write_size, uint8_t *read_buffer, size_t read_size, TickType_t ticks_to_wait)参数:
i2c_num – I2C port number to perform the transfer on
device_address – I2C device's 7-bit address
write_buffer – Bytes to send on the bus
write_size – Size, in bytes, of the write buffer
read_buffer – Buffer to store the bytes received on the bus
read_size – Size, in bytes, of the read buffer
ticks_to_wait – Maximum ticks to wait before issuing a timeout.

2.3 ESP32 IDF平台 MAX30102 驱动

2.3.1 ESP32硬件初始化

ESP32硬件主要包含两个部分:IIC驱动 与 GPIO中断。

IIC驱动器初始化

配置IIC为 I2C_MODE_MASTER 模式。设置好SCL与SDA对应的GPIO后,就可以调用官方API i2c_param_config 进行配置,配置好后使用i2c_driver_install安装驱动。

IIC驱动初始化函数:

#define MAX30102_I2C_SCL                33 // GPIO number used for I2C master clock
#define MAX30102_I2C_SDA                32 // GPIO number used for I2C master data
#define MAX30102_I2C_NUM                0  // I2C master i2c port number, the number of i2c peripheral interfaces available will depend on the chip
#define MAX30102_I2C_FREQ_HZ            50000 // I2C master clock frequency
#define MAX30102_I2C_TX_BUF_DISABLE     0 // I2C master doesn't need buffer
#define MAX30102_I2C_RX_BUF_DISABLE     0
#define MAX30102_I2C_TIMEOUT_MS         1000/*** @brief init the i2c port for MAX30102*/
static esp_err_t max30102_i2c_init()
{int i2c_master_port = MAX30102_I2C_NUM;i2c_config_t conf = {.mode = I2C_MODE_MASTER,.sda_io_num = MAX30102_I2C_SDA,.scl_io_num = MAX30102_I2C_SCL,.sda_pullup_en = GPIO_PULLUP_ENABLE,.scl_pullup_en = GPIO_PULLUP_ENABLE,.master.clk_speed = MAX30102_I2C_FREQ_HZ,};i2c_param_config(i2c_master_port, &conf);return i2c_driver_install(i2c_master_port, conf.mode, MAX30102_I2C_RX_BUF_DISABLE, MAX30102_I2C_TX_BUF_DISABLE, 0);}

GPIO初始化

  1. 初始化GPIO配置参数结构体gpio_config_t,配置中断类型为下降沿触发GPIO_INTR_NEGEDGE,gpio模式为输入模式GPIO_MODE_INPUT,并且使能上拉功能。
  2. 调用api gpio_config 初始化GPIO。
  3. 创建一个队列gpio_evt_queue用于处理gpio中断事件。
  4. 创建一个任务gpio_intr_task作为中断处理函数。
  5. 调用api gpio_isr_handler_add为特定的gpio 引脚挂载isr处理程序

参考代码:

void gpio_intr_task()
{uint8_t byte[6];int data[2];uint8_t io_num;for(;;) {if(xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {ESP_ERROR_CHECK(max30102_register_read(0x07, &byte, 6));data[0] = ((byte[0]<<16 | byte[1]<<8 | byte[2]) & 0x03ffff);data[1] = ((byte[3]<<16 | byte[4]<<8 | byte[5]) & 0x03ffff);printf("Red: %d, IR: %d\n", data[0], data[1]);sample_cnt += 1;} }}static void IRAM_ATTR gpio_isr_handler(void* arg)
{uint32_t gpio_num = (uint32_t) arg;xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}/*** @brief init the gpio intr for MAX30102*/
static esp_err_t max30102_gpio_intr_init()
{gpio_config_t io_conf = {};io_conf.intr_type = GPIO_INTR_NEGEDGE;io_conf.mode = GPIO_MODE_INPUT;io_conf.pin_bit_mask = (1ULL<<MAX30102_GPIO_INT);io_conf.pull_down_en = 0;io_conf.pull_up_en = 1;ESP_ERROR_CHECK(gpio_config(&io_conf));//create a queue to handle gpio event from isr gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));//start gpio taskxTaskCreate(gpio_intr_task, "gpio_intr_task", 2048, NULL, 10, NULL);//install gpio isr servicegpio_install_isr_service(0);//hook isr handler for specific gpio pingpio_isr_handler_add(MAX30102_GPIO_INT, gpio_isr_handler, (void*) MAX30102_GPIO_INT);return ESP_OK;}

其中中断处理函数读取寄存器 FIFO Data Register(0x07) 7个字节的数据,即一个采样点的数据,按照协议前三个字节为Red channel,后3个字节为 IR channel。一般IR通道交流分量与直流分量的比例更大,但是不知道是我的传感器有问题还是其他方面的原因,我的实测结果是反的。

2.3.2 MAX30102 初始化配置

完成硬件初始化后,需要对传感器进行初始化配置。主要流程:

  1. 复位(可选)

  2. 寄存器0x02~0x03,使能需要的中断。我这里使能了A_FULL_ENPPG_RDY_ENDIE_TEMP_RDY_EN,所以寄存器Interrupt Enable 1(0x02) 配置为0xc0,寄存器Interrupt Enable 2(0x03) 配置为0x02,

  3. 寄存器0x04~0x06,清除FIFO指针。寄存器FIFO Write Pointer(0x04)FIFO Overflow Counter(0x05)FIFO Read Pointer(0x06)配置为0x00。

  4. 寄存器0x08,配置FIFO工作参数。寄存器FIFO Configuration (0x08)SMP_AVE[2:0]字段表示对采样值取平均,这里不做平均处理,所以配置为0b000;FIFO_ROLLOVER_EN字段控制如果数据满了需不需要循环填充,我这里配置为禁用;FIFO_A_FULL字段配置当触发FIFO满中断A_FULL时FIFO剩余空闲采样点的个数,这里配置为0xf,即还有15个空闲就触发满。所以寄存器配置为0x0f。

  5. 寄存器0x09,配置工作模式。寄存器Mode Configuration [0x09]MODE[2:0]字段配置为0b011,表示SpO2 模式。所以寄存器配置为 0x03

  6. 寄存器0x0a,配置SpO2 模式参数。寄存器SpO2 Configuration (0x0A)SPO2_ADC_RGE[1:0]字段控制ADC采样范围,LED_PW[1:0]字段配置LED发光脉冲长度与ADC采样精度,以及后面的LED电流大小这几个指标是相互权衡的。更高的精度需要更长的转换时间,所以对应的脉冲宽度需要更长,所以采样率需要小一点。如果LED电流更大,则采集到的光电信号范围会更大,所以需要实测来确定,这里配置ADC范围为8192nA,脉冲宽度为411.75us,对应ADC精度为18bit。SPO2_SR[2:0]字段配置采样率,这里设置为100次/秒。因此寄存器配置为0x47。可以设置的几套参数为:

  7. 寄存器0x0C~0x0D,设置LED脉冲电流值。这里设置为0x50,对应电流值为 16mA。(没有实测最佳值,根据ADC采样范围实测了一个合适的值)。

  8. 最后需要清空一下数据各种中断标志位,因为在上面还没有配置完成就已经开始数据采集了,如果不清除后面时序不好处理。

参考代码:

void max30102_init()
{ESP_ERROR_CHECK(max30102_i2c_init());ESP_LOGI(TAG, "MAX30102 I2C initialized successfully");max30102_gpio_intr_init();ESP_LOGI(TAG, "MAX30102 GPIO INTR initialized successfully");// resetESP_ERROR_CHECK(max30102_register_write_byte(0x09, 0x40)); vTaskDelay(100 / portTICK_RATE_MS);   // Interrupt EnableESP_ERROR_CHECK(max30102_register_write_byte(0x02, 0xc0)); // enable interrupts: A_FULL: FIFO Almost Full Flag and PPG_RDY: New FIFO Data ReadyESP_ERROR_CHECK(max30102_register_write_byte(0x03, 0x02)); // enable interrupt: DIE_TEMP_RDY: Internal Temperature Ready Flag // FIFOESP_ERROR_CHECK(max30102_register_write_byte(0x04, 0x00)); // clear FIFO Write PointerESP_ERROR_CHECK(max30102_register_write_byte(0x05, 0x00)); // clear FIFO Overflow CounterESP_ERROR_CHECK(max30102_register_write_byte(0x06, 0x00)); // clear FIFO Read Pointer // FIFO ConfigurationESP_ERROR_CHECK(max30102_register_write_byte(0x08, 0x0f)); // SMP_AVE = 0b000: 1 averaging, FIFO_ROLLOVER_EN = 0, FIFO_A_FULL = 0xf // Mode Configuration ESP_ERROR_CHECK(max30102_register_write_byte(0x09, 0x03)); // MODE = 0b011: SpO2 mode// SpO2 Configuration ESP_ERROR_CHECK(max30102_register_write_byte(0x0a, 0x47)); // SPO2_ADC_RGE = 0b10: 8192, SPO2_SR = 0b001: 100 SAMPLES PER SECOND, // LED_PW = 0b11: PULSE WIDTH 411, ADC RESOLUTION 18// LED Pulse AmplitudeESP_ERROR_CHECK(max30102_register_write_byte(0x0c, 0x50)); // LED1_PA(red) = 0x24, LED CURRENT 16mA ESP_ERROR_CHECK(max30102_register_write_byte(0x0d, 0x50)); // LED2_PA(IR) = 0x24, LED CURRENT 16mA // ESP_ERROR_CHECK(max30102_register_write_byte(0x10, 0x50)); // PILOT_PA = 0x24, LED CURRENT 16mA// clear PPG_RDY ! Cannot receive the first interrupt without clearing !  uint8_t data;ESP_ERROR_CHECK(max30102_register_read(0x00, &data, 1));ESP_LOGI(TAG, "Interrupt Status 1: 0x%x", data);ESP_ERROR_CHECK(max30102_register_read(0x01, &data, 1));ESP_LOGI(TAG, "Interrupt Status 2: 0x%x", data);
}

2.3.3 MAX30102 数据读取

不同ADC分辨率得到的数据格式如下图所示。

可以看到数据是左对齐的,因为血氧计算过程是线性的,所以全部当作18bit数据处理就行。

GPIO 中断服务函数:

void gpio_intr_task()
{uint8_t byte[6];int data[2];uint8_t io_num;for(;;) {if(xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {ESP_ERROR_CHECK(max30102_register_read(0x07, &byte, 6));data[0] = ((byte[0]<<16 | byte[1]<<8 | byte[2]) & 0x03ffff);data[1] = ((byte[3]<<16 | byte[4]<<8 | byte[5]) & 0x03ffff);printf("Red: %d, IR: %d\n", data[1], data[0]);} }
}

我这个传感器测到的data[0]交流/直流比data[1]的小3倍左右,刚好与正确的RED与IR通道相反,而且实测过程中明显能看出data[0]受温度影响大,只有当传感器被手指加热到二十多度后数据才能稳定。所以我实际是把这两个通道调换了一下的,不过这样肯定是不对的,具体的原因不太清楚,需要学友做个实验和我对比下,排除一下传感器的问题。

3 基于Pyqt的上位机开发

3.1 UI

值得注意的是添加QGraphicsView窗口后,需要右击提升为PlotWidget,这样配合 pyqtgraph 模块进行绘图比较方便。

3.2 程序逻辑

  1. 初始化,扫描串口列表添加到Combobox。
  2. 打开正确的串口后,对数据进行接收。
  3. 通过正则处理解析出RED、IR 两个LED 通道的值,以及每隔10s的温度值Temp。
  4. 将RED、IR数据通过pyqtgraph绘制在对应的窗口中。
  5. 每隔1s对数据进行解析,得到波峰与波谷的值,然后解析出RED、IR的AC、DC分量,并计算出心率、血氧值。
  6. 在label中刷新显示。

由于处理算法非常暴力垃圾,用了一个简单的判断来过滤波峰与波谷的位置,然后通过一个简单的相邻比较来得到更大值对应得那个波峰与波谷的位置与采样值。所以处理的结果异常值还是比较多的,不过这里也主要是学习pyqtgraph模块。在数据比较稳定的时候还是能够测量出血氧与心率的。

上位机源码:

#!/usr/bin/python3
# -*- coding: utf-8 -*-import sys,os,math
from ast import Tryfrom PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtWidgets import QApplication, QWidget, QMainWindow, QColorDialog ,QMessageBox,QLabel
from PyQt5.QtGui import QIcon, QImage, QPixmap, QColor
from PyQt5.QtCore import QTimer, QDateTime
import pyqtgraph as pg import numpy as np
import serial
from serial.tools import list_ports
import re from ui.Ui_mainWindow import Ui_MainWindowclass mainWindow(QMainWindow):def __init__(self):super().__init__()self.initUI()self.init()def initUI(self):self.ui = Ui_MainWindow()self.ui.setupUi(self)self.setWindowTitle('MAX30102')self.setWindowIcon(QIcon('logo.png'))self.ui.gv.setTitle("Red Channel")self.ui.gv1.setTitle("IR Channel")self.show()def init(self):self.ser = serial.Serial()self.receive_timer = QTimer(self) self.receive_timer.start(10)self.receive_timer.timeout.connect(self.dataReceive)self.initSerial()self.ui.btn_serial_scan.clicked.connect(self.initSerial)self.ui.btn_serial_ctrl.clicked.connect(self.serialCtrl) self.data_red = np.zeros(300)self.data_ir = np.zeros(300)self.time = 0self.hr = 0self.spo2 = 0self.curve = self.ui.gv.plot(self.data_red)self.curve1 = self.ui.gv1.plot(self.data_ir)self.data_anal_timer = QTimer(self)self.data_anal_timer.start(1000)self.data_anal_timer.timeout.connect(self.dataAnalyse)self.ui.statusbar.showMessage(QDateTime.currentDateTime().toString('hh:mm:ss ')+'打开软件')def initSerial(self):self.ui.comboBox_port.clear()self.port_list = list(serial.tools.list_ports.comports()) for port in self.port_list:self.ui.comboBox_port.addItem(port[0]+':'+port[1])def serialCtrl(self):if((self.ui.btn_serial_ctrl.text() == "打开串口") and (self.ser.is_open == False)): self.ser.port = self.port_list[self.ui.comboBox_port.currentIndex()][0]self.ser.baudrate = 115200self.ser.timeout = 0.5try:self.ser.open()self.ui.btn_serial_ctrl.setText('关闭串口')self.ui.btn_serial_scan.setDisabled(True)self.ui.statusbar.showMessage(QDateTime.currentDateTime().toString('hh:mm:ss ')+'打开串口:'+self.ser.port + ' baudrate = 115200')except serial.SerialException:self.ui.statusbar.showMessage(QDateTime.currentDateTime().toString('hh:mm:ss ')+'串口打开失败!')elif((self.ui.btn_serial_ctrl.text() == "关闭串口") and (self.ser.is_open == True)):try:self.ser.close()self.ui.btn_serial_ctrl.setText('打开串口')self.ui.btn_serial_scan.setDisabled(False)except serial.SerialException:self.ui.statusbar.showMessage(QDateTime.currentDateTime().toString('hh:mm:ss ')+'串口关闭失败!')def dataReceive(self):try:num = self.ser.inWaiting() #返回接收缓存中的字节数except:passelse:if num > 0:data_read = self.ser.read(num)reveive_num = len(data_read)self.dataRepack(data_read)self.ui.statusbar.showMessage(QDateTime.currentDateTime().toString('hh:mm:ss ')+'接收 '+ str(reveive_num) + 'Bytes')def dataRepack(self, data):try:red_re = re.search(r'(?<=Red: )\d+', data.decode('utf-8'))ir_re = re.search(r'(?<=IR: )\d+', data.decode('utf-8'))temp_re = re.search(r'(?<=Temp: )((\-|\+)?\d+(\.\d+)?)+', data.decode('utf-8'))if(red_re != None):self.red = float(red_re.group(0))if(ir_re != None):self.ir = float(ir_re.group(0))if(temp_re != None):self.temp = float(temp_re.group())self.dataDraw(self.red, self.ir)# print("red :", self.red, "ir :", self.ir)except Exception as se:print(str(se))def dataDraw(self, red, ir):self.data_red[:-1] = self.data_red[1:]self.data_red[-1] = redself.data_ir[:-1] = self.data_ir[1:]self.data_ir[-1] = irself.curve.setData(self.data_red)self.curve1.setData(self.data_ir)def dataAnalyse(self):data_red = self.data_reddata_ir = self.data_irhr_num = []valley_red_index = []valley_red_data = []valley_ir_index = []valley_ir_data = []peak_red_index = []peak_red_data = []peak_ir_index = []peak_ir_data = []# redvalley_pre = np.min(data_red)peak_pre = np.max(data_red)for i in range(3, 300-3):if((np.min(data_red[i-3:i-1]) >= data_red[i]) and (np.min(data_red[i+1:i+3]) > data_red[i])):valley = data_red[i]valley_gate = (valley + valley_pre) / 2valley_pre = valleyif(valley <= valley_gate):valley_red_index.append(i)valley_red_data.append(data_red[i])if((np.max(data_red[i-3:i-1]) <= data_red[i]) and (np.max(data_red[i+1:i+3]) < data_red[i])):peak = data_red[i]peak_gate = (peak + peak_pre) / 2peak_pre = peakif(peak >= peak_gate):peak_red_index.append(i)peak_red_data.append(data_red[i])# irvalley_pre = np.min(data_ir)peak_pre = np.max(data_ir)for i in range(3, 300-3):if((np.min(data_ir[i-3:i-1]) >= data_ir[i]) and (np.min(data_ir[i+1:i+3]) > data_ir[i])):valley = data_ir[i]valley_gate = (valley + valley_pre) / 2valley_pre = valleyif(valley <= valley_gate):valley_ir_index.append(i)valley_ir_data.append(data_ir[i])if((np.max(data_ir[i-3:i-1]) <= data_ir[i]) and (np.max(data_ir[i+1:i+3]) < data_ir[i])):peak = data_ir[i]peak_gate = (peak + peak_pre) / 2peak_pre = peakif(peak >= peak_gate):peak_ir_index.append(i)peak_ir_data.append(data_ir[i])# calc hrhr_num_mean = np.mean(np.diff(valley_ir_index))self.hr = 60 / (hr_num_mean * (1/100) )# calc spo2ac_red = np.mean(peak_red_data) - np.mean(valley_red_data)dc_red = np.mean(peak_red_data) - ac_red / 2ac_ir = np.mean(peak_ir_data) - np.mean(valley_ir_data)dc_ir = np.mean(peak_ir_data) - ac_ir / 2R = (ac_red / dc_red) / (ac_ir / dc_ir)# R =  (ac_ir / dc_ir) / (ac_red / dc_red)self.spo2 = -45.060 * R * R + 30.354 * R + 94.845# print("ACred %d, DCred: %d, ACir %d, DCir %d , spo2 %f \n" % (round(ac_red), round(dc_red), round(ac_ir), round(dc_ir), self.spo2))if((hr_num_mean >= 30) and (hr_num_mean <= 120)):self.ui.label_hr.setText('心率:'+str(round(self.hr, 1)) + '  BPM')else:self.ui.label_hr.setText('心率:')if((hr_num_mean >= 30) and (hr_num_mean <= 120) and (self.spo2 >= 0) and (self.spo2 <= 100)):self.ui.label_spo2.setText('血氧:'+str(round(self.spo2, 1)) + ' %')else:self.ui.label_spo2.setText('血氧:') self.ui.label_temp.setText('温度:' + str(round(self.temp, 1)) + ' ℃') def closeEvent(self, a0: QtGui.QCloseEvent) -> None:reply = QMessageBox.question(self, 'Message', "确认退出?",QMessageBox.Yes | QMessageBox.No, QMessageBox.No)if reply == QMessageBox.Yes:a0.accept()else:a0.ignore()if __name__ == '__main__':app = QApplication(sys.argv)main_window = mainWindow()sys.exit(app.exec_())

4 程序源码

ESP32( IDF平台)+MAX30102 配合Pyqt上位机实现PPG波形显示与心率计算
https://download.csdn.net/download/lum250/87397911

参考:

  1. “Photoplethysmogram”
  2. 《Current progress of photoplethysmography and SPO2 for health monitoring》
  3. 《MAX30102 datasheet》

ESP32( IDF平台)+MAX30102 配合Pyqt上位机实现PPG波形显示与心率计算相关推荐

  1. QT从零开始作单片机上位机-串口调试助手+波形显示-实现串口模块的配置

    目录 实现串口模块的配置(1) 一.先列举需要思考的问题: 二.所有的问题要由简单到复杂逐一解决 实现串口模块的配置(1) 完成了基本的界面设计后,我们就要着手实现功能.下来的几章我们看串口数据收发的 ...

  2. QT从零开始作单片机上位机-串口调试助手+波形显示-实现串口模块的配置(2)

    QT实现串口模块的配置(2) 一.先列举需要思考的问题: 怎么实现点击刷新按钮后,串口工具可以自动发现可用的COM口,并将com口显示在Combobox1? 如何添加所有可用的波特率?同样给其他Com ...

  3. esp32系列(11):ESP32 IDF平台 mpu6050 DMP 驱动移植及测试上位机开发

    目录 1 DMP 官方库介绍 1.1 DMP与MPL(Motion Processing Libraries)功能 1.2 运行MPL的硬件要求 1.3 Motion Driver 6.12 的架构 ...

  4. 物联网云平台DTU Modbus协议 上位机 下位机 源码 源代码 程序

    物联网云平台DTU Modbus协议 上位机 下位机 源码 源代码 程序 一.源码的使用基本说明: 1.1 编译语言: 下位机使用C语言:上位机使用C#语言 标准Modbus Slave通信下位机源码 ...

  5. C#之windows桌面软件第十一课:电脑ADC值显示(上位机)(多通道显示)

    C#之windows桌面软件第十一课:电脑ADC值显示(上位机)(多通道显示) using System; using System.Collections.Generic; using System ...

  6. QT上位机串口实时温湿度显示

    STM32与上位机通信协议--UART协议: 串行通讯需要有通信协议 通信协议:规定发送与接收方,通信的方式与要求,数据的格式 由RXD和TXD两条线,由于没有时钟线,所以需要规定波特率 数据传输速率 ...

  7. 基于uFUN开发板的心率计(三)Qt上位机的实现

    前言 上两周利用周末的时间,分别写了基于uFUN开发板的心率计(一)DMA方式获取传感器数据和基于uFUN开发板的心率计(二)动态阈值算法获取心率值,介绍了AD采集传感器数据和数据的滤波处理获取心率值 ...

  8. 【科普】干货!带你从0了解移动机器人(三) ——自主导航系统及上位机软件设计与实现

    随着机器人技术的不断发展,我们可以在许多简单重复,危险的岗位上看到机器人的身影,移动机器人凭借其在复杂环境下工作,具有自行感知.自行规划.自我决策功能的能力,它可以在不同的环境中移动并执行任务,在人类 ...

  9. 1.1-做了这么久,才知道什么是上位机

    一.定义 上位机: 上位机指可以直接发送操作指令的计算机或单片机,一般提供用户操作交互界面并向用户展示反馈数据. 典型设备类型:电脑,手机,平板,面板,触摸屏 下位机: 下位机指直接与机器相连接的计算 ...

最新文章

  1. 不需要借助GPU的力量,用树莓派也能实时训练agent玩Atari
  2. 扎心!程序员旅行却只能紧紧抱着电脑加班?
  3. JavaScript引用方法说明
  4. [转]Python测试框架对比----unittest, pytest, nose, robot framework对比
  5. PKI/CA (4)根CA信任模型“证书构建”
  6. Managing the Lifecycle of a Bound Service
  7. java判断字符串中是否包含字母
  8. IOS开发--TextField
  9. 数据结构---二叉排序树
  10. 怎么看mysql的最大连接数_怎么查看和修改 MySQL 的最大连接数?
  11. 注册表在各个系统中保存路径
  12. (04)VHDL实现打两拍
  13. vue保存页面的值_vue前端页面跳转参数传递及存储
  14. CentOS7的安装和配置
  15. opencv算法精解 c++/python
  16. windows全系1
  17. 2022-2028年中国美容美发行业现状调研与未来前景趋势报告
  18. sql简介香气和sql简介_香气和SQL简介
  19. Xdebug中文文档-安装
  20. 用友u8服务器优化,用友U8erp软件运行的性能优化方案图文教程

热门文章

  1. 4月8日--Date的使用方法
  2. t568a/t568b的线序
  3. 分库分表基础知识总结
  4. jQuery 选择器(checked)详解
  5. 不用机器学习不用大数据,给你讲通ChatGPT的深层原理
  6. 30天自制操作系统Day8
  7. Cookie Session跨站无法共享问题(单点登录解决方案)
  8. Acwing 238. 银河英雄传说
  9. 什么是性格不良?如何自我分析性格不良?
  10. 毕业设计-基于 MATLAB 的工业机器人运动学分析与实现