Android 人脸识别实战:兼容不同厂商摄像头的 CameraX、Camera API1 与 RK3568 排障
最近在做一个 Android 端离线人脸识别项目,业务场景是门口横屏设备实时识别人脸。手机上测试一切正常,但是放到 RK3568 这类定制 Android 硬件上后,最典型的问题就来了:
App 已经授权相机权限,但页面没有相机预览流,或者 CameraX 绑定成功后一直没有首帧。
这类问题很容易被误判成“SDK 不识别人脸”或者“权限没给”,但真正排查下来,相机链路通常才是第一道坎。本文记录一套比较稳的实战方案:优先使用 CameraX,失败或无首帧时降级到 Android Camera API1,然后统一把 NV21 画面交给人脸 SDK 分析。
本文不绑定某一个人脸 SDK,旷视、百度或其他本地人脸 SDK 都可以使用类似思路。核心目标只有一个:先稳定拿到不同厂商设备上的摄像头预览流。
1. 先搞清楚 Android 相机链路
很多人看到设备上有/dev/video0,就以为 Android App 一定能打开摄像头。实际不是这样。
Android App 常规访问摄像头的链路是:
App -> CameraX / Camera2 / Camera API1 -> CameraService -> Camera HAL / External Camera Provider -> Linux 驱动 / UVC / MIPI 摄像头 -> /dev/video*所以要注意两点:
- Camera API1 不能绕过 Camera HAL。它只是旧版 Java 相机接口,底层仍然要经过系统 CameraService 和 HAL。
/dev/video*存在,不代表 Android Framework 一定能枚举到摄像头。如果 CameraService 枚举结果是 0,App 通过 CameraX、Camera2、Camera API1 都可能拿不到画面。
在 RK3568、RK3588、全志、MTK 定制板等设备上,最容易出问题的是 HAL 或 External Camera Provider 没有适配好 USB 摄像头,或者系统固件只给某个厂商测试 App 开了私有通道。
2. 权限先确认,但不要只盯权限
Manifest 至少需要:
<uses-permissionandroid:name="android.permission.CAMERA"/><uses-featureandroid:name="android.hardware.camera"android:required="false"/><uses-featureandroid:name="android.hardware.camera.front"android:required="false"/>运行时也要申请:
if(ContextCompat.checkSelfPermission(context,Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){requestPermissionLauncher.launch(Manifest.permission.CAMERA)}如果设备上明明授权了,但仍然没有画面,可以继续检查:
adb shell pm list permissions-g-dadb shell dumpsys package your.package.name|grepCAMERA adb shell appops get your.package.name CAMERA如果权限正常,就不要一直卡在权限层面了。下一步应该看系统到底有没有把摄像头暴露给 Android CameraService。
3. CameraX 优先:枚举所有相机,而不是写死前置
很多业务代码会直接写:
CameraSelector.DEFAULT_FRONT_CAMERA这在手机上通常没问题,但在门口闸机、工控屏、RK 板子、外接 USB 摄像头上就不稳。更好的方式是先枚举 CameraX 能看到的所有相机,再按业务优先级选择。
我当前项目里的策略是:
FRONT -> EXTERNAL -> BACK -> ANY也就是优先前置,其次外接,再后置,实在识别不出类型就拿第一个可用相机。
核心代码如下:
internalenumclassCameraKind{FRONT,EXTERNAL,BACK,UNKNOWN,ANY,}internalfunselectPreferredCameraKind(available:List<CameraKind>):CameraKind?=when{available.isEmpty()->nullCameraKind.FRONTinavailable->CameraKind.FRONT CameraKind.EXTERNALinavailable->CameraKind.EXTERNAL CameraKind.BACKinavailable->CameraKind.BACKelse->CameraKind.ANY}CameraX 枚举时可以把 cameraId 和 lensFacing 打出来:
@OptIn(ExperimentalCamera2Interop::class)privatefunCameraInfo.toCameraInventoryItem():CameraInventoryItem{valkind=when(lensFacing){CameraSelector.LENS_FACING_FRONT->CameraKind.FRONT CameraSelector.LENS_FACING_EXTERNAL->CameraKind.EXTERNAL CameraSelector.LENS_FACING_BACK->CameraKind.BACKelse->CameraKind.UNKNOWN}valcameraId=runCatching{Camera2CameraInfo.from(this).cameraId}.getOrDefault("unknown")returnCameraInventoryItem(info=this,id=cameraId,kind=kind,)}这一步非常关键。因为不同厂商设备可能把 USB 摄像头标成EXTERNAL,也可能标成BACK,甚至是UNKNOWN。不要按设备型号写死,尽量按系统能力列表动态选择。
4. CameraX 绑定成功,不代表真的有画面
有些设备更“迷惑”:CameraX 没有抛异常,bindToLifecycle也执行成功了,但预览就是黑的,分析器也没有收到帧。
我的处理方式是加一个首帧超时。如果 3 秒内没有收到ImageAnalysis第一帧,就认为 CameraX 在当前设备上不可用,自动降级到 Camera API1。
privateconstvalCAMERAX_FIRST_FRAME_TIMEOUT_MS=3_000LvalfirstFrameReceived=AtomicBoolean(false)valfallbackStarted=AtomicBoolean(false)valmainHandler=Handler(Looper.getMainLooper())funfallbackToCamera1(error:Throwable){if(!active||!fallbackStarted.compareAndSet(false,true))returnfirstFrameTimeout?.let{timeout->mainHandler.removeCallbacks(timeout)}imageAnalysis?.clearAnalyzer()runCatching{cameraProvider?.unbindAll()}Log.e("FaceCamera","CameraX failed; falling back to Camera API1",error)onFallbackToCamera1(error)}firstFrameTimeout=Runnable{if(active&&!firstFrameReceived.get()){fallbackToCamera1(IllegalStateException("CameraX produced no frames within${CAMERAX_FIRST_FRAME_TIMEOUT_MS}ms",),)}}分析器收到第一帧时取消超时:
useCase.setAnalyzer(analysisExecutor){image->if(firstFrameReceived.compareAndSet(false,true)){firstFrameTimeout?.let{timeout->mainHandler.removeCallbacks(timeout)}Log.i("FaceCamera","CameraX delivered the first frame")}analyzer.analyze(image)}绑定完成后启动超时检测:
provider.bindToLifecycle(lifecycleOwner,selectedCamera.info.cameraSelector,preview,analysis,)if(!firstFrameReceived.get()){firstFrameTimeout?.let{timeout->mainHandler.postDelayed(timeout,CAMERAX_FIRST_FRAME_TIMEOUT_MS)}}这个机制在定制硬件上很实用。因为它不只判断“能不能 open”,还判断“业务到底有没有拿到帧”。
5. Camera API1 兜底:拿 NV21 预览流
CameraX 不稳定时,可以用 Camera API1 做兜底。虽然 API1 已废弃,但很多工控 Android 系统对它的兼容反而更直接。
注意:这里不是说 API1 比 CameraX 高级,而是在定制设备上多一条兼容路径。
一个 Compose 页面里可以这样封装:
@ComposablefunCameraPreview(analyzer:CameraXFaceAnalyzer,onCameraError:(String)->Unit,modifier:Modifier=Modifier,){varuseCamera1byremember(analyzer){mutableStateOf(false)}if(useCamera1){Camera1Preview(analyzer=analyzer,onCameraError=onCameraError,modifier=modifier,)}else{CameraXPreview(analyzer=analyzer,onFallbackToCamera1={useCamera1=true},modifier=modifier,)}}API1 侧使用TextureView承载预览,并通过setPreviewCallbackWithBuffer复用缓冲区,避免频繁分配内存:
candidate.setPreviewTexture(surface)candidate.setDisplayOrientation(config.displayOrientation)candidate.setPreviewCallbackWithBuffer(this)repeat(PREVIEW_BUFFER_COUNT){candidate.addCallbackBuffer(ByteArray(config.bufferSize))}candidate.startPreview()回调里把 NV21 数据交给统一的人脸分析器:
overridefunonPreviewFrame(data:ByteArray?,sourceCamera:Camera?){if(data==null||sourceCamera==null||disposed.get())returnvalconfig=cameraConfigtry{if(config!=null){currentAnalyzer.analyzeNv21(data=data,width=config.width,height=config.height,rotation=config.frameRotation,isBackCamera=config.isBackCamera,)}}finally{runCatching{sourceCamera.addCallbackBuffer(data)}}}这里一定要在finally里把 buffer 还回去,否则跑一会儿就可能没有预览回调了。
6. API1 不要写死尺寸、FPS、格式
不同厂商摄像头支持的预览尺寸和 FPS 差异很大。不要直接写死1920x1080或30fps,应该读取能力列表后选择最接近业务目标的配置。
privatefunconfigureCamera(sourceCamera:Camera,descriptor:Camera1Descriptor,):Camera1Config{valparameters=sourceCamera.parametersvalsupportedFormats=parameters.supportedPreviewFormats.orEmpty()if(supportedFormats.isNotEmpty()&&ImageFormat.NV21!insupportedFormats){throwIllegalStateException("Camera API1 不支持 NV21 预览格式")}valsupportedSizes=parameters.supportedPreviewSizes.orEmpty()valpreviewSize=choosePreviewSize(supportedSizes)?:parameters.previewSizevalsupportedFpsRanges=parameters.supportedPreviewFpsRange.orEmpty()valfpsRange=choosePreviewFpsRange(supportedFpsRanges)Log.i("FaceCamera1","supportedFormats=$supportedFormats, "+"supportedSizes=${supportedSizes.joinToString{"${it.width}x${it.height}"}},"+"supportedFps=${supportedFpsRanges.joinToString{it.contentToString()}}",)parameters.previewFormat=ImageFormat.NV21 parameters.setPreviewSize(previewSize.width,previewSize.height)fpsRange?.let{range->parameters.setPreviewFpsRange(range[0],range[1])}if(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEOinparameters.supportedFocusModes.orEmpty()){parameters.focusMode=Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO}sourceCamera.parameters=parameters// 省略 rotation、bufferSize 等计算}尺寸选择可以按比例和面积接近度来选:
privateconstvalTARGET_PREVIEW_AREA=1280L*720LprivateconstvalTARGET_ASPECT_RATIO=16.0/9.0privatefunchoosePreviewSize(sizes:List<Camera.Size>):Camera.Size?=sizes.minWithOrNull(compareBy<Camera.Size>({size->abs(size.width.toDouble()/size.height-TARGET_ASPECT_RATIO)},{size->abs(size.width.toLong()*size.height-TARGET_PREVIEW_AREA)},),)FPS 也一样:
privateconstvalTARGET_MIN_FPS=15_000privateconstvalTARGET_MAX_FPS=30_000privatefunchoosePreviewFpsRange(ranges:List<IntArray>):IntArray?=ranges.filter{range->range.size>=2}.minWithOrNull(compareBy<IntArray>({range->abs(range[1]-TARGET_MAX_FPS)},{range->abs(range[0]-TARGET_MIN_FPS)},),)Android Camera API1 里的 FPS 单位是乘以 1000 的,例如30000表示 30fps。
7. 横屏门口设备要处理旋转和填充
门口设备常见是横屏安装,有些摄像头方向还会反着来。如果只把预览画面铺满,可能出现人脸方向不对、预览被拉伸、识别框和人脸位置对不上的问题。
API1 需要计算两套角度:
displayOrientation:给预览显示用。frameRotation:给人脸 SDK 分析用。
valdisplayDegrees=displayRotationDegrees()valisFrontCamera=descriptor.info.facing==Camera.CameraInfo.CAMERA_FACING_FRONTvalisBackCamera=descriptor.info.facing==Camera.CameraInfo.CAMERA_FACING_BACKvalframeRotation=if(isFrontCamera){(descriptor.info.orientation+displayDegrees)%360}else{(descriptor.info.orientation-displayDegrees+360)%360}valdisplayOrientation=if(isFrontCamera){(360-(descriptor.info.orientation+displayDegrees)%360)%360}else{frameRotation}TextureView预览建议做fill center,保持比例填满容器:
privatefunapplyFillCenterTransform(config:Camera1Config){if(width<=0||height<=0)returnvalswapsDimensions=config.displayOrientation==90||config.displayOrientation==270valbufferWidth=if(swapsDimensions)config.height.toFloat()elseconfig.width.toFloat()valbufferHeight=if(swapsDimensions)config.width.toFloat()elseconfig.height.toFloat()valviewWidth=width.toFloat()valviewHeight=height.toFloat()valdefaultScaleX=viewWidth/bufferWidthvaldefaultScaleY=viewHeight/bufferHeightvalfillScale=max(defaultScaleX,defaultScaleY)valmatrix=Matrix().apply{setScale(fillScale/defaultScaleX,fillScale/defaultScaleY,viewWidth/2f,viewHeight/2f,)}setTransform(matrix)}如果是门口人脸识别场景,通常建议:
- 预览区域按 16:9 或设备实际屏幕比例设计。
- 人脸检测取最大人脸,减少多人经过时误识别。
- SDK 分析帧可以节流,例如 200ms 到 300ms 分析一次,不需要每一帧都做人脸比对。
8. CameraX 和 API1 统一到 NV21 分析入口
为了后续换 SDK 或集成到其他应用,建议把相机层和人脸 SDK 层解耦。相机只负责产出帧,人脸模块只关心统一格式。
可以定义一个统一的 NV21 帧对象:
dataclassNv21Frame(valdata:ByteArray,valwidth:Int,valheight:Int,valrotation:Int,valisBackCamera:Boolean,)CameraX 的ImageProxy是YUV_420_888,可以转成 NV21:
privatefunImageProxy.toNv21Frame(isBackCamera:Boolean):Nv21Frame{valnv21=Yuv420888ToNv21Converter.convert(width=width,height=height,y=planes[0].toPlaneData(),u=planes[1].toPlaneData(),v=planes[2].toPlaneData(),)returnNv21Frame(data=nv21,width=width,height=height,rotation=imageInfo.rotationDegrees,isBackCamera=isBackCamera,)}API1 本身就可以配置为 NV21,所以直接提交:
funanalyzeNv21(data:ByteArray,width:Int,height:Int,rotation:Int,isBackCamera:Boolean,){valnormalizedRotation=((rotation%360)+360)%360require(normalizedRotation%90==0){"frame rotation must be a multiple of 90"}valframe=Nv21Frame(data=data,width=width,height=height,rotation=normalizedRotation,isBackCamera=isBackCamera,)submit(frame)}这样 CameraX 和 API1 都走同一个identify(frame),后续无论接旷视、百度还是其他算法,只要适配一个输入层即可。
9. RK3568 设备排障思路
如果手机正常,RK3568 上没有预览流,可以按下面顺序排查。
第一步,看 Android Framework 能不能枚举到相机:
adb shell dumpsys media.camera重点看:
Number of camera devices: 0或者有没有对应的Camera ID、Facing、Orientation、Device version等信息。
如果这里就是 0,那 App 侧怎么改 CameraX、Camera2、API1 都可能打不开。因为系统 CameraService 没有相机可用。
第二步,看 Linux 设备节点:
adb shellls-l/dev/video*如果/dev/video*存在,但dumpsys media.camera枚举为 0,说明摄像头可能只在 Linux 驱动层存在,还没有接入 Android Camera HAL。
这种情况常见解决方向是:
- 找硬件厂商确认固件是否启用了 External Camera Provider。
- 确认 USB 摄像头是否是标准 UVC。
- 检查系统是否有 camera provider 进程。
- 让固件厂商把
/dev/video*映射到 Android CameraService。 - 如果系统无法改,只能考虑 App 自己通过 UVC/libusb 直接取流,但这就不是标准 Camera API 方案了。
第三步,看日志:
adb logcat|grep-icamera常见关键词:
CameraService CameraProvider CameraDevice open camera failed No camera available Permission denied如果是Permission denied,再回头看权限、SELinux、设备节点访问策略。如果是 provider/HAL 报错,就要找系统固件层。
10. 常见问题对照表
| 现象 | 可能原因 | 建议处理 |
|---|---|---|
| 手机有画面,RK3568 没画面 | 固件 HAL 没暴露摄像头 | 先看dumpsys media.camera |
/dev/video0存在,但 App 枚举不到 | Linux 驱动有,Android CameraService 没接上 | 找厂商适配 External Camera Provider |
| CameraX 绑定成功但无首帧 | CameraX/Camera2 在当前系统兼容性差 | 加首帧超时,自动切 Camera API1 |
API1getNumberOfCameras()为 0 | Framework 没相机 | App 侧无法通过标准 Camera API 解决 |
| 有预览但识别方向错 | rotation 传错 | 区分显示旋转和算法帧旋转 |
| 预览拉伸 | TextureView 没按比例缩放 | 使用 fill center 矩阵 |
| 跑一会儿没回调 | API1 buffer 没归还 | finally里调用addCallbackBuffer |
| 识别卡顿 | 每帧都做人脸比对 | 对分析帧节流,只取最大人脸 |
11. 性能和生命周期建议
实时人脸识别不是单纯“拿到帧”就完了,还要避免设备长时间运行后发热、卡顿、内存抖动。
建议做到:
ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST,永远处理最新帧。- 人脸分析加节流,例如 250ms 一次。
- API1 使用
setPreviewCallbackWithBuffer复用内存。 - 页面销毁时释放相机、清空 analyzer、关闭线程。
- 不在主线程做人脸检测和 1:N 比对。
- 人员库保存人脸特征向量,不要每次拿原图重新提特征。
一个简单的节流逻辑:
privatefuntryAcquireFrameSlot():Boolean{if(closed.get())returnfalsevalnow=SystemClock.elapsedRealtime()valprevious=lastSubmittedAt.get()if(previous!=0L&&now-previous<frameIntervalMs)returnfalseif(!processing.compareAndSet(false,true))returnfalselastSubmittedAt.set(now)returntrue}12. 完整降级流程总结
最终我的推荐流程是:
申请 CAMERA 权限 -> CameraX 枚举相机 -> 按 FRONT / EXTERNAL / BACK / ANY 选择相机 -> 绑定 Preview + ImageAnalysis -> 3 秒内收到首帧 -> YUV_420_888 转 NV21 -> 人脸 SDK 分析 -> 3 秒内没有首帧或绑定失败 -> 切换 Camera API1 -> 枚举 API1 相机 -> 选择尺寸 / FPS / NV21 -> setPreviewCallbackWithBuffer -> NV21 交给同一个人脸分析器如果 CameraX 和 Camera API1 都枚举不到摄像头,那就不是 App 层普通兼容代码能解决的问题了,优先检查系统固件、Camera HAL、External Camera Provider、UVC 驱动和 SELinux 策略。
结语
做人脸识别项目时,人脸 SDK 往往不是第一个难点。真正影响落地的,通常是不同 Android 硬件上的摄像头兼容性。
我的经验是:不要按厂商型号硬编码,不要只判断 open 成功,不要只盯权限。更稳的方式是围绕系统能力列表做适配,CameraX 拿不到真实帧就及时降级 Camera API1,同时把两套相机输入统一成 NV21,再交给人脸识别模块。
这样做的好处是,业务层只关心“识别成功、未注册、识别失败”等结果,相机兼容细节都收敛在独立模块里。后续无论换设备、换摄像头,还是把人脸 SDK 模块集成到其他 App,维护成本都会低很多。