【转载】微信Android 视频编码爬过的那些坑

(转载自:https://baijiahao.baidu.com/s?id=1576396092961884106&wfr=spider&for=pc
Android 视频相关的开发,大概一直是整个 Android 生态、以及 Android API 中,最为分裂以及兼容性问题最为突出的一部分,本文从视频编码器的选择和如何对摄像头输出的 YUV 帧进行快速预处理两方面,从实践角度解析笔者曾趟过 Android 视频编码的那些坑,希望对广大读者有所助益。

Google 针对摄像头以及视频编码相关的 API,控制力一直非常差,导致不同厂商对这两个 API 的实现有不少差异,而且从 API 的设计来看,一直以来优化也相当有限,甚至有人认为这是“Android 上最难用的 API 之一”。

以微信为例,在 Android 设备录制一个 540P 的 MP4 文件,大体上遵循以下流程:

图1 Android 视频流编码流程图

从摄像头输出的 YUV 帧经过预处理之后,送入编码器,获得编码好的 H264 视频流。

上面只是针对视频流的编码,另外还需要对音频流单独录制,最后再将视频流和音频流合成最终视频。

这篇文章主要会对视频流的编码中两个常见问题进行分析:

视频编码器的选择:硬编 or 软编?

如何对摄像头输出的 YUV 帧进行快速预处理:镜像、缩放、旋转?

视频编码器的选择

对于录制视频的需求,不少 App 都需要对每一帧数据进行单独处理,因此很少会直接用到 MediaRecorder 来录取视频,一般来说,会有两个选择:

MediaCodec

FFMpeg+x264/openh264

下面我们逐个进行解析。

MediaCodec

MediaCodec 是 API 16 之后 Google 推出的用于音视频编解码的一套偏底层的 API,可以直接利用硬件加速进行视频的编解码。调用的时候需要先初始化 MediaCodec 作为视频的编码器,然后只需要不停传入原始的 YUV 数据进入编码器就可以直接输出编码好的 H.264 流,整个 API 设计模型同时包含了输入端和输出端的两条队列。

《【转载】微信Android 视频编码爬过的那些坑》 u=1812239839,2024028431&fm=173&s=AFEAFE128CA07A1953F574C70300C0F8&w=640&h=247&img.JPG

因此,作为编码器,输入端队列存放的是原始 YUV 数据,输出端队列输出的是编码好的 H.264 流,作为解码器则对应相反。在调用的时候,MediaCodec 提供了同步和异步两种调用方式,但是异步使用 Callback 的方式是在 API 21 之后才加入的,以同步调用为例,一般来说调用方式大概是这样(摘自官方例子):

简单解释一下,通过getInputBuffers获取输入队列,然后调用dequeueInputBuffer获取输入队列空闲数组下标,注意dequeueOutputBuffer会有几个特殊的返回值表示当前编解码状态的变化,然后再通过queueInputBuffer把原始 YUV 数据送入编码器,而在输出队列端同样通过getOutputBuffers和获取输出的 H.264 流,处理完输出数据之后,需要通过releaseOutputBuffer把输出 buffer 还给系统,重新放到输出队列中。

关于 MediaCodec 更复杂的使用例子,可以参照 CTS测试里面的使用方式:EncodeDecodeTest.java。

从上面例子来看 MediaCodec 的确是非常原始的 API,由于 MediaCodec 底层直接调用了手机平台硬件的编解码能力,所以速度非常快,但是因为 Google 对整个 Android 硬件生态的掌控力非常弱,所以这个 API 有很多问题:

颜色格式问题

MediaCodec 在初始化的时候,configure 过程中需要传入一个 MediaFormat 对象,当作为编码器使用的时候,我们一般需要在 MediaFormat 中指定视频的宽高、帧率、码率、I 帧间隔等基本信息。除此之外,还有一个重要的信息就是,指定编码器接受的 YUV 帧的颜色格式,这是由于 YUV 根据其采样比例,UV 分量的排列顺序有很多种不同的颜色格式,而对于 Android 的摄像头在 onPreviewFrame 输出的 YUV 帧格式,没有配置任何参数的情况下,基本上都是 NV21 格式,但 Google 对 MediaCodec 的 API 在设计和规范的时候,显得很不厚道,过于贴近 Android 的 HAL 层了,导致了 NV21 格式并不是所有机器的MediaCodec都支持这种格式作为编码器的输入格式。 因此,在初始化的时候,我们需要通过codecInfo.getCapabilitiesForType来查询机器上的实现具体支持哪些 YUV 格式作为输入格式。一般来说,起码在 4.4+ 的系统上,这两种格式在大部分机器上都有支持:

MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420PlanarMediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar

两种格式分别是 YUV420P 和 NV21,如果机器上只支持 YUV420P 格式,则需要先将摄像头输出的 NV21 格式先转换成 YUV420P,才能送入编码器进行编码,否则最终出来的视频就会花屏,或者颜色出现错乱。

这个算是一个不大不小的坑,基本上用 MediaCodec 进行视频编码都会遇上这个问题。

编码器支持特性相当有限

如果使用 MediaCodec 来编码 H.264 视频流,对于 H.264 格式来说,会有一些针对压缩率以及码率相关的视频质量设置,典型的诸如 Profile(baseline, main, hight)、Profile Level、Bitrate mode(CBR、CQ、VBR),合理配置这些参数可以让我们在同等的码率下,获得更高的压缩率,从而提升视频的质量,Android 也提供了对应的 API 进行设置,可以设置到 MediaFormat 中:

MediaFormat.KEY_BITRATE_MODEMediaFormat.KEY_PROFILEMediaFormat.KEY_LEVEL

但问题是,对于 Profile、Level、Bitrate mode 这些设置,在大部分手机上都是不支持的,即使是设置了最终也不会生效,例如设置了 Profile 为 high,最后出来的视频依然还会是 Baseline、Shit 等等。

这个问题,在 7.0 以下的机器几乎是必现的,其中一个可能的原因是,Android 在源码层级 hardcode 了 Profile 的的设置:

// XXXif (h264type.eProfile != OMX_VIDEO_AVCProfileBaseline) { ALOGW(“Use baseline profile instead of %d for AVC recording”, h264type.eProfile); h264type.eProfile = OMX_VIDEO_AVCProfileBaseline;

Android 直到 7.0 之后才取消了这段地方的 Hardcode。

if (h264type.eProfile == OMX_VIDEO_AVCProfileBaseline) { …. } else if (h264type.eProfile == OMX_VIDEO_AVCProfileMain || h264type.eProfile == OMX_VIDEO_AVCProfileHigh) { ….. }

这个问题可以说间接导致了编码出来的视频质量偏低,同等码率下,难以获得跟软编码甚至iOS那样的视频质量。

16 位对齐要求

前面说到,这个 API 在设计的时候,过于贴近 HAL 层,这在很多 SoC 的实现上,是直接把传入的 buffer,在不经过任何前置处理的情况下就直接送入了 Soc 中。而在编码 H264 视频流的时候,由于 H264 的编码块大小一般是 16×16,于是在一开始设置视频宽高的时候,如果设置了一个没有对齐 16 的大小,例如 960×540,在某些 CPU 上,最终编码出来的视频就会直接花屏。

很明显这还是因为厂商在实现这个 API 的时候,对传入的数据缺少校验以及前置处理导致的。目前来看,华为、三星的 SoC 出现这个问题会比较频繁,其他厂商的一些早期 Soc 也有这种问题,一般来说解决方法还是在设置视频宽高的时候,统一设置成对齐 16 位就好了。

FFMpeg+x264/openh264

除了使用进行编码之外,另外一种比较流行的方案就是使用 FFmpeg + x264/OpenH264 进行软编码,FFmpeg 适用于一些视频帧的预处理。这里主要是使用 x264/OpenH264 作为视频的编码器。

x264 基本上被认为是当今市面上最快的商用视频编码器,而且基本上所有 H264 的特性都支持,通过合理配置各种参数还是能够得到较好的压缩率和编码速度的,限于篇幅,这里不再阐述 H.264 的参数配置。

OpenH264 则是由思科开源的另外一个 H264 编码器,项目在 2013 年开源,对比起 x264 来说略显年轻,不过由于思科支付买了 H.264 的年度专利费,所以对于外部用户来说,相当于可以直接免费使用了。另外,firefox 直接内置了 OpenH264,作为其在 WebRTC 中的视频编解码器使用。

但对比起 x264,OpenH264 在 H264 高级特性的支持比较差:

Profile 只支持到 baseline,level 5.2;

多线程编码只支持 slice based,不支持 frame based 的多线程编码。

从编码效率上来看,OpenH264 的速度也并不会比 x264 快,不过其最大的好处,还是能够直接免费使用。

软硬编对比

从上面的分析来看,硬编的好处主要在于速度快,而且系统自带,不需要引入外部的库,但是特性支持有限,而且硬编的压缩率一般偏低。对于软编码来说,虽然速度较慢,但是压缩率比较高,而且支持的 H264 特性也会比硬编码多很多,相对来说比较可控。就可用性而言,在 4.4+的系统上,的可用性是能够基本保证的,但是不同等级机器的编码器能力会有不少差别,建议可以根据机器的配置,选择不同的编码器配置。

YUV 帧的预处理

根据最开始给出的流程,在送入编码器之前,我们需要先对摄像头输出的 YUV 帧进行一些前置处理。

缩放

如果设置了 Camera 的预览大小为 1080P,在 onPreviewFrame 中输出的 YUV 帧直接就是 1920×1080 的大小,如果需要编码跟这个大小不一样的视频,我们就需要在录制的过程中,实时的对 YUV 帧进行缩放。

以微信为例,摄像头预览 1080P 的数据,需要编码 960×540 大小的视频。

最为常见的做法是使用 FFmpeg 的 swsscale 函数进行直接缩放,效果/性能比较好的一般是选择 SWSFAST_BILINEAR算法:

mScaleYuvCtxPtr = sws_getContext( srcWidth, srcHeight, AV_PIX_FMT_NV21, dstWidth, dstHeight, AV_PIX_FMT_NV21, SWS_FAST_BILINEAR, NULL, NULL, NULL);sws_scale(mScaleYuvCtxPtr, (const uint8_t* const *) srcAvPicture->data, srcAvPicture->linesize, 0, srcHeight, dstAvPicture->data, dstAvPicture->linesize);

在 Nexus 6P 上,直接使用 FFmpeg 来进行缩放的时间基本上都需要 40ms+,对于我们需要录制 30fps 的来说,每帧处理时间最多就 30ms,如果光是缩放就消耗了如此多的时间,基本上录制出来的视频只能在 15fps 上下了。

很明显,直接使用 FFmpeg 进行缩放实在太慢了,不得不说 swsscale 在 FFmpeg 里面不适用。经对比了几种业界常用的算法之后,我们最后考虑使用快速缩放的算法,如图 3 所示。

《【转载】微信Android 视频编码爬过的那些坑》 u=2103263734,2892445634&fm=173&s=AD02A45C669EE63AC630D1FA03008035&w=640&h=178&img.JPG

我们选择一种叫做局部均值的算法,前后两行四个临近点算出最终图片的四个像素点,对于源图片的每行像素,我们可以使用 Neon 直接实现,以缩放 Y 分量为例:

上面使用的 Neon 指令每次只能读取和存储 8 或者 16 位的数据,对于多出来的数据,只需要用同样的算法改成用 C 语言实现即可。

在使用上述的算法优化之后,进行每帧缩放,在 Nexus 6P 上,只需要不到 5ms 就能完成了,而对于缩放质量来说,FFmpeg 的 SWSFASTBILINEAR 算法和上述算法缩放出来的图片进行对比,峰值信噪比(psnr)在大部分场景下大概在 38-40 左右,质量也足够好。

旋转

在 Android 机器上,由于摄像头安装角度不同,onPreviewFrame 出来的 YUV 帧一般都是旋转了 90 度或者 270 度,如果最终视频是要竖拍的,那一般来说需要把 YUV 帧进行旋转。

对于旋转的算法,如果是纯 C 实现的代码,一般来说是个 O(n2 )复杂度的算法,如果是旋转 960×540 的 YUV 帧数据,在 Nexus 6P 上,每帧旋转也需要 30ms+,这显然也是不能接受的。

在这里我们换个思路,能不能不对 YUV 帧进行旋转?显当然是可以的。

事实上在 MP4 文件格式的头部,我们可以指定一个旋转矩阵,具体来说是在 moov.trak.tkhd box 里面指定,视频播放器在播放视频的时候,会读取这里的矩阵信息,从而决定视频本身的旋转角度、位移、缩放等,具体可以参考苹果的文档

通过 FFmpeg,我们可以很轻松的给合成之后的 mp4 文件打上这个旋转角度:

char rotateStr[1024];sprintf(rotateStr, “%d”, rotate);av_dict_set(&out_stream->metadata, “rotate”, rotateStr, 0);

于是可以在录制的时候省下一大笔旋转的开销。

镜像

在使用前置摄像头拍摄的时候,如果不对 YUV 帧进行处理,那么直接拍出来的视频是会镜像翻转的,这里原理就跟照镜子一样,从前置摄像头方向拿出来的 YUV 帧刚好是反的,但有些时候拍出来的镜像视频可能不合我们的需求,因此这个时候我们就需要对 YUV 帧进行镜像翻转。

但由于摄像头安装角度一般是 90 度或者 270 度,所以实际上原生的 YUV 帧是水平翻转过来的,因此做镜像翻转的时候,只需要刚好以中间为中轴,分别上下交换每行数据即可,注意 Y 跟 UV 要分开处理,这种算法用 Neon 实现相当简单:

同样,剩余的数据用纯 C 代码实现就好了, 在 Nexus 6P 上,这种镜像翻转一帧 1080×1920 YUV 数据大概只要不到 5ms。

在编码好 H.264 视频流之后,最终处理就是把音频流跟视频流合流然后包装到 mp4 文件,这部分我们可以通过系统的 MediaMuxer、mp4v2,或者 FFmpeg 来实现,这部分比较简单,在这里就不再阐述了。

参考文献

雷霄骅(leixiaohua1020)的专栏,大名鼎鼎雷神的博客,里面有非常多关于音视频编码/FFmpeg 相关的学习资料,入门必备。也祈愿他能够在天堂安息吧。

Android MediaCodec stuff,包含了一些 MediaCodec 使用的示例代码,初次使用可以参考下这里。

Coding for NEON,一个系列教程,讲述了一些常用 Neon 指令使用方法。上面在介绍缩放的时候使用到了 Neon,事实上大部分音视频处理过程都会使用到,以 yuv 帧处理为例,缩放,旋转,镜像翻转都可以使用 Neon 来做优化。

libyuv,Google 开源的一个 YUV 处理库,上面只针对 1080p->540p 视频帧缩放的算法,而对于通用的压缩处理,可以直接使用这里的实现,对比起 FFmpeg 的速度快上不少。

作者: 周俊杰,微信 Android 客户端开发工程师,常年维护音视频相关模块的开发以及各类兼容性问题的处理,负责微信支付相关需求的开发。

    原文作者:lmy小明
    原文地址: https://www.jianshu.com/p/638c1104a948
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞