Android通过摄像头检测心率

时间:2024-11-30 15:33:48

话不多说,先看效果

Android通过摄像头测量心率

借鉴文章如下
Android通过摄像头计算心率、心率变异性
该文章的核心功能点已经很全了,为了方便使用,我这边整理成了工具类可直接使用

该功能全网文章还是比较少的,还是要感谢下借鉴文章作者
该文章用法

  1. 动态申请权限少不了,不仅仅是摄像头权限还有麦克风权限,如果不自己申请,CameraView 内部也会自己判断的

在这里插入图片描述

  1. 首先进行摄像头监听
    在这里插入图片描述
  2. 此时打开摄像头

在这里插入图片描述

平时的检测的时候都要打开闪光灯,我一开始的思路是单独去打开闪光灯,会报出摄像头被占用的错误,所以只能从使用的摄像头下手,因为用的是 CameraView 经源码勘测,发现打开闪光灯代码如下

在这里插入图片描述
3. 在检测过程中会有手指挪开的问题,这里加了个判断,移开五次以上视为检测失效

在这里插入图片描述
4. 在界面离开的时候别忘记销毁相机和还原数据

在这里插入图片描述

上述就是部分代码功能说明

以下是demo 全部代码

  1. 添加依赖
   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" />
  1. 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()
    }

}
  1. 布局文件代码
<?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>
  1. 工具类代码
/**
 * 心跳检测数据类*/
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