1. 问题现象:点了一下屏幕,人脸就“黑”了
先交代一下背景。前阵子在做一个三方相机项目,就是那种在系统相机之外、自己实现取景、对焦、曝光、拍照全流程的App。功能做到人脸追踪阶段时,碰到一个非常典型的坑:用户在取景界面点击屏幕任意位置后,人脸框还在,但人脸区域的曝光明显不正常了——要么过曝,要么压暗,反正不是你想要的“以人脸为准”的效果。用行业术语说,就是自动触发了touch ae,导致后续face ae全部失效。
这个现象在系统相机里几乎遇不到,因为系统相机会帮你做策略协调。但到了三方相机,所有策略都得自己写,问题就暴露了。我起初以为是芯片平台的bug,后来定位了一圈才发现,问题出在AE(Auto Exposure,自动曝光)的region权重管理和人脸检测回调的时序冲突上。这篇文章就把整个排查过程、根因分析和最终的解决方案完整记录下来,希望能帮到正在做Camera开发、尤其是做三方相机人脸相关功能的同学。
先说清楚两个概念,不然下面的内容容易绕。touch ae,指的是用户触摸屏幕后,相机将测光区域(Metering Region)锁定到触摸点附近,让曝光以那个区域为基准。face ae,指的是相机检测到人脸后,自动将测光区域切换到人脸区域,让人脸曝光始终处于理想状态。正常逻辑下,这两者应该是互斥且可恢复的关系——用户摸了屏幕,touch ae生效;人脸重新出现或用户没再触摸时,系统应该恢复face ae。但实际做的时候,很多三方相机把这两件事做成了“一次触摸、永久覆盖”,于是就有了标题里这个case。
这个case的典型表现有三个:第一,触摸对焦后,人脸框仍然检测到,但曝光不再跟随人脸;第二,人脸从画面边缘移到中央,曝光也不跟着变;第三,切后台再回前台,曝光恢复正常,但一旦再触摸又失效。这三个表象看似无关,其实指向同一个根因:测光区域的更新被touch ae的注册信息占住了,face ae的更新请求根本提交不进去。
下面我从底层原理开始拆,再给可落地的修复方案。
2. 自动曝光的底层机制:AE Region是怎么工作的
2.1 从Camera2的AE三要素说起
现在做三方相机,基本都基于Camera2 API(或者厂商扩展的CameraX、自有HAL接口)。不管哪套,底层都离不开三个关键控制项:
- CONTROL_AE_MODE:曝光模式,分为ON(自动曝光)、OFF(手动)、ON_AUTO_FLASH(自动闪光)等。做face ae和touch ae,前提是AE模式处于ON状态。
- CONTROL_AE_REGIONS:测光区域,是一个整数数组,格式为
[x1, y1, x2, y2, weight]。前四个值是相对于传感器输出坐标系的矩形范围,范围是0到10000(Camera2的normalized坐标),最后一位是权重,取值范围1到1000。 - CONTROL_AE_LOCK:是否锁定当前曝光值。
touch ae的做法,就是用户点击屏幕后,App把触摸点对应的坐标换算成上述region格式,然后设置到CONTROL_AE_REGIONS里,同时把CONTROL_AF_MODE切到CONTROL_AF_MODE_AUTO或CONTROL_AF_MODE_CONTINUOUS_PICTURE,并同样下发CONTROL_AF_REGIONS。face ae的做法类似,只不过region的坐标来源不是触摸点,而是人脸检测回调(CameraCaptureSession的CaptureCallback里通过CaptureResult.STATISTICS_FACE_DETECTOR_MODE拿到的人脸矩形)。
这里有一个非常关键的底层事实:AE Registers(测光区域)是会被HAL层合并计算的。当你同时设置多个region时,HAL会按照权重进行加权测光。如果某个region的权重设置成1000(区域内),而另一个只有1,那么前者几乎完全主导最终的曝光值计算。
2.2 touch ae为什么会“压过”face ae
很多人以为,只要我在触摸之后继续做人脸检测,并且检测到人脸就重新下发face ae的region,就能恢复。实际操作中你会发现,下发是下发了,但曝光值纹丝不动。原因在于:
第一,你下发的新region和touch ae的region是叠加关系,不是覆盖关系。如果touch ae的region权重是1000,而face ae的region权重也是1000,HAL会同时计算这两个区域。人脸这时候如果不在触摸点附近,曝光就会在“人脸区域”和“触摸点区域”之间取折中,最终结果是两者都不讨好。
第二,很多三方相机的代码在触摸回调里,会主动设置mCaptureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, false)或者干脆用了CONTROL_AE_PRECAPTURE_TRIGGER,这些操作会强制HAL重新收敛曝光。而face ae的更新如果发生在这个收敛窗口内,极容易被丢弃。
第三,也是最重要的一点:触摸事件处理线程和人脸检测回调线程不是同一个。触摸回调通常运行在UI线程或单独的手势处理线程,人脸检测回调运行在Camera的Callback线程。两者并发写入同一个CaptureRequest.Builder,如果没有加锁,后写入的region很可能被先写入的request覆盖——但你以为你写入成功了。这种“你以为写了,其实没写进去”的竞态,是这类bug最常见的幕后黑手。
2.3 坐标换算里的隐性坑
再补充一个容易踩的坑:坐标换算。触摸点坐标是屏幕坐标(比如1080x2400),而AE Region需要的是传感器坐标(0到10000的归一化坐标)。中间要经过预览画面的裁剪比例换算和传感器旋转/镜像映射。大多数三方相机用TextureView+ 自定义裁剪,屏幕坐标和传感器坐标不是简单的等比关系。如果换算错了一个维度,touch ae的region会落在人脸区域之外的某个奇怪位置,这时候脸一动,曝光立刻跳变。
下面这个公式是标准的换算思路(以竖屏、传感器旋转90度为例):
// 屏幕坐标 -> 传感器归一化坐标 val sensorX = (screenX / previewViewWidth) * 10000f val sensorY = (screenY / previewViewHeight) * 10000f // 如果传感器方向是90度,需要交换并反转 val sensorNormalizedX = sensorY // 旋转90度后x来自原y val sensorNormalizedY = 10000f - sensorX // 旋转后y需要反转这个换算每个平台略有差异,高通、联发科、三星的ISP对region的坐标系解释也不完全一致。后面我会讲怎么用实拍图验证你的换算对不对。
3. 根因定位:一次完整的链路追踪
3.1 抓Log的“三大件”
拿到这个bug之后,我没有急着改代码,而是先搭了一套可观测的日志系统。重点抓三类数据:
- 触摸事件流:包括触摸坐标、换算后的sensor坐标、下发时间戳。
- 人脸检测事件流:包括人脸框坐标、检测置信度、回调时间戳。
- AE状态流:通过
CaptureResult.CONTROL_AE_STATE和CaptureResult.CONTROL_AE_REGIONS回读实际生效的region。
抓取CONTROL_AE_REGIONS的回读值非常关键。很多开发者只盯着写出去的request,不看HAL实际采纳的result。实际上,写出去的request和最终生效的result之间,可能隔了2到3帧。你写了face ae的region,但HAL可能还在用上一个touch ae的region做曝光计算。只有回读CaptureResult.CONTROL_AE_REGIONS,才能确认HAL到底在执行哪套参数。
我在这个case里抓到的典型日志如下(脱敏后):
12:30:01.234 TouchEvent: x=540, y=1200, sensorRegion=[2500, 4000, 3500, 4500, 1000] 12:30:01.240 RequestSubmit: AE_REGIONS=[[2500, 4000, 3500, 4500, 1000]], AE_MODE=ON 12:30:01.512 FaceDetected: rect(0.2, 0.3, 0.5, 0.8), region=[2000, 3000, 5000, 8000, 1000] 12:30:01.520 RequestSubmit: AE_REGIONS=[[2000, 3000, 5000, 8000, 1000]] // face ae尝试覆盖 12:30:01.780 CaptureResult: AE_REGIONS=[[2500, 4000, 3500, 4500, 1000]] // HAL还是touch ae 12:30:02.100 CaptureResult: AE_REGIONS=[[2500, 4000, 3500, 4500, 1000]] // 始终未切换注意上面倒数第二行——App在01.520的时候确实写入了face ae的region,但HAL在01.780回读的仍然是touch ae的region。这说明写入被HAL拒绝或忽略了。为什么会被忽略?这才是我真正要查的方向。
3.2 排查方向一:AE Region的合法性与合并策略
先检查写入的region是否合法。Camera2对CONTROL_AE_REGIONS有个隐含约束:region数量上限通常为10个(部分平台只有5个),且每个region的坐标必须在0到10000范围内,weight必须在1到1000之间。如果写入的region越界,HAL会静默丢弃整个region列表,而不是丢弃单个非法region。
我当时检查了换算代码,发现一个很容易忽略的场景:当用户触摸屏幕边缘时,触摸点换算出的region矩形可能超出10000的边界。比如手指点在屏幕最左侧,换算后x1可能是负数。我在代码里做了clamp,但clamp之后x2 - x1可能变成0或者负数——一个宽度为零的region,HAL会认为它非法,然后丢弃整个AE_REGIONS更新。这就能解释为什么触摸屏幕边缘后,face ae再也拉不回来。
3.3 排查方向二:AE状态的机内状态机冲突
第二个排查方向是CONTROL_AE_STATE。Camera2的AE状态机有几个关键状态:CONVERGED(已收敛)、SEARCHING(搜索中)、FLASH_REQUIRED、PRECAPTURE等。当你手动设置AE_REGIONS时,如果当前状态处于PRECAPTURE或SEARCHING,新的region请求会被HAL排队处理,而不是立即生效。
问题出在touch ae的触发时机。三方相机通常会在触摸后立刻执行一次CONTROL_AE_PRECAPTURE_TRIGGER,让HAL重新计算曝光。这个过程需要几帧时间。如果在这个窗口内,face ae的region更新到达,HAL会认为这是一个新的AE收敛请求,于是抛弃当前正在进行的precapture,重新开始。但重新开始之后,它读到的region可能是旧的(因为request queue里还有上一帧的touch ae数据),于是face ae的更新就“被吞了”。
这个问题的本质是:你发region更新的频率超过了HAL的收敛速度,导致新region一直在排队,而旧region一直在生效。
3.4 排查方向三:线程竞态——最常见的真凶
最后排查到线程问题。我当时的代码结构是:
- 触摸回调:在主线程,通过
Handler发送RepeatingRequest。 - 人脸检测回调:在
CameraCaptureSession的CaptureCallback里,也就是Camera的线程。
两个线程都在修改同一个CaptureRequest.Builder对象。我的代码里没有对这个Builder做同步,于是出现了下面的场景:
线程A(触摸):builder.set(CONTROL_AE_REGIONS, touchRegion) 线程B(人脸):builder.set(CONTROL_AE_REGIONS, faceRegion) // 覆盖了A的写入 线程A(触摸):session.setRepeatingRequest(builder.build()) // 提交了A的旧值这里的关键是:Builder对象的set操作和build()操作之间没有原子性保证。线程B在Abuild()之前覆盖了region,但A提交的request已经是B覆盖后的值——但因为时序交错,A后续还会再次提交,而且它可能用的是自己缓存的一个副本,导致最终提交的永远是touch ae的region。
说白了,这个bug不是“face ae的逻辑错了”,而是“face ae的更新动作根本没在正确的时机执行”。这是我最终定位到的核心问题。
4. 解决方案落地:从策略设计到代码实现
4.1 整体策略:引入“曝光模式优先级管理器”
修复思路很清晰:不能让touch ae和face ae无序竞争,必须由一个统一的管理器来决定当前应该使用哪个region。我做了一个ExposureModeManager,核心职责有三块:
第一,维护一个状态枚举:FACE_AE、TOUCH_AE、NONE。默认状态是FACE_AE;用户触摸后状态切换为TOUCH_AE;当满足一定条件(人脸重新检测到、且用户超过N秒未触摸、且触摸点与人脸区域重叠度较高)时,自动切回FACE_AE。
第二,所有region的写入请求都经过这个管理器,由它统一构造CaptureRequest.Builder,再做加锁提交。任何线程都不能直接动Builder。
第三,管理器内部维护一个“最近一次触摸时间戳”。如果用户连续触摸,那么timeout顺延;如果在timeout内检测到人脸,且人脸框包含了原触摸点(或触摸点旁边一定的扩张区域),立即切回face ae。
这个设计的好处是,把“什么时候用谁的region”这个策略问题,从“谁先抢到锁”变成了“谁符合策略”。策略是可解释、可测试的,而不是靠运气。
4.2 核心代码实现
先看ExposureModeManager的核心骨架(Kotlin,基于Camera2):
enum class ExposurePriority { FACE_AE, TOUCH_AE, NONE } class ExposureModeManager( private val cameraDevice: CameraDevice, private val session: CameraCaptureSession, private val sensorOrientation: Int ) { private val lock = ReentrantLock() private val callbackHandler = HandlerThread("exposure-manager").apply { start() }.looper.let { Handler(it) } @Volatile var currentPriority: ExposurePriority = ExposurePriority.FACE_AE private set @Volatile private var lastTouchTimestamp: Long = 0L @Volatile private var lastFaceRegion: Rect? = null private val touchAeTimeoutMs = 3000L private val pendingBuilder = LinkedBlockingQueue<CaptureRequest.Builder>() fun onTouchEvent(screenX: Float, screenY: Float, previewWidth: Int, previewHeight: Int) { lock.withLock { val sensorRegion = convertScreenToSensorRegion(screenX, screenY, previewWidth, previewHeight) currentPriority = ExposurePriority.TOUCH_AE lastTouchTimestamp = SystemClock.elapsedRealtime() submitRequest { builder -> builder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(sensorRegion)) builder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(sensorRegion)) builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO) } } } fun onFaceDetected(faceRect: Rect) { lock.withLock { lastFaceRegion = faceRect // 关键:如果当前是TOUCH_AE但已超时,或人脸框包含触摸区域,切回FACE_AE val now = SystemClock.elapsedRealtime() val touchExpired = (now - lastTouchTimestamp) > touchAeTimeoutMs val faceCoversTouch = isFaceCoveringTouch(faceRect) if (currentPriority == ExposurePriority.TOUCH_AE && (touchExpired || faceCoversTouch)) { currentPriority = ExposurePriority.FACE_AE submitRequest { builder -> builder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(faceRect.toSensorRegion())) builder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(faceRect.toSensorRegion())) builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) } } } } private fun submitRequest(action: (CaptureRequest.Builder) -> Unit) { callbackHandler.post { val builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) action(builder) session.setRepeatingRequest(builder.build(), null, callbackHandler) } } }这里有几个关键设计点要解释。
第一,提交请求走独立的Handler线程。我在submitRequest里用了callbackHandler.post,所有region更新都串行化,避免UI线程和Camera回调线程竞争同一个Builder。ReentrantLock只是保护状态变量的读写,真正的request提交顺序靠Handler的串行队列来保证。
第二,face ae的恢复条件做了一个“双保险”。不光是超时切回,还判断了“人脸框是否覆盖触摸点”。这个覆盖判断的代码如下:
private fun isFaceCoveringTouch(faceRect: Rect): Boolean { val touchSensor = lastTouchSensorRegion ?: return false // 取人脸框的几何中心 val faceCenterX = (faceRect.left + faceRect.right) / 2 val faceCenterY = (faceRect.top + faceRect.bottom) / 2 // 触摸点附近500单位(传感器坐标)的容差 val tolerance = 500 return faceCenterX in (touchSensor.centerX() - tolerance)..(touchSensor.centerX() + tolerance) && faceCenterY in (touchSensor.centerY() - tolerance)..(touchSensor.centerY() + tolerance) }这个判断解决了一个很实际的产品问题:用户触摸屏幕后,如果人脸恰好也在触摸点附近,那这时用户的本意可能就是想让人脸曝光,而不是锁定触摸点。所以没必要等3秒超时,直接切回face ae更符合直觉。
第三,超时时长的选择。我试过1秒、2秒、3秒、5秒。1秒太短,用户刚摸完屏幕还没看清曝光变化就被切回去了,体验很怪;5秒太长,人脸从暗处走到亮处,曝光一直锁在旧位置,人脸会过曝。3秒是个折中,跟系统相机的“触摸后自动恢复连续自动对焦”的时长体感一致。但这个值建议做成可配置项,Face detection频率高的场景可以适当缩短。
4.3 坐标换算的正确姿势
坐标换算是这个方案里最容易出错的地方,单独拿出来说。Camera2的坐标系定义:AE Region的坐标范围是0到10000,表示的是传感器输出图像的坐标,且不受旋转和镜像影响。也就是说,你看到的预览画面通常已经经过旋转,而AE Region需要的是传感器原始方向上的坐标。
我踩过的坑是:直接用getTransform()或者TextureView.getTransform()的结果去换算,结果发现方向是对的但镜像反了。最终的可靠换算是用官方推荐的公式,在onPreviewSizeChanged时计算一次变换矩阵:
fun createSensorCoordinateTransformer( previewWidth: Int, previewHeight: Int, sensorWidth: Int, sensorHeight: Int, sensorOrientation: Int ): (Float, Float) -> FloatArray { // 归一化到0~10000的传感器坐标 // 注意:preview可能做了scaleType=centerCrop,需要先计算裁剪偏移 val previewScale = max(sensorWidth.toFloat() / previewWidth, sensorHeight.toFloat() / previewHeight) val cropWidth = previewWidth * previewScale val cropHeight = previewHeight * previewScale val offsetX = (sensorWidth - cropWidth) / 2f val offsetY = (sensorHeight - cropHeight) / 2f return { screenX, screenY -> val sensorX = screenX * previewScale + offsetX val sensorY = screenY * previewScale + offsetY val normalizedX = sensorX / sensorWidth * 10000f val normalizedY = sensorY / sensorHeight * 10000f // 根据传感器方向做旋转映射 when (sensorOrientation) { 90 -> floatArrayOf(normalizedY, 10000f - normalizedX) 180 -> floatArrayOf(10000f - normalizedX, 10000f - normalizedY) 270 -> floatArrayOf(10000f - normalizedY, normalizedX) else -> floatArrayOf(normalizedX, normalizedY) } } }这里要注意:很多平台在竖屏时sensorOrientation是90,但你看到的预览画面实际已经通过Surface旋转过了。如果你的预览TextureView是竖屏布局,而传感器是横屏输出,那么在触摸回调里拿到的screenX, screenY是竖屏坐标,必须先映射到横屏传感器坐标再做归一化,否则x和y是反的。
验证坐标换算对不对的方法很土但很有效:取一个纯色场景,把touch ae的region设成一个小矩形,然后观察预览画面里曝光最亮/最暗的位置是否就是你触摸的位置。如果不是,用这个方法打印出换算后的region坐标,和画面的实际位置对比,就能快速定位是旋转、镜像还是缩放的问题。
4.4 兼容不同平台的region权重策略
最后是region权重。不同平台的ISP对多region合并的算法有差异。高通倾向取“所有region的加权平均”,联发科某些平台则直接取“权重最大的region”。这意味着同样的参数,在不同手机上表现可能完全不同。
我的建议是:触摸后不要保留两个region,而是直接替换。touch ae生效时,face ae的region不要追加进去,而是清空。这样不管什么平台,HAL都只会看到一套region,不会出现“两套region打架”的情况。
具体的Builder操作:
// touch ae生效时,清空face region builder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(touchRegionOnly)) builder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(touchRegionOnly)) // 不要调用add,每次都是全新的set如果产品需求确实希望“触摸对焦后,人脸仍然参与测光”,那就需要把两个region同时放到数组里,并调整权重让触摸点占主导(比如touch weight=800,face weight=200)。但这种方案在部分平台上有坑——人脸一旦移动,旧的face region不会自动更新,如果你不重新下发,HAL会一直拿着旧人脸位置的region参与测光,导致曝光偏差。所以做多region方案时,必须保证人脸位置变化后立即重新下发整套region数组。
5. 验证方法:怎么确认face ae真的恢复了
5.1 三个维度的量化验证
修复完代码,不能只凭肉眼确认“好像正常了”,要做量化验证。我分了三个维度:
维度一:AE_REGIONS回读确认。在CaptureCallback里回读CaptureResult.CONTROL_AE_REGIONS,确认触摸后它是touch ae的region,超时或人脸覆盖后它切换回face ae的region。写一个自动测试脚本,模拟触控事件,记录每次result的region变化时间点,可以精确算出切换延迟。
override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) { val aeRegions = result.get(CaptureResult.CONTROL_AE_REGIONS) aeRegions?.let { val center = calculateRegionCenter(it[0]) Log.d("AE_VERIFY", "center=$center, priority=${manager.currentPriority}") } }维度二:曝光值稳定性。让手机对准一个匀速转动的灯光场景,记录touch ae超时前后的CONTROL_AE_EXPOSURE_COMPENSATION和SENSOR_EXPOSURE_TIME。如果face ae恢复成功,曝光值会平滑过渡到以人脸为准的数值;如果恢复失败,曝光值会卡在触摸点的数值上。我当时的测试数据显示,修复前曝光值在触摸后一直保持不变(偏移约+1.2EV),修复后在3秒超时点开始变化,约5帧内收敛到人脸区域的最优值。
维度三:人脸移动跟随。让人脸从触摸点位置横向移动到画面另一端,验证曝光是否跟着人脸走。修复前,人在左边和右边曝光值几乎一样(说明还是touch ae的region在起作用);修复后,人脸移动时曝光值会随之调整,虽然存在约2到3帧的滞后,但整体趋势是跟随的。
5.2 重点回归场景清单
除了主流程,我还整理了一份回归测试清单,专门覆盖容易出问题的边缘场景:
- 触摸屏幕边缘(左、右、上、下四个边界)后,人脸从边角出现,确认region合法且能正常切回。
- 快速连点屏幕多次,确认不会出现region数组越界或者竞态。
- 人脸离开画面再回来,确认face ae能重新接管(此时不应依赖touch ae超时,而是靠人脸重新检测事件)。
- 相机前后摄切换后,传感器方向变化,确认坐标换算仍然正确。
- 灭屏再亮屏、切后台再回前台,确认状态管理器重置合理(如果App杀了进程,管理器会重新初始化;如果只是切后台,需要确认touch ae的timeout没有被后台时间影响)。
其中“切后台再回前台”这个case很隐蔽。如果App在后台停留了10秒,回到前台时,SystemClock.elapsedRealtime()是持续走的,所以touch ae早已超时,会正确切回face ae。但如果用的是System.currentTimeMillis(),用户改了系统时间就会出问题。建议所有时间戳统一用elapsedRealtime(),不要用wall clock。
6. 常见问题与排查技巧实录
6.1 为什么写了face ae的region,曝光还是不对
这个问题的答案有四种可能,按优先级排查:
- 线程竞态导致face ae的region根本没提交成功——用回读值确认。
- HAL还在AE收敛中,新region排队未生效——等3到5帧再观察。
- region坐标换算错误,face的region落在了画面上其他位置——用我上面提到的“纯色场景验证法”排查。
- 曝光补偿值被其他逻辑干扰——检查是否有其他地方在修改
CONTROL_AE_EXPOSURE_COMPENSATION。
6.2 为什么人脸框还在,但face ae就是不肯接管
最常见的原因是touch ae的timeout策略没写好。很多人的实现是“触摸后永远切不回face ae”,因为他们只在触摸时更新了状态,没有在检测到人脸时再次评估状态。我的方案里,onFaceDetected中主动判断并切换,就是为了解决这个问题。
另一种可能是,人脸检测回调的置信度太低,一直被过滤掉。看看你的onFaceDetected是否被人脸检测的置信度阈值挡住了。当时我调试时就发现,有些低光场景下人脸检测置信度降到0.3以下,回调根本不会触发,face ae自然就断了。这种情况下可以放宽置信度阈值,或者在没有face ae时退回到“环境测光”而非“保持上一个触摸点测光”。
6.3 触摸后画面频繁闪烁、曝光来回跳
这是另一个我遇到的衍生问题。根因是我在touch ae和face ae之间切换时,CONTROL_AF_MODE也跟着切换了,导致对焦马达反复拉动,曝光也跟着小幅跳动。后来我调整了策略:触摸后只切AE_REGIONS,AF_MODE保持不变,只在真正需要重新对焦时才动AF相关参数。这样曝光不会因为对焦搜索而抖动。
另外,CONTROL_AE_PRECAPTURE_TRIGGER别乱用。这个trigger是给闪光灯场景用的,正常拍照流程里不该在三方相机的预览阶段频繁触发。我一度用它来强制HAL重新收敛AE,结果导致每次切换region都经历一次完整的precapture流程,屏幕会先暗一下再亮回来。去掉之后,切换平滑多了。
6.4 代码层面容易忽略的三个细节
第一,request的repeating模式。如果用了setRepeatingRequest,需要确认你提交的是修改后的builder的build结果,而不是同一个builder对象反复build。Builder一旦build之后,如果再次使用需要重新创建,否则可能带上旧的状态。
第二,region的weight值。我见过有人把weight设成0想表示“不参与测光”,结果HAL直接忽略整个region。weight合法范围是1到1000,想不参与就干脆从数组里移除,不要用0凑数。
第三,多摄场景下要指定正确的cameraId。如果是超广角镜头做face ae,它的传感器坐标和主摄完全不同,坐标换算必须按对应镜头的SENSOR_INFO_ACTIVE_ARRAY_SIZE来做。当时我们在做超广角人像模式时就踩到了这个坑,表现为“主摄一切正常,切超广角后人脸曝光立刻错乱”——其实都是坐标没换。
6.5 排查工具的最终推荐
做这个case期间,我积累了一套比较顺手的排查工具链。Log层面,强烈建议把CaptureResult里的关键字段做成周期性的抽样日志,不要每个回调都打,否则一秒钟几十条日志容易把关键信息冲掉。我一般用SystemClock.elapsedRealtime()加上序号,每10帧打一次当前AE_REGIONS的center和曝光时间。
图像层面,可以开一个debug开关,把当前生效的AE_REGIONS画到预览画面上(一个半透明的矩形框)。这样能直观地看到touch ae和face ae切换时region位置的变化。这个功能虽然简单,但比纯看日志高效得多——毕竟日志里一串数字,很难想象它对应画面上的哪个位置。
最后说一点体会。这类AE策略问题,表面上是“touch ae导致face ae失败”,本质上是状态管理混乱。Camera开发里很多东西都是这样:底层能力开放了,但策略得自己拼。拼的时候只要有一个状态没想清楚,就会在某个不常见的时序上炸出来。这次case的教训就是:所有涉及状态切换的地方,先画清楚状态机,确认每个状态的进入和退出条件,再写代码。touch ae和face ae之间的关系本来不复杂,复杂的是你让它们无序竞争的那些瞬间。
如果你也在做三方相机的人脸相关功能,建议先把region回读验证机制搭好,再谈策略优化。没有可靠的观测手段,一切优化都是盲调。这个case做完之后,我把ExposureModeManager沉淀成了项目里的通用组件,后续做美颜、人像虚化、人脸追焦等功能时,都能直接复用这套优先级切换逻辑。希望这篇文章能帮你少走几步弯路。