1. 一个误导了很多人的命名纠葛
1.1 我亲身经历的一次"彩屏"事故
早几年做视频采集模块时,遇到过一件让我印象深刻的事。设备端编码器输出的数据,SDK文档上清清楚楚写着"IYUV",我按I420的布局去解析,结果画面颜色完全错乱——人脸是蓝绿色的,天空是橘红色的。当时第一反应是数据没取对,逐字节dump出来核对了半天,Y平面和U平面的数据量都对得上,可颜色就是不对。
后来请教了一位做编解码底层的老同事,他看了一眼就笑了:"IYUV就是I420,只是名字不一样。你颜色错乱,八成是U、V两个平面的读取顺序搞反了。"我回去仔细一查,果然——数据处理流程里有个上游模块把YV12的布局误当成I420在传。也就是说,问题根源不是解码,而是有人把"看起来相似的名字"当成了"不同的格式"。
这个经历给我留下的教训很深:YUV这个领域,命名混乱导致的隐性bug,远比编解码本身的复杂度更折腾人。YU12、I420、IYUV,这三个名字频繁出现在各种SDK、播放器、网络协议和芯片文档里,表面上像三种不同格式,实际上它们的字节布局完全一致。
1.2 三个名字对应的唯一内存布局
说结论之前,先把最核心的概念往前放:YU12、I420、IYUV,三者都是YUV 4:2:0 planar(平面)格式的别名,数据排列方式完全一致——一帧图像按"先Y平面,后U平面,再V平面"的顺序连续存储。在FFmpeg里对应的是AV_PIX_FMT_YUV420P,在Android里叫ImageFormat.YU12,在Intel的老款SDK和很多多媒体框架里则写成IYUV。
我遇到过不少开发者,把这三个名字当成分属不同色彩格式,花了大量时间做没必要的格式转换。所以这篇文章不做高深理论,只做一件事:把这几个名字的来龙去脉、内存布局、实际验证方法、以及真正容易踩的坑一次讲透。看完之后,你至少能少走我当年绕过的那些弯路。
2. 从采样到内存布局:懂YUV420P才能真正理解三个名字
2.1 4:2:0采样在说什么
要理解这三个名字,绕不开YUV 4:2:0这个基础概念。先说人话版本:人眼对亮度(Luma,Y)的敏感度远高于对颜色(Chroma,U/V)的敏感度。因此视频编码领域很早就想到一个省流量的办法——亮度的分辨率完整保留,色度的分辨率大幅缩减。4:2:0采样指的就是:每4个亮度像素点,对应1个U色度点和1个V色度点,而且U、V各自独立采样。
从数值上讲,一张width x height的图像,Y平面就是width x height个字节;U平面和V平面分别是(width/2) x (height/2)个字节。图像的YUV420数据总大小就是:
Y平面大小 = width * height U平面大小 = (width / 2) * (height / 2) V平面大小 = (width / 2) * (height / 2) 一帧总大小 = width * height * 3 / 2注意这里的前提是宽高均为偶数。实际编码器在遇到奇数宽高时通常会对齐到偶数,但如果是你手动组装数据,务必把这一点算清楚。
2.2 Y、U、V三个平面的物理排布
YUV420P里的P就是Planar,意思是三个分量分别存在三个独立的平面里。这三个平面在内存中是连续排列的,顺序为:Y平面在前,U平面居中,最后一个平面是V。
举个例子,一张640x480的I420帧:
Y平面: 640 * 480 = 307200 字节 U平面: 320 * 240 = 76800 字节 V平面: 320 * 240 = 76800 字节 总大小: 307200 + 76800 + 76800 = 460800 字节对应到C语言的指针操作就是:
uint8_t *y_plane = data; uint8_t *u_plane = data + width * height; uint8_t *v_plane = data + width * height + (width / 2) * (height / 2);Y平面内同样按行存储,第0行、第1行、第2行……连续排下去;U和V平面同理,只是行列各减半。U平面代表的是每个2x2亮度块对应的一个色度值,所以U的坐标映射关系是:
U[x][y] 对应亮度坐标 (x*2, y*2) 处2x2区域的颜色很多人在做缩放、裁剪时没考虑到这种映射关系,导致局部区域颜色错位。这里先记个印象,后面会专门讲。
2.3 YU12、I420、IYUV的布局完全一致
现在回到标题本身。大前提已经清楚了:只要满足"Y平面 + U平面 + V平面连续存储"这一条,任何名字指向的物理数据都是同一回事。
- I420:最早来自视频编码标准里的常见命名方式,I代表Interleaved的逆向——实际是Planar,但叫习惯了没改。顺序是Y、U、V,也就是U平面在前、V平面在后。
- YU12:这个名字几乎只在Android和部分国产平台出现。Android官方的
ImageFormat.YU12注释里直接写明"equivalent to I420"。 - IYUV:这是比较老派的名字,常见于Intel早期多媒体SDK、一些直接基于DirectShow的滤镜和部分播放器内核。同样是Y、U、V的布局。
我见过有人把IYUV和YV12混在一起,这两个才是真正容易搞混的东西。YV12的顺序是Y、V、U——也就是V平面在前、U平面在后,与I420恰好是U、V互换。**IYUV和I420是同一个格式,IYUV和YV12不是。**这一句话,值得记十年。
3. 为什么同一个格式要留三个名字:平台生态与历史遗留
3.1 I420:编解码器世界的事实标准
I420这个名字之所以流传最广,主要是因为视频编码标准的发展路径。H.264、H.265的编码器内部,几乎都把YUV420P当成默认输入输出格式。虽然规范文档中很少直接使用"I420"这个字符串,但它作为描述性名字,已经深入到了FFmpeg、libvpx、x264、OpenH264这些项目的代码和注释里。
我在实际项目中看到的典型情况是:FFmpeg解码H.264裸流,输出像素格式为AV_PIX_FMT_YUV420P;如果你把这个帧直接送到GL着色器或者OpenCV处理,绝大多数库都会默认按I420去解析。所以在一个典型的视频处理链里,I420就是那个"对接标准"——上游、下游默认都认它。
3.2 YU12:Android与国产平台的习惯
Android的Camera2、MediaCodec等接口在输出预览数据时,经常出现YU12这个字符串。Android官方开发文档中ImageFormat.YU12的定义如下:它是一个YUV 4:2:0 planar格式,Y平面、U平面、V平面依次排列,U/V plane的宽高都是Y平面的一半。
这里有一个很多人踩过的坑:Android的Image对象里,每个plane都有独立的getRowStride()和getPixelStride(),并不是你简单按width去算偏移就一定对。很多设备上Y平面的行字节数是按照16或64字节对齐过的,导致实际布局是"带padding的YU12",而不是教科书式的紧凑排列。后面专门讲stride时会展开。
国产平台方面,海思、瑞芯微、安霸等芯片厂商的SDK里,"YU12"这个名字出现频率非常高。比如海思的采样格式枚举中,PIXEL_FORMAT_YUV_SEMIPLANAR_420对应的是NV12,而PIXEL_FORMAT_YUV_PLANAR_420对应的数据就是YU12/I420。芯片手册里写YU12,应用层代码里写I420,这两者实际是同一块内存。
3.3 IYUV:老牌软件遗留下来的叫法
IYUV这个名字要追溯到多媒体技术早期。Intel在推广其视频处理库和硬件编解码解决方案时,习惯使用IYUV来指代一个YUV 4:2:0 planar帧。很多老旧的视频采集SDK、视频播放滤镜、以及部分文档,一直沿用这个叫法。
在Windows平台上,DirectShow的MEDIASUBTYPE_IYUV和MEDIASUBTYPE_I420就经常交替出现,且很多实现内部完全一致。VLC、ffplay这类播放器在识别FourCC编码时,也会把I420、IYUV、YU12统一映射到同一种像素格式。
四字符码(FourCC)是理解这个问题的另一个角度。同样的布局数据,可以用不同FourCC标记:
| FourCC字符串 | 十六进制整数 | 平面顺序 | 本质 |
|---|---|---|---|
| I420 | 0x30323449 | Y、U、V | 与YU12/IYUV一致 |
| YU12 | 0x32315559 | Y、U、V | 与I420/IYUV一致 |
| IYUV | 0x56555949 | Y、U、V | 与I420/YU12一致 |
| YV12 | 0x32315659 | Y、V、U | 唯一真正不同的格式 |
这份表我建议直接收藏。排查颜色异常时,先对着这个表核对一遍U/V顺序,能省出大量调试时间。
4. 不要混淆的同胞兄弟:I420与YV12的U/V交换问题
4.1 U和V交换后会怎样
U和V在YUV色彩空间里,一个决定蓝色差分量,一个决定红色差分量。两个平面一旦对调,画面最直观的表现就是颜色大范围错误:偏蓝、偏紫、肤色呈暗绿色,或者画面像老式照片的负片效果。
具体来说,U通道(Cb)描述的是蓝色分量与亮度的差值,V通道(Cr)描述的是红色分量与亮度的差值。当你把U、V对调后,相当于每个像素的色度信息整体错位,但在灰度图上可能完全看不出来——因为亮度分量Y没有动。这也是为什么很多人对着黑白纹理解析半天,始终找不到问题。
4.2 如何快速识别U/V是否反了
先说一个最快的方法:找一块肤色区域(比如人脸),看颜色是否偏红。肤色在YUV里通常是U偏小、V偏大。如果画面里人脸区域整体发蓝绿色,大概率就是U、V对调了。也可以用一块纯红色测试图,I420下红色区域应该是U值接近128、V值接近一定阈值;如果换成YV12解析,红色区域就变了样。
还有个更工程化的办法:拆出U平面和V平面各算一个平均亮度值。标准测试图的U平面平均值和V平面平均值有明显差异,如果你发现两者与已知参考值交换了位置,基本可以确认布局是YV12。实际项目中,我习惯在调试阶段打一条日志,输出前64字节的U平面和V平面十六进制内容,用肉眼就能看出规律——U平面和V平面如果内容互换,模式会完全不同。
4.3 平台适配时如何避免踩坑
跨平台视频管线中,最容易出现U/V反转的环节是:硬件解码器输出、显卡显存数据回读、以及Unity/Unreal引擎的纹理上传。NVIDIA的Video Codec SDK输出通常是NV12(半平面),而很多老平台的DXVA输出却是YV12,你如果直接把这数据标成I420去处理,颜色必乱。
处理方案很简单:在任何涉及YUV420P数据对接的地方,不要用"猜"的,要在初始化阶段做一次显式检测。检测思路是喂入一帧已知颜色分布的标准图,解析后在关键区域取色,与期望值比对。这个自动化测试步骤如果每次接入新设备都跑一遍,能挡掉80%以上的兼容性问题。
5. 实战验证:写代码确认三个名字指向同一块数据
5.1 用FFmpeg生成统一的YUV420P数据
理论讲再多,不如直接把数据dump出来看。下面是一套非常实操的验证方案,用FFmpeg把任意视频源转为YUV420P原始帧,再通过哈希校验和十六进制对比确认布局一致。
# 把视频的前30帧转成raw I420格式 ffmpeg -i input.mp4 -t 1 -c:v rawvideo -pix_fmt yuv420p output.yuvFFmpeg的yuv420p像素格式就是I420/YU12/IYUV布局。如果你的输入源本身是I420的raw文件,转换前后字节应该完全不变;如果输入源是其他格式,FFmpeg会负责转换。
验证脚本来一段Python,直接读YUV文件的前几个字节:
import hashlib with open("output.yuv", "rb") as f: data = f.read() # 计算整段数据的MD5,用于跨格式对比 md5 = hashlib.md5(data).hexdigest() print(f"总字节数: {len(data)}") print(f"MD5: {md5}") # 假设宽高为 640x480 y_size = 640 * 480 uv_size = 320 * 240 y_plane = data[:y_size] u_plane = data[y_size:y_size + uv_size] v_plane = data[y_size + uv_size:y_size + uv_size * 2] print(f"Y平面前16字节: {y_plane[:16].hex()}") print(f"U平面前16字节: {u_plane[:16].hex()}") print(f"V平面前16字节: {v_plane[:16].hex()}")这段代码能直观看到Y、U、V三个平面的字节分布。同一份数据文件,你可以在代码里分别用I420命名的解析器和YU12命名的解析器去读,只要都是按"Y、U、V"顺序取的,结果必然一致。
5.2 在Android上用ImageReader实测YU12
Android端更贴近日常项目。使用Camera2的ImageReader时,指定ImageFormat.YU12,回调里拿到的Image对象就是标准的YUV420P。你可以在代码里直接取出三个平面:
Image image = reader.acquireLatestImage(); Image.Plane[] planes = image.getPlanes(); ByteBuffer yBuffer = planes[0].getBuffer(); ByteBuffer uBuffer = planes[1].getBuffer(); ByteBuffer vBuffer = planes[2].getBuffer(); // 注意这三个plane的rowStride和pixelStride可能不同 int yRowStride = planes[0].getRowStride(); int uRowStride = planes[1].getRowStride(); int uvPixelStride = planes[2].getPixelStride();关键提醒:Android的YU12在不少设备上存在padding,不能直接按width连续读取。你需要使用getRowStride()拿到每一行实际的字节数,再逐行拷贝到紧凑数组中。这一步做不好,图像会呈现斜切错位或绿边现象,而且难以用肉眼直接判断格式问题。
5.3 借助libyuv进行格式间的显式转换
Google的libyuv库是一个非常好用的参考。它在内部定义了一组FourCC枚举,把FOURCC_I420、FOURCC_YU12、FOURCC_IYUV分别标记出来,而函数libyuv::CanonicalFourCC()会返回它们统一的规范格式——实际上都落到FOURCC_I420。
#include "libyuv/convert.h" #include "libyuv/convert_argb.h" #include "libyuv/row.h" // 无论源标记为I420、YU12还是IYUV,这里都能正确转为ARGB libyuv::I420ToARGB( src_y, width, src_u, width / 2, src_v, width / 2, argb, width * 4, width, height);所以在libyuv的视角里,这三种FourCC在转换为内核函数时,走的完全是同一个I420ToARGB路径。库设计者显然清楚它们是一个东西。
6. 比命名更重要的两个细节:stride对齐与宽高边界
6.1 stride不等于width的场景
这是整个I420话题里最容易被忽视的工程细节。很多初学者拿到的YUV420P数据,按width * height计算Y平面大小后直接跳转到U平面,结果画面出现左右错位、斜条纹。真正的原因通常是:每一行数据实际占用的字节数大于图像宽度,多出来的部分是硬件为了内存对齐而填充的无效字节。
这个"实际每行字节数"就是stride(行距)。在海思、瑞芯微等芯片平台以及Android的ImageReader中,stride普遍存在。典型值:
- ARM平台:经常按16字节对齐
- GPU回读数据:经常按64字节甚至256字节对齐
- 部分VPU硬件:按宏块(16x16)边界对齐
处理方式不复杂,逐行拷贝即可:
for (int h = 0; h < height; h++) { memcpy(dst + h * width, src + h * y_stride, width); }U、V平面同理,只是高度和宽度都减半,且各平台U/V平面的stride可能与Y平面不相同。写代码时务必分别获取,不要只拿Y平面的stride去套U、V平面。
6.2 非偶数宽高带来的问题
I420严格要求宽高为偶数。实际项目中,如果遇到1921x1080这种奇数宽度,直接用公式计算就会出错。因为U/V平面的宽是width/2,在C语言里整数除法会向下取整,导致U/V平面的像素数量计算结果和硬件实际采样不一致,从而引发色度通道偏移。
业界标准做法是先做对齐:
- 宽度对齐到偶数:
aligned_width = (width + 1) & ~1 - 高度对齐到偶数:
aligned_height = (height + 1) & ~1
然后再用对齐后的值计算平面大小。如果你手里的数据是奇数宽高的I420,要么找一份已对齐的数据源,要么在接收数据前让上游统一处理。
6.3 推荐的上手排查链路
结合我这些年处理YUV数据的经验,给出一套排查链路,遇到颜色异常或布局错乱时,照着走基本能定位:
- 确认源格式字符串,明确是I420/YU12/IYUV还是YV12。如果是后者,直接交换U/V处理。
- 打印Y、U、V三个平面的起始地址和总长度,和理论值比对。
- 检查stride。用第一行数据做连续比对,确认一行实际字节数。
- 对宽高做偶数校验,不对齐的数据源尽早拒绝。
- 用已知标准图做端到端测试,绕开真实视频源的颜色干扰。
在实际工作中,把这套流程固化成自动化脚本,每次接入新设备、新SDK、新格式源时自动跑一遍,能避免非常多看起来神秘实际全是布局问题的bug。
最后再分享一个小技巧:如果你手头没有现成的检测工具,可以临时生成一张纯红色测试图,直接编码成I420,然后用待验证的解析函数去解码。纯红色在YUV里对应的U、V值有明确特征,只要输出颜色不是红色,立刻就能判断是U/V顺序的问题还是stride的问题。这个小工具我几乎每个项目都会写一遍,比任何文档都可靠。