返回系列

峰谷与 BPM:find_peaks、distance、prominence 和延迟确认

解释 RespiraScope 如何把滤波波形转换成波峰、波谷、BPM 和稳定性指标,并对比固定阈值、自适应阈值和候选峰确认的设计取舍。

背景问题

滤波后的波形只是连续曲线,业务上更关心的是呼吸周期:哪里是吸气或呼气的极值,当前 BPM 是多少,节律是否稳定。RespiraScope 通过波峰、波谷和相邻波峰间隔来计算这些指标。

难点在于真实信号不总是干净的正弦波。浅呼吸幅度低,体动会制造尖峰,噪声会带来局部小峰,屏气会让峰谷缺失。所以峰谷识别不能只写成“当前点比左右点大就是峰”。

设计方案对比

方案优点问题项目判断
固定高度阈值简单直观不同患者、基线和幅度下容易失效不作为主策略
固定最小间距能减少连续伪峰无法适配 BPM 范围变化作为基础保护
自适应 prominence能随信号幅度调整阈值需要估计信号跨度和噪声当前核心策略
候选峰延迟确认实时更稳输出会晚几十到几百毫秒可选开关
复杂状态机能表达更多呼吸阶段调参和解释成本高当前不采用

RespiraScope 的目标是工程可解释,所以采用 scipy.signal.find_peaks 加业务约束,而不是训练模型或复杂状态机。

find_peaks 如何用于波峰和波谷

SciPy 的 find_peaks(data, ...) 会在数组中寻找局部峰值。找波峰时直接传滤波数据;找波谷时传负信号:

peaks, _ = signal.find_peaks(data, distance=..., prominence=...)
valleys, _ = signal.find_peaks(-data, distance=..., prominence=...)

这是一种简洁但很重要的设计:波谷识别不需要另写一套算法,只要把信号翻转,谷底就变成峰值。

prominence distance:两个峰之间的最小间隔

distance 控制两个峰之间至少相隔多少点。项目在自动模式下用 high_bpm 推导最小间隔:

最短呼吸周期秒数 = 60 / high_bpm
最小峰间距点数 = sampling_rate * 最短周期秒数 * 0.65

乘以 0.65 是工程余量:不要求峰间距必须等于完整呼吸周期,而是给不规则呼吸和检测误差留空间。

prominence 为什么比固定高度更稳

固定高度阈值看的是“这个峰绝对值有多高”。prominence 看的是“这个峰相对周围背景有多突出”。呼吸传感器的基线和幅度会变,固定高度很容易在不同人、不同姿势、不同设备上失效。

项目的 _prominence() 在自动模式下计算:

adaptive_prominence = max(0.5, 信号跨度 * peak_threshold_ratio, 相邻差分噪声 * 3)

这里使用了三个保护:

  • 0.5 是最小下限,避免阈值太低。
  • 信号跨度 * peak_threshold_ratio 让阈值随当前波形幅度变化。
  • 相邻差分噪声 * 3 用来抑制噪声制造的小峰。

这不是医学模型,而是一个可解释的工程启发式。它的优点是参数少、反馈直接、能覆盖多数演示和调试场景。

实时模式为什么要折叠和确认

理论上呼吸事件应该 peak、valley、peak、valley 交替出现。现实中可能出现连续两个 peak,这是噪声或局部抖动导致的。项目做了几层保护:

保护代码位置作用
_min_turn_distance()PeakValleyDetectorpeak 和 valley 之间也要有最小转折距离
last_event_type实时接受事件时避免连续输出同类型事件
_collapse_alternating_events()离线批量识别连续同类型事件只保留更强的
confirm_realtime_events可选配置等待未来若干点确认候选峰仍成立

候选峰确认是一个典型取舍。不开启时,前端峰谷输出更及时,但遇到噪声时可能更敏感;开启后,系统会等待 confirmation_delay_points 个采样点再输出事件,默认 12 点,在 50 Hz 下约 240ms。这个延迟通常肉眼可接受,但能换来更稳的实时标记。

BPM 和稳定性

BPM 使用最近若干个波峰之间的 sequence 间隔计算:

间隔秒数 = 相邻 peak sequence 差值 / sampling_rate
BPM = 60 / 平均间隔秒数

稳定性使用间隔变异系数:

interval_cv = 标准差 / 平均值

项目当前分级:

条件quality含义
peak 少于 2 个insufficient数据不足
interval_cv < 0.15stable节律稳定
interval_cv < 0.35variable有一定波动
其他irregular明显不规则

这个分级的价值在于前端可以给用户一个简洁状态,而不是只显示一个 BPM 数字。BPM 在节律很乱时可能仍能算出来,但解释价值下降,所以必须配合稳定性一起看。

代价和边界

find_peaks 是局部几何算法,不理解呼吸生理阶段。它不会知道某个尖峰是咳嗽还是一次真实强呼吸,只会根据 distance 和 prominence 判断它是否像一个峰。因此项目还需要 SignalQualityAnalyzer 去提示 artifact、noisy、low_amplitude 和 gap。

自适应阈值也不是万能的。弱信号加高噪声时,阈值升高会漏检;阈值降低会误检。RespiraScope 的设计把这些参数暴露给 Lab 和 Monitor,不把默认值伪装成唯一正确答案。

关键函数表

函数或类来源作用
signal.find_peaks()SciPy寻找局部峰值,波谷通过 find_peaks(-data) 实现
np.percentile()NumPy估计信号 5% 到 95% 跨度
np.diff()NumPy计算相邻差分,用于噪声和 BPM 间隔
np.mean()NumPy计算平均呼吸间隔
np.std()NumPy计算呼吸间隔波动
PeakValleyDetector.update_from_filter_config()项目代码同步采样率、BPM、阈值和确认延迟
BreathRateCalculator.metrics_from_sequences()项目代码从 peak sequence 计算 BPM 和稳定性

读源码建议

先读 PeakValleyDetector._find_peaks() 和 _find_valleys(),理解 SciPy API 如何接入。再读 _distance_from_bpm() 和 _prominence(),这两个函数体现了项目如何把业务参数转成算法参数。最后读 _accept_realtime_event() 和 _collapse_alternating_events(),理解为什么项目要防止连续同类事件。