談?wù)勱P(guān)于Android視頻編碼的那些坑
Android的視頻相關(guān)的開發(fā),大概一直是整個(gè)Android生態(tài),以及Android API中,最為分裂以及兼容性問題最為突出的一部分。攝像頭,以及視頻編碼相關(guān)的API,Google一直對(duì)這方面的控制力非常差,導(dǎo)致不同廠商對(duì)這兩個(gè)API的實(shí)現(xiàn)有不少差異,而且從API的設(shè)計(jì)來看,一直以來優(yōu)化也相當(dāng)有限,甚至有人認(rèn)為這是“Android上最難用的API之一”
以微信為例,我們錄制一個(gè)540p的mp4文件,對(duì)于Android來說,大體上是遵循這么一個(gè)流程:
大體上就是從攝像頭輸出的YUV幀經(jīng)過預(yù)處理之后,送入編碼器,獲得編碼好的h264視頻流。
上面只是針對(duì)視頻流的編碼,另外還需要對(duì)音頻流單獨(dú)錄制,最后再將視頻流和音頻流進(jìn)行合成出最終視頻。
這篇文章主要將會(huì)對(duì)視頻流的編碼中兩個(gè)常見問題進(jìn)行分析:
- 視頻編碼器的選擇(硬編 or 軟編)?
- 如何對(duì)攝像頭輸出的YUV幀進(jìn)行快速預(yù)處理(鏡像,縮放,旋轉(zhuǎn))?
視頻編碼器的選擇
對(duì)于錄制視頻的需求,不少app都需要對(duì)每一幀數(shù)據(jù)進(jìn)行單獨(dú)處理,因此很少會(huì)直接用到 MediaRecorder 來直接錄取視頻,一般來說,會(huì)有這么兩個(gè)選擇
- MediaCodec
- FFMpeg+x264/openh264
我們來逐個(gè)解析一下
MediaCodec
MediaCodec是API 16之后Google推出的用于音視頻編解碼的一套偏底層的API,可以直接利用硬件加速進(jìn)行視頻的編解碼。調(diào)用的時(shí)候需要先初始化MediaCodec作為視頻的編碼器,然后只需要不停傳入原始的YUV數(shù)據(jù)進(jìn)入編碼器就可以直接輸出編碼好的h264流,整個(gè)API設(shè)計(jì)模型來看,就是同時(shí)包含了輸入端和輸出端的兩條隊(duì)列:
因此,作為編碼器,輸入端隊(duì)列存放的就是原始YUV數(shù)據(jù),輸出端隊(duì)列輸出的就是編碼好的h264流,作為解碼器則對(duì)應(yīng)相反。在調(diào)用的時(shí)候,MediaCodec提供了同步和異步兩種調(diào)用方式,但是異步使用Callback的方式是在API 21之后才加入的,以同步調(diào)用為例,一般來說調(diào)用方式大概是這樣(摘自官方例子):
- 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();
簡(jiǎn)單解釋一下,通過 getInputBuffers 獲取輸入隊(duì)列,然后調(diào)用 dequeueInputBuffer 獲取輸入隊(duì)列空閑數(shù)組下標(biāo),注意 dequeueOutputBuffer 會(huì)有幾個(gè)特殊的返回值表示當(dāng)前編解碼狀態(tài)的變化,然后再通過 queueInputBuffer 把原始YUV數(shù)據(jù)送入編碼器,而在輸出隊(duì)列端同樣通過 getOutputBuffers 和 dequeueOutputBuffer 獲取輸出的h264流,處理完輸出數(shù)據(jù)之后,需要通過 releaseOutputBuffer 把輸出buffer還給系統(tǒng),重新放到輸出隊(duì)列中。
關(guān)于MediaCodec更復(fù)雜的使用例子,可以參照下CTS測(cè)試?yán)锩娴氖褂梅绞剑?EncodeDecodeTest.java
從上面例子來看的確是非常原始的API,由于MediaCodec底層是直接調(diào)用了手機(jī)平臺(tái)硬件的編解碼能力,所以速度非???,但是因?yàn)镚oogle對(duì)整個(gè)Android硬件生態(tài)的掌控力非常弱,所以這個(gè)API有很多問題:
1、顏色格式問題
MediaCodec在初始化的時(shí)候,在 configure 的時(shí)候,需要傳入一個(gè)MediaFormat對(duì)象,當(dāng)作為編碼器使用的時(shí)候,我們一般需要在MediaFormat中指定視頻的寬高,幀率,碼率,I幀間隔等基本信息,除此之外,還有一個(gè)重要的信息就是,指定編碼器接受的YUV幀的顏色格式。這個(gè)是因?yàn)橛捎赮UV根據(jù)其采樣比例,UV分量的排列順序有很多種不同的顏色格式,而對(duì)于Android的攝像頭在 onPreviewFrame 輸出的YUV幀格式,如果沒有配置任何參數(shù)的情況下,基本上都是NV21格式,但Google對(duì)MediaCodec的API在設(shè)計(jì)和規(guī)范的時(shí)候,顯得很不厚道,過于貼近Android的HAL層了,導(dǎo)致了NV21格式并不是所有機(jī)器的MediaCodec都支持這種格式作為編碼器的輸入格式! 因此,在初始化MediaCodec的時(shí)候,我們需要通過 codecInfo.getCapabilitiesForType 來查詢機(jī)器上的MediaCodec實(shí)現(xiàn)具體支持哪些YUV格式作為輸入格式,一般來說,起碼在4.4+的系統(tǒng)上,這兩種格式在大部分機(jī)器都有支持:
- MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar
- MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
兩種格式分別是YUV420P和NV21,如果機(jī)器上只支持YUV420P格式的情況下,則需要先將攝像頭輸出的NV21格式先轉(zhuǎn)換成YUV420P,才能送入編碼器進(jìn)行編碼,否則最終出來的視頻就會(huì)花屏,或者顏色出現(xiàn)錯(cuò)亂
這個(gè)算是一個(gè)不大不小的坑,基本上用上了MediaCodec進(jìn)行視頻編碼都會(huì)遇上這個(gè)問題
2、編碼器支持特性相當(dāng)有限
如果使用MediaCodec來編碼H264視頻流,對(duì)于H264格式來說,會(huì)有一些針對(duì)壓縮率以及碼率相關(guān)的視頻質(zhì)量設(shè)置,典型的諸如Profile(baseline, main, high),Profile Level, Bitrate mode(CBR, CQ, VBR),合理配置這些參數(shù)可以讓我們?cè)谕鹊拇a率下,獲得更高的壓縮率,從而提升視頻的質(zhì)量,Android也提供了對(duì)應(yīng)的API進(jìn)行設(shè)置,可以設(shè)置到MediaFormat中這些設(shè)置項(xiàng):
- MediaFormat.KEY_BITRATE_MODE
- MediaFormat.KEY_PROFILE
- MediaFormat.KEY_LEVEL
但問題是,對(duì)于Profile,Level, Bitrate mode這些設(shè)置,在大部分手機(jī)上都是不支持的,即使是設(shè)置了最終也不會(huì)生效,例如設(shè)置了Profile為high,最后出來的視頻依然還會(huì)是Baseline,Shit....
這個(gè)問題,在7.0以下的機(jī)器幾乎是必現(xiàn)的,其中一個(gè)可能的原因是,Android在源碼層級(jí) hardcode 了profile的的設(shè)置:
- // 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) {
- .....
- }
這個(gè)問題可以說間接導(dǎo)致了MediaCodec編碼出來的視頻質(zhì)量偏低,同等碼率下,難以獲得跟軟編碼甚至iOS那樣的視頻質(zhì)量。
3、16位對(duì)齊要求
前面說到,MediaCodec這個(gè)API在設(shè)計(jì)的時(shí)候,過于貼近HAL層,這在很多Soc的實(shí)現(xiàn)上,是直接把傳入MediaCodec的buffer,在不經(jīng)過任何前置處理的情況下就直接送入了Soc中。而在編碼h264視頻流的時(shí)候,由于h264的編碼塊大小一般是16x16,于是乎在一開始設(shè)置視頻的寬高的時(shí)候,如果設(shè)置了一個(gè)沒有對(duì)齊16的大小,例如960x540,在某些cpu上,最終編碼出來的視頻就會(huì)直接 花屏 !
很明顯這還是因?yàn)閺S商在實(shí)現(xiàn)這個(gè)API的時(shí)候,對(duì)傳入的數(shù)據(jù)缺少校驗(yàn)以及前置處理導(dǎo)致的,目前來看,華為,三星的Soc出現(xiàn)這個(gè)問題會(huì)比較頻繁,其他廠商的一些早期Soc也有這種問題,一般來說解決方法還是在設(shè)置視頻寬高的時(shí)候,統(tǒng)一設(shè)置成對(duì)齊16位之后的大小就好了。
FFMpeg+x264/openh264
除了使用MediaCodec進(jìn)行編碼之外,另外一種比較流行的方案就是使用ffmpeg+x264/openh264進(jìn)行軟編碼,ffmpeg是用于一些視頻幀的預(yù)處理。這里主要是使用x264/openh264作為視頻的編碼器。
x264基本上被認(rèn)為是當(dāng)今市面上最快的商用視頻編碼器,而且基本上所有h264的特性都支持,通過合理配置各種參數(shù)還是能夠得到較好的壓縮率和編碼速度的,限于篇幅,這里不再闡述h264的參數(shù)配置,有興趣可以看下 這里 和 這里 對(duì)x264編碼參數(shù)的調(diào)優(yōu)。
openh264 則是由思科開源的另外一個(gè)h264編碼器,項(xiàng)目在2013年開源,對(duì)比起x264來說略顯年輕,不過由于思科支付滿了h264的年度專利費(fèi),所以對(duì)于外部用戶來說,相當(dāng)于可以直接免費(fèi)使用了,另外,firefox直接內(nèi)置了openh264,作為其在webRTC中的視頻的編解碼器使用。
但對(duì)比起x264,openh264在h264高級(jí)特性的支持比較差:
- Profile只支持到baseline, level 5.2
- 多線程編碼只支持slice based,不支持frame based的多線程編碼
從編碼效率上來看,openh264的速度也并不會(huì)比x264快,不過其最大的好處,還是能夠直接免費(fèi)使用吧。
軟硬編對(duì)比
從上面的分析來看,硬編的好處主要在于速度快,而且系統(tǒng)自帶不需要引入外部的庫(kù),但是特性支持有限,而且硬編的壓縮率一般偏低,而對(duì)于軟編碼來說,雖然速度較慢,但是壓縮率比較高,而且支持的H264特性也會(huì)比硬編碼多很多,相對(duì)來說比較可控。就可用性而言,在4.4+的系統(tǒng)上,MediaCodec的可用性是能夠基本保證的,但是不同等級(jí)的機(jī)器的編碼器能力會(huì)有不少差別,建議可以根據(jù)機(jī)器的配置,選擇不同的編碼器配置。
YUV幀的預(yù)處理
根據(jù)最開始給出的流程,在送入編碼器之前,我們需要先對(duì)攝像頭輸出的YUV幀進(jìn)行一些前置處理
1.縮放
如果設(shè)置了camera的預(yù)覽大小為1080p的情況下,在 onPreviewFrame 中輸出的YUV幀直接就是1920x1080的大小,如果需要編碼跟這個(gè)大小不一樣的視頻,我們就需要在錄制的過程中, 實(shí)時(shí) 的對(duì)YUV幀進(jìn)行縮放。
以微信為例,攝像頭預(yù)覽1080p的數(shù)據(jù),需要編碼960x540大小的視頻。
最為常見的做法是使用ffmpeg這種的sws_scale函數(shù)進(jìn)行直接縮放,效果/性能比較好的一般是選擇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來進(jìn)行縮放的時(shí)間基本上都需要 40ms+ ,對(duì)于我們需要錄制30fps的來說,每幀處理時(shí)間最多就30ms左右,如果光是縮放就消耗了如此多的時(shí)間,基本上錄制出來的視頻只能在15fps上下了。
很明顯,直接使用ffmpeg進(jìn)行縮放是在是太慢了,不得不說swsscale簡(jiǎn)直就是ffmpeg里面的渣渣,在對(duì)比了幾種業(yè)界常用的算之后,我們最后考慮實(shí)現(xiàn)使用這種快速縮放的算法:
我們選擇一種叫做的 局部均值 算法,前后兩行四個(gè)臨近點(diǎn)算出最終圖片的四個(gè)像素點(diǎn),對(duì)于源圖片的每行像素,我們可以使用Neon直接實(shí)現(xiàn),以縮放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指令每次只能讀取和存儲(chǔ)8或者16位的數(shù)據(jù),對(duì)于多出來的數(shù)據(jù),只需要用同樣的算法改成用C語(yǔ)言實(shí)現(xiàn)即可。
在使用上述的算法優(yōu)化之后,進(jìn)行每幀縮放,在Nexus 6p上,只需要不到 5ms 就能完成了,而對(duì)于縮放質(zhì)量來說,ffmpeg的SWS_FAST_BILINEAR算法和上述算法縮放出來的圖片進(jìn)行對(duì)比,峰值信噪比(psnr)在大部分場(chǎng)景下大概在 38-40 左右,質(zhì)量也足夠好了。
2.旋轉(zhuǎn)
在android機(jī)器上,由于攝像頭安裝角度不同, onPreviewFrame 出來的YUV幀一般都是旋轉(zhuǎn)了90或者270度,如果最終視頻是要豎拍的,那一般來說需要把YUV幀進(jìn)行旋轉(zhuǎn)。
對(duì)于旋轉(zhuǎn)的算法,如果是純C實(shí)現(xiàn)的代碼,一般來說是個(gè)O(n^2 ) 復(fù)雜度的算法,如果是旋轉(zhuǎn)960x540的yuv幀數(shù)據(jù),在nexus 6p上,每幀旋轉(zhuǎn)也需要 30ms+ ,這顯然也是不能接受的。
在這里我們換個(gè)思路,能不能不對(duì)YUV幀進(jìn)行旋轉(zhuǎn)?(當(dāng)然是可以的6666)
事實(shí)上在mp4文件格式的頭部,我們可以指定一個(gè)旋轉(zhuǎn)矩陣,具體來說是在 moov.trak.tkhd box 里面指定,視頻播放器在播放視頻的時(shí)候,會(huì)在讀取這里矩陣信息,從而決定視頻本身的旋轉(zhuǎn)角度,位移,縮放等,具體可以參考下蘋果的 文檔
通過ffmpeg,我們可以很輕松的給合成之后的mp4文件打上這個(gè)旋轉(zhuǎn)角度:
- char rotateStr[1024];
- sprintf(rotateStr, "%d", rotate);
- av_dict_set(&out_stream->metadata, "rotate", rotateStr, 0);
于是可以在錄制的時(shí)候省下一大筆旋轉(zhuǎn)的開銷了,excited!
3.鏡像
在使用前置攝像頭拍攝的時(shí)候,如果不對(duì)YUV幀進(jìn)行處理,那么直接拍出來的視頻是會(huì) 鏡像翻轉(zhuǎn) 的,這里原理就跟照鏡子一樣,從前置攝像頭方向拿出來的YUV幀剛好是反的,但有些時(shí)候拍出來的鏡像視頻可能不合我們的需求,因此這個(gè)時(shí)候我們就需要對(duì)YUV幀進(jìn)行鏡像翻轉(zhuǎn)。
但由于攝像頭安裝角度一般是90或者270度,所以實(shí)際上原生的YUV幀是水平翻轉(zhuǎn)過來的,因此做鏡像翻轉(zhuǎn)的時(shí)候,只需要?jiǎng)偤靡灾虚g為中軸,分別上下交換每行數(shù)據(jù)即可,注意Y跟UV要分開處理,這種算法用Neon實(shí)現(xiàn)相當(dāng)簡(jiǎn)單:
- 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
- );
同樣,剩余的數(shù)據(jù)用純C代碼實(shí)現(xiàn)就好了, 在nexus6p上,這種鏡像翻轉(zhuǎn)一幀1080x1920 YUV數(shù)據(jù)大概只要不到 5ms
在編碼好h264視頻流之后,最終處理就是把音頻流跟視頻流合流然后包裝到mp4文件,這部分我們可以通過系統(tǒng)的 MediaMuxer , mp4v2 ,或者ffmpeg來實(shí)現(xiàn),這部分比較簡(jiǎn)單,在這里就不再闡述了。