微信 Android 视频编码爬过的那些坑

2,162 阅读16分钟
原文链接: mp.weixin.qq.com

【编者按】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 设计模型同时包含了输入端和输出端的两条队列。

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

MediaCodec codec = MediaCodec.createByCodecName(name); codec.configure(format, …); MediaFormat outputFormat = codec.getOutputFormat(); // option B codec.start(); for (;;) {   int inputBufferId = codec.dequeueInputBuffer(timeoutUs);   if (inputBufferId >= 0) {     ByteBuffer inputBuffer = codec.getInputBuffer(…);     // fill inputBuffer with valid data     …     codec.queueInputBuffer(inputBufferId, …);   }   int outputBufferId = codec.dequeueOutputBuffer(…);   if (outputBufferId >= 0) {     ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);     MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A     // bufferFormat is identical to outputFormat     // outputBuffer is ready to be processed or rendered.     …     codec.releaseOutputBuffer(outputBufferId, …);   } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {     // Subsequent data will conform to new format.     // Can ignore if using getOutputFormat(outputBufferId)     outputFormat = codec.getOutputFormat(); // option B   } } codec.stop(); codec.release();

简单解释一下,通过 getInputBuffers 获取输入队列,然后调用 dequeueInputBuffer 获取输入队列空闲数组下标,注意 dequeueOutputBuffer 会有几个特殊的返回值表示当前编解码状态的变化,然后再通过 queueInputBuffer 把原始 YUV 数据送入编码器,而在输出队列端同样通过 getOutputBuffersdequeueOutputBuffer 获取输出的 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 都支持这种格式作为编码器的输入格式。 因此,在初始化 MediaCodec 的时候,我们需要通过 codecInfo.getCapabilitiesForType 来查询机器上的 MediaCodec 实现具体支持哪些 YUV 格式作为输入格式。一般来说,起码在 4.4+ 的系统上,这两种格式在大部分机器上都有支持:

MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar MediaCodecInfo.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_MODE MediaFormat.KEY_PROFILE MediaFormat.KEY_LEVEL

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

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

// XXX if (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) {        .....    }

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

  • 16 位对齐要求

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

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

FFMpeg+x264/openh264

除了使用 MediaCodec 进行编码之外,另外一种比较流行的方案就是使用 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+的系统上,MediaCodec 的可用性是能够基本保证的,但是不同等级机器的编码器能力会有不少差别,建议可以根据机器的配置,选择不同的编码器配置。

YUV 帧的预处理

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

缩放

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

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

最为常见的做法是使用 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 所示。

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

const uint8* src_next = src_ptr + src_stride;    asm volatile (      "1:                                          \n"            "vld4.8     {d0, d1, d2, d3}, [%0]!        \n"        "vld4.8     {d4, d5, d6, d7}, [%1]!        \n"        "subs       %3, %3, #16                    \n"  // 16 processed per loop        "vrhadd.u8   d0, d0, d1                    \n"        "vrhadd.u8   d4, d4, d5                    \n"        "vrhadd.u8   d0, d0, d4                    \n"        "vrhadd.u8   d2, d2, d3                    \n"        "vrhadd.u8   d6, d6, d7                    \n"        "vrhadd.u8   d2, d2, d6                    \n"        "vst2.8     {d0, d2}, [%2]!                    \n"  // store odd pixels        "bgt        1b                             \n"      : "+r"(src_ptr),          // %0        "+r"(src_next),         // %1        "+r"(dst),              // %2        "+r"(dst_width)         // %3      :      : "q0", "q1", "q2", "q3"              // Clobber List);

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

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

旋转

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

对于旋转的算法,如果是纯 C 实现的代码,一般来说是个 O(n2 )复杂度的算法,如果是旋转 960x540 的 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 实现相当简单:

asm volatile (      "1:                                          \n"        "vld4.8     {d0, d1, d2, d3}, [%2]!        \n"  // load 32 from src        "vld4.8     {d4, d5, d6, d7}, [%3]!        \n"  // load 32 from dst        "subs       %4, %4, #32                    \n"  // 32 processed per loop        "vst4.8     {d0, d1, d2, d3}, [%1]!        \n"  // store 32 to dst        "vst4.8     {d4, d5, d6, d7}, [%0]!        \n"  // store 32 to src        "bgt        1b                             \n"      : "+r"(src),   // %0        "+r"(dst),   // %1        "+r"(srcdata), // %2        "+r"(dstdata), // %3        "+r"(count)  // %4  // Output registers      :                     // Input registers      : "cc", "memory", "q0", "q1", "q2", "q3"  // Clobber List    );

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

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

参考文献

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

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

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

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

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

责编:唐门教主(tangxy@csdn.net)

声明:本文为 CSDN《程序员》原创文章,未经许可,请勿转载,如需转载,请留言。