Android:MediaCodeC硬編碼解碼視訊,並將視訊幀儲存為圖片檔案

AiLo發表於2019-02-17

好久不見,AiLo肥來了! 原文地址

原創文章,轉載請聯絡作者

醉拍春衫惜舊香,天將離恨惱疏狂。 年年陌上生秋草,日日樓中到夕陽。

目的

  • MediaCodeC搭配MediaExtractor將視訊完整解碼
  • 視訊幀儲存為JPEG檔案
  • 使用兩種方式達成
    • 硬編碼輸出資料二次封裝為YuvImage,並直接輸出為JPEG格式檔案
    • 硬編碼搭配Surface,用OpenGL封裝為RGBA資料格式,再利用Bitmap壓縮為圖片檔案
    • 二者皆可以調整圖片輸出質量

參考

  • YUV的處理方式,強推大家觀看這篇文章高效率得到YUV格式幀,絕對整的明明白白
  • OpenGL的處理方式,當然是最出名的BigFlake,硬編碼相關的示例程式碼很是詳細

解碼效率分析

  • 參考物件為一段約為13.8s,H.264編碼,FPS為24,72*1280的MPEG-4的視訊檔案。鴨鴨戲水視訊
    • 此視訊的視訊幀數為332
  • 略好點的裝置解碼時間稍短一點。但兩種解碼方式的效率對比下來,OpenGl渲染耗費的時間比YUV轉JPEG多。
    • 另:差一點的裝置上,這個差值會被提高,約為一倍多。較好的裝置,則小於一倍。

實現過程

對整個視訊的解析,以及壓入MediaCodeC輸入佇列都是通用步驟。


mediaExtractor.setDataSource(dataSource)
// 檢視是否含有視訊軌
val trackIndex = mediaExtractor.selectVideoTrack()
if (trackIndex < 0) {
    throw RuntimeException("this data source not video")
}
mediaExtractor.selectTrack(trackIndex)
      
       
fun MediaExtractor.selectVideoTrack(): Int {
    val numTracks = trackCount
    for (i in 0 until numTracks) {
        val format = getTrackFormat(i)
        val mime = format.getString(MediaFormat.KEY_MIME)
        if (mime.startsWith("video/")) {
            return i
        }
    }
    return -1
}

複製程式碼

配置MediaCodeC解碼器,將解碼輸出格式設定為COLOR_FormatYUV420Flexible,這種模式幾乎所有裝置都會支援。
使用OpenGL渲染的話,MediaCodeC要配置一個輸出Surface。使用YUV方式的話,則不需要配置

		outputSurface = if (isSurface) OutputSurface(mediaFormat.width, mediaFormat.height) else null

        // 指定幀格式COLOR_FormatYUV420Flexible,幾乎所有的解碼器都支援
        if (decoder.codecInfo.getCapabilitiesForType(mediaFormat.mime).isSupportColorFormat(defDecoderColorFormat)) {
            mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, defDecoderColorFormat)
            decoder.configure(mediaFormat, outputSurface?.surface, null, 0)
        } else {
            throw RuntimeException("this mobile not support YUV 420 Color Format")
        }

        val startTime = System.currentTimeMillis()
        Log.d(TAG, "start decode frames")
        isStart = true
        val bufferInfo = MediaCodec.BufferInfo()
        // 是否輸入完畢
        var inputEnd = false
        // 是否輸出完畢
        var outputEnd = false
        decoder.start()
        var outputFrameCount = 0

        while (!outputEnd && isStart) {
            if (!inputEnd) {
                val inputBufferId = decoder.dequeueInputBuffer(DEF_TIME_OUT)
                if (inputBufferId >= 0) {
                    // 獲得一個可寫的輸入快取物件
                    val inputBuffer = decoder.getInputBuffer(inputBufferId)
                    // 使用MediaExtractor讀取資料
                    val sampleSize = videoAnalyze.mediaExtractor.readSampleData(inputBuffer, 0)
                    if (sampleSize < 0) {
                        // 2019/2/8-19:15 沒有資料
                        decoder.queueInputBuffer(inputBufferId, 0, 0, 0L,
                                MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                        inputEnd = true
                    } else {
                        // 將資料壓入到輸入佇列
                        val presentationTimeUs = videoAnalyze.mediaExtractor.sampleTime
                        decoder.queueInputBuffer(inputBufferId, 0,
                                sampleSize, presentationTimeUs, 0)
                        videoAnalyze.mediaExtractor.advance()
                    }
                }
            }
複製程式碼

可以大致畫一個流程圖如下:

Android:MediaCodeC硬編碼解碼視訊,並將視訊幀儲存為圖片檔案

YUV

通過以上通用的步驟後,接下來就是對MediaCodeC的輸出資料作YUV處理了。步驟如下:

1.使用MediaCodeC的getOutputImage (int index)函式,得到一個只讀的Image物件,其包含原始視訊幀資訊。

By:當MediaCodeC配置了輸出Surface時,此值返回null

2.將Image得到的資料封裝到YuvImage中,再使用YuvImage的compressToJpeg方法壓縮為JPEG檔案

YuvImage的封裝,官方文件有這樣一段描述:Currently only ImageFormat.NV21 and ImageFormat.YUY2 are supported。 YuvImage只支援NV21或者YUY2格式,所以還可能需要對Image的原始資料作進一步處理,將其轉換為NV21的Byte陣列

讀取Image資訊並封裝為Byte陣列

此次演示的機型,反饋的Image格式如下:

getFormat = 35
getCropRect().width()=720
getCropRect().height()=1280

35代表ImageFormat.YUV_420_888格式。Image的getPlanes會返回一個陣列,其中0代表Y,1代表U,2代表V。由於是420格式,那麼四個Y值共享一對UV分量,比例為4:1。
程式碼如下,參考YUV_420_888編碼Image轉換為I420和NV21格式byte陣列,不過我這裡只保留了NV21格式的轉換

fun Image.getDataByte(): ByteArray {
    val format = format
    if (!isSupportFormat()) {
        throw RuntimeException("image can not support format is $format")
    }
    // 指定了圖片的有效區域,只有這個Rect內的畫素才是有效的
    val rect = cropRect
    val width = rect.width()
    val height = rect.height()
    val planes = planes
    val data = ByteArray(width * height * ImageFormat.getBitsPerPixel(format) / 8)
    val rowData = ByteArray(planes[0].rowStride)

    var channelOffset = 0
    var outputStride = 1
    for (i in 0 until planes.size) {
        when (i) {
            0 -> {
                channelOffset = 0
                outputStride = 1
            }
            1 -> {
                channelOffset = width * height + 1
                outputStride = 2
            }
            2 -> {
                channelOffset = width * height
                outputStride = 2
            }
        }

        // 此時得到的ByteBuffer的position指向末端
        val buffer = planes[i].buffer
        //  行跨距
        val rowStride = planes[i].rowStride
        // 行內顏色值間隔,真實間隔值為此值減一
        val pixelStride = planes[i].pixelStride

        val TAG = "getDataByte"

        Log.d(TAG, "planes index is  $i")
        Log.d(TAG, "pixelStride $pixelStride")
        Log.d(TAG, "rowStride $rowStride")
        Log.d(TAG, "width $width")
        Log.d(TAG, "height $height")
        Log.d(TAG, "buffer size " + buffer.remaining())

        val shift = if (i == 0) 0 else 1
        val w = width.shr(shift)
        val h = height.shr(shift)
        buffer.position(rowStride * (rect.top.shr(shift)) + pixelStride +
                (rect.left.shr(shift)))
        for (row in 0 until h) {
            var length: Int
            if (pixelStride == 1 && outputStride == 1) {
                length = w
                // 2019/2/11-23:05 buffer有時候遺留的長度,小於length就會報錯
                buffer.getNoException(data, channelOffset, length)
                channelOffset += length
            } else {
                length = (w - 1) * pixelStride + 1
                buffer.getNoException(rowData, 0, length)
                for (col in 0 until w) {
                    data[channelOffset] = rowData[col * pixelStride]
                    channelOffset += outputStride
                }
            }

            if (row < h - 1) {
                buffer.position(buffer.position() + rowStride - length)
            }
        }
    }
    return data
}
複製程式碼
最後封裝YuvImage並壓縮為檔案
	val rect = image.cropRect
    val yuvImage = YuvImage(image.getDataByte(), ImageFormat.NV21, rect.width(), rect.height(), null)
    yuvImage.compressToJpeg(rect, 100, fileOutputStream)
    fileOutputStream.close()
複製程式碼

MediaCodeC配置輸出Surface,使用OpenGL渲染

OpenGL的環境搭建和渲染程式碼不再贅述,只是強調幾個點:

  • 渲染紋理的執行緒一定要和MediaCodeC配置Surface的執行緒保持一致
  • 在渲染紋理程式碼前,一定要呼叫MediaCodeC的releaseOutputBuffer函式,將輸出資料及時渲染到輸出Surface上,否則Surface內的紋理將不會收到任何資料
獲得可用的RGBA資料,使用Bitmap壓縮為指定格式檔案
fun saveFrame(fileName: String) {
        pixelBuf.rewind()
        GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, pixelBuf)
        var bos: BufferedOutputStream? = null
        try {
            bos = BufferedOutputStream(FileOutputStream(fileName))
            val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
            pixelBuf.rewind()
            bmp.copyPixelsFromBuffer(pixelBuf)
            bmp.compress(Bitmap.CompressFormat.JPEG, 100, bos)
            bmp.recycle()
        } finally {
            bos?.close()
        }
    }
複製程式碼

結果分析

到目前為止,針對樣例視訊,YUV解碼出來的視訊幀亮度會稍低一點,且圖片邊緣處有細微的失真。OpenGL渲染解碼的視訊幀會明亮一些,放大三四倍邊緣無失真。後續會繼續追蹤這個問題,會使用FFmpeg解碼來作為對比。

結語

此處有專案地址,點選傳送


以上
原創不易,大家走過路過看的開心,可以適當給個一毛兩毛聊表心意

Android:MediaCodeC硬編碼解碼視訊,並將視訊幀儲存為圖片檔案

相關文章