いまや心拍センサーはどんなスマートウォッチにも入っている。では、時計を持っていなかったら? スマートフォンにはカメラが残っている — そして実は、それだけで心拍数を推定できる。外付けのハードウェアも、クラウドも、保存されるフレームも一切なしで。MeteoHealth では、この機能を丸ごとシステムフレームワークだけで組み上げた — キャプチャはAVFoundation、顔検出はVision、スペクトル解析はAccelerate。MLモデルは一切なし:中身は純粋なデジタル信号処理だ。以下では、プロダクションの実コードとともにその仕組みを解説し、この手法の限界がどこにあるのかも正直に述べる。
指がセンサーになる仕組み#
原理は光電容積脈波(PPG)と呼ばれ、パルスオキシメーターと同じものだ。指を背面カメラに載せ、フラッシュ(torch)が指先を透かして照らす。血液は光を吸収し、心臓が拍動するたびに毛細血管の血流量が変わる — それに伴い、センサーに届く光の量も変わる。カメラは光検出器になる:1フレームが信号の1サンプルだ。
各フレームからは、中央の64×64領域における赤チャンネルの平均を取る — Core Imageも中間コピーも使わず、生のBGRAバッファを直接走査する:
/// 中央64×64領域の赤(RED)の平均(指モード)。BGRA、Rは+2。
private nonisolated static func meanRedCenter(of pixelBuffer: CVPixelBuffer) -> Float? {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return nil }
// ...
var redSum: UInt64 = 0
for y in startY ..< startY + cropSize {
let rowStart = y * bytesPerRow
for x in startX ..< startX + cropSize {
redSum &+= UInt64(bytes[rowStart + x * 4 + 2])
}
}
return Float(redSum) / Float(cropSize * cropSize) / 255.0
}30 fpsで25–30秒の間に、およそ750–900サンプルのバッファが溜まる。フレームは平均化の直後に破棄される — CMSampleBuffer はどこにも保持されず、ディスクにファイルが生まれることもない。これは副作用ではなく、view modelのヘッダーに明記された不変条件だ。
数学的処理はすべて独立した構造体 PPGProcessor に収まっており、AVFoundationもSwiftUIも知らない — Float の配列とタイムスタンプだけを扱う。おかげでパイプライン全体がユニットテストで覆える:1.2 Hzの合成サイン波を入力すれば、必ず72 BPMが返ってこなければならない。
Swift 6の世界に住む人向けの補足をひとつ:AVCaptureSession と AVCaptureVideoDataOutput はMainActor上ではなく専用の sessionQueue 上で構成されるため、strict concurrencyでは nonisolated(unsafe) とマークされている — 隔離(confinement)はキューによって保証される、という明示的なコメント付きで:
/// セッションとoutputはsessionQueue上に閉じ込められる(begin/commitConfiguration、
/// start/stopRunning)— MainActor上ではない。ゆえにnonisolated(unsafe)。
nonisolated(unsafe) let captureSession = AVCaptureSession()不均一な時間:なぜリサンプリングが要るのか#
こうして各フレームが1つのサンプルになった — ただし、等しい時間間隔でではない。パイプラインで最も見落とされがちなステップはFFTではなく、その前段だ。FFTは等間隔サンプリングを前提とするが、CMSampleBuffer のタイムスタンプは揺らぐ:システムはフレームをドロップし、フレーム間隔は「呼吸する」。サンプルが等間隔だと見なしてしまうと、スペクトルはにじみ、ピークはずれる。だから最初のステップとして、信号を30 Hzの等間隔グリッドへ線形補間する:
/// 等間隔グリッドへの線形補間。
private func resample(samples: [PPGSample], to rate: Double) -> [Float] {その先はDSPの定石だ:移動平均(約3秒)を引くデトレンドで輝度のゆっくりしたドリフトを除去し、Hamming窓で端のアーティファクトを抑える。最小長のゲートも正直に作ってある — 「サンプル数」ではなく、ドロップフレームに強いウィンドウの実際の長さで判定する:しきい値は8.5秒で、30 Hzなら少なくとも256個のリサンプル済みポイントがFFTに渡る計算だ。
ピークを数える代わりにスペクトルを#
素朴なやり方は、時間領域の局所極大を拍として数えることだ。ノイズだらけのカメラ信号ではこれは崩壊する:指が一度動くだけで、存在しなかった「拍」が生まれる。スペクトル経由の方が頑健だ:vDSPによるreal-FFT(vDSP_fft_zrip → vDSP_zvmags)でパワースペクトルを得て、ピークは生理学的な帯域0.7–4.0 Hz — つまり42–240 BPM — の中だけで探す:
let bandStart = Int((Self.minBPM / 60.0) * Double(paddedLength) / Self.resamplingRate)
let bandEnd = min(spectrum.count - 1,
Int((Self.maxBPM / 60.0) * Double(paddedLength) / Self.resamplingRate))帯域の外にあるもの — 呼吸、手の震え、照明のちらつき — はピーク選択に一切影響しない。しかし最大値を見つけるだけでは足りない:それが本物のピークなのか、ノイズの偶然の盛り上がりなのかを見極める必要がある。そのためにSNRを計算する — 同じ帯域内の近傍ビンの中央値に対するピークの比だ(ピーク自身と±2ビンの近傍は除外する):
floorBins.sort()
let floorValue = floorBins.isEmpty ? 1e-9 : Double(floorBins[floorBins.count / 2])
let snr = max(1.0, Double(peakValue) / max(floorValue, 1e-9))
guard snr >= Self.minSNR else {
return .failure(.lowSignalQuality)
}これがプロダクトとしての核心的な決定だ:SNRがしきい値を下回れば、アプリは「信号が弱い」というエラーを返してやり直しを促す — 自信ありげな顔で作り物の心拍数を表示する代わりに。正直に棄却された悪い計測は、信用できないきれいな数字に勝る。
仕上げは、3つのビンによるピーク頂点の放物線補間だ。短いウィンドウではFFTのグリッドは粗く(1ビンあたり約1.7 BPM)、補間はほぼタダでサブビンの周波数分解能を与えてくれる。
フレームの中の額:VisionによるrPPG#
第2のモードは指もフラッシュも使わない:フロントカメラが顔を見る(remote PPG)。血液の脈動は肌の色合いをわずかに変え、その応答が最も強く現れるのは緑チャンネルだ。Visionはここでただ一つのこと — 顔を見つけること — にだけ使われ、その上にMLの推論は乗っていない:
guard let face = request.results?.first,
face.boundingBox.width >= Self.minFaceWidth else {
return nil
}
// ROI「額」:顔の上部(Vision:Y軸は下から上 → 上=大きいY)。
let bb = face.boundingBox
let roi = CGRect(
x: bb.minX + bb.width * 0.25,
y: bb.minY + bb.height * 0.68,
width: bb.width * 0.5,
height: bb.height * 0.20
)ROIはbounding boxからの幾何計算で組み立てる:額の高さにある、顔の高さの20%分の帯で、幅は中央の50% — そこは肌が露出していて、目や髪の干渉が最も少ない。minFaceWidth = 0.15 のゲートは「顔が遠すぎる」ケースを弾く:小さすぎる顔ではROIがひと握りのピクセルに退化してしまうからだ。
その先は、CVPixelBufferLockBaseAddress の下で生のBGRAバッファを走査する同じ処理 — ただしチャンネルオフセットが +2 ではなく +1(緑)になるだけで、得られた信号は同じ PPGProcessor に渡る。プライバシーも指モードと同じだ:顔の写ったフレームから取り出すのはただ一つの数値 — 緑の平均 — であり、フレームは死ぬ。
ただし注意点がある:rPPGは接触型PPGより明らかに気難しい。頭の動き、照明の変化、額にかかる影 — そのどれもが信号そのものと同じオーダーのノイズになる。ここでも救ってくれるのは同じSNRゲートだ:疑わしい計測は通らない。
Baevsky法によるストレス — 但し書き付きで#
同じ信号から得られるのは心拍数だけではない。デトレンド済みの時間領域信号からRR間隔(拍と拍の間の距離)を抽出し、そこからBaevskyのストレス指数(Baevsky Stress Index)を計算する:SI = AMo / (2·Mo·MxDMn)、ビン幅50 msのRRヒストグラムに基づく。式は古典的だが、その適用にはソースコードに直接書かれた正直な但し書きが付いている:
// 注意:古典的なBaevskyは5分間の記録(100拍以上)で計算する。短いPPGウィンドウ
// (約15秒、約15–20拍)では値はあくまで参考値 — トレンドの観察には使えるが、診断には使えない。古典的な手法は5分間の記録を要求する;約15秒のPPGでは、この指数はトレンドを眺めるための指標であって、それ以上ではない。有効なRR間隔が12個未満なら、計算器は nil を返す — ここでもまた、当て推量ではなく拒否だ。
カメラの信号について理解した4つのこと#
第一に:カメラはすでにセンサーであり、「生のフレームバッファ」と「心拍数」の間に魔法はない。あるのは信号の連鎖だけだ:1フレーム1サンプル → 等間隔グリッドへのリサンプリング → デトレンド → 窓関数 → FFT → 生理学的帯域でのピーク探索。第二に:リサンプリングは最も省略されがちなステップであり、まさにそれが、動くFFTパイプラインと、見た目は立派だが嘘をつくパイプラインとを分ける。第三に:信号品質は飾りではなくAPIの一部だ:SNRゲート、長さのゲート、顔サイズのゲートが、「時々デタラメを表示する」を「信用できる数字か、明示的な拒否か」に変える。そして第四に、枠組みの話:これらすべてはウェルネスの推定であって、医療計測ではない。この手法は動きと照明に敏感であり、こうした機能にできる最も大人な振る舞いとは、自らの限界を知り、それをユーザーに率直に伝えることだ。



