rPPG人脸心率工程落地:ECG/PPG验证与信号融合实践
发布时间:2026/9/14 11:29:44来源:尧图网络
简介面向生物医学工程与计算机视觉研究者一套基于人脸视频的无接触心率测量实现代码即rPPG远程光电容积描记方法。原理基于心跳引起皮下毛细血管血液流量变化致使皮肤颜色周期性改变从而通过摄像头视频提取心率值。实现对应van der Kooij与Naber2018提出的标准测试流程适合复现论文或搭建非接触式生命体征监测原型的开发者。包内共12个文件以m脚本为主7个涵盖核心算法、人脸检测、肤色模板选择等模块附带1个mp4演示视频、2个txt说明、1个md及license文件总大小3.32MB。整体结构紧凑便于快速定位算法与测试流程。目前已有942人学习下载。整套实现不仅可直接运行演示还可依据说明与注释理解每一步信号处理逻辑为后续引入心电ECG/PPG对比或个性化皮肤分割提供基础代码实用价值较高。1. rPPG 和 ECG/PPG 绑在一个人脸心率工程里到底在解决什么人脸心率这个需求落到工程上是两件事先靠 rPPGremote photoplethysmography远程光电容积脉搏波描记从视频里算出脉搏波再靠 ECG 和 PPG 去验证它。只做前者结果对不对没人能说清。rPPG 的难点在信噪比皮肤颜色随血流的脉动幅度通常不到通道均值的 1%光照和运动一扰动就被淹没。ECG 提供时间基准PPG 提供波形形态两者联合起来正好评估 rPPG 质量并做加权融合。下面按一条可复现链路走rPPG 成像假设与最小提取代码、ECG/PPG 波形对齐、质量评估与 cfpwv 类特征、误差验证最后落到三个最容易翻车的参数细节。2. 从人脸视频到脉搏波rPPG 的成像假设与最小复现管线做 rPPG 之前先得接受一个前提你要找的信号不是被摄像头直接拍到的而是藏在皮肤漫反射里的一层微小调制。把这层调制从 RGB 通道里抽出来是 rPPG 提取阶段唯一要处理的事下面按从物理到代码的顺序展开。2.1 皮肤光学模型为什么人脸 ROI 的 RGB 均值里藏着 PPG 信号光打到皮肤上一部分被表皮吸收一部分进入真皮层并在那里被散射散射回来的光就是相机收到的漫反射。血红蛋白在 500600nm 波段吸收明显心室收缩时血容量上升真皮层吸收增强漫反射光变弱舒张期反之。于是每一帧里ROI 内的通道均值都叠加了一个与心动周期同步的微小波动这就是 rPPG 的信号来源。这个波动的幅度决定了后续手段。实测里脉动量通常只有通道均值的 0.1%1%跟摄像头位深、编码器、光照都有关系。正因如此只取绿色通道做带通滤波也能看出波形轮廓血红蛋白在绿光段的吸收峰最明显很多早期 demo 用 G 通道比值就能报心率。不过单通道方案吃光照。rPPG 的成像假设是漫反射里非脉动成分占主导且变化平缓脉动成分在 RGB 三个通道上有相对固定的比例。CHROM、POS 这类彩色算法正是利用这个比例做投影把肤色方向的共模成分光照变化压掉而不是拿单个通道硬扛。2.2 三种常用 rPPG 提取算法的选型对比选算法前先确认数据条件单人脸、RGB 摄像头、30fps、可见光照明。满足这些条件时主流选择集中在下面三种。算法通道利用抗光照变化抗运动伪差实现成本适用场景GREEN仅 G 通道弱弱最低头部固定、光照恒定的短片段CHROMRGB 投影到肤色正交色度空间中中低有自然晃动的日常监控POS滑窗内时间归一化后投影较强较强中通用首选要求 ROI 逐帧一致ICA/PCA对 RGB 盲源分离中弱中高离线分析短窗容易发散一般默认先跑 POS它在公开基准上的表现最接近现成可用参数只有窗口长度和带通范围出问题容易定位。ICA 短窗内成分分解经常发散不建议在实时场景做首选。2.3 用 POS 在本地跑通 rPPG 提取的最小代码下面这段把 POS 写成点估计形式对每个时间点取滑窗在窗内归一化和投影输出窗末采样点效果等同逐帧输出。import numpy as np from scipy.signal import butter, filtfilt def pos_ppg(rgb, fps30, low0.75, high4.0, win32): 从人脸 ROI 的逐帧 RGB 均值提取 rPPG 波形。 rgb: (T, 3) 浮点数范围 0~1fps: 视频帧率。 T rgb.shape[0] ppg np.zeros(T) half win // 2 for t in range(T): s rgb[max(0, t - half): t half 1] # 滑窗截断边界 # 1) 时间归一化除以窗口均值消光照强度漂移 cn s / (np.mean(s, axis0) 1e-9) # 2) 肤色正交投影S1G-BS2-2RGB s1 cn[:, 1] - cn[:, 2] s2 -2.0 * cn[:, 0] cn[:, 1] cn[:, 2] # 3) 用标准差之比组合两分量避免过曝通道主导 alpha np.std(s1) / (np.std(s2) 1e-9) ppg[t] s1[-1] alpha * s2[-1] # 取窗末值 # 4) 带通滤波0.75Hz~4Hz 对应 45bpm~240bpm b, a butter(2, [low / (fps / 2.0), high / (fps / 2.0)], btypeband) return filtfilt(b, a, ppg)逻辑说明第 1 步的通道归一化把静态肤色和慢变光照的直流部分去掉第 2 步的投影矩阵来自 POS 原文的肤色正交投影S1 等价于色度差S2 等价于亮度归一后的色度残差第 3 步的 alpha 让两分量幅值可比第 4 步的带通是最后防线带外能量基本算噪声。参数上 win 建议取 12 秒30fps 下 3264 帧窗口太短会引入低频抖动太长会让心率变化变迟钝。2.4 从 rPPG 波形到心率的两种读法FFT 与逐拍峰值波形到手后心率有两条路。FFT 法把一段波形当周期信号在主频处读数抗单点毛刺但窗口越短频率分辨率越差30 秒窗的频率分辨率约 0.033Hz折合心率约 2bpm窗口再短就逼近误差上限。逐拍法先找收缩峰再取间期输出粒度细适合看瞬时心率缺点是峰值误检会直接变成心率跳变。import numpy as np from numpy.fft import rfft, rfftfreq def hr_fft(ppg, fps30, low0.75, high4.0): n len(ppg) if n fps: return np.nan f rfftfreq(n, 1.0 / fps) amp np.abs(rfft(ppg * np.hanning(n))) # 加窗抑制频谱泄漏 mask (f low) (f high) return f[mask][np.argmax(amp[mask])] * 60.0 def hr_peaks(ppg, fps30, min_gap0.25): # min_gap0.25s 对应 240bpm 上限防止噪声峰入选 from scipy.signal import find_peaks peaks, _ find_peaks(ppg, distancemin_gap * fps, prominence0.3 * np.std(ppg)) if len(peaks) 2: return np.nan return 60.0 / np.median(np.diff(peaks) / fps)参数说明FFT 法必须加窗矩形窗会让旁瓣把低频漂移泄漏进脉搏带峰值法的 prominence 取波形标准差的 0.3 倍是经验值运动片段建议升到 0.5 倍并用中位数而不是均值估计间期因为个别误检对中位数不敏感。两种方法的输出粒度不同后面与 ECG 比较时全程只使用同一种粒度。3. ECG 与 PPG 波形对齐给 rPPG 的人脸心率结果建立时间基准rPPG 提取出来的是光学波形ECG 是电学波形两者之间没有固定的时间对齐关系所以要放在同一条时间轴上对齐有三个问题要处理延迟大小、采样率差异、逐拍配对。对齐做扎实了rPPG 的标定和融合才有依据。3.1 ECG 的 R 波为什么不能直接和 PPG 收缩峰对齐R 波代表心室去极化是电学事件PPG 收缩峰是血流压力波到达测量点的时刻两者之间隔着电机械延迟和脉搏波传导时间。健康成人从 R 波到指端或面部 PPG 收缩峰典型延迟约 200400ms面部路径短延迟更靠近下限。这个延迟对心率数值没有影响ECG 的 R-R 间期和 PPG 的峰-峰间期均值一致可以直接比 bpm。但逐拍匹配时不能按最近邻配对心率变化快的片段会把上一拍错配到下一拍。对 rPPG 验证来说错配比延迟本身更致命一对错就是 10bpm 量级的伪误差。3.2 采样率、延迟窗口与插值统一时间基准的三个参数三种信号各说各话ECG 设备常见 250Hz1kHz指夹 PPG 常见 50125Hz摄像头 30fps 且帧间隔抖动明显。统一做法是先按时间戳重采样到同一频率再做峰值检测直接按帧号当等间隔会引入 33ms 级抖动对 200ms 量级的延迟估计来说误差太大。信号推荐重采样率允许下限说明ECG500Hz对齐用250HzR 波定位到 2ms 即满足逐拍匹配指夹/面部 PPG250Hz100Hz收缩峰宽度约 100~200ms过低会丢峰顶rPPG视频保持 30Hz25Hz升采样只插出虚假形态不建议超过 60Hz重采样工具上用 scipy.signal.resample_poly 而不是 resample后者按 FFT 等间隔插值对非周期信号边界会出现振铃poly 相位法按有理数比例采样边界可控得多。ECG 从 1000Hz 降到 500Hz 基本无感PPG 从 125Hz 升到 250Hz 也足够平滑。延迟窗口的范围一般取 50ms 到 450ms下限避开 R 波检测的振铃上限覆盖包括病理慢传导在内的人群。如果数据来自儿童上限要缩到 350ms儿童的脉搏波传导时间更短窗口太宽会误配到下一拍。3.3 用 R 波到收缩峰的延迟自动配对两条波形import numpy as np from scipy.signal import find_peaks def align_ecg_ppg(ecg, ppg, fs, lag_min0.05, lag_max0.45): 在统一采样率 fs 上逐拍配对。 返回数组每行为 (R 波位置, PPG 收缩峰位置, 延迟 ms)。 r_peak, _ find_peaks(ecg, distance0.4 * fs, prominence0.3 * np.std(ecg)) s_peak, _ find_peaks(ppg, distance0.4 * fs, prominence0.2 * np.std(ppg)) pairs [] for r in r_peak: # 只接受落在 [lag_min, lag_max] 时间窗内的收缩峰 cand s_peak[(s_peak - r lag_min * fs) (s_peak - r lag_max * fs)] if len(cand): pairs.append((r, cand[0], (cand[0] - r) / fs * 1000.0)) return np.asarray(pairs)逻辑说明find_peaks 的 distance 取 0.4 秒对应 240bpm 上限高频噪声不会被当成心跳prominence 用各自波形标准差的倍数ECG 门槛高是因为 R 波尖PPG 门槛低是为了保留小振幅脉搏。每个 R 波只取窗口内第一个收缩峰避开重搏波dicrotic notch 后的小波造成的误配。输出的延迟列直接画直方图健康数据应该集中在一个窄带里若分散超过 50ms说明某一路峰值检测存在系统性问题先修信号再谈对齐。提示找峰之前先做基线漂移去除ECG 用 0.5Hz 高通PPG 用 0.54Hz 带通顺序不能反先滤波后找峰否则滤波相位会改变峰值位置。4. 信号质量评估与 ECG/PPG 融合cfpwv 这类波形特征在 rPPG 里的实际用途对齐之后下一个问题是谁值得信。rPPG 和 PPG 各有失效模式波形看起来像正弦波并不代表质量合格更实用的做法是用频带能量比值定义信噪比用信噪比决定融合权重再用 ECG 与 PPG 的时间差类特征做交叉验证。4.1 为什么 rPPG 心率会跳变频带信噪比与运动伪差rPPG 心率跳变几乎都来自三种扰动面部刚体运动让 ROI 里的像素来源不断变化表情变化让非皮肤像素混入光源频闪直接叠加周期噪声。它们的共同表现是脉搏带内的能量分布不再集中FFT 主峰在相邻频率间跳来跳去逐拍法的峰值间隔也被打乱。所以质量评估的通用做法是算频带信噪比脉搏带能量与相邻噪声带能量的对数比值。噪声带选 0.10.5Hz慢变光照漂移和 58Hz压缩伪影与运动有意避开脉搏带。SNR 高于 3dB 的片段才参与融合是保守且常用的门槛。现象直接原因检查手段心率每隔几秒跳 10bpmROI 混入非皮肤像素叠加 ROI 蒙版逐帧回看SNR 整体偏低光源频闪或丢帧看频谱里是否存在工频分量峰值间隔忽长忽短重搏波被当主峰提高 prominence 阈值两台设备结果不一致曝光与白平衡不一致固定曝光与白平衡再采集4.2 按 SNR 加权的 rPPG 与 PPG 融合代码融合策略是分窗、赋权、取加权平均每 10 秒一个分析窗分别从 rPPG 和 PPG 算出心率和 SNR权重取 SNR 减去门槛后的正值最后加权平均并做滑动中值平滑。import numpy as np from scipy.signal import periodogram def window_snr(ppg, fps30, low0.75, high4.0): f, pxx periodogram(ppg, fps) sig pxx[(f low) (f high)].sum() nz pxx[(f 0.1) (f low)].sum() \ pxx[(f high) (f 8.0)].sum() return 10.0 * np.log10(sig / (nz 1e-12)) def fuse_hr(hrs, snrs, gate3.0): # hrs 与 snrs 等长例如 [hr_rppg, hr_ppg] 及其对应 SNR w np.clip(np.asarray(snrs) - gate, 0.0, None) # 低于门槛权重为 0 if w.sum() 1e-9: return np.nan return float(np.dot(hrs, w) / w.sum())参数说明gate 取 3dB 对应“FFT 主峰还能连续辨认”的下限低于它融合结果和单路随机挑差不多。权重用线性 clip 而不是指数是避免某一路 SNR 特别高时把另一路完全压制高 SNR 片段偶尔也有系统偏差保留少量低权重路径能兜底。滑动中值窗口取 5 个分析窗50 秒能压掉单窗跳变又不会把真实的心率变化抹平。4.3 cfpwv 与脉搏波传导ECG 提供时间基准PPG 提供形态cfpwv 在临床上是颈股脉搏波传导速度反映血管硬度在 ECG/PPG 联合分析的工程语境里它代表一类以时间差为核心的特征ECG 的 R 波是电学起点PPG 的收缩峰或浪起点是脉搏到达的终点两者之差就是脉搏波传导时间结合测量点距离可换算成速度。放到人脸心率工程里cfpwv 类特征不做诊断而是当交叉验证的试金石如果摄像头 rPPG 与接触式 PPG 同源两组信号对同一次心跳算出的传导时间应当高度接近。跑通对齐后把这个传导时间序列的标准差打出来超过 50ms 基本可以断定某一路峰值检测有系统性问题比盯着波形主观判断可靠。要注意的是面部 rPPG 算出的传导时间与传统颈股路径量纲完全不同不要拿它比临床参考值。要比较的只是同一个工程内部、两种光学测量途径之间的一致程度。5. 以 ECG 为金标准验证 rPPG 人脸心率MAE、RMSE 与 Bland-Altman 的计算口径融合完成不等于验证完成。把估计得到的心率序列与 ECG 金标准放在同一时间轴上逐窗比较才能量化误差下面给出三个必看指标、可接受范围以及一段直接可用的计算代码。5.1 MAE、RMSE、Bland-Altman 的可接受范围怎么定MAE 管平均偏差RMSE 管大误差出现的频率Bland-Altman 管偏差的系统性。只用 MAE 的问题在于它会被大量小误差拉低偶尔出现的 15bpm 跳变淹没在均值里RMSE 对这类异常值敏感两个指标一起看才能区分均匀小偏差和偶发大偏差。场景MAERMSEBland-Altman 95% 界限静态 30s 视频≤2bpm≤3bpm±5bpm自然头部运动≤3bpm≤5bpm±8bpm强运动或低光照≤5bpm≤8bpm±12bpm需逐段标注这些数值参考公开 rPPG 基准里的常见水平受控光照下主流算法基本处在 MAE 13bpm进入自然运动条件误差翻倍很常见。如果静态片段跑到 4bpm 以上先怀疑对齐而不是算法——很多项目里所谓“算法差”其实是参考信号和估计信号错配了一拍。有的项目还加 Pearson 相关系数它衡量趋势一致性但两条序列一个整体偏快、一个整体偏慢时 r 值仍然可能很高只能当辅助指标。更实用的是分段统计把整段数据切成 30 秒小块逐块算 MAE 再画直方图右尾就是问题片段配合日志里的 SNR 一起排查。还有一个常被忽略的口径问题逐拍瞬时心率由单次间期计算10 秒窗平均心率由多拍平均得到两者在数值上不是一回事。呼吸性窦性心律不齐会让逐拍心率在相邻拍之间波动超过 5bpm比较时两边必须用同一种口径要么都是窗内平均要么都对逐拍序列做相同长度的中值滤波。5.2 对齐后计算误差指标的最小代码import numpy as np def eval_hr(ref, est): ref: ECG 金标准 bpm 数组est: rPPG 估计 bpm 数组 ref np.asarray(ref, float) est np.asarray(est, float) diff ref - est mae np.mean(np.abs(diff)) rmse np.sqrt(np.mean(diff ** 2)) bias np.mean(diff) loa 1.96 * np.std(diff, ddof1) # 95% 一致性界限 return {MAE: mae, RMSE: rmse, bias: bias, LoA_95: (bias - loa, bias loa)}对比前有两个前提一是两条序列按同一分析窗切分推荐直接复用第 4 章的 10 秒融合窗避免额外重采样二是先对整条序列做滑动相关求出最佳延迟把残余固定时间差消掉再算指标否则 bias 会被系统性高估。提示Bland-Altman 的 LoA 只看一遍不够。把 diff 按时间画出来如果偏差随时间变大说明 rPPG 的信号质量在退化此时整体 MAE 没有意义应该分段评估并剔除标记的低质量片段。6. 落地验证 rPPG 时最容易翻车的三个参数细节流程跑通之后误差来源基本收敛到滤波实现、帧间隔处理、ROI 稳定性三个细节按顺序排查通常能把整体误差再压下一个量级。6.1 滤波必须零相位否则峰值位置系统性偏移同样的带通参数lfilter 是因果滤波会引入与频率相关的群延迟在 30fps 数据上0.75Hz 高通附近的延迟能到几十毫秒直接毁掉 ECG 对齐和传导时间计算。离线处理统一用 filtfilt 做零相位滤波峰值位置不偏移实时管线若必须用因果滤波先拿 scipy.signal.group_delay 算出通带群延迟再在峰值检测时回加补偿。from scipy.signal import filtfilt, lfilter # 离线处理零相位峰值位置与真实事件对齐 ppg_f filtfilt(b, a, ppg) # 实时处理因果滤波拿到当前输出群延迟单独补偿 y_last lfilter(b, a, ppg)[-1] # delay group_delay((b, a), fs) # 取通带中值检测时回加6.2 帧间隔抖动要按时间戳重采样笔记本摄像头在自动曝光切换瞬间经常丢帧帧号并不等间隔。直接按帧号当作均匀序列丢帧处的波形会被拉长心率被低估。常见做法是记录每帧时间戳用线性插值把波形重采样到理想帧率再进带通滤波。# ts: 每帧时间戳ppg_raw: 未滤波的逐帧波形 ts_new np.linspace(ts[0], ts[-1], len(ts)) ppg_fixed np.interp(ts_new, ts, ppg_raw) # 按时间轴重建等间隔信号线性插值对 30fps 的 rPPG 足够峰值位置误差约半个采样周期丢帧超过总帧数 10% 的片段直接标记为低质量不参与融合。6.3 ROI 平滑与人脸检测置信度门槛人脸检测器逐帧输出的框会抖动ROI 像素不同源。先对检测框中心做指数移动平均alpha 从 0.3 起调再按人脸先验裁出脸颊与额头区域能明显压低 ROI 切换引入的运动伪差检测置信度低于阈值的帧直接沿用上一帧 ROI。现象参数调整波形周期性变差ROI 中心 EMA 平滑alpha 从 0.3 起调峰值误检增多prominence 从 0.3σ 提到 0.5σ对比验证偏差恒定偏大检查 ECG 与 rPPG 的固定延迟补偿是否完成指标随片段漂移对低 SNR 片段单独标注不混入总体统计日常工作里把分段 MAE 直方图做成管线固定输出每次调参先看右尾右尾收得越紧说明阈值和滤波参数的调整方向是对的。本文还有配套的精品资源点击获取
网站建设高端定制企业官网