心跳之旅—💗—iOS用手機攝像頭檢測心率(PPG)
[前情提要] 光陰似箭,日月如梭,最近幾年,支持心率檢測的設備愈發常見了,大家都在各種測空氣測雪碧的,如火如荼,于是我也來湊一湊熱鬧。[0]
這段時間,我完成了一個基于iOS的心率檢測Demo,只要穩定地用指尖按住手機攝像頭,它就能采集你的心率數據。Demo完成后,我對心率檢測組件進行了封裝,并提供了默認動畫和音效,能夠非常方便導入到其他項目中。在這篇文章里,我將向大家分享一下我完成心率檢測的過程,以及,期間我遇到的種種困難。
本文中涉及到的要點主要有:
- AVCapture
- Core Graphics
- Delegate & Block
- RGB -> HSV
- 帶通濾波
- 基音標注算法(TP-Psola)
- 光電容積脈搏波描記法(PhotoPlethysmoGraphy, PPG)
在開始之前,我先為大家展示一下最后成品的效果:
心率檢測的ViewController
上圖展示的是心率檢測過程中的主要界面。
在檢測的過程中,應用能夠實時捕捉心跳的波峰,計算相應的心率,并以Delegate或Block的形式回調,在界面上顯示相應的動畫和音效。
〇、劇情概覽
好吧,😂其實上面的前情提要都是我瞎掰的,這個Demo是我來到公司的第一天接到的任務。剛接到任務的時候其實是有點懵逼的,原本以為剛入職兩天可能都是要看看文檔,或者拖拖控件,寫寫界面什么的,結果Xcode都還沒裝好,突然接到一個心率檢測的任務,頓時壓力就大起來了😨,趕緊拍拍屁股起來找資料。
心率檢測的APP在我高三左右就有了,我清楚地記得當時,年少無知的我還誤以為,大概又是哪個刁民閑著無聊惡搞的流氓應用,特地下載下來試了一下,沒想到居然真的能測。。。
總有刁民想害朕
當時就震驚地打開了某度查了這類應用的原理。所以現在找起資料來還是比較有方向性的。
花了一天的時間找資料,發現在手機心率檢測方面,網上相關的東西還是比較少。不過各種資料參考下來,基本的實現思路已經有了。
任務清單
- 實現心率檢測
一、整體思路
原理
首先說一說用手機攝像頭實現心率檢測所用到的原理。
我們知道,現在市面上有非常多具備心率檢測功能的可穿戴設備,比如各種手環以及各種Watch,其實從本質上講,我們這次要用到的原理跟這些可穿戴設備所用到的原理并無二致,它們都是基于光電容積脈搏波描記法(PhotoPlethysmoGraphy, PPG)。
iWatch的心率傳感器發出的綠光
PPG是追蹤可見光(通常為綠光)在人體組織中的反射。它具備一個可見光光源來照射皮膚,再使用光電傳感器采集被皮膚反射回來的光線。PPG有兩種模式,透射式和反射式,像一般的手環手表這樣,光源和傳感器在同一側的,就是反射式;而醫院中常見的夾在指尖上的通常是透射式的,即光源和傳感器在不同側。
皮膚本身對光線的反射能力是相對穩定的,但是心臟泵血使得血管容積周期性地變化,導致反射光也呈現出周期性的波動值,特別是在指尖這種毛細血管非常豐富的部位,這種周期性的波動很容易被觀察到。
使用iPhone的系統相機就可以輕易地用肉眼觀察到這種波動——在錄像中打開閃光燈,然后用手指輕輕覆蓋住攝像頭,就能觀察到滿屏的紅色圖像會隨著心跳產生一陣一陣的明暗變化,如下圖(請忽略滿屏的摩爾紋)。
直接用肉眼就能觀察到相機圖像的明暗變化
至于,為什么可穿戴設備上用的光源大多數都是綠光,我們用手機閃光燈的白光會不會有問題。這主要是因為綠光在心率檢測中產生的信噪比比較大,有利于心率的檢測,用白光也是完全沒問題的。詳情可以移步知乎:各種智能穿戴的心率檢測功能 。我在這里就不細說了。
https://www.zhihu.com/question/27391584
我的思路
我們已經知道我們需要用閃光燈和攝像頭來充當PPG的光源和傳感器,那么下面就來分析一下后續整體的方案。下面是我搜集完數據之后大致畫出的一個流程圖。
整體思路
- 首先我們需要采集相機的數據,這一步可以使用AVCapture;
- 然后按照某種算法,對每一幀圖像計算出一個相應的特征值并保存到數組中,算法可以考慮取紅色分量或者轉換為HSV再計算;
- 在得到一定量的數據后,我們對這個時間段內的數據進行預處理,譬如進行濾波,過濾掉一些噪聲,可以參考一篇博客:巴特沃斯濾波器;
- 接下來,就可以進行心率計算,這一步可能涉及到一些數字信號處理的內容,例如波峰檢測,信號頻率計算,可以使用Accelerate.Framework的vDSP處理框架,Accelerate框架的用法可以參考:StackOverFlow的一個回答(最終我并沒有使用,原因后面會提到);
- 最終就可以得到心率。
二、初步實現
有了大概的方案之后,我決定著手進行實現了。
1)視頻流采集
我們前面已經提到,我們要用AVCapture進行視頻流的采集。在使用AVCapture的時候,需要先建立AVCaptureSession,相當于是一個傳輸流,用來連接數據的輸入輸出,然后分別建立輸入和輸出的連接。因此,為了更加直觀,我先做了一個類似于相機的Demo,把AVCapture采集到的相機圖像直接傳輸到一個Layer上。
1.創建AVCaptureSession
AVCaptureSession的配置過程類似于一次數據庫事務的提交。開始配置前必須調用[_session beginConfiguration];來開始配置;完成所有的配置工作后,再調用[_session commitConfiguration];來提交此次配置。
因此,整個配置過程大致是這樣的:
- /** 建立輸入輸出流 */
- _session = [AVCaptureSession new];
- /** 開始配置AVCaptureSession */
- [_session beginConfiguration];
- /*
- * 配置session
- * (建立輸入輸出流)
- * ...
- */
- /** 提交配置,建立流 */
- [_session commitConfiguration];
- /** 開始傳輸數據流 */
- [_session startRunning];
2.建立輸入流From Camera
要從相機建立輸入流,就得先獲取到照相機設備,并且對它進行相應的配置。這里對照相機的配置最關鍵的是要打開閃光燈常亮。此外,再設置一下白平衡、對焦等參數的鎖定,來保證后續的檢測過程中,不會因為相機的自動調整而導致特征值不穩定。
- /** 獲取照相機設備并進行配置 */
- AVCaptureDevice *device = [self getCameraDeviceWithPosition:AVCaptureDevicePositionBack];
- if ([device isTorchModeSupported:AVCaptureTorchModeOn]) {
- NSError *error = nil;
- /** 鎖定設備以配置參數 */
- [device lockForConfiguration:&error];
- if (error) {
- return;
- }
- [device setTorchMode:AVCaptureTorchModeOn];
- [device unlockForConfiguration];//解鎖
- }
需要注意的是,照相機Device的配置過程中,需要事先鎖定它,鎖定成功后才能進行配置。并且,在配置閃光燈等參數前,必須事先判斷當前設備是否支持相應的閃光燈模式或其他功能,確保當前設備支持才能夠進行設置。
此外,對于相機的配置,還有一點非常重要:記得調低閃光燈亮度!!
長期打開閃光燈會使得電池發熱,這對電池是一種傷害。在我調試的過程中,曾經無數次調著調著忘了閃光燈還沒關,最后整只手機發熱到燙手的程度才發現,直接進化成小米~ 所以,盡量將閃光燈的亮度降低,經過我的測試,即使閃關燈亮度開到最小也能夠測得清晰的心率。
接下來就是利用配置好的device創建輸入流:
- /** 建立輸入流 */
- NSError *error = nil;
- AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:device
- error:&error];
- if (error) {
- NSLog(@"DeviceInput error:%@", error.localizedDescription);
- return;
- }
3.建立輸出流To AVCaptureVideoDataOutput
建立輸出流需要用到AVCaptureVideoDataOutput類。我們需要創建一個AVCaptureVideoDataOutput類并設置它的像素輸出格式為32位的BGRA格式,這似乎是iPhone相機的原始格式(經@熊皮皮提出,除了這種格式,還有兩種YUV的格式)。后續我們讀取圖像Buffer中的像素時,也是按照這個順序(BGRA)去讀取像素點的數據。設置中需要用一個NSDictionary來作為參數。
我們還要設置AVCaptureVideoDataOutput的代理,并創建一個新的線程(FIFO)來給輸出流運行。
- /** 建立輸出流 */
- AVCaptureVideoDataOutput *videoDataOutput = [AVCaptureVideoDataOutput new];
- NSNumber *BGRA32PixelFormat = [NSNumber numberWithInt:kCVPixelFormatType_32BGRA];
- NSDictionary *rgbOutputSetting;
- rgbOutputSetting = [NSDictionary dictionaryWithObject:BGRA32PixelFormat
- forKey:(id)kCVPixelBufferPixelFormatTypeKey];
- [videoDataOutput setVideoSettings:rgbOutputSetting]; // 設置像素輸出格式
- [videoDataOutput setAlwaysDiscardsLateVideoFrames:YES]; // 拋棄延遲的幀
- dispatch_queue_t videoDataOutputQueue = dispatch_queue_create("VideoDataOutputQueue",DISPATCH_QUEUE_SERIAL);
- [videoDataOutput setSampleBufferDelegate:self queue:videoDataOutputQueue];
4.連接到AVCaptureSession
建立完輸入輸出流,就要將它們和AVCaptureSession連接起來啦!
這里需要注意的是,必須先判斷是否能夠添加,再進行添加操作,如下所示。
- if ([_session canAddInput:deviceInput])
- [_session addInput:deviceInput];
- if ([_session canAddOutput:videoDataOutput])
- [_session addOutput:videoDataOutput];
5.實現代理協議的方法,獲取視頻幀
上面的步驟中,我們將self設為AVCaptureVideoDataOutput的delegate,那么現在我們就要在self中實現AVCaptureVideoDataOutputSampleBufferDelegate的方法xxx didOutputSampleBuffer xxx,這樣在視頻幀到達的時候我們就能夠在這個方法中獲取到它。
- #pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate & Algorithm
- - (void)captureOutput:(AVCaptureOutput *)captureOutput
- didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
- fromConnection:(AVCaptureConnection *)connection {
- /** 讀取圖像Buffer */
- CVPixelBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
- //
- // 我們可以在這里
- // 計算這一幀的
- // 特征值。。。
- //
- /** 轉成位圖以便繪制到Layer上 */
- CGImageRef quartzImage = CGBitmapContextCreateImage(context);
- /** 繪圖到Layer上 */
- id renderedImage = CFBridgingRelease(quartzImage);
- dispatch_async(dispatch_get_main_queue(), ^(void) {
- [CATransaction setDisableActions:YES];
- [CATransaction begin];
- _imageLayer.contents = renderedImage;
- [CATransaction commit];
- });
- }
做到這里,我們已經獲得了一個類似于相機的Demo,在屏幕上可以輸出攝像頭采集的畫面了,接下來,我們就要在這個代理方法中對每一幀圖像進行特征值的計算。
2)采樣(計算特征值)
采樣過程中,最關鍵的就是如何將一幅圖像轉換為一個對應的特征值。
我先將所有像素點轉換為一個像素點(RGB):
累加合成一個像素點
轉換成一個像素點之后,我們只剩下RGB三個數值,事情就變簡單得多。在設計采樣的算法的過程中,我進行了許多種嘗試。
我先試著簡單地使用R、G、B分量中的其中一個直接作為信號輸入,結果都不理想。
– HSV色彩空間
想到之前圖形學的課上有介紹過HSV色彩空間,是將顏色表示為色相、飽和度、明度(Hue, Saturation, Value)三個數值。
HSV色彩空間[5]
我想,既然肉眼都能觀察到圖像顏色的變化,而RGB又沒有明顯的反映,那HSV的三個維度中應該有某個維度是能夠反映出它的變化的。我便試著轉換為HSV,結果發現色相H隨脈搏的變化很明顯!于是,我就先確定用H值來作為特征值。
我簡單地用Core Graphics直接在圖像的Layer上畫出H數值的折線:
色相H隨脈搏的變化
3)心率計算
為了使得曲線更加直觀,我對特征值稍做處理,又改變了一下橫坐標的比例,得到如下截圖。現在心率信號穩定以后,波峰已經比較明顯了,我們開始進行心率的計算。
縮放后,穩定的時候的心率信號
最初,我想到的是利用快速傅里葉變換(FFT)對信號數組進行處理。FFT可以將時域的信號轉換成頻域的信號,也就得到了一段信號在各個頻率上的分布,這樣,我們就能通過判斷占比最大的頻率,就差不多能確定心率了。
但是可能由于我缺乏信號處理的相關知識,經過將近兩天的研究,我還是看不懂跟高數課本一樣的文檔。。。
于是我決定先用暴力的方法算出心率,等能用的Demo出來之后,看看效果如何,再考慮研究算法的優化。
通過上面的曲線,我們可以看出,在信號穩定的時候,波峰還是比較清晰的。因此我想,我可以設置一個閾值,進行波峰的檢測,只要信號超過閾值,就判定該幀處于一個波峰。然后再設置一個狀態機,完成波峰波谷之間的狀態轉換,就能檢測出波峰了。
因為從AVCapture得到的圖像幀數為30幀,也就是說,每一幀代表1/30s的時間。那么我只需要數一數從第一個波峰到最后一個波峰之間,經過了多少幀,檢測到了多少波峰,那么,就能算出每個波峰間的周期,也就能算出心率了。
這個想法非常簡單,但是存在一個問題,那就是,閾值的設置。波峰的凸起程度并不是恒定的,有時明顯,有時微弱。因此,一個固定的閾值肯定不能滿足實際檢測的需求。于是我想到我們可以根據心跳曲線波動的上下范圍,來實時確定一個合適的閾值。我做了如下修改:
每次進行心率計算的時候,先找出整個數組的極大和極小值,確定數據上下波動的范圍。
然后,根據這個范圍的一個百分比,來確定閾值。
也就是說,一個特征值只有超過了整組數據的百分之多少,它才會被判定為波峰。
根據這個方法,我每隔一段時間對數據進行一遍檢測,在Demo中實現了心率的計算,又對界面進行了簡單的實現,大致的效果如下。
初步實現的心率檢測Demo
使用的過程中還存在一定程度的誤檢率,不過總算是實現了心率檢測~ 🎉🎉🎉
三、性能優化
在我粗略實現了心率檢測的功能后,Leader提出了對性能進行優化的要求,順便向我普及了一波Instruments的用法(以前我一直沒有用過🙊)。
任務清單
- 性能優化
- 封裝組件(delegate或block的形式);
- 提供兩種默認動畫;
我用Instrument分析了心率檢測過程中的CPU占用,發現占用率很高,維持在50%~60%左右。不過這在我的預料中,因為我的算法確實很暴力😂——每幀的圖像是1920×1080尺寸的,在1/30秒內,要對這200多萬個像素點進行遍歷計算,還要轉換成位圖顯示在layer上,隔一段時間還要計算一次心率。。。
我分析了CPU占用比較多的部分,歸納了幾個可以考慮優化的方向
- 降低采樣范圍
- 降低采樣率
- 取消AV輸出
- 降低分辨率
- 改進算法,去除冗余計算
1.降低采樣范圍
現在的采樣算法是對所有的像素點進行一次采樣,我想著是否能夠縮小采樣的范圍,例如只對中間某塊區域采樣,但試驗后我發現,只對某塊區域采樣會使得檢測到的波峰變得模糊,說明個別區域的采樣并不具有代表性。
接著我又想到了一個新的辦法。我發現圖像中,臨近像素點的顏色差異很小,那么我可以跳躍著采樣,每隔幾列、每隔幾行采樣一次,這樣一方面可以減少工作量,一方面對采樣的效果的影響也可以減少。
跳躍著采樣
采樣的方式就像上圖展示的一樣,再設置一個常量用來調節每次跳躍的間距。這樣一來,理論上,每次占用的時間就可以降低為原來的1/n^2,大大減少。經過幾次嘗試后,可以看到,采樣算法所在的函數的CPU占用比例由原來的31%降低到了14%了。
在分析CPU占用時,我發現在循環中對RGB分別累加時,第一個R的運算占用100倍以上的時間。開始時以為可能是Red分量數值較大,計算難度大,貓哥建議我使用位運算,但是我改成位運算后,瓶頸依舊存在,弄得我十分困惑。后來我試著把RGB的計算順序換一下,結果發現,瓶頸和R無關,不論RGB,只要誰在第一位,誰就會成為瓶頸。后來我想到,這應該是CPU和內存之間的數據傳輸造成的瓶頸,因為像素點都存在一塊很大的內存塊里,在取第一個數據的時候可能速度比較慢,然后后面取臨近數據的時候可能就有Cache了,所以速度回提高兩個數量級。
2.降低采樣率
降低采樣率就是將視頻的幀數降低,我記得,不知道是香農還是誰,有一個定理,大概的意思就是說,采樣率只要達到頻率的兩倍以上,就能檢測出信號的頻率。
(經coderMoe童鞋指出,此處正式名稱應為“耐奎斯特采樣定理”~香農是參與者之一)
人的心跳上限一般是160/分鐘,也就是不到3Hz,那理論上,我們的采樣率只要達到6幀/秒,就能夠計算出頻率。
不過,由于我之前使用的算法還不是特別穩定,所以,當時我沒有對采樣率進行改變。
3.取消AV輸出
之前我為了方便看效果,將采集到的視頻圖像輸出到了界面上的一層Layer上,其實這個畫面完全沒必要顯示出來。因此我去除了這部分的功能,這樣一來,整體的CPU占用就降低到了33%以下。
4.降低分辨率
目前我們采集視頻的大小是1920×1080,其實我們并不需要分辨率這么高。降低分辨率一方面可以減少需要計算的像素點,另一方面可以減少IO的時間。
在我將分辨率降低到640×480:
- if ([_session canSetSessionPreset:AVCaptureSessionPreset640x480]) {
- /** 降低圖像采集的分辨率 */
- [_session setSessionPreset:AVCaptureSessionPreset640x480];
- }
結果非常驚人,整體的CPU占用率直接降低到了5%左右!
5.改進算法,去除冗余計算
最后,我對算法中一些冗余的計算進行了優化,不過,由于CPU占用已經降低到了5%左右,真正的瓶頸已經消除,所以這里的改進并沒有很明顯的變化。
四、封裝
此前,我們已經完成了一個大致可用的心率監測Demo,但在此之前,我著重考慮的都是如何盡快實現心率檢測的功能,對整體的結構和對象的封裝都沒有太多的考慮,簡直把OC的面向對象用成了面向過程。
那么我們接下來的一個重要任務,就是對我們的心率檢測進行封裝,使它成為一個可復用的組件。
任務清單
- 封裝組件并提供合理接口(delegate或block的形式);
- 提供兩種默認動畫;
封裝ViewController
最開始的時候,我想到的是對ViewController進行封裝,這樣別人有需要心率檢測的時候,就可以彈出一個心率監測的ViewController,上面帶有一些檢測過程中的動畫效果,檢測完成后自動dismiss,并且返回檢測到的心率。
我在protocol中聲明了三個接口:
- /**
- * 心率檢測ViewController的代理協議
- */
- @protocol MTHeartBeatsCaptureViewControllerDelegate
- @optional
- - (void)heartBeatsCaptureViewController:(MTHeartBeatsCaptureViewController *)captureVC
- didFinishCaptureHeartRate:(int)rate;
- - (void)heartBeatsCaptureViewControllerDidCancel:(MTHeartBeatsCaptureViewController *)captureVC;
- - (void)heartBeatsCaptureViewController:(MTHeartBeatsCaptureViewController *)captureVC
- DidFailWithError:(NSError *)error;
- @end
我將三個方法都設為了optional的,因為我還在ViewController中設置了三個相應的Block供外部使用,分別對應三個方法。
- @property (nonatomic, copy)void(^didFinishCaptureHeartRateHandle)(int rate);
- @property (nonatomic, copy)void(^didCancelCaptureHeartRateHandle)();
- @property (nonatomic, copy)void(^didFailCaptureHeartRateHandle)(NSError *error);
封裝心率檢測類
對ViewController進行封裝之后,我們可以看到,還是比較不合理的。這意味著別人只能使用我們封裝起來的界面進行心率檢測,如果使用組件的人有更好的交互方案,或者有特殊的邏輯需求,那他使用起來就會很不方便。因此,我們很有必要進行更深層次的封裝。
接下來,我將會剝離出心率檢測的類,進行封裝。
首先,我一點點剝離出心率檢測的關鍵代碼,放進新的MTHeartBeatsCapture類中。剝離的差不多之后,就發現滿屏的代碼都是紅色的Error😲,花了一個下午,才把項目恢復到能運行的狀態。
我在心率檢測類中設置了兩個方法:啟動和停止。使用起來很方便。
- /** 開始檢測心率 */
- - (NSError *)start;
- /** 停止檢測心率 */
- - (void)stop;
然后,我重新設計了一個心率檢測器的回調接口,依舊是delegate和block并存的。新的接口如下:
- /**
- * 心率檢測器的代理協議;
- * 可以選擇Delegate或者block來獲得通知,
- * 因此protocol中所有方法均為可選方法
- */
- @protocol MTHeartBeatsCaptureDelegate
- @optional
- /** 檢測到一次波峰(跳動),可通過返回值選擇是否停止檢測 */
- - (BOOL)heartBeatsCapture:(MTHeartBeatsCapture *)capture heartBeatingWithRate:(int)rate;
- /** 失去穩定信號 */
- - (void)heartBeatsCaptureDidLost:(MTHeartBeatsCapture *)capture;
- /** 得到新的特征值(30幀/秒) */
- - (void)heartBeatsCaptureDataDidUpdata:(MTHeartBeatsCapture *)capture
- @end
我在新的接口中加入了heartBeatsCaptureDidLost:,方便在特征值波動劇烈的時候進行回調,這樣外部就能提醒用戶姿勢不對。而第三個方法,則是為了之后外部的動畫view能夠做出類似于心電圖一樣的動畫效果,而對外傳出數據。
我還移除了檢測成功的回調didFinishCaptureHeartRate:,換成了heartBeatingWithRate:,把成功時機的判斷交給了外部,當外部的開發人員認為檢測的心率足夠穩定了,就可以返回YES來停止檢測。
此外,我還移除了遇到錯誤的回調DidFailWithError:,因為我發現,幾乎所有可能遇到的錯誤,都是發生在開始前的準備階段,因此,我改成了在start方法中返回錯誤信息,并且枚舉出錯誤類型作為code,封裝成NSError。
- typedef NS_OPTIONS(NSInteger, CaptureError) {
- CaptureErrorNoError = 0, /**< 沒有錯誤 */
- CaptureErrorNoAuthorization = 1 << 0, /**< 沒有照相機權限 */
- CaptureErrorNoCamera = 1 << 1, /**< 不支持照相機設備,很可能處于模擬器上 */
- CaptureErrorCameraConnectFailed = 1 << 2, /**< 相機出錯,無法連接到照相機 */
- CaptureErrorCameraConfigFailed = 1 << 3, /**< 照相機配置失敗,照相機可能被其他程序鎖定 */
- CaptureErrorTimeOut = 1 << 4, /**< 檢測超時,此時應提醒用戶正確放置手指 */
- CaptureErrorSetupSessionFailed = 1 << 5, /**< 視頻數據流建立失敗 */
- };
主要的工作完成后,貓哥給我提了不少意見,主要還是封裝上存在的一些問題,很多地方沒有必要對外公開,應該盡可能地對外隱藏,接口也應該盡量地精簡,沒必要的功能要盡可能的去掉。特別是對外公開的一個特征值數組(NSMutableArray),對外應該不可變,這一點我一直沒有考慮到。
封裝動畫&改進動畫
心率檢測類封裝完成后,我又剝離出顯示心跳波形的部分,封裝成一個MTHeartBeatsWaveView,使用的時候只要將動畫View賦給MTHeartBeatsCapture作為delegate,該view上就能獲取到特征值數據并進行顯示。
動畫改進:在測試的過程中,我發現波形動畫顯示的波形不太理想,View的大小是初始化的時候就確定的,但是心跳波動的幅度變化是比較大的,有時候一馬平川,堪比飛機場,有時候波瀾壯闊,直接超出View的范圍。
因此我對動畫的顯示做了一個改進:能夠根據當前波形的范圍,計算出合適的縮放比,對心跳曲線的Y坐標進行動態的縮放,使它的上下幅度適合當前的View。
這個改進大大提高了用戶體驗。
五、優化
我們可以看到,先前得到的曲線已經能較好地反映出心臟的搏動,但是現在進行心率的計算還是存在一定的誤檢率。上圖中展示的清晰的心跳曲線,實際上是比較理想的時候,測試中會發現,采樣得到的數據經常存在較大的噪聲和擾動,導致心率計算中經常會有波峰的誤判。因此,我在以下兩方面做了優化,來提高心率檢測的準確度。
1、在預處理環節進行濾波
得到的曲線有時含有比較多的噪聲
分析一下心率曲線里的噪聲,我們會發現,噪聲中含有一些高頻噪聲,這部分噪聲可能是手指的細微抖動造成的,也可能是相機產生的一些噪點。因此,我找到了一個簡易的實時的帶通濾波器,對之前我們采樣獲得到的H值進行處理,濾除了一部分高頻和低頻的噪聲。
加入濾波器處理后的心率信號
在經過濾波器的處理之后,我們得到的曲線就更加平滑啦。
2、參考TP-Psola算法,排除偽波峰
經過濾波器的處理之后,我們會發現,在每個心跳周期中,總會有一個小波峰,因為它不是真正的波峰,因此我稱它為“偽波峰”,這個偽波峰非常明顯,有時也會干擾到我們心率的檢測,被算法誤判為心跳波峰,導致心率直接翻倍。
這個偽波峰出現是因為,除了外部的噪聲之外,心臟本身的跳動周期中也會出現許多的“雜波”。我們來看一次心跳的完整過程。
心電圖波形產生過程的動畫 [1]
上圖是一次心跳周期中,心臟的狀態變化以及對應產生的波段。可以看到,在心臟收縮前后,人體也會有電信號刺激心臟舒張,這在心電圖上會表現出若干次的波動。而血壓也會有相應的變化,我們檢測到的數據的波動就是這樣形成的。
正常心電周期 [2]
因此,這個偽波峰的形成是無法避免的,現有的通過閾值來判斷波峰的方法很容易被欺騙,還是要考慮算法的改進,因此我又想到了快速傅里葉變換。
由于我對信號處理知之甚少,我看了兩天的快速傅里葉,還是沒有進展。于是我請教了部門里的前輩們,大家非常熱情,推薦了不少方案和資料。其中一位實驗室音頻處理的博偉學長,碰巧在新人入職培訓時和我分到了同一組,我就趁著閑暇的時候請教了他一些相關的問題。他覺得心率的波形比較簡單,沒必要用快速傅里葉變換,并且向我推薦了基音檢測算法。
基音標注
簡單地說,這個算法會標注出可能的波峰,然后通過動態規劃排除掉偽波峰,就能得到真正的波峰啦。我根據這個算法的思路,實現了一個簡化版的偽波峰排除算法。經過改進后的心率檢測,經測試準確度達到了和Apple watch差不多的程度。(自我感覺良好😂,求輕噴~~)
實時波峰檢測
我還希望提供一個實時的心跳動畫,因此我還實現了一個實時的波峰檢測。這樣每次檢測到一個波峰之后,就可以立刻通知delegate或者block,在界面上做出動畫。
心率檢測的ViewController
歇-后-語
由于這一章節是歇了一陣子之后才寫的,因此我把它叫做——歇后語。
這個心率檢測的項目前后一共做了三個禮拜左右,雖然第一個Demo用了三四天就完成,但是后續的封裝和優化卻用了兩個星期的時間,嗯,感觸頗深。。。
從最開始的incredible,到最后的好意思說堪比Apple Watch,真的是一個很有成就感的過程。雖然期間遇到了不少困難,甚至有那么一兩次覺得自己真的無解了,但到最后總能熬過去,山重水復疑無路,柳暗花明又一村。真的忍不住要念詩了,感覺很充實,很開心。
在做這個項目的過程中,我也得到了許多人的幫助。特別是貓哥,各種指導就不用說了,在聽說我們對某友好公司食堂的抱怨之后,經常帶我們出去開葷,強有力地改善了我們的伙食~😋 還有部門里的各位前輩、同事,在看到我的提問之后,非常熱情地向我提供意見和資料。希望這篇博客會對大家有所幫助。謝謝大家~