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三个平面,然后按顺序写入文件。
我习惯把整个流程画成一张图,方便理解:
颜色格式:绕不开的坑
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文件后,怎么知道对不对?我一般用两种方法:
- 用YUV播放器:比如YUV Player Deluxe,设置好宽高和格式,直接播放
- 转成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两种格式自动识别。这样上层调用者只需要传入文件路径和帧数,不用关心底层细节。你如果做视频处理相关的项目,建议也这么搞。
好了,今天的实战内容就到这里。代码可以直接复用,但记得根据你的设备做适配。有问题欢迎交流。