一、背景说明
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 方法调用顺序
从启动相机到处理一帧图像,主要调用顺序如下:
- 启动相机。 Activity 调用
Camera2Helper.resumeCamera(TextureView),打开相机并创建采集会话,分别向 TextureView 和 ImageReader 输出预览与检测图像。 - 接收图像。
Camera2Listener.onImageAvailable(Image)通知 Activity,再由 Activity 调用viewModel.onPreviewFrame(image),将当前帧交给 ViewModel。 - 转换格式。 调用
ImageUtil.getNV21FromImage2(Image),将相机图像转换为 NV21。 - 检测人脸。 调用
faceHelper.onPreviewFrame(rgbNv21, null, false),获取当前帧的人脸信息。 - 处理活体业务。 将同一帧的 NV21 和人脸信息交给
processLiveness(),按业务配置执行后续处理,具体见 4.3 节。 - 显示结果。 Activity 调用
drawPreviewInfo(),显示人脸框和检测状态。 - 释放图像。 当前帧处理完成、回调返回后,由 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 源码节选:
- 检查是否已授权。 使用
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA),结果为PackageManager.PERMISSION_GRANTED时,继续检查页面与预览控件是否已就绪。 - 未授权时发起申请。 先按
ActivityCompat.shouldShowRequestPermissionRationale()的结果决定是否解释用途,例如“用于显示相机预览并进行人脸检测”。用户继续操作后,再发起系统授权请求。 - 接收授权结果。 若沿用项目现有的
ActivityCompat.requestPermissions(),应在onRequestPermissionsResult()中核对请求码,并检查grantResults非空且相机权限已获准。也可在依赖版本支持时使用ActivityResultContracts.RequestPermission;两种方式选择一种,避免重复申请。 - 拒绝后保留可操作的页面。 暂停相机预览与检测,显示权限未开启的原因,允许用户返回或主动再次尝试。系统不再显示授权弹窗时,可提供由用户点击的“去设置”入口;不要循环弹窗或自动跳转。不能仅凭
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() 中,与图像尺寸和显示相关的配置包括:
- 根据预览尺寸和方向调整预览控件与覆盖层大小。
- 创建
FaceRectTransformer,配置尺寸、方向和镜像关系。 - 调用
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 的调用流程,按需求接入人脸检测、活体检测或注册识别业务。
接入的关键是保证图像格式、尺寸和方向与算法配置一致,图像与人脸信息来自同一帧。同时,应明确后台处理与主线程更新的分工,并在页面暂停、恢复和退出时协调相机启停与资源释放,避免重复打开、图像提前关闭或检测资源仍在使用时被释放。