简介:面向安卓多媒体开发者的相机拍照示例工程,聚焦人脸检测对焦与实时特效处理,适合学习相机接口调用、图像算法集成及自定义相机界面的中高级开发者。工程围绕拍照主流程拆分多个功能模块,涵盖人脸区域自动锁定、对焦联动、方向变化动画,并给出拍照流程控制与角度变化的实现细节;同时内置黑白、负片、曝光过度、色调分离、浮雕、素描、霓虹灯等十余种滤镜效果,可直观对比各算法思路。压缩包共106个文件,以79个PNG图像资源、12个Java源码、10个XML配置为主,辅以依赖库与工程描述文件,整体仅1.12MB,结构紧凑,方便对照界面布局和逻辑代码快速定位关键内容。已有495人学习下载,适合需要直接运行示例并二次开发人脸检测拍照应用的开发者,也可参考其相机权限配置与滤镜渲染实现。
1. 拿到这个范例之后,先别急着跑:人脸识别拍照程序到底解决了什么
很多开发者下载了“Android 支持人脸识别拍照程序范例.rar”,解压后导入 IDE,要么编译报错、要么一进页面就黑屏、要么人脸框跑得比脸还快。原因很简单:人脸识别拍照这个需求,表面是“调起相机 + 画个框”,实则需要把相机预览帧、人脸检测引擎、坐标映射和照片保存四条链路拧在一起。这个范例的真正价值是给你一个最小可用的闭环,让你看到一条基准路径:CameraX 拿预览帧、开源检测引擎出框、ImageCapture 拍图、最后把框画到图上。但它的代码往往是跑流程用的,很多细节需要换掉。这篇文章就是按这条路径拆开讲,从技术选型到代码骨架,再到参数调优和翻车点,让你把它改造成自己产品里可用的模块。
2. 拆解范例的技术选型:人脸检测方案和相机链路为什么这么搭
2.1 人脸检测的三种常见方案:系统 API、开源引擎、云端 SDK
先落一个判断:范例里绝对不会用系统自带的FaceDetector。那个类活在 API 很久以前的设计里,只能分析静态位图,返回的人脸只有一个矩形框,没有置信度、没有左眼右眼、没有欧拉角,而且它要求图片必须转成特定格式的 Bitmap,现代相机预览帧直接喂进去非常别扭。我自己早期试过,识别率惨淡,稍微歪头就丢。所以如果你拿到一个还在用FaceDetector的范例,基本可以放弃改造,重写更划算。
真正常见的有两条路。一条是开源引擎,比如 OpenCV 的 Haar 级联分类器或 DNN 人脸检测器,它们通过 NDK 编译成 so 库,在 Android 上可以直接加载,识别人脸的速度足够跑实时预览。另一条是厂商离线 SDK,检测精度高、还能拿到带关键点和角度的人脸信息,但体积大、APK 至少增加几十兆,而且有使用期限和授权限制。范例为了让你快速跑通且能二次修改,几乎都选开源引擎——它没有网络依赖,权限诉求少,改起来也直观。你要做的不是纠结哪种最准,而是知道范例选的这一种在你自己的场景里够不够用。
2.2 相机预览与拍照:Camera2 还是 CameraX
相机侧也有一场历史遗留的拉锯战。Camera2 API 功能强,但它把每个环节都拆成了异步回调,你要管理 Session 和 Buffer,还要处理旋转、对焦模式、输出尺寸选择。写一个能用的预览界面,少说几百行,再把人脸检测织进去,代码就缠成一团。CameraX 是官方扩展库,它把摄像头访问抽象成 Preview、ImageCapture、ImageAnalysis 三个独立的 UseCase,分别负责出画面、拍照片和取数据流,而且自动处理生命周期。范例如果用了 Camera2,那你拿它做学习材料还行,真要落地,建议全部换成 CameraX,这不是偏好问题,是开发效率问题。
这套组合还有一个隐藏的好处:ImageAnalysis 每个回调都给你一个ImageProxy,它带有一个旋转角度属性和像素数据,正好是做人脸检测的输入。你不再需要自己去CameraManager里读传感器方向,也不用担心各种机型屏幕旋转带来的映射问题,因为ImageProxy已经替你算好了。所以一个标准范例通常长这样:PreviewView显示预览,ImageAnalysis拿帧喂给检测器,检测结果画在自绘 View 上,ImageCapture负责按下时拍图。
2.3 范例里最省事的组合方式
把上面两点并起来,最省事的组合是:CameraX 的PreviewView做预览,ImageAnalysis以 YUV 格式输出预览帧,你在分析器里把这一帧转成 Bitmap,交给开源检测引擎,得到人脸矩形坐标;然后把这个坐标从分析帧的分辨率空间映射到预览控件空间,画在一个透明的OverlayView上。与此同时,ImageCapture负责拍照,拍照时用一个大分辨率的图片,而检测框的坐标则通过一个比例缩放映射到大图上,最后把框画在照片上保存。
这样拆有三个理由。第一,预览和拍照是两条独立管道,检测用的低分辨率帧不占用拍照的画质。第二,ImageAnalysis 的帧坐标和 ImageCapture 的图坐标各自独立,只要有映射关系就不怕比例变化。第三,检测逻辑只关心输入 Bitmap 和输出矩形列表,不关心相机是哪一种,以后换引擎或者换相机器件都容易。范例能跑,骨架就是这个;范例不能跑,十有八九是这条链路里坐标变换出了问题,后面避坑章我们再细说。
3. 把范例跑起来:从解压到全流程可用的核心代码
3.1 项目结构和权限配置
解压后你会看到一个标准 Android 工程:app/src/main/java下面有 MainActivity、DetectorManager、OverlayView 这类类,res/layout下有 activity_main.xml,libs目录里放着 so 文件和 jni 封装 jar。如果 so 文件不全,导入后能编译但运行时UnsatisfiedLinkError直接崩。先把 NDK 版本和 abiFilters 对齐,我的建议是在build.gradle里加上 abiFilters 限制,只保留手机常见的armeabi-v7a和arm64-v8a,省得 x86 模拟器上跑不起来又报错。
权限这一关是最容易翻车的。Android 6.0 以上,相机和存储都不能只写进 Manifest。下面是我在范例里放的标准权限段:
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />注意WRITE_EXTERNAL_STORAGE在 Android 10 之后不再是必需项,但很多范例因为用了较老的Environment.getExternalStorageDirectory()写路径,又没处理分区存储,所以你不能只改 Manifest,还得把保存逻辑换成MediaStore或应用私有目录。推荐的做法如下:
private fun checkPermissions() { val perms = arrayOf(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !hasPermissions(perms)) { requestPermissions(perms, REQUEST_CODE) } }这里的关键是:动态权限只影响高版本机型,低版本机型直接放行,所以你判断条件里必须带着SDK_INT >= M的前置,否则老机型在运行时重复弹窗。还有存储权限如果只是保存到getExternalFilesDir()下的应用专属目录,就不需要申请存储权限,范例里如果还弹存储申请,说明它写的是公共目录,你要改掉。
3.2 相机预览画面中叠加人脸框
预览布局通常就两个控件,一个 CameraX 的PreviewView,一个自定义的人脸框 View。PreviewView负责把画面渲染到屏幕上,它是 CameraX 封装的,不需要你自己创建SurfaceView去折腾。人脸框 View 的重心在onDraw,把检测到的矩形数组画出来。下面的代码是 OverlayView 的核心:
class OverlayView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { val faceRects = mutableListOf<android.graphics.RectF>() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val paint = Paint().apply { style = Paint.Style.STROKE strokeWidth = resources.displayMetrics.density * 2 color = 0xFF00FF00.toInt() } faceRects.forEach { rect -> canvas.drawRect(rect, paint) } } fun setFaces(faces: List<android.graphics.RectF>) { faceRects.clear() faceRects.addAll(faces) invalidate() } }逻辑说明:faceRects存的是当前帧检测到的所有人脸矩形,setFaces由主线程调用后触发重绘,onDraw只做一件事——把矩形画出来。因为 Canvas 的坐标系统就是 View 的坐标系统,所以你在外部传入的 RectF 必须已经映射到 View 的宽高范围。也就是说,检测结果从相机帧坐标到 OverlayView 坐标的换算不能写在这里,而是写在分析器里。
参数说明:strokeWidth用了resources.displayMetrics.density * 2,这样可以保证不同像素密度的手机线条粗细一致;颜色用的是半透明绿色,实际项目中你可以根据明暗背景动态换色。@JvmOverloads是为了让这个 View 能在 XML 里直接实例化,省去写构造函数模板。
3.3 人脸识别命中后自动拍照并保存
这是整个范例最核心的一段逻辑。每一帧进来,检测器算出人脸,程序判断人脸是否满足“拍照条件”,比如人脸大小超过屏幕宽度的某个比例、人脸中心接近画面中心,然后触发ImageCapture.takePicture()。下面是简化版的 Analyzer:
class FaceAnalyzer( private val detector: FaceDetectorHelper, private val imageCapture: ImageCapture, private val overlayView: OverlayView, private val isBusy: AtomicBoolean ) : ImageAnalysis.Analyzer { override fun analyze(imageProxy: ImageProxy) { // 如果正在拍照,丢弃这一帧 if (isBusy.get()) { imageProxy.close() return } val bitmap = imageProxy.toBitmap() val faces = detector.detect(bitmap) val largest = faces.maxByOrNull { it.width() * it.height() } if (largest != null && largest.width() > bitmap.width * 0.4f) { isBusy.set(true) takePhotoWithFaceRect(bitmap, largest) } // 把检测框映射到 OverlayView 的坐标系 overlayView.post { val scaleX = overlayView.width.toFloat() / bitmap.width val scaleY = overlayView.height.toFloat() / bitmap.height overlayView.setFaces(faces.map { RectF( it.left * scaleX, it.top * scaleY, it.right * scaleX, it.bottom * scaleY ) }) } imageProxy.close() } }逻辑说明:isBusy是一个原子布尔值,拍照期间不再重复触发;detect(bitmap)返回的是检测器输出的原始坐标,坐标系是 bitmap 的坐标系;largest.width() > bitmap.width * 0.4f意思是人脸宽度要占到画面宽度的四成以上,这是一个反直觉但实用的条件——人脸离镜头太远时拍出来的照片没意义。最后把坐标映射到OverlayView上,必须用overlayView.post切到主线程,因为 UI 控件不能在子线程里操作。
参数说明:0.4f这个系数不是固定值,它会直接影响触发距离和误触次数。设得越大,人脸越近才拍,照片质量越高,但体验上需要用户把脸凑得更近。范例默认给 0.4,你可以往下调到 0.3 让人更容易拍,但随之而来的问题是背景里的人也可能被当成目标,后面调参章会进一步讲。
再看takePhotoWithFaceRect的实现,它负责让ImageCapture拍一张全分辨率照片,并把这次的人脸矩形画到保存的图片上:
private fun takePhotoWithFaceRect(bitmap: Bitmap, faceRect: Rect) { val file = File(cacheDir, "face_${System.currentTimeMillis()}.jpg") val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build() imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { // 读取拍到的全尺寸图片,把框画上去 val fullBitmap = BitmapFactory.decodeFile(file.absolutePath) val canvas = Canvas(fullBitmap) val scaleX = fullBitmap.width.toFloat() / bitmap.width val scaleY = fullBitmap.height.toFloat() / bitmap.height val paint = Paint().apply { style = Paint.Style.STROKE strokeWidth = 4f * resources.displayMetrics.density color = 0xFF00FF00.toInt() } canvas.drawRect( faceRect.left * scaleX, faceRect.top * scaleY, faceRect.right * scaleX, faceRect.bottom * scaleY, paint ) // 保存覆盖后的图片 FileOutputStream(fullBitmapToFile()).use { fullBitmap.compress(Bitmap.CompressFormat.JPEG, 95, it) } } override fun onError(exception: ImageCaptureException) { isBusy.set(false) } } ) }这里最主要的坑在于两个 Bitmap 分辨率不同:预览帧可能只有 640x480,而照片是 1920x1080,你必须按比例把矩形坐标放大,否则框会画偏。imageCapture.takePicture的第一个参数是保存请求,第二个参数是回调所依附的 Executor,第三个是回调对象。回调返回时isBusy需要置回 false,但别在onImageSaved里急着置,先保存完再说。
3.4 拍照回调与相册刷新的衔接
拍照文件写到了应用缓存目录,用户是看不到的。范例里通常会有一个“保存到相册”的动作,这需要把文件复制到公共媒体库并通知系统刷新。最简单的做法是用MediaStore插入一张图片,然后调用MediaScannerConnection扫描。下面是一段可靠写法:
public static void insertToGallery(Context context, File photoFile) { ContentValues values = new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, photoFile.getName()); values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg"); values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/FaceDemo"); Uri uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); try (FileOutputStream out = context.getContentResolver().openOutputStream(uri)) { Bitmap bitmap = BitmapFactory.decodeFile(photoFile.getAbsolutePath()); bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out); } catch (Exception e) { e.printStackTrace(); } }注意这段用的是 Java 风格,因为很多范例仍然是 Java 写的。它跟前面的 Kotlin 混着看也没问题。RELATIVE_PATH是 Android 10 之后才有的字段,如果你的 targetSdk 低于 29,这个值会被忽略,所以当你在兼容层同时维护新旧写法时,要先判断Build.VERSION.SDK_INT。DISPLAY_NAME必须带.jpg后缀,否则某些相册应用识别不了。这段写完,回到桌面打开相册就能看到刚拍的照片,省去手动刷新的麻烦。
4. 调参和集成:让范例真正变成你自己产品的一部分
4.1 三个必调参数:识别阈值、检测频率、图像旋转
范例能跑通之后,你会发现它要么拍太勤,要么拍不到。三处参数几乎决定了整个体验,第一是检测引擎内部的置信度阈值,第二是ImageAnalysis的帧率控制,第三是图像旋转角度的处理方式。
先看置信度阈值。开源引擎里通常带一个置信度参数,例如 OpenCV DNN 的setScoreThreshold,默认是 0.7 或 0.5。阈值越高,误判越少,但漏检也会变多;阈值过低,墙上的海报、人形立牌都会被当成脸。我一般会把阈值放在 0.55 到 0.65 之间,然后做一张可调目标:把阈值暴露在调试界面里,运行时拖动滑块看识别效果,找到稳定点再固定。范例里很多时候写死为 0.5,你必须改。
再看检测频率。ImageAnalysis在 30fps 的手机上每秒回传 30 帧,如果每帧都跑一次检测,低端机直接卡死,而且前后两帧结果几乎一样,白费算力。常规做法是做一个跳帧计数,每三帧检测一次,其余帧直接close()掉:
class ThrottledAnalyzer(...) : ImageAnalysis.Analyzer { private var frameCount = 0 override fun analyze(imageProxy: ImageProxy) { if (frameCount++ % 3 != 0) { imageProxy.close() return } // 继续检测 } }% 3表示每隔两帧执行一次,也就是实际检测频率约为 10fps。这个频率对拍照触发场景足够,因为人脸不会在几十毫秒内突然消失。如果你跑的是活体检测或眨眼识别,需要包 20fps,那就改成% 1,但性能压力会明显上升。
最后说图像旋转。ImageProxy自带一个imageInfo.rotationDegrees,它告诉你摄像头数据需要旋转多少度才能跟屏幕对齐。范例最常见的坑是忽略这个值,直接把 YUV 转过来的 Bitmap 送检测器。前置摄像头尤其严重,因为它自带了镜像,如果检测器没处理好,人脸矩形会偏移几十个像素。正确做法是先旋转 Bitmap,再检测:
val matrix = Matrix() matrix.postRotate(imageProxy.imageInfo.rotationDegrees.toFloat()) val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)参数说明:postRotate的度数来自系统给的 0/90/180/270,而不是拍照时用户手持的方向。用createBitmap会新生成一张图,内存翻倍,所以检测完要立刻回收原 Bitmap。如果范例里没有这段旋转,你现在补上,绝大概率能解决人脸框偏移问题。
4.2 把示例的检测逻辑抽成独立 Manager
范例的代码通常会把检测逻辑全部塞进 Activity,一个类上千行,光字段就有几十个。你要把它集成进自己的项目,第一步是抽一个FaceDetectorManager,统一封装模型加载、检测、资源释放三件事。我一般会这样设计接口:
class FaceDetectorManager(context: Context) { lateinit var detector: NativeDetector private set fun init() { detector = NativeDetector() detector.loadModel("face_detection_model.dat") } fun detect(bitmap: Bitmap): List<RectF> { val result = detector.detect(bitmap) return result.map { it.rect } } fun release() { detector.release() } }为什么非要抽这个类?因为范例里加载模型用的是绝对路径,release 不主动调,还有可能把登录页和拍摄页各加载一遍模型,整体拖慢应用启动速度。抽成单例或 Activity 级单例对象后,你只要在进程启动时加载一次模型,后续页面复用。模型加载是耗时操作,要放到Executor里做异步初始化,别在主线程new一个 detector。范例里如果直接写在onCreate,那启动时白屏、掉帧不是手机的问题。
4.3 前后摄像头切换与对焦策略
范例一般只写了后置摄像头,或者只写了前置。真实产品经常需要一个切换按钮。CameraX 的切换并不复杂,核心是重写CameraProvider时把CameraSelector换成前置:
fun switchToFront() { val selector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_FRONT) .build() cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, selector, preview, imageCapture, imageAnalysis ) }关键点在于unbindAll和重新绑定,不要在原来的 UseCase 上做绑定,必须先解开。切换后之前设置的旋转角度也会跟着变,因为不同的镜头朝向着坐标系可能不同,所以你需要重新获取当前摄像头的传感器方向。对焦方面,拍照场景通常用ImageCapture自带的默认对焦模式,大多数手机上已经够快。但你如果要拍“人脸对准后自动拍”,可以把对焦模式改成FOCUS_MODE_CONTINUOUS_PICTURE,这样画面里的脸移动时,摄像头会持续跟焦,不出现拍出来糊的情况。用 Camera2 Interop 的方式写:
val camera2Info = Camera2Interop.getCameraCharacteristic(camera, CameraCharacteristics.LENS_INFO_AVAILABLE_FOCUS_MODES) val request = CaptureRequest.Builder虽然 CameraX 抽象了底层,但局部优化还是得靠Camera2Interop进去设置短视频记录和聚焦模式。范例不会教你这些,它只会给你一个打开摄像头就完事的 Demo。所以这一节就是告诉你:切换和聚焦按上面这套写,不要自己去碰CameraDevice的底层回调。
5. 避坑指南:人脸识别拍照在路上常翻车的 5 个问题
5.1 预览是横向的,人脸框却歪了
现象:手机竖屏拿在手里,人脸框画在屏幕左侧,实际人脸在右侧,而且框是倾斜的。原因:ImageAnalysis返回的 Bitmap 方向和预览 View 的方向不一致,检测结果没有做旋转校正。解决:回到第 4.1 节的postRotate,把 Bitmap 先转成和屏幕一致的坐标系,再送检测器。注意要在检测前旋转,不是在画框时旋转画布。我见过不少范例是直接在onDraw里旋转 Canvas,那只能让框的显示方向对,但框的位置还是错的,因为坐标数据本身的方向没变。所以最稳妥的路径是:旋转 → 检测 → 坐标映射 → 画框。
5.2 预览正常但拍出的照片没有人脸框
现象:屏幕上有绿色的框,拍照后打开相册,照片上干干净净什么都没有。原因:ImageCapture拍照用的传感器输出分辨率远高于预览帧,预览帧的检测矩形直接套到全尺寸照片上,等于拿小老鼠的脚掌去套大象的脚印,当然对不上。解决:第 3.3 节已经写了scaleX = fullBitmap.width / bitmap.width,但很多人忘了bitmap是旋转后的 Bitmap,它的宽高可能已经和 fullBitmap 的宽高发生了交换。比如预览帧是 640x480,旋转 90 度后变成 480x640,而照片是 1920x1080 横向的,坐标映射时分子分母必须使用同一个轴向。写缩放前,先Log两个 Bitmap 的宽高,你就知道该用谁做除数。
5.3 检测成功却一直触发拍照回调
现象:页面一打开,相机还没对准脸,就开始咔咔连拍,十几秒拍了一百张。原因:takePicture回调还没结束,下一帧分析又来了,判定条件再次满足。解决:isBusy这个原子布尔值必须在takePicture调用前立刻置为 true,而不是在onImageSaved里才同步。另外,onError回调里也要把isBusy恢复成 false,否则一旦拍照失败,整个流程就锁死了。很多范例只处理成功回调,这就是连拍和死锁两个极端问题都出现的根源。
5.4 低端机上预览卡顿掉帧
现象:设置同样条件下,高端机流畅,低端机帧率掉到个位数。原因:每一帧都在做 Bitmap 转换、像素数据处理、模型推理,这些全挤在 Analyze 线程上。解决:三条路同时走。第一,把ImageAnalysis的输出分辨率降到 320x240,检测精度其实够用,因为人脸框不是识别身份的;第二,跳帧机制,每 3 帧只处理 1 帧;第三,把 Bitmap 转换放到独立线程池,不要在 Analyzer 回调里直接createBitmap。范例不会帮你做这层优化,但它跑起来卡的时候,你按这三条改,效果立竿见影。
5.5 图片旋转后识别率骤降
现象:拍照后保存的图片已经把矩形框画对了,但你把这张照片再送去另一个模块做二次人脸识别时,识别不出来。原因:保存的图片旋转后,人脸检测模型在推理时不会再把图片转回来。如果检测器内部没有处理图像旋转,对于偏转 90 度的人脸,关键点定位会完全失效。解决:在保存图片之前,把截图内容先旋转回传感器原始方向,或者在使用该图做二次识别时,创建一个Matrix把图旋转到模型期望的方向。更简单的办法是进入照片检测模块时,加一个ExifInterface读取角度,然后Bitmap.createBitmap旋转。
6. 从范例到落地:把“能拍照”做成“拍得准、拍得稳”
6.1 增加检测结果置信度过滤
范例里通常只会给你一个人脸矩形框,不会把置信度传出来。但你自己的检测引擎是有置信度的,你在封装FaceDetectorManager时把它暴露出来,然后定一个过滤策略:置信度低于 0.4 的矩形直接丢弃,高于 0.8 的才符合“拍照条件”。这么做的好处是,背景里误判的脸不会触发拍照,也不会让 OverlayView 显示一堆闪来闪去的无用框。别把置信度阈值设死,从调试界面读进去,方便你拿真实样本调。
6.2 用多人脸结果选最大人脸
拍照场景经常出现多人同框。范例的简单逻辑是选最大的人脸,但“最大”有两种计算方式:按宽度或按面积。我建议按面积,因为横向脸和侧脸宽度差异大,宽度最大不一定是最近的人。取到最大人脸后,还要加一个“人脸中心偏离屏幕中心多少”的限制,比如脸中心到屏幕中心距离小于屏幕宽度的 20%。这样能避免画面里两个人时,照片拍到了旁边的人。
6.3 拍照前后做光线补偿
人脸识别在强逆光和黑夜里的表现天差地别。范例里没有灯光控制,但你可以利用相机曝光补偿接口,在检测到人脸大面积曝光不足时,临时调高曝光值。CameraX 里可以通过Camera2CameraControl实现:
val cameraControl = camera.cameraControl as Camera2CameraControl cameraControl.enableCaptureRequest(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) cameraControl.setExposureCompensationIndex(6)注意曝光补偿只在自动曝光模式下有意义,范围一般在 -12 到 12 之间。拍照结束后把值调回 0,否则后续预览会持续过曝。这不属于范例自带功能,但落地时一定要加,否则用户晚上一测,识别率和拍照成功率都会让你怀疑人生。
6.4 性能优化:降分辨率与异步管线
最后一步,像做产品一样收尾。把整个流程画成一张数据流图(别用代码画,心理有数就行):CameraX 采集帧 → 降采样 → 转 Bitmap → 检测 → 映射坐标 → UI 画框。瓶颈一般出现在转 Bitmap 和检测两步。我的习惯是把这两步放到同一个后台线程,然后通过Handler向主线程发结果;OverlayView.invalidate()的调用频率降低到 10fps 以内,因为 UI 重绘再快,也赶不上人脸移动的语义变化。内存方面,预先分配 Bitmap 复用池,不要每帧createBitmap,这能大幅减少 GC 卡顿。
以前我拿过一个范例直接改进项目,跑在低端机上,画面像幻灯片,我当时以为是人脸检测库的问题,换了两个引擎还是一样。后来把ImageAnalysis分辨率从 640x480 降到 320x240,再把跳帧数从 0 改成 2,问题立刻消失。这件事给我的经验是:范例代码的路径设计通常没问题,但性能参数和旋转逻辑一定是为特定机型调过的,你拿去用必须重新标定。如果你也正在被这类问题折磨,按这篇文章的顺序,从旋转入手,再调阈值和跳帧,最后检查坐标映射,基本几分钟就能把问题圈定。希望帮到你。
本文还有配套的精品资源,点击获取