Android:Camera2開發詳解(下):實現人臉檢測功能並實時顯示人臉框

android.jpg
前言
- 本篇文章是在上篇文章的基礎之上,在預覽的時使用Camera2自帶的人臉檢測功能實時檢測人臉位置,並通過一個自定義view顯示在預覽畫面上
實現思路
-
佈局中使用 AutoFitTextureView 代替 TextureView。AutoFitTextureView 繼承自 TextureView,能夠根據傳入的寬高值調整自身大小。目的是使預覽畫面不變形,否則在人臉座標轉換的時候會出現比較大的誤差,這個後文中會提到
-
在建立預覽會話的時候,開啟人臉檢測
-
在預覽會話的狀態回撥中可以得到檢測到的人臉資訊
-
將檢測到的人臉座標進行相應的轉換,並傳遞給FaceView
-
自定義一個FaceView,接收人臉位置並實時繪製出來
具體實現步驟
注: 由於本文是在上篇文章基礎之上,故省略了很多相同的程式碼,完整程式碼在文末給出
一、定義一個AutoFitTextureView,並在佈局中使用
/** * A {@link TextureView} that can be adjusted to a specified aspect ratio. */ public class AutoFitTextureView extends TextureView { private int mRatioWidth = 0; private int mRatioHeight = 0; public AutoFitTextureView(Context context) { this(context, null); } public AutoFitTextureView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public AutoFitTextureView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } /** * Sets the aspect ratio for this view. The size of the view will be measured based on the ratio * calculated from the parameters. Note that the actual sizes of parameters don't matter, that * is, calling setAspectRatio(2, 3) and setAspectRatio(4, 6) make the same result. * * @param widthRelative horizontal size * @param height Relative vertical size */ public void setAspectRatio(int width, int height) { if (width < 0 || height < 0) { throw new IllegalArgumentException("Size cannot be negative."); } mRatioWidth = width; mRatioHeight = height; requestLayout(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int width = MeasureSpec.getSize(widthMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); if (0 == mRatioWidth || 0 == mRatioHeight) { setMeasuredDimension(width, height); } else { if (width < height * mRatioWidth / mRatioHeight) { setMeasuredDimension(width, width * mRatioHeight / mRatioWidth); } else { setMeasuredDimension(height * mRatioWidth / mRatioHeight, height); } } } }
佈局中使用
<com.cs.camerademo.view.AutoFitTextureView android:id="@+id/textureView" android:layout_width="match_parent" android:layout_height="wrap_content" /> <com.cs.camerademo.view.FaceView android:id="@+id/faceView" android:layout_width="match_parent" android:layout_height="match_parent" />
AutoFitTextureView 能夠根據設定的寬高調整自身大小,防止畫面出現拉伸的情況。如果畫面出現拉伸的話,會導致人臉座標在轉換的時候出現較大的誤差,不能精確的繪製出人臉位置
二、在上篇文章中Camera2Helper的基礎上新增人臉檢測相關的程式碼
class Camera2HelperFace(val mActivity: Activity, private val mTextureView: AutoFitTextureView) { companion object { const val PREVIEW_WIDTH = 1080//預覽的寬度 const val PREVIEW_HEIGHT = 1440//預覽的高度 const val SAVE_WIDTH = 720//儲存圖片的寬度 const val SAVE_HEIGHT = 1280//儲存圖片的高度 } private var mFaceDetectMode = CaptureResult.STATISTICS_FACE_DETECT_MODE_OFF //人臉檢測模式 private var openFaceDetect = true//是否開啟人臉檢測 private var mFaceDetectMatrix = Matrix()//人臉檢測座標轉換矩陣 private var mFacesRect = ArrayList<RectF>()//儲存人臉座標資訊 private var mFaceDetectListener: FaceDetectListener? = null//人臉檢測回撥 ... ... }
-
為了跟上一篇文章區別,這裡將類名改為了Camera2HelperFace,並將構造方法裡的第二個引數改為AutoFitTextureView
-
我們定義了一個 mFaceDetectMatrix ,它是一個 Matrix 物件,用於對人臉座標進行轉換
注:相機檢測到的人臉座標與我們看到的螢幕座標並不是同一個座標系,所以必須通過轉換後才能使用
- 注意!這裡我將預覽的寬高設為了 1080 * 1440 ,為什麼要這樣設定,上篇文章中不是設定的 720 * 1280 嗎? 這個問題下面會給出答案
三、 在初始化的方法中,根據預覽尺寸重新調整TextureView的大小
/** * 初始化 */ private fun initCameraInfo() { ... ... //根據預覽的尺寸大小調整TextureView的大小,保證畫面不被拉伸 val orientation = mActivity.resources.configuration.orientation if (orientation == Configuration.ORIENTATION_LANDSCAPE) mTextureView.setAspectRatio(mPreviewSize.width, mPreviewSize.height) else mTextureView.setAspectRatio(mPreviewSize.height, mPreviewSize.width) if (openFaceDetect) initFaceDetect()//初始化人臉檢測相關引數 ... ... openCamera() }
-
這裡根據預覽尺寸和螢幕方向對mTextureView重新設定了寬高值,保證畫面不被拉伸
-
如果開啟人臉檢測的話,初始化人臉檢測相關引數
四、初始化人臉檢測相關資訊
/** * 初始化人臉檢測相關資訊 */ private fun initFaceDetect() { val faceDetectCount = mCameraCharacteristics.get(CameraCharacteristics.STATISTICS_INFO_MAX_FACE_COUNT)//同時檢測到人臉的數量 val faceDetectModes = mCameraCharacteristics.get(CameraCharacteristics.STATISTICS_INFO_AVAILABLE_FACE_DETECT_MODES)//人臉檢測的模式 mFaceDetectMode = when { faceDetectModes.contains(CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL) -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL faceDetectModes.contains(CaptureRequest.STATISTICS_FACE_DETECT_MODE_SIMPLE) -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL else -> CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF } if (mFaceDetectMode == CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF) { mActivity.toast("相機硬體不支援人臉檢測") return } val activeArraySizeRect = mCameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) //獲取成像區域 val scaledWidth = mPreviewSize.width / activeArraySizeRect.width().toFloat() val scaledHeight = mPreviewSize.height / activeArraySizeRect.height().toFloat() val mirror = mCameraFacing == CameraCharacteristics.LENS_FACING_FRONT mFaceDetectMatrix.setRotate(mCameraSensorOrientation.toFloat()) mFaceDetectMatrix.postScale(if (mirror) -scaledWidth else scaledWidth, scaledHeight) if (exchangeWidthAndHeight(mDisplayRotation, mCameraSensorOrientation)) mFaceDetectMatrix.postTranslate(mPreviewSize.height.toFloat(), mPreviewSize.width.toFloat()) log("成像區域${activeArraySizeRect.width()}${activeArraySizeRect.height()} 比例: ${activeArraySizeRect.width().toFloat() / activeArraySizeRect.height()}") log("預覽區域${mPreviewSize.width}${mPreviewSize.height} 比例 ${mPreviewSize.width.toFloat() / mPreviewSize.height}") for (mode in faceDetectModes) { log("支援的人臉檢測模式 $mode") } log("同時檢測到人臉的數量 $faceDetectCount") }
- 首先,我們獲取到相機硬體所支援的人臉檢測模式和同時最大檢測到的人臉數
相機支援的人臉檢測模式分為3種:
- STATISTICS_FACE_DETECT_MODE_FULL :
完全支援。返回人臉的矩形位置、可信度、特徵點(嘴巴、眼睛等的位置)、和 人臉ID - STATISTICS_FACE_DETECT_MODE_SIMPLE:
支援簡單的人臉檢測。返回的人臉的矩形位置和可信度。 - STATISTICS_FACE_DETECT_MODE_OFF:
不支援人臉檢測
注 : 我的手機支援的人臉檢測模式是STATISTICS_FACE_DETECT_MODE_SIMPLE,但是在實踐過程中發現,我得到的人臉可信度值全部都是1,而正常範圍應該是 0~100。
實踐結果與原始碼中描述的STATISTICS_FACE_DETECT_MODE_SIMPLE模式下能返回可信度值不一致,對此我也存在疑問,如果有小夥伴清楚這個問題的話可以留言討論~
- 通過mCameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) 獲取到相機的成像區域。這句就比較關鍵了,什麼是成像區域呢?原始碼中是這樣描述的:
This is the rectangle representing the size of the active region of the sensor //這是表示感測器活動區域大小的矩形
也就是說,這塊矩形區域是相機感測器不捕捉影象資料時使用的範圍。檢測人臉所得到的座標也正是基於此矩形的。
當我們拿到了這塊矩形後,又知道預覽時的矩形(即AutoFitTextureView的大小),這樣我們就能找出這兩塊矩形直接的轉換關係(即 mFaceDetectMatrix),從而就能夠將感測器中的人臉座標轉換成預覽頁面中的座標
這裡解釋一下為什麼要把預覽尺寸設定為 1080 * 1440
首先,我們要知道相機的成像區域與我們上顯示的預覽區域是相對獨立的。而我們通過系統給的 api 得到的人臉位置資訊就是基於這個成像區域的,我們需要通過這兩個區域之前的轉換關係把人臉位置資訊進行轉換後才能正確地繪製在預覽區域上
通過log可以看到我的手機後置攝像頭成像區域是 4608 * 3456,寬高比是 1.3333334。這時,如果我們將預覽區域設定為 720 * 1080 的話,寬高比為 1.7777778。因為這兩者的寬高比不一致,這會導致人臉座標在轉換後顯示的時候與實際預覽到的人臉不重合(會有壓縮)。
所以我們將預覽寬高設定為 1080 * 1440 ,與相機成像區域的寬高比一致,這樣我們得到的人臉矩形的比例與預覽效果中的實際人臉是一致的

成像區域與預覽區域
- 通過對相機成像區域和預覽區域的大小以及不同的攝像頭,對轉換關係(即 mFaceDetectMatrix )進行賦值
五、建立預覽會話時設定人臉檢測功能
/** * 建立預覽會話 */ private fun createCaptureSession(cameraDevice: CameraDevice) { val captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) ... ... //設定人臉檢測 if (openFaceDetect && mFaceDetectMode != CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF) captureRequestBuilder.set(CaptureRequest.STATISTICS_FACE_DETECT_MODE, CameraCharacteristics.STATISTICS_FACE_DETECT_MODE_SIMPLE) // 為相機預覽,建立一個CameraCaptureSession物件 cameraDevice.createCaptureSession(arrayListOf(surface, mImageReader?.surface), object : CameraCaptureSession.StateCallback() { ... ... }, mCameraHandler) }
六、在預覽會話的回撥函式中對檢測到的人臉進行處理,並將結果回撥給Activity
private val mCaptureCallBack = object : CameraCaptureSession.CaptureCallback() { override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest?, result: TotalCaptureResult) { super.onCaptureCompleted(session, request, result) if (openFaceDetect && mFaceDetectMode != CaptureRequest.STATISTICS_FACE_DETECT_MODE_OFF) handleFaces(result) } ... ... } /** * 處理人臉資訊 */ private fun handleFaces(result: TotalCaptureResult) { val faces = result.get(CaptureResult.STATISTICS_FACES) mFacesRect.clear() for (face in faces) { val bounds = face.bounds val left = bounds.left val top = bounds.top val right = bounds.right val bottom = bounds.bottom val rawFaceRect = RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat()) mFaceDetectMatrix.mapRect(rawFaceRect) val resultFaceRect = if (mCameraFacing == CaptureRequest.LENS_FACING_FRONT) rawFaceRect else RectF(rawFaceRect.left, rawFaceRect.top - mPreviewSize.width, rawFaceRect.right, rawFaceRect.bottom - mPreviewSize.width) mFacesRect.add(resultFaceRect) log("原始人臉位置: ${bounds.width()} * ${bounds.height()}${bounds.left} ${bounds.top} ${bounds.right} ${bounds.bottom}分數: ${face.score}") log("轉換後人臉位置: ${resultFaceRect.width()} * ${resultFaceRect.height()}${resultFaceRect.left} ${resultFaceRect.top} ${resultFaceRect.right} ${resultFaceRect.bottom}分數: ${face.score}") } mActivity.runOnUiThread { mFaceDetectListener?.onFaceDetect(faces, mFacesRect) } log("onCaptureCompleted檢測到 ${faces.size} 張人臉") }
-
當我們拿到檢測到的人臉資訊後,通過轉換關係mFaceDetectMatrix 對其做相應的轉換。還需要根據前後攝像頭做不同的轉換處理
-
將轉換後的人臉資訊通過回撥函式傳遞出去
注意!在實踐過程中,我發現這一系列的人臉座標轉換是存在誤差的。當我們的預覽尺寸與成像尺寸越接近,誤差越小。所以,建議在相機支援的尺寸列表中儘量選取與成像尺寸最接近的大小來使用,以減小誤差。
關於轉換存在誤差這個問題,如果小夥伴們有更好的解決方式或者思路,歡迎留言交流討論。
七、自定義一個FaceView,接收人臉位置資訊,並繪製出來
class FaceView : View { lateinit var mPaint: Paint private var mCorlor = "#42ed45" private var mFaces: ArrayList<RectF>? = null constructor(context: Context) : super(context) { init() } constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init() } constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init() } private fun init() { mPaint = Paint() mPaint.color = Color.parseColor(mCorlor) mPaint.style = Paint.Style.STROKE mPaint.strokeWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, context.resources.displayMetrics) mPaint.isAntiAlias = true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) mFaces?.let { for (face in it) { canvas.drawRect(face, mPaint) } } } fun setFaces(faces: ArrayList<RectF>) { this.mFaces = faces invalidate() } }
實現效果

效果圖.gif
完整程式碼
https://github.com/smashinggit/Study
注:此工程包含多個module,本文所用程式碼均在CameraDemo下