当前位置:   article > 正文

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

max30102与esp

0 引言

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

主要学习的内容:

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

实现的效果:
在这里插入图片描述

实现的思路:

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

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

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

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.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

读数据时序:
在这里插入图片描述

标准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.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

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);

}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

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;
 
}  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

其中中断处理函数读取寄存器 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);
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43

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]);

        } 
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

我这个传感器测到的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_())
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230
  • 231
  • 232
  • 233
  • 234
  • 235
  • 236
  • 237

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》
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Cpp五条/article/detail/491438
推荐阅读
相关标签
  

闽ICP备14008679号