峰谷与 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=...)
这是一种简洁但很重要的设计:波谷识别不需要另写一套算法,只要把信号翻转,谷底就变成峰值。
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() | PeakValleyDetector | peak 和 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.15 | stable | 节律稳定 |
interval_cv < 0.35 | variable | 有一定波动 |
| 其他 | 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(),理解为什么项目要防止连续同类事件。