MediaCodec 解碼視頻快速取幀
開發(fā)背景
所以考慮在需要 1s 視頻取 30 幀縮略圖時(shí),采取 MediaCodec 硬解視頻,獲取 YUV 數(shù)據(jù),再使用 libyuv 庫(kù),編碼 YUV 為 ARGB 生成 bitmap 的優(yōu)化方案,該方案輸出一幀 1080p 視頻幀耗時(shí)在 50ms 左右,并且還有優(yōu)化空間
MediaCodec 解碼取幀流程
mediaCodec 解碼流程

mediaCodec 的使用比較流程化,對(duì)于使用者來(lái)說(shuō),更關(guān)注輸入源與輸出源。
輸入源
使用 MediaExtractor 作為輸入源,首先區(qū)分軌道,選擇視頻軌進(jìn)行操作,然后在解碼線程循環(huán)中,不斷的從 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")
}
}
讀取數(shù)據(jù)
val sampleSize = mediaExtractor.readSampleData(inputBuffer!!, 0)
val presentationTimeUs = mediaExtractor.sampleTime
mediaExtractor.advance()
輸出源
由于不需要渲染到屏幕上,這里選擇的輸出源是 ImageReader,并且 MediaCodec 使用 ImageReader 會(huì)更高效一些
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)
然后調(diào)用 mediaCodec 的 releaseOutputBuffer 方法,在 ImageReader 回調(diào)之中就能拿到 Image 對(duì)象。
處理輸出得到 Bitmap
從 ImagerReader 的回調(diào)之中,我們能夠得到 一個(gè) ImagerReader 對(duì)象
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 對(duì)象,然后從 Image 對(duì)象中取得 YUV 數(shù)據(jù)的 三個(gè)分量,然后通過(guò) libyuv 庫(kù)將 yuv 數(shù)據(jù)轉(zhuǎn)換為 Bitmap 對(duì)象,寫入 LruDiskCache中。
MediaCodec 使用中遇到的問(wèn)題
整個(gè)方案的流程應(yīng)該是比較清晰簡(jiǎn)單的,但在實(shí)際做的過(guò)程中,遇到了很多阻塞的問(wèn)題,在解決這些問(wèn)題的過(guò)程中,才能更深入的了解到 MediaCodec 解碼與 YUV 數(shù)據(jù)處理。
- 使用 mediaExtractor.seekTo() 定位需要取幀時(shí)間戳的輸入源,發(fā)現(xiàn)會(huì)有很多的重復(fù)幀,并且,幀和預(yù)覽畫面對(duì)不上 (seekTo 是以關(guān)鍵幀為基準(zhǔn)的,當(dāng)視頻關(guān)鍵幀間隔較遠(yuǎn)的時(shí)候,會(huì)出現(xiàn)這樣的情況)
- 使用 mediaExtractor.advance() 與 mediaExtractor.seekTo() 配合取幀,會(huì)發(fā)現(xiàn)有些幀取出來(lái)時(shí)間特別長(zhǎng),不流暢 (同樣是視頻關(guān)鍵幀間隔較遠(yuǎn)的時(shí)候,會(huì)出現(xiàn)這樣的情況)
- 發(fā)現(xiàn)上述兩個(gè)問(wèn)題應(yīng)該是和關(guān)鍵幀有關(guān),突發(fā)奇想,使用 mediaExtractor.getSampleFlags() 來(lái)判斷幀是不是關(guān)鍵幀,把所有的關(guān)鍵幀與需要的時(shí)間戳的幀都扔進(jìn)解碼器,這樣確實(shí)效率高了很多,但也沒(méi)有得到正確的結(jié)果,很多幀是花的。(解碼不止需要關(guān)鍵幀,視頻幀分為)
這三個(gè)問(wèn)題都是由于對(duì)視頻的幀間編碼不夠了解導(dǎo)致的,向增輝學(xué)習(xí)了很多下,然后深入了解了一下幀間編碼,與解碼原理之后,換了一個(gè)方案,就解決了這幾個(gè)問(wèn)題。之后又遇到了新的問(wèn)題。
最終方案:關(guān)鍵是關(guān)鍵幀的間隔,如果視頻源比較可靠,關(guān)鍵幀間隔比較小,并且取幀的間隔比較大,可以直接seek到目標(biāo)時(shí)間取幀。
我們因?yàn)橐曨l源不確定,而且取幀間隔特別小,這樣的話會(huì)遇到上述的問(wèn)題,所以我就把所有的幀全都扔給解碼器,然后在輸出短,通過(guò) Image 的 pts 去過(guò)濾我要的幀,因?yàn)榻獯a一幀的耗時(shí)比較小,編碼 yuv 到 bitmap 的耗時(shí)可以省掉,性能還可以接受,但這個(gè)應(yīng)該還是可以優(yōu)化的點(diǎn)
- 在一些機(jī)型上,生成的 bitmap 色彩值不對(duì),有虛影。(不同機(jī)型上 YUV 格式不同)
- libyuv 轉(zhuǎn)換 yuv to bitmap 效率不高,耗時(shí)很大。 (libyuv 未開啟 neno 指令集優(yōu)化)
這兩個(gè)問(wèn)題,主要是對(duì) YUV 數(shù)據(jù)格式不太了解,對(duì) libyuv 的使用不夠熟悉導(dǎo)致,在向老敏學(xué)習(xí)了很多下,然后深入了解了 YUV 格式與 libyuv 的使用,解決了這兩個(gè)問(wèn)題。
幀格式
I frame :幀內(nèi)編碼幀 又稱 intra picture,I 幀通常是每個(gè) GOP(MPEG 所使用的一種視頻壓縮技術(shù))的第一個(gè)幀,經(jīng)過(guò)適度地壓縮,做為隨機(jī)訪問(wèn)的參考點(diǎn),可以當(dāng)成圖象。I幀可以看成是一個(gè)圖像經(jīng)過(guò)壓縮后的產(chǎn)物。
P frame: 前向預(yù)測(cè)編碼幀 又稱 predictive-frame,通過(guò)充分將低于圖像序列中前面已編碼幀的時(shí)間冗余信息來(lái)壓縮傳輸數(shù)據(jù)量的編碼圖像,也叫預(yù)測(cè)幀;
B frame: 雙向預(yù)測(cè)內(nèi)插編碼幀 又稱bi-directional interpolated prediction frame,既考慮與源圖像序列前面已編碼幀,也顧及源圖像序列后面已編碼幀之間的時(shí)間冗余信息來(lái)壓縮傳輸數(shù)據(jù)量的編碼圖像,也叫雙向預(yù)測(cè)幀;
兩個(gè)I frame之間形成一個(gè)GOP,在x264中同時(shí)可以通過(guò)參數(shù)來(lái)設(shè)定bf的大小,即:I 和p或者兩個(gè)P之間B的數(shù)量。
- I 幀自身可以通過(guò)視頻解壓算法解壓成一張單獨(dú)的完整視頻畫面
- P 幀需要參考前面一個(gè) I 幀或者 P 幀來(lái)解碼
- B 幀需要參考前一個(gè) I 幀 或者 P 幀,以及后面一個(gè) P 幀來(lái)解碼
PTS:Presentation Time Stamp。PTS 顯示時(shí)間戳
DTS:Decode Time Stamp。DTS 解碼時(shí)間戳。

YUV 格式與 Image
Image類在API 19中引入,Image作為相機(jī)得到的原始幀數(shù)據(jù)的載體(Camera 2);硬件編解碼的 MediaCodec類 加入了對(duì)Image和Image的封裝ImageReader的全面支持??梢灶A(yù)見,Image將會(huì)用來(lái)統(tǒng)一Android內(nèi)部混亂的中間圖片數(shù)據(jù)(這里中間圖片數(shù)據(jù)指如各式Y(jié)UV格式數(shù)據(jù),在處理過(guò)程中產(chǎn)生和銷毀)管理。
每個(gè)Image當(dāng)然有自己的格式,這個(gè)格式由ImageFormat確定。對(duì)于YUV420,ImageFormat在API 21中新加入了YUV_420_888類型,其表示YUV420格式的集合,888表示Y、U、V分量中每個(gè)顏色占8bit。s
YUV420格式分為 YUV420P 和 YUV420SP兩種。
其中YUV420P格式,分為 I420 和 YV12 兩種,YUV420SP格式分為 NV12 和 NV21 兩種。他們的存儲(chǔ)格式,區(qū)別是 Planar 的 uv 分量是平面型的,SemiPlanar 的 uv 分量是交織的。
- I420: YYYYYYYY UUVV => YUV420P (Android 格式)
- YV12: YYYYYYYY VVUU => YUV420P
- NV12: YYYYYYYY UVUV => YUV420SP
- NV21: YYYYYYYY VUVU => YUV420SP (Android 格式)
Image 中的 Y、U和V三個(gè)分量的數(shù)據(jù)分別保存在三個(gè)Plane類中,可以通過(guò)getPlanes()得到。Plane實(shí)際是對(duì)ByteBuffer的封裝。Image保證了plane #0一定是Y,#1一定是U,#2一定是V。且對(duì)于plane #0,Y分量數(shù)據(jù)一定是連續(xù)存儲(chǔ)的,中間不會(huì)有U或V數(shù)據(jù)穿插,也就是說(shuō)我們一定能夠一次性得到所有Y分量的值。
接下來(lái)看看U和V分量,我們考慮其中的兩類格式:Planar,SemiPlanar。
Planar 下U和V分量是分開存放的,所以我們也應(yīng)當(dāng)能夠一次性從plane #1和plane #2中獲得所有的U和V分量值,事實(shí)也是如此。
而SemiPlanar,此格式下U和V分量交叉存儲(chǔ),Image 并沒(méi)有為我們將U和V分量分離出來(lái)。
所以,看到這里,對(duì)于開發(fā)過(guò)程中遇到的問(wèn)題已經(jīng)有了答案,在一些機(jī)型上,生成的 bitmap 色彩值不對(duì),有虛影。是因?yàn)?,一些機(jī)型上,解碼獲得的 YUV 數(shù)據(jù)是交織的,也就是 NV21 格式的 YUV 數(shù)據(jù),而在使用libyuv 對(duì) yuv 數(shù)據(jù)進(jìn)行處理獲得 bitmap 對(duì)象的操作時(shí),由于認(rèn)為 Image 對(duì)象會(huì)完整的分離 uv 分量,所以并沒(méi)有考慮到這個(gè)問(wèn)題,導(dǎo)致對(duì) NV21 的數(shù)據(jù)格式不能正確處理,導(dǎo)致了色彩值不對(duì),并且有虛影。
優(yōu)化方向
由于項(xiàng)目時(shí)間比較趕,留下了幾個(gè)可以優(yōu)化的點(diǎn):
- 不同機(jī)型支持的 MediaCodec 實(shí)例數(shù)量不一樣,目前是把解碼器做成了單例,然后把需要解碼的視頻做成 List 傳入,串行解碼,后續(xù)可以嘗試判斷不同機(jī)型支持的實(shí)例數(shù)量,并行解碼。
- 使用 libyuv 庫(kù) 轉(zhuǎn)換 YUV 到 bitmap 過(guò)程中,先把 NV21 格式轉(zhuǎn)成了 I420,然后再進(jìn)行了縮放、旋轉(zhuǎn)的操作,可以更改接口,直接先用 NV21 縮放,然后再轉(zhuǎn)換,效率上能夠得到提升