谈谈关于Android视频编码的那些坑

本文讲的是谈论关于Android视频编码的那些坑,Android的视频相关的开发,大概一直是整个Android生态,以及Android API中,最为分裂以及兼容性问题最为突出的一部分。摄像头,以及视频编码相关的API,谷歌一直对这方面的控制力非常差,导致不同厂商对这两个API的实现有不少差异,而且从API的设计来看,一直以来优化也相当有限,甚至有人认为这是“安卓上最难用的API之一”

以微信为例,我们录制一个540P的MP4文件,对于安卓来说,大体上是遵循这么一个流程:

图片.png

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

上面只是针对视频流的编码,另外还需要对音频流单独录制,最后再将视频流和音频流进行合成出最终视频。
这篇文章主要将会对视频流的编码中两个常见问题进行分析:

视频编码器的选择(硬编或软编)?

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

视频编码器的选择

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

MediaCodec

FFmpeg的+ X264 / openh264

我们来逐个解析一下

MediaCodec

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

图片.png

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

MediaCodec codec = MediaCodec.createByCodecName(name);

codec.configure(format,...);

MediaFormat outputFormat = codec.getOutputFormat();//选项B

codec.start();
for(;;){

intinputBufferId = codec.dequeueInputBuffer(timeoutUs);

if(inputBufferId> = 0){

ByteBuffer inputBuffer = codec.getInputBuffer(...);

//用有效的数据填充inputBuffer

...

codec.queueInputBuffer(inputBufferId,...);

}

intoutputBufferId = codec.dequeueOutputBuffer(...);

if(outputBufferId> = 0){

ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);

MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId);//选项A

// bufferFormat是相同于OUTPUTFORMAT

// OutputBuffer中是准备要被处理或渲染。

...

codec.releaseOutputBuffer(outputBufferId,...);

}elseif(outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){

//随后的数据将符合到新的格式。

//可以忽略使用getOutputFormat(outputBufferId)

outputFormat = codec.getOutputFormat();//选项B

}

}

codec.stop();

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

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

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

1,颜色格式问题

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

MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar

MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar

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

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

2,编码器支持特性相当有限

如果使用MediaCodec来编码H264视频流,对于H264格式来说,会有一些针对压缩率以及码率相关的视频质量设置,典型的如Profile(baseline,main,high),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,最后出来的视频依然还会是基线,妈....

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

// XXX

if(h264type.eProfile!= OMX_VIDEO_AVCProfileBaseline){

ALOGW(“使用基线配置文件代替AVC录制的%d”,

h264type.eProfile);

h264type.eProfile = OMX_VIDEO_AVCProfileBaseline;

}

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

if(h264type.eProfile == OMX_VIDEO_AVCProfileBaseline){

....

}否则如果(h264type.eProfile == OMX_VIDEO_AVCProfileMain ||

h264type.eProfile == OMX_VIDEO_AVCProfileHigh){

.....

}

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

3,16位对齐要求

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

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

FFmpeg的+ X264 / openh264

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

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

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

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

简介只支持到基准,等级5.2

多线程编码只支持片基,不支持基于帧的多线程编码

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

软硬编对比

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

YUV帧的预处理

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

1.缩放

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

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

最为常见的做法是使用ffmpeg的这种的sws_scale函数进行直接缩放,效果/性能比较好的一般是选择SWS_FAST_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的里面的渣渣,在对比了几种业界常用的算之后,我们最后考虑实现使用这种快速缩放的算法:

图片.png

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

const uint8 * src_next = src_ptr + src_stride;

asm挥发性(

“1:\ n”

“vld4.8 {d0,d1,d2,d3},[%0]!\ n”

“vld4.8 {d4,d5,d6,d7},[%1]!\ n”

“subs%3,%3,#16 \ n”//每个循环处理16次

“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”//存储奇数像素

“bgt 1b \ n”

:“+ r”(src_ptr),//%0

“+ r”(src_next),//%1

“+ r”(dst),//%2

“+ r”(dst_width)//%3

:“q0”,“q1”,“q2”,“q3”// Clobber List

);

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

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

2.旋转

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

对于旋转的算法,如果是纯C实现的代码,一般来说是个O(n ^ 2)复杂度的算法,如果是旋转960x540的yuv帧数据,在nexus 6p上,每帧旋转也需要30ms +,这显然也是不能接受的。

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

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

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

charrotateStr [1024];

sprintf(rotateStr,“%d”,rotate);

av_dict_set(&out_stream-> metadata,“rotate”,rotateStr,0);

于是可以在录制的时候省下一大笔旋转的开销了,兴奋!

3.镜像

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

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

asm挥发性(

“1:\ n”

“vld4.8 {d0,d1,d2,d3},[%2]!\ n”//从src载入32

“vld4.8 {d4,d5,d6,d7},[%3]!\ n”//从dst载入32

“subs%4,%4,#32 \ n”// 32每个循环处理

“vst4.8 {d0,d1,d2,d3},[%1]!\ n”//存储32到dst

“vst4.8 {d4,d5,d6,d7},[%0]!\ n”//存储32到src

“bgt 1b \ n”

:“+ r”(src),//%0

“+ r”(dst),//%1

“+ r”(srcdata),//%2

“+ r”(dstdata),//%3

“+ r”(count)//%4 //输出寄存器

://输入寄存器

:“cc”,“memory”,“q0”,“q1”,“q2”,“q3”// Clobber List

);

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容