前言
因业务需要,使用大屏端摄像头捕获图像,且要识别图像中人脸的数目以及从中随机抽取一人。
业务流程如下,调用摄像头预览、拍照,使用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: Int
while (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 = 3
private var targetId = 0
private var scoreThreshold = 0.70F
private var nmsThreshold = 0.2F
private 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.FacialDrawView
android: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 = false
mFaces = faces
rows = 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.STROKE
paint.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.FILL
color = 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 = w
vHeight = h
calculateTextPos()
}
/**
* 计算文字的高度
*/
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 = true
random = 0
//如果总人数是1,不随机,直接回调
if(rows == 1){
isDetecting = false
mListener?.complete(scale,detectRect)
}else{
initTimer()
}
}
/**
* 开启计时器
*/
private fun initTimer() {
isDetecting = true
timer = Timer()
task = object : TimerTask() {
override fun run() {
//在人脸数目范围内生成随机数
random = Random.nextInt(rows)
count += 1
if (count == countdown) {
isDetecting = false
//回调到主类显示人脸
mListener?.complete(scale,detectRect)
cancelTask()
}else{
invalidate()
}
}
}
timer!!.schedule(task, 0, 200)
}
/**
* 根据人脸数目获取pick时间
*/
private fun getCount():Int{
var countdown = 10
if (rows < 10){
countdown = 10
}else if(rows in 11..30){
countdown = 20
}else if(rows > 30){
countdown = 40
}
return countdown
}
/**
* 识别完成回调
*/
private var mListener:DetectCompleteListener ? = null
interface 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 + 80
var vWidth = rect.width() / 2 + 80
if(totalW > bitmap.width){
vWidth = bitmap.width - leftX
}
val totalH = topY + rect.height() / 2 + 80
var vHeight = rect.height() / 2 + 80
if(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
}
摄像头拍照识别效果如下:
结尾
~~