用C++和OpenCV从视频中测心率:rPPG信号处理实战
简介一套基于视频的心率测量C工程代码采用视频脉冲检测技术从人脸视频中提取心率信号面向生物医学信号处理与计算机视觉方向开发者适合有C基础并希望实践非接触式生命体征测量的读者。工程集成OpenCV及contrib、OpenVINO推理引擎、Eigen3线性代数库、iir滤波库和Smorodov源码覆盖人脸检测、信号提取与滤波等关键环节内部包含视频帧读取、ROI定位、颜色信号分解、滤波去噪与心率估计等模块。资源共148个文件以44个cpp、30个h、32个xml为核心涉及工程源代码、模型推理配置、界面布局与构建说明另含模型文件、帮助文档和界面资源压缩包大小84.32MB。目前已有199人学习下载。使用者可获得可直接编译的工程框架、完整脉冲提取处理链路、第三方库集成示例配合README可快速理解模块关系并迁移至自身项目。1. 基于视频的心率测量C 要完成的不只是算数在信号处理里基于视频的心率测量叫成像式光电容积描记iPPG / rPPG用普通摄像头拍皮肤区域从像素亮度的周期变化估算心跳频率。心室收缩时皮肤血容量增加反射光强下降摄像头捕捉到的亮度随之周期波动。问题是这个波动只占像素电平的 1% 到 5%常常比传感器噪声和环境噪声还低所以项目重心不是“能不能算出频率”而是“如何把微弱周期信号从噪声里分离出来”。用 C 是因为视频采集、图像预处理、信号分析要在同一个低延迟闭环里完成还要能打包成不依赖额外运行时的交付物塞进工控机或边缘盒子。本文按可编译工程组织最后你能从视频文件或摄像头持续输出稳定 BPM并附带一个合成视频生成器用来验证整条测量链路是否正常。2. 视频测量心率的信号源头先确认信号长什么样2.1 血容量脉动如何写进像素值摄像头记录的不是“肤色”而是环境光在皮肤上的反射强度。入射光穿过表皮进入真皮一部分被血液中的血红蛋白吸收剩下的散射回来落在传感器上。心室收缩期动脉压升高毛细血管和微静脉里血容量增加对绿光和蓝绿光的吸收增强反射亮度下降心室舒张期则恢复。这个周期就是心搏周期频率落在 0.75Hz 到 4Hz对应 45 到 240 次每分钟。为什么第一版直接选绿色通道而不是亮度最大的红色通道血红蛋白吸收光谱在 500nm 到 600nm 区间有两个主峰正好匹配传感器颜色阵列里绿色滤镜的响应范围。红光虽然能穿透更深的组织但在环境光成分里红光对黑色素和肤色变化更敏感容易引入静态偏差。绿色通道均值方案信噪比通常优于红色通道代码改动量也最小。2.2 从二维帧到一维信号空间平均的意义把每帧图像压缩成一个采样值常规做法是 ROI 内空间平均。ROI 选在额头和脸颊这些区域毛细血管密集、皮下脂肪薄、运动状态相对稳定。空间平均本质上是几十万像素的并联采样独立噪声按根号 N 的倒数衰减画面越稳定降噪收益越大。但 ROI 不能无限大更不能直接套整个人脸框。人脸框包含眼睛、嘴巴、发际线眨眼和说话带来的局部纹理变化会以远大于血容量信号的幅度污染平均值。实际工程里常见方案是把人脸框上半部分作为 ROI或者用内缩矩形避开太阳穴。ROI 方案面积占比运动伪差实现成本推荐度整个人脸框100%高0 行额外代码不推荐上半脸额头鼻梁约 45%低4 行最常用额头窄带约 15%极低6 行头部固定时双颊组合约 30%中等8 行遮挡环境在代码里取均值只是一个cv::mean调用cv::Scalar mean cv::mean(frame(roi)); double green mean[1]; // OpenCV 默认 BGR 顺序下标 1 是绿色 signal.push_back(green); // 每帧追加一个采样点mean返回的Scalar里四个通道分别对应 B、G、R、alpha。BGR 顺序是 OpenCV 的老约定其他图像库顺序可能不同跨库移植时最容易在这一行出问题。2.3 三类干扰决定了后续滤波器的边界第一类干扰是环境光中的交流分量工频荧光灯以 50Hz 或 60Hz 闪烁会向信号中注入远高于心率频段的谐波只能靠带通滤波或频域截断压制。第二类是头部刚体运动平移和旋转会直接改变 ROI 内像素分布幅度是信号本身的好几倍前级人脸框跟踪能缓解但低频运动往往没有简单过滤方案。第三类是局部遮挡与表情运动眼镜反光、头发晃动、眉毛跳动都会注入非周期变化在频谱上表现为宽泛的背景能量。明白这三类干扰后续所有参数取舍就都有了依据帧率、窗口长度、滤波上下限全是在信号和噪声的两个频带之间找边界。3. 在 C 里搭出最小可跑的视频心率测量代码3.1 依赖选择与 CMake 工程骨架我用的技术栈是 OpenCV 4.x 的四个核心模块videoio 负责读视频帧objdetect 提供 Haar Cascade 人脸框imgproc 负责灰度转换core 提供 Mat 与 DFT。不需要引入 FFTWcv::dft够用也不需要引入 Eigen几百点的频谱计算向量化收益不明显。这个选择把第三方依赖限制到一个库交付时只需带上一个 cascade xml 文件相关文件清单非常短。cmake_minimum_required(VERSION 3.16) project(video_hr) set(CMAKE_CXX_STANDARD 17) find_package(OpenCV REQUIRED COMPONENTS core videoio imgproc objdetect) add_executable(video_hr main.cpp) target_link_libraries(video_hr PRIVATE ${OpenCV_LIBS})OpenCV_LIBS在 CMake 里自动展开成对应的函数库不需要手动指定。Windows 上如果用的是 vcpkg 环境配置命令里要加-DVCPKG_TARGET_TRIPLETx64-windows否则默认 x86 库会和 x64 编译器冲突链接期报 LNK1112。3.2 视频源初始化与时间戳记录先支持视频文件和摄像头两种输入源调试阶段优先用视频文件cv::VideoCapture cap; if (argc 1) { cap.open(argv[1]); // 打开视频文件 } else { cap.open(0); // 打开默认摄像头 cap.set(cv::CAP_PROP_FRAME_WIDTH, 640); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480); cap.set(cv::CAP_PROP_FPS, 30); } if (!cap.isOpened()) { std::cerr 视频源打开失败 std::endl; return -1; }参数上摄像头分辨率不必强求 720p 以上。rPPG 空间平均本来就要聚合大量像素人脸在 640×480 画面里占满四分之一面积时ROI 内仍有几万个像素统计稳定性已经足够。分辨率越高读取、传输、平均耗时越大实时性反而下降。另一个必须记录的量是每帧真实到达时刻double t_ms cap.get(cv::CAP_PROP_POS_MSEC);对视频文件POS_MSEC返回解码层面的毫秒时间戳对实时摄像头它不一定可靠更稳的做法是用std::chrono::steady_clock记录读帧前后的真实时间。后续所有频率换算都基于时间差序列不基于相机标称 FPS。3.3 Haar Cascade 人脸检测与 ROI 裁剪参数这里是整个项目里最花时间的部分。Haar Cascade 在正脸、光线均匀的场景下检出率足够单张 640×480 灰度图上的推理时间在 CPU 上通常小于 3 毫秒。cv::CascadeClassifier face_cascade; if (!face_cascade.load(haarcascade_frontalface_default.xml)) { std::cerr 缺少 haar cascade 文件 std::endl; return -1; } std::vectorcv::Rect faces; face_cascade.detectMultiScale( gray, faces, 1.15, 3, cv::CASCADE_SCALE_IMAGE, cv::Size(80, 80), cv::Size(400, 400));detectMultiScale的参数按上面写即可scaleFactor 1.15 让金字塔层间步长适中检出率和速度平衡minNeighbors 3 表示至少 3 个重叠窗口同时确认候选框才保留太低会误检太高会把侧脸或远处人脸滤掉minSize 80×80 排除了小到没有生理信号价值的面部区域。选面积最大的人脸框再截取上半脸作为 ROIif (faces.empty()) { continue; // 本帧无人脸跳过处理 } int best 0; for (size_t i 1; i faces.size(); i) { if (faces[i].area() faces[best].area()) { best i; } } cv::Rect face faces[best]; cv::Rect roi(face.x face.width * 0.2, face.y face.height * 0.1, face.width * 0.6, face.height * 0.55);ROI 参数含义x 方向内缩 20% 宽度把太阳穴和耳前区域排除y 方向从人脸框上边向下 10% 高度开始截取 55% 高度覆盖前额、眉间到鼻梁中段。这样避开眼睛高光和嘴巴开合同时保留足够大的采样面积。戴眼镜场景可以把起点下移到 0.15牺牲一点额头区域来逃离镜框反光。OpenCV 模块涉及头文件承担工作出错时的表现videoiovideoio.hpp帧读取与时间戳打不开视频、帧率异常objdetectobjdetect.hppCascade 人脸检测一直检不出人脸imgprocimgproc.hpp灰度与通道转换通道顺序错乱corecore.hppMat 运算、DFT崩溃或频谱全零表里的“通道顺序错乱”是最常见的移植问题。cv::imread和cv::VideoCapture读出的都是 BGR 布局RGB 习惯在这里必须反过来写。交付时把haarcascade_frontalface_default.xml、编译后的可执行文件和输入视频放在同一目录避免代码里写死绝对路径。3.4 信号缓存与滑动覆盖策略把每帧绿色通道均值追加到一个定长容器填满时从头部弹出const size_t MAX_SAMPLES 600; struct Sample { double t_ms; double value; }; std::dequeSample signal; signal.push_back({t_ms, mean[1]}); while (signal.size() MAX_SAMPLES) { signal.pop_front(); }MAX_SAMPLES 600 对应 30FPS 下 20 秒窗口。20 秒里 DFT 分辨率约 0.05Hz即 3 BPM能在 45 到 240 的范围内稳定区分相邻心率。这里不用 vector 而用 deque是为了避免头部移除的拷贝开销deque 的前端删除是常数时间。缓存越长频率分辨率越高但 BPM 响应越慢实时测量时需要在两者间找折中。4. 把亮度序列变成心率值DFT、频段截取与 BPM 提取4.1 时域预处理的三个动作拿到亮度序列后不直接上 FFT。先做三件事。第一去均值把直流分量扣掉否则 DC 会成为 DFT 的第 0 个 bin幅度极大且向邻近低频泄漏第二去趋势处理呼吸导致的缓慢基线漂移第三加窗把序列两端压到零附近减少频谱泄漏。double mean_sum 0.0; for (const auto s : signal) { mean_sum s.value; } double mean_val mean_sum / signal.size(); std::vectordouble centered(signal.size()); for (size_t i 0; i signal.size(); i) { centered[i] signal[i].value - mean_val; }去趋势用移动平均估计基线const int W 31; // 移动平均窗口宽度需为奇数 double move_sum 0.0; std::vectordouble trend(signal.size(), 0.0); for (size_t i 0; i signal.size(); i) { move_sum centered[i]; if (i W) move_sum - centered[i - W]; trend[i] move_sum / std::min(i 1, (size_t)W); } for (size_t i 0; i signal.size(); i) { centered[i] - trend[i]; }W 值的选择取决于要剔除的漂移周期。以 30FPS 计31 帧约等于 1 秒移动平均只保留 1 秒以上的缓慢成分心率频率高于 0.75Hz 即 45BPM不会被过度损伤。4.2 加窗为什么决定频谱质量一段长度为 N 的离散信号做 DFT等价于把它当成无限长周期信号展开。如果窗口两端不从平滑过渡开始时间边界会生成虚假频率分量并向两侧泄漏。汉宁窗把首尾幅度压到接近 0让主要成分集中在真实频点附近。std::vectordouble windowed(signal.size()); for (size_t i 0; i signal.size(); i) { double hann 0.5 * (1.0 - std::cos(2.0 * M_PI * i / (signal.size() - 1))); windowed[i] centered[i] * hann; }汉宁窗的主瓣宽度比矩形窗宽频率分辨率略差但对旁瓣的抑制提升约 20dB。对于 rPPG 这种微弱信号这个代价完全值得。心率信号很强时矩形窗也能出结果一旦视频里有明显低频干扰是否加窗的差距就很直观。4.3 用 cv::dft 走完频谱分析和峰值定位做成完整函数会让逻辑更清晰关键部分是对 DFT 结果做 bin 扫描// 输入 windowed采样率 fps_actual int n static_castint(windowed.size()); cv::Mat input(1, n, CV_64F); for (int i 0; i n; i) { input.atdouble(0, i) windowed[i]; } cv::Mat spectrum; cv::dft(input, spectrum, cv::DFT_COMPLEX_OUTPUT); double bin_width fps_actual / n; // 每根谱线对应的频率宽度 int k_min static_castint(0.75 / bin_width); int k_max static_castint(4.0 / bin_width); k_max std::min(k_max, n / 2); double peak_mag -1.0; int peak_bin -1; for (int k k_min; k k_max; k) { double re spectrum.atcv::Vec2d(0, k)[0]; double im spectrum.atcv::Vec2d(0, k)[1]; double mag std::sqrt(re * re im * im); if (mag peak_mag) { peak_mag mag; peak_bin k; } } double heart_rate_bpm peak_bin * bin_width * 60.0;逻辑说明第 k 个 DFT bin 对应的物理频率是 k × bin_widthbin_width 等于实际采样率除以样本数。k_min 从 0.75Hz 对应索引开始避免 DC 分量锁定结果k_max 截断到 4Hz 对应索引排除高频噪声。幅值最大的 bin 就是目标心率频率乘 60 得到 BPM。FPS样本数bin 宽度k_min (0.75Hz)k_max (4.0Hz)心率分辨率303000.10Hz8406 BPM306000.05Hz15803 BPM606000.10Hz8406 BPM6012000.05Hz15803 BPM窗口长 10 秒时频率分辨率只有 6BPM75 和 80 很难区分窗口拉长到 20 秒分辨率提高到 3BPM输出稳定性明显改善。4.4 实际帧率为什么比标称帧率重要如果摄像头标称 30FPS实际平均只有 28.7FPS直接用 30 换算75BPM 的真实心率会被算成约 78.4BPM误差随频率线性放大。正确做法是统计当前窗口内相邻帧时间戳差值的平均值double avg_interval_ms 0.0; for (size_t i 1; i signal.size(); i) { avg_interval_ms (signal[i].t_ms - signal[i - 1].t_ms); } avg_interval_ms / (signal.size() - 1); double fps_actual 1000.0 / avg_interval_ms;这段代码放在 DFT 计算之前。很多项目一开始用标称值也能跑通但换一套采集设备或播放器后结果立刻偏差根源就在这里。5. BPM 不准时先查什么时间轴、窗口、频段边界5.1 按顺序排错的三个检查点先做三个检查能覆盖大部分“测不准”案例。第一确认帧率一致性。有的视频文件实际是 29.97FPS标称写在 30.0连续采 600 帧后时间误差累计到 0.6 秒对应频率偏置约 3%。把第 4.4 节的fps_actual计算打印出来和标称值比对。第二确认窗口长度。低于 8 秒的窗口只有 6BPM 以上的分辨率输出跳动强烈至少让窗口覆盖 15 秒。第三确认搜索上下界。很多人在频域里从第 0 个 bin 开始找峰值DC 分量会锁定结果输出直接变成 0BPM。第三点在代码里就是一行int k_min (int)std::ceil(0.75 / bin_width); int k_max (int)std::floor(4.0 / bin_width);bin_width 30.0 / 600 0.05Hz时k_min15k_max80。任何小于 15 的 bin 都属于 DC 或呼吸频段直接跳过。5.2 关键参数对照表和使用逻辑参数常规取值调整方向对应现象scaleFactor1.10~1.20值越小检出越细、耗时增加漏检时微降minNeighbors3~5值越大误检越少误检时上调ROI 上边界偏移0.10~0.15戴眼镜时下移镜框反光污染FFT 起始频率0.75Hz运动场景提到 0.80Hz低频干扰FFT 终止频率4.0Hz常规测量可降到 3.33Hz结果异常偏低置信度倍率2.0~3.0跳变多时调高BPM 左右横跳这些值的依据不是玄学。0.75Hz 对应 45BPM是成年人静息心率下限之外的安全边际4.0Hz 对应 240BPM已经高于绝大多数运动上限设太高只会把运动伪迹当心率。scaleFactor 每减小 0.05检测耗时大约增加 30%不要盲目调小。5.3 光照突变和眨眼带来的频谱污染光照突变在时域里是尖峰短窗内能量极高傅里叶展开后向周围频点泄漏直接把真正的微小心率峰压下去。处理手段分输入侧和输出侧。输入侧做信号标准化把亮度浮动拉平到同一量纲double m 0.0; for (double v : centered) { m v; } m / centered.size(); double s 0.0; for (double v : centered) { s (v - m) * (v - m); } s std::sqrt(s / centered.size()); for (double v : centered) { v / s; }标准化之后同一套滤波和频谱阈值就能适应早中晚不同光线。输出侧用中位数跳变保护std::vectordouble recent; // 保留最近 5~9 个有效输出 std::sort(recent.begin(), recent.end()); double median recent[recent.size() / 2]; if (std::abs(median - current_bpm) 8.0) { current_bpm median; }这个策略在实时展示场景很有效单次误检不会让数字明显跳变连续两次偏离则说明工况真的发生了变化可以额外打一个信号质量告警。6. 先造一段假心跳再把滑动窗口装进 C 代码6.1 用合成视频验证测量链路不真人脸先造一个已知心率的合成视频。这个方法能把“算法算错”和“采集环境干扰”分开因为在合成流里 ROI 噪声和光照变化都被去掉了。cv::VideoWriter writer(synth_72bpm.avi, cv::VideoWriter::fourcc(M, J, P, G), 30, cv::Size(640, 480)); cv::Mat frame(480, 640, CV_8UC3, cv::Scalar(0, 128, 0)); for (int i 0; i 900; i) { double phase 2.0 * M_PI * 72.0 / 60.0 * (double)i / 30.0; int g static_castint(128 30.0 * std::sin(phase)); frame.setTo(cv::Scalar(0, g, 0)); // 整帧绿色通道按 72BPM 波动 writer.write(frame); } writer.release();读回这个视频时把 ROI 固定成整帧、跳过人脸检测信号处理部分不动。如果读出的心率落在 70 到 74BPM 之间说明滤波、加窗、DFT、频率换算整条链路没有系统性偏置如果偏差超过 3BPM问题出在帧率归一化或 FFT 索引换算上跟相机和光照无关。6.2 滑动窗口实时估计保留最近一次质量好的结果离线帧跑通后把整段分析改成滑窗。窗口长度决定频率分辨率步进决定 BPM 更新频率两者独立设置。const size_t win_len 450; // 15 秒窗口 const size_t step 15; // 0.5 秒更新一次 BPM for (size_t start 0; start win_len raw_signal.size(); start step) { auto it raw_signal.begin() start; std::vectordouble slice(it, it win_len); double hr computeBpmFromSignal(slice, fps_actual); bpm_history.push_back(hr); }15 秒窗口给出约 3BPM 的频率分辨率0.5 秒步进让输出变化平滑。把窗口缩短到 10 秒分辨率变成 6BPMBPM 数字会明显变“碎”把步进增加到 1 秒则输出更稳但实时感下降。接进界面后再把帧率、ROI 偏移、FFT 频段上下限开放成配置文件不同光照条件微调时不用改动源码。这套合成验证加滑窗的组合是上手基于视频心率测量时最值得先落地的两块工作。本文还有配套的精品资源点击获取