拓冰建站拓冰建站
首页 / 资讯中心 / 正文

rPPG与ECG联合分析:从信号提取到质量评估指南

简介基于人脸视频的rPPG远程光电容积描记代码包利用普通摄像头捕捉面部肤色随心跳的细微变化来估计心率实现无接触式测量面向生物医学工程、计算机视觉与人机交互方向的研究者和开发者也适合需要复现标准化心率成像实验流程的读者。压缩包共12个文件大小3.32MB其中含7个MATLAB脚本、2个文本说明、1份Markdown文档、1份许可证文件与1段示例视频。目前已有942人学习/下载适合从入门到进阶的相关技术探索。代码从人脸检测、肤色模板选择、面部帧提取到心率信号计算均有对应实现并配有示例视频与说明文档可帮助快速跑通rPPG流程整体结构清晰便于二次开发也为ECG/PPG信号特征对比研究提供基础。1. rPPG 不是测心率是测血流容积波打开rPPG-master_remote_人脸心率_everyb1q_ECGPPG_ECGandPPG_这类仓库名第一眼容易被 人脸心率 带走真正决定技术路线的其实是后面那个ECGandPPG。ECG 是心电PPG 是光电容积描记rPPG 则是用摄像头远程采集的 PPG。换句话说rPPG 拿到的从来不是心率本身而是皮肤下毛细血管随心脏搏动产生的容积变化曲线心率只是对这个周期信号的二次推断。很多刚入坑的人用绿色通道均值做 FFT 就以为完成了心率提取等到与 ECG 对照发现相位对不上、谷峰漂移、误差跑到 ±10 bpm 以上才开始理解为什么做这行的人反复强调 ground truth 和信号对齐。这个标题适合三类人做健康监测算法预研的工程师、想把手环式 PPG 方案迁移到摄像头场景的团队以及需要评估 rPPG 测量精度的测试开发。本文按信号链路把每一步讲透从 ROI 选择到与 ECG 的联合评估最后给出验证管线的硬指标。2. rPPG 最小信号链路从人脸 ROI 到干净 PPG 波2.1 为什么 rPPG 首选色度特征而不是直接用绿色通道绿色通道对血红蛋白的吸收峰更敏感但不代表直接对 ROI 内绿色像素求平均就能得到好信号。人脸视频里混着三件事光源强度波动、头部微小运动、皮肤血流变化三者都呈周期性。头部运动引起的像素位移在绿色通道上的幅度往往比血流信号大一个数量级。单纯用绿色通道等价于把运动噪声当成首要信号源处理。常见做法是引入色度模型。皮肤对光的反射服从 modified Lambert-Beer 定律血液体积变化主要影响吸收系数而皮肤表面反射几乎与波长无关。利用这一差异可以把 RGB 三个通道投影到一个与运动正交的方向上达到分离血流信号的目的。业界用得最多的是 CHROM (Chrominance-based) 和 POS (Plane-Orthogonal-to-Skin) 两种方法前者假设皮肤色调的投影方向在标准化 RGB 空间里相对稳定后者直接在时域上构造一个与运动轨迹正交的平面。两者对光照变化的鲁棒性都不错POS 在运动较大时更稳CHROM 胜在计算简单。工程落地上我一般两个都实现先用 CHROM 做原型再根据测试数据切 POS。2.2 用 POS/CHROM 法从视频帧提取 rPPG 时间序列以下代码是 rPPG 管线里最核心的一段输入为视频帧序列输出是干净的 rPPG 信号。实际工程中 ROI 会做平滑跟踪这里先给出稳定的静态人脸假设版本便于调试信号链路。import cv2 import numpy as np from scipy.signal import detrend, butter, sosfiltfilt def extract_rppg_from_frames(frames, fs30.0, roiNone): 从视频帧序列提取 rPPG 信号。 frames: list[np.ndarray]BGR 图像列表 fs: 视频帧率单位 Hz roi: 人脸区域 (x, y, w, h)不传则取图像中心 60% 区域 signals [] for frame in frames: if roi is None: h, w frame.shape[:2] roi (int(w*0.2), int(h*0.2), int(w*0.6), int(h*0.6)) x, y, w, h roi face_region frame[y:yh, x:xw] # 转浮点避免 uint8 截断误差 rgb cv2.cvtColor(face_region, cv2.COLOR_BGR2RGB).astype(np.float32) # 空间平均得到每个通道的均值 signals.append(rgb.mean(axis(0, 1))) rgb np.array(signals) # shape: (n_frames, 3) # --- CHROM 特征 --- # 先做时间归一化每个通道除以其时间均值 mean_rgb rgb.mean(axis0) normalized rgb / (mean_rgb 1e-6) # CHROM 权重来自肤色在 YCbCr 空间的经验投影 X 3.0 * normalized[:, 0] - 2.0 * normalized[:, 1] # 3R - 2G Y 1.5 * normalized[:, 0] normalized[:, 1] - 1.5 * normalized[:, 2] # 1.5R G - 1.5B chrom X - (np.std(X) / np.std(Y)) * Y # detrend 去掉基线漂移 chrom detrend(chrom, typelinear) # 带通滤波0.75~4.0 Hz 对应 45~240 bpm sos butter(4, [0.75, 4.0], btypebandpass, fsfs, outputsos) filtered sosfiltfilt(sos, chrom, axis0) return filtered代码里的两个关键点需要解释。第一CHROM 特征为什么是X - (std(X)/std(Y)) * Y而不是直接拿 X标准 CHROM 假设 X 和 Y 的方差比反映了运动与血流在色度空间的投影比例用 std 比值做动态校准让运动方向的干扰被减掉。如果视频中头部固定不动这个比值会趋于一个常数此时退化成固定权重。第二sosfiltfilt用的是零相位滤波正向反向各过一遍消除了 IIR 滤波器的相位延迟——这在后续与 ECG 对齐时是硬性要求有相位延迟的心率信号和 ECG 做峰值匹配会引入系统性偏差。2.3 预处理三件套detrend、归一化、带通滤波工程上常把 detrend、归一化、带通滤波统称为预处理三件套。detrend 去掉的是光源慢变和呼吸引起的基线漂移呼吸频率约 0.2~0.4 Hz如果阶段数据只有 10 秒线性 detrend 就能有效抑制呼吸对后续 FFT 的泄漏干扰。归一化z-score在计算 CHROM 特征前做避免不同环境光照下幅值差异对特征权重造成影响。带通滤波的频带选择直接决定心率估值的上下限默认 45~240 bpm 覆盖了绝大多数场景但婴幼儿和剧烈运动后的检测建议放宽到 240 bpm 以上。参数推荐值说明视频帧率≥ 30 fps低于 20 fps 时 240 bpm 以上的频率会被混叠ROI 尺寸人脸宽度的 60%~80%过小噪声大过大会框入背景导致皮肤占比下降滑动窗口10 s步进 2 s兼顾频率分辨率和实时性带通滤波0.75~4.0 Hz对应 45~240 bpm可调滤波阶数4 阶 Butterworth更高阶滚降快但数值稳定性差窗口长度这一行的取舍容易被忽略。10 秒窗口的 FFT 频率分辨率是 0.1 Hz对应 6 bpm——这意味着在低帧率下测得的瞬时心率本身就带 ±3 bpm 的不确定性这不是算法误差而是物理极限。要求更高精度时把窗口加到 30 秒代价是响应变慢不适合做实时看板。我一般会把这组参数做成配置项而不是硬编码因为运动场景和静坐场景的最优参数差距很大统一用一组参数测试往往得出rPPG 不行的错误结论。3. 拿 ECG 做 ground truth同步对齐与误差评估3.1 R 峰检测与码率对齐rPPG 信号提取出来后最直接的问题是怎么知道它测得准不准答案是用 ECG 做对照。ECG 的 R 峰是心室去极化的标志时间精度在毫秒级临床上公认是心率检测的金标准。但直接比较 rPPG 估计心率bpm和 ECG 心率bpm是不够的因为两边都是先检测事件再换算频率任何一方的误检都会被放大。正确做法是先分别做峰值检测再在事件级别对齐最后才换算成心率指标。ECG 峰值检测最经典的算法是 Pan-Tompkins它利用 QRS 波的斜率、幅度和宽度特征做自适应阈值。Python 生态里biosppy和neurokit2都内置了可靠的实现但为了理解参数含义这里给一个用scipy.signal.find_peaks的最小版本import numpy as np from scipy.signal import find_peaks, butter, sosfiltfilt def detect_r_peaks(ecg, fs250.0): 检测 ECG 中的 R 峰返回峰值索引列表。 ecg: 一维 ECG 信号 fs: ECG 采样率 # 带通 5~15 Hz突出 QRS 能量 sos butter(4, [5.0, 15.0], btypebandpass, fsfs, outputsos) filtered sosfiltfilt(sos, ecg) # 整流 移动平均增强 R 峰 rectified np.abs(filtered) win_len int(0.12 * fs) # 120 ms 窗口QRS 宽度约 80~120 ms kernel np.ones(win_len) / win_len smoothed np.convolve(rectified, kernel, modesame) # 最小峰间距300 ms 对应 200 bpm防止 T 波被误检 min_distance int(0.3 * fs) peaks, props find_peaks(smoothed, distancemin_distance, prominencenp.percentile(smoothed, 90) * 0.3) return peaks注意这里没有用 Pan-Tompkins 的动态阈值而是用 90 分位数的比例做 prominence 阈值。原因在于短时 ECG 记录30 秒内的信号统计特性相对稳定简化后的检测精度已经足够但如果你的数据是长时动态心电比如 24 小时记录幅值会随时间漂移务必换成滑动窗口自适应阈值。0.3 * fs的最小峰间距对应 200 bpm 上限这比 rPPG 的 240 bpm 保守所以两个峰值序列对齐时要先统一各自的生理上限。3.2 用峰值匹配把 ECG 心跳归属到 rPPG 时间轴rPPG 和 ECG 的采样率通常不同。ECG 设备常见 125、250、500 Hz而视频只有 30 fps峰值索引必须在统一的时间轴上比较。工程上我一般先把两边都换算成秒再建立一个以 ECG R 峰为锚点向前找最近 rPPG 峰值的匹配逻辑。要注意的是 rPPG 的谷峰和 ECG 的 R 峰本来就存在生理相位差——脉搏波从心脏传到外周皮肤需要时间这个时间叫脉搏传输时间PTT通常在 100~300 ms 量级。忽略这个延迟直接比较峰值时间会得到一个看似很大的误差实际上只是两个信号定义的事件本身就不同步。def match_peaks(rppg_peaks, ecg_peaks, fs_rppg, fs_ecg, max_delay0.5): 将 ECG R 峰与 rPPG 峰值做时间轴匹配。 返回每对匹配的 (时间差, ECG 间期, rPPG 间期) max_delay: 允许的最大峰匹配裕度单位秒 t_rppg rppg_peaks / fs_rppg t_ecg ecg_peaks / fs_ecg pairs [] for t in t_ecg: # 在 rPPG 时间轴上寻找最接近的峰 idx np.argmin(np.abs(t_rppg - t)) delta t_rppg[idx] - t if abs(delta) max_delay: pairs.append(delta) # 由配对峰值各自计算心率用中位数抵抗误检 rr_ecg np.diff(t_ecg) hr_ecg 60.0 / np.median(rr_ecg) # bpm hr_rppg 60.0 / np.median(np.diff(t_rppg)) return pairs, hr_ecg, hr_rppgmax_delay这个参数是做匹配时最容易拍脑袋定的。设 0.5 秒看似宽松但如果心率是 120 bpm心搏间隔本身就是 0.5 秒这意味着匹配窗口几乎覆盖整个心跳周期任意 rPPG 峰值都可能被错误匹配给最近的 R 峰。严谨做法是把窗口设成预期心率周期的 40%比如默认 60~100 bpm 时用 0.3 秒。心率特别高或特别低的场景按实际心率动态调整窗口避免匹配歧义。3.3 rPPG 误差评估指标MAE/RMSE/Bland-Altman评估 rPPG 与 ECG 的一致性业界通用的三个指标各有侧重点MAE平均绝对误差反映整体偏差RMSE均方根误差对 outliers 更敏感Bland-Altman 图则揭示误差是否随心率水平变化。很多文章只看 MAE 和相关系数但相关系数高并不代表一致性好——两个信号一个有 5 bpm 恒定偏移另一个没有偏移相关系数可以完全相同。强烈建议补一张 Bland-Altman。指标计算方式临床/工程可接受阈值MAEmean(abs(hr_rppg[i] - hr_ecg[i])) 3 bpm 优秀 5 bpm 可接受RMSEsqrt(mean((hr_rppg[i] - hr_ecg[i])^2)) 5 bpm 优秀成功率误差 ≤ 5 bpm 的样本占比 90%Bland-Altman 95% LoAmean_diff ± 1.96 * std_diff越窄越好需要注意的是这些阈值来自可穿戴 PPG 设备的行业惯例rPPG 因为信噪比天然更低行业共识上 MAE 5 bpm 已算良好。如果你的测试人群包含深肤色受试者或暗光环境请单独统计这两类子集的指标——rPPG 对肤色和光照的敏感度远高于接触式 PPG这在学术界和工程界都是被反复验证的结论。评估代码最后用一个错误率的输出把每个窗口的结果汇总起来方便追踪是哪段时间片段的信号把整体误差拉高了。4. ECG-PPG 联合分析里的滤波与质量门控4.1 滤波参数对心率估值的影响把 ECG 和 rPPG 放在一起分析时滤波不再是各自管各自。ECG 的带通是 0.5~40 HzrPPG 是 0.75~4.0 Hz两者的频带差异决定了不能共用一套滤波器。rPPG 频带窄是因为心率的最快生理极限也就 240~300 bpm4~5 Hz再高的频率要么是噪声要么是谐波。但窄带滤波有个副作用如果被测者心率恰好接近频带边缘比如婴幼儿 180 bpm3 Hz滤波器的过渡带会压制信号幅度导致峰值检测不稳定。运动噪声是 rPPG 最大的干扰源典型频谱集中在 1~3 Hz 之间正好与静止心率频带重叠。静止场景下靠带通滤波就够了运动场景必须引入额外的运动伪差消除策略。常见做法是用加速度计可穿戴设备或光流纯视觉方案测量运动量按幅度对 rPPG 信号做自适应滤波。纯视觉方案里把运动量作为一个回归变量做自适应滤波类似 Widrow-Hoff 结构的自适应噪声抵消器在工程上可复现。简单说就是建一个以运动信号为参考的滤波器从 rPPG 中减掉与运动正相关的成分。4.2 SNR 门控与可用片段筛选不是所有视频片段都能提取出有效心率环境光突变、快速转头、遮挡都会让某一片段彻底失效。与其让算法带着脏数据硬算不如加一道质量门控计算信号质量指标低于阈值直接标记为不可用不参与心率统计。这个思路在 ECG 分析里叫 signal quality index (SQI)PPG/rPPG 场景下最实用的指标是频域信噪比def compute_rppg_snr(signal, fs30.0): 计算 rPPG 信号的频谱信噪比。 以最大谱峰作为心率候选计算其邻域能量与全频段噪声能量之比。 from scipy.signal import welch freqs, psd welch(signal, fsfs, npersegmin(len(signal), 256)) # 只关心 0.75~4.0 Hz 的范围 valid (freqs 0.75) (freqs 4.0) freqs, psd freqs[valid], psd[valid] hr_idx np.argmax(psd) hr_freq freqs[hr_idx] # 信号能量以主峰为中心 ±0.1 Hz 的邻域 signal_mask np.abs(freqs - hr_freq) 0.1 signal_power psd[signal_mask].sum() # 噪声能量全频带减去信号邻域 noise_power psd[~signal_mask].sum() 1e-12 # 避免除零 snr_db 10 * np.log10(signal_power / noise_power) return snr_db, hr_freq * 60.0 # 返回 SNR(dB) 和心率(bpm)SNR 阈值没有绝对标准我一般低于 -3 dB 的片段直接丢弃-3~0 dB 用中值滤波平滑后再用大于 0 dB 才进入心率竞速模型。这个阈值在暗光低于 50 lux场景要放宽因为整体 SNR 下降是物理限制而非算法缺陷。SNR 门控是 rPPG 工程落地时回报最高的优化点——它不会提升单点精度但能显著降低误报率误报比精度差对用户伤害更大。4.3 联合分析中的 CF 校正与 PWV 类衍生指标当 ECG 和 PPG/rPPG 同时存在时多数人只做心率对比忽略了这两路信号还能交叉验证测量可信度。这里提两个工程中容易被忽略的衍生参数校准因子calibration factor和脉搏波传导速度的前置估计。在 ECG-PPG 联合分析的社区代码里这两个概念经常被合并在cfpwv这类命名的工具包中先说 CF——当 PPG 的幅值受皮肤厚度、贴合压力影响而失真时用同一时段 ECG 的 R 峰振幅做归一化得到一个相对血流灌注的校准系数再说 PWV——ECG R 峰到 PPG 峰值的延迟时间即脉搏传输时间PTT用身高估算动脉路径长度后可得 PWV 的粗略值。PTT 与血压相关性强是 rPPG 从测心率走向测血压的关键中间量。PTT 的计算依赖峰值匹配的质量。假如匹配代码里max_delay设得太大次要峰dicrotic notch 后的重搏波可能被误认为主峰PTT 会系统性偏大。验证 PTT 可靠性的一个简单方法计算连续 10 个心搏的 PTT 标准差如果超过 30 ms说明匹配不稳定需要检查滤波参数或 ROI 区域是否包含了额头发丝遮挡。PWV 这类指标的好处是和心率无关短期内心率波动再大 PWV 也是相对平稳的这为信号质量评估提供了一个独立于心率的验证维度。5. 验证 rPPG 管线的三个硬指标5.1 用重叠滑动窗口看 SNR 与心率稳定性不要只输出一个最终心率值要输出一段时间内的心率轨迹和配套的 SNR 轨迹。用 10 秒窗口、2 秒步进滑动计算你会得到一组长度超过 40 的心率片段这组序列能暴露三个问题心跳间期是否有生理合理性、SNR 是否突然掉到负值、心率轨迹是否出现单点跳变。一个实用的验证脚本是统计心率轨迹的相邻跳动差def validate_rppg_trace(hr_series, max_jump10.0): 验证 rPPG 心率轨迹的稳定性。 max_jump: 相邻窗口心率最大允许跳变单位 bpm hr_series np.asarray(hr_series) # 去除 NaN/无效片段 valid ~np.isnan(hr_series) hr_valid hr_series[valid] # 相邻差分 diffs np.abs(np.diff(hr_valid)) invalid_jumps diffs max_jump jump_ratio invalid_jumps.mean() # 心率变异合理性静息状态下相邻心跳间期变化一般在 3~10 bpm # 超过 max_jump 的比例高于 5% 说明信号质量不稳定 return jump_ratio, { jump_ratio: jump_ratio, max_jump_bpm: diffs.max() if len(diffs) else 0.0, std_bpm: hr_valid.std() }这个指标在运动场景下尤其好用。运动时心率确实在变化但变化率受生理约束相邻 2 秒窗口之间跳变超过 20 bpm 就值得怀疑。结合 5.2 节的两个指标基本能过滤掉 90% 以上的无效 rPPG 管线设置错误。5.2 自采数据时的同步验证建议如果你在采自己的数据最容易被忽视的是时间同步。视频流的时钟来自采集电脑ECG 设备有自己的时钟两者即使都显示系统时间也可能有几秒的漂移。稳妥做法是用秒表法采集在视频画面里放一块毫秒计时器同时用 ECG 设备的标记功能打一个事件点——后面的所有对齐都以这个事件点为锚。这个技巧能省掉你在分析阶段排查为什么 rPPG 比 ECG 慢 2 秒的大量时间。最后一层验证是跨受试者一致性。单独一个人的视频里跑通算法只能证明你的 pipeline 没有 bug不能证明泛化性。至少要采集 10 人以上、覆盖不同肤色和光照条件的数据按第 3 章的方法统计每个受试者的 MAE 和 SNR 分布观察是否存在某个子集的误差系统性偏大。如果发现深肤色或暗光下 MAE 翻倍优先检查 ROI 选择和色度特征是否需要按光照做自适应——这是 rPPG 领域目前最核心的落地瓶颈。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门