DIY高精度パルスオキシメータセンサーの製作 – IR、Arduino、LCD、部品ガイド
部品と材料
| 赤外線LED(18°) | 波長 805~1000nm | 1 |
| フォトダイオード LPT80A | 赤色(600nm)と赤外線(940nm)の両方を受光可能な広範囲タイプ | 1 |
| 5mm 赤色LED | 波長 600nm 付近 | 1 |
| 抵抗 10kΩ | 1 | |
| 抵抗 330Ω | 2 | |
| DFRobot I2C 16x2 Arduino LCD ディスプレイモジュール | 値を表示するためのLCD(他のLCDでも可) | 1 |
| Arduino UNO | 1 | |
| ジャンパーワイヤー | 1 | |
| 高輝度白色LED | オプション(改良版で使用) | 1 |
必要な工具と機器
- はんだごて(ガルウィングチップ)
アプリとオンラインサービス
- Arduino IDE
このプロジェクトについて
外出自粛期間中、手元にあった部品でパルスオキシメータを製作しました。オキシメータは基本的に2つのLEDとフォトダイオードで構成されています。
私は医療の専門家ではなく、現段階ではこの装置に診断的価値があるか分かりませんが、仕組みを学ぶ良い教育教材です。いくつかの改良を加えれば、家庭用医療機器として実用化できる可能性があります。
酸素飽和度とCOVID-19
この未曾有の時期に、私たちはウイルス、肺、マスク、石鹸、手洗いについて多くを学びました。咳、発熱、呼吸困難などの症状が知られるようになり、血中酸素量で呼吸困難を測定できることも分かりました。
この測定はパルスオキシメータという医療機器で間接的に行います。指先に挟む非侵襲的な装置で、脈動する光で測定します。

通常、健康な人の酸素飽和度(SpO2)は95%以上です。90%以下で咳や発熱がある場合は危険な状態です。
誰でもオキシメータを作れれば、感染の早期発見が容易になり、パニックではなく本当に問題がある時に病院へ行く判断ができます。
まず心拍センサーの仕組みを理解する
多くのセンサーキットに含まれるKY-039心拍センサーで実験を始めました。回路図の通り、赤外線LEDがフォトダイオードを照らし、2つの抵抗でLEDを保護し微小な信号を読み取ります。
KY-039がなくても、少ない部品で自作センサーを作れます。

指をセンサーとフォトダイオードの間に挟みます(写真は元サイトから引用・加工):

赤外線光は爪、皮膚、指の組織で一部吸収されますが、血液の流れに合わせて吸収量が変化します。心拍で血液が押し出されると光吸収が変わり、フォトダイオードに到達する光量が変化します。この変化を電流として測定します。
KY-039にはこの変化する値を読み取るS(信号)ピンがあります。
信号のピークを数えて心拍数を測定
センサーからの可変信号を読み取るのは容易ではありません。ノイズが多く、信号は微弱で、プロットするための適切な値を得るには数学的処理が必要です。
Johan Ha氏の投稿が参考になりました。信号の平均化と家庭用照明(50/60Hz)によるノイズ除去の方法が説明されています。
コツは、センサーから読み取った直近X個の値を配列に保持し、古い値を捨てて新しい値を追加しながら平均を取ることです。また、値がN回連続で上昇したらピークとみなす方法も紹介されています。ArduinoのシリアルプロッターやSerialPlotなどで値を分析し、適切なN(コード内のrise_threshold定数)を決定します。大きすぎるとピークを見逃し、小さすぎると二重切痕を心拍と誤認します。
ピーク検出ができたら、ピークを数えるか、連続する数拍の時間間隔からBPM(分あたり拍数)を算出します。
オキシメータの製作(KY-039の改造)で酸素飽和度を測定
血液は光の波長によって吸収率が異なります。赤色光(~600nm)は酸素を多く含む血液でより吸収されるため、赤外線LED(~950nm)と赤色LEDの測定値を比較して血中酸素割合を求めます。この値がSpO2%(経皮的動脈血酸素飽和度)です。
KY-039には赤外線LEDしかないため、赤色LEDを追加し、赤外線LEDをVccから切り離して、それぞれ330Ω抵抗経由でArduinoの別ピンに接続しました。
(KY-039がなくても、LED2個、フォトダイオード、抵抗3個で自作可能。回路は非常にシンプルです!)
改造センサーの回路図:

これで赤外線LEDを点灯してKY-039のSピンを読み、次に赤外線を消して赤色LEDを点灯して同じピンを読むことができます。

2つの信号をプロットすると、IR値は常に赤色値より低くなります。

良い信号を得るには、指先をフォトダイオードとLEDに正しく当て、LEDが爪に触れるようにします。プロットで良い波形が出たら指を動かさないでください。
信号が微弱でノイズの影響が大きいため、安定した周囲光が必要です。測定中は指を動かさず、光環境も変えないでください。センサーに影がかかるだけで測定値が大きく変わります。
酸素飽和度 SpO2% の測定原理
酸素飽和度(SpO2)は、酸素化ヘモグロビンが総ヘモグロビンに占める割合で、パラメータRの関数として表されます(ミラノ工科大学の学術論文より)。Rは2つの信号の最大値・最小値から計算します:
R = ( (REDmax - REDmin) / REDmin ) / ( (IRmax - IRmin) / IRmin )
各機器固有のRとSpO2%を結ぶ曲線(関数)を見つけるにはキャリブレーションが必要です。
心拍の「周期」(1拍あたりのミリ秒)を求め、サンプリングレートで割って1周期分のサンプル数を出します。サンプリングレートは40ms(IR 20ms、赤色 20ms)です。
周期は信号の立ち上がり間の時間です。直近Lサンプル(L = 周期 / 40)を配列に保存し、REDmax、REDmin、IRmax、IRminを求めます。これらからRを計算し、毎拍ごとに更新します。
RからSpO2%へ:オキシメータのキャリブレーション方法
RとSpO2の関数は一次近似で表せます:
SpO2 = K * R + M
KとMを求めるには2点(SpO2とRの組み合わせ2組)が必要です。市販のオキシメータを基準に、自作機のR値と同時測定します。
- 正常呼吸でSpO2とRを記録
- 息を止めて10-20秒後、基準機のSpO2低下と自作機のR上昇を記録(失神直前まで)
- 2点からKとMを算出
これで毎測定のRからBPMとSpO2が算出できます。
LCDには、周期が±10%以内で安定した5回以上の測定値がある場合のみ値を表示します。これにより、部品のバラツキ、環境光変化、指の動きによる外れ値を除外します。表示されるc値は安定した測定回数を示します。
プロジェクト改良:周囲光変動の除去
数日実験して、安価な部品(LEDとフォトダイオードのみ)では測定が周囲光に大きく依存することが分かりました。晴天時は良好でも、曇天や夕方の電灯下では結果が変わります。
対策として第3のLED(常時点灯の白色LED)を追加し、指に常時光を当てるようにしました。さらに黒い布で覆って周囲光を遮断します。


これで周囲光の影響を受けない安定した測定が可能になりました。再キャリブレーション後、動画の通り数秒で正しいBPMとSpO2%を表示します。
コード
oximeter-diy-ver-0.92.ino (Arduino)
自宅にある少ない部品で作るDIYオキシメータのソースコード(KY-039改造版または赤外線LED、赤色LED、フォトダイオードで自作版)。
/*
* DIYオキシメータ v.0.92 (微修正)
* KY-039心拍センサーを改造、または赤外線LED、赤色LED、フォトダイオードで自作
* https://hackaday.io/project/170752-oximeter-do-it-yourself
*/
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#define maxperiod_siz 80 // 1周期の最大サンプル数
#define measures 10 // 保存する周期数
#define samp_siz 4 // 平均化するサンプル数
#define rise_threshold 3 // ピーク判定の連続上昇回数
// I2C LCDでBPM表示
LiquidCrystal_I2C lcd(0x3F, 16, 2);
int T = 20; // センサー読み取りスロット (ms)
int sensorPin = A1;
int REDLed = 3;
int IRLed = 4;
// LCD用カスタム文字(ハート、疑問符など)
byte sym[3][8] = {
{ B00000, B01010, B11111, B11111, B01110, B00100, B00000, B00000 },
{ B00000, B00000, B00000, B11000, B00100, B01000, B10000, B11100 },
{ B00000, B00100, B01010, B00010, B00100, B00100, B00000, B00100 }
};
void setup() {
Serial.begin(9600);
Serial.flush();
pinMode(sensorPin, INPUT);
pinMode(REDLed, OUTPUT);
pinMode(IRLed, OUTPUT);
lcd.init();
lcd.backlight();
digitalWrite(REDLed, LOW);
digitalWrite(IRLed, LOW);
for(int i=0; i<8; i++) lcd.createChar(i, sym[i]);
}
void loop() {
bool finger_status = true;
float readsIR[samp_siz], sumIR, lastIR, reader, start;
float readsRED[samp_siz], sumRED, lastRED;
int period, samples;
period = 0;
samples = 0;
int samplesCounter = 0;
float readsIRMM[maxperiod_siz], readsREDMM[maxperiod_siz];
int ptrMM = 0;
for (int i = 0; i < maxperiod_siz; i++) {
readsIRMM[i] = 0;
readsREDMM[i] = 0;
}
float IRmax = 0, IRmin = 0, REDmax = 0, REDmin = 0;
double R = 0;
float measuresR[measures];
int measuresPeriods[measures];
int m = 0;
for (int i = 0; i < measures; i++) {
measuresPeriods[i] = 0;
measuresR[i] = 0;
}
int ptr;
float beforeIR;
bool rising;
int rise_count;
int n;
long int last_beat;
for (int i = 0; i < samp_siz; i++) {
readsIR[i] = 0;
readsRED[i] = 0;
}
sumIR = 0;
sumRED = 0;
ptr = 0;
while(1) {
// --- IR LED点灯 ---
digitalWrite(REDLed, LOW);
digitalWrite(IRLed, HIGH);
// 20ms期間の平均値を取得(50Hz商用電源ノイズ除去)
n = 0;
start = millis();
reader = 0.;
do {
reader += analogRead(sensorPin);
n++;
} while (millis() < start + T);
reader /= n;
// 移動平均配列を更新
sumIR -= readsIR[ptr];
sumIR += reader;
readsIR[ptr] = reader;
lastIR = sumIR / samp_siz;
// --- 赤色LED点灯 ---
digitalWrite(REDLed, HIGH);
digitalWrite(IRLed, LOW);
n = 0;
start = millis();
reader = 0.;
do {
reader += analogRead(sensorPin);
n++;
} while (millis() < start + T);
reader /= n;
sumRED -= readsRED[ptr];
sumRED += reader;
readsRED[ptr] = reader;
lastRED = sumRED / samp_siz;
// --- Rパラメータ計算用にサンプル保存 ---
readsIRMM[ptrMM] = lastIR;
readsREDMM[ptrMM] = lastRED;
ptrMM++;
ptrMM %= maxperiod_siz;
samplesCounter++;
// 1周期分のサンプルが揃ったら最大/最小値を求めてRを計算
if(samplesCounter >= samples) {
samplesCounter = 0;
IRmax = 0; IRmin = 1023;
REDmax = 0; REDmin = 1023;
for(int i=0; i<maxperiod_siz; i++) {
if(readsIRMM[i] > IRmax) IRmax = readsIRMM[i];
if(readsIRMM[i] > 0 && readsIRMM[i] < IRmin) IRmin = readsIRMM[i];
readsIRMM[i] = 0;
if(readsREDMM[i] > REDmax) REDmax = readsREDMM[i];
if(readsREDMM[i] > 0 && readsREDMM[i] < REDmin) REDmin = readsREDMM[i];
readsREDMM[i] = 0;
}
R = ((REDmax - REDmin) / REDmin) / ((IRmax - IRmin) / IRmin);
}
// 指がセンサーにあるか判定(指がないと赤色曲線がIRより下になる)
if (lastRED < lastIR) {
if(finger_status == true) {
finger_status = false;
lcd.clear();
lcd.setCursor(0,0);
lcd.print("No finger?");
}
} else {
if(finger_status == false) {
lcd.clear();
finger_status = true;
lcd.setCursor(10,0);
lcd.print("c=");
lcd.setCursor(0,0);
lcd.print("bpm");
lcd.setCursor(0,1);
lcd.print("SpO");
lcd.write(1); // 2
lcd.setCursor(10,1);
lcd.print("R=");
}
}
float avR = 0;
int avBPM = 0;
if (finger_status == true) {
// 心拍の立ち上がり検出
if (lastIR > beforeIR) {
rise_count++;
if (!rising && rise_count > rise_threshold) {
lcd.setCursor(3,0);
lcd.write(0); // ハートマーク
// 心拍検出:前回ピークからの時間を記録、直近10拍で平均化
rising = true;
measuresR[m] = R;
measuresPeriods[m] = millis() - last_beat;
last_beat = millis();
int period = 0;
for(int i = 0; i < measures; i++) period += measuresPeriods[i];
period = period / measures;
samples = period / (2 * T);
int avPeriod = 0;
int c = 0; // 安定した測定回数(周期変動±10%以内)
for(int i = 1; i < measures; i++) {
if ((measuresPeriods[i] < measuresPeriods[i-1] * 1.1) &&
(measuresPeriods[i] > measuresPeriods[i-1] / 1.1)) {
c++;
avPeriod += measuresPeriods[i];
avR += measuresR[i];
}
}
m++;
m %= measures;
lcd.setCursor(12,0);
lcd.print(String(c) + " ");
// 平均BPMと平均R(安定した5拍以上の平均)
avBPM = 60000 / (avPeriod / c);
avR = avR / c;
lcd.setCursor(12,1);
if(c == 0) lcd.print(" ");
else lcd.print(String(avR) + " ");
// 5回以上の安定測定があればSpO2表示
if(c > 4) {
// キャリブレーション式: SpO2 = -19 * R + 112 (この機器固有)
int SpO2 = -19 * R + 112;
lcd.setCursor(4,0);
if(avBPM > 40 && avBPM < 220) lcd.print(String(avBPM) + " ");
lcd.setCursor(4,1);
if(SpO2 > 70 && SpO2 < 150) lcd.print(" " + String(SpO2) + "% ");
} else {
if(c < 3) {
lcd.setCursor(3,0);
lcd.write(2); // BPM?
lcd.setCursor(4,1);
lcd.write(2); // SpO2?
}
}
}
} else {
// 立ち下がり
rising = false;
rise_count = 0;
lcd.setCursor(3,0);
lcd.print(" ");
}
beforeIR = lastIR;
}
// シリアルプロッター用出力
Serial.print(lastIR);
Serial.print(",");
Serial.print(lastRED);
Serial.println();
ptr++;
ptr %= samp_siz;
}
}
回路図
KY-039を改造しましたが、Fritzingライブラリになかったため同等の部品で作成。フォトダイオードも適切なものが見つからなかったため代用しています。

シンプルなオキシメータは、KY-039を改造するか、ゼロからセンサーを自作することで製作可能です。
製造プロセス
- Raspberry Pi用DIY赤外線モーションセンサー構築 - ステップバイステップガイド
- Raspberry PiでPIRモーションセンサーとLEDを制御
- Raspberry Piで低コスト土壌水分センサーを構築 - スマート灌漑に活用
- Raspberry PiでDS18B20温度センサーをテスト
- Raspberry Pi、MCP9808、InfluxDB、Grafanaで作る家庭温度モニター
- Sensor Telemetry 2.0.1:Azure IoT Hubによる高度な温度監視
- エモーションセンサー&EEG:リアルタイム感情検知のための低コストハードウェア
- 自作Arduino TV-B-Gone:全テレビ対応の万能リモコン
- 色感度プロセス向け高精度赤外線温度センシング
- 受動赤外線センサー省電力回路:設計、動作、応用
- 自動車LiDARでどの波長が主流になるか?