17. 人脸检测:Camera2的人脸检测模式、获取人脸检测结果、绘制人脸框
人脸检测,说白了就是让摄像头认出画面里有没有人脸。嗯,这个功能现在手机相机里几乎成了标配——自动对焦到人脸、美颜、人像模式,底层都离不开它。
Camera2 框架其实内置了一套人脸检测机制,不需要我们自己去训练模型。我个人习惯是,能用系统 API 解决的,绝不自己造轮子。毕竟系统级的检测在功耗和性能上都有优化。
17.1 Camera2 的人脸检测模式
Camera2 提供了三种人脸检测模式,定义在 CameraMetadata.STATISTICS_FACE_DETECT_MODE 里。我刚开始接触时也搞混过,这里帮你理清楚。
| 模式 | 常量值 | 说明 |
|---|---|---|
| 关闭 | 0 | 不进行人脸检测 |
| 简单模式 | 1 | 只返回人脸矩形区域,不返回 landmarks |
| 完整模式 | 2 | 返回人脸矩形 + 眼睛、鼻子、嘴巴等关键点 |
重要:不是所有设备都支持完整模式。我在项目中遇到过,某款中端机在完整模式下直接返回空数据。建议先查询设备支持情况。
怎么查询?用 CameraCharacteristics.STATISTICS_INFO_AVAILABLE_FACE_DETECT_MODES。代码很简单:
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val faceDetectModes = characteristics.get(
CameraCharacteristics.STATISTICS_INFO_AVAILABLE_FACE_DETECT_MODES
)
// faceDetectModes 是一个 IntArray
// 检查是否包含 FACE_DETECT_MODE_FULL
val supportFull = faceDetectModes?.contains(
CameraMetadata.STATISTICS_FACE_DETECT_MODE_FULL
) ?: false
你想想看,如果设备不支持完整模式,你却硬要开启,结果就是白忙活。所以第一步永远是查能力。
17.2 开启人脸检测
开启人脸检测,需要在创建 CaptureRequest 时设置对应的 key。我一般是在预览的重复请求里开启:
val previewRequestBuilder = cameraDevice.createCaptureRequest(
CameraDevice.TEMPLATE_PREVIEW
)
previewRequestBuilder.set(
CaptureRequest.STATISTICS_FACE_DETECT_MODE,
CameraMetadata.STATISTICS_FACE_DETECT_MODE_SIMPLE
)
// 或者用完整模式
// previewRequestBuilder.set(
// CaptureRequest.STATISTICS_FACE_DETECT_MODE,
// CameraMetadata.STATISTICS_FACE_DETECT_MODE_FULL
// )
小提示:如果只是画个框,简单模式就够了。完整模式会多返回 landmarks,但计算量也更大。我个人建议,除非你需要做眼睛追踪或表情识别,否则用简单模式更省电。
17.3 获取人脸检测结果
人脸检测的结果,藏在 CaptureResult 里。每次预览帧过来,我们都能拿到最新的检测数据。
关键的两个 key:
CaptureResult.STATISTICS_FACES:返回Face[]数组CaptureResult.STATISTICS_FACE_DETECT_MODE:当前使用的检测模式
在 CameraCaptureSession.CaptureCallback 里获取:
private val captureCallback = object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: CaptureResult
) {
val faces = result.get(CaptureResult.STATISTICS_FACES)
if (faces != null && faces.isNotEmpty()) {
// 有人脸!
processFaces(faces)
}
}
}
Face 对象包含以下信息:
| 属性 | 类型 | 说明 |
|---|---|---|
| getBounds() | Rect | 人脸矩形区域,坐标是相对于传感器输出尺寸的 |
| getScore() | int | 置信度,1-100,越高越确定是人脸 |
| getId() | int | 人脸 ID,用于追踪同一张脸 |
| getLandmarks() | Face.Landmark[] | 关键点(仅完整模式) |
注意:getBounds() 返回的坐标是传感器坐标系,不是预览画面的坐标系。直接拿来画框,框的位置会完全不对。需要做坐标映射。
17.4 坐标映射:从传感器到预览画面
为什么会这样?因为 Camera2 的检测结果是基于传感器原始尺寸的。而我们在屏幕上看到的是经过裁剪和缩放的预览画面。所以必须做一次坐标转换。
我常用的转换公式:
fun mapFaceRectToView(
faceRect: Rect,
sensorSize: Size, // 传感器尺寸
previewSize: Size, // 预览尺寸
viewWidth: Int, // 显示控件的宽度
viewHeight: Int // 显示控件的高度
): RectF {
// 先计算裁剪后的预览区域在传感器中的位置
val sensorAspect = sensorSize.width.toFloat() / sensorSize.height
val previewAspect = previewSize.width.toFloat() / previewSize.height
val cropLeft: Float
val cropTop: Float
val cropWidth: Float
val cropHeight: Float
if (sensorAspect > previewAspect) {
// 传感器更宽,左右裁剪
cropHeight = sensorSize.height.toFloat()
cropWidth = cropHeight * previewAspect
cropLeft = (sensorSize.width - cropWidth) / 2f
cropTop = 0f
} else {
// 传感器更高,上下裁剪
cropWidth = sensorSize.width.toFloat()
cropHeight = cropWidth / previewAspect
cropLeft = 0f
cropTop = (sensorSize.height - cropHeight) / 2f
}
// 将人脸坐标从传感器映射到裁剪区域
val relLeft = (faceRect.left - cropLeft) / cropWidth
val relTop = (faceRect.top - cropTop) / cropHeight
val relRight = (faceRect.right - cropLeft) / cropWidth
val relBottom = (faceRect.bottom - cropTop) / cropHeight
// 映射到视图坐标
return RectF(
relLeft * viewWidth,
relTop * viewHeight,
relRight * viewWidth,
relBottom * viewHeight
)
}
嗯,这段代码看起来有点长,但核心逻辑就一句话:把传感器坐标归一化,再映射到视图坐标。我曾经在这个坑里爬了整整一下午,最后发现是忘了考虑裁剪偏移。
17.5 绘制人脸框
坐标映射好了,画框就简单了。我通常用自定义 View 的 onDraw 来实现:
class FaceOverlayView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val faceRects = mutableListOf<RectF>()
private val paint = Paint().apply {
color = Color.GREEN
style = Paint.Style.STROKE
strokeWidth = 4f
}
fun setFaces(faces: List<RectF>) {
faceRects.clear()
faceRects.addAll(faces)
postInvalidate() // 触发重绘
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
for (rect in faceRects) {
canvas.drawRect(rect, paint)
}
}
}
然后在 Activity 或 Fragment 里,把映射后的坐标传进去:
faceOverlayView.setFaces(mappedRects)
优化建议:如果人脸框在闪烁,可以加一个平滑滤波。比如只更新置信度高于 50 的人脸,或者用上一帧的位置做插值。我在做直播美颜时就加了这层处理,效果明显好很多。
17.6 完整流程梳理
到这里,整个流程就串起来了。我画了一张图帮你理解:
整个流程其实就是一个循环:查询能力 → 开启检测 → 接收结果 → 映射坐标 → 绘制界面。每一帧都重复这个过程。
17.7 避坑指南
最后分享几个我踩过的坑:
- 我曾经以为所有设备都支持完整模式,结果在某款手机上直接 crash。后来加了一层能力检查,问题解决。
- 坐标映射别搞反了。传感器坐标转预览坐标,方向要搞清楚。我建议先打印出原始坐标和映射后的坐标,用 Log 验证一遍。
- 人脸框闪烁怎么办?加一个置信度阈值,低于 30 的忽略掉。或者用上一帧的位置做平滑。
- 性能问题:不要在 onDraw 里做复杂计算。坐标映射在回调线程里算好,View 只负责画。
嗯,人脸检测这部分就讲到这里。代码量不大,但细节不少。尤其是坐标映射,搞懂了就一通百通。
公众号:蓝海资料掘金营,微信deep3321