话不多说,先看效果
Android通过摄像头测量心率
借鉴文章如下
Android通过摄像头计算心率、心率变异性
该文章的核心功能点已经很全了,为了方便使用,我这边整理成了工具类可直接使用
该功能全网文章还是比较少的,还是要感谢下借鉴文章作者
该文章用法
- 动态申请权限少不了,不仅仅是摄像头权限还有麦克风权限,如果不自己申请,CameraView 内部也会自己判断的
- 首先进行摄像头监听
- 此时打开摄像头
平时的检测的时候都要打开闪光灯,我一开始的思路是单独去打开闪光灯,会报出摄像头被占用的错误,所以只能从使用的摄像头下手,因为用的是 CameraView 经源码勘测,发现打开闪光灯代码如下
3. 在检测过程中会有手指挪开的问题,这里加了个判断,移开五次以上视为检测失效
4. 在界面离开的时候别忘记销毁相机和还原数据
上述就是部分代码功能说明
以下是demo 全部代码
- 添加依赖
implementation 'com.otaliastudios:cameraview:2.7.2'
2.清单文件中添加权限
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FLASHLIGHT" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.autofocus" />
- Activity代码
class MainActivity : AppCompatActivity() {
private lateinit var vwCamera: CameraView
private lateinit var tvTip1: TextView
private lateinit var tvBpm: TextView
private lateinit var vwLine: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
vwCamera = findViewById<CameraView>(R.id.vwCamera)
tvTip1 = findViewById<TextView>(R.id.tvTip1)
tvBpm = findViewById<TextView>(R.id.tvBpm)
vwLine = findViewById<TextView>(R.id.vwLine)
if (checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
vwCamera.setLifecycleOwner(this)
//监听摄像头状态
vwCamera.addCameraListener(object : CameraListener() {
override fun onCameraOpened(options: CameraOptions) {
super.onCameraOpened(options)
//摄像头开启之后,打开闪光灯
vwCamera.flash = Flash.TORCH
}
})
vwCamera.addFrameProcessor {
//每一帧的回调,在这里检查用户是否放上手指,并做计时然后计算心率
HeartRateUtils.handleFrameCamera(vwCamera,it,this,object :HeartRateUtils.HeartRateInterFace{
override fun callTipsBack(tips: String) {
//这里是摄像头是否检测到手指的回调
tvTip1.text = tips
}
override fun callRateBack(rate: String) {
//这里是心率数据回调
tvBpm.text = rate
}
override fun callEndRateBack(end: String) {
//这里是检测结束的回调 end 是最终心率
tvBpm.text = "检测结束: ${end}"
}
override fun callAngleBack(angle: String) {
//这里是手机抖动回调- 看需求是否需要
vwLine.text = "手机抖动:${angle}"
}
})
}
HeartRateUtils.startScanView(this)
} else {
//否则去请求相机权限
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 100);
}
}
override fun onDestroy() {
super.onDestroy()
HeartRateUtils.closeScanView()
}
}
- 布局文件代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
tools:context=".MainActivity">
<com.otaliastudios.cameraview.CameraView
android:id="@+id/vwCamera"
android:layout_width="80dp"
android:layout_height="80dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/tvTip1"
android:layout_marginTop="10dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvBpm"
android:layout_marginTop="10dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/vwLine"
android:layout_marginTop="10dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</androidx.appcompat.widget.LinearLayoutCompat>
- 工具类代码
/**
* 心跳检测数据类*/
object HeartRateUtils {
private var vwCamera: CameraView? = null
private val processing = AtomicBoolean(false)
private var beatBeanTimeStart = 0L
private val averageArraySize = 4
private val averageArray = IntArray(averageArraySize)
//开始时间
private var startTime: Long = 0
//设置默认类型
private var currentType = TYPE.GREEN
private var averageIndex = 0
//心跳脉冲
private var beats = 0f
private var allBeats = 0
private var flag = 1.0
//心跳次数无效次数,默认大于5次就无效了
private var noGet = 0
private var beatBeanTimeList = arrayListOf<Long>()
private val vwLinePos = mutableListOf<Float>()
private val vwLinePosValue = mutableListOf(20f, 0f, -20f, 10f, -10f, 0f)
//心跳数组
private val beatsArray = arrayListOf<Int>()
/**
* 类型枚举
*/
enum class TYPE {
GREEN, RED
}
interface HeartRateInterFace{
fun callTipsBack(
//文本说明
tips: String,
)
fun callRateBack(
//测量中心率数据
rate: String,
)
fun callEndRateBack(
//最终结果心率
end: String,
)
fun callAngleBack(
//手机平稳度
angle: String,
)
}
fun startScanView(activity: AppCompatActivity) {
vwCamera?.open()
}
private fun stopScanView() {
vwCamera?.stopVideo()
}
fun closeScanView(){
noGet = 0
allBeats = 0
beats = 0f
beatsArray.clear()
vwCamera?.close()
vwCamera = null
}
/**
* 处理每一帧的图像,已经在子线程中处理
*/
fun handleFrameCamera(camera: CameraView,frame: Frame,activity: AppCompatActivity,callBack: HeartRateInterFace) {
vwCamera = camera
val size: Size = frame.size
if (frame.dataClass === ByteArray::class.java) {
val data: ByteArray = frame.getData()
//processing是true直接退出这一步流程,初始执行是false,第一步在这里赋值为true了,如果还没执行到后面的流程下一帧就来了的话直接舍弃掉这一帧
if (!processing.compareAndSet(false, true)) return
val width: Int = size.width
val height: Int = size.height
//图像处理
val imgAvg: Int = decodeYUV420SPtoRedAvg(data.clone(), height, width)
//imgAvg小于200表示手指没有覆盖到摄像头,这时候终端整个流程还是说保存流程在一定时间内重启的话就继续
if (imgAvg < 200) {
activity.runOnUiThread {
stopScanView()
callBack.callTipsBack("将手指放在相机镜头和闪光灯上")
}
} else
activity.runOnUiThread {
callBack.callTipsBack("正在检测,请保持手指位置")
}
//像素平均值imgAvg,日志
//Log.i(TAG, "imgAvg=" + imgAvg);
if (imgAvg == 0 || imgAvg == 255) {
//红色像素的均值为0或者255时表示极端不正确的情况,直接退出,并重置标志位为false,下一帧图片再次执行赋值为true
processing.set(false)
beatBeanTimeStart = 0L//如果中途有手指移出等情况就清空上次保存的心跳时间,约等于舍弃掉这次不正常的记录
return
}
//计算4次帧图的像素均值列表值的和与个数
var averageArrayAvg = 0
var averageArrayCnt = 0
for (i in averageArray.indices) {
if (averageArray[i] > 0) {
averageArrayAvg += averageArray[i]
averageArrayCnt++
}
}
//计算整体全部帧图像素平均值
val rollingAverage = if (averageArrayCnt > 0) averageArrayAvg / averageArrayCnt else 0
if (rollingAverage == 0 && imgAvg > 200) {
startTime = System.currentTimeMillis()
}
var newType: TYPE = currentType
//如果当前帧像素平均值小于前4次帧像素平均值的话
if (imgAvg in 201 until rollingAverage) {
newType = TYPE.RED
if (newType != currentType) {
beats++
allBeats++
flag = 0.0
//这里表示心跳了一下,保存与上一次心跳的时间间隔,后续要用作心率变异性计算
if (beatBeanTimeStart == 0L) {
//保存第一次心跳
beatBeanTimeStart = System.currentTimeMillis()
} else {
val nowTime = System.currentTimeMillis()
val rrTime = nowTime - beatBeanTimeStart
if (rrTime in 400..1400)
beatBeanTimeList.add(nowTime - beatBeanTimeStart)
beatBeanTimeStart = nowTime
}
vwLinePos.clear()
activity.runOnUiThread {
vwLinePos.add(20f)
callBack.callAngleBack(vwLinePos.last().toString())
}
}
} else {
//心脏跳动控制六帧,六帧后恢复平静
newType = TYPE.GREEN
activity.runOnUiThread {
if (vwLinePos.size < 6 && allBeats > 0) {
vwLinePos.add(vwLinePosValue[vwLinePos.size])
callBack.callAngleBack(vwLinePos.last().toString())
} else {
vwLinePos.add(0f)
callBack.callAngleBack(vwLinePos.last().toString())
}
}
}
//保存4次帧像素平均值,4次后重置
if (averageIndex == averageArraySize) averageIndex = 0
averageArray[averageIndex] = imgAvg
averageIndex++
// Transitioned from one state to another to the same
if (newType !== currentType) {
currentType = newType
//image.postInvalidate();
}
//获取系统结束时间(ms)
val endTime = System.currentTimeMillis()
val totalTimeInSecs: Float =
(endTime - startTime) / 1000f//当前帧到达的时间减去摄像头初始的时间就等于第一帧的时间差
if (totalTimeInSecs >= 2) {//2秒处理一次
val bps: Float =
beats / totalTimeInSecs//脉冲次数表示当前间隔时间内的心跳次数,心跳次数/心跳持续时间 = 一秒内的心跳次数
val dpm = (bps * 60.0).toInt()//1秒内的心率乘以60等于一分钟内的心率也就是计算出来的心率值
Log.e("心率检测", "time:$totalTimeInSecs,2秒内的心跳次数:$beats, 每秒心跳次数:$bps, 心率:$dpm")
if (dpm < 30 || dpm > 180 || imgAvg < 200) {//这里是心率不合规的情况,心率小于30或者心率大于180或者当前帧像素平均值小于200(手指未正确覆盖),初始化程序继续探查
//获取系统开始时间(ms)
startTime = System.currentTimeMillis()
beatBeanTimeStart = startTime
//beats心跳总数
beats = 0f
processing.set(false)
noGet++
Log.e("心率检测", "此次检测无效${noGet}")
if (noGet > 5){
callBack.callTipsBack("此次检测无效")
vwCamera?.close()
}
return
}
//存储正常心率进心率表,心率表只保存近三次数据,新的数据来会顶掉
beatsArray.add(dpm)
var beatsArrayAvg = 0
var beatsArrayCnt = 0
for (i in beatsArray) {
if (i > 0) {
beatsArrayAvg += i
beatsArrayCnt++
}
}
val beatsAvg = beatsArrayAvg / beatsArrayCnt
activity.runOnUiThread {
if (beatsArray.size < 15) {
callBack.callRateBack(beatsAvg.toString())
}
}
//获取系统时间(ms)
startTime = System.currentTimeMillis()
beats = 0f
//总共记录半分钟或者1分钟的心率变化,半分钟后给结果,就是记录15次,心率数组保存15次的数据后删除。如果想更准确的话就加大记录的心跳数值
if (beatsArray.size == 15) {
vwCamera?.close()
//中断探测,得出结果去结果页
//心率等于一分钟心跳多少次,由于我们两秒钟记录一次所以这里的allBeats*2就应该是一分钟的心跳次数
val bpm = allBeats * 2//心率
Log.e("心率检测", "结束--${bpm}")
activity.runOnUiThread {
callBack.callEndRateBack(bpm.toString())
}
}
}
processing.set(false)
} else if (frame.dataClass === Image::class.java) {
Log.e("心率检测", "camera2帧数据返回")
val data: Image = frame.getData()
// Process android.media.Image...
}
}
/**
* 内部调用的处理图片的方法
*/
private fun decodeYUV420SPtoRedSum(yuv420sp: ByteArray?, width: Int, height: Int): Int {
if (yuv420sp == null) return 0
val frameSize = width * height
var sum = 0
var j = 0
var yp = 0
while (j < height) {
var uvp = frameSize + (j shr 1) * width
var u = 0
var v = 0
var i = 0
while (i < width) {
var y = (0xff and yuv420sp[yp].toInt()) - 16
if (y < 0) y = 0
if (i and 1 == 0) {
v