Android Camera2 API实现多摄像头同步预览与独立控制实战
1. 项目缘起:为什么需要同时操作多个摄像头?
在Android应用开发里,处理单个摄像头预览和拍照,对于大多数开发者来说已经不是什么难题。但当你接到一个需求,要求同时打开手机的前后两个摄像头,或者处理一个外接的双目摄像头模组,把两个摄像头的画面并排显示在屏幕上时,很多人可能就会觉得有点棘手了。这不仅仅是“再打开一个Camera实例”那么简单,它涉及到资源管理、生命周期同步、性能优化等一系列更深层次的问题。
我最初接触这个需求,是在一个需要实时人脸比对和活体检测的项目里。客户要求前置摄像头捕捉用户面部进行活体判断,同时后置摄像头扫描环境或文档,两个画面需要实时同步展示。市面上很多现成的SDK要么只支持单摄,要么封装得太死,无法满足我们自定义UI和灵活控制的需求。于是,我们决定从Android Camera2 API的底层开始,自己动手实现一套稳定、高效的多摄像头同显方案。
这个方案的核心价值在于它的“实用性”和“可扩展性”。它不仅解决了“同时预览”这个基本问题,还完整覆盖了在实际开发中你一定会遇到的细节:比如如何处理不同摄像头的分辨率差异?如何独立控制每个摄像头的缩放、对焦和旋转?截图时如何确保两个画面同步?以及,如何优雅地处理像红外摄像头这样的特殊设备?接下来,我将把这个从零到一踩坑、填坑的过程,毫无保留地分享出来。
2. 技术选型与架构设计:为什么是Camera2 API?
在开始敲代码之前,选择一个正确的API是成功的一半。Android的摄像头开发主要经历了Camera、Camera2和最新的CameraX三个阶段。对于多摄像头开发,我的结论非常明确: 必须使用Camera2 API 。
Camera API(android.hardware.Camera) 是旧时代的产物,它在设计之初就没有充分考虑多摄像头的场景。虽然通过一些Hack手段(比如先打开后置,再快速打开前置)可能实现双开,但这种方式极不稳定,容易导致预览卡顿、画面撕裂甚至应用崩溃。更重要的是,它缺乏对每个摄像头参数的精细控制能力,无法满足我们独立调整缩放、焦距等需求。
CameraX 是Google力推的现代化开发库,它基于Camera2构建,提供了更简洁的生命周期感知和用例(Use Case)模型。对于常见的单摄像头拍照、预览等场景,CameraX无疑是首选,能大幅降低开发复杂度。然而,在撰写本文时,CameraX对 多个摄像头流的同时输出和独立控制 的支持还不够成熟和灵活。它的用例绑定模型更倾向于“一个用例绑定一个摄像头”,要同时管理两个独立的预览流,需要更复杂的配置,并且可能无法直接访问底层Camera2的某些高级特性(如手动对焦环、精确的缩放控制)。对于追求极致控制和自定义的复杂多摄场景,直接使用Camera2 API仍然是更强大、更可靠的选择。
Camera2 API(android.hardware.camera2) 采用了全新的管道(Pipeline)模型,将摄像头抽象为一个可以向多个目标(Surface)输出图像数据的设备。这正是我们需要的核心模型:我们可以为前后两个摄像头分别创建独立的 CameraCaptureSession ,每个Session绑定到不同的预览Surface(例如两个 TextureView ),从而实现完全独立的控制和数据流。Camera2 API提供了对摄像头硬件的底层访问能力,包括:
- 摄像头枚举 :通过
CameraManager获取设备上所有可用的摄像头ID列表,并查询每个摄像头的特性(如朝向、是否支持逻辑多摄等)。 - 精细控制 :通过
CaptureRequest可以独立设置每个摄像头的几乎所有参数,包括缩放(通过SCALER_CROP_REGION)、对焦模式、曝光补偿、输出分辨率等。 - 同步管理 :虽然两个摄像头会话是独立的,但我们可以通过应用层逻辑来协调它们的启动、停止和状态同步。
因此,我们的架构设计思路如下:
- 独立会话 :为每个需要打开的摄像头(如
"0"代表后置,"1"代表前置)创建独立的CameraDevice和CameraCaptureSession。 - 独立预览目标 :每个会话绑定到独立的
Surface(通常来自TextureView或SurfaceView),用于渲染各自的画面。 - 集中式管理器 :创建一个
MultiCameraManager类,统一管理所有摄像头会话的生命周期(打开、关闭、异常处理),并协调它们与Activity/Fragment生命周期的同步。 - 参数隔离 :每个摄像头的控制参数(缩放、对焦等)完全独立,通过各自的
CaptureRequest.Builder进行设置。
这个架构清晰地将责任分离,使得代码易于维护和扩展,例如未来增加第三个外接USB摄像头也会相对容易。
3. 核心实现步骤详解:从权限到双画面预览
理论说完了,我们进入实战环节。我会按照开发的自然顺序,一步步拆解关键代码和配置。请注意,以下代码是核心逻辑的提炼,在实际项目中需要结合完整的生命周期管理和错误处理。
3.1 环境与权限准备
多摄像头操作需要声明更多的权限,并在运行时动态申请。
AndroidManifest.xml 配置:
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<!-- 如果支持,可添加相机闪光灯等特性 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 10 (API 29) 及以上,如果需要在后台访问摄像头,可能需要此权限 -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- 注意:后台位置权限敏感,非必要不申请。通常预览功能不需要。 -->
重要提示 :从Android 10开始,如果应用切换到后台后仍需访问摄像头,可能会受到限制。绝大多数多摄像头预览场景都是前台行为,因此无需声明后台位置权限。务必遵循最小权限原则。
运行时权限申请: 在Activity中,我们需要在 onCreate 或合适的时机请求 CAMERA 权限。由于可能同时需要读写存储权限来保存截图,通常一并申请。
// 本例使用Kotlin,Java逻辑类似
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.WRITE_EXTERNAL_STORAGE // API 33以下需要,33及以上使用MediaStore
)
private fun requestPermissions() {
if (checkAllPermissionsGranted()) {
setupMultiCamera()
} else {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
)
}
}
override fun onRequestPermissionsResult(...) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
setupMultiCamera()
} else {
Toast.makeText(this, "权限被拒绝,无法使用摄像头", Toast.LENGTH_SHORT).show()
}
}
3.2 摄像头枚举与选择
不是所有手机都有前后双摄,甚至有的设备有多个后置或前置摄像头。我们不能硬编码摄像头ID,必须动态查询。
private lateinit var cameraManager: CameraManager
private var backCameraId: String? = null
private var frontCameraId: String? = null
private fun setupCameraIds() {
cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraIdList = cameraManager.cameraIdList
for (id in cameraIdList) {
val characteristics = cameraManager.getCameraCharacteristics(id)
val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)
when (lensFacing) {
CameraCharacteristics.LENS_FACING_BACK -> {
if (backCameraId == null) {
backCameraId = id // 通常取第一个后置摄像头
// 可以进一步根据分辨率、是否支持RAW等特性选择最优摄像头
}
}
CameraCharacteristics.LENS_FACING_FRONT -> {
if (frontCameraId == null) {
frontCameraId = id
}
}
CameraCharacteristics.LENS_FACING_EXTERNAL -> {
// 处理外接USB或红外摄像头
Log.d(TAG, "发现外接摄像头: $id")
}
}
}
if (backCameraId == null || frontCameraId == null) {
Log.e(TAG, "未找到足够可用的摄像头")
// 处理错误,例如只显示一个摄像头或提示用户
}
}
这里有一个 关键细节 :有些高端手机拥有多个后置摄像头(广角、长焦、超广角)。上述代码简单取了第一个找到的后置摄像头。在实际产品中,你可能需要根据 StreamConfigurationMap 查询每个摄像头支持的分辨率,然后选择最适合预览分辨率(如1080P)的那个摄像头ID。对于双目摄像头模组(通常是外接USB摄像头),它们会被标记为 LENS_FACING_EXTERNAL ,你需要根据供应商提供的特性(如通过 CameraCharacteristics 查询自定义的Key)来识别哪个是左目、哪个是右目。
3.3 创建预览界面与Surface
我们使用两个 TextureView 来分别显示前后摄像头的画面。布局文件很简单:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextureView
android:id="@+id/texture_view_back"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<TextureView
android:id="@+id/texture_view_front"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
在Activity中,我们需要等待 TextureView 准备就绪( SurfaceTexture 可用)。
private val textureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
// 两个TextureView的监听器可以合并处理
if (::textureViewBack.isInitialized && ::textureViewFront.isInitialized) {
if (textureViewBack.isAvailable && textureViewFront.isAvailable) {
// 两个Surface都准备好了,开始打开摄像头
openAllCameras()
}
}
}
// ... 其他重写方法 onSurfaceTextureSizeChanged, onSurfaceTextureDestroyed, onSurfaceTextureUpdated
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_multi_camera)
textureViewBack.surfaceTextureListener = textureListener
textureViewFront.surfaceTextureListener = textureListener
}
3.4 打开摄像头并创建会话(核心中的核心)
这是整个流程最复杂的一步。我们必须为每个摄像头独立执行“打开设备 -> 创建会话”的流程,并且要处理好异步回调可能带来的时序问题。
我们定义一个 CameraSessionWrapper 类来封装单个摄像头的所有状态:
class CameraSessionWrapper(
val cameraId: String,
val textureView: TextureView,
val isFrontFacing: Boolean
) {
var cameraDevice: CameraDevice? = null
var captureSession: CameraCaptureSession? = null
lateinit var previewRequestBuilder: CaptureRequest.Builder
var currentZoom = 1.0f // 默认无缩放
fun openCamera(cameraManager: CameraManager, handler: Handler = Handler()) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
return
}
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
this@CameraSessionWrapper.cameraDevice = device
createPreviewSession(device)
}
override fun onDisconnected(device: CameraDevice) {
close()
}
override fun onError(device: CameraDevice, error: Int) {
Log.e(TAG, "打开摄像头 $cameraId 失败,错误码: $error")
close()
}
}, handler)
}
private fun createPreviewSession(device: CameraDevice) {
val surfaceTexture = textureView.surfaceTexture
// 设置默认预览尺寸,这里取摄像头支持的最大预览尺寸,实际应根据TextureView大小和性能权衡选择
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val previewSize = map?.getOutputSizes(SurfaceTexture::class.java)?.maxByOrNull { it.width * it.height }
previewSize?.let {
surfaceTexture.setDefaultBufferSize(it.width, it.height)
}
val previewSurface = Surface(surfaceTexture)
// 创建预览请求
previewRequestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
previewRequestBuilder.addTarget(previewSurface)
// 配置会话参数,这里只添加预览Surface
device.createCaptureSession(listOf(previewSurface), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
this@CameraSessionWrapper.captureSession = session
// 开始重复发送预览请求,画面就会显示了
updatePreview()
}
override fun onConfigureFailed(session: CameraCaptureSession) {
Log.e(TAG, "摄像头 $cameraId 会话配置失败")
}
}, null)
}
fun updatePreview() {
captureSession?.setRepeatingRequest(previewRequestBuilder.build(), null, null)
}
fun close() {
captureSession?.close()
cameraDevice?.close()
captureSession = null
cameraDevice = null
}
}
在Activity中,我们创建两个 CameraSessionWrapper 实例并管理它们:
private lateinit var backSession: CameraSessionWrapper
private lateinit var frontSession: CameraSessionWrapper
private fun openAllCameras() {
backCameraId?.let {
backSession = CameraSessionWrapper(it, textureViewBack, isFrontFacing = false)
backSession.openCamera(cameraManager)
}
frontCameraId?.let {
// **关键点:轻微延迟打开第二个摄像头**
// 部分手机硬件资源有限,同时发出两个openCamera调用可能导致其中一个失败。
// 延迟100-200ms可以显著提高成功率。
Handler(Looper.getMainLooper()).postDelayed({
frontSession = CameraSessionWrapper(it, textureViewFront, isFrontFacing = true)
frontSession.openCamera(cameraManager)
}, 150)
}
}
这里有一个非常重要的经验 :部分手机(尤其是中低端机型)的摄像头硬件或驱动可能无法承受两个摄像头被“同时”瞬间打开的巨大资源请求,会导致其中一个 onError 。因此,对第二个摄像头的 openCamera 调用做一个短暂的延迟(如150毫秒),是一个在实践中被证明有效的“土办法”,能极大提升双开成功率。这不是API的设计缺陷,而是对硬件资源竞争的一种现实妥协。
3.5 处理生命周期
我们必须确保在Activity进入后台时及时释放摄像头资源,否则其他应用将无法使用摄像头,且系统可能会强制关闭你的应用。
override fun onPause() {
super.onPause()
backSession.close()
frontSession.close()
}
override fun onResume() {
super.onResume()
if (textureViewBack.isAvailable && textureViewFront.isAvailable) {
// 如果Surface仍然可用(例如屏幕旋转但未销毁TextureView),则重新打开
openAllCameras()
}
// 否则,等待TextureView的onSurfaceTextureAvailable回调
}
对于屏幕旋转等配置变更,更优雅的做法是在 AndroidManifest.xml 中为Activity配置 android:configChanges="orientation|screenSize" ,并手动处理预览画面的旋转,避免Activity重建导致摄像头会话频繁开关。但这会引入额外的旋转逻辑处理。对于Demo,最简单的办法是让Activity重建,我们在 onCreate 中重新走一遍初始化流程。
4. 高级功能实现:缩放、对焦、旋转与镜像
当两个摄像头画面稳定预览后,下一步就是实现对每个摄像头的独立控制。这些功能都通过操作 CaptureRequest.Builder 来实现。
4.1 独立缩放控制
Camera2的缩放不是直接设置倍数,而是通过设置一个裁剪区域( CROP_REGION )来实现。传感器的有效像素区域是一个矩形,我们通过缩小这个矩形区域来实现“放大”画面的效果(相当于数码变焦)。
fun setZoom(sessionWrapper: CameraSessionWrapper, zoomLevel: Float) {
val device = sessionWrapper.cameraDevice ?: return
val characteristics = cameraManager.getCameraCharacteristics(sessionWrapper.cameraId)
val sensorRect = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) ?: return
// 计算裁剪区域
val ratio = 1.0f / zoomLevel
val cropWidth = (sensorRect.width() * ratio).toInt()
val cropHeight = (sensorRect.height() * ratio).toInt()
val cropX = (sensorRect.width() - cropWidth) / 2
val cropY = (sensorRect.height() - cropHeight) / 2
val zoomRect = Rect(cropX, cropY, cropX + cropWidth, cropY + cropHeight)
// 更新预览请求
sessionWrapper.previewRequestBuilder.set(CaptureRequest.SCALER_CROP_REGION, zoomRect)
sessionWrapper.currentZoom = zoomLevel
sessionWrapper.updatePreview()
}
注意事项 :
- 缩放范围 :需要通过
CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM查询该摄像头支持的最大数码变焦倍数。zoomLevel应介于1.0和该最大值之间。 - 性能与画质 :数码变焦本质是裁剪和插值,放大倍数过大会导致画面模糊和锯齿。如果手机支持双摄融合变焦(通过切换不同物理焦距的摄像头实现),逻辑会更复杂,需要查询
SCALER_AVAILABLE_STREAM_USE_CASES并可能涉及多个摄像头ID的切换,这超出了本文基础范围。 - 独立控制 :我们为
backSession和frontSession分别调用setZoom,它们之间的缩放倍数互不影响。
4.2 独立对焦控制
对焦模式同样通过 CaptureRequest.Builder 设置。常见的模式有 CONTROL_AF_MODE_AUTO (自动对焦)、 CONTROL_AF_MODE_CONTINUOUS_PICTURE (连续对焦,适合预览)、 CONTROL_AF_MODE_CONTINUOUS_VIDEO (连续对焦,适合录像)和 CONTROL_AF_MODE_OFF (手动固定焦距)。
fun setFocusMode(sessionWrapper: CameraSessionWrapper, afMode: Int) {
sessionWrapper.previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, afMode)
// 如果切换到自动对焦模式,可以触发一次对焦扫描
if (afMode == CaptureRequest.CONTROL_AF_MODE_AUTO) {
sessionWrapper.previewRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START)
}
sessionWrapper.updatePreview()
}
触摸对焦 的实现稍微复杂一些:需要在 TextureView 上监听触摸事件,将触摸坐标转换为传感器坐标系的 MeteringRectangle ,然后通过 CaptureRequest.Builder 设置 CONTROL_AF_REGIONS 并触发对焦。
textureViewBack.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
handleTouchFocus(event.x, event.y, backSession)
true
} else {
false
}
}
// 对frontSession同理
关键点 :前后摄像头的对焦区域计算是独立的。因为两个摄像头的传感器尺寸、镜头朝向可能不同,所以从 TextureView 坐标到传感器坐标的转换矩阵需要分别根据各自的 CameraCharacteristics 来计算。
4.3 处理画面旋转与镜像
这是多摄像头预览UI正确显示的关键,也是最容易出错的地方。
1. 画面旋转: 手机有自然方向(通常竖屏为0度),但摄像头传感器的安装方向是固定的。当用户旋转手机时,我们需要调整预览画面的显示方向,使其与用户期望的方向一致。同时,我们还需要考虑 TextureView 本身的旋转。
private fun getDisplayRotation(): Int {
return when (windowManager.defaultDisplay.rotation) {
Surface.ROTATION_0 -> 0
Surface.ROTATION_90 -> 90
Surface.ROTATION_180 -> 180
Surface.ROTATION_270 -> 270
else -> 0
}
}
private fun configureTransform(sessionWrapper: CameraSessionWrapper) {
val textureView = sessionWrapper.textureView
val characteristics = cameraManager.getCameraCharacteristics(sessionWrapper.cameraId)
val sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
val deviceRotation = getDisplayRotation()
// 计算预览需要旋转的角度
val isFrontCamera = sessionWrapper.isFrontFacing
val previewRotation = if (isFrontCamera) {
// 前置摄像头需要特殊处理镜像,这里先算基础旋转
(sensorOrientation + deviceRotation) % 360
} else {
// 后置摄像头
(sensorOrientation - deviceRotation + 360) % 360
}
// 应用旋转到TextureView
val matrix = Matrix()
val viewWidth = textureView.width.toFloat()
val viewHeight = textureView.height.toFloat()
val centerX = viewWidth / 2
val centerY = viewHeight / 2
matrix.postRotate(previewRotation.toFloat(), centerX, centerY)
// 前置摄像头需要额外处理镜像(见下文)
textureView.setTransform(matrix)
}
2. 前置摄像头镜像: 用户习惯看到的前置摄像头画面是镜像的(就像照镜子)。但Camera2 API输出的原始数据是非镜像的。因此,我们需要在旋转的基础上,对X轴进行缩放-1来实现水平翻转。
// 在 configureTransform 函数中,针对前置摄像头添加镜像
if (isFrontCamera) {
// 水平镜像翻转
matrix.postScale(-1f, 1f, centerX, centerY)
}
一个常见的坑 :如果你同时设置了旋转和镜像, 顺序很重要 。通常先进行旋转,再进行镜像,这样得到的画面才符合用户直觉。顺序错了会导致画面上下或左右颠倒。
4.4 同时截图保存
同时为两个摄像头截图,意味着需要为每个摄像头创建一个临时的 ImageReader 来捕获一帧静态图像,并处理好两个异步回调的同步问题。
fun captureBothCameras() {
val backImageReader = ImageReader.newInstance(1920, 1080, ImageFormat.JPEG, 2)
val frontImageReader = ImageReader.newInstance(1920, 1080, ImageFormat.JPEG, 2)
var backImage: Image? = null
var frontImage: Image? = null
val lock = Object() // 用于同步两个回调
backImageReader.setOnImageAvailableListener({ reader ->
val image = reader.acquireLatestImage()
synchronized(lock) {
backImage = image
if (frontImage != null) {
saveBothImages(backImage!!, frontImage!!)
}
}
}, backgroundHandler)
frontImageReader.setOnImageAvailableListener({ reader ->
val image = reader.acquireLatestImage()
synchronized(lock) {
frontImage = image
if (backImage != null) {
saveBothImages(backImage!!, frontImage!!)
}
}
}, backgroundHandler)
// 为每个摄像头创建单独的拍照请求
val backCaptureBuilder = backSession.cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
backCaptureBuilder?.addTarget(backImageReader.surface)
backCaptureBuilder?.set(CaptureRequest.JPEG_ORIENTATION, getJpegOrientation(backSession.cameraId))
backSession.captureSession?.capture(backCaptureBuilder?.build(), null, null)
val frontCaptureBuilder = frontSession.cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
frontCaptureBuilder?.addTarget(frontImageReader.surface)
frontCaptureBuilder?.set(CaptureRequest.JPEG_ORIENTATION, getJpegOrientation(frontSession.cameraId))
frontSession.captureSession?.capture(frontCaptureBuilder?.build(), null, null)
}
private fun saveBothImages(backImage: Image, frontImage: Image) {
// 将Image转换为byte数组,再保存为文件
val backBuffer = backImage.planes[0].buffer
val backBytes = ByteArray(backBuffer.remaining())
backBuffer.get(backBytes)
val frontBuffer = frontImage.planes[0].buffer
val frontBytes = ByteArray(frontBuffer.remaining())
frontBuffer.get(frontBytes)
// 使用后台线程保存文件,文件名可以加上时间戳区分
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
saveImageToFile(backBytes, "BACK_$timestamp.jpg")
saveImageToFile(frontBytes, "FRONT_$timestamp.jpg")
// 重要:关闭Image对象,释放资源
backImage.close()
frontImage.close()
}
关键细节 :
- 同步等待 :我们无法预知哪个摄像头的图像先到达。使用一个共享锁和状态变量来等待两张图片都就绪后再一起保存,确保“同时”的概念。
- 资源释放 :
Image对象是重量级资源,必须在使用后及时调用close()方法释放,否则会导致内存泄漏和后续图像捕获失败。 - 方向设置 :
JPEG_ORIENTATION确保了保存的图片文件带有正确的旋转信息,这样在相册中查看时方向才是正确的。getJpegOrientation函数需要综合传感器方向和设备旋转来计算,逻辑与预览旋转类似但略有不同。
5. 特殊设备与进阶话题:红外摄像头与活体检测
标题中提到了红外摄像头和活体检测,这属于更专业的领域,但基于我们已搭建的多摄像头框架,可以很好地接入。
5.1 接入红外摄像头
红外摄像头通常作为外接USB设备或手机上的特殊模组出现。在Android系统中,它们同样通过Camera2 API暴露。
识别红外摄像头 :遍历 CameraManager.getCameraIdList() ,对于每个ID,通过 CameraCharacteristics 查询其支持的 CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES 。有些红外摄像头会标记为 REQUEST_AVAILABLE_CAPABILITIES_MONOCHROME (单色),或者通过自定义的Key(需要查阅设备厂商的SDK文档)来标识。更简单的方法是,外接USB摄像头通常其 LENS_FACING 为 EXTERNAL ,你可以通过尝试打开并检查其输出的图像格式(如 ImageFormat.Y8 代表单色8位灰度图)来判断。
处理灰度图像 :红外摄像头输出的是灰度图像(没有颜色信息)。 TextureView 默认期望的是RGB格式。因此,你需要:
- 使用
ImageReader以ImageFormat.Y8或ImageFormat.YUV_420_888格式捕获图像。 - 将YUV或Y8数据通过OpenCV、RenderScript或自定义的
Shader程序转换为RGB,再渲染到一个SurfaceView或自定义的View上。或者,更简单的方式是,直接将ImageReader的Surface作为CameraCaptureSession的输出目标,然后从ImageReader中读取字节数据进行处理(如活体检测算法),而不直接显示原始红外画面。
在框架中集成 :你可以在 MultiCameraManager 中增加一个 IRCameraSessionWrapper ,其打开和预览流程与普通摄像头一致,只是在创建 ImageReader 和渲染时使用处理灰度图的逻辑。
5.2 结合活体检测与人脸识别
这是一个算法层面的集成。我们的多摄像头框架为算法提供了稳定的视频流输入源。
典型工作流 :
- 输入源 :前置彩色摄像头提供RGB视频流用于人脸检测和特征点定位。
- 活体线索 :
- 红外活体 :如果设备有红外摄像头,同步获取红外图像。真实人脸在红外下具有特定的温度分布特征,而照片或屏幕翻拍则没有,这是非常有效的防御手段。
- 动作指令 :通过语音或UI提示用户完成眨眼、张嘴、摇头等动作。利用前置摄像头的视频流进行动作检测。
- 3D结构光 (如有):利用结构光摄像头获取深度图,判断是否为立体人脸。
- 框架集成 :
- 在
onImageAvailableListener回调中,分别从前后摄像头的ImageReader获取最新的图像帧。 - 将图像帧转换为算法库(如OpenCV、libfacedetection,或商业SDK如虹软、商汤)所需的格式。
- 调用算法库进行人脸检测、特征点提取、活体判断。
- 将判断结果和可视化框(如人脸矩形、特征点)通过UI线程绘制到对应的
TextureView上。
- 在
性能考量 :人脸识别和活体检测算法通常比较耗时。务必在后台线程执行算法逻辑,避免阻塞摄像头预览线程。可以使用双缓冲或线程池来处理图像帧,确保预览流畅性和算法实时性的平衡。
6. 避坑指南与性能优化
在实际开发中,我踩过不少坑,这里总结几个最有代表性的:
坑1:双开失败或预览黑屏/卡顿
- 原因 :硬件资源(如ISP图像信号处理器)竞争,或相机服务内部状态冲突。
- 解决方案 :
- 延迟打开 :如前所述,第二个摄像头延迟100-200ms打开。
- 降低分辨率 :不要同时使用两个摄像头最高分辨率进行预览。通过
StreamConfigurationMap.getOutputSizes()选择适中的分辨率(如720P或1080P)。 - 检查可用性 :在
openCamera前,通过CameraCharacteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)检查摄像头是否支持REQUEST_AVAILABLE_CAPABILITIES_CONCURRENT_CAMERA(并发摄像头)。但注意,很多设备即使不支持此标志,也能通过我们的方法成功双开。 - 确保Surface就绪 :绝对要在
TextureView的SurfaceTexture可用(onSurfaceTextureAvailable回调后)后再去创建CameraCaptureSession。
坑2:画面旋转或镜像错误
- 原因 :旋转和镜像矩阵的计算顺序或参数错误。
- 解决方案 :
- 严格按照 “先旋转,后镜像” 的顺序应用变换。
- 仔细计算
sensorOrientation(摄像头传感器安装方向)和deviceRotation(设备当前旋转角度)的关系。记住,后置摄像头和前置摄像头的计算公式是 不同 的。 - 在
setTransform后,调用textureView.requestLayout()有时能解决显示异常。
坑3:内存泄漏与资源未释放
- 原因 :
CameraDevice、CameraCaptureSession、ImageReader、Image等对象未在onPause或onDestroy中正确关闭。 - 解决方案 :
- 在
CameraSessionWrapper中实现close()方法,并确保按顺序关闭:先captureSession?.stopRepeating()然后captureSession?.close(),最后cameraDevice?.close()。 - 所有
Image对象在使用完毕后立即close()。 - 在Activity的
onDestroy中,确保所有Handler也被正确释放。
- 在
坑4:不同机型兼容性问题
- 现象 :在A手机上运行正常,在B手机上闪退或画面异常。
- 解决方案 :
- 宽高比适配 :不同摄像头支持的分辨率宽高比可能不同(如4:3, 16:9, 18:9)。在设置
SurfaceTexture的缓冲区大小时,需要选择一个与TextureView显示区域宽高比最接近的预览尺寸,否则画面会被拉伸变形。可以通过计算宽高比差值来选择最佳尺寸。 - 特性检查 :任何API调用前,先通过
CameraCharacteristics检查是否支持该特性。例如,调用setZoom前,检查SCALER_AVAILABLE_MAX_DIGITAL_ZOOM是否大于1.0。 - 异常捕获 :在所有相机操作的回调中(
onError,onDisconnected)做好日志记录和优雅降级处理(如关闭出错的摄像头,仅显示另一个)。
- 宽高比适配 :不同摄像头支持的分辨率宽高比可能不同(如4:3, 16:9, 18:9)。在设置
性能优化建议 :
- 预览尺寸 :选择
TextureView尺寸的2倍左右分辨率即可,过高的预览分辨率消耗更多CPU/GPU和电量。 - ImageReader缓冲 :创建
ImageReader时,第三个参数maxImages不要设置过大,2-3张即可,够用就行,避免内存浪费。 - 后台线程 :所有相机操作(打开设备、创建会话、处理图像)都应使用后台线程的
Handler,避免阻塞主线程。可以使用HandlerThread来管理。 - 避免频繁操作 :像缩放、对焦模式切换这类操作,不要每帧都更新
CaptureRequest。可以在用户交互完成后一次性更新。
这套从底层Camera2 API构建的多摄像头控制框架,虽然初期搭建有一定复杂度,但它提供了无与伦比的灵活性和控制力。一旦跑通,你可以在此基础上轻松扩展出各种有趣且实用的功能,比如画中画、双摄对比、3D扫描等。希望这篇详尽的指南能帮助你绕过我当年踩过的那些坑,更顺畅地实现你的创意。
更多推荐
所有评论(0)