一种低成本的任意尺寸无变形相机预览及录像方案

377 阅读8分钟

该方案整理自我的博客文章《Camera无变形任意尺寸预览》《相机录像新姿势-OpenGL共享上下文+MediaCodec》,可参考iCamera源码

一、需求背景

多年前,尝试做过一个模仿Instagram的方形相机无变形预览及录像,做过Android相机开发的都知道,并不是你想让相机以什么尺寸预览都可以,就算是强行把预览画面设置成方形,画面也会变形,当时因为技术储备不到位,只能做到一种粗糙的方案,即使用不透明的遮盖,把视频的上下区域遮盖住,录像后,再通过一些视频处理的库,比如ffmpeg进行视频的裁剪。这种方案首先是做不到* *所见即所得的效果,有可能因为系统UI的影响,导致预览的画面与最终生成的画面有偏差,另外就是录像结束后,需要较长时间去处理。

多年后,做SDK开发,工作中遇到了这样的需求:

  1. 相机的预览画面可以是任意尺寸,且预览不能变形;
  2. 录像期间,可以做到切换前后摄像头;
  3. 可以扫码、拍照等;

这些需求中,最难的是1、2两点。

二、初版方案

使用传统的Camera+MediaRecorder肯定是不行的,因为MediaRecorder在录像期间,是不允许切换摄像头的。这种方案直接放弃。

那就使用一种更为曲折的方案,Camera帧数据+MediaCodec,由于不是本文的重点,简述一下这个初版方案。

  1. 预览使用TextureView,通过setTransform,将画面居中、缩放到一个合适的比例,避免画面预览时的变形问题;
  2. 通过camera.setPreviewCallback获得帧数据,然后通过libyuv对帧数据进行格式缩放、裁切、旋转等操作,对于前置摄像头还要做镜像旋转,然后将处理后的帧数据送入MediaCodec,配合音频采集的数据编码成视频。对于录像期间切换摄像头,只需要将PreviewCallback从当前相机移除,设置到新切换的相机实例上即可。

这种方案,满足了基本需求,但是有很大的缺点:

  1. 帧数据处理繁琐且易出错。 帧数据的缩放与裁剪是固定的步骤,除此之外, 当相机设置了方向,就要添加一个旋转帧数据的操作,当切换到自拍摄像头,就要添加一个镜像翻转的步骤,同样的,摄像头翻转回去,要去掉这个步骤。这还不算在处理帧数据时,遇到的不同处理器的兼容问题(主要是MediaCodec对帧数据尺寸的容错能力兼容)、绿屏问题、冷暖色调相反问题等;
  2. 尺寸问题。 额外引入的libyuv库会增加app/sdk的尺寸,当时我对libyuv库做了裁剪,移除了很多不需要的功能,之留下针对NV21这种yuv格式的支持以及缩放、裁剪、镜像、旋转等操作。最终的尺寸影响控制在100k以内,勉强可以接受;
  3. 后续维护难题。 由于做SDK开发,很多客户可能自身会使用libyuv,有可能冲突,同时还有加固的问题等,最麻烦的是后续遇到bug,比较难维护。

三、最终的低成本方案

在后续的开发工作中,积累了一些OpenGL与surface的相关经验,受到Grafika这个神库的启发,最终总结出如下的方案。 当前的方案实现,是在工作中遇到问题解决问题的过程中,总结出的方案,其中涉及到一些OpenGL的知识,我本人对这些并不精通,只是拿来用一下,如果有OpenGL的大神,可以提供更好的方案,欢迎评论区讨论或者fork代码进行修改。

3.1 基本思路

在一般的Camera应用开发时,通常需要有一个预览画面的控件,一般时SurfaceView或者TextureView,然后把Surface或者SurfaceTexture设置给Camera进行预览。

而在我们的方案中,这个过程,要增加一些步骤。具体的步骤如下:

  1. 从SurfaceView或者TextureView获得Surface对象,比如holder.surface或者Surface(surfaceTexture)
  2. 由此Surface对象,我们创建EGL环境,并重新创建一个SurfaceTexture对象,这个新的SurfaceTexture对象,最后需要设置给Camera进行预览;
  3. 监听步骤2中创建的SurfaceTexture对象的帧数据,通过OpenGL对画面消除形变并进行渲染。
  4. 与步骤2类似的,我们要创建一个新的EGL环境,用户共享步骤2中的EGL环境的纹理,这样我们就可以在共享的EGL环境中,进行录像或者扫码等。

以上就是基本思路,是不是还是一头雾水?没关系,下面进行具体实现的讲解。

3.2 具体实现

3.2.1 通过Surface创建EGL环境

一般代码示例如下:

private val display: EGLDisplay by lazy { EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) }
private var eglSurface: EGLSurface? = null
var eglContext: EGLContext? = null
    private set
private fun createEGL(surface: Surface) {
    val version = IntArray(2)
    EGL14.eglInitialize(display, version, 0, version, 1)

    val attributes = intArrayOf(
        EGL14.EGL_RED_SIZE, 8,
        EGL14.EGL_GREEN_SIZE, 8,
        EGL14.EGL_BLUE_SIZE, 8,
        EGL14.EGL_ALPHA_SIZE, 8,
        EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
        EGL14.EGL_NONE, 0,      // placeholder for recordable [@-3]
        EGL14.EGL_NONE
    )

    val configs = arrayOfNulls<EGLConfig>(1)
    val numConfigs = IntArray(1)
    EGL14.eglChooseConfig(display, attributes, 0, configs, 0,
        configs.size, numConfigs, 0)

    val config = configs[0]

    eglSurface = EGL14.eglCreateWindowSurface(
        display, config, surface, intArrayOf(
            EGL14.EGL_NONE
        ), 0
    )

    eglContext = EGL14.eglCreateContext(
        display, config, sharedContext ?: EGL14.EGL_NO_CONTEXT, intArrayOf(
            EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE
        ), 0
    )
    EGL14.eglMakeCurrent(display, eglSurface, eglSurface, eglContext)
}

这里需要注意的是,这个方法调用需要在一个独立的线程中,最后一句代码是与当前线程绑定,相关的OpenGL的操作,只能在对应的线程中进行。 有了这个OpenGL环境以后,就可以创建对应的SurfaceTexture了,简单的代码如下:

val textureIds = IntArray(1)
GLES20.glGenTextures(1, textureIds, 0)
val texture = SurfaceTexture(textureIds[0])

同样的,这里的代码也要运行在之前的创建EGL环境相同的线程之下,之后便可以将这里创建的texture设置给camera对象用于预览。

3.2.2 监听预览-消除形变-绘制画面

给上一步中创建的texture设置一个画面监听。

val matrix = FloatArray(16)
texture.setOnFrameAvailableListener {
    queue {
        texture.updateTexImage()
        texture.getTransformMatrix(matrix)

        drawFrame(textureIds[0], matrix)
    }
}

fun drawFrame(textureId: Int, matrix: FloatArray) {
    // ....
}

简单解释一下这里代码:

  1. 先创建一个浮点数组,用于接收预览画面的坐标数据,用于OpenGL绘制画面使用;
  2. 然后设置画面监听,这里在相机开启预览后会触发一次;
  3. 在画面监听回调中,切换到EGL线程中,调用updateTexImage,只有这样,画面监听回调才会持续的被执行;
  4. 最后调用getTransformMatrix获取画面坐标数据,为之后的画面绘制做准备。

接下来,便是执行画面的绘制,在画面绘制关键的一步就是消除形变,这里需要先获得camera对象的预览尺寸,从支持的预览尺寸中,挑选最佳匹配尺寸以及消除方形问题的相关代码,这里不再赘述,只提供消除形变的关键代码。

val inputSize: Size // 相机输出的预览画面尺寸,因为要作为绘制的输入,所以称为inputSize,注意消除屏幕方向旋转的问题
val outputSize: Size // 输出画面的尺寸
val viewport: Rect // 计算出预览画面要消除形变,需要做的位置与尺寸变更

private fun calculateBestViewPort() {
    if (inputSize.isEmpty || outputSize.isEmpty) {
        return
    }

    val scale = max(outputSize.width.toFloat() / inputSize.width, outputSize.height.toFloat() / inputSize.height)
    val srcWidth = (inputSize.width * scale).toInt()
    val srcHeight = (inputSize.height * scale).toInt()

    val left = (outputSize.width - srcWidth) / 2
    val top = (outputSize.height - srcHeight) / 2
    viewport.set(left, top, left + srcWidth, top + srcHeight)
}

在绘制画面前,执行OpenGL的glViewport方法,就可以消除形变了。

fun drawFrame(textureId: Int, texMatrix: FloatArray?) {
    if (!viewport.isEmpty) {
        GLES20.glViewport(viewport.left, viewport.top, viewport.width(), viewport.height())
    }
    // 绘制画面代码有大量的OpenGL操作,具体不再赘述
}

完成以上步骤,就可以完成无变形的预览画面了。但是我们还有录像的需求。

3.3.3 共享纹理-录像

我们需要事先获得一个Surface,对于录像而言,则是MediaCodec的getInputSurface。对这个Surface,配合预览的EGLContext,生成一个共享预览纹理的ShareSurface。

class SharedSurface(private val surface: Surface) : PreviewSurface.OnDrawFrameCallback {

    fun attach(previewSurface: PreviewSurface) {
        sharedThread.start()
        sharedHandler.post {
            eglCore = EglCore(previewSurface.eglContext, EglCore.FLAG_RECORDABLE)
            sharedSurface = WindowSurface(eglCore!!, surface, false)
            sharedSurface?.makeCurrent()

            drawer = SizedFullFrameRect(Texture2dProgram(Texture2dProgram.ProgramType.TEXTURE_EXT))
            val inputSize = previewSurface.getInputSize()
            drawer?.setInputSize(inputSize.width, inputSize.height)

            previewSurface.addOnDrawFrameCallback(this)
            attachedSurface = previewSurface
        }
    }

}

3.3.4 扫码

与录像类似,扫码同样需要用到共享纹理的ShareSurface,此时构造ShareSurface时传入的参数则需要传入ImageReader的getSurface(),然后在ImageReader.OnImageAvailableListener#onImageAvailable(ImageReader reader)中处理数据。源码中并没有设置扫码的demo,如果有需要,可以评论区留言,我将补充相关代码。

基于相同的原理,只要我们有一个ImageReader就可以做很多其他的处理,比如拍照,帧数据回调(此时的帧数据,也是所见即所得的帧数据尺寸)。

四、总结

新方案的优点如下:

  1. 直接将surface的画面送入MediaCodec,所见即所得
  2. 不需要引入额外的libyuv或者ffmpeg,只需要Android自身的API即可实现,不需要繁琐的帧数据处理,app/sdk的尺寸也变小了;
  3. 配合ImageReader实现的扫码、拍照、获取帧数据等功能,也不再需要进行裁剪、缩放等操作了;

当前文章中,也只是粗略的提供了思路和基本代码,更多的代码还是请参考iCamera源码

五、后记

当前的方案依然使用的是被官方标记为过时的Camera1的API,原因是在使用Camera2 API时,无法获取其预览的帧数据尺寸,也就无法做修正。如果有关于Camera2 API的更好的方式,可以评论区讨论。