
简介基于深度学习的rPPG心率估计MATLAB实现包面向计算机、电子信息工程、数学等专业本科生及研究生适用于课程设计、期末大作业与毕业设计也可作为生物医学信号处理方向研究者的算法参考。包内共118个文件以m脚本为主包含POS、CHROM、ICA、GREEN等经典rPPG算法实现辅以xml配置、png示意图、pdf文档及prj工程文件压缩包大小7.31MB结构清晰便于检索。代码采用参数化编程参数可灵活调整注释详尽并附可直接运行的案例数据兼容matlab2014a至2024b多个版本。已有121人学习下载适合希望快速上手非接触式心率估计、理解深度学习与图像/信号处理融合思路的学习者。通过研读代码可掌握从视频ROI提取、预处理到心跳信号分析与心率计算的全流程同时为后续扩展网络模型、改进算法鲁棒性提供可修改的基础框架。1. 基于深度学习的 rPPG 心率估计.zip远程测心率项目的正确打开方式假设你刚下载了一个「基于深度学习的 rPPG 心率估计.zip」第一反应当然是想解压、跑通、看到一个心率数字。但如果你直接把里面的模型当黑匣子多半会在第二天发现训练损失很好看真实验证时 BPM 却乱跳。rPPG 的全称是 remote Photoplethysmography远程光电容积描记意思是完全不接触皮肤靠普通 RGB 摄像头捕捉面部肤色里微弱的周期性波动来估计心率深度学习负责从这种几乎被噪声淹没的信号里把心跳周期「抠」出来。它的价值在于把测心率从「必须贴电极、夹手指」变成「摄像头对着脸就行」适合驾驶疲劳预警、婴儿睡眠监护、远程问诊初筛这类不方便佩戴设备的场景。适合谁想在自己系统里加远程体征能力的工程师还有拿它做毕设的深度学习相关学生。但这类项目最大的坑不在网络结构而在你把视频变成输入序列的那段预处理。下面把这套方案从信号原理到部署确认拆开讲。2. rPPG 心率估计在「看」什么从肤色脉动到时间序列信号的输入组织要复现整个压缩包之前先想清楚一件事普通视频里并没有直接写着「心率」两个字模型拿到的是 RGB 帧序列而心率藏在帧与帧之间的亮度和颜色变化里。这一章把 rPPG 的输入侧讲透因为后面所有训练和排错都建立在「输入信号是什么」之上。2.1 从心脏搏动到摄像头像素rPPG 信号链的三级传导心脏每收缩一次动脉血就被推进血管面部皮肤下微血管里的血容量随之增加。血容量变大后皮肤对入射光的吸收量会变化尤其血红蛋白在绿光波段吸收明显所以摄像头里人脸的绿色通道会跟着心跳节奏出现极微弱的起伏。这就是 rPPG 的物理基础把摄像头当成一个非接触式的光电传感器。整条信号链分三段心脏泵血产生周期源面部微血管网把周期源变成皮肤反射率变化摄像头把反射率变化变成像素值随时间变化的序列。注意第三段里脉搏引起的像素波动通常只有 13 个灰度级8bit 图像远小于环境光变化和头部运动引起的几十个灰度级跳变。所以任何预处理都必须围绕「放大周期性、压制大尺度变化」来做。这里有个多年形成的经验早期手工特征常用绿色通道因为血红蛋白吸收峰在 540580nm 附近到了深度学习时代直接把 RGB 三通道同时喂进去让网络自己加权更可靠但 G 通道仍然经常在解释性分析和频域分析中单独使用。如果你在 zip 里看到「只取 G 通道」的预处理脚本别急着删那往往是第一个能跑通工程的最小实现。2.2 传统算法为什么不够用ICA、带通滤波与运动伪迹在深度学习还没有普及的年代rPPG 的常见做法是先对脸部 ROI 做空间平均再把得到的时间序列做带通滤波然后用 ICA/PCA 之类盲源分离把脉搏分量从噪声里拆出来或者用 CHROM 这类色度方法把肤色变化投影到与光学噪声正交的方向。这在「人坐直、别动、别说话、灯光均匀」的受控条件下效果不错MAE 可以到 35 BPM。但一旦进入真实场景假设就崩了。ICA 假设脉搏信号与运动干扰统计独立可实际上面部旋转会改变皮肤区域的纹理分布说话会让嘴周区域反复变形这些运动产生的频率恰好也落在 0.73Hz 的心率频段附近市电灯光 50Hz/60Hz 的频闪在卷帘快门摄像头上还会混叠成低频波动。手工设计特征的方式很难把这些情况分开。深度学习在这里的意义不是「更高级的回归」而是用大量带干扰的样本去学习一个从原始像素到脉搏波形的非线性映射相当于把「什么是运动干扰」的判别知识直接存进网络参数里。2.3 视频怎么喂给模型窗口、帧率、ROI 与重叠策略输入侧需要定四个参数帧率、窗口长度、ROI 选取、窗口重叠率。我一般先按下面这组配置起步再根据运行环境和指标调整。输入参数推荐起点说明帧率30 fps心率 42180 BPM 对应 0.73.0 Hz根据奈奎斯特条件 15fps 理论够用但留足余量才能分辨谐波30fps 是多数公开数据集的默认值窗口长度10 s300 帧10s 做 FFT 时频率分辨率 0.1Hz对应 6 BPM 的量化步长5s 样本多但 BPM 粒度粗ROI整脸额头两颊整脸平均能稀释局部纹理运动嘴巴和眼睛边缘区域往往是运动伪迹重灾区重叠率25%训练集扩增靠小步长滑动窗口重叠过高会让相邻样本高度相关容易让训练 loss 曲线抖动推理时我通常用滑动窗口输出多段 BPM再做中位数投票或加权平均。单段窗口如果赶上一次眨眼或转头结果可能偏离 20 BPM多段投票能把这种偶发跳变压下去。ROI 要注意一个细节人脸检测框一般会包含发际线和下巴以下区域直接裁剪会产生非皮肤边缘。折中做法是把检测框向内收缩 10%再裁出包含额头和大部分脸颊的区域。深度学习模型虽然能容忍一定噪声但持续的头发像素会显著拉低肤色信号的周期性。3. 模型与训练脚本怎么落地STMap 预处理、双重 Loss 与三段可跑代码3.1 深度学习模型怎么选从 STMap 到 3D-CNN再到轻量化时序网络rPPG 项目里模型结构演进有明显脉络。最早的可行方案是 STMap把一段视频的每一帧脸部 ROI 做空间平均得到几条时间序列再重排成二维图交给普通 2D-CNN 做分类或回归。这种做法把问题从「视频理解」降维成「图像识别」实现成本最低。缺点是把空间信息全部压掉脸部的局部生理特征用不上。随后出现 3D-CNN 路线输入直接是一段视频立方体时空卷积同时提取「哪里在变」和「怎么变」精度上限更高、参数也更大。近几年更流行的是时间移位卷积这类轻量化结构把普通 2D 卷积的一部分通道沿时间轴平移用很小的额外参数实现时序建模。对于这个压缩包里的任务我的建议是先看算力显存 8G 以下先跑 STMap 或轻量化结构别一上来就上大 3D 网络数据量不够时 3D-CNN 很容易过拟合。3.2 模型输出到底是什么逐帧 PPG 信号与 Loss 设计很多项目把模型设计成「视频进、BPM 出」的直接回归训练往往不顺。常见做法是让模型输出与输入帧对齐的逐帧信号序列即预测的 PPG 波形再利用后处理从波形里提取心率。这样做有两个好处信号序列是连续监督网络必须学会每个时刻的波动后处理阶段还能做多次滤波和频域分析问题被拆成「学会提取信号」和「从信号算心率」两步。Loss 的主流设计是两项相加信号域监督用 MSE 或负信噪比让预测波形逼近真值 PPG频域监督对信号做 FFT 后约束频谱峰值位置一致。频域项权重一般给 0.20.5。如果只加信号域 Loss模型倾向于把波形拟平成直流时域看起来接近、频域完全不对这是 rPPG 训练最常见的隐性失败。3.3 预处理脚本把 10 秒视频变成特征序列先把深度学习环境配置好PyTorch 2.x 加 CUDA 即可然后跑下面这段核心预处理。脚本做三件事逐帧读取视频、检测人脸、把整脸 ROI 缩放到固定尺寸后做空间平均。import cv2 import numpy as np # 用 MTCNN 做人脸检测也可以换成 OpenCV DNN 或 mediapipe差别不大 from facenet_pytorch import MTCNN def video_to_stmap(video_path, target_frames300, roi_size64): cap cv2.VideoCapture(video_path) detector MTCNN(select_largestTrue, post_processFalse) prev_box None signals [] while len(signals) target_frames: ret, frame cap.read() if not ret: break frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB).astype(np.float32) boxes, _ detector.detect(frame) if boxes is None: if prev_box is None: continue # 前几帧没人脸就跳过 box prev_box # 中途检测丢失沿用上一帧防止信号断裂 else: box boxes[0] prev_box box x1, y1, x2, y2 [int(v) for v in box] # 向内收缩 10% 避开头发和下巴边缘 w, h x2 - x1, y2 - y1 x1, y1 x1 int(w * 0.1), y1 int(h * 0.1) x2, y2 x2 - int(w * 0.1), y2 - int(h * 0.1) roi cv2.resize(frame[y1:y2, x1:x2], (roi_size, roi_size)) # 空间平均把 64x64x3 的 ROI 压成 3 个通道值这就是当前帧的“脉搏观测” signals.append(roi.mean(axis(0, 1))) signals np.asarray(signals) # [T, 3] # 各通道去除直流并归一化让网络安全地学到“波动”而不是“平均色” signals (signals - signals.mean(axis0)) / (signals.std(axis0) 1e-6) return signals # [T, 3] # 用法10 秒 30fps 视频 - [300, 3] 的浮点数组 stmap video_to_stmap(demo.mp4) print(stmap.shape)这段代码的关键在两处。沿用上一帧 bbox 的处理很重要人脸检测偶尔丢帧如果这里留空洞后面 FFT 会产生一段假的方波信号。空间平均之前把 ROI 缩放到固定大小等于做了一次空间归一化让不同距离、不同脸型的人脸在特征空间对齐。最后的 z-score 归一化把每个通道的脉动幅度压到同一量级避免肤色深浅影响网络收敛。3.4 训练循环双重监督让模型学会周期性下面是一个最简训练循环模型输入 [B, 3, T]通道在前输出 [B, T] 的预测 PPG 波形。假设数据加载器已经返回裁剪好的信号片断和对应的真值 PPG 信号。import torch import torch.optim as optim model get_model(tiny_physnet, in_channels3, signal_len300).cuda() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) mse torch.nn.MSELoss() for epoch in range(30): for clip, label_signal in train_loader: clip, label_signal clip.cuda(), label_signal.cuda() # [B,3,T] [B,T] pred model(clip) # [B,T] # 时域监督波形形状要像真值 PPG time_loss mse(pred, label_signal) # 频域监督用 FFT 幅度谱约束主峰位置防止学到直流信号 pred_fft torch.fft.rfft(pred, dim-1).abs() true_fft torch.fft.rfft(label_signal, dim-1).abs() freq_loss mse(pred_fft, true_fft) loss time_loss 0.5 * freq_loss optimizer.zero_grad() loss.backward() optimizer.step()time_loss 是主监督freq_loss 是辅助监督。权重 0.5 是我反复试下来比较省心的起点如果发现预测波形和真值波形形状一致但整体平移可以把 freq_loss 权重提到 1.0如果频域监督过强模型会优先拟合基频而忽略波形细节表现在验证集上 MAE 不错但波形失真。batch size 在显存允许时选 32低于 16 时训练曲线容易抖学习率 1e-3 配合 AdamW 在大多数小数据集上比 SGD 收敛更省心。3.5 推理阶段最容易被忽视的 FFT 频率轴从信号到 BPM模型输出的是一条预测信号最后一步是用 FFT 把它变成心率。这里翻车概率最高的地方是频率轴映射。def signal_to_bpm(signal, fps30): 把模型输出的逐帧信号换算成 BPMfps 必须与训练一致。 n len(signal) signal signal - signal.mean() # 去掉直流否则 0Hz 峰值会霸榜 fft np.fft.rfft(signal) freqs np.fft.rfftfreq(n, d1.0 / fps) # 单位Hz mag np.abs(fft) # 心率限制在 42~180 BPM对应的频段是 0.7~3.0 Hz mask (freqs 0.7) (freqs 3.0) peak_freq freqs[np.argmax(mag * mask)] return peak_freq * 60.0 bpm signal_to_bpm(pred_signal.cpu().numpy(), fps30)rfftfreq 的 d 参数是采样间隔单位是秒不是帧率写成 d1/30 和 d1.0/30 都行怕的是直接把 30 当成 d 传进去频率轴缩水 30 倍BPM 全部偏大。去掉直流那一步同样关键信号里哪怕残留少量均值偏移0Hz 附近的频谱能量也会把峰值锁定到错误位置。频段掩码再加一道保险防止模型输出里出现明显高于 3Hz 的噪声峰。4. 数据、指标与参数把公开数据集和评估口径用在 rPPG 项目上4.1 公开数据集选型UBFC-rPPG、PURE、COHFACE、VIPL-HR 怎么配合使用手上没有自采数据时公开数据集是唯一起点。rPPG 领域常用四个特点互补数据集数据特点适合干什么UBFC-rPPG室内近距离受试者在固定光源下以静止为主真值来自接触式 PPG 设备跑通算法、快速迭代模型结构PURE包含静止、头部转动、说话等多种采集协议同一受试者多段动作检验运动鲁棒性做微调和困难测试COHFACE分辨率偏低肤色多样性比前两个更好验证低分辨率摄像头和不同肤色场景的泛化能力VIPL-HR多设备、多种光照条件覆盖跨域场景评估模型换设备、换光线后的迁移表现我的搭配方式是先用 UBFC-rPPG 把训练流程跑通确认 loss 能下降然后把 PURE 的运动片段加进训练集微调最后拿 COHFACE 做困难集盲测。这个组合已经能覆盖大部分对比表格里的口径不需要一开始就自采数据。注意PURE 这类数据集的受试者人数不多直接随机切分 train/val/test 会把同一个人的不同视频片段同时放进训练和测试造成数据泄漏。正确的切分是按受试者 ID 划分同一人所有视频只能出现在一个集合里。4.2 评估口径MAE、RMSE、皮尔逊 r 和 Bland-Altman 谁说了算模型输出和真实心率之间的误差不同指标从不同角度回答「预测准不准」。MAE 是平均绝对误差单位 BPM最直观RMSE 会对大误差平方放大能暴露「偶尔错得很离谱」的模型皮尔逊 r 反映两组数据的线性相关性但它对整体偏移完全无感——预测值全部比真实高 10 BPM相关性仍然可能高达 0.9。Bland-Altman 图能整体看一致性和偏移趋势但论文里常用工程交付时我用 MAE 加最大误差两个数。工程上我建议按这个门槛判断模型是否可用MAE 小于 5 BPM 算基本过关MAE 58 BPM 可用但需要限制使用场景比如要求受试者静止MAE 大于 8 BPM 时问题大概率不在模型结构而在预处理或数据质量。测试时还要看最大误差驾驶监测这类场景最怕的不是平均偏而是某一次突然偏出 30 BPM。4.3 训练参数与数据增强把干净数据集用成接近现场状态训练参数按显存大小分两档。显存充足时输入窗口 300 帧10s30fps、batch size 32、AdamW 初始 lr 1e-3、weight decay 1e-4、余弦退火到 1e-5、训练 30 个 epoch。显存紧张时batch size 降到 16 优先保证输入长度不缩水实在不行再把输入帧率降到 20fps 并同步修改推理端重采样参数。不要为了凑 batch 把窗口从 300 帧砍到 150 帧频率分辨率会劣化。数据增强是 rPPG 项目最容易偷懒的地方也是拉高真实场景指标最明显的手段。常用的四类增强随机亮度增益和偏置模拟不同环境光幅度控制在 ±10%对人脸 ROI 做水平翻转和 5 像素内的随机平移模拟检测框抖动随机在窗口内移动起始帧模拟不同时刻截取随机丢弃 2% 的帧再插值补回模拟摄像头掉帧。一个反直觉的点光照增强幅度一开始别给太大。肤色波动本身只有几个灰度级把亮度扰动开到 ±20% 时模型学到的可能是「无视所有人脸区域变化」而不是「保留周期信号」。我踩过这个坑之后统一把增益扰动限制在 10% 以内。注意按受试者划分数据集的 train/val/test同一 ID 绝不能跨集合出现。5. rPPG 深度学习常见坑排查5 个现象、原因与解决办法5.1 训练损失持续下降验证期 BPM 却锁死在均值附近现象训练 loss 曲线正常下降但每次在验证集上输出心率预测值始终在 72 BPM 附近轻微浮动无论真实心率是 55 还是 95。把模型输出信号画出来几乎是一条平线。原因这是 rPPG 训练里最常见的隐性失败。模型发现「输出窗口内平均肤色」就能把时域 MSE 压到很低因为真值 PPG 信号经过 z-score 归一化后均值接近 0预测一个接近 0 的常数就有不错的 MSE。网络学会了偷懒没有学到周期性。解决先给 Loss 加频域监督让模型必须把频谱峰值放到正确位置再把输入信号和标签信号的均值同时强制清零不允许网络依靠直流分量拟合最后检查预处理里是否把归一化做重复了重复中心化会把本来就微弱的脉动进一步压平。5.2 训练用 30fps、部署用 25fps算出来的 BPM 全部偏高现象在公开数据集上 MAE 只有 4换成自己录的视频后每个人都偏高 1520 BPM且偏高的程度随真实心率升高而增大。原因推理端的 FFT 频率轴用了训练时的抽样间隔。模型本身输出的是逐帧信号没有帧率语义但后处理算频率时必须知道相邻两帧之间的真实时间差。25fps 视频按 30fps 的 d 参数换算时间轴被压缩频率被放大BPM 整体上移。解决把帧率作为显式参数传进推理函数不要从视频文件名或默认值推断。摄像头采集端如果帧率浮动比如 USB 摄像头掉帧先按时间戳重采样到固定 30fps 再送模型而不是直接把可变帧率原始帧序列喂进去。5.3 人脸检测框逐帧抖动模型输出里出现和呼吸同频的假峰现象静止坐着的受试者心率曲线出现 0.20.4Hz 的周期性起伏整体像「呼吸调制」。有人会误以为是呼吸率估计的附带成果其实是噪声。原因人脸检测框在相邻帧之间有几个像素的抖动ROI 边缘会在皮肤和背景/头发之间来回切换切换的频率恰好落在低频段叠加到肤色信号上形成假峰。解决对检测框坐标做一阶低通平滑比如 bbox_smooth 0.8 * bbox_smooth 0.2 * bbox_current或者首帧检测人脸后用光流或跟踪算法维持 ROI 位置而不是每帧重新检测。如果两者都不方便至少把检测框向内收缩 15%让边缘远离头发和背景。5.4 运动任务全部翻车头部一动预测值就飞现象PURE 这类数据集上「静止」任务 MAE 3 BPM切到「转头」「说话」任务 MAE 飙到 15 以上预测曲线跟着动作幅度走完全没有心率的样子。原因两个因素叠加——训练集里运动片段本来就少网络把「运动导致的纹理变化」误当成了脉动运动引起的肤色区域结构变化幅度远大于真实脉动模型优先拟合大信号。解决在训练集里人为制造运动样本对静止视频做随机仿射变换小角度旋转、缩放、平移来模拟头部姿态变化把 MTCNN 检测框的抖动也作为一种增强引入。如果压缩包里预置了运动任务测试协议直接用协议里的分段评估只看整体 MAE 会掩盖运动场景失效的问题。5.5 Loss 曲线锯齿状震荡调整学习率就 NaN现象训练前 10 个 epoch loss 上蹿下跳怎么调 lr 都像在赌博偶尔一次成功收敛稍微加个增强项立刻发散。原因滑动窗口重叠率太高相邻训练样本只差一两帧梯度方向高度相关等价于在一个极小数据集上反复训练优化器进入震荡状态。光照增强幅度太猛时损失函数在「保留脉动」和「无视光照变化」两个目标间剧烈冲突。解决把窗口重叠率从 50% 降到 25%并按视频 ID 分组再 shuffle保证同一个 batch 里采样自不同视频增强幅度按前面说的控制在 ±10%优化器加 warmup前 3 个 epoch 从 1e-4 线性升到 1e-3。出现 NaN 时优先检查训练数据里有没有全黑帧或者全零信号这一类样本会让 BatchNorm 统计量炸掉。6. 压缩包验证与部署检查盲测、Bland-Altman 和导出前的三个一致性检查6.1 验证方法用不参与训练的真人视频做盲测拿到 zip 里跑通的模型先不要信训练曲线也不要信公开数据集上的测试表。我习惯的做法是录一段 60 秒的自己视频全程坐直、自然呼吸同时用手指式血氧仪或智能手环记录真实心率。把视频按 30fps 重采样后送进推理脚本得到每条 10 秒窗口的预测 BPM取中位数作为整段预测值和真值对比。多测几个人把误差点画成 Bland-Altman 图看偏差均值是否接近 095% 一致性区间是否在 ±10 BPM 内。这个验证半小时就能完成但它决定了这个压缩包是能直接用于产品还是只适合当学术基线。6.2 导出模型的三个一致性检查模型要部署到端侧时导出前必须核对三件事。归一化一致性训练脚本里的 mean/std 归一化是否被烘焙到模型节点里推理源码里是否还有一份重复的预处理帧率一致性摄像头实际输出帧率与训练集是否一致不一致就先重采样再进入模型ROI 一致性模型输入固定是 64x64 或 128x128部署端裁剪的人脸框缩放方式必须与训练端一致用 INTER_AREA 和 INTER_LINEAR 出来的特征哪怕差异很小也会影响周期信号的相位。三个一致性检查全过之后再做一次端点延迟估算从摄像头取帧到输出 BPM延迟如果超过 3 秒连续监测会有明显滞后感。滑动窗口投票通常会引入窗口长度一半的延迟这是方案本身的代价。我的习惯是在压缩包里找一个「能独立跑通的最小 demo」先对齐输入输出张量形状再逐步替换成自己的数据和部署环境。别一上来就做大全量复现rPPG 这类项目预处理环节太多每一步都可能带偏结果。谨记先让模型在你自己录的视频上输出一个合理心率再回头谈优化这条路径最省时间。希望帮到你。本文还有配套的精品资源点击获取