談談關于Android視頻編碼的那些坑
Android的視頻相關的開發(fā),大概一直是整個Android生態(tài),以及Android API中,最為分裂以及兼容性問題最為突出的一部分。攝像頭,以及視頻編碼相關的API,Google一直對這方面的控制力非常差,導致不同廠商對這兩個API的實現有不少差異,而且從API的設計來看,一直以來優(yōu)化也相當有限,甚至有人認為這是“Android上最難用的API之一”
以微信為例,我們錄制一個540p的mp4文件,對于Android來說,大體上是遵循這么一個流程:
大體上就是從攝像頭輸出的YUV幀經過預處理之后,送入編碼器,獲得編碼好的h264視頻流。
上面只是針對視頻流的編碼,另外還需要對音頻流單獨錄制,最后再將視頻流和音頻流進行合成出最終視頻。
這篇文章主要將會對視頻流的編碼中兩個常見問題進行分析:
- 視頻編碼器的選擇(硬編 or 軟編)?
- 如何對攝像頭輸出的YUV幀進行快速預處理(鏡像,縮放,旋轉)?
視頻編碼器的選擇
對于錄制視頻的需求,不少app都需要對每一幀數據進行單獨處理,因此很少會直接用到 MediaRecorder 來直接錄取視頻,一般來說,會有這么兩個選擇
- MediaCodec
- FFMpeg+x264/openh264
我們來逐個解析一下
MediaCodec
MediaCodec是API 16之后Google推出的用于音視頻編解碼的一套偏底層的API,可以直接利用硬件加速進行視頻的編解碼。調用的時候需要先初始化MediaCodec作為視頻的編碼器,然后只需要不停傳入原始的YUV數據進入編碼器就可以直接輸出編碼好的h264流,整個API設計模型來看,就是同時包含了輸入端和輸出端的兩條隊列:
因此,作為編碼器,輸入端隊列存放的就是原始YUV數據,輸出端隊列輸出的就是編碼好的h264流,作為解碼器則對應相反。在調用的時候,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 會有幾個特殊的返回值表示當前編解碼狀態(tài)的變化,然后再通過 queueInputBuffer 把原始YUV數據送入編碼器,而在輸出隊列端同樣通過 getOutputBuffers 和 dequeueOutputBuffer 獲取輸出的h264流,處理完輸出數據之后,需要通過 releaseOutputBuffer 把輸出buffer還給系統,重新放到輸出隊列中。
關于MediaCodec更復雜的使用例子,可以參照下CTS測試里面的使用方式: EncodeDecodeTest.java
從上面例子來看的確是非常原始的API,由于MediaCodec底層是直接調用了手機平臺硬件的編解碼能力,所以速度非???,但是因為Google對整個Android硬件生態(tài)的掌控力非常弱,所以這個API有很多問題:
1、顏色格式問題
MediaCodec在初始化的時候,在 configure 的時候,需要傳入一個MediaFormat對象,當作為編碼器使用的時候,我們一般需要在MediaFormat中指定視頻的寬高,幀率,碼率,I幀間隔等基本信息,除此之外,還有一個重要的信息就是,指定編碼器接受的YUV幀的顏色格式。這個是因為由于YUV根據其采樣比例,UV分量的排列順序有很多種不同的顏色格式,而對于Android的攝像頭在 onPreviewFrame 輸出的YUV幀格式,如果沒有配置任何參數的情況下,基本上都是NV21格式,但Google對MediaCodec的API在設計和規(guī)范的時候,顯得很不厚道,過于貼近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進行視頻編碼都會遇上這個問題
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,最后出來的視頻依然還會是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那樣的視頻質量。
3、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的特性都支持,通過合理配置各種參數還是能夠得到較好的壓縮率和編碼速度的,限于篇幅,這里不再闡述h264的參數配置,有興趣可以看下 這里 和 這里 對x264編碼參數的調優(yōu)。
openh264 則是由思科開源的另外一個h264編碼器,項目在2013年開源,對比起x264來說略顯年輕,不過由于思科支付滿了h264的年度專利費,所以對于外部用戶來說,相當于可以直接免費使用了,另外,firefox直接內置了openh264,作為其在webRTC中的視頻的編解碼器使用。
但對比起x264,openh264在h264高級特性的支持比較差:
- Profile只支持到baseline, level 5.2
- 多線程編碼只支持slice based,不支持frame based的多線程編碼
從編碼效率上來看,openh264的速度也并不會比x264快,不過其最大的好處,還是能夠直接免費使用吧。
軟硬編對比
從上面的分析來看,硬編的好處主要在于速度快,而且系統自帶不需要引入外部的庫,但是特性支持有限,而且硬編的壓縮率一般偏低,而對于軟編碼來說,雖然速度較慢,但是壓縮率比較高,而且支持的H264特性也會比硬編碼多很多,相對來說比較可控。就可用性而言,在4.4+的系統上,MediaCodec的可用性是能夠基本保證的,但是不同等級的機器的編碼器能力會有不少差別,建議可以根據機器的配置,選擇不同的編碼器配置。
YUV幀的預處理
根據最開始給出的流程,在送入編碼器之前,我們需要先對攝像頭輸出的YUV幀進行一些前置處理
1.縮放
如果設置了camera的預覽大小為1080p的情況下,在 onPreviewFrame 中輸出的YUV幀直接就是1920x1080的大小,如果需要編碼跟這個大小不一樣的視頻,我們就需要在錄制的過程中, 實時 的對YUV幀進行縮放。
以微信為例,攝像頭預覽1080p的數據,需要編碼960x540大小的視頻。
最為常見的做法是使用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里面的渣渣,在對比了幾種業(yè)界常用的算之后,我們最后考慮實現使用這種快速縮放的算法:
我們選擇一種叫做的 局部均值 算法,前后兩行四個臨近點算出最終圖片的四個像素點,對于源圖片的每行像素,我們可以使用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語言實現即可。
在使用上述的算法優(yōu)化之后,進行每幀縮放,在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 box 里面指定,視頻播放器在播放視頻的時候,會在讀取這里矩陣信息,從而決定視頻本身的旋轉角度,位移,縮放等,具體可以參考下蘋果的 文檔
通過ffmpeg,我們可以很輕松的給合成之后的mp4文件打上這個旋轉角度:
- char rotateStr[1024];
- sprintf(rotateStr, "%d", rotate);
- av_dict_set(&out_stream->metadata, "rotate", rotateStr, 0);
于是可以在錄制的時候省下一大筆旋轉的開銷了,excited!
3.鏡像
在使用前置攝像頭拍攝的時候,如果不對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代碼實現就好了, 在nexus6p上,這種鏡像翻轉一幀1080x1920 YUV數據大概只要不到 5ms
在編碼好h264視頻流之后,最終處理就是把音頻流跟視頻流合流然后包裝到mp4文件,這部分我們可以通過系統的 MediaMuxer , mp4v2 ,或者ffmpeg來實現,這部分比較簡單,在這里就不再闡述了。