MAX30102で作ったパルスオキシメーター
以前の記事でパルスオキシメーターまで製作したけど、せっかくESP8266にやらせているんだからグラフでモニタリングできたらなと思った。そんな中こんなサイトを見つけた。
Highchartsっていうグラフ描画用のライブラリを使って、BME280からリアルタイムでセンシングした温度・気圧・湿度を表示している様子。とりあえず、手元にBME280があったのでチュートリアルに沿って動かしてみたら結構良さそうなので心拍数、SPO2のリアルタイム表示をしてみようと思った。
Youtubeで検索すると似たようなことをやってる人がいるけど、数値が安定してない様子。もっと精度上がらないものか・・・
Arduino IDEにSPIFFSファイルシステムアップローダの導入
先ほどのBME280を使ったサイトにも書いてあるけど、Arduino IDEにSPIFFSファイルシステムアップローダを導入しておかないといけない。SPIFFSとはESP8266にファイルを保存することができる領域のことで今回はindex.htmlを置いてブラウザでグラフを表示するようにしている。日本語サイトだと下あたりが参考になりました。
最終的には「ESP8266 Sketch Data Upload」が表示されるようになればいいようです。
ここでちょっとわからないことが・・・ボード設定のFlashSizeを設定するときに
Tools > Flash Size: “?〇M (〇M SPIFFS)”
とかSPIFFSのサイズを決めたいんだけど、うちのは
Tools > Flash Size: “〇M (FS:〇KB OTA:〇KB)”
しかなくて…OTAに割く設定はできるんだけども、SPIFFSの割く設定がわからなくて・・・。
「ESP8266 Sketch Data Upload」では問題なくファイルは転送できるんですよねぇ。転送できるからまぁイイとしたんだけど、なんか気持ち悪い。
知ってる人教えてください!!
MAX30102センサー・ESP8266の接続
前の記事と一緒ですw。EPS8266とI2CでMAX30102をつなげるだけです。治具でつなげるなり、コンバータつけるなりESP8266に書き込めて実行できるようにしといてください。
dataフォルダの中のindex.htmlの作成
これもほぼ先のサイトに書いてありますが、dataフォルダの中のindex.htmlは多少いじりました。HighChartsなんて使ったこともないのでよくわかりませんでしたが、デモサイトなどをみてかるーーく変更してみました。
ちなみに私、JavaScriptも触ったことありませんので適当に予測しながら書き換えです。
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 |
<!DOCTYPE HTML><html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <script src="https://code.highcharts.com/highcharts.js"></script> <style> body { min-width: 310px; max-width: 800px; height: 400px; margin: 0 auto; } h2 { font-family: Arial; font-size: 2.5rem; text-align: center; } </style> </head> <body> <h2>心拍数・SpO2</h2> <div id="chart-hr" class="container"></div> <div id="chart-spo2" class="container"></div> </body> <script> // UTCを使わずにJSTを使う Highcharts.setOptions({ global: { useUTC: false } }); var chartHR = new Highcharts.Chart({ chart:{ renderTo : 'chart-hr' }, title: { text: '心拍数(HR)' }, series: [{ showInLegend: false, data: [] }], plotOptions: { line: { animation: false, dataLabels: { enabled: true } }, series: { color: '#059e8a' } }, xAxis: { type: 'datetime', dateTimeLabelFormats: { second: '%H:%M:%S' } }, yAxis: { title: { text: 'HR(拍/分)' }, min : 30, max : 150, plotBands: [{ // 徐脈 from: 0, to: 60, color: 'rgba(68, 170, 213, 0.1)', label: { text: '徐脈', style: { color: '#606060' } } },{ // 頻脈 from: 100, to: 200, color: 'rgba(250, 75, 130, 0.1)', label: { text: '頻脈', style: { color: '#606060' } } } ] }, credits: { enabled: false } }); setInterval(function ( ) { var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { var x = (new Date()).getTime(), y = parseFloat(this.responseText); //console.log(this.responseText); if(chartHR.series[0].data.length > 40) { chartHR.series[0].addPoint([x, y], true, true, true); } else { chartHR.series[0].addPoint([x, y], true, false, true); } } }; xhttp.open("GET", "/hr", true); xhttp.send(); }, 1000 ) ; var chartSPO2 = new Highcharts.Chart({ chart:{ renderTo:'chart-spo2' }, title: { text: '血中酸素飽和度(SpO2)' }, series: [{ showInLegend: false, data: [] }], plotOptions: { line: { animation: false, dataLabels: { enabled: true } } }, xAxis: { type: 'datetime', dateTimeLabelFormats: { second: '%H:%M:%S' } }, yAxis: { title: { text: 'SpO2(%)' }, min : 80, max : 100, plotBands: [{ // 90-95% from: 90, to: 95, color: 'rgba(255, 255, 65, 0.4)', label: { text: '90-95%', style: { color: '#606060' } } },{ // 90以下 from: 0, to: 90, color: 'rgba(250, 75, 130, 0.1)', label: { text: '< 90%', style: { color: '#606060' } } } ] }, credits: { enabled: false } }); setInterval(function ( ) { var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { var x = (new Date()).getTime(), y = parseFloat(this.responseText); //console.log(this.responseText); if(chartSPO2.series[0].data.length > 40) { chartSPO2.series[0].addPoint([x, y], true, true, true); } else { chartSPO2.series[0].addPoint([x, y], true, false, true); } } }; xhttp.open("GET", "/spo2", true); xhttp.send(); }, 1000 ) ; </script> </html> |
心拍数とSPO2の2つの表示に変えて、それぞれのY軸の最大値・最小値を固定し、それぞれの一般的なボーダーを描記してみました。データは1秒毎に読み込んでいます。
データ吐き出し用のWebサーバーのスケッチ
スケッチは以下のように書き換えました。BME280での要領で書きかえました。以前のコードよりもノイズ処理やイレギュラーな数値を拾わないようにしました。ノイズ処理が入りすぎるとコード可読性が悪いので基本コードは前の記事のコードを参考にしてください。
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 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
#ifdef ESP32 #include <WiFi.h> #include <ESPAsyncWebServer.h> #include <SPIFFS.h> #else #include <Arduino.h> #include <ESP8266WiFi.h> #include <Hash.h> #include <ESPAsyncTCP.h> #include <ESPAsyncWebServer.h> #include <FS.h> #endif #include <Wire.h> #include "MAX30105.h" #include <movingAvg.h> MAX30105 particleSensor; //------ Wifi AP情報 const char* ssid = "<SSID>"; const char* password = "<パスワード>"; //------ 定数定義 const uint32_t TH_FIN = 7000; //指が置いてあるかどうかの閾値 const int32_t TH_AMOUNT = 250; //パルスの起点となる検出変化量 const int32_t MIN_INIT = 9999999; //最小値の初期値 const int32_t MAX_INIT = 0; //最大値の初期値 //表示する心拍数とSpO2の範囲(用途により適宜変更) const uint32_t DISP_MIN_HR = 30; const uint32_t DISP_MAX_HR = 200; const uint32_t DISP_MIN_SPO2 = 70; const uint32_t DISP_MAX_SPO2 = 100; //瞬間心拍数の許容範囲(デフォルトは表示範囲と一緒) const uint32_t MAX_MOMENT_HR = DISP_MAX_HR; const uint32_t MIN_MOMENT_HR = DISP_MIN_HR; //ノイズと判断するカットレート 0.3 移動平均の+-30%の変化を認める // ex ) 移動平均70拍 : 49~91の範囲外はカットオフする const double NOISE_CUTOFF_RATE = 0.3; //HRの移動平均データのリセットするカットオフの連続回数 const uint32_t MAX_CUTOFF_COUNT = 5; //パルス検出保持変数 long l_time = millis(); //最後のパルス検出時間の保持 int32_t before_ir_v = 0; //最後のir_v値保持 int32_t b_diff = 0; // 最後の差の保持 long pulse_interval = -1; // パルス間隔時間 //1パルス中の生データの最大・最小値の保持 int32_t min_ir_v = MIN_INIT ,max_ir_v = MAX_INIT; int32_t min_red_v= MIN_INIT ,max_red_v = MAX_INIT; //生データの変化値の保持 int32_t diff = 0; //移動平均値(IR_DC、RED_DC) デフォルトは30(大体20~50の範囲で指定)サンプル movingAvg avgIr_v(30); movingAvg avgRed_v(30); //移動平均値(心拍数は直近3ビート、SPO2は5ビートの平均) movingAvg avgHR(3); movingAvg avgSPO2(5); //連続カットオフ回数 //MAX_CUTOFF_COUNT回連続でカットオフされたらavg_HRをリセットする int32_t cutoff_c = 0; //グラフ描記用のサーバー AsyncWebServer server(80); void setup() { Wire.begin(0,2); // I2Cのピン設定 Serial.begin(115200); Serial.println(""); Serial.println("Initializing..."); // センサー初期化 while(!particleSensor.begin(Wire, I2C_SPEED_FAST)){ Serial.print("."); } Serial.println("OK!"); byte ledBrightness = 0x1F; //Options: 0=Off to 255=50mA byte diffmpleAverage = 8; //Options: 1, 2, 4, 8, 16, 32 byte ledMode = 2; //Options: 1 = Red only, 2 = Red + IR, 3 = Red + IR + Green int diffmpleRate = 400; //Options: 50, 100, 200, 400, 800, 1000, 1600, 3200 int pulse_intervalWidth = 411; //Options: 69, 118, 215, 411 int adcRange = 4096; //Options: 2048, 4096, 8192, 16384 particleSensor.setup(ledBrightness, diffmpleAverage, ledMode, diffmpleRate, pulse_intervalWidth, adcRange); //Configure sensor with these settings //初期値として一回読んでおく(ir_vをパルス計測に用いる) before_ir_v = particleSensor.getRed(); //パルス最終取得時間の初期化 l_time = millis(); //移動平均ライブラリの初期化 avgIr_v.begin(); avgRed_v.begin(); avgHR.begin(); avgSPO2.begin(); // Initialize SPIFFS if(!SPIFFS.begin()){ Serial.println("An Error has occurred while mounting SPIFFS"); return; } // Connect to Wi-Fi WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(1000); Serial.println("Connecting to WiFi.."); } // IPアドレス http://XXX.XXX.XXX.XXX/index.html にアクセス Serial.println(WiFi.localIP()); // グラフ描記のルート index.html server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send(SPIFFS, "/index.html"); }); //心拍数 server.on("/hr", HTTP_GET, [](AsyncWebServerRequest *request){ char hr_st[10]; if(avgHR.getCount()>0){ dtostrf((double)avgHR.getAvg() / 1000.0, 0, 2,hr_st); }else{ sprintf(hr_st,"0.00"); } request->send_P(200, "text/plain", hr_st); }); //SPO2 server.on("/spo2", HTTP_GET, [](AsyncWebServerRequest *request){ char spo2_st[10]; if(avgSPO2.getCount()>0){ dtostrf((double)avgSPO2.getAvg() / 1000.0, 0, 2,spo2_st); }else{ sprintf(spo2_st,"0.00"); } request->send_P(200, "text/plain", spo2_st); }); //簡易ECG 高速で読みだせばEGCグラフも描記できるかも ※未実装 server.on("/ecg", HTTP_GET, [](AsyncWebServerRequest *request){ char ecg_st[10]; sprintf(ecg_st,"%d",diff); request->send_P(200, "text/plain", ecg_st); }); // Start server server.begin(); } void loop( { //逆になっている。センサーが逆を返しているので各自チェック uint32_t red_v = particleSensor.getIR(); uint32_t ir_v = particleSensor.getRed(); //指を置いているか if(red_v< TH_FIN || ir_v < TH_FIN) return; //移動平均値(IR_DC RED_DCの算出) double ir_v_dc = avgIr_v.reading(ir_v); double red_v_dc = avgRed_v.reading(red_v); //IRとREDのACを求める為に最大値・最初値の更新 if(ir_v<min_ir_v) min_ir_v = ir_v; if(ir_v>max_ir_v) max_ir_v = ir_v; if(red_v<min_red_v) min_red_v = red_v; if(red_v>max_red_v) max_red_v = red_v; //パルス検出にはir_vを利用。前回との差分(変化値)を求める diff = before_ir_v - ir_v; //TH_AMOUNT以上の変化がある場合、パルス起点と推定 if(b_diff < TH_AMOUNT && diff > TH_AMOUNT){ //1パルスの時間を計算 pulse_interval = millis() - l_time; l_time = millis(); //推定されるパルスの瞬間心拍数の計算 double hr_r = 60000.0/pulse_interval; boolean f; if(avgHR.getCount()>=3){ //移動平均avgHRでhr_rの妥当性を判断(+-NOISE_CUTOFF_RATEの範囲内にあるか) double hr_avg = (double)avgHR.getAvg() / 1000.0; f = hr_r > hr_avg*(1.0-NOISE_CUTOFF_RATE) && hr_r < hr_avg*(1.0+NOISE_CUTOFF_RATE); }else{ //移動平均がない場合はしゅんかん定義の心拍数の範囲内かどうか f = hr_r >= (double)MIN_MOMENT_HR && hr_r <= (double)MAX_MOMENT_HR; } if(f){ //連続カットオフカウントのリセット cutoff_c = 0; //1パルスの時間より心拍数の計算。avgHRは整数のみなので1000倍して1000で割り小数保持 double hr = (double)avgHR.reading(hr_r * 1000.0 ) / 1000.0; //SPO2の計算 //IR・REDのACを求める(振幅最大-振幅最小) int32_t ir_v_ac = max_ir_v-min_ir_v; int32_t red_v_ac = max_red_v-min_red_v; // R = (AC_RED / DC_RED) / (AC_IR / DC_IR)の計算式より double red_div = double(red_v_ac)/red_v_dc; double ir_div = double(ir_v_ac)/ir_v_dc; double R = red_div / ir_div; // SPO2 = -45.060*R^2 + 30.354*R + 94.845 これはspo2_algorithm.cppにあったのを準用 // 乗除1000は小数保持のため double spo2 = (double)avgSPO2.reading((-45.060*R*R + 30.354*R + 94.845)*1000.0) / 1000.0; //最大値・最小値の初期化 min_ir_v = MIN_INIT; max_ir_v = MAX_INIT; min_red_v = MIN_INIT; max_red_v = MAX_INIT; //心拍数とSPO2の表示(範囲は定義で変更可) if(hr <= DISP_MAX_HR && hr >= DISP_MIN_HR && spo2 <= DISP_MAX_SPO2 && spo2 >= DISP_MIN_SPO2){ Serial.print(hr); Serial.print(","); Serial.println(spo2); } }else{ //瞬間心拍範囲外・カットオフ cutoff_c++; if(cutoff_c >= MAX_CUTOFF_COUNT){ avgHR.reset(); //Serial.println("avgHR reset"); } } } //パルス検出用値の保持 before_ir_v = ir_v; b_diff = diff; } |
※SSIDとパスワードガッツリ記載してましたw。初歩的ミスです。いまは消しましたので見た人は忘れてネ!
下の2つのライブラリをZipファイルでダウンロードして[Zip形式のライブラリのインストール]からインストールしておきましょう。ライブラリマネージャからはインストールできないみたいです。include部をみるとわかると思いますが、ESP32だとESPAsyncTCPではなくAsyncTCPをインストールしましょう。
あと、移動平均のライブラリもお忘れなく。これはライブラリマネージャからインストールできます
動作確認
1秒毎のデータをプロットしていきます。40個(40秒)がMAXです。更新間隔を調整するなどはHighChartsで行えます。Youtubeに手持ちのパルスオキシメータとの比較や息ごらえしたときのSPO2の変化の動画をアップロードしましたので参考になればとおもいます。