APP 人脸识别增值版 SDK:Camera2 无缝接入指南

36 阅读13分钟

一、背景说明

Camera2 是 Google 自 Android 5.0 起推出的相机 API,在提供更丰富功能的同时,也增加了接入的复杂度。目前,不少用户反馈,在试用 APP 人脸识别增值版 Android 平台 V5.0 SDK(以下简称 APPAndroidV5.0)时,将 Camera2 采集的图像转换为 NV21 格式后,仍无法检测到人脸。

针对这一问题,本文以 APP Android V5.0 Demo 中的 Camera2 实现为例,按照“打开相机 → 获取图像 → 转换 NV21 → 执行检测 → 显示结果”的顺序,说明相关文件的职责、方法调用关系,以及图像转换、检测参数配置和资源释放的处理要点,为接入与排查提供参考。

1.1 接入准备

阅读和实践前,需准备对应版本的 SDK 与 APPAndroidV5.0 Demo,并完成 SDK 激活;相机权限的声明与申请见 2.4 节。预览与检测所用的图像尺寸、方向应保持匹配,相关配置会在后文结合代码说明。

示例工程环境: 本文参考的 APPAndroidV5.0 Demo 使用 Gradle 5.4.1、Android Gradle Plugin(AGP)3.5.3,编译 SDK 版本为 29,原生库打包目标为 arm64-v8a。以上为该 Demo 的配置,接入其他工程时应结合所用 SDK 版本确认兼容性。

1.2 阅读说明

第一节说明接入背景、准备事项与整体流程;第二节介绍 Camera2 相关文件、调用顺序,以及页面初始化和授权启动;第三节说明尺寸与坐标配置、预览输出和图像获取;第四节说明图像转换、SDK 算法接入与结果绘制;第五节说明相机暂停、恢复与资源释放;第六节汇总接入要点。

源码节选均标注对应文件和方法,类名与方法名沿用 Demo 中的实际名称。按本文范围裁剪的代码单独标为流程示意,接入时需结合业务需求配置检测分支。全文中的 Java 路径均相对于 app/src/main/java/com/arcsoft/arcfacedemo/。

1.3 流程概览

Camera2 在同一个采集会话中提供两路输出:一路通过 TextureView 显示预览,另一路由 ImageReader 获取图像,转换为 NV21 后用于人脸检测和活体处理。图中,蓝色表示相机采集,绿色表示图像转换与检测。

flowchart TD
    A["准备:完成 SDK 激活、获得相机权限、初始化检测引擎"] --> C2["Camera2:打开相机并创建采集会话"]

    C2 --> P2["TextureView 显示相机预览"]
    C2 --> I2["ImageReader 获取 Image"]

    I2 --> N["读取图像数据,转换成 NV21"]
    N --> F["检测人脸,得到人脸位置等信息"]
    F --> L["按业务需要进行活体检测"]
    L --> R["在预览画面上绘制人脸框和检测结果"]

    style C2 fill:#e8f2ff,stroke:#4777b3
    style N fill:#e9f6ec,stroke:#4e8b5c
    style F fill:#e9f6ec,stroke:#4e8b5c
    style L fill:#e9f6ec,stroke:#4e8b5c

二、相机接入

2.1 文件职责

Camera2 相关代码主要分布在以下文件:

文件在当前流程中的职责
ui/activity/LivenessInteractiveGlareCamera2Activity.java初始化页面、连接相机回调、绘制结果
util/camera2/Camera2Helper.java打开相机、创建预览会话和 ImageReader、取出并释放 Image
util/camera2/Camera2Listener.java将相机打开、图像到达和关闭通知传给页面
ui/viewmodel/InteractiveGlareLivenessCamera2ViewModel.java初始化引擎、处理帧数据、衔接活体业务
util/ImageUtil.java将 Image 转换成 NV21
util/face/FaceHelper.java调用人脸检测并组织人脸信息
util/FaceRectTransformer.java、widget/FaceRectView.java处理人脸框坐标和页面绘制

2.2 方法调用顺序

从启动相机到处理一帧图像,主要调用顺序如下:

  1. 启动相机。 Activity 调用 Camera2Helper.resumeCamera(TextureView),打开相机并创建采集会话,分别向 TextureView 和 ImageReader 输出预览与检测图像。
  2. 接收图像。 Camera2Listener.onImageAvailable(Image) 通知 Activity,再由 Activity 调用 viewModel.onPreviewFrame(image),将当前帧交给 ViewModel。
  3. 转换格式。 调用 ImageUtil.getNV21FromImage2(Image),将相机图像转换为 NV21。
  4. 检测人脸。 调用 faceHelper.onPreviewFrame(rgbNv21, null, false),获取当前帧的人脸信息。
  5. 处理活体业务。 将同一帧的 NV21 和人脸信息交给 processLiveness(),按业务配置执行后续处理,具体见 4.3 节。
  6. 显示结果。 Activity 调用 drawPreviewInfo(),显示人脸框和检测状态。
  7. 释放图像。 当前帧处理完成、回调返回后,由 Camera2Helper 调用 image.close() 释放图像。

预览与检测由采集会话的两路输出分别提供,上述转换、检测和结果显示描述的是单帧处理顺序。图像释放要求见 3.4 节,线程安排见 4.1 节。

2.3 页面初始化

LivenessInteractiveGlareCamera2Activity.onCreate() 按顺序调用 initView()、initViewModel() 和 initData():

  • initView() 注册预览控件的布局监听,并将“开始检测”按钮连接到 viewModel.startDetect()。
  • initViewModel() 创建 ViewModel 并调用 init(),订阅动作提示和流程结束等相关状态。
  • initData() 从配置读取摄像头选择信息,随后调用 initCamera2Helper()。

源码节选:LivenessInteractiveGlareCamera2Activity.initCamera2Helper()。

camera2Helper = new Camera2Helper();
Point point = viewModel.loadPreviewSize();
camera2Helper.init(this, String.valueOf(previewConfig.getRgbCameraId()), new Size(point.x, point.y), point);

这里的 loadPreviewSize() 从配置读取宽高,传给 Camera2Helper。随后 Activity 注册 Camera2Listener,接收相机打开和图像到达通知。init() 只是保存参数和准备 CameraManager,还没有真正打开相机。

2.4 授权与启动

SDK 激活与相机授权是两个独立步骤:SDK 激活完成后,仍需获得 Android 系统的相机权限,才能通过 Camera2 采集图像。

2.4.1 权限声明

在 app/src/main/AndroidManifest.xml 的 <manifest> 节点内、<application> 节点外添加:

<uses-permission android:name="android.permission.CAMERA" />

Android 6.0(API 23)及以上还需在运行时申请权限。应在用户进入相机检测功能时检查授权状态,并在打开相机前再次确认,不能仅依赖上次授权结果。具体规则参见 Android 运行时权限指南。

2.4.2 权限申请

以下为页面接入时需要补齐的权限流程,并非 Demo 源码节选:

  1. 检查是否已授权。 使用 ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA),结果为 PackageManager.PERMISSION_GRANTED 时,继续检查页面与预览控件是否已就绪。
  2. 未授权时发起申请。 先按 ActivityCompat.shouldShowRequestPermissionRationale() 的结果决定是否解释用途,例如“用于显示相机预览并进行人脸检测”。用户继续操作后,再发起系统授权请求。
  3. 接收授权结果。 若沿用项目现有的 ActivityCompat.requestPermissions(),应在 onRequestPermissionsResult() 中核对请求码,并检查 grantResults 非空且相机权限已获准。也可在依赖版本支持时使用 ActivityResultContracts.RequestPermission;两种方式选择一种,避免重复申请。
  4. 拒绝后保留可操作的页面。 暂停相机预览与检测,显示权限未开启的原因,允许用户返回或主动再次尝试。系统不再显示授权弹窗时,可提供由用户点击的“去设置”入口;不要循环弹窗或自动跳转。不能仅凭 shouldShowRequestPermissionRationale() 返回 false 就认定用户永久拒绝,因为首次申请前也可能返回该值。

从系统设置返回或重新进入页面后,应重新检查权限,再决定是否启动相机。权限申请期间也要避免重复提交请求。

2.4.3 启动相机

授权回调、页面恢复和预览控件就绪的先后顺序可能不同。建议让这些入口统一检查以下条件,再启动相机:

  • 页面处于前台可用状态,未进入第五节的退出流程。
  • 相机权限已获准,Camera2Helper 与检测所需配置已初始化。
  • 预览控件布局已就绪,且相机没有正在打开或已经打开。

Demo 的 onGlobalLayout() 会移除本次布局监听,因此首次未授权时,不能只等待该监听再次触发。授权成功后也应主动执行上述启动条件检查;若页面已暂停,则等页面恢复后再检查。每次实际打开设备前仍需确认权限,并处理授权已变化导致的 SecurityException。

源码节选:LivenessInteractiveGlareCamera2Activity.onGlobalLayout() 中的启动调用。

camera2Helper.resumeCamera(binding.cameraTexturePreviewRgb);

Camera2Helper.resumeCamera(TextureView) 先调用 startHandlerThread(),再检查 TextureView 的 SurfaceTexture 是否可用:已经可用就打开相机,否则等待 onSurfaceTextureAvailable() 后再打开。

实际开相机发生在 openCamera():检查权限,配置输出,调用 cameraManager.openCamera()。收到 CameraDevice.StateCallback.onOpened() 后,保存 CameraDevice,开始创建预览会话,并通过 onCamera2Opened() 通知 Activity。

这里的 onCamera2Opened() 表示设备已经打开,不表示会话一定配置成功;会话是否成功还要看后续的 onConfigured() 或 onConfigureFailed()。

三、图像采集

3.1 尺寸与坐标

Activity 的 onCamera2Opened() 中,与图像尺寸和显示相关的配置包括:

  1. 根据预览尺寸和方向调整预览控件与覆盖层大小。
  2. 创建 FaceRectTransformer,配置尺寸、方向和镜像关系。
  3. 调用 viewModel.onRgbCameraOpened(previewSizeRgb),再把坐标转换器传给 ViewModel。

ViewModel 的 init() 创建 ftEngine 和 flEngine,分别用于人脸检测和该页面的活体处理;onRgbCameraOpened(Size) 再用预览宽高构建 FaceHelper,并配置只保留最大人脸。算法输入宽高依赖这里保存的尺寸,因此改变采集分辨率时,也必须更新对应的 FaceHelper 配置。

3.2 创建 ImageReader

setUpCameraOutputs() 遍历设备相机 ID,查找与 specifiedCameraId 相同的相机,读取其输出配置,并创建 ImageReader。

源码节选:Camera2Helper.setUpCameraOutputs()。

previewSize = getBestPreviewSize(map.getOutputSizes(SurfaceTexture.class));
imageReader = ImageReader.newInstance(previewSize.getWidth(), previewSize.getHeight(), IMAGE_READER_FORMAT, 5);
imageReader.setOnImageAvailableListener(onImageAvailableListener, cameraHandler);

IMAGE_READER_FORMAT 在类中定义为 ImageFormat.YUV_420_888。最后的 5 是可同时获取而尚未关闭的图像数量上限,不是采集帧率。

接入时,根据目标相机支持的输出配置选择分辨率,确认预览和 YUV 图像能够按所选尺寸同时输出。将选定的尺寸传给 Camera2Helper,并使用对应的图像尺寸初始化 FaceHelper。

3.3 配置采集会话

源码节选:Camera2Helper.createCameraPreviewSession() 中的输出配置。

previewRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
Surface imageReaderSurface = imageReader.getSurface();
previewRequestBuilder.addTarget(imageReaderSurface);
if (surfaceTexture != null) {
    surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight());
    Surface surface = new Surface(surfaceTexture);
    previewRequestBuilder.addTarget(surface);
    outPuts = Arrays.asList(surface, imageReaderSurface);
} else {
    outPuts = Collections.singletonList(imageReaderSurface);
}

ImageReader 的 Surface 始终加入请求;如果传入了 SurfaceTexture,再加入预览 Surface。随后 createCaptureSession(outPuts, ...) 用这组输出创建会话。

在会话的 onConfigured() 中,Demo 配置连续自动对焦,在支持闪光灯时设置自动闪光相关参数,再调用 setRepeatingRequest() 持续采集。预览显示和算法取帧共享同一相机会话,但分别消费各自的输出。

3.4 取帧与释放

源码节选:Camera2Helper.onImageAvailableListener。

private final ImageReader.OnImageAvailableListener onImageAvailableListener = new ImageReader.OnImageAvailableListener() {
    @Override
    public void onImageAvailable(ImageReader reader) {
        final Image image = reader.acquireLatestImage();
        if (image == null) {
            return;
        }
        if (camera2Listener != null) {
            camera2Listener.onImageAvailable(image);
        }
        image.close();
    }
};

acquireLatestImage() 取得可用的最新图像。Helper 把 Image 交给页面监听器,等监听器返回后再关闭,所以当前调用是同步串起来的:页面调用 ViewModel,ViewModel 转换和处理图像,全部返回后才执行 image.close()。

接入自己的页面时,将监听器调用放在 try/finally 中,并由 Helper 在 finally 中执行 image.close(),这样无论处理是否正常结束,都能释放图像。页面和 ViewModel 在 Image 关闭前完成读取,由 Helper 统一负责关闭。

四、图像处理与检测

4.1 帧处理入口

Activity 收到 onImageAvailable(Image) 回调后,先清空上一轮显示信息,再将图像交给 viewModel.onPreviewFrame(image),完成格式转换、人脸检测和活体处理。返回结果非空且坐标转换器已就绪时,调用 drawPreviewInfo() 绘制结果,具体见 4.4 节。

流程示意:基于 InteractiveGlareLivenessCamera2ViewModel.onPreviewFrame(Image) 裁剪。活体业务入口的配置见 4.3 节。

public List<FacePreviewInfo> onPreviewFrame(Image image) {
    // 转换图像并检测人脸。
    byte[] rgbNv21 = ImageUtil.getNV21FromImage2(image);
    List<FacePreviewInfo> facePreviewInfoList =
            faceHelper.onPreviewFrame(rgbNv21, null, false);
    clearLeftFace(facePreviewInfoList);

    // 将同一帧图像和人脸信息交给活体业务处理。
    return processLiveness(rgbNv21, facePreviewInfoList);
}

接入时注意两点:

  • 线程安排: 可通过后台 Handler 顺序执行取帧、转换和检测;页面更新及 LiveData.setValue() 回到主线程。跨线程传递绘制结果时,应复制所需数据,避免结果被下一帧覆盖。
  • 图像释放: 当前示例在回调内同步完成图像读取与检测,返回后由 Camera2Helper 关闭 Image。若改为异步处理,应先复制所需数据,避免任务执行时 Image 已被关闭。

4.2 转换 NV21

这一步是在为人脸算法整理图像数据。相机交出来的是 YUV_420_888 格式,而当前人脸检测调用需要的是 NV21 格式,所以 Demo 使用 ImageUtil.getNV21FromImage2() 完成转换,返回一个 byte[] 数组供后续检测使用。

源码节选:ImageUtil.getNV21FromImage2()。

public static byte[] getNV21FromImage2(Image image) {
    byte[] nv21 = ArcSoftImageUtil.yuvToNv21(
            ArcSoftImageUtil.getBufferAddress(image.getPlanes()[0].getBuffer()),
            image.getPlanes()[0].getRowStride(),
            ArcSoftImageUtil.getBufferAddress(image.getPlanes()[1].getBuffer()),
            image.getPlanes()[1].getRowStride(),
            image.getPlanes()[1].getPixelStride(),
            ArcSoftImageUtil.getBufferAddress(image.getPlanes()[2].getBuffer()),
            image.getPlanes()[2].getRowStride(),
            image.getPlanes()[2].getPixelStride(),
            image.getWidth(),
            image.getHeight()
    );
    return nv21;
}

这个方法传入的是 Image 的完整宽高,没有传入裁剪区域,也没有执行旋转或左右镜像。转换成 NV21 只表示数据格式已经整理好,不表示画面方向也已经调整好。 例如,预览画面即使显示为镜像,这个方法也不会因此自动把图像字节左右翻转。

4.3 SDK 算法接入

完成 NV21 转换后,先检测人脸,再根据业务需求衔接活体检测或注册识别。以下通过 Demo 的业务封装说明接入位置。

1. 人脸检测

调用 faceHelper.onPreviewFrame(rgbNv21, null, false) 获取人脸信息,其中 null 表示不传入 IR 图像,false 表示不执行身份识别分支。FaceHelper 内部调用 SDK 的 ftEngine.detectFaces(),输入格式为 FaceEngine.CP_PAF_NV21,宽高使用初始化时保存的 previewSize,需与当前图像一致。返回结果封装为 FacePreviewInfo,包含人脸信息和跟踪 ID。

2. 活体检测

需要接入动作活体时,将 NV21 图像及其人脸检测结果交给 Demo 的 processLiveness() 业务入口,并按所选业务配置内部处理逻辑。图像与人脸信息必须来自同一帧,避免将上一帧的检测结果用于当前图像。

3. 注册与识别

需要接入人脸注册、特征提取和身份比对时,可参考 RecognizeViewModel 中的业务逻辑,将 Camera2 输出的 NV21 数据接入对应流程。上述 false 参数会跳过身份识别分支,因此注册识别业务还需配置相应的处理入口。

4.4 结果绘制

源码节选:LivenessInteractiveGlareCamera2Activity.drawPreviewInfo()。

private void drawPreviewInfo(List<FacePreviewInfo> facePreviewInfoList) {
    if (rgbFaceRectTransformer != null) {
        List<FaceRectView.DrawInfo> rgbDrawInfoList = viewModel.getDrawInfo(facePreviewInfoList, LivenessType.RGB);
        binding.cameraFaceRectView.drawRealtimeFaceInfo(rgbDrawInfoList);
    }
}

ViewModel 的 getDrawInfo() 根据活体结果设置显示文本和颜色,例如“检测中”“检测完成:活体”“检测完成:非活体”。页面再把绘制信息交给 FaceRectView。

坐标处理依赖相机打开时建立的 FaceRectTransformer。预览方向、算法字节方向和检测框坐标是不同问题:getNV21FromImage2() 并未旋转图像,不能因为预览显示正常就认定算法输入已经旋转。

五、生命周期管理

1. 暂停采集

页面进入后台时,在 onPause() 中调用 pauseCamera()。以下为调用示意,具体停止与关闭逻辑以项目中的 Helper 实现为准。

@Override
protected void onPause() {
    super.onPause();
    if (camera2Helper != null) {
        camera2Helper.pauseCamera();
    }
}

2. 恢复预览

页面恢复时,在 onResume() 中按 2.4 节检查相机权限、预览控件和初始化状态。确认上一轮关闭完成,且相机未启动、未处于打开过程中,再调用 resumeCamera()。

3. 退出清理

清理时先停止新任务和相机采集,等待正在执行的图像处理结束,再释放图像及不再使用的检测资源。Helper 应协调相机会话、设备和 ImageReader 的关闭,检测引擎由其所属的 Activity 或 ViewModel 统一释放,最后结束不再使用的后台线程。

暂停与销毁可能先后触发清理,应避免重复释放。等待任务或线程结束的操作放在后台,避免阻塞主线程。

六、总结

Camera2 与 SDK 通过 NV21 图像衔接:相机侧负责预览和图像采集,业务层负责格式转换、算法调用与结果展示。集成时,可参考 Demo 的调用流程,按需求接入人脸检测、活体检测或注册识别业务。

接入的关键是保证图像格式、尺寸和方向与算法配置一致,图像与人脸信息来自同一帧。同时,应明确后台处理与主线程更新的分工,并在页面暂停、恢复和退出时协调相机启停与资源释放,避免重复打开、图像提前关闭或检测资源仍在使用时被释放。