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 存储布局对比

为了让你更直观地理解,我画了一张存储布局对比图。

四种 YUV 格式存储布局对比 I420 (Planar) Y 平面 (W × H) U 平面 (W/2 × H/2) V 平面 (W/2 × H/2) 顺序:Y → U → V YV12 (Planar) Y 平面 (W × H) V 平面 (W/2 × H/2) U 平面 (W/2 × H/2) 顺序:Y → V → U NV12 (半平面) Y 平面 (W × H) UV 交错平面 (W/2 × H/2 × 2) 顺序:Y → UVUVUV... NV21 (半平面) Y 平面 (W × H) VU 交错平面 (W/2 × H/2 × 2) 顺序:Y → VUVUVU... 注意:NV12 和 NV21 的 UV 交错顺序相反,这是移动端开发最常见的坑

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 存储布局这块,说白了就是「分清楚谁先谁后,算清楚偏移地址」。搞懂了这些,后面做色彩空间转换、调试输出画面,心里就有底了。