☰
Android模拟实现心率检测:基于摄像头与PPG的信号处理全解析
2026/10/15 14:53:02 网站建设 项目流程

简介:面向Android开发者的心率检测应用源码包,适合学习传感器接入、健康监测界面搭建与数据处理的进阶人群。资源内含完整Eclipse工程,可导入开发环境直接运行,共53个文件,涵盖Java业务逻辑、XML界面布局、achartengine图表库与android-support-v4.jar依赖库、编译生成的APK与DEX字节码等,压缩包仅1.73MB,结构紧凑便于对照研读。已有1896人学习下载。代码演示了如何通过SensorManager注册光学心率传感器监听,实时读取原始物理数据,并采用滑动平均或低通滤波去除噪声;结合光度变化与脉搏时间间隔计算心率值,再用自定义View或第三方图表将趋势可视化。项目同时展示了使用Handler异步处理传感器数据、动态申请BODY_SENSORS权限、通过onSaveInstanceState保存检测状态,以及通过限制采样频率降低功耗等工程化实践,适合想掌握Android传感器应用完整开发链路的开发者参考。

1. 先看明白:Android 真要测心率,靠的其实是摄像头和算法

打开 Android 的传感器列表,你会发现并没有一个叫“心率传感器”的标准硬件接口,绝大多数手机靠的是 Camera 加光电容积描记(PPG)原理:手指按住摄像头和补光灯,血液流量的周期性变化会让指腹透光率产生微小波动,摄像头捕捉到这一串亮度信号后,再用算法还原成心率数值。所谓的“Android 模拟实现检测心率变化的应用源代码”,指的就是一套把摄像头取帧、信号预处理、心率计算、UI 展示串起来的完整工程,核心不在界面,而在信号处理链路。

这套方案适合三类人:一是正在做健康类 App 预研的 Android 开发者,先拿它验证 PPG 这条路是否可行;二是做毕业设计或课程项目的学生,需要一套能现场演示、能测出真实心率的 Demo;三是想了解传感器数据怎么变成业务指标的初学者——心率检测是个极好的“原始数据 → 信号处理 → 结果”案例。先说结论:这条路完全走得通,但别指望它达到医疗级精度,它在安静场景下能做到接近医用脉搏血氧仪的误差范围,稍微晃动就飘,这个特性决定了它的代码结构和坑都藏在哪里。

2. 方案选型与原理:为什么是 Camera,以及心率信号长什么样

2.1 PPG 信号的采集原理:摄像头看到的是“透光率波动”而不是脉搏

用摄像头测心率的基础是光电容积描记,简称 PPG。心脏每搏动一次,血管里的血量会发生周期性变化,指腹组织的血液容量也同步变化,导致手指对光线的吸收和反射产生波动。手指按住摄像头镜头时,补光灯(通常是 LED 闪光灯)照亮指腹,一部分光透过组织进入摄像头传感器,传感器读到的红色通道亮度就会随心跳轻微起伏。这个起伏很小,通常只有原始亮度值的 1% 到 3%,肉眼几乎看不出来,但通过连续取帧和数值分析就能提取出来。

实现时不需要 Camera2 的复杂管线,直接用 Camera 或 CameraX 的预览帧回调就能拿到数据。关键是别去处理整张图片,只取画面中心一小块区域的红色通道均值,既减少计算量,又因为手指正好盖住镜头中心而让信号更干净。常见做法是每次预览回调时截取一个 64×64 或 100×100 的 ROI,然后对这个 ROI 的所有像素做平均,得到一个标量值,再把这个值和对应的时间戳存下来。

// CameraX 的 ImageAnalysis.Analyzer 回调中提取 PPG 信号 @Override public void analyze(ImageProxy image) { ByteBuffer buffer = image.getPlanes()[0].getBuffer(); byte[] bytes = new byte[buffer.remaining()]; buffer.get(bytes); // 假设拿到的是 YUV_420_888 格式,这里转成 NV21 再取 ROI ImageConverter.convertYUVToNV21(image, bytes); int width = image.getWidth(); int height = image.getHeight(); int roiSize = 100; int startX = (width - roiSize) / 2; int startY = (height - roiSize) / 2; double redSum = 0; int pixelCount = 0; // NV21 中 R 分量需要从 YUV 反算,这一步通常用查表优化 for (int y = startY; y < startY + roiSize; y++) { for (int x = startX; x < startX + roiSize; x++) { int yIndex = y * width + x; int uvIndex = width * height + (y / 2) * width + (x / 2) * 2; int Y = bytes[yIndex] & 0xFF; int U = bytes[uvIndex] & 0xFF; int V = bytes[uvIndex + 1] & 0xFF; int R = (int) (Y + 1.402 * (V - 128)); redSum += R; pixelCount++; } } double redMean = redSum / pixelCount; signalBuffer.add(new PpgSample(System.currentTimeMillis(), redMean)); }

代码里的 YUV 转 RGB 是只做红色通道的单点计算,不是完整图像转换,性能开销很低。ROI 取画面中心是因为手指按住镜头时,中心区域的光照最均匀,边缘容易出现漏光和阴影干扰。signalBuffer 是一个带时间戳的采样列表,后续的滤波和心率计算都从这个缓冲区取数。

2.2 心率恢复的整条链路:从原始信号到每分钟心跳次数

从原始亮度序列到心率值,中间隔着四步:去趋势、带通滤波、峰值检测或频谱分析、平滑输出。原始信号里既有低频漂移(手指压力变化、环境光缓慢变化),也有高频噪声(摄像头自动曝光调整、手抖),心率信号本身落在 0.8Hz 到 3Hz 之间,对应 48 到 180 次/分钟,所以用一个带通滤波器就能把有效成分拎出来。

实际工程里最常用的是 FIR 带通滤波器加滑动窗口频谱分析。FIR 滤波器相位线性、不会把信号波形扭歪,对峰值检测友好;频谱分析则用 FFT 把最近 8 秒的窗口数据变成频率谱,找到能量最高的频率点,乘以 60 就是当前心率。窗口太短频率分辨率不够,太长则响应迟钝,8 秒是我试下来准确性跟实时性的平衡点。

// 对滑动窗口数据做 FFT,找主频并换算成心率 public double computeHeartRate(List<Double> signal, double sampleRate) { int n = signal.size(); // 补零到 2 的幂,FFT 库要求长度满足条件 int padded = 1; while (padded < n) { padded <<= 1; } double[] real = new double[padded]; double[] imag = new double[padded]; for (int i = 0; i < n; i++) { real[i] = signal.get(i) - signal.stream().mapToDouble(Double::doubleValue).average().orElse(0); } Fft.transform(real, imag); // 使用 JTransforms 等库 int maxIndex = -1; double maxMagnitude = 0; int lowBin = (int) (48.0 / 60.0 * padded / sampleRate); int highBin = (int) (180.0 / 60.0 * padded / sampleRate); for (int i = lowBin; i <= highBin && i < padded / 2; i++) { double magnitude = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]); if (magnitude > maxMagnitude) { maxMagnitude = magnitude; maxIndex = i; } } if (maxIndex <= 0) { return -1; } double freq = (double) maxIndex * sampleRate / padded; return freq * 60; }

代码里先做去均值,相当于去掉直流分量,否则 FFT 结果里 0Hz 的巨大能量会压过真实心率峰。lowBin 和 highBin 把搜索范围限制在 48 到 180 BPM,既排除低频漂移干扰,也避免把高频噪声当成心率。这个实现的问题是相邻频点间分辨率有限,心率可能落在两个 bin 之间,实际输出会跳变,所以后面通常还会接一个滑动平均或中值滤波来平滑。

3. 搭一个能跑的工程:从 CameraX 采集到心率曲线展示

3.1 工程骨架与依赖选择:CameraX、FFT 库、图表库怎么配

我一般用 CameraX 而不是 Camera2,因为生命周期绑定和预览回调都省心,ImageAnalysis 的背压策略也能避免回调堆积。FFT 用 JTransforms,纯 Java 实现、体积小、不需要额外权限。图表展示可以用 MPAndroidChart 或者干脆自己用 View 画折线,心率曲线这种低频数据自己画反而更灵活,还少一个依赖。

模块划分上,建议拆成采集模块(CameraX 配置和帧回调)、信号处理模块(滤波和心率算法)、UI 模块(实时曲线和历史记录),三个模块之间用接口或 LiveData 通信。这样做的原因是信号处理的调参频率远高于 UI,隔离开之后改滤波参数不至于牵动整个 Activity。

dependencies { implementation("androidx.camera:camera-camera2:1.3.1") implementation("androidx.camera:camera-lifecycle:1.3.1") implementation("androidx.camera:camera-view:1.3.1") implementation("com.github.haifengl:smile-core:3.0.3") // 含 JTransforms FFT implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") }

这里用 smile-core 是因为它把 FFT 和部分滤波工具打包在一起,省得单独引 JTransforms 再配一堆传递依赖。如果项目已经很大,也可以只引com.github.wendykierp:JTransforms:3.1,更轻。CameraX 版本建议跟着 AndroidX 的稳定版本走,不要用 alpha 版,预览回调的接口在 alpha 阶段变过几次,照着旧博客写容易踩版本坑。

3.2 CameraX 的 ImageAnalysis 配置与帧率控制

心率检测对帧率有硬性要求,采样率至少要 20fps,否则 FFT 的频率分辨率不够。CameraX 的 ImageAnalysis 可以设置目标帧率,但实际帧率取决于设备性能,所以代码里要记录真实采样率,而不是假设 30fps。

ImageAnalysis imageAnalysis = new ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build(); imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor(), new HeartRateAnalyzer());

STRATEGY_KEEP_ONLY_LATEST 表示分析器来不及处理时丢弃中间帧,只保留最新一帧,这样心率计算的输入始终是新鲜数据,不会因为处理慢而堆积延迟。用单线程执行器是防止多帧并发处理导致采样时间戳乱序。YUV_420_888 是 Android 上兼容性最好的输出格式,几乎不需要额外转换。

采样率不稳定是这个方案最常见的坑之一。低端设备上 ImageAnalysis 实际只有 15fps,如果代码里硬编码 30fps 去算频率,结果会系统性偏高。解决方法是维护一个长度为 n 的采样时间戳数组,每次 FFT 前用n / (timestamp[n-1] - timestamp[0])估算真实帧率,这样即使掉帧也能保持频率换算准确。

3.3 手指覆盖检测:不做这一步,数值会乱跳

很多人第一次跑通代码会发现心率值在 30 到 200 之间疯狂跳动,原因不是算法错了,而是手指没盖好镜头。手指没有完全遮住摄像头时,环境光会大量进入画面,信号信噪比骤降,频谱里全是噪声峰。所以正式测量前一定要加覆盖检测:计算 ROI 区域的红色通道均值,如果低于某个阈值(比如 50),就提示用户“请用手指完全遮住摄像头”。

另一个指标是信号方差。手指稳定时亮度信号的方差处在一个合理的窄区间内,太高说明手指在动,太低说明信号饱和或手指压得过死。实际经验值是红色均值在 80 到 200 之间、方差在 1 到 20 之间算正常,具体阈值要拿两三台不同设备实测后定,不同品牌手机摄像头的色彩响应差异很大。

public interface FingerStatusListener { void onStatusChanged(FingerStatus status); } public enum FingerStatus { NOT_COVERED, // 未覆盖或覆盖不严 TOO_MOVING, // 信号抖动过大 READY // 可以开始测量 } // 在 Analyzer 中判断状态 private FingerStatus checkFingerStatus(double redMean, double variance) { if (redMean < 50) { return FingerStatus.NOT_COVERED; } if (variance > 20) { return FingerStatus.TOO_MOVING; } return FingerStatus.READY; }

把状态判断放进分析器里,状态变化通过回调通知 UI,而不是在 Analyzer 里直接改 UI,避免跨线程崩溃。实测中,NOT_COVERED 状态最常被触发,因为很多用户习惯把手指竖着放而不是横着覆盖整个镜头模组;提示文案要说明“用指腹横向覆盖摄像头和闪光灯之间的区域”,比“请按住镜头”有效得多。

4. 核心算法细节:滤波参数、窗口长度和心率平滑的处理

4.1 FIR 带通滤波器的参数选择:48 到 180 BPM 是怎么定下来的

原始信号进入 FFT 之前,先做一次带通滤波会显著改善频谱质量。前面提到过,心率的物理范围是 48 到 180 次/分钟,对应频率 0.8Hz 到 3Hz。但滤波器不能只滤到这个范围,因为 FFT 窗口边缘的泄漏效应会把窗口外的能量“抹”到窗口内,所以实际设计通带要稍宽一点,通常设为 0.7Hz 到 4Hz,过渡带留给噪声。

FIR 滤波器的阶数要看采样率。采样率 25fps、通带 4Hz 时,采样率与通带之比约为 6,这要求滤波器阶数至少 32 以上才有可用的过渡带陡峭度。阶数越高滤波越干净,但计算量也上去了,在低端设备上 128 阶 FIR 对每帧数据做滤波会卡顿,因为每产生一个输出点需要 128 次乘加运算。

// 生成 FIR 带通滤波器系数,窗口法设计 public class BandpassFilter { private double[] coefficients; private double[] buffer; public BandpassFilter(int order, double sampleRate, double lowFreq, double highFreq) { coefficients = new double[order + 1]; buffer = new double[order + 1]; double normalizedLow = lowFreq / sampleRate; double normalizedHigh = highFreq / sampleRate; for (int i = 0; i <= order; i++) { double n = i - order / 2.0; if (n == 0) { coefficients[i] = 2 * (normalizedHigh - normalizedLow); } else { coefficients[i] = (Math.sin(2 * Math.PI * normalizedHigh * n) - Math.sin(2 * Math.PI * normalizedLow * n)) / (Math.PI * n); } // 海明窗加窗,抑制吉布斯现象 double window = 0.54 - 0.46 * Math.cos(2 * Math.PI * i / order); coefficients[i] *= window; } } public double filter(double input) { System.arraycopy(buffer, 0, buffer, 1, buffer.length - 1); buffer[0] = input; double output = 0; for (int i = 0; i < coefficients.length; i++) { output += coefficients[i] * buffer[i]; } return output; } }

海明窗是 FIR 设计的默认选择,主瓣宽度和旁瓣衰减比较均衡。实际调试时最常改的两个参数是低通频率和高通频率,如果把 highFreq 从 4Hz 降到 3.5Hz,能减少一部分肌肉抖动引入的高频干扰,但代价是心率上限可能被削到 210 BPM 附近,对运动场景不友好。我一般保持通带到 4Hz,靠后端的频谱搜索范围来限制输出。

4.2 滑动窗口长度:为什么 8 秒是性价比最高的选择

FFT 的输入窗口长度直接影响两个东西:时间分辨率和频率分辨率。采样率 25fps、窗口 8 秒时,有效数据点数为 200,补零到 256 后频率分辨率约为 0.098Hz,对应 5.86 BPM,这个精度足以区分相邻两个整数心率值。窗口降到 4 秒时分辨率变为 11.7 BPM,心率 75 和 80 在频谱上几乎重叠,输出值会来回跳。

窗口加长到 12 秒,频率分辨率提升到约 3.9 BPM,但实时性变差:用户心率从 70 升到 100 时,旧数据会拖慢响应,大约要 6 到 8 秒才能跟上来。8 秒窗口在实时性和精度之间取的平衡,也正是大多数商用心率 App 采用的时长。

还有一个容易忽略的细节:窗口内的采样点要尽量等间隔,否则 FFT 会把非均匀采样造成的伪频率当主峰。如果设备掉帧严重,我一般会在采集阶段做一次时间戳校验,连续两帧间隔超过 150ms 就直接丢弃这一帧并标记空隙,计算前用线性插值把空缺补齐。这样比直接对原始不均匀序列做 FFT 可靠得多。

4.3 心率平滑策略:中值滤波比滑动平均更抗躁点

FFT 输出的心率值即使算法正确,显示在界面上也常常有 ±3 BPM 的跳动,这是因为呼吸、手指微动会让频谱主峰在几个相邻频点间游走。直接显示原始计算值,用户看到的是不断跳动的数字,体验很差。常见的做法是对连续输出的心率值做平滑,但滑动平均有个问题:一次剧烈干扰产生的离群值会污染接下来好几个输出。

我习惯用窗口长度为 5 的中值滤波。中值滤波选的是窗口内排序后的中间值,单个离群值会被直接丢弃,对心率这种时不时被干扰的信号效果明显好于均值。缺点是对持续性的心率变化响应稍慢,但心率本身就是慢变量,这种滞后完全可以接受。

public class HeartRateSmoother { private final int windowSize; private final LinkedList<Integer> history = new LinkedList<>(); public HeartRateSmoother(int windowSize) { this.windowSize = windowSize; } public int smooth(int newValue) { if (newValue <= 0) { return history.isEmpty() ? 0 : history.getLast(); } history.addLast(newValue); if (history.size() > windowSize) { history.removeFirst(); } List<Integer> sorted = new ArrayList<>(history); Collections.sort(sorted); return sorted.get(sorted.size() / 2); } }

注意上面对非法值(0 或负数)的处理是直接沿用上一次结果,而不是把异常值塞进窗口。这样当手指短暂离开、算法返回 -1 时,UI 上的数字不会瞬间跳到 0。中值窗口里攒够 5 个有效值之前,输出会偏保守,首次测量的前 2 秒数值会有一点滞后,但比起跳动的数字,这点滞后完全值得。

5. 实战避坑:从生理假象到设备差异的 5 个典型问题

5.1 镜头附近发热导致信号漂移

现象:手机连续测量 3 到 5 分钟后,心率值开始缓缓上漂,有时甚至会从 70 漂到 85,但用户实际心率并没有变化。原因分析:补光灯持续点亮让摄像头模组附近温度上升,CMOS 传感器在温度升高后暗电流增加,输出亮度值整体抬升,形成缓慢正漂移。解决:在信号预处理阶段加一个高通滤波器,截止频率设在 0.5Hz 附近,把这种缓慢漂移当成基线变化滤除。我曾在代码里去趋势时把窗口设成 200 帧、每帧减掉窗口内均值,效果比固定高通滤波更好,因为它能适应漂移的非线性。

5.2 自动对焦和自动曝光捣乱

现象:心率曲线每隔几秒出现一个大幅尖刺,FFT 主频被尖刺带偏,输出瞬间跳到 180 再回来。原因分析:CameraX 默认开启自动对焦和自动曝光,手指遮挡后对焦系统会反复寻找焦点,曝光系统每隔几秒重新评估亮度,导致画面整体明暗突变,这种突变在频谱上表现为冲击噪声。解决:在 CameraX 配置里设置对焦模式为 FOCUS_MODE_FIXED,曝光模式为 ExposureMode.INACTIVE 或固定曝光时间,关掉自动调节。代码上就是在 Preview 或 ImageAnalysis 的 CaptureConfig 里对控制点做复用设置。

5.3 不同皮肤颜色的基线差异

现象:同一套代码在肤色较深的用户手上测得的红色通道均值比浅肤色用户低 30% 以上,导致覆盖检测阈值误判为“未覆盖”。原因分析:皮肤中黑色素含量对光的吸收差异极大,深色皮肤透光率低,进入传感器的反射光少。解决:不要用固定阈值判断手指状态,改用自适应策略——在手指刚覆盖镜头的 2 秒内先统计红色均值的均值和方差,以此作为基线,之后的判断跟基线比较而不是跟绝对阈值比较。实测这个改动能让覆盖检测在各类肤色用户上都稳定工作。

5.4 手指按压力度造成的信号幅值变化

现象:用户开始测量时心率正常,过一会儿数字开始频繁跳变,检查代码逻辑没有发现问题。原因分析:手指压得太紧时毛细血管被压扁,血流信号消失,PPG 波形变得平坦;压得太松时环境光漏进镜头,噪声增大。解决:UI 上显示信号质量指示,推荐做法是用最近 2 秒信号的峰峰值除以均值得到一个比值,低于 0.5% 提示“请轻压”,高于 5% 提示“请保持稳定”。峰值计算的代码只在数据更新时做,不做实时全窗口计算,避免无谓耗电。

5.5 后台运行时 CPU 降频导致采样率塌方

现象:连着测量 10 分钟后,心率值开始变得不稳定,打印采样时间戳发现帧率从 25fps 降到了 10fps。原因分析:手机发热后系统限制 CPU 频率,CameraX 分析线程执行变慢,帧回调间隔拉长,FFT 假设的均匀采样不再成立。解决:两件事同时做,一是把分析器里的计算尽量精简,YUV 转换用查表替代浮点运算,滤波器改用定点近似;二是在测量页持续显示并监测真实采样率,低于 15fps 时提示用户休息或降低分辨率。实际上把 ROI 从 100×100 降到 64×64 就能省掉近一半像素运算量,精度损失在可接受范围。

6. 进阶验证与调优:用仿真信号先证明算法没写错

拿到工程后第一件事其实不是拿真手测,而是先用仿真 PPG 信号验证算法链路。生成一路已知频率的正弦波,叠加随机噪声和趋势漂移,喂给心率计算模块,看输出是否等于设定的频率。这一步能隔离问题:如果仿真信号都算不对,就别急着怀疑摄像头;如果仿真通过了但真手测试不准,问题大概率出在采集端。

// 生成 72 BPM 仿真信号,叠加漂移和噪声 public static List<Double> generateSyntheticSignal() { double sampleRate = 25.0; double heartRate = 72.0; // BPM double frequency = heartRate / 60.0; // Hz int durationSeconds = 20; int sampleCount = (int) (sampleRate * durationSeconds); List<Double> signal = new ArrayList<>(); for (int i = 0; i < sampleCount; i++) { double t = i / sampleRate; double pulse = Math.sin(2 * Math.PI * frequency * t); double baselineDrift = 0.02 * Math.sin(2 * Math.PI * 0.1 * t); // 低频漂移 double noise = (Math.random() - 0.5) * 0.05; signal.add(pulse + baselineDrift + noise); } return signal; }

把这段信号直接传给 4.2 节的心率计算函数,要求输出在 70 到 74 BPM 之间算是基本合格。然后逐步降低信噪比,观察心率输出何时开始偏离,这个边界值就是你这套算法能容忍的最差信号质量,后续可以反过来用它标定手指状态的阈值。

最后一个建议:把测量过程中的原始信号和心率结果同时录下来,导出成 CSV 分析。因为真机上的问题大概率是偶发的,没有原始波形就很难定位是采集问题还是算法问题。我养成的习惯是代码里加一个调试开关,打开时把每秒的红色均值、滤波值、FFT 主频全部落盘,出问题时拉一份数据回放,十次有九次能直接看出原因。

心率检测这个方向看着小,但把信号链路走通之后,血压趋势估计、血氧饱和度模拟、呼吸频率检测都是同一套技术栈换参数的事,值得投入。希望帮到你。

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

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

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

立即咨询