Android通过摄像头检测心率

话不多说,先看效果

Android通过摄像头测量心率

借鉴文章如下
Android通过摄像头计算心率、心率变异性

该文章的核心功能点已经很全了,为了方便使用,我这边整理成了工具类可直接使用

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

该文章用法

  1. 动态申请权限少不了,不仅仅是摄像头权限还有麦克风权限,如果不自己申请,CameraView 内部也会自己判断的
  1. 首先进行摄像头监听
  2. 此时打开摄像头

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

  1. 在检测过程中会有手指挪开的问题,这里加了个判断,移开五次以上视为检测失效
  1. 在界面离开的时候别忘记销毁相机和还原数据

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

以下是demo 全部代码

  1. 添加依赖
java 复制代码
   implementation 'com.otaliastudios:cameraview:2.7.2'

2.清单文件中添加权限

java 复制代码
    <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代码
java 复制代码
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. 布局文件代码
java 复制代码
<?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. 工具类代码
java 复制代码
/**
 * 心跳检测数据类*/
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 = (0xff and yuv420sp[uvp++].toInt()) - 128
                    u = (0xff and yuv420sp[uvp++].toInt()) - 128
                }
                val y1192 = 1192 * y
                var r = y1192 + 1634 * v
                var g = y1192 - 833 * v - 400 * u
                var b = y1192 + 2066 * u
                if (r < 0) r = 0 else if (r > 262143) r = 262143
                if (g < 0) g = 0 else if (g > 262143) g = 262143
                if (b < 0) b = 0 else if (b > 262143) b = 262143
                val pixel = (-0x1000000 or (r shl 6 and 0xff0000)
                        or (g shr 2 and 0xff00) or (b shr 10 and 0xff))
                val red = pixel shr 16 and 0xff
                sum += red
                i++
                yp++
            }
            j++
        }
        return sum
    }

    /**
     * 对外开放的图像处理方法
     */
    private fun decodeYUV420SPtoRedAvg(
        yuv420sp: ByteArray?, width: Int,
        height: Int
    ): Int {
        if (yuv420sp == null) return 0
        val frameSize = width * height
        val sum = decodeYUV420SPtoRedSum(yuv420sp, width, height)
        return sum / frameSize
    }

}
相关推荐
莞凰6 小时前
昇腾CANN的“灵脉根基“:Runtime仓库探秘
android·人工智能·transformer
NiceCloud喜云7 小时前
Claude Files API 深入:从上传、复用到配额管理的工程化指南
android·java·数据库·人工智能·python·json·飞书
ujainu7 小时前
CANN pto-isa:虚拟指令集如何连接编译与执行
android·ascend
赏金术士8 小时前
第六章:UI组件与Material3主题
android·ui·kotlin·compose
TechMerger9 小时前
Android 17 重磅重构!服役 20 年的 MessageQueue 迎来无锁改造,卡顿大幅优化!
android·性能优化
yuhuofei202112 小时前
【Python入门】Python中字符串相关拓展
android·java·python
dalancon12 小时前
Android Input Spy Window
android
dalancon14 小时前
InputDispatcher派发事件,查找目标窗口
android
我命由我1234514 小时前
Android Framework P3 - MediaServer 进程、认识 ServiceManager 进程
android·c语言·开发语言·c++·visualstudio·visual studio·android runtime
天才少年曾牛15 小时前
Android14 新增系统服务后,应用调用出现 “hidden api” 警告的原因与解决方案
android·frameworks