清洗后的波形看起来让人安心,但 App 最后还是要回答用户真正关心的问题:我现在心率是多少?答案不在峰值有多高,而在峰和峰之间隔了多久。

MonoPump 的算法刻意保持朴素:找可信峰值,用时间戳计算峰间隔,过滤可疑间隔,把秒换算成 BPM,再做平滑,避免界面上的数字乱跳。
This diagram could not be rendered. Its source is still available below.
View diagram source
flowchart LR
A[清洗后的 PPG 窗口] --> B[自适应峰值阈值]
B --> C[局部最大值]
C --> D[峰间隔列表]
D --> E[中位数离群过滤]
E --> F[BPM = 60 / 平均间隔]
F --> G[EMA 平滑]
为什么没有一上来用 FFT
频域方法很有吸引力。FFT 的模型很漂亮:在脉搏频段里找最强频率,再换算成 BPM。但手机摄像头的短窗口、脏数据不一定奖励这种优雅。运动、丢帧、接触不稳定,都可能让一个错误频率看起来很自信。
峰值检测没那么酷,但容易调试。估错了,我可以直接看峰和峰间隔。对消费级 App 来说,大多数失败来自接触质量,而不是数学不够高级。
峰值本质上是时间标记
MonoPump 寻找超过自适应阈值的局部最大值,同时限制峰之间的最小距离,避免一次心跳被拆成多个小峰。
minPeakDistance = sampleRate * 60 / maxBPM
threshold = mean + 0.5 * standardDeviation
固定阈值只适合幅度稳定的场景,而手机摄像头 PPG 不是这样的场景。自适应阈值会跟随当前窗口,同时避免每个小波动都变成心跳。
真相在间隔里
找到峰之后,用时间戳计算相邻峰之间的间隔。一个坏峰不应该决定结果,所以 MonoPump 会围绕中位数过滤间隔,再对剩余间隔求平均。
This diagram could not be rendered. Its source is still available below.
View diagram source
flowchart TD
P1[t1 的峰] --> I1[t2 - t1]
P2[t2 的峰] --> I2[t3 - t2]
P3[t3 的峰] --> I3[t4 - t3]
I1 --> M[中位数间隔]
I2 --> M
I3 --> M
M --> F[剔除偏离过大的间隔]
平滑数字,但不掩盖变化
原始 BPM 每次更新都会自然波动。每个跳动都展示出来,App 会显得紧张;把数字完全冻住,又显得不真实。MonoPump 用指数移动平均,并限制每次变化幅度,让数字能动,但不要瞬移。
smoothed = 0.35 * current + 0.65 * previous
smoothed = clamp(smoothed, previous - maxDelta, previous + maxDelta)
置信度不是装饰。它来自可用峰间隔数量,质量分来自近期信号是否有足够变化。心率 App 永远可以打印一个数字,更难的是知道什么时候不该打印。