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 缩放,然后再转换,效率上能够得到提升