从 40 多到 100+:一次 iOS 相机 PPG 心率算法重写复盘

30 阅读18分钟

摘要

相机测心率这个功能,最容易给人的错觉是:拿到手指区域的颜色变化,找出几个波形高点,再把它们之间的时间换算成 BPM,就完成了。

真正接入真机后,问题会同时冒出来:有时结果明显偏高,有时掉到 40 多,有时测量进度已经走完,却没有结果。单摄设备和多摄设备的表现也不一定一样,因为不同镜头和闪光灯之间的距离,会改变手指获得的光源条件。

这次复盘里,我没有继续靠固定补偿把结果乘 2 或减几个数,也没有把参考设备的心率塞回算法,处理过程分成了几层:

  1. 用相机帧的真实时间戳替代固定 FPS 假设;
  2. 把“看到局部高点”改成“从可能的心跳高点里选出一串可信的心跳时间点”;
  3. 用独立的“最常见重复节奏”证据处理二倍周期造成的半心率;
  4. 完整窗口不稳定时,只允许最近一段严格稳定的信号走备用路径;
  5. 用可解释的 Debug 摘要记录每次为什么输出、为什么拒绝。

在单后摄设备上做了两轮小样本复测,共 20 次有效测量,结果均落在参考设备实时心率的 ±10 bpm 内,也没有再出现 40 多的半心率和 100+ 的明显假高结果。这个结果只说明单摄基线进入了可继续验证的状态,多摄设备仍然要单独按镜头测试。

先给结论:这类算法最难的不是“找出高点”

如果只记住这次实践里的三个判断,可以记住下面这些:

  1. 不要把固定 30 FPS 当成真实时间。真机的帧间隔会受到曝光、系统调度、设备负载和热状态影响,心跳周期必须从帧时间戳计算。
  2. 可能的高点不等于真正的心跳位置。同一个心动周期里可能出现多个局部尖点,所有尖点都参与 RR 计算,结果自然会偏高。
  3. 完整窗口失败时,不要直接放低全局门槛。更安全的做法,是从最近一小段信号里寻找稳定片段,并继续使用严格的质量检查。

这三个判断背后其实是同一个问题:算法不能只负责“算出一个数”,还要知道这个数是不是值得输出。

一、手机摄像头到底在测什么

把手指覆盖在摄像头和闪光灯附近时,闪光灯提供光源,摄像头接收透过手指后的光线。心脏每次收缩,手指末梢的血容量会出现周期性变化,红光、绿光通道的亮度也会随之产生微小波动。

这类光学脉搏信号通常称为 PPG(Photoplethysmography,光电容积描记)。

先把本文里几个容易让人困惑的词说清楚:波形上的“高点”,就是曲线局部向上凸起的位置;它可能对应一次心跳,也可能只是噪声。算法先把这些位置列为“可能的心跳高点”,再根据时间间隔和重复规律,选出真正能代表连续心跳的一串位置。两个相邻真实心跳之间的时间差叫 RR 间隔,最后用 RR 间隔换算出 BPM。也就是说,本文后面说的“选高点”,不是在屏幕上找一个视觉上的尖角,而是在判断哪些时间点真的属于心跳。

工程上可以把它理解成一条很弱、很容易被干扰的周期波形:

相机帧
  ↓
手指区域的颜色均值
  ↓
去掉缓慢变化的亮度趋势
  ↓
找出有可能代表心跳的波形高点
  ↓
从可能的心跳高点中选出一串可信时间点
  ↓
计算 RR 间隔
  ↓
输出 BPM

问题在于,手机不是实验室设备。手指压力、漏光、镜头位置、闪光灯距离、自动曝光、帧率抖动和轻微移动,都会改变波形。

所以,这类功能更适合定位为健康参考,而不是医疗诊断。算法要做的不是“任何情况下都给一个数字”,而是在信号不可信时拒绝输出,在信号足够可信时给出稳定结果。

二、表面上是心率不准,底层其实是 RR 不可信

最初遇到的现象看起来很杂:

看到的现象更可能的工程原因
参考设备约 80 bpm,App 输出 100+一个心动周期里的多个伪高点被当成多个心跳
参考设备约 80 bpm,App 输出 40 多算法选成了二倍周期,心率被减半
进度已经走完,却没有结果完整窗口里可信的心跳时间点覆盖不够,质量检查拒绝输出
单摄正常,多摄偏高或偏低摄像头和闪光灯的相对位置改变了光源条件

这些问题不能用同一个阈值解决。

把所有结果简单限制在某个 BPM 区间,只能把异常数字藏起来;把结果固定乘 2,也会误伤真实低心率;把完整窗口覆盖门槛整体调低,又会把噪声片段放进最终结果。

真正需要追踪的是:

相机采样是否可信
→ 每个波形高点是否可信
→ 高点之间的 RR 是否可信
→ 一整段 RR 是否形成稳定节律
→ 最终 BPM 是否值得展示

三、三个看起来省事、实际不可靠的修法

1. 固定乘法或减法补偿

如果发现结果总是偏高,可以把结果乘一个系数;发现结果总是偏低,可以减去一个固定值。这种做法只适合非常稳定、误差来源单一的传感器。

相机 PPG 的误差不是固定的:

  • 漏光会改变波形形状;
  • 自动曝光会改变亮度基线;
  • 不同镜头的光源条件不同;
  • 同一个人不同时间的手指压力也不同。

误差来源在变,固定补偿就没有可靠依据。

2. 把 Apple Watch 的数字放回算法

参考设备可以用来做验证,但不能进入手机算法的输入。

如果把 Apple Watch 的心率拿来修正 App 结果,测试时看起来会更接近,但这不再是独立测量,也无法说明相机算法真的识别出了正确的周期。

正确的关系应该是:

相机算法 ─────→ App BPM
                    ↑
Apple Watch ─────→ 只做同时间对照

3. 直接降低全局质量阈值

完整窗口经常因为局部漏光或帧率抖动而覆盖不足,第一反应可能是把“有效信号覆盖比例”的门槛调低。

这样确实可能让“无结果”变少,但也会让半段稳定、半段噪声的信号更容易通过,之前的 40 多和 100+ 就可能回来。

后面采用的办法是保留完整窗口的保守门槛,只在明确属于“局部有稳定证据,但完整窗口不完整”的情况下尝试最近窗口备用路径。

四、第一个坑:固定 FPS 会把时间算错

最容易被忽略的一点,是把相机当成稳定的 30 FPS 设备。

真机运行时,帧间隔可能因为曝光、系统调度、CPU 负载或设备温度出现变化。假设算法认为每帧间隔都是 1/30 秒,但实际有些帧间隔接近 1/15 秒,那么用帧数估算时间,RR 就会被系统性算错。

抽象示例:

// 不推荐:把帧数直接当成固定时间
double rrMs = frameDistance * (1000.0 / 30.0);

// 推荐:使用相机帧和波形高点对应的真实时间
double rrMs = (currentPeakTime - previousPeakTime) * 1000.0;

最终 BPM 也必须建立在真实 RR 上:

double medianRRMs = median(confirmedRRMs);
double bpm = medianRRMs > 0 ? 60000.0 / medianRRMs : 0;

这里的重点不是某一种语言,而是时间来源。相机帧带来的时间戳比“我以为设备应该是多少 FPS”更可靠。

五、第二个坑:波形高点不等于心跳位置

PPG 波形里可以找到很多局部极大值,但它们的身份不一样:

  • 有些是真实心跳对应的主高点;
  • 有些是同一周期里的次高点;
  • 有些来自亮度抖动;
  • 有些只是平滑后剩下的噪声尖点。

如果把所有局部高点按相邻时间差生成 RR,一个真实心动周期很容易被拆成两个短周期,BPM 立刻被抬高。

一个抽象示意

真实节律:
主高点       主高点       主高点
  |          |          |
--+----------+----------+----

波形里检测到的可能高点:
主高点 次高点   主高点       主高点 次高点
  |    '      |          |    '
--+----+------+----------+----+---

错误做法:
主高点、次高点全部参与 RR
→ RR 变短
→ BPM 偏高

更稳妥的做法:
让同一预测周期内的可能高点竞争
→ 只保留更可信的一个
→ 再生成确认 RR

也可以把这个选择过程画成一条处理链:

flowchart LR
    A[相机颜色波形] --> B[局部高点候选]
    B --> C[平滑后高点聚合]
    C --> D[周期假设]
    D --> E[可能高点竞争]
    E --> F[确认的心跳时间点]
    F --> G[确认 RR]
    G --> H[median RR]
    H --> I[BPM]

代码层面,关键不是“找到几个高点”,而是不要让所有可能的高点直接进入 RR:

for each periodHypothesis:
    sequence = chooseBestCandidatePath(candidates, periodHypothesis)
    score = peakQuality
          + rrConsistency
          + periodicityEvidence
          + sequenceCoverage

choose the highest-scoring reliable sequence

原始微小高点的数量可以保留在 Debug 摘要里,帮助解释波形为什么复杂,但它不应该直接给最终 BPM 投票。

六、第三个坑:半心率不是“低于 55 就乘 2”

另一个典型错误是:真实心率约 80 bpm,算法却输出 40 多。

常见原因是,算法隔了一个真实心跳位置才选中下一个位置,把 2F 当成了真实周期 F

真实的最常见重复节奏 F:
|------|------|------|

错误选择心跳位置:
|-------------|-------------|
        2F

结果:
周期变成原来的两倍
心率变成原来的一半

最简单的修法是“低于 55 就乘 2”,但这不可靠,因为真实低心率确实可能存在。

更稳妥的判断需要同时满足几件事:

  1. 当前长周期结果确实落在疑似半心率范围;
  2. 更短的“最常见重复节奏”(算法里叫基频)有独立的周期证据;
  3. 大部分确认 RR 都能解释成这个重复节奏 F 或它的二倍 2F
  4. 恢复后的心跳时间点序列质量不低于原来的长周期路径。

抽象代码可以写成:

if outputBpm < halfRateGuard:
    fundamental = findIndependentShorterPeriod(hypotheses)

    if fundamental is reliable
       and most confirmedRRs match F or 2F:
        use fundamental period
    else:
        reject the suspicious result

这不是一个固定补偿,而是一次有证据的周期判断。没有独立的最常见重复节奏证据时,宁可拒绝,也不要强行翻倍。

七、新算法的核心:从“找高点”变成“选出一串真实心跳时间点”

把前面的判断串起来,处理链可以拆成六层。

1. 输入层:保存真实时间和颜色均值

每一帧只保留必要信息:

  • ROI 区域的红、绿、蓝平均值;
  • 相机帧 timestamp;
  • 当前帧率和帧间隔统计;
  • 手指覆盖质量。

不直接依赖绝对亮度,因为不同镜头、曝光和闪光灯距离会让绝对值差异很大。

2. 预处理层:去趋势和短窗平滑

用局部均值去掉缓慢变化的亮度趋势,再用短窗口抑制逐帧抖动。

可以把处理想成这样:

原始亮度:
缓慢变亮的基线 + 心跳波动 + 逐帧噪声

局部均值归一化:
心跳波动 + 较少的基线漂移

短窗平滑:
更容易比较波形高点的形状和周期

3. 可能高点层:先保留候选,不急着确认

每个可能高点会记录时间、突出程度、局部噪声和宽度。这个阶段只回答“这里可能有一个心跳位置”,不回答“它一定是真实心跳”。

4. 周期假设层:让多个证据一起参与

周期候选可以来自:

  • 自相关;
  • 频域扫描;
  • 相邻高点之间的时间间隔;
  • 对可能的二倍周期和半周期保留竞争项。

自相关(把波形和延迟后的自己比较)看整体重复性,频域扫描(查看哪些重复节奏最明显)提供一份独立证据,相邻高点的时间间隔提供局部信息。它们不需要完全一致,但不能只凭其中一项直接输出。

5. 心跳时间点层:选择最可信的一串位置

对每个周期假设,算法在可能的心跳高点中寻找一条连续路径。评分通常会同时考虑:

  • 高点本身是否明显;
  • RR 是否围绕某个周期;
  • 是否覆盖了足够长的有效窗口;
  • 是否出现连续漏掉心跳位置;
  • 这个周期是否有独立的重复性证据。

这样做的结果是:同一个心动周期里的多个可能高点会竞争,而不是全部进入 RR。

6. 输出层:用确认 RR 的中位数

原始相邻 RR 只用于诊断。最终 BPM 只使用确认的心跳时间点生成的 RR,并取中位数:

confirmedRRMs = [731, 748, 752, 761, 768, 775, 789]
medianRRMs = 761
BPM = 60000 / 761 ≈ 78.8

中位数比简单平均更不容易被一两个离群 RR 拉动。

八、无结果问题:最近稳定窗口备用流程

心跳时间点筛选加严后,另一类问题变得明显:完整测量窗口可能因为前半段漏光、手指压力变化或帧率波动,导致整体有效信号比例不足。

这时有两种选择:

  • 直接降低完整窗口的全局覆盖门槛;
  • 保留完整窗口门槛,但在明确可恢复的情况下分析最近稳定片段。

最终选择了第二种。

最近稳定片段的备用流程

flowchart TD
    A[完整窗口分析] --> B{是否通过质量检查}
    B -- 是 --> C[输出完整窗口结果]
    B -- 否 --> D{失败是否属于可恢复类型}
    D -- 否 --> E[拒绝输出并记录失败原因]
    D -- 是 --> F[分析最近 12 秒]
    F --> G{最近 12 秒是否稳定}
    G -- 是 --> H[输出最近 12 秒结果]
    G -- 否 --> I[分析最近 8 秒]
    I --> J{最近 8 秒是否稳定}
    J -- 是 --> K[输出最近 8 秒结果]
    J -- 否 --> E

只有两类完整窗口失败允许进入这个备用流程:

  • 完整窗口覆盖不足;
  • 心跳时间点局部不完整,但仍有周期证据。

心跳时间点太少、周期证据太弱、疑似半心率等高风险失败,不允许直接进入备用流程。

最近窗口还要过哪些门

本次工程样本使用了比较保守的额外质量检查。它们是工程经验值,不是医学标准:

门控作用
最近窗口至少有足够数量的确认 RR避免两三个偶然间隔被当成稳定心率
BPM 处于保守的普通测量范围避免重新放出半心率和明显噪声高心率
RR 的离散程度不能太大排除手指压力、漏光仍在变化的片段
与已经得到的稳定 BPM 不能突然跳变避免最后几秒噪声覆盖前面真实结果

最近窗口不是“放松版算法”,而是“更短但仍然严格的算法”。

完整窗口和最近窗口的区别

完整测量窗口:
| 前半段漏光/抖动 | 后半段稳定信号       |
|-----------------|----------------------|
整体覆盖不足,主流程拒绝

最近稳定窗口:
                  | 后 12 秒稳定信号     |
                  |----------------------|
                  有足够 RR,且波动集中,允许保守输出

这个取舍解决的是“整段不完美,但最后一段足够可信”的真实手机场景,而不是为了让每次测量都必须给出数字。

九、让算法能解释自己:Debug 摘要比一个 BPM 更有用

只看最终 BPM,很难判断一次结果为什么对、为什么错。一次完整的 Debug 摘要至少应该包含:

{
  "state": "finished",
  "failureReason": "none",
  "selectedChannel": "green",
  "confirmedRRCount": 8,
  "medianRRMs": 750,
  "halfRateRecovered": true,
  "fallbackUsed": true,
  "fallbackReason": "none"
}

公开文章里展示的是字段类型示意,不是完整项目日志。

这些字段可以回答不同问题:

  • state:这次到底完成了,还是中途失败;
  • failureReason:失败是超时、信号质量不足还是其他原因;
  • selectedChannel:最终依赖红光还是绿光;
  • confirmedRRCount:最终到底有多少个 RR 参与输出;
  • medianRRMs:BPM 的时间依据是什么;
  • halfRateRecovered:是否检测并恢复了疑似二倍周期;
  • fallbackUsed:是否使用了最近稳定片段的备用流程;
  • fallbackReason:备用流程为什么成功或被拒绝。

还要注意一个日志阅读陷阱:失败 session 里可能保留一次中间计算得到的 BPM,但只要 state=failedmedianRRMs=0 或确认 RR 数量为 0,就不能把这个 BPM 当成最终结果。

十、真机验证:不要只看一两次“看起来准”

参考设备只用于对照,不进入算法输入。

验证时需要同时记录:

  1. App 的最终 BPM;
  2. 同一时间段参考设备的实时动态心率;
  3. 是否中途离开摄像头、锁屏、切后台或按歪;
  4. 日志里的状态、通道、确认 RR、备用流程情况和失败原因。

每次结果要分成四类:

类型是否计入算法准确性
正常完成,误差在要求范围内计入通过
正常完成,但明显偏高或偏低计入准确性问题
手指完整覆盖仍然无结果计入自然失败
中途离开摄像头或其他操作中断单独备注,不和自然失败混算

一次小样本结果可以说明当前阶段的方向,但不能包装成医疗结论。

在单摄设备上做的两轮小样本复测中,共有 20 次有效测量,均落在参考设备实时心率的 ±10 bpm 内,没有再出现 40 多半心率、100+ 假高心率或自然失败。

这说明单摄基线已经可以继续推进,但它不能自动推导出多摄设备也通过。

十一、多摄设备的真正难点:光源几何条件

单摄设备上,摄像头通常离闪光灯比较近,手指覆盖后获得的光源相对容易稳定。

多摄设备的镜头位置不同:

Snipaste_2026-07-17_19-34-03.png 同样的手指覆盖动作,可能在主摄上得到清楚的脉搏波,在另一个镜头上却因为光照不均、漏光或反射变化,导致:

  • 绿光信号标准差明显下降;
  • 红绿通道选择发生变化;
  • RR 离散度增加;
  • 最近稳定窗口也无法通过;
  • 结果出现系统性偏高或偏低。

因此多摄验证必须按镜头分组,统计每颗镜头的:

  • 成功率;
  • 与参考设备的误差;
  • 最近稳定片段备用流程的使用率;
  • 最终选择的颜色通道;
  • 失败原因;
  • 平均 FPS 和帧间隔。

单摄通过只是基线,多摄要逐镜头验证。算法需要允许“信号不合格时拒绝”,而不是为了体验强行出数。

十二、这次重写留下的八条工程经验

1. 用真实 timestamp,不要相信固定 FPS

帧率是运行状态,不是永远不变的常量。心跳周期必须来自真实时间。

2. 波形高点不等于心跳位置

检测到局部极大值,只说明那里可能有一个位置较高的波形点。确认它代表心跳,还需要周期和连续路径证据。

3. 参考设备只能验收,不能校准输入

否则测试结果看起来接近,却无法说明相机算法是否独立工作。

4. 偏高、偏低、无结果是三类不同问题

伪高点、二倍周期和覆盖不足的处理方式不同,不能用一个阈值统一修。

5. 半心率需要“最常见重复节奏”证据

低于某个数字并不等于一定要乘 2。没有独立的最常见重复节奏证据时,拒绝比强行翻倍更安全。

6. 无结果不能靠粗暴放宽全局阈值

完整窗口不稳定时,可以分析最近稳定片段,但这条备用路径仍然要有自己的质量检查。

7. Debug 日志要能解释每一次决定

最终数字只是结果,通道、心跳时间点序列、RR、门控和失败原因才是定位问题的材料。

8. 小样本通过后先冻结基线

新设备验证阶段不要同时修改已经通过的路径。否则即使结果变好,也不知道是新设备条件解决了,还是单摄基线被改坏了。

结语

这次重写让我重新确认了一点:移动端算法优化最怕把所有问题都归结为“再调一个阈值”。

在相机 PPG 这种场景里,偏高、偏低和无结果往往来自不同层级:时间基准可能错了,可能的心跳高点可能混在一起,二倍周期可能被误选,完整窗口也可能只是局部不稳定。

只有把采样时间、信号质量、可能的心跳高点、心跳时间点序列、RR、质量检查和真机日志串起来,才能知道一次结果为什么对、为什么错。

单摄基线通过只是第一步,接下来真正需要验证的,是不同镜头和闪光灯的物理关系改变后,这条处理链还能不能继续找到一条可信的心跳序列。