Android实现任意分辨率视频编码的思考与实现

1,851 阅读8分钟

  HardwareVideoCodec是一个高效的Android音视频编码库,支持软编和硬编。使用它你可以很容易的实现任何分辨率的视频编码,无需关心摄像头预览大小。一切都如此简单。目前已迭代多个稳定版本,欢迎查阅学习和使用,如有BUG或建议,欢迎Issue。

  对Android摄像头开发有所了解的童鞋都知道,每个设备的摄像头都只支持固定的一系列分辨率,并且每个设备都有所不同。比如有些手机支持540x960,有的手机却不支持。   这使得我们每次使用Android摄像头的时候,不得不去获取一个支持分辨率列表,然后从中选取一个匹配的分辨率。但是前面说了,每个设备支持的分辨率都是不一样的,万一我们希望的分辨率正好在某设备上不支持,那怎么办。相信不少人不得不妥协,选择一个普遍支持的分辨率,来支持绝大多数的设备。这虽然能把出错率降到最低,但理论上还是会存在不支持的设备。对于有强迫症的部分人,简直不能忍!   摄像头兼容性这件苦差事,相信折磨着不少同鞋,也包括我。所以最近我一直在思考,有没有一劳永逸的方法,来支持任意分辨率摄像头的预览以及视频编码呢?我们先来分析一下。   对于以上的提问其实可以分解为两个问题

1. 如何根据给定的目标分辨率来选择最合适的摄像头分辨率 2. 如何高效的把摄像头输出画面大小裁剪成目标大小

最佳分辨率的选择

  这里有人可能知道,选择一个最接近的分辨率不就行了吗。那么这里我要提问了,这个最接近我们如何定义?相信很多同鞋也去网上查找过一些帖子,我一开始也是这样子,能找到的基本上都是对摄像头分辨率进行排序,然后选择一个最接近目标分辨率的大小。我司的线上项目也是使用的这种方法,很遗憾,在某些机型上出现了选择错误。为什么会这样,问题出在哪里?   各位有没有想过,分辨率是包含这两个属性的。举个例子,我们需要输出540x960这个分辨率,但是某机型并不支持这个分辨率,只支持720x960和540x1080这两个分辨率,如果我们用排序法对这两个分辨率进行排序,我们应该优先对宽进行排序,还是对高进行排序呢。显然,这里应该优先对排序,进而选择540x960这个分辨率。同理,有没有可能会出现对进行优先排序才能选出最佳分辨率的情况呢,这里我不能给出确定的答案,但是作为一个负责人的程序猿,我们需要考虑这种情况。说了这么多,结论就是:对分辨率进行多关键字排序并不能确保我们能拿到最佳分辨率,因为我们并不知道哪个优先级最高。   在这里,我直接给出答案。首先,最佳分辨率的定义有以下两点:

  1. 宽高同时大于或等于目标分辨率的宽高
  2. 分辨率(像素点个数)大于或等于目标分辨率

  第二点很容易被忽视,这也是解决上述问题的关键,基于这个,我们就不需要关心宽和高谁优先了。下面直接贴出代码。

fun setPreviewSize(cameraParam: Camera.Parameters, context: CodecContext) {
    val supportSizes = cameraParam.supportedPreviewSizes
    var bestWidth = 0
    var bestHeight = 0
    for (size in supportSizes) {
        if (size.width >= context.video.height//预览宽大于输出宽
                && size.height >= context.video.width//预览高大于输出高
                && (size.width * size.height < bestWidth * bestHeight || 0 == bestWidth * bestHeight)) {//选择像素最少的分辨率
            bestWidth = size.width
            bestHeight = size.height
        }
    }
    context.cameraSize.width = bestWidth
    context.cameraSize.height = bestHeight
    cameraParam.setPreviewSize(context.cameraSize.width, context.cameraSize.height)
}

  是不是很简单,一个循环比较就能得出最佳分辨率,比那个长处天际的排序方法简洁多了。其中context.cameraSize是最终选择的分辨率,实现逻辑和我们上面分析的差不多,我就不注释了。   通过上面的分析,虽然我们能够正确选择最佳分辨率,但是并不意味着最佳分辨率就是我们的目标分辨率。在非一般情况下,我们选出的只是一个大于目标分辨率的大小,这时候就需要对画面进行裁剪,由此引出下面的问题。

如何对画面进行高效裁剪

#####1. 直接裁剪   这种方法需要在摄像头回调中获取每一帧数据,然后根据不通的图像格式(YUV420P、YUV420SP...)使用不同的算法进行画面裁剪,裁剪完成后把画面渲染到SurfaceView上。先不说裁减性能如何,就这需要根据不同格式使用不同的裁剪算法相信已经难倒一批童鞋了。   然而实际上,如果自己写裁剪算法是几乎不可用的,因为太消耗资源了。当然你可以用google开源的libyuv,这是目前性能最好的裁剪以及转格式第三方库,实测6ms内实现720p转格式并裁剪毫无问题。然而你以为就这么简单的解决了吗,那就太天真了。   当你使用libyuv成功把YUV420SP的画面裁剪成需要的大小,并尝试把画面绘制到SurfaceView上的时候,你会发现,Canvas只支持Bitmap格式的图片绘制,这意味着你还需要把YUV420SP转成Bitmap。然而图片转格式这种工作性能消耗真的太高了,几乎不可用。呵呵,别再折腾了,放弃吧! #####2. 使用硬件   这时候你会想,要是能像视频编码那样使用硬件该多好啊。嗯嗯,OpenGL ES你值得拥有。   对OpenGL有所了解的人应该知道,OpenGL对纹理的定位是通过顶点来完成的。这里包含一组输入纹理顶点和一组目标纹理顶点。前者是用来表示把输入纹理的哪一部分绘制到目标纹理上面(或者窗口),后者表示把输入纹理绘制到目标纹理(或窗口)的哪个位置。   说的可能有点复杂,一开始我也很难理解,后来经过高人指点才恍然大悟,其实跟下面的代码是一样的。

Canvas.drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint)

src就是上述的输入纹理顶点,dst就是目标纹理顶点,这意味着OpenGL支持对输入纹理进行裁剪。这里需要注意的是,前者的范围是第一象限,也就是x,y∈[0, 1],而后者则是x,y∈[-1, 1]。   说了这么多,这到底有什么用呢。别忘了,Android是原生支持把摄像头画面转换成SurfaceTexture(也就是OpenGL支持的纹理)。我们可以通过把画面输出到SurfaceTexture,然后计算输入纹理顶点(x,y∈[0, 1])来对画面进行裁剪,最后直接利用OpenGL把画面渲染到GLSurfaceView。整个过程不占用CPU资源,爽歪歪。   关于OpenGL ES的使用可以参考Android音视频编码那点破事」第二章,使用TextureView渲染Camera画面。这里直接贴上顶点计算方法。

private fun calculateBestLocation(previewWidth: Int, previewHeight: Int, videoWidth: Int, videoHeight: Int,
                                  location: FloatArray, textureLocation: FloatArray) {
    val previewScale = previewWidth / previewHeight.toFloat()
    val videoScale = videoWidth / videoHeight.toFloat()
    var destPreviewWidth = previewWidth
    var destPreviewHeight = previewHeight
    /**
     * if (previewScale > videoScale) previewHeight不变,以previewHeight为准计算previewWidth
     * else previewWidth不变,以previewWidth为准计算previewHeight
     */
    if (previewScale > videoScale) {
        destPreviewWidth = (previewHeight * videoScale).toInt()
        if (0 != destPreviewWidth % 2) ++destPreviewWidth
    } else {
        destPreviewHeight = (previewWidth / videoScale).toInt()
        if (0 != destPreviewHeight % 2) ++destPreviewHeight
    }
    val left = (previewWidth - destPreviewWidth) / 2f / previewWidth.toFloat()
    val right = 1f - left
    val bottom = (previewHeight - destPreviewHeight) / 2f / previewHeight.toFloat()
    val top = 1 - bottom
    System.arraycopy(floatArrayOf(-1f, -1f, //LEFT,BOTTOM
            1f, -1f, //RIGHT,BOTTOM
            -1f, 1f, //LEFT,TOP
            1f, 1f//RIGHT,TOP
    ), 0, location, 0, 8)
    System.arraycopy(floatArrayOf(left, bottom, //LEFT,BOTTOM
            right, bottom, //RIGHT,BOTTOM
            left, top, //LEFT,TOP
            right, top//RIGHT,TOP
    ), 0, textureLocation, 0, 8)
}

  previewWidth和previewHeight是摄像头预览分辨率,也就是上述的最佳分辨率。videoWidth和videoHeight是输出分辨率,也就是目标分辨率。location是目标纹理顶点,textureLocation是输入纹理顶点。在使用的时候需要注意,在竖屏情况下previewWidth和previewHeight其实是反过来的,所以使用的时候需要这样子传参。

calculateBestLocation(previewHeight, previewWidth, videoWidth, videoHeight, location, textureLocation)

  以上便是关于Android任意分辨率视频编码的思考与实现,看起来还是有点复杂的。等把这两部分逻辑都实现出来,说不定都被产品给活活打死了。这时候有人要问了,有没有一款可以实现任意分辨率预览和视频编码第三方库呢!

  HardwareVideoCodec就是这样一个高效的Android音视频编码库。使用它你可以很容易的实现任何分辨率的视频编码,无需关心摄像头预览大小。并且接口使用简单,你只需要几行代码就可以实现这样高大上的功能,还赠送20多款特效,以及RTMP推送模块,不管你是短视频需求还是直播需求,一切都如此简单。目前已迭代多个稳定版本,赶快来查阅学习吧!如有BUG或建议,欢迎Issue。


欢迎关注微信公众,第一时间获取一手多媒体技术资讯