简介:这份资源围绕摄像头检测血氧的物理与信号原理展开,面向移动健康、远程医疗及生物医学信号处理方向的学习者与开发者,帮助理解如何用普通RGB摄像头近似实现非接触式SpO₂监测。内容从朗伯-比尔定律切入,讲解氧合血红蛋白与还原血红蛋白的吸收差异、心脏搏动引起的动脉血容积周期变化,以及R/G/B通道对660nm、540nm、450nm波长的近似映射,并给出红蓝或红绿双波长模型与R值计算公式。资源包为1个docx文档,约23KB,篇幅紧凑但覆盖完整,包含IPPG信号提取流程(人脸检测、ROI选取、RGB均值序列、0.5–4Hz带通滤波、AC/DC计算与线性标定)及Matlab参考代码,涉及Computer Vision与Signal Processing工具箱的使用。已有94人学习,适合希望快速掌握摄像头血氧测量原理并动手复现的读者,也可作为标定参数拟合与算法改进的入门参考。
1. 用普通 RGB 摄像头测血氧:从朗伯-比尔定律到可跑的 Matlab 离线流程
手边只有一颗普通摄像头,却想拿到血氧饱和度(SpO₂)的估计值,这件事在原理上并非玄学。核心依据是朗伯-比尔定律:氧合血红蛋白(HbO₂)与还原血红蛋白(Hb)对可见-近红外光的吸收系数不同,心脏搏动时动脉血容积周期性变化,皮肤反射光强随之周期性起伏。普通摄像头没有红外通道,但 R 通道约 660 nm、G 通道约 540 nm、B 通道约 450 nm,可以近似搭出「红-蓝」或「红-绿」双波长模型,套用传统血氧仪的 R/IR 比值公式。这套资源给的就是一条从视频文件到 SpO₂ 曲线的完整 Matlab 离线链路,适合做移动健康、非接触生理监测的从业者拿来验证和二次开发。
2. 物理模型与信号链路:为什么 R 通道能替代红外
2.1 朗伯-比尔定律与双波长比值
光穿过含吸收物质的介质时,透射光强按指数衰减,吸收程度与物质浓度、光程长度成正比。血液里 HbO₂ 和 Hb 的吸收谱不同:HbO₂ 对红光吸收少、对红外吸收多,Hb 反过来。传统血氧仪用红光(约 660 nm)和红外光(约 940 nm)两路,算出一个比值 R,再线性映射到 SpO₂。
摄像头没有红外通道,但 R 通道中心波长接近 660 nm,B 通道约 450 nm、G 通道约 540 nm,虽然都不是理想的红外参考波长,但作为「另一路」参与比值运算,仍能捕捉到脉搏引起的吸收变化差异。常见做法是用红-蓝组合,因为蓝通道对血红蛋白吸收变化的响应与红光形成足够对比。公式写成:
R = (ΔR/R) / (ΔB/B)
其中 ΔR、ΔB 是脉搏波的交流分量(AC),R、B 是直流分量(DC)。这个 R 值本身没有生理单位,必须靠同步的指夹式血氧仪数据做线性标定,才能变成 SpO₂ 百分比。
2.2 IPPG 信号提取的五个环节
从视频到 SpO₂,中间是一条标准的 IPPG(成像式光电容积描记)流水线:
- 人脸检测,锁定 ROI(额头或脸颊);
- 每帧在 ROI 内取 RGB 均值,形成三条时间序列;
- 0.5–4 Hz 带通滤波,只保留心跳频段;
- 滑动窗内计算 AC = std(filtered),DC = mean(raw);
- 算 R 值,线性标定 SpO₂ = A·R + B。
这五步里,ROI 选得稳不稳、滤波频段对不对、AC/DC 窗口长度合不合适,直接决定最后曲线能不能看。额头区域受头发和表情影响小,是常见首选;脸颊信号强但容易被说话、咀嚼干扰。
2.3 为什么选 Matlab 而不是 Python
这套参考代码用 Matlab 实现,依赖 Computer Vision Toolbox 的人脸检测器和 Signal Processing Toolbox 的滤波器设计。对已经装了 Matlab 的团队,vision.CascadeObjectDetector和butter/filtfilt开箱即用,省去自己搭检测器和滤波器的时间。如果团队主力是 Python,OpenCV 的 Haar 级联加 SciPy 的butter/filtfilt也能平移,但本文以 Matlab 版本为准,因为资源本身给的就是这套。
3. 逐帧 ROI 提取与信号预处理:把视频变成三条时间序列
3.1 初始化参数与滤波器设计
先定视频文件、滑动窗长度、帧率,再设计 0.5–4 Hz 带通滤波器。帧率必须和实际视频一致,否则滤波频段会整体偏移。
%% 1. 参数与初始化 videoFile = 'face.avi'; % 输入视频 winSec = 8; % 滑动窗长度(s) fs = 30; % 视频帧率 [b,a] = butter(3,[0.5 4]/(fs/2),'bandpass'); % 0.5-4 Hz 带通 faceDet = vision.CascadeObjectDetector(); % 人脸检测器 vidReader = VideoReader(videoFile); % 预分配 nFrames = vidReader.NumFrames; sigR = zeros(nFrames,1); % Red channel sigG = zeros(nFrames,1); % Green channel sigB = zeros(nFrames,1); % Blue channelwinSec = 8是滑动窗长度,8 秒在 30 fps 下约 240 帧,足够覆盖多个心跳周期,又不会把非平稳段混进来。butter(3, ...)是三阶巴特沃斯带通,通带 0.5–4 Hz 对应 30–240 次/分,覆盖正常心率范围。fs/2是奈奎斯特频率,归一化后才能传给butter。vision.CascadeObjectDetector默认用 Viola-Jones 级联,对正面人脸够用,侧脸和遮挡会漏检。
3.2 逐帧读取与 ROI 均值提取
每帧读进来先做 BGR 到 RGB 的通道翻转,再检测人脸,取额头区域(人脸框上半 1/3)算 RGB 均值。
%% 2. 逐帧提取 ROI 均值 for k = 1:nFrames img = readFrame(vidReader); % BGR → RGB img = img(:,:,[3 2 1]); % 检测人脸 bbox = step(faceDet, img); if isempty(bbox) continue; % 没检测到人脸,用上一帧数据 end % 取额头区域(简化:人脸框上半 1/3) x = bbox(1); y = bbox(2); w = bbox(3); h = bbox(4); roi = imcrop(img, [x y+0.1*h w 0.25*h]); sigR(k) = mean(roi(:,:,1),'all'); sigG(k) = mean(roi(:,:,2),'all'); sigB(k) = mean(roi(:,:,3),'all'); endimg(:,:,[3 2 1])是因为readFrame返回的通道顺序是 BGR,而后续按 RGB 索引。bbox是[x y w h],y+0.1*h往下偏 10% 避开眉毛,0.25*h高度取额头带。mean(...,'all')对 ROI 内所有像素求均值,得到该帧的 R/G/B 标量。漏检时continue会留下零值,后面用valid掩码剔除。
3.3 去趋势与带通滤波
原始均值序列含低频漂移(光照渐变、缓慢移动),先detrend去掉线性趋势,再filtfilt零相位滤波,避免相位失真。
%% 3. 信号预处理 % 去除零值 valid = sigR>0 & sigG>0 & sigB>0; t = (0:nFrames-1)'/fs; sigR = sigR(valid); sigG = sigG(valid); sigB = sigB(valid); t = t(valid); % 去趋势 sigR = detrend(sigR); sigG = detrend(sigG); sigB = detrend(sigB); % 带通滤波 filtR = filtfilt(b,a,sigR); filtB = filtfilt(b,a,sigB); % 使用红+蓝通道valid同时要求三通道都大于零,避免漏检帧污染。detrend默认去线性趋势,对缓慢光照变化有效。filtfilt前后各滤一次,相位抵消,适合离线处理;实时场景只能用filter,但会有相位延迟。这里只用红和蓝通道算 R,绿通道留着做备用或质量评估。
4. 滑动窗 AC/DC 与 R 序列:参数怎么定、异常怎么剔
4.1 滑动窗计算 AC 与 DC
AC 用滤波后信号的标准差,DC 用原始信号的均值,窗口以当前帧为中心前后各取一半。
%% 4. 计算 AC/DC 与 R 序列 win = round(winSec*fs); half = floor(win/2); len = length(filtR); Rseq = nan(len,1); for k = half+1 : len-half idx = k-half : k+half; AC_R = std(filtR(idx)); DC_R = mean(sigR(idx)); AC_B = std(filtB(idx)); DC_B = mean(sigB(idx)); Rseq(k) = (AC_R/DC_R)/(AC_B/DC_B); endwin = round(winSec*fs)把秒换成帧数,8 秒 30 fps 得 240 帧。half是半窗,循环从half+1到len-half,保证每个窗口都完整。std(filtR(idx))是 AC,mean(sigR(idx))是 DC。R 序列两端是NaN,因为边界窗口不完整。窗口太短 AC 估计噪声大,太长会平滑掉血氧的快速变化,8 秒是常见折中。
4.2 线性标定与异常剔除
R 值本身不是血氧,必须用同步指夹仪数据拟合 A、B。资源里给的 A=-25、B=110 只是示例,直接拿来用会翻车。
%% 5. 血氧标定(示例:先用同步指夹仪数据拟合 A,B) % 假设已拟合:A = -25.0; B = 110.0 A = -25.0; % 需用最小二乘重新标定 B = 110.0; SpO2 = A*Rseq + B; SpO2(SpO2<70 | SpO2>100) = NaN; % 剔除异常A*Rseq + B是线性映射,SpO2<70 | SpO2>100把生理上不可能的段置NaN,避免曲线被离群点拉飞。标定参数必须自己拟合,下面单独讲。
4.3 结果可视化与均值输出
三段子图分别看原始 ROI 均值、滤波后波形、SpO₂ 曲线,最后打印整段均值。
%% 6. 结果可视化 figure; subplot(3,1,1); plot(t,sigR,'r',t,sigB,'b'); title('原始 ROI 均值'); subplot(3,1,2); plot(t,filtR,'r'); hold on; plot(t,filtB,'b'); title('带通滤波后'); subplot(3,1,3); plot(t,SpO2,'k'); ylim([70 100]); ylabel('SpO₂ (%)'); xlabel('Time (s)'); title('非接触估计血氧饱和度'); meanSpO2 = nanmean(SpO2); fprintf('整段视频平均 SpO₂ = %.1f %%\n', meanSpO2);nanmean忽略NaN求均值,比mean安全。ylim([70 100])固定纵轴,方便多段视频对比。如果第二段滤波后波形看不出周期性,说明 ROI 或光照有问题,先回去查采集,不要硬看 SpO₂。
5. 标定参数 A、B 怎么拿:同步录制与最小二乘拟合
5.1 同步采集视频与指夹仪数据
标定是这套流程里最容易糊弄、也最不能糊弄的一步。需要同时录两路:摄像头视频(算出 Rseq)和医用指夹式血氧仪(得到真实 SpO₂_ref)。两者时间要对齐,常见做法是录视频时让指夹仪读数出现在画面里,或者用同一台电脑打时间戳。
采集时建议固定 30 fps、关闭自动曝光、锁定白平衡。自动曝光会让 DC 分量随光照漂移,白平衡漂移会让 R/B 通道比例失真,这两个是标定不准的头号原因。ROI 可以改成「脸颊+额头」拼接,提高信噪比;光照变化剧烈时,先用 CHROM 或 POS 算法做颜色归一化再进后续流程。
5.2 最小二乘拟合 A、B
对齐时间后,把 Rseq 和 SpO₂_ref 配对,用一次多项式拟合。
% 假设 SpO₂_ref 与 Rseq 已对齐 p = polyfit(Rseq, SpO2_ref, 1); % 线性拟合 A = p(1); B = p(2);polyfit返回一次项和常数项,p(1)是斜率 A,p(2)是截距 B。拟合前要剔除NaN和明显离群点,否则斜率会被拉偏。拟合完把 A、B 代回第 4 章的SpO2 = A*Rseq + B,再跑一遍看曲线是否落在合理区间。如果拟合出的 A 是正数,说明 R 和 SpO₂ 的相关方向反了,大概率是通道选错或时间没对齐。
提示:标定参数和采集设备、光照、ROI 强相关,换一套环境就要重新拟合,不要跨设备复用。
6. 避坑与排查:五条血泪经验
6.1 现象:SpO₂ 曲线全程贴 100 或贴 70
原因:标定参数 A、B 直接用了示例值,没有用自己的同步数据拟合。示例 A=-25、B=110 只是占位,和你的摄像头、光照、ROI 都不匹配。
解决:按第 5 章流程重新采集同步数据,polyfit出新的 A、B,再代回。拟合前确认 Rseq 和 SpO₂_ref 时间对齐,否则拟合出来的斜率没有物理意义。
6.2 现象:滤波后波形看不出周期性,像随机噪声
原因:ROI 选到了头发、背景或运动区域,RGB 均值里没有脉搏成分;或者帧率和fs不一致,带通频段整体偏移。
解决:先确认fs和视频实际帧率一致,VideoReader的FrameRate属性可以读出来核对。再把 ROI 可视化出来看是不是落在额头。如果 ROI 没问题,检查光照是否稳定,自动曝光是否关闭。
6.3 现象:R 序列大量 NaN,有效段很短
原因:人脸检测频繁漏检,valid掩码把大量帧剔掉;或者视频里人脸转动、遮挡太多。
解决:换更稳的检测器,或者对漏检帧做插值而不是直接丢。ROI 可以放宽到整张脸再取上半部分。采集时让被拍者尽量保持正面、少动。
6.4 现象:不同视频之间 SpO₂ 均值差很多
原因:每段视频的光照、白平衡、曝光都不同,DC 分量变化直接影响 R 值,而 A、B 是在某一组条件下拟合的。
解决:要么每段视频单独标定,要么在预处理阶段加颜色归一化(CHROM/POS),把光照引起的共模变化压掉。跨视频比较前先看原始 ROI 均值的量级是否接近。
6.5 现象:代码跑通但结果没有生理意义
原因:把教学验证代码当成了临床工具。资源本身写明「代码仅作教学验证,临床使用需通过 IEC 80601-2-61 标准测试」。
解决:明确这套流程的定位是算法验证和原型开发,输出只能做趋势参考,不能用于诊断。要做产品化,标定流程、ROI 稳定性、运动伪影抑制、标准符合性测试都得单独补。
7. 进阶:把离线流程改成可复用函数并做质量评估
跑通单视频之后,我一般会把整段逻辑封成函数,输入视频路径和标定参数,输出 SpO₂ 序列和质量指标。这样换视频只改参数,不用每次复制粘贴。下面是一个可复用的骨架:
function [t, SpO2, quality] = estimateSpO2(videoFile, A, B, fs, winSec) % 输入:视频路径、标定参数 A/B、帧率、滑动窗长度 % 输出:时间轴、SpO2 序列、质量指标 [b,a] = butter(3,[0.5 4]/(fs/2),'bandpass'); faceDet = vision.CascadeObjectDetector(); vidReader = VideoReader(videoFile); nFrames = vidReader.NumFrames; sigR = zeros(nFrames,1); sigB = zeros(nFrames,1); for k = 1:nFrames img = readFrame(vidReader); img = img(:,:,[3 2 1]); bbox = step(faceDet, img); if isempty(bbox), continue; end x = bbox(1); y = bbox(2); w = bbox(3); h = bbox(4); roi = imcrop(img, [x y+0.1*h w 0.25*h]); sigR(k) = mean(roi(:,:,1),'all'); sigB(k) = mean(roi(:,:,3),'all'); end valid = sigR>0 & sigB>0; t = (0:nFrames-1)'/fs; t = t(valid); sigR = detrend(sigR(valid)); sigB = detrend(sigB(valid)); filtR = filtfilt(b,a,sigR); filtB = filtfilt(b,a,sigB); win = round(winSec*fs); half = floor(win/2); len = length(filtR); Rseq = nan(len,1); for k = half+1 : len-half idx = k-half : k+half; AC_R = std(filtR(idx)); DC_R = mean(sigR(idx)); AC_B = std(filtB(idx)); DC_B = mean(sigB(idx)); Rseq(k) = (AC_R/DC_R)/(AC_B/DC_B); end SpO2 = A*Rseq + B; SpO2(SpO2<70 | SpO2>100) = NaN; % 质量指标:有效帧占比 + 滤波后红蓝通道相关性 quality.validRatio = sum(valid)/nFrames; quality.corrRB = corr(filtR, filtB, 'rows', 'complete'); endquality.validRatio是有效帧占比,低于 0.7 说明检测或采集有问题。quality.corrRB是滤波后红蓝通道的相关系数,脉搏成分强时两通道应该高度相关,低于 0.3 基本可以判定这段数据不可用。这两个指标比单看 SpO₂ 曲线更早暴露问题。
验证方法上,我习惯拿一段已知指夹仪读数的视频做回归:把函数输出的 SpO₂ 均值和指夹仪均值比,误差在 ±3% 以内算可接受,超过就回去查标定和 ROI。参数边界方面,winSec低于 4 秒 AC 估计抖动明显,高于 12 秒会平滑掉真实变化;fs低于 15 fps 时 4 Hz 上限接近奈奎斯特,滤波会失真,建议至少 30 fps。
从那以后我每次换采集环境,都强制先跑一遍同步标定和quality检查,两个指标不过关就不看 SpO₂ 曲线。希望帮到你。
本文还有配套的精品资源,点击获取