Android视频快速取帧图片

上一篇文章我们讲到图片转字符画,这篇文章要实现视频转字符画视频。
我们看一下实现出来的效果图:


播放字符画视频

实现的效果还是让人挺满意的。我们下面说一下具体的实现步骤,

  1. 视频取帧
  2. 对帧图片进行字符画转换
  3. 对获取到的字符画合成视频

我们分开一步一步的讲:

视频取帧

视频取帧的整个功能最麻烦的一步,目前Android视频取帧的方法有好几种。
其中有使用SDK自带的MediaMetadataRetriever直接获取bimap的,但是缺点就是慢。
也有使用强大的FFmpeg库的,但是需要针对编译不同架构的CPU编译不同的so文件十分的麻烦。
也有人推荐使用一个名为Jcodec的库,开发效率上来说这个工具确实十分的好,但是运行起来真的十分的慢,我写了个Demo取一帧大概要我4s的时间(测试手机是Redmi note 7 pro),所以只用他的视频合成功能(虽然仍然很慢具体的解决办法还没找到).
后来在别的大佬博客里面找到一篇使用原生接口MediaCodec硬解码视频的文章,用该方法取帧完美解决对不同机型的兼容性问题,因为使用的原生接口速度也是可以保证的,参考:
Android: MediaCodec视频文件硬件解码,高效率得到YUV格式帧,快速保存JPEG图片(不使用OpenGL)
写的比较的详细,主要的问题点是MediaCodec解码返回的帧图片数据是YUV格式的,它跟我们平时使用的RGB格式很不一样的是它的三个值表示的是亮度,色度,饱和度,YUV下也分不同的格式分别有:
Y'UV, YUV, YCbCrYPbPr等,安卓设备因为API 21 统一的原因都能使用COLOR_FormatYUV420Flexible格式,使得MediaCodec的所有硬件解码都支持这种格式。但这样解码后得到的YUV420的具体格式又会因设备而异,有:YUV420Planar,YUV420SemiPlanar,YUV420PackedSemiPlanar等,我们可以使用Image类来处理这些格式统一处理向NV21进行转换。
然后我们可以对Image类进行转换成Bitmap,再对Bimap的进行像素转换成字符数组再绘制成图片保存作为转换字符画视频 的其中一帧。
具体实现,首先我们在解码的过程的中需要获取设备是否支持COLOR_FormatYUV420Flexible帧格式,然后初始化几个重要的对象:

...
MediaExtractor extractor = null;
MediaFormat mediaFormat = null;
MediaCodec decoder = null;

extractor = initMediaExtractor(file);//使用视频文件对象初始化extractor
mediaFormat = initMediaFormat(videoPath, extractor);
decoder = initMediaCodec(mediaFormat);
//初始化解码配置
decoder.configure(mediaFormat, null, null, 0);
decoder.start();
//开始解码
...

static private MediaExtractor initMediaExtractor(File path) throws IOException {
        MediaExtractor extractor = null;
        extractor = new MediaExtractor();
        extractor.setDataSource(path.toString());
        return extractor;
    }

static private MediaFormat initMediaFormat(String path, MediaExtractor extractor) {
        //选择解码通道
        int trackIndex = selectTrack(extractor);
        if (trackIndex < 0) {
            throw new RuntimeException("No video track found in " + path);
        }
        extractor.selectTrack(trackIndex);
        MediaFormat mediaFormat = extractor.getTrackFormat(trackIndex);
        return mediaFormat;
    }

static public MediaCodec initMediaCodec(MediaFormat mediaFormat) throws IOException {
        MediaCodec decoder = null;
        String mime = mediaFormat.getString(MediaFormat.KEY_MIME);
        decoder = MediaCodec.createDecoderByType(mime);
        //showSupportedColorFormat(decoder.getCodecInfo().getCapabilitiesForType(mime));
        if (isColorFormatSupported(decodeColorFormat, decoder.getCodecInfo().getCapabilitiesForType(mime))) {
            //  设置 解码格式        
            mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, decodeColorFormat);
        } else {
        }
        return decoder;
    }

初始化完成后我们可以利用这三个对象进行关键帧获取:

   private static Bitmap getBitmapBySec(MediaExtractor extractor, MediaFormat mediaFormat, MediaCodec decoder, long sec) throws IOException {

        MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
        Bitmap bitmap = null;
        boolean sawInputEOS = false;
        boolean sawOutputEOS = false;
        boolean stopDecode = false;
        final int width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH);
        final int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT);
        long presentationTimeUs = -1;
        int outputBufferId;
        Image image = null;

        //视频定位到指定的时间的上一帧
        extractor.seekTo(sec, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);

        //因为extractor定位的帧不是准确的,所以我们要用一个循环不停读取下一帧来获取我们想要的时间画面。
        while (!sawOutputEOS && !stopDecode) {
            if (!sawInputEOS) { 
                int inputBufferId = decoder.dequeueInputBuffer(-1);                
                if (inputBufferId >= 0) {
                    ByteBuffer inputBuffer = decoder.getInputBuffer(inputBufferId);
                    int sampleSize = extractor.readSampleData(inputBuffer, 0);
                    if (sampleSize < 0) {                        
                        decoder.queueInputBuffer(inputBufferId, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                        sawInputEOS = true;
                    } else {
                        //获取定位的帧的时间
                        presentationTimeUs = extractor.getSampleTime();                       
                        //把定位的帧压入队列
                        decoder.queueInputBuffer(inputBufferId, 0, sampleSize, presentationTimeUs, 0);
                        //跳到下一帧
                        extractor.advance();
                    }
                }
            }
            outputBufferId = decoder.dequeueOutputBuffer(info, DEFAULT_TIMEOUT_US);
            if (outputBufferId >= 0) {
                //能够有效输出
                if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 | presentationTimeUs >= sec) {
                    //时间是指定时间或者已经是视频结束时间,停止循环
                    sawOutputEOS = true;
                    boolean doRender = (info.size != 0);
                    if (doRender) {
                        //获取指定时间解码出来的Image对象。
                        image = decoder.getOutputImage(outputBufferId);
                        //将Image转换成Bimap
                        stream.close();
                        image.close();
                    }
                }
                decoder.releaseOutputBuffer(outputBufferId, true);
            }
        }

        return bitmap;
    }

获取到了帧画面数据,下面我们可以做关于Image对Bimap的转换,主要是用到YuvImage这个类,在使用YuvImage这个类前需要把YUV_420_888的编码格式转成NV21格式:

...
image = decoder.getOutputImage(outputBufferId);
YuvImage yuvImage = new YuvImage(YUV_420_888toNV21(image), ImageFormat.NV21, width, height, null);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
yuvImage.compressToJpeg(new Rect(0, 0, width, height), 100, stream);
bitmap = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size());
...
 private static byte[] YUV_420_888toNV21(Image image) {
        Rect crop = image.getCropRect();
        int format = image.getFormat();
        int width = crop.width();
        int height = crop.height();
        Image.Plane[] planes = image.getPlanes();
        byte[] data = new byte[width * height * ImageFormat.getBitsPerPixel(format) / 8];
        byte[] rowData = new byte[planes[0].getRowStride()];
        //if (VERBOSE) Log.v("YUV_420_888toNV21", "get data from " + planes.length + " planes");
        int channelOffset = 0;
        int outputStride = 1;
        for (int i = 0; i < planes.length; i++) {
            switch (i) {
                case 0:
                    channelOffset = 0;
                    outputStride = 1;
                    break;
                case 1:
                    channelOffset = width * height + 1;
                    outputStride = 2;

                    break;
                case 2:
                    channelOffset = width * height;
                    outputStride = 2;

                    break;
            }
            ByteBuffer buffer = planes[i].getBuffer();
            int rowStride = planes[i].getRowStride();
            int pixelStride = planes[i].getPixelStride();

            int shift = (i == 0) ? 0 : 1;
            int w = width >> shift;
            int h = height >> shift;
            buffer.position(rowStride * (crop.top >> shift) + pixelStride * (crop.left >> shift));
            for (int row = 0; row < h; row++) {
                int length;
                if (pixelStride == 1 && outputStride == 1) {
                    length = w;
                    buffer.get(data, channelOffset, length);
                    channelOffset += length;
                } else {
                    length = (w - 1) * pixelStride + 1;
                    buffer.get(rowData, 0, length);
                    for (int col = 0; col < w; col++) {
                        data[channelOffset] = rowData[col * pixelStride];
                        channelOffset += outputStride;
                    }
                }
                if (row < h - 1) {
                    buffer.position(buffer.position() + rowStride - length);
                }
            }
           // if (VERBOSE) Log.v("", "Finished reading data from plane " + i);
        }
        return data;
    }

这样我们就能获取到了帧图片的Bitmap数据了,剩下的步骤都跟上一篇文章的图片转换差不多,当我们所有的帧都转换完以后,我们就可以把这些图片按顺序合成视频了,这里我调用的是上面提到的Jcodec这个工具,它有支持图片合成视频的功能,代码如下:

 static public String convertVideoBySourcePics(Context context, String picsDri) {
        SeekableByteChannel out = null;
        //找到输出目录,没有的话创建
        File destDir = new File(Environment.getExternalStorageDirectory() + "/FunVideo_Video");
        if (!destDir.exists()) {
            destDir.mkdirs();
        }
        //新建输出文件
        File file = new File(destDir.getPath() + "/funvideo_" + System.currentTimeMillis() + ".mp4");
        try {
            file.createNewFile();
            // for Android use: AndroidSequenceEncoder
            File _piscDri = new File(picsDri);
            //创建编码对象
            AndroidSequenceEncoder encoder = AndroidSequenceEncoder.createSequenceEncoder(file, 5);
            for (File childFile : _piscDri.listFiles()) {
                Bitmap bitmap = BitmapUtils.getBitmapByUri(context, Uri.fromFile(childFile));
                encoder.encodeImage(bitmap);
                bitmap.recycle();
            }
            //结束编码
            encoder.finish();
            //通知系统添加了视频文件。
            Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
            Uri contentUri = Uri.fromFile(file);
            mediaScanIntent.setData(contentUri);
            context.sendBroadcast(mediaScanIntent);
           //Log.i("addGraphToGallery", "ok");
        } catch (IOException ex) {
            ex.printStackTrace();
        } finally {
            NIOUtils.closeQuietly(out);
        }
        //转化完成输出文件路径
        return file.getPath();
    }

调用Jcodec的转换如果视频在15s以内转换的效率还是可以的,大于15s的视频转换就会变得十分的慢,可能是我自己的原因也可能是这个工具本来也存在一些优化的问题。鉴于上面的视频解码取帧,最好的视频编码合成当然也是用原生的MediaMetadataRetriever来做。思路大概跟上面的方法反着来,看着是不是很清晰了,具体实现方法我就不细说了,因为我也还没做,后面会基于这个思路来优化合成视频这一模块。字符画转换的全部内容大概都到这里了,谢谢大家阅读,喜欢的话可以给个赞。
源码地址:
https://github.com/452kinton/CharacterDance

推荐阅读更多精彩内容