如今任何一块手表都带心率传感器。可如果没有手表呢?手机上还有摄像头——而事实证明,这就足以估算心率:不需要外接硬件,不需要云端,也不保存任何一帧画面。在 MeteoHealth 里,我完全基于系统框架实现了这个功能——用 AVFoundation 采集,用 Vision 检测人脸,用 Accelerate 做频谱分析。没有任何 ML 模型:内部是纯粹的数字信号处理。下面讲讲它的原理,配上真实的生产代码,并坦率地说清这个方法的局限在哪里。
手指如何变成传感器#
原理叫光电容积脉搏波(PPG)——与脉搏血氧仪相同。把手指按在后置摄像头上,闪光灯(torch)把指尖照透。血液吸收光线,每一次心跳都会改变毛细血管的充盈度——随之改变的还有到达传感器的光量。摄像头变成了光电接收器:每一帧就是信号的一个采样点。
从每一帧中取中央 64×64 区域红色通道的均值——直接遍历原始 BGRA 缓冲区,不经过 Core Image,也没有中间拷贝:
/// 中央 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 上,因此在严格并发模式下它们被标记为 nonisolated(unsafe)——并附有明确的注释,说明隔离由队列来保证:
/// 会话和 output 被限定在 sessionQueue 上(begin/commitConfiguration、
/// start/stopRunning)——而不是 MainActor,因此使用 nonisolated(unsafe)。
nonisolated(unsafe) let captureSession = AVCaptureSession()不均匀的时间:为什么需要重采样#
每一帧都成了一个采样点——但采样的时间间隔并不相等。流水线中最不显眼的一步不是 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——峰值与同一频带内相邻 bin 中位数之比(峰值本身及其 ±2 bin 的邻域被排除在外):
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 低于阈值,应用会返回"信号弱"的错误并建议重新测量——而不是一脸自信地显示一个编造出来的心率。被诚实拒绝的糟糕测量,好过一个无法信任的漂亮数字。
最后一笔是对峰顶做三个 bin 的抛物线插值。在短窗口上 FFT 的网格很粗(每个 bin 约 1.7 BPM),而插值几乎零成本地带来了亚 bin 级的频率分辨率。
画面中的额头:借助 Vision 的 rPPG#
第二种模式不用手指也不用闪光灯:前置摄像头看着人脸(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),基于 bin 宽 50 ms 的 RR 直方图。公式是经典的,但它的应用附带一条诚实的说明,直接写在源码里:
// 注意:经典的 Baevsky 指数基于 5 分钟记录(100+ 次心跳)计算。在短 PPG 窗口上
// (约 15 秒,约 15–20 次心跳)该值仅供参考——适合看趋势,不适合诊断。经典方法要求五分钟的记录;在约 15 秒的 PPG 上,这个指数只是用于观察趋势的指标,仅此而已。如果有效 RR 间期少于 12 个,计算器会返回 nil——又一次选择拒绝,而不是猜测。
关于摄像头信号我明白的四件事#
第一:摄像头本身已经是传感器,从"原始帧缓冲区"到"心率"之间没有魔法,只有一条信号链:每帧一个采样 → 重采样到均匀网格 → 去趋势 → 加窗 → FFT → 在生理频带内找峰。第二:重采样是最常被跳过的一步,而恰恰是它,把能正常工作的 FFT 流水线与漂亮却撒谎的流水线区分开来。第三:信号质量是 API 的一部分,而不是装饰:SNR 门控、时长门控和人脸尺寸门控,把"有时显示胡话"变成"要么是可以信任的数字,要么是明确的拒绝"。第四,也是框架性的一点:这一切都是 wellness 层面的估算,不是医疗测量。这个方法对运动和光照敏感,而这样一个功能所能做的最成熟的事,就是清楚自己的局限,并坦率地告诉用户。



