Andoid MediaCodec 解碼視頻快速取幀

MediaCodec 解碼視頻快速取幀

開發背景

所以考慮在需要 1s 視頻取 30 幀縮略圖時,采取 MediaCodec 硬解視頻,獲取 YUV 數據,再使用 libyuv 庫,編碼 YUV 為 ARGB 生成 bitmap 的優化方案,該方案輸出一幀 1080p 視頻幀耗時在 50ms 左右,并且還有優化空間

MediaCodec 解碼取幀流程

mediaCodec 解碼流程

image.png

mediaCodec 的使用比較流程化,對于使用者來說,更關注輸入源與輸出源。

輸入源

使用 MediaExtractor 作為輸入源,首先區分軌道,選擇視頻軌進行操作,然后在解碼線程循環中,不斷的從 MediaExtractor 中取出視頻 buffer 作為 MediaCodec 的輸入源即可

 mediaExtractor = MediaExtractor()
 mediaExtractor.setDataSource(path)
 var videoFormat: MediaFormat? = null
 for (i in 0..mediaExtractor.trackCount) {
        val mediaFormat = mediaExtractor.getTrackFormat(i)
        if (mediaFormat.getString(MediaFormat.KEY_MIME).contains("video")) {
               mediaExtractor.selectTrack(i)
               videoFormat = mediaFormat
               break
            }
        }
        if (videoFormat == null) {
            throw IllegalStateException("video format is null")
        }
}

讀取數據

val sampleSize = mediaExtractor.readSampleData(inputBuffer!!, 0)
val presentationTimeUs = mediaExtractor.sampleTime
mediaExtractor.advance()

輸出源

由于不需要渲染到屏幕上,這里選擇的輸出源是 ImageReader,并且 MediaCodec 使用 ImageReader 會更高效一些

you should use a Surface for raw video data to improve codec performance. Surface uses native video buffers without mapping or copying them to ByteBuffers; thus, it is much more efficient. You normally cannot access the raw video data when using a Surface, but you can use the ImageReader class to access unsecured decoded (raw) video frames. This may still be more efficient than using ByteBuffers

初始化 ImageLoader

 imageReader = ImageReader.newInstance(
            videoFormat.getInteger(MediaFormat.KEY_WIDTH),
            videoFormat.getInteger(MediaFormat.KEY_HEIGHT),
            ImageFormat.YUV_420_888,
            3)
 imageReaderThread = ImageReaderHandlerThread()
 codec.configure(videoFormat, imageReader.surface, null, 0)
 codec.start()
 imageReader.setOnImageAvailableListener(MyOnImageAvailableListener(path),imageReaderThread.handler)

然后調用 mediaCodec 的 releaseOutputBuffer 方法,在 ImageReader 回調之中就能拿到 Image 對象。

處理輸出得到 Bitmap

從 ImagerReader 的回調之中,我們能夠得到 一個 ImagerReader 對象

            img = reader.acquireLatestImage()
            if (img != null) {
                val outputTime = readCount * intervalTime * 1000 * 1000L
                if (img.timestamp >= outputTime) {
                    if (debugLog) {
                        BLog.d(TAG, "start get bitmap $readCount timestamp is " + img.timestamp)
                    }
                    val planes = img.planes
                    if (planes[0].buffer == null) {
                        return
                    }
                    var bitmap: Bitmap? = null
                    val cacheKey = "$path#${readCount * intervalTime}"
                    if (DiskCacheProvider.diskCache.get(cacheKey)?.exists() != true) {
                        bitmap = getBitmapScale(img, rotation)
                        BLog.d(TAG, "write cache by cache key $cacheKey")
                        DiskCacheProvider.diskCache.put(cacheKey, object : IWriter {
                            override fun write(file: File): Boolean {
                                return FileUtil.saveBmpToFile(bitmap, file, Bitmap.CompressFormat.JPEG)
                            }
                        })
                    }
                    bitmap?.let {
                        callback?.invoke(readCount, bitmap)
                    }
                    readCount++
                    if (debugLog) {
                        BLog.d(TAG, "end get bitmap $readCount timestamp is " + img.timestamp + " cache key id " + cacheKey)
                    }
                }
            }

從 ImageReader 中,我們能夠取出 Image 對象,然后從 Image 對象中取得 YUV 數據的 三個分量,然后通過 libyuv 庫將 yuv 數據轉換為 Bitmap 對象,寫入 LruDiskCache中。

MediaCodec 使用中遇到的問題

整個方案的流程應該是比較清晰簡單的,但在實際做的過程中,遇到了很多阻塞的問題,在解決這些問題的過程中,才能更深入的了解到 MediaCodec 解碼與 YUV 數據處理。

  1. 使用 mediaExtractor.seekTo() 定位需要取幀時間戳的輸入源,發現會有很多的重復幀,并且,幀和預覽畫面對不上 (seekTo 是以關鍵幀為基準的,當視頻關鍵幀間隔較遠的時候,會出現這樣的情況)
  2. 使用 mediaExtractor.advance() 與 mediaExtractor.seekTo() 配合取幀,會發現有些幀取出來時間特別長,不流暢 (同樣是視頻關鍵幀間隔較遠的時候,會出現這樣的情況)
  3. 發現上述兩個問題應該是和關鍵幀有關,突發奇想,使用 mediaExtractor.getSampleFlags() 來判斷幀是不是關鍵幀,把所有的關鍵幀與需要的時間戳的幀都扔進解碼器,這樣確實效率高了很多,但也沒有得到正確的結果,很多幀是花的。(解碼不止需要關鍵幀,視頻幀分為)

這三個問題都是由于對視頻的幀間編碼不夠了解導致的,向增輝學習了很多下,然后深入了解了一下幀間編碼,與解碼原理之后,換了一個方案,就解決了這幾個問題。之后又遇到了新的問題。

最終方案:關鍵是關鍵幀的間隔,如果視頻源比較可靠,關鍵幀間隔比較小,并且取幀的間隔比較大,可以直接seek到目標時間取幀。

我們因為視頻源不確定,而且取幀間隔特別小,這樣的話會遇到上述的問題,所以我就把所有的幀全都扔給解碼器,然后在輸出短,通過 Image 的 pts 去過濾我要的幀,因為解碼一幀的耗時比較小,編碼 yuv 到 bitmap 的耗時可以省掉,性能還可以接受,但這個應該還是可以優化的點

  1. 在一些機型上,生成的 bitmap 色彩值不對,有虛影。(不同機型上 YUV 格式不同)
  2. libyuv 轉換 yuv to bitmap 效率不高,耗時很大。 (libyuv 未開啟 neno 指令集優化)

這兩個問題,主要是對 YUV 數據格式不太了解,對 libyuv 的使用不夠熟悉導致,在向老敏學習了很多下,然后深入了解了 YUV 格式與 libyuv 的使用,解決了這兩個問題。

幀格式

I frame :幀內編碼幀 又稱 intra picture,I 幀通常是每個 GOP(MPEG 所使用的一種視頻壓縮技術)的第一個幀,經過適度地壓縮,做為隨機訪問的參考點,可以當成圖象。I幀可以看成是一個圖像經過壓縮后的產物。

P frame: 前向預測編碼幀 又稱 predictive-frame,通過充分將低于圖像序列中前面已編碼幀的時間冗余信息來壓縮傳輸數據量的編碼圖像,也叫預測幀;

B frame: 雙向預測內插編碼幀 又稱bi-directional interpolated prediction frame,既考慮與源圖像序列前面已編碼幀,也顧及源圖像序列后面已編碼幀之間的時間冗余信息來壓縮傳輸數據量的編碼圖像,也叫雙向預測幀;

兩個I frame之間形成一個GOP,在x264中同時可以通過參數來設定bf的大小,即:I 和p或者兩個P之間B的數量。

  1. I 幀自身可以通過視頻解壓算法解壓成一張單獨的完整視頻畫面
  2. P 幀需要參考前面一個 I 幀或者 P 幀來解碼
  3. B 幀需要參考前一個 I 幀 或者 P 幀,以及后面一個 P 幀來解碼

PTS:Presentation Time Stamp。PTS 顯示時間戳

DTS:Decode Time Stamp。DTS 解碼時間戳。

image.png

YUV 格式與 Image

Image類在API 19中引入,Image作為相機得到的原始幀數據的載體(Camera 2);硬件編解碼的 MediaCodec類 加入了對Image和Image的封裝ImageReader的全面支持。可以預見,Image將會用來統一Android內部混亂的中間圖片數據(這里中間圖片數據指如各式YUV格式數據,在處理過程中產生和銷毀)管理。

每個Image當然有自己的格式,這個格式由ImageFormat確定。對于YUV420,ImageFormat在API 21中新加入了YUV_420_888類型,其表示YUV420格式的集合,888表示Y、U、V分量中每個顏色占8bit。s

YUV420格式分為 YUV420P 和 YUV420SP兩種。
其中YUV420P格式,分為 I420 和 YV12 兩種,YUV420SP格式分為 NV12 和 NV21 兩種。他們的存儲格式,區別是 Planar 的 uv 分量是平面型的,SemiPlanar 的 uv 分量是交織的。

  1. I420: YYYYYYYY UUVV => YUV420P (Android 格式)
  2. YV12: YYYYYYYY VVUU => YUV420P
  3. NV12: YYYYYYYY UVUV => YUV420SP
  4. NV21: YYYYYYYY VUVU => YUV420SP (Android 格式)

Image 中的 Y、U和V三個分量的數據分別保存在三個Plane類中,可以通過getPlanes()得到。Plane實際是對ByteBuffer的封裝。Image保證了plane #0一定是Y,#1一定是U,#2一定是V。且對于plane #0,Y分量數據一定是連續存儲的,中間不會有U或V數據穿插,也就是說我們一定能夠一次性得到所有Y分量的值。

接下來看看U和V分量,我們考慮其中的兩類格式:Planar,SemiPlanar。

Planar 下U和V分量是分開存放的,所以我們也應當能夠一次性從plane #1和plane #2中獲得所有的U和V分量值,事實也是如此。

而SemiPlanar,此格式下U和V分量交叉存儲,Image 并沒有為我們將U和V分量分離出來。

所以,看到這里,對于開發過程中遇到的問題已經有了答案,在一些機型上,生成的 bitmap 色彩值不對,有虛影。是因為,一些機型上,解碼獲得的 YUV 數據是交織的,也就是 NV21 格式的 YUV 數據,而在使用libyuv 對 yuv 數據進行處理獲得 bitmap 對象的操作時,由于認為 Image 對象會完整的分離 uv 分量,所以并沒有考慮到這個問題,導致對 NV21 的數據格式不能正確處理,導致了色彩值不對,并且有虛影。

優化方向

由于項目時間比較趕,留下了幾個可以優化的點:

  1. 不同機型支持的 MediaCodec 實例數量不一樣,目前是把解碼器做成了單例,然后把需要解碼的視頻做成 List 傳入,串行解碼,后續可以嘗試判斷不同機型支持的實例數量,并行解碼。
  2. 使用 libyuv 庫 轉換 YUV 到 bitmap 過程中,先把 NV21 格式轉成了 I420,然后再進行了縮放、旋轉的操作,可以更改接口,直接先用 NV21 縮放,然后再轉換,效率上能夠得到提升
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。