前言
因业务需要,使用大屏端摄像头捕获图像,且要识别图像中人脸的数目以及从中随机抽取一人。
业务流程如下,调用摄像头预览、拍照,使用OpenCV
库进行人脸识别,将识别到的人脸使用矩形框绘制出来,从识别的人脸中随机选中一人进行展示。
实现
一、集成OpenCV4.8
版本库
本例中人脸识别功能使用OpenCV4.8.0
版本来实现,进入官网,选择OpenCV-4.8.0
版本,Android平台下载。
OpenCV官网
下载解压,将文件夹中的sdk
作为module
导入到工程中,以下是OpenCV
的检测流程。
1、加载本地OpenCV
库
if (!OpenCVLoader.initDebug()) {Log.d(TAG, "Internal OpenCV library not found. Using OpenCV Manager for initialization")OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION, DemoApplication.context, loaderCallback)} else {Log.d(TAG, "OpenCV library found inside package. Using it!")loaderCallback.onManagerConnected(LoaderCallbackInterface.SUCCESS)}private val loaderCallback: BaseLoaderCallback = object : BaseLoaderCallback(DemoApplication.context) {override fun onManagerConnected(status: Int) {when (status) {SUCCESS -> {initOnnx()Log.i(TAG, "OpenCV loaded successfully")}else -> {super.onManagerConnected(status)}}}}
2、初始化检测模型
检测模型使用YuNet
架构,YuNet
是一种轻量级的面部检测网络,在移动设备和嵌入式设备上实现高效、实时的面部检测。320_320
: 表示模型的输入尺寸是 320x320
像素,模型通常在特定的输入尺寸下进行训练,以优化检测性能和速度。
新建res/raw
,将检测模型文件放入raw
中。
/*** 初始化检测模型*/private fun initOnnx() {try {val yyNet = resources.openRawResource(R.raw.yunet_n_320_320)val cascadeDir = requireContext().getDir("cascade", Activity.MODE_PRIVATE)val cascadeFile = File(cascadeDir, "yunet_n_320_320.onnx")val os = FileOutputStream(cascadeFile)val buffer = ByteArray(4096)var bytesRead: Intwhile (yyNet.read(buffer).also { bytesRead = it } != -1) {os.write(buffer, 0, bytesRead)}yyNet.close()os.close()//创建检测器时用到modelPath = cascadeFile.absolutePath} catch (e: Exception) {e.printStackTrace()}}
3、创建检测器
步骤2读取检测模型,在创建检测器时作为参数传入,
public static FaceDetectorYN create(String model, String config, Size input_size, float score_threshold, float nms_threshold, int top_k, int backend_id, int target_id) {}
FaceDetectorYN
的create()
方法参数含义如下。
private var backendId = 3private var targetId = 0private var scoreThreshold = 0.70Fprivate var nmsThreshold = 0.2Fprivate var topK = 5000/*** @param modelPath 预训练模型文件的路径,通常是一个 .onnx 文件* @param config 配置文件路径,一般为空字符串* @param inputSize 输入图像的尺寸,以 Size(width, height) 的形式传入* @param scoreThreshold 置信度分数阈值,默认值为 0.9* @param nmsThreshold 非极大值抑制(NMS)阈值,默认值为 0.3* @param topK 留的最高置信度检测结果的数量上限,默认值为 5000* @param backendId 指定使用的计算后端,默认值为 0(DNN_BACKEND_DEFAULT)* @param targetId 指定计算目标设备,默认值为 0(DNN_TARGET_CPU)*/private fun detectPicture() {val images = org.opencv.android.Utils.loadResource(DemoApplication.context, R.mipmap.face)val detector = FaceDetectorYN.create(modelPath, "", images.size(), scoreThreshold, nmsThreshold, topK, backendId, targetId)detector.inputSize = images.size()val faces = Mat()detector.detect(images, faces)//自定义View,绘制人脸mViewBinding.detectResult.setFacesData(images, faces)}
二、自定义FacialDrawView绘制人脸
前面介绍了OpenCV
的配置过程,集成导入、加载模型、初始化检测器以及识别方法。识别的结果参数中有总共识别到的数目、以及每个人脸的的坐标信息。有了信息接下来就比较简单了,自定义FacialDrawView
,绘制识别到总人数以及绘制矩形框将人脸标记出来即可,下面是识别标记效果。
下面贴下FacialDrawView
中主要的方法,绘制标记所有人脸、绘制总人数提示框。
<!--布局文件中引入--><com.ho.csdn.widget.FacialDrawViewandroid:id="@+id/detectResult"android:layout_width="match_parent"android:layout_height="match_parent"android:visibility="visible" />
/*** 是否是pick模式*/private var isPick = false/*** 识别到的人脸数据*/private var mFaces: Mat? = null/*** 缩放比*/private var scale = 0f/*** 随机选人,计数使用*/private var count = 0/*** 人脸数目*/private var rows: Int = 0/*** 图片宽*/private var imgWidth = 0f/*** 图片高*/private var imgHeight = 0f/*** 设置识别数据* @param[images] 识别的图片* @param[faces] 图片中*/fun setFacesData(images: Mat, faces: Mat) {isPick = falsemFaces = facesrows = faces.rows()countdown = getCount()imgWidth = images.width().toFloat()imgHeight = images.height().toFloat()scale = (width / imgWidth).coerceAtMost(height / imgHeight)invalidate()}/*** onDraw方法中调用* 绘制所有人脸矩形框*/private fun drawAllRect(canvas: Canvas) {paint.style = Paint.Style.STROKEpaint.color = ctx.getColor(R.color.color_rect)for (i in 0..rows) {val x1 = mFaces?.get(i, 0)?.get(0)?.times(scale)?.toInt()val y1 = mFaces?.get(i, 1)?.get(0)?.times(scale)?.toInt()val x2 = mFaces?.get(i, 2)?.get(0)?.times(scale)?.toInt()val y2 = mFaces?.get(i, 3)?.get(0)?.times(scale)?.toInt()if (x1 != null && x2 != null && y1 != null && y2 != null) {detectRect = Rect(x1, y1, x2 + x1, y2 + y1)canvas.drawRect(detectRect, paint)}}}
/*** 文字的高度*/private var textY = 0f/*** 自定义View宽度*/private var vWidth = 0/*** 自定义View高度*/private var vHeight = 0/*** 圆角矩形距离顶部距离*/private var rectTop = 60f/*** 圆角矩形的高度*/private var rectH = 120/*** 圆角矩形的宽度*/private var rectW = 520/*** 绘制总人数*/private fun drawNumber(canvas: Canvas) {if (rows > 0) {paint.apply {style = Paint.Style.FILLcolor = ctx.getColor(R.color.color_rect)}//绘制蓝色底圆角矩形canvas.drawRoundRect(RectF((width - rectW) / 2f,rectTop,width / 2f + rectW / 2,rectH + rectTop),20f,20f,paint)paint.color = ctx.getColor(R.color.colorWhite)val personTxt = ctx.getString(R.string.detect_student_num,rows)//计算文字的宽度val txtWidth = paint.measureText(personTxt,0,personTxt.length)//绘制文字canvas.drawText(personTxt, (width - txtWidth) / 2, textY + rectTop + (rectH - textY) / 2, paint)}}override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {super.onSizeChanged(w, h, oldw, oldh)vWidth = wvHeight = hcalculateTextPos()}/*** 计算文字的高度*/private fun calculateTextPos(){//获取fontMetrics对象val fontMetrics = paint.fontMetrics//获取文本的高度的一半,取文字垂直中线高度值val textHalfHeight = (abs(fontMetrics.descent) + abs(fontMetrics.ascent)) / 2//将文字的向上移动Descent,再向下移动文字高度一半textY = abs(fontMetrics.descent) + textHalfHeight}
关于文字高度的计算,这里就不详细介绍了,可以参考之前写的一篇文章 Android自定义控件(六) Andriod仿iOS控件Switch开关
三、实现随机选人功能
识别到所有的人脸后,再从其他随机选择一个人脸进行展示就很简单了,开启定时器,在人脸总数范围内生成随机索引,每次绘制对应索引的人脸矩形,就能达到这样的效果。
/*** 是否正在识别*/private var isDetecting = false/*** 随机倒计时*/private var countdown = 10/*** 最终选择学生人脸矩形框对象*/private lateinit var detectRect : Rect/*** 定时器对象*/private var timer: Timer? = null/*** 定时器任务对象*/private var task: TimerTask? = null/*** 随机数*/private var random = 0/*** 是否正在检测,主类中使用*/fun isDetecting():Boolean = isDetecting/*** 随机选人*/fun setRandomPick() {isPick = truerandom = 0//如果总人数是1,不随机,直接回调if(rows == 1){isDetecting = falsemListener?.complete(scale,detectRect)}else{initTimer()}}/*** 开启计时器*/private fun initTimer() {isDetecting = truetimer = Timer()task = object : TimerTask() {override fun run() {//在人脸数目范围内生成随机数random = Random.nextInt(rows)count += 1if (count == countdown) {isDetecting = false//回调到主类显示人脸mListener?.complete(scale,detectRect)cancelTask()}else{invalidate()}}}timer!!.schedule(task, 0, 200)}/*** 根据人脸数目获取pick时间*/private fun getCount():Int{var countdown = 10if (rows < 10){countdown = 10}else if(rows in 11..30){countdown = 20}else if(rows > 30){countdown = 40}return countdown}/*** 识别完成回调*/private var mListener:DetectCompleteListener ? = nullinterface DetectCompleteListener{fun complete(scale:Float,rect:Rect)}fun setCompleteListener(listener:DetectCompleteListener){mListener = listener}
四、展示
在FacialDrawView
实现随机点名后,将最终的选中的学生人脸坐标范围回调到主类,PickDialog
中内容比较简单,实现展示头像、重新点名功能。
/*** 完成随机选人后回调到主类*/ mViewBinding.detectResult.setCompleteListener(object:FacialDrawView.DetectCompleteListener{override fun complete(scale:Float,rect: Rect) {val left = (rect.left / scale).toInt()val top = (rect.top / scale).toInt()//裁剪图片中指定的区域val cropBitmap = cropBitmap(detectBitmap!!,scale,left ,top ,rect)requireActivity().runOnUiThread { showPickDialog(cropBitmap) }}})/*** 裁剪识别到的头像*/private fun cropBitmap(bitmap: Bitmap, scale:Float,left: Int, top: Int,rect:Rect): Bitmap {val matrix = Matrix()matrix.setScale(scale,scale)val leftX: Int = if(left - 40 < 0){ 0 }else{ left - 40 }val topY: Int = if(top - 40 < 0) { 0 }else { top - 40 }val totalW = leftX + rect.width() / 2 + 80var vWidth = rect.width() / 2 + 80if(totalW > bitmap.width){vWidth = bitmap.width - leftX}val totalH = topY + rect.height() / 2 + 80var vHeight = rect.height() / 2 + 80if(totalH > bitmap.height){vHeight = bitmap.height - topY}return Bitmap.createBitmap(bitmap, leftX, topY, vWidth , vHeight,matrix,false)}private fun showPickDialog(cropBitmap: Bitmap) {PickDialog.init(requireContext()).setAvatar(cropBitmap).create().setPickListener(object :PickDialog.PickListener{override fun pickAgain(isRetry:Boolean) {if(isRetry){//重新选人mViewBinding.detectResult.setRandomPick()}else{//todo}}}).show()}
五、UVC摄像头预览拍照
上述使用本地资源文件进行人脸识别过程,和摄像头拍照识别过程是一致的,只需要将预览、拍照、将照片文件转为为OpenCV
能识别的图像格式即可。因大屏端使用的是USB Camera
,尝试使用Android
原生Camera1
、Camera2
API
来调用相机,预览十分卡顿,无法正常使用,这里就借用了Github
上大神的AndroidUSBCamera工程库来实现,下面介绍下识别拍照中人脸的一些重要内容。
首先是覆写getCameraRequest()
方法,指定根据自身需求预览的尺寸等。
override fun getCameraRequest(): CameraRequest {return CameraRequest.Builder().setPreviewWidth(1920) // camera preview width.setPreviewHeight(1080) // camera preview height.setRenderMode(CameraRequest.RenderMode.OPENGL) // camera render mode.setDefaultRotateType(RotateType.ANGLE_0) // rotate camera image when opengl mode.setAudioSource(CameraRequest.AudioSource.SOURCE_AUTO) // set audio source.setAspectRatioShow(true) // aspect render,default is true.setCaptureRawImage(false) // capture raw image picture when opengl mode.setRawPreviewData(false) // preview raw image when opengl mode.create()}
savePath
是拍照图片保存的路径,个将拍照的图片转化成Bitmap
。其他后续流程和上述识别本地资源图片过程一致。
/*** 识别拍照图片图片*/private fun detectPicture() {detectBitmap = ImageProcessor.resizeBitmap(ImageProcessor.compressBitmap(savePath,50),1920,1080)mViewBinding.facesPic.setImageBitmap(detectBitmap)val images = ImageProcessor.convertToMat(detectBitmap)val detector = FaceDetectorYN.create(modelPath, "", images.size(), scoreThreshold, nmsThreshold, topK, backendId, targetId)detector.inputSize = images.size()val faces = Mat()detector.detect(images, faces)mViewBinding.detectResult.setFacesData(images, faces)mViewBinding.loading.visibility = View.GONE}
转化的过程需要注意一点的是,如果直接将生成的位图交给OpenCV识别,会提示一下错误信息。Number of input channels should be multiple of 3 but got 4
:表示在卷积层中期望输入的通道数是3的倍数,但实际上输入的通道数为4,这导致了错误。
输入数据格式不正确:通常,图像数据在RGB
模式下有3个通道(红、绿、蓝),而在RGBA
模式下有4个通道(红、绿、蓝、透明度)。如果模型或网络期望的是RGB
数据,提供的是RGBA
数据,就会出现这个错误。
将生成的Bitmap
通过Imgproc.cvtColor
转化为3通道的数据,这样OpenCV
才能正常识别。
fun convertToMat(bitmap: Bitmap?): Mat {val mat = Mat()Utils.bitmapToMat(bitmap, mat)Imgproc.cvtColor(mat, mat, Imgproc.COLOR_BGR2RGB)return mat}
摄像头拍照识别效果如下:
结尾
~~