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 完整流程梳理

到这里,整个流程就串起来了。我画了一张图帮你理解:

人脸检测完整流程 查询设备能力 检查支持的检测模式 开启检测模式 设置 CaptureRequest 接收检测结果 CaptureCallback 回调 坐标映射 传感器→预览画面 绘制人脸框 自定义 View onDraw 循环更新 每帧重复 1-5 循环处理每一帧 注意:坐标映射是关键,传感器坐标系 ≠ 预览坐标系

整个流程其实就是一个循环:查询能力 → 开启检测 → 接收结果 → 映射坐标 → 绘制界面。每一帧都重复这个过程。

17.7 避坑指南

最后分享几个我踩过的坑:

  • 我曾经以为所有设备都支持完整模式,结果在某款手机上直接 crash。后来加了一层能力检查,问题解决。
  • 坐标映射别搞反了。传感器坐标转预览坐标,方向要搞清楚。我建议先打印出原始坐标和映射后的坐标,用 Log 验证一遍。
  • 人脸框闪烁怎么办?加一个置信度阈值,低于 30 的忽略掉。或者用上一帧的位置做平滑。
  • 性能问题:不要在 onDraw 里做复杂计算。坐标映射在回调线程里算好,View 只负责画。

嗯,人脸检测这部分就讲到这里。代码量不大,但细节不少。尤其是坐标映射,搞懂了就一通百通。


公众号:蓝海资料掘金营,微信deep3321