격리 기간 동안 집에 있던 부품들로 산소포화도 측정기(옥시미터)를 만들었습니다. 옥시미터는 결국 두 개의 LED와 하나의 포토다이오드로 구성되기 때문입니다.
의학 전문가는 아니며, 현재 단계에서 이 프로젝트가 진단적 가치가 있는지 확신할 수 없습니다. 하지만 작동 원리를 공부하기에 좋은 교육용 프로젝트이며, 몇 가지 팁을 더하면 가정용 의료 도구로 발전시킬 수도 있을 것입니다.
산소포화도와 코로나19
우리는 바이러스, 폐, 마스크, 비누, 손 씻기에 대해 많은 것을 배웠습니다. 기침, 발열, 호흡 곤란 같은 증상뿐만 아니라, 혈중 산소량을 측정해 호흡 곤란을 간접적으로 확인할 수 있다는 것도 알게 되었습니다.
이 측정은 옥시미터라는 의료 기기로 비침습적으로 수행합니다. 손가락에 끼워 맥동하는 빛으로 측정하는 장치입니다.
정상적인 경우 산소포화도(SpO₂)는 95% 이상입니다. 90% 미만으로 떨어지면서 기침과 발열이 동반되면 위험 신호입니다.
메이커 누구나 옥시미터를 만들 수 있다면, 감염 여부를 더 쉽게 파악하고 진짜 응급 상황일 때만 병원을 찾아 패닉을 방지할 수 있을 것입니다.
심박 센서 작동 원리 이해하기
키트 센서에 들어있던 KY-039 심박 센서로 시작했습니다. 회로도를 보면 적외선 LED가 포토다이오드를 비추고, 두 개의 저항이 LED 보호와 미세 신호 판독용으로 쓰입니다.
KY-039 센서가 없어도 몇 가지 부품으로 직접 센서를 만들 수 있습니다.
손가락을 센서와 포토다이오드 사이에 놓습니다.
적외선 빛은 손톱, 피부, 혈관 등을 통과하며 부분적으로 흡수됩니다. 심장이 뛸 때마다 혈액이 밀려들어 흡수율이 변하고, 포토다이오드에 도달하는 빛의 양이 변합니다. 이 변화를 전류로 측정합니다. KY-039의 S (신호) 핀에서 이 변화하는 값을 읽을 수 있습니다.
신호의 피크를 세어 심박수 측정하기
센서에서 들어오는 가변 신호를 읽는 것은 쉽지 않습니다. 노이즈가 많고 신호가 매우 약하기 때문입니다. 요한 하(Johan Ha)의 유용한 글을 참고해 신호 평균을 내고 가정용 조명(50/60Hz)에서 오는 노이즈를 제거하는 방법을 적용했습니다.
핵심은 최근 X개 샘플의 평균을 내는 배열을 유지하고, N번 연속으로 값이 커질 때 피크로 판단하는 것입니다(rise_threshold 상수). 아두이노 시리얼 플로터나 SerialPlot 같은 도구로 값을 분석하며 적절한 N을 찾습니다. 너무 크면 박동을 놓치고, 너무 작으면 이첨파(dicrotic notch)를 박동으로 오인합니다.
피크를 찾는 법을 익혔다면, 피크 개수를 세거나 연속된 몇 박동 간의 시간 간격으로 BPM(분당 박동 수)을 계산합니다.
옥시미터 제작 – KY-039 센서 해킹으로 산소포화도 측정
혈액은 빛의 파장에 따라 흡수율이 다릅니다. 적색광(~600nm)은 산소 함량이 높은 혈액에서 더 잘 흡수됩니다. 따라서 적외선 LED(~950nm)와 적색 LED로 측정한 값을 비교해 혈중 산소 비율인 SpO₂%(말초 모세혈관 산소포화도)을 구할 수 있습니다.
KY-039에는 적외선 LED만 있으므로 적색 LED를 추가했습니다. 기존 IR LED를 Vcc에서 분리하고, 두 LED를 각각 330Ω 저항을 통해 아두이노의 서로 다른 핀에 연결했습니다.
(KY-039가 없다면 처음부터 만들 수도 있습니다. LED 두 개, 포토다이오드, 저항 세 개면 되며 회로는 매우 단순합니다!)
이제 IR LED를 켜고 S 핀 값을 읽은 뒤, IR LED를 끄고 RED LED를 켜서 다시 S 핀 값을 읽는 식으로 번갈아 측정합니다.
두 신호를 플로팅하면 IR 값이 항상 RED 값보다 낮게 나옵니다.
손가락 끝을 포토다이오드와 LED에 정확히 대고, 손톱이 LED에 닿도록 위치를 잡습니다. 플로터에서 안정적인 파형이 나오면 위치를 고정하세요.
신호가 약하고 노이즈가 심하므로, 측정 중에는 주변 조명을 일정하게 유지해야 합니다. 그림자만 져도 값이 크게 변합니다.
SpO₂% 측정 원리
산소포화도 SpO₂는 산소화 헤모글로빈 비율로, R 파라미터의 함수입니다(밀라노 공대 논문 참조). R은 두 신호의 최댓값과 최솟값으로 계산합니다:
심박 주기(한 박동이 걸리는 밀리초)를 구하고, 샘플링 주기(40ms: IR 20ms + RED 20ms)로 나누면 한 주기당 샘플 수 L이 나옵니다. 최근 L개 샘플(배열에 저장)에서 REDmax, REDmin, IRmax, IRmin을 찾아 R을 계산합니다. 이 과정은 매 박동마다 반복됩니다.
R에서 SpO₂로: 옥시미터 보정 방법
R과 SpO₂의 관계는 1차 함수로 근사할 수 있습니다:
SpO₂ = K × R + M
K와 M을 구하려면 두 개의 기준점(SpO₂, R 쌍)이 필요합니다. 시중 옥시미터를 참고 장비로 써서 동시에 측정합니다.
정상 호흡 상태에서 시중 옥시미터의 SpO₂와 우리 장치의 R을 기록합니다.
숨을 참아 SpO₂가 떨어지게 한 뒤(10~20초), 다시 두 값을 기록합니다. (실신 직전까지만)
두 점으로 연립방정식을 풀어 K, M을 구합니다.
이제 매 R 측정마다 BPM과 SpO₂를 모두 계산할 수 있습니다.
LCD에 값을 표시할 때는 최근 10개 피크 중 주기 변동이 ±10% 이내인 안정적인 측정값이 5개 이상일 때만 보여줍니다. 이렇게 하면 부품 불량, 조명 변화, 손가락 움직임으로 인한 오차를 걸러낼 수 있습니다. 화면의 c 값은 계산에 쓰인 안정적 측정 횟수입니다.
프로젝트 개선: 주변광 변동 제거
며칠 테스트해보니 저가 부품(LED와 포토다이오드만 사용)만으로는 주변광 의존성이 너무 큽니다. 맑은 날은 잘 나오는데 흐린 날이나 저녁 인공조명 아래서는 부정확했습니다. 그래서 항상 켜지는 3번째 LED(백색 고휘도)를 추가해 손가락에 일정한 조명을 공급하고, 검은 천으로 센서 전체를 가려 주변광을 차단했습니다.
이제 주변광 영향 없이 안정적인 측정이 가능합니다. 보정을 다시 하면 몇 초 만에 정확한 BPM과 SpO₂%를 표시합니다.
코드
oximeter-diy-ver-0.92.ino (아두이노)
집에 있는 몇 가지 부품으로 만든 DIY 옥시미터 소스 코드입니다. (KY-039 해킹 또는 IR/적색 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 // 한 주기 최대 샘플 수
#define measures 10 // 저장할 주기 수
#define samp_siz 4 // 평균낼 샘플 수
#define rise_threshold 3 // 피크 판정용 상승 카운트 임계값
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);
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;
// --- RED 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++;
// 한 주기 분량 샘플이 모이면 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);
}
// 손가락 감지: RED 곡선이 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);
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); // 하트 표시
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;
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)+" ");
avBPM = 60000 / (avPeriod / c);
avR = avR / c;
lcd.setCursor(12,1);
if (c==0) lcd.print(" "); else lcd.print(String(avR)+" ");
if (c > 4) {
// SpO₂ = K*R + M (보정된 계수)
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); // SpO₂ ?
}
}
}
} 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 센서를 개조했지만 프리칭 라이브러리에 없어 구성 부품으로 직접 그렸습니다. 포토다이오드 적정 부품이 없어 대체 표현했습니다.
간단한 옥시미터는 KY-039 센서 해킹이나, 부품 몇 개로 센서를 직접 만들어 제작할 수 있습니다.