AI 탐구노트

웹캠을 이용한 rPPG (원격 광혈류측정) 기반의 심박동수 측정 본문

DIY 테스트

웹캠을 이용한 rPPG (원격 광혈류측정) 기반의 심박동수 측정

42morrow 2024. 12. 1. 22:27

 

1. 들어가며

 

요즘 아침저녁으로 온도가 뚝 떨어져 감기에 걸린 사람들이 많죠? 이번 한 주만 하더라도 예상치 못한 첫눈 폭설에 운전이 힘들어진 도로에서, 혹은 눈 덮힌 길가에서 고생하신 분들도 많을테구요... 어찌됐건 이런 날씨에는 건강에 더 신경을 써건강에 더 신경을 쓰게 마련입니다. 그런데 미래에는 내 건강 상태를 일일이 확인하지 않아도 기술이 알아서 점검해주는 시대가 올지도 모릅니다. 특히 카메라와 인공지능(AI)을 활용해 건강 상태나 심리적인 변화를 감지할 수 있는 기술이 주목받고 있습니다. 카메라가 단순히 촬영만 하는 시대는 끝났습니다. 얼굴 표정, 자세, 피부 색, 심박수 변화 등을 분석해 건강 상태를 판단할 수 있다니, 상상만 해도 신기하지 않나요?

 

과거에 비해 건강을 관리할 수 있는 다양한 도구와 기술을 손에 쥐고 있지만 우리는 여전히 스트레스와 건강 문제로 고통받고 있습니다. 그 이유는 단순히 건강을 돌보는 기술이 부족해서가 아니라, 우리의 생활 방식이 병원을 중심으로 건강 관리를 하도록 설계되어 있기 때문입니다. 일상적으로 관리가 필요한 상태일지라도 병원에 가서야 심박수, 혈압, 스트레스 수준을 측정하는 현실은 건강 관리가 비효율적으로 이루어지고 있음을 보여 준다고 할 수 있습니다. 

 

최근 몇 년 사이, 건강 관리의 패러다임이 변화하고 있긴 합니다. 병원에서만 이루어지던 진단과 관찰이 일상 속으로 스며들고 있는 것입니다. 이러한 변화의 중심에는 기술 혁신이 자리 잡고 있습니다. 그중에서도 주목받는 기술 가운데 하나가 바로 카메라 촬영을 통해 심박수를 측정할 수 있는 원격 PPG(Remote Photoplethysmogram) 기술입니다. 원격 PPG는 촬영된 영상을 분석해 심박수와 같은 생체 신호를 추출하고 이를 통해 스트레스와 컨디션 등 다양한 건강 지표를 평가할 수 있습니다. 기존에는 병원을 방문해야 했고, 진단, 측정을 위한 전문 장비가 있어야 했으며 상시적인 관찰이 불가능하므로 즉각적으로 상태에 대한 피드백을 받을 수가 없었습니다. 

 

이번 글에서는 rPPG 기술에 대한 아주아주 개략적인 소개와 더불어 이용한 간단한 심박동 측정 예시를 보여 드리도록 하겠습니다. 


2. 본론 : 카메라 기반 심박동 측정 테스트 

2.1. PPG (rPPG: remote Photolethysmography) 기술

PPG란?

PPG란 빛을 이용해 피부 근처의 혈관의 혈류량의 변화를 측정하는 기술입니다. '광혈류측정'이라고도 불립니다. 흠... 어려운 용어네요... 좀 더 쉽게 풀어 보면 다음과 같습니다.

 

사람의 몸에 빛을 비추면 빛이 흡수되는데 그 정도는 빛을 비춘 부윙의 부피에 비례합니다. 그런데 사람 피부의 경우, 표면 피부 조직 자체는 부피 변화가 크게 일어나지 않는 반면 피부 하부의 혈관 속 혈액 부피는 심장의 펌핑에 따라 늘고 줄고를 반복하게 됩니다. 그래서, 빛이 흡수되는 패턴을 파악하면 심박수를 측정할 수 있게 되는 것입니다. 

 

rPPG란?

rPPG 즉, remote PPG란 RGB 카메라를 이용해 빛의 반사량 변화로 인해 발생하는 피부색의 변화를 감지해 이를 기반으로 심박수를 측정하는 기술입니다. 스마트폰, 태블릿, 노트북 등의 카메라를 활용해 사용자의 얼굴 영상에서 피부 미세 변화를 감지함으로써 심박수와 관련 데이터를 추출합니다.


이 기술의 주요 장점은 다음과 같습니다.

  • 접근성: 별도의 장비가 필요 없이 일상에서 사용하던 기기로 활용 가능
  • 실시간 데이터: 사용자가 원하는 순간 건강 상태를 측정할 수 있음
  • 비용 효율성: 추가 장비나 병원 방문 없이 측정 가능

 

2.2.적용 기술 및 세부 사항

 

원격 PPG는 피부에 투과된 빛이 혈류에 따라 반사되는 양상이 변화하는 원리를 기반으로 합니다. 이를 통해 다음과 같은 생체 데이터를 측정할 수 있다고 합니다. 다만, 이 가운데  심박수를 제외한 나머지  이 가운데 가장 기본이 되는 것이 심박수이며 이를 이용해 스트레스, 컨디션 지수 등을 분석합니다. 

  • 심박수: 심장의 박동 주기 측정
  • 스트레스 지수: 심박수 변동성(HRV)을 분석하여 계산
  • 컨디션 지수: 심박수와 신체 움직임 데이터를 조합하여 평가
  • 기타 : 표면온도, 산소포화도, 호흡도, 혈압 등

이 과정은 일반적으로 다음의 단계로 이루어집니다.

  • 1단계. 영상 촬영: 얼굴의 고해상도 영상을 카메라로 촬영
  • 2단계. 영상 처리: 특정 알고리즘을 사용해 피부 영역 분석
  • 3단계. 데이터 추출: 빛 반사 패턴을 분석하여 심박수 및 관련 데이터를 추출

 

2.3 . 적용 사례

 

이 기술이 적용된 사례는 아주 다양한데 그 가운데 일부를 보면 다음과 같습니다. 

  • 스마트폰 앱: 스트레스 및 컨디션 평가 기능을 제공하는 다양한 건강 관리 앱
  • 자동차 산업: 졸음운전을 방지하기 위해 원격 PPG를 이용한 드라이버 상태 모니터링
  • 피트니스 기기: 운동 중 심박수와 피로도를 분석하여 최적의 운동 계획 제공
  • 딥페이크 판별 : 딥페이크로 생성된 어굴 영상의 경우, rPPG 신호 패턴이 실제 사람에게서 나타나는 것과 확연히 달라 이를 이용해 실제 얼굴인지 파악

 

2.4.제약사항

 

범용 장비(웹캠이나 스마트폰 카메라)를 사용하고 방식이 간단해서 편리한 기술이지만 제약사항도 만만치 않게 있습니다. 

  • 조명 조건 : 밝기와 색상이 일정하지 않은 환경에서 데이터 정확도 저하 가능성
  • 움직임 제약 : 정확한 측정을 위해서는 얼굴 표정이나 위치 변화를 급격하게 주면 안 됨. 대략 5~10초간 자세 유지 권장
  • 알고리즘 개선 필요 : 다양한 피부 톤이나 얼굴 구조에서도 일관된 결과를 제공해야 함
  • 개인 정보 보호 : 얼굴 영상 데이터가 악용되지 않도록 보안 기술 필수
  • 고도화된 딥페이크 기술 등장 : rPPG 신호 패턴을 포함시킨 안면 영상 제작 기법이 등장할 경우 판별이 어려울 수 있음 

 

 

3.심박동 측정 테스트 

3.1. 환경준비

광혈류변화 측정을 위한 ROI(관심영역)은 얼굴 가운데서 이마 부분으로 잡았습니다. 찾아본 바로는 어떤 곳은 이마, 어떤 곳은 얼굴 양쪽 뺨 등을 이용하고 있었는데 그 가운데 이마를 임의로 잡아봤습니다. 이 영역은 향후 어느 쪽이 더 적합한지 판단해서 선택적으로 적용하면 될 것 같습니다. 그리고, 안면감지와 이마 부분 추출을 위해 mediapipe를 이용합니다.

 

$ pip install opencv-python mediapipe numpy scipy scikit-learn

 

3.2.코드

ChatGPT에는 다음과 같은 몇 가지 요구사항을 전달했습니다. 

  • rPPG 측정을 위한 ROI(관심영역)은 이마 부분으로 잡을 것
  • 화면은 상단, 하단으로 나눠 위에는 카메라 영상 (ROI 영역 표시 포함)을, 아래에는 측정한 심박동수를 시계열 그래프와 수치를 표시해야 함
  • 2명 이상의 사람이 존재할 수 있으며 각 사람 별로 ROI 박스 색과 그래프 선 색을 동일하게 매칭해서 보여줘야 함

 

위의 요구사항이 반영된 코드는 다음과 같습니다. 역시나 많은 시행착오가 있었습니다. 대부분은 값의 측정이 아닌 화면 표시와 관련한 것이었던 것 같습니다. 

import cv2
import numpy as np
import mediapipe as mp
from scipy.signal import butter, filtfilt
from scipy.fft import fft
from collections import deque
import os

# CUDA 비활성화 설정
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"

# ForeheadDetector 클래스 정의
class ForeheadDetector:
    def __init__(self, maxFaces=1, minDetectionCon=0.5, minTrackCon=0.5):
        self.maxFaces = maxFaces
        self.minDetectionCon = minDetectionCon
        self.minTrackCon = minTrackCon

        # Mediapipe의 FaceMesh 초기화
        self.mpFaceMesh = mp.solutions.face_mesh
        self.faceMesh = self.mpFaceMesh.FaceMesh(static_image_mode=False,
                                                 max_num_faces=self.maxFaces,
                                                 min_detection_confidence=self.minDetectionCon,
                                                 min_tracking_confidence=self.minTrackCon)

    def find_face_mesh(self, img):
        img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        self.landmark_results = self.faceMesh.process(img_rgb)
        return self.landmark_results

    def get_forehead_coordinates(self, img):
        forehead_points_list = []

        if self.landmark_results.multi_face_landmarks:
            for face_landmarks in self.landmark_results.multi_face_landmarks:
                landmarks = face_landmarks.landmark
                top_left = int(landmarks[109].x * self.img_width), int(landmarks[109].y * self.img_height)
                top_right = int(landmarks[338].x * self.img_width), int(landmarks[338].y * self.img_height)
                bottom_left = int(landmarks[107].x * self.img_width), int(landmarks[107].y * self.img_height)
                bottom_right = int(landmarks[336].x * self.img_width), int(landmarks[336].y * self.img_height)

                points = [top_left, top_right, bottom_right, bottom_left]
                forehead_points = np.array(points).astype("int32").reshape((-1, 1, 2))
                forehead_points_list.append(forehead_points)

        return forehead_points_list

    def analyze(self, img):
        self.img_height, self.img_width, _ = img.shape
        # 얼굴 메쉬 찾기
        self.find_face_mesh(img)
        # 이마 좌표 추출
        return self.get_forehead_coordinates(img)

# 신호 처리 함수
def bandpass_filter(signal, lowcut, highcut, fs, order=5):
    nyquist = 0.5 * fs
    low = lowcut / nyquist
    high = highcut / nyquist
    b, a = butter(order, [low, high], btype='band')
    y = filtfilt(b, a, signal)
    return y

# 메인 코드
def main():
    cap = cv2.VideoCapture(0)
    fps = cap.get(cv2.CAP_PROP_FPS)
    fs = 30 if fps == 0 else int(fps)

    # ForeheadDetector 객체 초기화
    detector = ForeheadDetector(maxFaces=1)

    # 신호 버퍼 초기화
    SIGNAL_BUFFER_SIZE = int(fs * 5)  # 5초 신호 버퍼
    signal_buffer = deque(maxlen=SIGNAL_BUFFER_SIZE)
    heart_rate_buffer = deque(maxlen=100)  # 그래프에 표시할 최근 심박수 데이터 저장

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break

        frame = cv2.flip(frame, 1)  # 좌우 반전
        forehead_coords_list = detector.analyze(frame)

        # 이마 박스 색상 설정 (그래프와 일치시키기 위해)
        box_color = (0, 255, 0)  # 초록색

        # 각 얼굴에 대해 이마 영역의 평균 색상 추출 및 신호 버퍼에 추가
        for forehead_coords in forehead_coords_list:
            if forehead_coords is not None and len(forehead_coords) > 0:
                mask = np.zeros_like(frame)
                cv2.drawContours(mask, [forehead_coords], -1, (255, 255, 255), -1)
                roi = cv2.bitwise_and(frame, mask)
                gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
                avg_color = np.mean(gray[gray > 0])  # 이마 영역의 평균 색상 값
                signal_buffer.append(avg_color)

                # 이마 영역을 화면에 표시
                cv2.drawContours(frame, [forehead_coords], -1, box_color, 2)

        # 신호 처리 및 심박수 추정
        heart_rate = None
        if len(signal_buffer) == SIGNAL_BUFFER_SIZE:
            signal = np.array(signal_buffer)
            signal = signal - np.mean(signal)
            signal = signal / np.std(signal)

            # 대역 통과 필터 적용
            try:
                filtered_signal = bandpass_filter(signal, 0.7, 3.0, fs)
            except Exception as e:
                print(f"필터링 중 오류 발생: {e}")
                filtered_signal = signal

            # FFT를 사용한 심박수 추정
            n = len(filtered_signal)
            fft_values = np.abs(fft(filtered_signal))
            fft_values = fft_values[:n // 2]
            freq = np.fft.fftfreq(n, d=1 / fs)[:n // 2]

            idx = np.where((freq >= 0.7) & (freq <= 3.0))
            if len(idx[0]) > 0:
                peak_freq = freq[idx][np.argmax(fft_values[idx])]
                heart_rate = peak_freq * 60  # bpm
                heart_rate_buffer.append(heart_rate)

        # 화면에 심박수 그래프 표시
        graph_height = 100
        graph_width = int(frame.shape[1])  # 그래프 폭을 더 길게 설정 (전체 화면의 70% 차지)
        heart_rate_width = 0  # 심박수 표시 부분 폭 (전체 화면의 30% 차지)
        extended_height = frame.shape[0] + graph_height + 20
        graph_overlay = np.zeros((extended_height, frame.shape[1], 3), dtype=np.uint8)
        graph_overlay[:frame.shape[0], :frame.shape[1]] = frame
        overlay_y_start = frame.shape[0] + 10  # 아랫쪽에 그래프 표시

        if len(heart_rate_buffer) > 1:
            for i in range(1, len(heart_rate_buffer)):
                x1 = int((i - 1) / len(heart_rate_buffer) * graph_width)
                y1 = overlay_y_start + graph_height - int(heart_rate_buffer[i - 1] / 200 * graph_height)
                x2 = int(i / len(heart_rate_buffer) * graph_width)
                y2 = overlay_y_start + graph_height - int(heart_rate_buffer[i] / 200 * graph_height)
                cv2.line(graph_overlay, (x1, y1), (x2, y2), box_color, 2)  # 이마 박스 색상과 동일하게 설정

        # y축 수치 표시
        for y in range(0, 201, 50):
            y_pos = overlay_y_start + graph_height - int(y / 200 * graph_height)
            cv2.putText(graph_overlay, f'{y}', (graph_width + 5, y_pos), cv2.FONT_HERSHEY_PLAIN, 1, box_color, 1)

        # 심박수 값을 그래프 오른쪽에 표시
        if heart_rate is not None:
            cv2.putText(graph_overlay, f'HR: {int(heart_rate)}', (int(frame.shape[1] * 0.8), overlay_y_start + 30), cv2.FONT_HERSHEY_PLAIN, 1, box_color, 1)

        # 화면에 결과 표시
        cv2.imshow('Heart Rate Monitor', graph_overlay)

        # 'q' 키로 종료
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break

    cap.release()
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()

 

3.3.테스트 및 결과 확인

 

실행 결과는 다음과 같습니다. 다만, 복수의 사람에 대해서는 대상이 없어서 테스트를 못 해 봤습니다. 대략 60~100 사이의 수치가 나오는데 일부러 가만있지 않고 움직이거나 몸에 격한 움직임을 주면 순간적으로 수치가 튀어 오르곤 합니다. 정확하고 정밀한 측정치인지는 정밀 측정장치를 달고 테스트를 해 봐야할테니 넘어 가도록 합니다. 다만, 이런 식으로 측정한다 정도만 보시면 될 것 같습니다. 

 

 

4. 결론

이번 글에서는 원격 PPG에 대한 개요와 이를 웹캠을 이용해 측정해 보는 예제를 소개해 드렸습니다. 원격 PPG 기술은 우리의 건강 관리를 '병원 중심'에서 '일상 생활'로 옮길 수 있는 한가지 방법인 것 같습니다. 이를 잘 이용하면 자신의 스트레스와 컨디션을 실시간으로 점검하고 필요한 조치를 미리 취할 수 있습니다. 아직 광범위하게  확산되진 않은 것을 보면 분명 기술적/산업적으로 풀어야 할 제약사항들이 있을 것 같지만, 이 기술 자체가 접근성과 편리함 측면에서 이미 충분히 높은 잠재력을 가지고 있기 때문에 앞으로 좀 더 기술적 보완만 된다면 다양한 분야에 활용될 수 있지 않을까 기대해 봅니다. 

 


5. 참고자료

  • rPPG: Contactless heart rate measurement (유튜브 영상)
  • Heart-rate-monitoring-system (깃헙) : 뺨 영역을 관심영역(ROI)로 잡고 진행
  • Camera-based rPPG signal extraction for Heart Rate Estimation (Undergraduate_English Presentation) (기술소개, 광운대)

 


6. Q&A

Q: 원격 PPG 기술은 어떤 원리로 심박수를 측정하나요?

카메라로 촬영한 피부 영상에서 혈류 변화에 따른 빛 반사량 변화를 분석하여 심박수를 계산합니다.

Q: 조명이 어두운 환경에서도 이 기술을 사용할 수 있나요?

조명이 부족하거나 색상이 일정하지 않은 환경에서는 측정 정확도가 떨어질 수 있습니다. 이를 해결하기 위해 고감도 카메라와 알고리즘 개선이 필요합니다.

Q: 원격 PPG 기술이 활용되는 대표적인 분야는 무엇인가요?

스트레스 관리, 피트니스, 졸음운전 방지, 심리 상태 판정 등 다양한 분야에서 활용되고 있습니다.