4、YUV数据存储布局:Planar与Packed模式,NV12、NV21、I420、YV12格式详解
YUV 这东西,说起来概念不复杂,但一落到存储上,坑就多了。我早年做视频采集驱动时,就因为在格式上搞混了,折腾了整整两天才把画面调正常。今天咱们就把 Planar、Packed,还有 NV12、NV21、I420、YV12 这些常见格式彻底捋清楚。
4.1 两种基本存储模式:Planar 与 Packed
YUV 数据怎么摆到内存里?说白了就两种思路。
4.1.1 Planar(平面模式)
把所有 Y 分量连续存一块,再把所有 U 分量连续存一块,最后存 V 分量。就像把三种颜色的豆子分别装进三个袋子。
- 特点:每个分量独立成片,便于逐分量处理。
- 典型格式:I420、YV12。
- 适用场景:编码器输入、图像处理算法。
4.1.2 Packed(打包模式)
Y、U、V 分量交错排列,像 RGB 那样混在一起。比如 YUYV、UYVY。
- 特点:内存访问局部性好,适合显示输出。
- 典型格式:YUY2、NV12(其实是半平面,后面细说)。
- 适用场景:摄像头输出、DirectShow 滤镜。
核心区别:Planar 是「分堆放」,Packed 是「混着放」。你想想看,做色彩增强时,Planar 格式直接改 Y 平面就行,Packed 格式就得跳着改,效率差不少。
4.2 四种常见格式详解
下面这四种格式,做视频开发的基本天天见。我建议你把它们的存储布局刻在脑子里。
4.2.1 I420(也叫 IYUV)
这是最标准的 Planar 格式。Y 平面完整,U、V 平面各为 Y 的 1/4(宽高各减半)。
- 存储顺序:Y 平面 → U 平面 → V 平面。
- 数据量:宽×高 + (宽×高)/4 + (宽×高)/4 = 1.5 倍像素数。
- 我的经验:H.264 编码器默认输入格式就是 I420,几乎所有软件编码器都支持它。
4.2.2 YV12
和 I420 几乎一样,唯一的区别是 U、V 平面的顺序互换。
- 存储顺序:Y 平面 → V 平面 → U 平面。
- 注意:很多初学者把 I420 和 YV12 搞混。我曾经在调试一个播放器时,就因为把 YV12 当 I420 送进去,画面颜色完全错乱,像打翻了调色盘。
| 格式 | 存储顺序 | U/V 分辨率 | 常见用途 |
|---|---|---|---|
| I420 | Y → U → V | 宽/2 × 高/2 | 编码器输入、FFmpeg 默认 |
| YV12 | Y → V → U | 宽/2 × 高/2 | 旧版解码器、某些 GPU 纹理 |
4.2.3 NV12
这是「半平面」格式。Y 平面单独存,U 和 V 交错存成一个平面。
- 存储顺序:Y 平面 → UVUVUV... 交错平面。
- 特点:比纯 Planar 少一次内存拷贝,硬件友好。
- 我的经验:Android 摄像头输出的默认格式就是 NV12,iOS 也是。做移动端开发,NV12 是绕不开的。
4.2.4 NV21
NV12 的变种,U 和 V 的顺序反过来。
- 存储顺序:Y 平面 → VUVUVU... 交错平面。
- 注意:Android 的 Camera1 API 默认输出 NV21。我记得第一次写 Android 相机应用时,直接拿 NV21 当 NV12 处理,结果画面偏紫,查了半天才发现是 UV 顺序反了。
避坑指南:我曾经在跨平台项目中,iOS 端用 NV12,Android 端用 NV21,结果视频通话时颜色不一致。后来统一在 YUV 转 RGB 阶段做格式适配,才彻底解决。记住:NV12 和 NV21 的 UV 顺序是反的,转换时一定要区分。
4.3 存储布局对比
为了让你更直观地理解,我画了一张存储布局对比图。
4.4 内存布局与指针计算
搞清楚了布局,还得会算地址。我见过不少同事在计算 UV 平面偏移时出错。
4.4.1 I420 指针计算
// 假设图像宽 w,高 h
uint8_t* y_plane = data;
uint8_t* u_plane = data + w * h;
uint8_t* v_plane = data + w * h + (w * h) / 4;
// 访问像素 (x, y) 的 YUV 值
uint8_t y = y_plane[y * w + x];
uint8_t u = u_plane[(y/2) * (w/2) + (x/2)];
uint8_t v = v_plane[(y/2) * (w/2) + (x/2)];
4.4.2 NV12 指针计算
uint8_t* y_plane = data;
uint8_t* uv_plane = data + w * h;
// UV 交错,每个像素对对应一个 UV 对
uint8_t y = y_plane[y * w + x];
uint8_t u = uv_plane[(y/2) * w + (x/2) * 2]; // 偶数索引
uint8_t v = uv_plane[(y/2) * w + (x/2) * 2 + 1]; // 奇数索引
小技巧:我习惯在代码里用宏或内联函数封装这些地址计算,避免到处写魔法数字。比如 #define Y_OFFSET(w, h) (0)、#define UV_OFFSET(w, h) ((w)*(h)),这样改格式时只改一处。
4.5 格式转换实战
实际开发中,经常需要在不同格式之间转换。下面给一个 I420 转 NV12 的示例。
void i420_to_nv12(uint8_t* i420, uint8_t* nv12, int w, int h) {
int y_size = w * h;
int uv_size = y_size / 4;
// 拷贝 Y 平面
memcpy(nv12, i420, y_size);
// 转换 UV 平面:I420 的 U、V 是分开的,NV12 需要交错
uint8_t* u_src = i420 + y_size;
uint8_t* v_src = i420 + y_size + uv_size;
uint8_t* uv_dst = nv12 + y_size;
for (int i = 0; i < uv_size; i++) {
uv_dst[i * 2] = u_src[i]; // U
uv_dst[i * 2 + 1] = v_src[i]; // V
}
}
注意:上面的代码假设输入输出缓冲区已经分配好。实际项目中还要考虑内存对齐、边界检查。我曾经在一个嵌入式项目里,因为没做 16 字节对齐,导致 NEON 优化代码崩溃,查了整整一个下午。
4.6 如何选择格式
没有最好的格式,只有最合适的。我一般按这个原则选:
- 编码器输入:优先 I420,兼容性最好。
- 移动端相机:Android 用 NV21,iOS 用 NV12。
- GPU 纹理上传:NV12 最友好,因为 UV 交错可以减少纹理绑定次数。
- 图像处理算法:Planar 格式(I420/YV12)更方便,可以直接操作单个分量。
嗯,YUV 存储布局这块,说白了就是「分清楚谁先谁后,算清楚偏移地址」。搞懂了这些,后面做色彩空间转换、调试输出画面,心里就有底了。