1. 程式人生 > >微信 Android 視訊編碼爬過的那些坑

微信 Android 視訊編碼爬過的那些坑

【編者按】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

這個問題,在 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 客戶端開發工程師,常年維護音視訊相關模組的開發以及各類相容性問題的處理,負責微信支付相關需求的開發。
  • 責編:唐門教主([email protected]
  • 宣告:本文為 CSDN《程式設計師》原創文章,未經許可,請勿轉載,如需轉載,請留言。