iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

30 天拆解 Wearable × AI:從穿戴裝置生理訊號到AI健康洞察系列 第 3

Day 03|PPG 如何從皮膚上的光,看見心跳?

  • 分享至 

  • xImage
  •  

今天為什麼研究這個?

Day 2 把穿戴輸出拆成量測、計算、推估三層,並把 PPG 放在最底下的「量測」那一格。但那一格裡面到底是什麼,當時沒有打開看。今天把它打開:感測器真正拿到的是什麼、那串數長什麼樣子、從它走到「72 bpm」中間發生了哪些事。

這是整條管線的第一層。如果這一層的東西是什麼都說不清楚,後面的 baseline、deviation、feature contract 都是蓋在沙上。

我原本以為:

PPG 發出綠光,照到血管,血管裡的血把光吸收掉一部分,反射回去的光的強弱會隨著心跳而有規律地起伏,接收器 PD(photodiode)接收光訊號,再透過傅立葉轉換得到波形,然後再經由波形去算心率。兩個波峰之間是心跳間隔⋯⋯

但實際真的是這樣嗎?

Concept

感測器實際記錄下來的是什麼?

綠光 LED 打在皮膚上,光進入組織,一部分被吸收、一部分散射回來,由 photodiode(PD,光偵測器)接收。PD 把光轉成光電流,經過放大、濾波、ADC 取樣之後,成為一串隨時間排列的數。沒有傅立葉轉換,波形就是取樣後的時域序列。

所以直接量測到的是反射光強度。強度越強,代表進入 PD 的光越多,代表該處的血液量越少(血量少 → 吸收少 → 反射多)。

要注意量的對象:不是某一條血管,而是組織微血管床(microvascular bed)的血容積變化

這個量為什麼會隨心跳週期變化?

心臟收縮把血液打出去,壓力波沿著動脈傳到手腕,該處微血管床的血容積上升,光吸收增加,反射光強度下降;壓力波過去之後,局部血容積回落,吸收減少,反射光回升。每一次搏動帶動一次起伏,於是反射光強度出現週期性變化。

這裡有兩件容易漏掉的事。

一是時間差。手腕上的脈動和心臟收縮不同時發生,壓力波要花時間傳到末梢。這也是 Day 9 要談的 PPG 與 ECG 對不齊的原因之一。

二是波形不只有心跳。Allen J. (2007) 把 PPG 拆成兩部分:心跳同步的脈動成分(AC),疊在一條緩慢變化的基線(DC)上,而基線帶有呼吸、交感神經活性與體溫調節的低頻成分。畫出來的波形會漂,那不是壞掉,是 DC 那一層。濾波第一個要處理的通常就是它。

一段波形裡,哪個特徵對應一次心跳?

先排除一個常見的混淆:P、Q、R、S、T 是 ECG 的波形成分,描述心臟電氣活動在體表的投影。PPG 量的是光,沒有這些成分。PPG 有自己的一套特徵點:主峰(systolic peak)、重搏切跡(dicrotic notch)與其後的次峰(diastolic peak)。

一次脈搏的間隔通常取相鄰主峰之間,也可以取波谷或上升支斜率最大的點。取哪一個點,抖動程度不一樣。

名字也要分清楚。從 PPG 峰到峰得到的是 pulse interval,由它算出的變異性叫 PRV(pulse rate variability)R-R interval 專指 ECG。安靜狀態下 PRV 估 HRV 夠接近,但運動與部分心理壓力下,兩者的一致性會差到不可接受的程度(Schäfer & Vagedes 2013)。這是 Day 4 與 Day 9 的伏筆。

從波形到一個 bpm,中間有幾步?

直覺是三步:去雜訊、找峰、換算。實際上頭尾各多一步,而多出來的那兩步,正是後面出問題的地方。

開頭要自己算時間軸。 檔案打開是一長串數字,沒有時間欄。哪個數字對應第幾秒,得從取樣率推回去——64 Hz 就是一秒 64 個數字。

結尾的 bpm 不是一拍算出來的,通常取一段視窗的平均。PPG-DaLiA 用 8 秒視窗、每 2 秒算一次,而且是在峰「偵測並修正」之後才算(Reiss 2019):漏抓、多抓、雜訊造成的假峰,都得先清掉。

傅立葉轉換也在這一步,不在前面。對一段視窗做 FFT、取主頻,是換算 bpm 的另一條路線。

所以「72 bpm」裡藏著兩個別人替你做的決定:走時域還是頻域,視窗多長。

這條路上哪裡最容易壞?

動作最常見。手一晃,感測器和皮膚的相對位置就變了,波形跟著亂。PPG-DaLiA資料集 特地在手腕上多錄一組加速度,就是為了事後把這些時段挑出來(Reiss 2019)。天冷手冰、末梢血流少的時候,脈動幅度本來就小,峰也更難找(Nitzan 2014)。

取樣率則是躲不掉的那個。64 Hz 表示每 15.625 ms 才有一個數字,峰只能落在這些點上。

而「壞」不只是波形難看。一個位置抓偏的峰,會先變成一個錯的間隔,再變成一個錯的 PRV,最後變成一句「你今天恢復不佳」。

從原理到檔案:中間還有一層

上面講的是 PPG 的原理。但實際打開PPG-DaLiA資料集檔案,欄位叫 BVP,不叫「反射光強度」。

同一個訊號,論文把它列為 raw data,廠商自己的說明卻說它經過一套 proprietary algorithm、把綠光和紅光的訊號混算出來(Reiss 2019;Empatica)。兩邊對不上,細節查不到。

也就是說,連「載入並畫出波形」這種最基本的動作,拿到手的都已經不是感測器的原始輸出。Day 2 那張三層圖講的事,在「量測」這一格裡面又發生了一次。

Hands-on

Dataset

PPG-DaLiA(UCI dataset 495)。15 位受試者在日常活動情境下同步錄製腕式 Empatica E4 與胸帶 RespiBAN。壓縮檔 2.7 GB,解壓後 24.1 GB,今天只解出 S1。

S1 的三個訊號通道各跨 9,212.00 s(153.53 min):腕式 BVP 64 Hz(589,568 點,單位原文未給)、腕式 ACC 32 Hz(g)、胸帶 ECG 700 Hz(mV),樣本數比例和取樣率比例分毫不差。ground truth HR 另存成 label,4,603 筆,剛好等於 (9212 − 8) / 2 + 1——Reiss 2019 說它是「8 秒視窗、2 秒位移」算出來的,論文裡的一句方法描述可以直接在檔案上驗證。

另外兩件和研究筆記對不上的事:胸帶實際還有 EDAEMGTemp 三個通道,論文的通道描述只列了 ECG、加速度與呼吸;檔案裡另有 questionnaire(含 SKIN,膚色)與 rpeaks(11,431 筆,胸帶 ECG 的 R-peak 位置),筆記都沒記。最後這一項等一下會用到。

Method

  1. 取段:只解壓 S1,用 pickle.load(fh, encoding="latin1") 讀(資料集是 Python 2 存的)。用 activity 標籤挑最長的靜坐區段(700 s,起於 90 s),取其中的 120–140 s;下面的圖畫的是這 20 秒的前半。
  2. 濾波與找峰:由取樣率重建時間軸;0.25–8 Hz 帶通,sosfiltfilt(零相位,峰不會位移),兩端各鏡像填充 2 秒;scipy.signal.find_peaksdistancemax_bpm 換算,prominence 由訊號振幅動態決定,用來擋掉重搏切跡後的次峰。
  3. 換算:相鄰峰的索引差乘上 1000 / 64,得到毫秒。

Code

from wearable_ai.datasets import load_subject
from wearable_ai.signal_processing import find_pulse_peaks, peak_intervals_ms, time_axis

FS = 64.0
subject = load_subject(1)                      # data/public/ppg+dalia/PPG_FieldStudy/S1/S1.pkl
x = subject.ppg[int(120 * FS): int(140 * FS)]  # 靜坐區段內的 20 秒

t = time_axis(len(x), FS)                      # 秒;t[-1] 是 (n-1)/fs,不是 n/fs
peaks = find_pulse_peaks(x, FS)                # 主峰的樣本索引
intervals = peak_intervals_ms(peaks, FS)       # 毫秒

[S1 腕式 BVP 的 10 秒波形,上圖標出 7 個主峰,下圖為相鄰峰的間隔]
https://ithelp.ithome.com.tw/upload/images/20260916/20184206lZMEkZFXYj.png

上圖:S1 的 120–130 s,也就是上面那 20 秒的前半,7 個主峰。每個週期都是陡升到最大值,下降段有一個次級隆起——那就是重搏切跡之後的次峰,Concept 裡講的形態,在真實訊號上看得到。下圖:相鄰峰的間隔,1,078 到 1,516 ms。

結果與意外

對照開頭那段「我原本以為」,實際跑完之後:

  1. 波形就是 ADC 取樣後的時域序列。FFT 出現在最後一步,是估 bpm 的其中一條路線,不參與產生波形。
  2. P、Q 是 ECG 的成分。PPG 的特徵點是 systolic peak、dicrotic notch、diastolic peak,圖上的次級隆起就是後者。
  3. 峰到峰得到的是 pulse interval,不是 R-R interval。
  4. 檔案欄位叫 BVP。全長 589,568 點的平均是 −0.0023、標準差 97.14——DC 成分已經被廠商移除,原理層說的「反射光強度」根本拿不到。
  5. 該段(120–140 s)14 個峰的間隔,全部是 15.625 ms 的整數倍(69 到 99 倍)。峰間期不是連續量,它的解析度等於一個取樣週期。
  6. 為了讓合成測試變綠而加的左側 padding,在真實資料上每一段都製造一個 index 0 的假峰。三段 10 秒,三段都中。

第 6 點值得展開。合成波形的第一個峰原本落在 index 0,而 scipy.signal.find_peaks 找不到端點——端點沒有兩側鄰居。為了讓那個峰被找到,程式在左側加了鏡像填充。測試變綠了。

但鏡像填充讓 x[0] 必然成為局部極大。換到真實資料,S1 的 120 s 那一點 BVP 是 −15.70,其後五個樣本單調下降到 −27.51,明顯在下降段中間——偵測器照樣把它標成峰,並產生一個 703 ms 的假間隔,把中位間隔從 1,437.5 ms 拉到 1,421.875 ms。

真正修好它的是另一步:明確排除 index 0 與 n−1。把三種填充方式各跑一次,排除端點之後三種的結果一模一樣,都是 14 個峰、中位間隔 1,437.5 ms。填充方式只影響合成測試(改成左右對稱,讓末峰的偏差由 3 個樣本降到 1 個),在真實資料上不改變答案。

也就是說,這個補丁裡真正有用的那一半,不是當初為了讓測試變綠而加的那一半。

這對 AI Engineering 的意義

「量測」那一格裡面還有一層。 Day 2 把 PPG 歸在量測層,但今天打開檔案才發現,拿到手的 BVP 平均值是零——廠商已經把 DC 拿掉了,用的是一個查不到細節的 proprietary algorithm。所以 feature contract 要記的不只是 level,還要記「這個欄位在到我手上之前被誰動過、怎麼動的」,以及最誠實的那一欄:不知道。

測試綠燈不等於正確。 padding 那個 bug 的因果鏈是:合成 fixture 的生成慣例(峰從相位 0 開始)→ 一條測試要求端點的峰被找到 → 程式加了填充 → 填充在真實資料上製造系統性假峰。整條鏈上每一步都合理,合起來是錯的。而且它只在真實資料上現形。這件事會在 Day 26 的評測集上以更大的規模重演:評測集本身的偏差,會塑造被評測的系統。

上游的取樣率是下游指標的精度上限。 15.625 ms 這個數字不會出現在任何一個 CSV 欄位裡,但它決定了峰間期能有多細。Day 4 之後要算的 RMSSD 是「相鄰間期差值」的統計量,而差值的顆粒就是它。一個標著 hrv_ms: 42 的欄位,背後有一個沒人寫下來的解析度下限。


上一篇
Day 02|Wearable 到底在量什麼?量測、指標與推估的差別
下一篇
Day 04|為什麼 HRV 不是越高越好?RMSSD 與 SDNN 在說什麼
系列文
30 天拆解 Wearable × AI:從穿戴裝置生理訊號到AI健康洞察10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言