Android 实现 视频 转 字符画效果

    技术2024-11-25  32

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

    Android 实现 图片 转 字符画 效果

    我们看一下实现出来的效果图:

    效果图有点糊,原文的效果图会更好

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

    视频取帧

    对帧图片进行字符画转换

    对获取到的字符画合成视频

    我们分开一步一步的讲:

    视频取帧

    视频取帧的整个功能最麻烦的一步,目前Android视频取帧的方法有好几种。其中有使用SDK自带的MediaMetadataRetriever直接获取bimap的,但是缺点就是慢。

    也有使用强大的FFmpeg库的,但是需要针对编译不同架构的CPU编译不同的so文件十分的麻烦。

    也有人推荐使用一个名为Jcodec的库,开发效率上来说这个工具确实十分的好,但是运行起来真的十分的慢,我写了个Demo取一帧大概要我4s的时间(测试手机是Redmi note 7 pro),所以只用他的视频合成功能(虽然仍然很慢具体的解决办法还没找到)。

    后来在别的大佬博客里面找到一篇使用原生接口MediaCodec硬解码视频的文章,用该方法取帧完美解决对不同机型的兼容性问题,因为使用的原生接口速度也是可以保证的。

    但主要的问题点是 MediaCodec 解码返回的帧图片数据是YUV格式的,它跟我们平时使用的 RGB 格式很不一样的是它的三个值表示的是亮度,色度,饱和度。

    YUV下也分不同的格式分别有:Y'UV, YUV, YCbCr,YPbPr等,安卓设备因为 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

    作者:Kinton

    来源:https://www.jianshu.com/p/16ef3bf9ac5c

    技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。

    推荐阅读:

    音视频面试基础题

    OpenGL ES 学习资源分享

    一文读懂 YUV 的采样与格式

    OpenGL 之 GPUImage 源码分析

    推荐几个堪称教科书级别的 Android 音视频入门项目

    觉得不错,点个在看呗~

    Processed: 0.060, SQL: 9