26、提取并保存为YUV:将视频帧解码后保存为YUV数据

各位同学,今天我们来聊一个非常实战的话题——把视频帧解码后保存为YUV数据。

说实话,YUV格式在Android开发中是个老面孔了。做音视频的,几乎天天跟它打交道。我刚开始接触时也觉得这东西有点绕,但用多了你会发现,它其实就是视频处理领域的"通用货币"。很多底层算法、编码器、渲染管线,最终都要落到YUV上。

为什么非要存成YUV?

你可能会问:直接存成Bitmap不香吗?嗯,这个问题我当年也纠结过。

简单说几个理由:

  • 体积小:YUV420P比RGB888少一半数据量,处理起来更快
  • 兼容性强:编码器、滤镜库、OpenGL纹理,原生支持YUV
  • 算法友好:很多图像处理算法(降噪、锐化)在Y分量上做就够了
  • 调试方便:用YUV Viewer直接看原始数据,比Bitmap更直观

我在项目中遇到过一个问题:用Bitmap做帧分析,内存直接飙到200MB+,换成YUV后降到80MB。你想想看,差距有多大。

MediaExtractor + MediaCodec 解码流程

先回顾一下核心流程。我们之前讲过,MediaExtractor负责读取和分离,MediaCodec负责解码。今天要做的,就是在解码输出后,把数据从ByteBuffer里捞出来,转成YUV格式。

核心思路:从MediaCodec的OutputBuffer中获取数据,根据颜色格式(ColorFormat)解析成Y、U、V三个平面,然后按顺序写入文件。

我习惯把整个流程画成一张图,方便理解:

MediaExtractor 读取视频文件 MediaCodec 解码为原始帧 OutputBuffer ByteBuffer数据 颜色格式判断 COLOR_FormatYUV420Planar YUV数据提取 Y + U + V 平面 写入.yuv文件 FileOutputStream 视频轨道 selectTrack configure + start dequeueOutputBuffer

颜色格式:绕不开的坑

Android设备上的YUV格式五花八门。最常见的两种:

格式常量 说明 数据排布
COLOR_FormatYUV420Planar I420(YUV420P) YYYY... UUUU... VVVV...
COLOR_FormatYUV420SemiPlanar NV12(YUV420SP) YYYY... UVUVUV...

我踩过最大的坑就是:你以为设备支持I420,结果它给你的是NV12。曾经有一次,我在某款国产平板上调试,解码出来的画面颜色完全不对,查了半天才发现是格式问题。

注意:MediaCodec输出的颜色格式由设备厂商决定,不要硬编码。一定要通过MediaCodecInfo查询支持的格式列表,或者用COLOR_FormatSurface绕开这个坑。

实战代码:提取YUV并保存

下面这段代码,是我在实际项目中提炼出来的核心逻辑。注释写得很详细,你直接拿去用就行。

// 1. 配置MediaCodec,输出格式设为YUV
MediaFormat format = MediaFormat.createVideoFormat(mimeType, width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
    MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar);
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_DECODE);

// 2. 解码循环中获取输出Buffer
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
int outputIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 10000);

if (outputIndex >= 0) {
    ByteBuffer outputBuffer = mediaCodec.getOutputBuffer(outputIndex);
    // 注意:outputBuffer可能包含padding,需要根据stride处理
    int stride = mediaCodec.getOutputFormat()
        .getInteger(MediaFormat.KEY_STRIDE);
    int sliceHeight = mediaCodec.getOutputFormat()
        .getInteger(MediaFormat.KEY_SLICE_HEIGHT);

    // 3. 提取Y平面
    byte[] yPlane = new byte[width * height];
    for (int row = 0; row < height; row++) {
        outputBuffer.position(row * stride);
        outputBuffer.get(yPlane, row * width, width);
    }

    // 4. 提取U平面(I420格式)
    int uvSize = width * height / 4;
    byte[] uPlane = new byte[uvSize];
    int uOffset = sliceHeight * stride;
    for (int row = 0; row < height / 2; row++) {
        outputBuffer.position(uOffset + row * stride / 2);
        outputBuffer.get(uPlane, row * width / 2, width / 2);
    }

    // 5. 提取V平面
    byte[] vPlane = new byte[uvSize];
    int vOffset = uOffset + uvSize;
    // 同理读取V数据...

    // 6. 写入文件
    FileOutputStream fos = new FileOutputStream(yuvFile, true);
    fos.write(yPlane);
    fos.write(uPlane);
    fos.write(vPlane);
    fos.close();

    mediaCodec.releaseOutputBuffer(outputIndex, false);
}

小技巧:如果遇到stride不等于width的情况(常见于某些高通芯片),一定要按stride逐行读取,否则画面会"斜掉"。我建议你写个工具方法,专门处理stride对齐。

NV12格式的处理

如果设备输出的是NV12(SemiPlanar),U和V是交错存储的。处理方式略有不同:

// NV12:Y平面 + UV交错平面
byte[] yPlane = new byte[width * height];
// 读取Y平面(同上)

// UV平面:U和V交替,每个像素对共享一组UV
int uvSize = width * height / 2;  // 注意:NV12的UV平面大小是Y的一半
byte[] uvPlane = new byte[uvSize];
outputBuffer.position(sliceHeight * stride);
outputBuffer.get(uvPlane, 0, uvSize);

// 如果需要I420,需要做一次拆分
byte[] uPlane = new byte[uvSize / 2];
byte[] vPlane = new byte[uvSize / 2];
for (int i = 0; i < uvSize / 2; i++) {
    uPlane[i] = uvPlane[i * 2];
    vPlane[i] = uvPlane[i * 2 + 1];
}

说实话,我建议你统一转成I420再保存。这样后续处理(比如用FFmpeg转码、用OpenCV分析)都方便。我在项目里就是这么干的——不管设备输出什么格式,一律转成I420。

性能优化:别让I/O拖后腿

逐帧写入文件,如果处理不好,性能会很难看。我分享几个优化点:

  • 使用BufferedOutputStream:别直接FileOutputStream.write,加个缓冲层,吞吐量能翻倍
  • 批量写入:攒够10帧再写一次,减少系统调用
  • 异步写入:用独立线程写文件,解码线程不阻塞
  • 内存复用:byte[]对象重复使用,避免频繁GC

经验之谈:我曾经在4K视频上做逐帧YUV提取,没做优化时每秒只能处理15帧。加上BufferedOutputStream和批量写入后,直接跑到30帧。优化前后差距就是这么大。

验证YUV文件是否正确

保存完YUV文件后,怎么知道对不对?我一般用两种方法:

  1. 用YUV播放器:比如YUV Player Deluxe,设置好宽高和格式,直接播放
  2. 转成PNG对比:用FFmpeg命令 ffmpeg -f rawvideo -pix_fmt yuv420p -s 1920x1080 -i input.yuv output.png,跟原视频对比

如果画面颜色不对,八成是U/V顺序搞反了。如果画面斜了,那就是stride没处理好。这两个问题我调试时至少各遇到三次。

避坑指南

我曾经踩过的坑:

  • 忘记处理stride,导致画面扭曲——后来加了stride判断逻辑
  • 误以为所有设备都支持I420——后来改用COLOR_FormatSurface + 手动转换
  • 文件写入没加flush,最后几帧数据丢失——现在每次写完都flush
  • 多线程环境下ByteBuffer位置混乱——每次读取前手动position复位

嗯,说到避坑,我再补充一个:别忘了释放OutputBuffer。我见过有人循环里忘了releaseOutputBuffer,结果解码器卡死,整个应用无响应。这个错误很低级,但确实容易犯。

总结

提取YUV数据,说白了就是三步:解码 → 解析颜色格式 → 按平面写入文件。核心难点在于处理不同设备上的颜色格式差异和stride对齐问题。

我个人习惯把YUV提取封装成一个工具类,支持I420和NV12两种格式自动识别。这样上层调用者只需要传入文件路径和帧数,不用关心底层细节。你如果做视频处理相关的项目,建议也这么搞。

好了,今天的实战内容就到这里。代码可以直接复用,但记得根据你的设备做适配。有问题欢迎交流。


公众号:蓝海资料掘金营,微信deep3321