Android Camera2 教程 · 第三章 · 预览

Android Camera2 教程 · 第三章 · 预览

DarylGo关注

Android Camera

上一章《Camera2 开启相机》我们学习了如何开启和关闭相机,接下来我们来学习如何开启预览。

阅读完本章,你将会学到以下几个知识点:

  1. 如何配置预览尺寸
  2. 如何创建 CameraCaptureSession
  3. 如何创建 CaptureRequest
  4. 如何开启和关闭预览
  5. 如何适配预览画面的比例
  6. 如何使用 ImageReader 获取预览数据
  7. 设备方向的概念
  8. 局部坐标系的概念
  9. 显示方向的概念
  10. 摄像头传感器方向的概念
  11. 如何矫正图像数据的方向

你可以在 https://github.com/darylgo/Camera2Sample 下载相关的源码,并且切换到 Tutorial3 标签下。

1 获取预览尺寸

在第一章《Camera2 概览》我们提到了 CameraCharacteristics 是一个只读的相机信息提供者,其内部携带大量的相机信息,包括代表相机朝向的 LENS_FACING;判断闪光灯是否可用的 FLASH_INFO_AVAILABLE;获取所有可用 AE 模式的 CONTROL_AE_AVAILABLE_MODES 等等。如果你对 Camera1 比较熟悉,那么 CameraCharacteristics 有点像 Camera1 的 Camera.CameraInfo 或者 Camera.Parameters。CameraCharacteristics 以键值对的方式提供相机信息,你可以通过 CameraCharacteristics.get() 方法获取相机信息,该方法要求你传递一个 Key 以确定你要获取哪方面的相机信息,例如下面的代码展示了如何获取摄像头方向信息:

val cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraId)
val lensFacing = cameraCharacteristics[CameraCharacteristics.LENS_FACING]
when(lensFacing) {
    CameraCharacteristics.LENS_FACING_FRONT -> { // 前置摄像头 }
    CameraCharacteristics.LENS_FACING_BACK -> { // 后置摄像头 }
    CameraCharacteristics.LENS_FACING_EXTERNAL -> { // 外置摄像头 }
}

CameraCharacteristics 有大量的 Key 定义,这里就不一一阐述,当你在开发过程中需要获取某些相机信息的时候再去查阅 API文档即可。

由于不同厂商对相机的实现都会有差异,所以很多参数在不同的手机上支持的情况也不一样,相机的预览尺寸也是,所以接下来我们就要通过 CameraCharacteristics 获取相机支持的预览尺寸列表。所谓的预览尺寸,指的就是相机把画面输出到手机屏幕上供用户预览的尺寸,通常来说我们希望预览尺寸在不超过手机屏幕分辨率的情况下,越大越好。另外,出于业务需求,我们的相机可能需要支持多种不同的预览比例供用户选择,例如 4:3 和 16:9 的比例。由于不同厂商对相机的实现都会有差异,所以很多参数在不同的手机上支持的情况也不一样,相机的预览尺寸也是。所以在设置相机预览尺寸之前,我们先通过 CameraCharacteristics 获取该设备支持的所有预览尺寸:

val streamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val supportedSizes = streamConfigurationMap?.getOutputSizes(SurfaceTexture::class.java)

从上面的代码可以看出预览尺寸列表并不是直接从 CameraCharacteristics 获取的,而是先通过 SCALER_STREAM_CONFIGURATION_MAP 获取 StreamConfigurationMap 对象,然后通过 StreamConfigurationMap.getOutputSizes() 方法获取尺寸列表,该方法会要求你传递一个 Class 类型,然后根据这个类型返回对应的尺寸列表,如果给定的类型不支持,则返回 null,你可以通过 StreamConfigurationMap.isOutputSupportedFor() 方法判断某一个类型是否被支持,常见的类型有:

  • ImageReader:常用来拍照或接收 YUV 数据。
  • MediaRecorder:常用来录制视频。
  • MediaCodec:常用来录制视频。
  • SurfaceHolder:常用来显示预览画面。
  • SurfaceTexture:常用来显示预览画面。

由于我们使用的是 SurfaceTexture,所以显然这里我们就要传递 SurfaceTexture.class 获取支持的尺寸列表。如果我们把所有的预览尺寸都打印出来看时,会发现一个比较特别的情况,就是预览尺寸的宽是长边,高是短边,例如 1920x1080,而不是 1080x1920,这是因为相机 Sensor 的宽是长边,而高是短边。

在获取到预览尺寸列表之后,我们要根据自己的实际需求过滤出其中一个最符合要求的尺寸,并且把它设置给相机,在我们的 Demo 里,只有当预览尺寸的比例和大小都满足要求时才能被设置给相机,如下所示:

@WorkerThread
private fun getOptimalSize(cameraCharacteristics: CameraCharacteristics, clazz: Class<*>, maxWidth: Int, maxHeight: Int): Size? {
    val aspectRatio = maxWidth.toFloat() / maxHeight
    val streamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
    val supportedSizes = streamConfigurationMap?.getOutputSizes(clazz)
    if (supportedSizes != null) {
        for (size in supportedSizes) {
            if (size.width.toFloat() / size.height == aspectRatio && size.height <= maxHeight && size.width <= maxWidth) {
                return size
            }
        }
    }
    return null
}

2 配置预览尺寸

在获取适合的预览尺寸之后,接下来就是配置预览尺寸使其生效了。在配置尺寸方面,Camera2 和 Camera1 有着很大的不同,Camera1 是将所有的尺寸信息都设置给相机,而 Camera2 则是把尺寸信息设置给 Surface,例如接收预览画面的 SurfaceTexture,或者是接收拍照图片的 ImageReader,相机在输出图像数据的时候会根据 Surface 配置的 Buffer 大小输出对应尺寸的画面。

获取 Surface 的方式有很多种,可以通过 TextureView、SurfaceView、ImageReader 甚至是通过 OpenGL 创建,这里我们要将预览画面显示在屏幕上,所以我们选择了 TextureView,并且通过 TextureView.SurfaceTextureListener 回调接口监听 SurfaceTexture 的状态,在获取可用的 SurfaceTexture 对象之后通过 SurfaceTexture.setDefaultBufferSize() 设置预览画面的尺寸,最后使用 Surface(SurfaceTexture) 构造方法创建出预览的 Surface 对象:

首先,我们在布局文件中添加一个 TextureView,并给它取个 ID 叫 camera_preview:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextureView
        android:id="@+id/camera_preview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

然后我们在 Activity 里获取 TextureView 对象,并且注册一个 TextureView.SurfaceTextureListener 用于监听 SurfaceTexture 的状态:

private inner class PreviewSurfaceTextureListener : TextureView.SurfaceTextureListener {
    @MainThread
    override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) = Unit

    @MainThread
    override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit

    @MainThread
    override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean = false

    @MainThread
    override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
        previewSurfaceTexture = surfaceTexture
    }
}
cameraPreview = findViewById<CameraPreview>(R.id.camera_preview)
cameraPreview.surfaceTextureListener = PreviewSurfaceTextureListener()

当 SurfaceTexture 可用的时候会回调 onSurfaceTextureAvailable() 方法并且把 SurfaceTexture 对象和尺寸传递给我们,此时我们要做的就是通过 SurfaceTexture.setDefaultBufferSize() 设置预览画面的尺寸并且创建 Surface 对象:

val previewSize = getOptimalSize(cameraCharacteristics, SurfaceTexture::class.java, width, height)!!
previewSurfaceTexture.setDefaultBufferSize(previewSize.width, previewSize.height)
previewSurface = Surface(previewSurfaceTexture)

到这里,用于预览的 Surface 就准备好了,接下来我们来看下如何创建 CameraCaptureSession。

3 创建 CameraCaptureSession

用于接收预览画面的 Surface 准备就绪了,接了下来我们要使用这个 Surface 创建一个 CameraCaptureSession 实例,涉及的方法是 CameraDevice.createCaptureSession(),该方法要求你传递以下三个参数:

  • outputs:所有用于接收图像数据的 Surface,例如本章用于接收预览画面的 Surface,后续还会有用于拍照的 Surface,这些 Surface 必须在创建 Session 之前就准备好,并且在创建 Session 的时候传递给底层用于配置 Pipeline。
  • callback:用于监听 Session 状态的 CameraCaptureSession.StateCallback 对象,就如同开关相机一样,创建和销毁 Session 也需要我们注册一个状态监听器。
  • handler:用于执行 CameraCaptureSession.StateCallback 的 Handler 对象,可以是异步线程的 Handler,也可以是主线程的 Handler,在我们的 Demo 里使用的是主线程 Handler。
private inner class SessionStateCallback : CameraCaptureSession.StateCallback() {
    @MainThread
    override fun onConfigureFailed(session: CameraCaptureSession) {

    }

    @MainThread
    override fun onConfigured(session: CameraCaptureSession) {
       
    }

    @MainThread
    override fun onClosed(session: CameraCaptureSession) {
        
    }
}
val sessionStateCallback = SessionStateCallback()
val outputs = listOf(previewSurface)
cameraDevice.createCaptureSession(outputs, sessionStateCallback, mainHandler)

4 创建 CaptureRequest

在介绍如何开启和关闭预览之前,我们有必要先介绍下 CaptureRequest,因为它是我们执行任何相机操作都绕不开的核心类,因为 CaptureRequest 是向 CameraCaptureSession 提交 Capture 请求时的信息载体,其内部包括了本次 Capture 的参数配置和接收图像数据的 Surface。CaptureRequest 可以配置的信息非常多,包括图像格式、图像分辨率、传感器控制、闪光灯控制、3A 控制等等,可以说绝大部分的相机参数都是通过 CaptureRequest 配置的。我们可以通过 CameraDevice.createCaptureRequest() 方法创建一个 CaptureRequest.Builder 对象,该方法只有一个参数 templateType 用于指定使用何种模板创建 CaptureRequest.Builder 对象。因为 CaptureRequest 可以配置的参数实在是太多了,如果每一个参数都要我们手动去配置,那真的是既复杂又费时,所以 Camera2 根据使用场景的不同,为我们事先配置好了一些常用的参数模板:

  • TEMPLATE_PREVIEW:适用于配置预览的模板。
  • TEMPLATE_RECORD:适用于视频录制的模板。
  • TEMPLATE_STILL_CAPTURE:适用于拍照的模板。
  • TEMPLATE_VIDEO_SNAPSHOT:适用于在录制视频过程中支持拍照的模板。
  • TEMPLATE_MANUAL:适用于希望自己手动配置大部分参数的模板。

这里我们要创建一个用于预览的 CaptureRequest,所以传递了 TEMPLATE_PREVIEW 作为参数:

val requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)

一个 CaptureRequest 除了需要配置很多参数之外,还要求至少配置一个 Surface(任何相机操作的本质都是为了捕获图像),并且配置的 Surface 必须属于创建 Session 时添加的那些 Surface,涉及的方法是 CaptureRequest.Builder.addTarget(),你可以多次调用该方法添加多个 Surface。

requestBuilder.addTarget(previewSurface)

最后,我们通过 CaptureRequest.Builder.build() 方法创建出一个只读的 CaptureRequest 实例:

val request = requestBuilder.build()

5 开启和停止预览

在 Camera2 里,预览本质上是不断重复执行的 Capture 操作,每一次 Capture 都会把预览画面输出到对应的 Surface 上,涉及的方法是 CameraCaptureSession.setRepeatingRequest(),该方法有三个参数:

  • request:在不断重复执行 Capture 时使用的 CaptureRequest 对象。
  • callback:监听每一次 Capture 状态的 CameraCaptureSession.CaptureCallback 对象,例如 onCaptureStarted() 意味着一次 Capture 的开始,而 onCaptureCompleted() 意味着一次 Capture 的结束。
  • hander:用于执行 CameraCaptureSession.CaptureCallback 的 Handler 对象,可以是异步线程的 Handler,也可以是主线程的 Handler,在我们的 Demo 里使用的是主线程 Handler。

了解了核心方法之后,开启预览的操作就很显而易见了:

val requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
requestBuilder.addTarget(previewSurface)
val request = requestBuilder.build()
captureSession.setRepeatingRequest(request, RepeatingCaptureStateCallback(), mainHandler)

如果要关闭预览的话,可以通过 CameraCaptureSession.stopRepeating() 停止不断重复执行的 Capture 操作:

captureSession.stopRepeating()

到目前为止,如果一切正常的话,预览画面应该就已经显示出来了。

6 适配预览比例

前面我们使用了一个占满屏幕的 TextureView 来显示预览画面,并且预览尺寸我们选择了 4:3 的比例,你很可能会看到预览画面变形的情况,这因为 Surface 的比例和 TextureView 的比例不一致导致的,你可以想象 Surface 就是一张图片,TextureView 就是 ImageView,将 4:3 的图片显示在 16:9 的 ImageView 上必然会出现画面拉伸变形的情况:

Android Camera2 教程 · 第三章 · 预览

预览画面变形

所以接下来我们要学习的是如何适配不同的预览比例。预览比例的适配有多种方式:

  1. 根据预览比例修改 TextureView 的宽高,比如用户选择了 4:3 的预览比例,这个时候我们会选取 4:3 的预览尺寸并且把 TextureView 修改成 4:3 的比例,从而让画面不会变形。
  2. 使用固定的预览比例,然后根据比例去选取适合的预览尺寸,例如固定 4:3 的比例,选择 1440x1080 的尺寸,并且把 TextureView 的宽高也设置成 4:3。
  3. 固定 TextureView 的宽高,然后根据预览比例使用 TextureView.setTransform() 方法修改预览画面绘制在 TextureView 上的方式,从而让预览画面不变形,这跟 ImageView.setImageMatrix() 如出一辙。

简单来说,解决预览画面变形的问题,本质上就是解决画面和画布比例不一致的问题。在我们的 Demo 中,出于简化的目的,我们选择了第二种方式适配比例,因为这种方式实现起来比较简单,所以我们会写一个自定义的 TextureView,让它的比例固定是 4:3,它的宽度固定填满父布局,高度根据比例动态计算:

class CameraPreview @JvmOverloads constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int = 0) : TextureView(context, attrs, defStyleAttr) {
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val width = MeasureSpec.getSize(widthMeasureSpec)
        setMeasuredDimension(width, width / 3 * 4)
    }
}

7 认识 ImageReader

在 Camera2 里,ImageReader 是获取图像数据的一个重要途径,我们可以通过它获取各种各样格式的图像数据,例如 JPEG、YUV 和 RAW 等等。我们可以通过 ImageReader.newInstance() 方法创建一个 ImageReader 对象,该方法要求我们传递以下四个参数:

  • width:图像数据的宽度。
  • height:图像数据的高度。
  • format:图像数据的格式,定义在 ImageFormat 里,例如 ImageFormat.YUV_420_888
  • maxImages:最大 Image 个数,可以理解成 Image 对象池的大小。

当有图像数据生成的时候,ImageReader 会通过通过 ImageReader.OnImageAvailableListener.onImageAvailable() 方法通知我们,然后我们可以调用 ImageReader.acquireNextImage() 方法获取存有最新数据的 Image 对象,而在 Image 对象里图像数据又根据不同格式被划分多个部分分别存储在单独的 Plane 对象里,我们可以通过调用 Image.getPlanes() 方法获取所有的 Plane 对象的数组,最后通过 Plane.getBuffer() 获取每一个 Plane 里存储的图像数据。以 YUV 数据为例,当有 YUV 数据生成的时候,数据会被分成 Y、U、V 三部分分别存储到 Plane 里,如下图所示:

Android Camera2 教程 · 第三章 · 预览

override fun onImageAvailable(imageReader: ImageReader) {
    val image = imageReader.acquireNextImage()
    if (image != null) {
        val planes = image.planes
        val yPlane = planes[0]
        val uPlane = planes[1]
        val vPlane = planes[2]
        val yBuffer = yPlane.buffer // Data from Y channel
        val uBuffer = uPlane.buffer // Data from U channel
        val vBuffer = vPlane.buffer // Data from V channel
    }
    image?.close()
}

上面的代码是获取 YUV 数据的流程,特别要注意的是最后一步调用 Image.close() 方法十分重要,当我们不再需要使用某一个 Image 对象的时候记得通过该方法释放资源,因为 Image 对象实际上来自于一个创建 ImageReader 时就确定大小的对象池,如果我们不释放它的话就会导致对象池很快就被耗光,并且抛出一个异常。类似的的当我们不再需要使用 某一个 ImageReader 对象的时候,也要记得调用 ImageReader.close() 方法释放资源。

8 获取预览数据

介绍完 ImageReader 之后,接下来我们就来创建一个接收每一帧预览数据的 ImageReader,并且数据格式为 YUV_420_888。首先,我们要先判断 YUV_420_888 数据格式是否支持,所以会有如下的代码:

val imageFormat = ImageFormat.YUV_420_888
val streamConfigurationMap = cameraCharacteristics[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP]
if (streamConfigurationMap?.isOutputSupportedFor(imageFormat) == true) {
    // YUV_420_888 is supported
}

接着,我们使用前面已经确定好的预览尺寸创建一个 ImageReader,并且注册一个 ImageReader.OnImageAvailableListener 用于监听数据的更新,最后通过 ImageReader.getSurface() 方法获取接收预览数据的 Surface:

val imageFormat = ImageFormat.YUV_420_888
val streamConfigurationMap = cameraCharacteristics[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP]
if (streamConfigurationMap?.isOutputSupportedFor(imageFormat) == true) {
    previewDataImageReader = ImageReader.newInstance(previewSize.width, previewSize.height, imageFormat, 3)
    previewDataImageReader?.setOnImageAvailableListener(OnPreviewDataAvailableListener(), cameraHandler)
    previewDataSurface = previewDataImageReader?.surface
}

创建完 ImageReader,并且获取它的 Surface 之后,我们就可以在创建 Session 的时候添加这个 Surface 告诉 Pipeline 我们有一个专门接收 YUV_420_888 的 Surface:

val sessionStateCallback = SessionStateCallback()
val outputs = mutableListOf<Surface>()
val previewSurface = previewSurface
val previewDataSurface = previewDataSurface
outputs.add(previewSurface!!)
if (previewDataSurface != null) {
    outputs.add(previewDataSurface)
}
cameraDevice.createCaptureSession(outputs, sessionStateCallback, mainHandler)

获取预览数据和显示预览画面一样都是不断重复执行的 Capture 操作,所以我们只需要在开始预览的时候通过 CaptureRequest.Builder.addTarget() 方法添加接收预览数据的 Surface 即可,所以一个 CaptureRequest
会有两个 Surface,一个现实预览画面的 Surface,一个接收预览数据的 Surface:

val requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
val previewSurface = previewSurface
val previewDataSurface = previewDataSurface
requestBuilder.addTarget(previewSurface!!)
if (previewDataSurface != null) {
    requestBuilder.addTarget(previewDataSurface)
}
val request = requestBuilder.build()
captureSession.setRepeatingRequest(request, RepeatingCaptureStateCallback(), mainHandler)

在开始预览之后,每一次刷新预览画面的时候,都会通过 ImageReader.OnImageAvailableListener.onImageAvailable() 方法通知我们:

private inner class OnPreviewDataAvailableListener : ImageReader.OnImageAvailableListener {

    /**
     * Called every time the preview frame data is available.
     */
    override fun onImageAvailable(imageReader: ImageReader) {
        val image = imageReader.acquireNextImage()
        if (image != null) {
            val planes = image.planes
            val yPlane = planes[0]
            val uPlane = planes[1]
            val vPlane = planes[2]
            val yBuffer = yPlane.buffer // Data from Y channel
            val uBuffer = uPlane.buffer // Data from U channel
            val vBuffer = vPlane.buffer // Data from V channel
        }
        image?.close()
    }
}

9 如何矫正图像数据的方向

如果你熟悉 Camera1 的话,也许已经发现了一个问题,就是 Camera2 不需要经过任何预览画面方向的矫正,就可以正确现实画面,而 Camera1 则需要根据摄像头传感器的方向进行预览画面的方向矫正。其实,Camera2 也需要进行预览画面的矫正,只不过系统帮我们做了而已,当我们使用 TextureView 或者 SurfaceView 进行画面预览的时候,系统会根据【设备自然方向】、【摄像传感器方向】和【显示方向】自动矫正预览画面的方向,并且该矫正规则只适用于显示方向和和设备自然方向一致的情况下,举个例子,当我们把手机横放并且允许自动旋转屏幕的时候,看到的预览画面的方向就是错误的。此外,当我们使用一个 GLSurfaceView 显示预览画面或者使用 ImageReader 接收图像数据的时候,系统都不会进行画面的自动矫正,因为它不知道我们要如何显示预览画面,所以我们还是有必要学习下如何矫正图像数据的方向,在介绍如何矫正图像数据方向之前,我们需要先了解几个概念,它们分别是【设备自然方向】、【局部坐标系】、【显示方向】和【摄像头传感器方向】。

9.1 设备方向

当我们谈论方向的时候,实际上都是相对于某一个 0° 方向的角度,这个 0° 方向被称作自然方向,例如人站立的时候就是自然方向,你总不会认为一个人要倒立的时候才是自然方向吧,而接下来我们要谈论的设备方向就有的自然方向的定义。

设备方向指的是硬件设备在空间中的方向与其自然方向的顺时针夹角。这里提到的自然方向指的就是我们手持一个设备的时候最习惯的方向,比如手机我们习惯竖着拿,而平板我们则习惯横着拿,所以通常情况下手机的自然方向就是竖着的时候,平板的自然方向就是横着的时候。

Android Camera2 教程 · 第三章 · 预览

以手机为例,我们可以有以下四个比较常见的设备方向:

  • 当我们把手机垂直放置且屏幕朝向我们的时候,设备方向为 0°,即设备自然方向
  • 当我们把手机向右横放且屏幕朝向我们的时候,设备方向为 90°
  • 当我们把手机倒着放置且屏幕朝向我们的时候,设备方向为 180°
  • 当我们把手机向左横放且屏幕朝向我们的时候,设备方向为 270°

了解了设备方向的概念之后,我们可以通过 OrientationEventListener 监听设备的方向,进而判断设备当前是否处于自然方向,当设备的方向发生变化的时候会回调 OrientationEventListener.onOrientationChanged(int) 方法,传给我们一个 0° 到 359° 的方向值,其中 0° 就代表设备处于自然方向。

9.2 局部坐标系

所谓的局部坐标系指的是当设备处于自然方向时,相对于设备屏幕的坐标系,该坐标系是固定不变的,不会因为设备方向的变化而改变,下图是基于手机的局部坐标系示意图:

Android Camera2 教程 · 第三章 · 预览

局部坐标系

  • x 轴是当手机处于自然方向时,和手机屏幕平行且指向右边的坐标轴。
  • y 轴是当手机处于自然方向时,和手机屏幕平行且指向上方的坐标轴。
  • z 轴是当手机处于自然方向时,和手机屏幕垂直且指向屏幕外面的坐标轴。

为了进一步解释【坐标系是固定不变的,不会因为设备方向的变化而改变】的概念,这里举个例子,当我们把手机向右横放且屏幕朝向我们的时候,此时设备方向为 90°,局部坐标系相对于手机屏幕是保持不变的,所以 y 轴正方向指向右边,x 轴正方向指向下方,z 轴正方向还是指向屏幕外面,如下图所示:

Android Camera2 教程 · 第三章 · 预览

设备方向 90°

9.3 显示方向

显示方向指的是屏幕上显示画面与局部坐标系 y 轴的顺时针夹角。

为了更清楚的说明这个概念,我们举一个例子,假设我们将手机向右横放看电影,此时画面是朝上的,如下图所示:

Android Camera2 教程 · 第三章 · 预览

屏幕方向

从上图来看,手机向右横放会导致设备方向变成了 90°,但是显示方向却是 270°,因为它是相对局部坐标系 y 轴的顺时针夹角,所以跟设备方向没有任何关系。如果把图中的设备换成是平板,结果就不一样了,因为平板横放的时候就是它的设备自然方向,y 轴朝上,屏幕画面显示的方向和 y 轴的夹角是 0°,设备方向也是 0°。

总结一下,设备方向是相对于其现实空间中自然方向的角度,而显示方向是相对局部坐标系的角度。

9.4 摄像头传感器方向

摄像头传感器方向指的是传感器采集到的画面方向经过顺时针旋转多少度之后才能和局部坐标系的 y 轴正方向一致,也就是通过 CameraCharacteristics.SENSOR_ORIENTATION 获取到的值。

例如 orientation 为 90° 时,意味我们将摄像头采集到的画面顺时针旋转 90° 之后,画面的方向就和局部坐标系的 y 轴正方向一致,换个说法就是原始画面的方向和 y 轴的夹角是逆时针 90°。

最后我们要考虑一个特殊情况,就是前置摄像头的画面是做了镜像处理的,也就是所谓的前置镜像操作,这个情况下, orientation 的值并不是实际我们要旋转的角度,我们需要取它的镜像值才是我们真正要旋转的角度,例如 orientation 为 270°,实际我们要旋转的角度是 90°。

注意:摄像头传感器方向在不同的手机上可能不一样,大部分手机都是 90°,也有小部分是 0° 的,所以我们要通过 CameraCharacteristics.SENSOR_ORIENTATION 去判断方向,而不是假设所有设备的摄像头传感器方向都是 90°。

9.5 矫正图像数据的方向

介绍完几个方向的概念之后,我们就来说下如何校正相机的预览画面。我们会举几个例子,由简到繁逐步说明预览画面校正过程中要注意的事项。

首先我们要知道的是摄像头传感器方向只有 0°、90°、180°、270° 四个可选值,并且这些值是相对于局部坐标系 的 y 轴定义出来的,现在假设一个相机 APP 的画面在手机上是竖屏显示,也就是显示方向是 0° ,并且假设摄像头传感器的方向是 90°,如果我们没有校正画面的话,则显示的画面如下图所示(忽略画面变形):

Android Camera2 教程 · 第三章 · 预览

很明显,上面显示的画面内容方向是错误的,里面的人物应该是垂直向上显示才对,所以我们应该吧摄像头采集到的画面顺时针旋转 90°,才能得到正确的显示结果,如下图所示:

Android Camera2 教程 · 第三章 · 预览

上面的例子是建立在我们的显示方向是 0° 的时候,如果我们要求显示方向是 90°,也就是手机向左横放的时候画面才是正的,并且假设摄像头传感器的方向还是 90°,如果我们没有校正画面的话,则显示的画面如下图所示(忽略画面变形):

Android Camera2 教程 · 第三章 · 预览

此时,我们知道传感器的方向是 90°,如果我们将传感器采集到的画面顺时针旋转 90° 显然是无法得到正确的画面,因为它是相对于局部坐标系 y 轴的角度,而不是实际显示方向,所以在做画面校正的时候我们还要把实际显示方向也考虑进去,这里实际显示方向是 90°,所以我们应该把传感器采集到的画面顺时针旋转 180°(摄像头传感器方向 + 实际显示方向) 才能得到正确的画面,显示的画面如下图所示(忽略画面变形):

Android Camera2 教程 · 第三章 · 预览

总结一下,在校正画面方向的时候要同时考虑两个因素,即摄像头传感器方向和显示方向。接下来我们要回到我们的相机应用里,看看通过代码是如何实现预览画面方向校正的。

如果你有自己看过 Camera 的官方 API 文档,你会发现官方已经给我们写好了一个同时考虑显示方向和摄像头传感器方向的方法,我把它翻译成 Kotlin 语法:

private fun getDisplayRotation(cameraCharacteristics: CameraCharacteristics): Int {
    val rotation = windowManager.defaultDisplay.rotation
    val degrees = when (rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> 0
    }
    val sensorOrientation = cameraCharacteristics[CameraCharacteristics.SENSOR_ORIENTATION]!!
    return if (cameraCharacteristics[CameraCharacteristics.LENS_FACING] == CameraCharacteristics.LENS_FACING_FRONT) {
        (360 - (sensorOrientation + degrees) % 360) % 360
    } else {
        (sensorOrientation - degrees + 360) % 360
    }
}

如果你已经完全理解前面介绍的那些角度的概念,那你应该很容易就能理解上面这段代码,实际上就是通过 WindowManager 获取当前的显示方向,然后再参照摄像头传感器方向以及是否是前后置,最后计算出我们实际要旋转的角度。

上一篇:Android播放器之SurfaceView与GLSurfaceView


下一篇:pygame (三)