☰
深度学习rPPG心率估计:从人脸视频到非接触心率监测
2026/10/2 2:11:49 网站建设 项目流程

简介:面向基于 rPPG 的深度学习心率估计任务,这份 MATLAB 源码包集成了多种经典算法与可运行案例数据。适用于计算机、电子信息工程、数学等专业的课程设计、期末大作业及毕业设计,也适合研究者快速复现和扩展实验。包内共 118 个文件,以 m 脚本为核心,辅以 xml 配置文件、png 示意图、pdf 文档与 prj 工程文件,整体约 7.31MB,目录结构清晰便于按需取用。代码采用参数化编程,参数易调整,思路与注释均较为细致,能帮助使用者理解图像处理、信号分析和深度学习在心率估计中的完整流程。目前已有 121 人学习下载,对希望入门或深化 rPPG 技术、开展生物医学信号处理实践的学生与开发者而言,是一份可直接上手的学习与实验资料。

1. 一个 zip 背后的非接触心率估计:它解决什么问题,适合谁用

拿到类似《基于深度学习的基于 rPPG 心率估计.zip》这种工程打包,我一般会先做三件事:解压看目录、查环境依赖、确认里面的数据是原始视频还是已经切好的真值标注。这类项目解决的其实是一个很具体的诉求:只靠普通摄像头对着人脸拍视频,就能把心率估出来,不用胸带、不用指夹、不用任何接触式传感器。rPPG(remote Photoplethysmography,远程光电容积描记)就是这套技术的统称,深度学习要学的是人脸视频到脉搏波信号之间的映射关系。

它适合三类人:做疲劳驾驶或健康监测产品的工程师,想低成本验证非接触生理信号方案的硬件团队,以及拿这个方向做毕业设计、需要快速跑通完整链路的同学。但我要先说清楚:这类 zip 包大多数不是开箱即用,数据格式、时间戳对齐、训练和推理的帧率差异,坑都在代码外面。这篇笔记按我自己的实践路径,把数据准备、模型选型、训练、推理和排错整条链路讲完。

2. 先立住信号假设:rPPG 为什么能被深度学习学会

2.1 传统方法的三条假设,以及它们为什么在真实场景翻车

rPPG 的物理基础不复杂:心脏每搏动一次,面部皮肤毛细血管里的血容量就变化一次,血红蛋白对绿光的吸收率随之波动,摄像头拍到的 RGB 信号里就携带了这个微弱的周期分量。传统方法比如 POS、CHROM、ICA,本质上是做信号分解——从面部 ROI 的平均 RGB 序列里,把和心跳相关的色度变化分量分离出来。

这些方法依赖三条隐含假设:第一,光照是稳定的;第二,人脸是静止的;第三,皮肤区域的颜色变化主要来自血流,而不是阴影或反光。实验室环境满足这三条,所以传统方法在公开数据集上表现不差。一旦进到真实场景,日光灯频闪、头部转动、说话时的肌肉牵拉、手机自动白平衡,都会把 RGB 信号里的非周期分量放大,导致传统方法的输出心率要么剧烈抖动,要么直接锁在环境光的整数倍频率上。

深度学习换了个思路:不去手工定义哪个色度子空间是“干净”的,而是用标注好的视频直接学“RGB 时序 → PPG 波形”这段映射。网络能看到传统方法看不到的非线性耦合,比如运动带来的肤色变化和血流带来的肤色变化在空间纹理上的差异。代价是它需要足够多样的训练数据,否则会过拟合到某个数据集特有的光照模式上,这个是后面避坑章节的重点。

2.2 深度模型的输入输出设计:把视频回归成 PPG 波形

在动手搭模型之前,先要把输入输出定下来。我给这个方向的项目定过不同方案,最常见的有三种建模方式,各有侧重点。

建模方式输入输出优点工程代价
PPG 波形回归T 帧人脸 ROI 序列长度 T 的一维 PPG 信号中间量可检查、可后处理,心率从频谱计算需要波形级标注或由 R-peak 生成参考波形
心率直接回归T 帧人脸 ROI 序列单个 bpm 数值链路最短,训练简单输出不可解释,窗口长度选择敏感
心率区间分类T 帧人脸 ROI 序列若干心率区间的概率逻辑简单精度粗糙,只能做粗筛

我一般选波形回归。原因很实际:输出是一段信号,我能拿它的频谱图去定位问题。如果模型输出的波形频谱在 0.75 到 4Hz 之间有清晰单峰,说明网络学到了东西;如果频谱一片混沌,我就可以判断是数据问题还是后处理问题。直接回归心率值等于把后处理交给了黑匣子,排查难度大得多。

输入侧有两个关键参数先定:每段采样帧数 T 和 ROI 分辨率。T 至少要覆盖两个完整心跳周期,按最低心率 45bpm 算,一个周期约 1.33 秒,30fps 下两个周期就是 80 帧,我通常取 128 帧,约 4.3 秒,留出余量。ROI 分辨率不需要高,36x36 到 64x64 足够,因为 rPPG 利用的是区域平均颜色变化,局部纹理信息反而可能引入运动噪声。

2.3 评价指标怎么定:MAE、±5bpm 准确率和使用场景的关系

训练和验证阶段,我建议同时看三个指标,别只盯 loss。MAE(平均绝对误差)反映整体偏差,单位是 bpm,疲劳监测场景我要求 MAE 小于 6bpm;±5bpm 准确率反映有多少时间窗口的估计误差落在 5bpm 以内,这个指标对报警类应用更重要,因为误报比均值偏差更伤人;RMSE 则用于暴露个别窗口的大误差。

对比论文数据时要小心统计口径。有的工作报的是段级指标,比如对整段 30 秒视频只算一个心率误差,有的工作报的是窗口级指标,每 5 秒算一次。同一套模型前者的 MAE 会比后者低 2 到 3bpm。我自己的习惯是固定用窗口级指标,窗口长度 10 秒、步长 1 秒,这样测试结果能和部署场景对齐。新手最容易犯的错是拿段级结果去对标论文里的窗口级数字,以为自己复现成功了。

3. 把最小闭环跑通:公开数据集、切块预处理与训练脚本

3.1 准备数据集:公开数据的标签读取与文件组织

做这类项目,第一步不是写模型,而是把数据组织好。公开数据集我常用的是 UBFC-rPPG 和 PURE 这类,前者是用相机录制的面部视频加 R-peak 标注,后者包含多个受试者在不同运动状态下的样本。下载下来大多是 zip 或 tar 包,解压后每个受试者一个目录,里面有视频文件和标注文件。我习惯先把原始目录重排成统一结构,避免后续写死在路径里。

rppg_project/ data/ raw/ subject01/ video.avi peaks.txt subject02/ video.avi peaks.txt processed/ subject01_128.npy subject01_hr.npy dataset.py train.py config.yaml

标注文件最需要小心。不同数据集的 R-peak 文件格式不一样,有的每行一个峰值时间戳,单位是秒;有的则是两列数据,需要先查文件头再决定解析方式。我吃过亏的地方是直接用 np.loadtxt 整批加载,结果某个文件的列数不对,训练到一半才报错。先读几行确认格式,再写完整的加载函数,这一步能省下半天排错时间。

import numpy as np def load_peaks(txt_path, fs_label=60.0): # 先看前几行,确认是单列还是多列、单位是秒还是毫秒 with open(txt_path, "r") as f: head = [next(f).strip() for _ in range(3)] print("head:", head) # 常见格式:每行一个峰值时间戳,单位秒 peaks = np.loadtxt(txt_path, dtype=np.float64) if np.nanmax(peaks) > 300: # 如果数值大于300,大概率是毫秒,转成秒 peaks = peaks / 1000.0 return peaks

这段代码的逻辑是先打印标注文件头,确认格式后再解析。参数说明:fs_label 是标注系统的采样率,这类数据集里 R-peak 时间戳的精度至少要毫秒级,否则后续算逐帧心率真值时会引入额外抖动。单位判断用最大值是否超过 300 只是经验法则,因为一段几十秒的视频不可能出现超过 300 秒的峰间隔,遇到特殊格式还是要回到文件头确认。

3.2 切块与增强:T、ROI 尺寸、帧率归一化这三个参数先定下来

视频不能整段塞进网络。我按 T=128 帧切块,滑动步长 64 帧,这样相邻样本有 50% 重叠,既增加了训练样本量,也起到轻微的平滑正则作用。每个样本先做人脸检测,把脸部区域裁剪并缩放到 64x64,再做 z-score 归一化。帧率归一化容易被忽略:如果数据集的标注视频是 30fps,推理端是 15fps,模型在时间维度上的卷积核就完全错位了。训练时统一把视频重采样到固定帧率是必做项。

def preprocess_video(video_path, T=128, resize=64, fs_target=30.0): cap = cv2.VideoCapture(video_path) fs_native = cap.get(cv2.CAP_PROP_FPS) frames = [] while True: ret, frame = cap.read() if not ret: break # 这里用 OpenCV 的人脸检测器拿 bbox,后续会替换成关键点对齐 boxes = face_detector(frame) x, y, w, h = pick_face_box(boxes) roi = frame[y:y+h, x:x+w] roi = cv2.resize(roi, (resize, resize)) frames.append(roi) cap.release() # 重采样到目标帧率,避免训练和推理帧率不一致 n_total = len(frames) idx = np.linspace(0, n_total - 1, int(n_total * fs_target / fs_native)) frames = [frames[int(i)] for i in idx] return np.stack(frames[:T])

重采样这步的 idx 计算,本质上是在时间轴上做了线性插值。如果原生帧率是 30、目标是 25,相当于每 6 帧抽 5 帧,能接受;如果帧率差太多,比如从 60fps 降到 15fps,建议先用平均池化做平滑再抽帧,否则会引入混叠噪声。ROI 尺寸这个参数容易被盲目加大,实际 64x64 在多数设备上已经够了,加到 128x128 不会提升精度,只会增加显存压力。

3.3 最小训练脚本(PyTorch):3D-CNN 基线与负 Pearson loss

模型结构我不建议一上来就上很重的网络。一个能跑通的最小深度学习项目,用 3D-CNN 做基线就够了。输入是 B x 3 x T x 64 x 64,输出是 B x T 的 PPG 波形。下面这个结构是我常用的起点,它把空间分辨率逐层压缩,保留时间维度,最后通过线性插值对齐到目标长度。

import torch import torch.nn as nn import torch.nn.functional as F class MiniRPPGNet(nn.Module): def __init__(self, in_ch=3, T=128, roi=64): super().__init__() # 三层 3D 卷积,逐步压缩空间分辨率,保留时间信息 self.backbone = nn.Sequential( nn.Conv3d(in_ch, 32, kernel_size=(3, 5, 5), stride=(1, 2, 2), padding=(1, 2, 2)), nn.BatchNorm3d(32), nn.ReLU(inplace=True), nn.Conv3d(32, 64, kernel_size=(3, 3, 3), stride=(2, 2, 2), padding=(1, 1, 1)), nn.BatchNorm3d(64), nn.ReLU(inplace=True), nn.Conv3d(64, 128, kernel_size=(3, 3, 3), stride=(2, 2, 2), padding=(1, 1, 1)), nn.BatchNorm3d(128), nn.ReLU(inplace=True), ) self.head = nn.Sequential( nn.AdaptiveAvgPool3d((1, 1, 1)), # 空间和时间全局池化 nn.Conv3d(128, 1, kernel_size=1), # 输出单通道 ) def forward(self, x): # x: (B, 3, T, roi, roi) x = self.backbone(x) # 时间长度因为stride减半,空间降到 16x16 x = self.head(x) # (B, 1, 1, 1, 1) return x.view(x.size(0), -1) # 塌缩成 (B, 1),注意这里只输出了一个标量

等等,这段代码有个问题:AdaptiveAvgPool3d((1,1,1)) 会把整段时间压成一个点,波形回归就退化成标量回归了。正确的做法是只池化空间维度,保留时间长度。修正后的头部应该是先 AdaptiveAvgPool3d((1,1,1)) 不对,应该用 AdaptiveAvgPool3d 输出完整时间长度,然后接 1x1 卷积。

class MiniRPPGNet(nn.Module): def __init__(self, in_ch=3, T=128): super().__init__() self.backbone = nn.Sequential( nn.Conv3d(in_ch, 32, kernel_size=(3, 5, 5), stride=(1, 2, 2), padding=(1, 2, 2)), nn.BatchNorm3d(32), nn.ReLU(inplace=True), nn.Conv3d(32, 64, kernel_size=(3, 3, 3), stride=(2, 2, 2), padding=(1, 1, 1)), nn.BatchNorm3d(64), nn.ReLU(inplace=True), nn.Conv3d(64, 128, kernel_size=(3, 3, 3), stride=(2, 2, 2), padding=(1, 1, 1)), nn.BatchNorm3d(128), nn.ReLU(inplace=True), ) # 只压缩空间,时间交给后面的 interpolate 对齐 self.head = nn.Sequential( nn.AdaptiveAvgPool3d((None, 1, 1)), # 时间维度不变,空间压到1x1 nn.Conv3d(128, 1, kernel_size=(1, 1, 1)), ) def forward(self, x): # x: (B, 3, T, 64, 64) x = self.backbone(x) # (B, 128, T_out, 16, 16),T_out 因为stride变小 x = self.head(x) # (B, 1, T_out, 1, 1) x = x.squeeze(-1).squeeze(-1) # (B, 1, T_out) x = F.interpolate(x, size=(128,), mode="linear", align_corners=False) # 等比回到128 return x.squeeze(1) # (B, 128)

这里的 AdaptiveAvgPool3d((None, 1, 1)) 写法可能不是所有 PyTorch 版本都支持 None,实际工程里我用 torch.nn.AdaptiveAvgPool3d 时,会用一个包装函数处理时间维度的保留。更稳妥的做法是不靠池化,直接在卷积后对时间维度做线性插值,代码里我用 F.interpolate 把输出长度对齐到 128。反正对齐这一步必须有,因为 backbone 的三个 stride 已经把时间维度缩短到约 32,不插值回来没法算 loss。

loss 用负 Pearson 相关系数,它衡量的是预测波形和真值波形的形状相似度,对振幅不敏感。rPPG 的 PPG 幅度本身受肤色和光照影响,绝对误差 loss 会让模型把精力花在拟合幅度上,而不是波形形状。

def pearson_loss(pred, target, eps=1e-6): # pred, target: (B, T) pred = pred - pred.mean(dim=1, keepdim=True) target = target - target.mean(dim=1, keepdim=True) cov = (pred * target).mean(dim=1) std_pred = pred.std(dim=1) std_target = target.std(dim=1) return - (cov / (std_pred * std_target + eps)).mean()

训练循环本身不复杂,关键是每轮验证时算一次 Pearson 相关系数,而不是只看 loss 下降。相关系数到 0.5 以上才能说明模型开始学习到与心跳相关的周期成分,低于 0.3 基本是没学到,需要回去查数据。

model = MiniRPPGNet(in_ch=3, T=128) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) for epoch in range(30): for frames, label_ppg in train_loader: # frames: (B, 3, 128, 64, 64),label_ppg: (B, 128) out = model(frames) loss = pearson_loss(out, label_ppg) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()

训练参数说明:AdamW 配合 CosineAnnealing 在小数据集上表现稳定,learning rate 从 1e-3 开始,30 个 epoch 后基本收敛。batch size 显存能吃下就设 8,吃不下就把 ROI 改成 48x48,或者把 T 降到 64。验证集上每 5 个 epoch 手动算一次 MAE,比 loss 更直观。如果训练时 loss 下降但验证集 Pearson 不涨,优先检查标签对齐。

3.4 启动前先做两件事:看真值波形、看数据 loader 输出

这一步我给新手强烈建议,能筛掉一半玄学问题。第一件事是把一个样本的真值 PPG 画出来,确认它有明显的周期峰,峰间隔对应的心率在 45 到 100bpm 之间。如果真值波形平得像噪声,后面训练没有意义。第二件事是跑一遍数据 loader,打印一个 batch 的输入输出 shape,确认和模型 forward 的第一行注释一致。

import matplotlib.pyplot as plt sample = train_set[0] frames, label_ppg = sample print("frames shape:", frames.shape, "ppg shape:", label_ppg.shape) plt.plot(label_ppg[:256]) # 画256个采样点,看是否周期性 plt.show()

真值波形如果出现台阶状或突变,通常是 R-peak 换算成逐帧心率时用了阶跃插值而不是线性插值。这类问题模型学不到东西,还会让你误以为是网络结构不行。

4. 从模型权重到心率数值:推理链路与参数落地

4.1 人脸对齐与 ROI 选取:不只框脸,还要避开眼睛和嘴

训练时做的是全脸裁剪,推理时如果照搬就会出现问题。眼睛的眨动、嘴部的说话动作会产生大幅度的像素位移,这些运动成分和血流信号混在一起。常见做法是做人脸关键点检测,然后取脸颊区域作为 ROI——两眼连线以下、嘴部以上的梯形区域。这个区域皮肤暴露面积大,肌肉运动少,血流信号最干净。我一般用关键点里的左右眼中心和鼻尖三个点来确定一个矩形,再往下扩一点点。

逐帧跑关键点检测在 CPU 上会拖垮帧率,推理时通常每 5 帧检测一次,中间帧用线性插值补出关键点坐标。头部小幅转动时,这种方式比逐帧检测的抖动还小,因为检测器本身的输出也有帧间跳动,插值相当于做了平滑。如果头部运动剧烈,线性插值会失效,那就只能缩短检测间隔,或者后端加一个跟踪器。

ROI 分辨率这里我明确建议 64x64,不要学分类任务那样加大。rPPG 要的是区域平均色度,不是高频纹理。分辨率太高会把皮肤毛孔、胡渣的噪声学进模型,而且增加推理耗时。

4.2 后处理三板斧:去趋势、带通滤波、FFT 找峰

模型输出的是一段连续 PPG 波形,不能直接除 60 变成心率。三个固定操作:去趋势去掉基线漂移、带通滤波限定到心率频带、FFT 找峰换算 bpm。这里的带通滤波必须用零相位滤波,普通 Butterworth 滤波会引入相位延迟,导致峰的位置偏移,心率计算结果偏差可达几 bpm。

import numpy as np from scipy import signal as sig def ppg_to_hr(ppg, fs=30.0, low=0.75, high=4.0): # ppg: 1D array,长度对应输入帧数 ppg = sig.detrend(ppg, type="linear") # 去线性趋势,去掉呼吸和光照基线漂移 b, a = sig.butter(2, [low / (fs / 2), high / (fs / 2)], btype="band") ppg = sig.filtfilt(b, a, ppg) # 零相位滤波,避免相位偏移 n = len(ppg) win = np.hanning(n) # 加窗减少频谱泄漏 spec = np.abs(np.fft.rfft(ppg * win)) freqs = np.fft.rfftfreq(n, d=1.0 / fs) idx = np.where((freqs >= low) & (freqs <= high))[0] peak_freq = freqs[idx[np.argmax(spec[idx])]] return peak_freq * 60.0

参数说明:带通下限 0.75Hz 对应 45bpm,上限 4Hz 对应 240bpm,覆盖绝大多数应用场景。FFT 的频率分辨率是 fs / n,n=128、fs=30 时分辨率约 0.23Hz,折合心率约 14bpm,太粗了。所以要保证送入 FFT 的序列足够长,推理时多个窗口拼接后的 PPG 序列至少有 256 点。如果只有 128 点,心率结果只能到 ±7bpm 的精度,这是我踩过的坑。

4.3 输出平滑:瞬时心率做 EMA,报警看趋势

FFT 峰值跳动很大,哪怕模型很准,相邻两个窗口的心率也能差出 8 到 10bpm。直接把这个值显示给用户,体验很差。我常用的平滑方式是 EMA,alpha 取 0.4,让当前心率 60% 来自新结果、40% 来自历史。再激进一点,取最近 10 个窗口的中位数。报警逻辑不要用瞬时值,用"最近 60 秒内中位心率持续超过阈值"这种趋势判断。

ema_hr = None alpha = 0.4 def update_hr(raw_hr): global ema_hr if ema_hr is None: ema_hr = raw_hr else: ema_hr = alpha * raw_hr + (1 - alpha) * ema_hr return ema_hr

EMA 的 alpha 参数在设备上实测调,alpha 太小响应慢,心率已经变化了 10bpm 界面还停在旧值;alpha 太大又抖动明显。我一般先取 0.4,再根据实际视频的抖动幅度上下微调 0.1。

5. rPPG 工程化的 4 个常见问题与避坑指南

5.1 帧率不一致:训练 30fps,部署 15fps,波形全乱

现象:模型训练时输入视频是 30fps,部署端摄像头输出 15fps。推理时模型输出的 PPG 波形频率整体减半,心率看起来只有真实值的一半,或者频谱上出现奇怪的谐波峰。

原因:3D 卷积的时间维度是按绝对帧数设计的,它默认相邻两帧的时间间隔是固定的。把 15fps 的视频直接喂给按 30fps 训练的网络,等于把时间轴拉长了一倍,所有频率分量全部加倍。这个问题在训练时几乎不会暴露,因为数据集大多是统一帧率。

解决:推理端先读摄像头的真实帧率,如果和训练帧率不一致,先做时间轴重采样。最简单的做法是拉普拉斯插值,把 15fps 补到 30fps,再做推理。更省事的方案是训练时就做帧率扰动:每个 epoch 随机把视频抽帧到 20 到 30fps 之间再训练,让模型学到帧率不变性。

5.2 光源频闪:为什么输出心率死死贴在 60 或 120

现象:在室内日光灯下测试,心率输出恒定为 60bpm 或者 120bpm,且怎么动都不变。换到自然光环境,输出又正常了。

原因:交流电驱动的日光灯有 50Hz 或 60Hz 的亮度脉动,摄像头采样后这些高频分量会混叠到低频,落在 0.75 到 4Hz 的心率频带内。模型可能学到的是灯光周期而不是心跳周期。更隐蔽的是,训练集里如果有大量室内灯光数据,模型会被训练得偏向输出 60 的倍数。

解决:拍摄时控制曝光时间。对着 50Hz 光源,把曝光时间设为 10ms 的整数倍,让每个帧内累积的光能量相等,从源头消掉频闪。已经污染的数据做离线处理时,用陷波滤波器在 50Hz、100Hz 处做衰减,但前提是视频帧率足够高,奈奎斯特频率覆盖这些频点。还有一个快速诊断方法:关灯只用自然光重新录一段,如果模型输出从 120 跳到 75 左右,基本可以确认是光源问题。

5.3 标签错位:离线指标好看、线上偏差大的隐形原因

现象:训练时 loss 正常下降,验证集 Pearson 也到了 0.6,但部署后实测心率始终比真实值高 2 到 4bpm,且偏差方向固定。

原因:R-peak 标注的时间戳和视频帧的时间戳没有对齐。很多公开数据集的标注文件是从生理信号采集系统导出的,采集系统有自己的时钟,和摄像头的时间轴有固定偏移。训练时网络被迫学习这个偏移,所以验证集上表现正常,换到新视频就暴露了。

解决:录制同步数据时用 LED 打点,视频里闪一下,标注文件里也记一个时间戳,用这个差值做全局对齐。后补对齐的土办法是互相关:把模型输出的 PPG 和真值 PPG 做相关,找到最大相关系数对应的偏移量。但注意只能用训练集或验证集来估计这个偏移量,不能在测试集上做,否则会泄漏信息。我发现这个坑的时候浪费了好几天,后来在数据集加载器里加了一个可选全局偏移参数,先把时间对齐问题可视化再训练。

5.4 跨肤色泛化:换个数据集 MAE 翻倍,怎么办

现象:在公开数据集 A 上训练,MAE 5bpm,拿到自己拍的数据 B 上测试,MAE 直接翻倍到 10bpm 以上。数据集 B 的人员肤色、相机型号、室内光照都不一样。

原因:rPPG 的本质是捕捉微小颜色变化,相机白平衡、肤色深浅、光源色温都会改变 RGB 通道的统计分布。模型学到了数据集 A 的"颜色风格",而不是泛化的血流信号。

解决:训练侧做颜色增强,模拟不同相机和光源的通道偏差。增强幅度不能太大,否则破坏 rPPG 的物理真实性。

def color_augment(img): # img: (3, T, H, W),RGB顺序 gain = torch.empty(3).uniform_(0.85, 1.15) # 通道增益,模拟白平衡偏移 bias = torch.empty(3).uniform_(-5, 5).view(3, 1, 1, 1) # 亮度偏移 return img * gain.view(3, 1, 1, 1) + bias

参数说明:gain 的扰动范围 0.85 到 1.15 对应约 ±15% 的通道增益变化,这个范围既能覆盖手机相机之间的白平衡差异,又不会把信号的频带结构破坏掉。bias 的 ±5 是在 0 到 255 的像素尺度上,相当于很小的黑电平漂移。推理侧,如果目标场景相机固定,可以在模型前加一个通道均值归一化层,把输入视频的 RGB 均值对齐到训练集统计值。这个增强通常能挽回一半以上的跨场景误差。

6. 不依赖真值设备的离线验证技巧:先用频谱和失败样本说话

在没有指夹式血氧仪做同步真值的情况下,我有一套离线验证流程,能判断模型到底行不行。第一件事是频谱体检:让测试者正对摄像头保持不动,录 30 秒视频,把模型输出的 PPG 画成频谱图。如果频谱在 0.75 到 4Hz 之间有单一清晰的峰,峰的位置换算成心率后和手测脉搏对得上,说明模型学到了真东西。如果频谱上几个峰差不多高,或者主峰在 0.2Hz 附近,那是呼吸或头部微动占主导,模型没学对。

第二件事是手测脉搏对比。不用什么精密设备,手机秒表计时 15 秒,自己数手腕脉搏次数乘以 4,和模型输出的心率做对比。这个方法精度有限,但能在 5bpm 内区分"基本可用"和"完全不可用"。我在做边缘设备部署时,经常先在办公室拿自己录一段 15 秒视频跑通全链路,再上真值设备做定量评估,这样能快速筛掉环境、帧率、光源这类低级问题。第三件事是失败样本聚类:把误差最大的几个窗口找出来,逐个看对应视频片段有什么共性——头部转动、说话、光照突变、人脸出画,这些共性就是你下一步要补的数据方向。

我现在的工程习惯是先把后处理和频带设计定稿,再回去调模型。很多团队把精力全花在网上结构上,最后发现误差主要来自频谱分辨率太低或者光源混叠。这个方向值不值得做,我的判断是明确值得——非接触心率监测在疲劳驾驶、婴儿监护、远程医疗预筛查里都有真实需求,但一定要把验证流程做扎实。希望这些踩坑记录能帮你在 rPPG 深度学习这条路上少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询