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

news2025/1/14 18:00:37

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)表示为:

t H b = O 2 H b + R H b + M e t H b + C O H b + S H b tHb = O2Hb + RHb + Met Hb + COHb + SHb tHb=O2Hb+RHb+MetHb+COHb+SHb

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

S = O 2 H b ∕ ( O 2 H b + R H b ) S = O2Hb∕(O2Hb + RHb) S=O2Hb(O2Hb+RHb)

血氧计算公式由来

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

光吸收A为:

A ≡ l o g ( I 0 I ) = E ∗ C ∗ D A \equiv log(\frac{I_0}{I}) = E*C*D Alog(II0)=ECD

I 0 I_0 I0: 入射光强度; I I I: 透射光强度
E E E: 吸光系数(dL/g/cm)
C C C: 浓度(g / dL)
D D D: 厚度(cm)

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

Δ A ≡ l o g ( I I − Δ I ) = E h ∗ H b ∗ Δ D \Delta A \equiv log(\frac{I}{I-\Delta I}) = Eh*Hb*\Delta D ΔAlog(IΔII)=EhHbΔD
= Δ I I − Δ I 2 = A C D C =\frac{\Delta I}{I - \frac{\Delta I}{2}} = \frac{AC}{DC} =I2ΔIΔI=DCAC

H b Hb Hb: 血红蛋白浓度(g/dL)
E h Eh Eh: 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的吸光度差比值为

Φ = Δ A R e d Δ A I R = A C R e d / D C R e d A C I R / D C I R \Phi = \frac{\Delta A_{Red}}{\Delta A_{IR}}=\frac{AC_{Red}/DC_{Red}}{AC_{IR}/DC_{IR}} Φ=ΔAIRΔARed=ACIR/DCIRACRed/DCRed

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

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

E h = ( E o ∗ S + E r ∗ ( 1 − S ) ) Eh = (Eo*S + Er*(1-S)) Eh=(EoS+Er(1S))

E o Eo Eo: 氧血红蛋白O2Hb吸光系数
E r Er Er: 脱氧血红蛋白RHb吸光系数

又有
S = O 2 H b ∕ ( O 2 H b + R H b ) S = O2Hb∕(O2Hb + RHb) S=O2Hb(O2Hb+RHb)
O 2 H b + R H b = 1 O2Hb + RHb=1 O2Hb+RHb=1

所以有

Φ = Δ A R e d Δ A I R = A C R e d / D C R e d A C I R / D C I R = E o R e d ∗ S + E r R e d ∗ ( 1 − S ) E o I R ∗ S + E r I R ∗ ( 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/DCIRACRed/DCRed=EoIRS+ErIR(1S)EoRedS+ErRed(1S)

E o R e d Eo_{Red} EoRed E r R e d Er_{Red} ErRed E o I R Eo_{IR} EoIR E r I R Er_{IR} ErIR是常数,所以可以使用 Φ \Phi Φ根据S对标准曲线进行校准,这样就可以从 Φ \Phi Φ计算出S,可以测量S。

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

S = − 45.060 ∗ Φ ∗ Φ + 30.354 ∗ Φ + 94.845 S = -45.060 * \Phi * \Phi + 30.354 * \Phi + 94.845 S=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 task
    xTaskCreate(gpio_intr_task, "gpio_intr_task", 2048, NULL, 10, NULL);
    //install gpio isr service
    gpio_install_isr_service(0);
    //hook isr handler for specific gpio pin
    gpio_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");

    // reset
    ESP_ERROR_CHECK(max30102_register_write_byte(0x09, 0x40)); 
    vTaskDelay(100 / portTICK_RATE_MS);   

    // Interrupt Enable
    ESP_ERROR_CHECK(max30102_register_write_byte(0x02, 0xc0)); // enable interrupts: A_FULL: FIFO Almost Full Flag and PPG_RDY: New FIFO Data Ready
    ESP_ERROR_CHECK(max30102_register_write_byte(0x03, 0x02)); // enable interrupt: DIE_TEMP_RDY: Internal Temperature Ready Flag 

    // FIFO
    ESP_ERROR_CHECK(max30102_register_write_byte(0x04, 0x00)); // clear FIFO Write Pointer
    ESP_ERROR_CHECK(max30102_register_write_byte(0x05, 0x00)); // clear FIFO Overflow Counter
    ESP_ERROR_CHECK(max30102_register_write_byte(0x06, 0x00)); // clear FIFO Read Pointer 

    // FIFO Configuration
    ESP_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 Amplitude
    ESP_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 Try

from 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_MainWindow

class 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 = 0
        self.hr = 0
        self.spo2 = 0
        self.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 = 115200
            self.ser.timeout = 0.5
            try:
                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:
            pass
        else:
            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] = red

        self.data_ir[:-1] = self.data_ir[1:]
        self.data_ir[-1] = ir

        self.curve.setData(self.data_red)
        self.curve1.setData(self.data_ir)

        

    def dataAnalyse(self):
        data_red = self.data_red
        data_ir = self.data_ir

        hr_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 = []

        # red
        valley_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) / 2
                valley_pre = valley
                if(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) / 2
                peak_pre = peak
                if(peak >= peak_gate):
                    peak_red_index.append(i)
                    peak_red_data.append(data_red[i])

        # ir
        valley_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) / 2
                valley_pre = valley
                if(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) / 2
                peak_pre = peak
                if(peak >= peak_gate):
                    peak_ir_index.append(i)
                    peak_ir_data.append(data_ir[i])

        # calc hr
        hr_num_mean = np.mean(np.diff(valley_ir_index))
        self.hr = 60 / (hr_num_mean * (1/100) )
        
        # calc spo2
        ac_red = np.mean(peak_red_data) - np.mean(valley_red_data)
        dc_red = np.mean(peak_red_data) - ac_red / 2

        ac_ir = np.mean(peak_ir_data) - np.mean(valley_ir_data)
        dc_ir = np.mean(peak_ir_data) - ac_ir / 2

        R = (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 程序源码

参考:

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/178765.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

8、快捷键的使用

文章目录8、快捷键的使用8.1 常用快捷键第1组&#xff1a;通用型第2组&#xff1a;提高编写速度&#xff08;上&#xff09;第3组&#xff1a;提高编写速度&#xff08;下&#xff09;第4组&#xff1a;类结构、查找和查看源代码第5组&#xff1a;查找、替换与关闭第6组&#x…

理光M340W激光打印机加粉清零

粉盒型号&#xff1a; M340L&#xff08;如图&#xff09;&#xff1a; 加粉及清零&#xff1a; 原装粉盒不用考虑加粉了&#xff0c;原装粉盒墨粉用完后建议更换品牌代用的墨粉盒&#xff0c;品牌代用的墨粉盒直接带加粉口及清零齿轮&#xff1b; 1、加粉&#xff0c;打开加粉…

通信原理简明教程 | 物联网通信技术简介

文章目录1 物联网通信技术概述1.1 物联网通信的产生和发展1.2 物联网通信系统2 RFID技术2.1 RFID系统的组成2.2 RFID系统的工作原理2.3 RFID的典型应用3 ZigBee技术3.1 ZigBee技术的特点及应用3.2 ZigBee协议3.3 ZigBee网络的拓扑结构4 蓝牙通信技术4.1 蓝牙协议4.2 蓝牙网络连…

缓存失效问题和分布式锁引进

缓存失效问题 先来解决大并发读情况下的缓存失效问题&#xff1b; 1、缓存穿透  缓存穿透是指查询一个一定不存在的数据&#xff0c;由于缓存是不命中&#xff0c;将去查询数据库&#xff0c;但是数 据库也无此记录&#xff0c;我们没有将这次查询的 null 写入缓存&#x…

CNN中池化层的作用?池化有哪些操作?

(还没写完~) 一、What is 池化 1. 基本介绍 池化一般接在卷积过程后。池化,也叫Pooling,其本质其实就是采样,池化对于输入的图片,选择某种方式对其进行压缩,以加快神经网络的运算速度。这里说的某种方式,其实就是池化的算法,比如最大池化或平均池化。在卷积神经网络…

Linux常见命令 21 - 网络命令 ping、ifconfig、last、lastlog、traceroute、netstat

目录 1. 测试网络连通性 ping 2. 查看和设置网卡 ifconfig 3. 查看用户登录信息 last 4. 查看所有用户最后一次登录时间 lastlog 5. 查看数据包到主机间路径 traceroute 6. 显示网络相关信息 netstat 1. 测试网络连通性 ping 语法&#xff1a;ping [-c] IP地址&#xff0c…

【计算几何】叉积

叉积 海伦公式求三角形面积 已知三角形三条边分别为a&#xff0c;b&#xff0c;c,设 pabc2p \frac{abc}{2}p2abc​, 那么三角形的面积为&#xff1a; p(p−a)(p−b)(p−c)\sqrt{p(p-a)(p-b)(p-c)}p(p−a)(p−b)(p−c)​ 缺点&#xff1a;在开根号的过程中精度损失 概念 两个…

DFS(深度优先搜索)详解(概念讲解,图片辅助,例题解释)

目录 那年深夏 引入 1.什么是深度优先搜索&#xff08;DFS&#xff09;&#xff1f; 2.什么是栈&#xff1f; 3.什么是递归&#xff1f; 图解过程 问题示例 1、全排列问题 2、迷宫问题 3、棋盘问题&#xff08;N皇后&#xff09; 4、加法分解 模板 剪枝 1.简介 2.剪枝的…

Jupyter notebook折叠隐藏cell代码块 (hidden more than code cell in jupyter notebook)

Nbextensions 中的 hidden input 可以隐藏cell 我们在notebook中嵌入了一段画图的代码&#xff0c;影响代码阅读&#xff0c;搜一下的把这段代码隐藏。 我们使用了 jupyter notebook配置工具 Nbextensions。找到hidden input&#xff0c;这样只会隐藏输入的代码&#xff0c;而…

Tkinter的Radiobutton控件

Tkinter的Radiobutton是一个含有多个选项的控件&#xff0c;但是只能选择其中的一个选项 使用方法 R1tk.Radiobutton(root,textA,variablevar,valueA,commandprintf) R1.pack() R2tk.Radiobutton(root,textB,variablevar,valueB,commandprintf) R2.pack() R3tk.Radiobutton(ro…

【Linux】同步与互斥

目录&#x1f308;前言&#x1f338;1、Linux线程同步&#x1f368;1.1、同步概念与竞态条件&#x1f367;1.2、条件变量&#x1f33a;2、条件变量相关API&#x1f368;2.1、初始化和销毁条件变量&#x1f367;2.2、阻塞等待条件满足&#x1f383;2.3、唤醒阻塞等待的条件变量&…

2023 年第一弹, Flutter 3.7 发布啦,快来看看有什么新特性

核心内容原文链接&#xff1a; https://medium.com/flutter/whats-new-in-flutter-3-7-38cbea71133c 2023 年新春之际&#xff0c; Flutter 喜提了 3.7 的大版本更新&#xff0c;在 Flutter 3.7 中主要有改进框架的性能&#xff0c;增加一些很棒的新功能&#xff0c;例如&#…

初识网络爬虫

爬虫简介 网络爬虫又称网络蜘蛛、网络机器人&#xff0c;它是一种按照一定的规则自动浏览、检索网页信息的程序或者脚本。网络爬虫能够自动请求网页&#xff0c;并将所需要的数据抓取下来。通过对抓取的数据进行处理&#xff0c;从而提取出有价值的信息。 爬虫简单来说就是是通…

Mongodb基础操作

打开Mongodb服务&#xff0c;打开Robo 3T&#xff0c;链接服务并创建数据库&#xff1a; 创建表&#xff08;集合&#xff09;&#xff1a; 双击打开一个界面&#xff1a; 添加数据 查询book表&#xff1a; 添加属性名&#xff08;新数据&#xff09;&#xff1a; 查询&#xf…

推荐算法入门:序列召回(二)

召回&#xff1a;输入一个用户的&#xff08;点击&#xff09;序列&#xff0c;通过某种方法&#xff08;序列建模的方法&#xff09;&#xff0c;把用户输入的序列变为向量&#xff0c;用用户向量&#xff0c;在所有的item的向量进行快速检索&#xff0c;依次达到序列召回的效…

Linux使用YUM源安装Docker

安装环境查看Linux版本&#xff0c;如图&#xff1a;下载docker yum源登录阿里云开源镜像站&#xff0c;地址如下&#xff1a;阿里云开源镜像站搜索docker&#xff0c;如图&#xff1a;打开docker-ce&#xff0c;如图&#xff1a;复制docker-ce源地址&#xff0c;如下&#xff…

高并发环境如何有效缓解带宽压力

网络带宽是指在单位时间&#xff08;一般指的是1秒钟&#xff09;内能传输的数据量。网络和高速公路类似&#xff0c;带宽越大&#xff0c;就类似高速公路的车道越多&#xff0c;其通行能力越强。   在持续的多用户、高并发的情况下&#xff0c;缓解带宽压力可以避免客户端卡…

IO和NIO

什么是I/O模型: 通常情况下I/O操作是比较耗时的&#xff0c;所以为了高效的使用硬件&#xff0c;应用程序可以专门设置一个线程进行I/O操作&#xff0c;而另外一个线程则利用CPU的空闲去做其他计算&#xff0c;这种为提高应用执行效率而采用的I/O操作方法称为I/O模型&#xff…

【笔记】ASP.NET Core技术内幕与项目实现:基于DDD与前后端分离

最近在写论文&#xff0c;想使用ASP.NET Core Web API技术&#xff0c;但对它还不是很熟&#xff0c;鉴权组件也没用过&#xff0c;于是在网上查找资料&#xff0c;发现了杨中科老师写的这本书&#xff08;微信读书上可以免费看&#xff09;&#xff0c;说起来我最初自学C#时看…

C++:类中const修饰的成员函数

目录 一.const修饰类的成员函数 1.问题引出&#xff1a; 代码段&#xff1a; 2.问题分析 3.const修饰类的成员函数 二. 类的两个默认的&运算符重载 三. 日期类小练习 一.const修饰类的成员函数 1.问题引出&#xff1a; 给出一段简单的代码 代码段&#xff1a; #in…