跳到正文

音视频数据流基础 · 第 3 篇

视频底层密码:YUV 颜色空间、内存对齐与格式转换揭秘

约 12 分钟音视频流基础#YUV
目录1. 为什么我们需要 YUV?2. 色度采样:如何在画质与带宽之间走钢丝2.1 YUV 4:4:4(顶级画质,毫不妥协)2.2 YUV 4:2:2(水平减半)2.3 YUV 4:2:0(工业界的绝对黄金标准)3. 内存存储布局:数据在内存里是怎么排的?3.1 Planar(平面格式):I420 / YV123.2 Semi-Planar(半平面格式):NV12 / NV213.3 Packed(打包格式):YUYV / UYVY3.4 常见 YUV 格式速查表4. Stride 与字节对齐:音视频开发者的“一生之敌”4.1 为什么 Width ≠ Stride?4.2 常见对齐公式推导4.3 FFmpeg 实战中的避坑指南5. 动手实战:提取、播放与格式转换5.1 FFmpeg 常用 YUV 处理命令5.2 徒手写代码:YUV420P 与 NV12 互相转换

参考推荐博客:YUV格式初探

1. 为什么我们需要 YUV?

在开始探究 YUV 之前,我们先看看一个典型的视频系统链路:

摄像头 / 图像源(输出 RGB 或 Sensor Raw)
        ↓
转换为 YUV / YCbCr 颜色空间
        ↓
色度降采样(例如转换成 YUV 4:2:0)
        ↓
送入硬件或软件编码器(例如 H.264 / H.265 / AV1)

既然屏幕像素和摄像头感光元件都是基于 RGB(红绿蓝)的,为什么中间偏偏要多此一举转成 YUV?

RGB 就像是每个像素点都死板地记录“红、绿、蓝三种颜料各放多少”。而在 YUV 中,它先画一张黑白素描稿记录明暗轮廓,这就是亮度 Y (Luma),再额外标注“这里偏蓝一点、那里偏红一点”,这就是色度 U/V (Chroma)。

生物学研究表明,人类视网膜对“明暗轮廓”极其敏感,但对“颜色细节的微小变化”却相对迟钝。 加上一幅图像中相邻像素的颜色往往是非常相近的,这就给了我们偷工减料(压缩数据)的绝佳空间:我们可以完整保留 Y(亮度),大幅削减 U/V(色度)的采样数量,人的肉眼几乎察觉不到画质下降!这就是 YUV 被发明的核心意义——大幅减少视频的原始体积。

分量全称作用
YLuma(亮度)决定画面的明暗、轮廓、纹理。如果没有它,画面是一团黑;如果只有它,画面就是黑白电视。
UCb (Chrominance Blue)表示颜色相对蓝色方向的差分偏移。
VCr (Chrominance Red)表示颜色相对红色方向的差分偏移。

(注:严格来说,数字视频工程里常说的 YUV,更加准确的学术称呼应该是 YCbCr。)


2. 色度采样:如何在画质与带宽之间走钢丝

YUV 最重要的概念是色度采样格式(Chroma Subsampling),它解决的核心问题是:“U/V 相对于 Y 到底少存多少?”。因为亮度 Y 决定了画面的清晰度,所以每个像素点都必须保留 Y,而我们可以对 UV 进行共享。

2.1 YUV 4:4:4(顶级画质,毫不妥协)

YUV 4:4:4 意味着不降采样,每个像素都有自己独立且完整的 Y、U、V。

对于 2x2 像素块:
Y0 Y1      U0 U1      V0 V1
Y2 Y3      U2 U3      V2 V3
  • 数据量:极大(8-bit 下,每个像素占 3 个字节)。
  • 常见场景:专业图像处理、高质量影视后期调色、追求像素级清晰的屏幕共享(防止文字发虚)。
  • 体积计算:1920 x 1080 的画面,每帧大小 = 1920 * 1080 * 3 ≈ 5.93 MiB。

2.2 YUV 4:2:2(水平减半)

YUV 4:2:2 意味着水平方向上,两个相邻的像素共享一组 U/V,垂直方向不缩减。

对于 2x2 像素块:
Y0 Y1      U0 (共享)   V0 (共享)
Y2 Y3      U1 (共享)   V1 (共享)
  • 数据量:中等(8-bit 下,每个像素平均占 2 个字节)。
  • 常见场景:广播电视标准、专业采集卡、高端单反相机录制。
  • 体积计算:1920 x 1080 的画面,每帧大小 = 1920 * 1080 * 2 ≈ 3.96 MiB。

2.3 YUV 4:2:0(工业界的绝对黄金标准)

YUV 4:2:0 意味着一个 2x2 的田字格(共 4 个像素)共享唯一的一组 U/V。它的色度分辨率在水平和垂直方向都减半了。

对于 2x2 像素块:
Y0 Y1
Y2 Y3      共享唯一的 U0 和 V0
  • 数据量:极小(8-bit 下,每个像素平均占 1.5 字节)。
  • 常见场景:H.264/H.265/AV1 编码、MP4 电影、直播流、WebRTC 视频通话。你能在网上看到的所有视频,99% 都是 4:2:0。
  • 体积计算(面试常考题):
    • Y 平面大小:W * H
    • U 平面大小:(W/2) * (H/2) = W*H/4
    • V 平面大小:(W/2) * (H/2) = W*H/4
    • 1920 x 1080 画面总计:1920 * 1080 * 1.5 = 3,110,400 字节 ≈ 2.97 MiB。

3. 内存存储布局:数据在内存里是怎么排的?

采样格式(4:2:0)解决的是“U/V 存多少”的数学问题; 而存储格式(Layout)解决的则是这些 Y、U、V 字节在物理内存里“到底是一块一块放,还是交织在一起放”的问题。

主要分为三大流派:

3.1 Planar(平面格式):I420 / YV12

Planar 表示 Y、U、V 三个分量各自独占一块连续的内存区域,互不打扰。这是最符合人类直觉的存储方式。对于 YUV 4:2:0,最经典的就是 I420 和 YV12。

【I420】 (也叫 YU12):按 Y -> U -> V 的顺序存放。

内存排列:YYYYYYYY... UUUU... VVVV...

【YV12】:按 Y -> V -> U 的顺序存放。

内存排列:YYYYYYYY... VVVV... UUUU...

避坑指南:如果你在代码里把 I420 当成了 YV12 扔给渲染器,U 和 V 通道就会反转,导致画面里的人脸变得像阿凡达一样发蓝、发紫。这也是音视频开发中最经典的“红蓝反转”偏色Bug。

3.2 Semi-Planar(半平面格式):NV12 / NV21

Semi-Planar 表示 Y 独自霸占一块内存,而 U 和 V 像拉链一样交织在一起,共用第二块连续内存。这种格式在移动端硬件编解码器中极度普及。

【NV12】:Y 存完后,UV 交错存放,且 U 在前,V 在后。

内存排列:YYYYYYYY... UVUVUVUV...

(注:NV12 是 iOS 平台和绝大多数硬件加速 API(如 VideoToolbox、MediaCodec)的钦定格式,FFmpeg 中也极为常见。)

【NV21】:Y 存完后,VU 交错存放,且 V 在前,U 在后。

内存排列:YYYYYYYY... VUVUVUVU...

(注:NV21 是 Android 旧版 Camera API 默认输出的历史遗留格式。)

3.3 Packed(打包格式):YUYV / UYVY

Packed 表示根本不分平面,Y、U、V 直接按照像素紧密地打包挨在一起。这种通常见于 YUV 4:2:2 格式。

【YUYV】 (也叫 YUY2):以两个像素为一组,交错打包。

宏块结构:[Y0 U0 Y1 V0]
解读:像素0的亮度是Y0,像素1的亮度是Y1,它们共同使用色度(U0, V0)

【UYVY】:仅仅是字节顺序不同。

宏块结构:[U0 Y0 V0 Y1]

(注:Packed 格式常用于底层的 V4L2 USB 摄像头采集。)

3.4 常见 YUV 格式速查表

格式代号采样类型存储流派内存顺序走向每个像素平均占用
I4204:2:0PlanarY + U + V1.5 字节
YV124:2:0PlanarY + V + U1.5 字节
NV124:2:0Semi-PlanarY + UV 混合1.5 字节
NV214:2:0Semi-PlanarY + VU 混合1.5 字节
YUYV4:2:2PackedY0 U0 Y1 V02 字节
YUV444P4:4:4PlanarY + U + V3 字节

4. Stride 与字节对齐:音视频开发者的“一生之敌”

在处理图像内存时,有一个极其容易导致程序崩溃或画面撕裂的概念:Stride(步长),有时也被叫做 pitch 或 linesize。

面试金句:“Width 是你看得到的画面物理宽度,Stride 是内存中这行数据实际占用的字节宽度。解析时如果错把 Width 当作 Stride,画面就会倾斜撕裂,甚至内存越界死机。”

4.1 为什么 Width ≠ Stride?

由于 CPU/GPU 架构的特性,它们在读取内存时,如果首地址是 16、32 或 64 的整数倍(内存对齐),读取效率会呈现指数级跃升。因此,图像 API 在为你分配 YUV 内存时,往往会在每一行的末尾塞入一些无意义的填充字节(Padding),强行把一行的数据量撑到对齐边界。

举个例子:一张 1919 x 1080 分辨率的图片,Y 频面的物理 Width 是 1919 字节。 但如果显卡要求 64 字节对齐,1919 并不能被 64 整除。系统向上取整,会把每一行的实际空间分配为 1920 字节。 此时:Width = 1919,但 Stride = 1920。多出来的 1 个字节就是 Padding。

如果在遍历这幅图像时,你单纯地认为 行首地址 = 数组基址 + Y * Width,那么从第二行开始,你的指针就会错位 1 个字节,第三行错位 2 个字节……结果就是画面出现恐怖的对角线倾斜失真。

4.2 常见对齐公式推导

在 C/C++ 源码中,计算对齐长度最经典的位运算宏定义是:

// 比如要求以 64 对齐:
#define ALIGN_64(x) (((x) + 63) & ~63)

对于 YUV420,因为 UV 分量的宽高都是 Y 的一半,所以我们不仅要求 Stride 满足对齐,甚至连视频的真实 Width 和 Height 也通常被强制要求为偶数。如果分辨率是奇数,U/V 的尺寸就会出现 0.5 像素这种根本无法在内存中表达的荒谬情况。

4.3 FFmpeg 实战中的避坑指南

永远优先相信底层 API 返回的 linesize 或 stride,绝对不要自己用 width 去盲猜算地址!

在 FFmpeg 的 AVFrame 结构体中,它是这样设计的:

frame->data[0]     // Y 平面的起始指针
frame->data[1]     // U 或 UV 平面的起始指针
frame->data[2]     // V 平面的起始指针

frame->linesize[0] // Y 平面真实的 Stride 跨度
frame->linesize[1] // U/UV 平面真实的 Stride 跨度
frame->linesize[2] // V 平面真实的 Stride 跨度

正确遍历 Y 平面并提取纯净图像像素的姿势:

for (int y = 0; y < height; y++) {
    // 找准这一行的真实起点(必须用 linesize[0] 来跨行)
    uint8_t *row_start = frame->data[0] + y * frame->linesize[0];
    
    // 只拷贝我们需要的有效画面像素(只拷 Width 长度,丢弃末尾 Padding)
    memcpy(dest_buffer + y * width, row_start, width);
}

5. 动手实战:提取、播放与格式转换

大多数视频编码算法(如 H.264)内部都钦定 YUV 4:2:0 为首选输入源。

5.1 FFmpeg 常用 YUV 处理命令

在日常排查时,我们经常需要把 MP4 剥离成裸 YUV 来确认画质:

# 1. 将视频导出为裸的 YUV420P(I420)数据:
ffmpeg -i input.mp4 -pix_fmt yuv420p -f rawvideo output.yuv

# 2. 截取第一帧导出为 YUV:
ffmpeg -i input.mp4 -vframes 1 -pix_fmt yuv420p frame.yuv

# 3. 查看视频原本的底层像素格式:
ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt,width,height -of default=nw=1 input.mp4

# 4. 将 yuv420p 裸数据无损转换为 nv12 格式:
ffmpeg -pix_fmt yuv420p -s 1280x720 -i yuv420p.yuv -pix_fmt nv12 nv12_ffmpeg.yuv

强行播放裸 YUV 数据: 因为 .yuv 仅仅是一坨庞大且纯粹的像素数组,它没有任何文件头(Header)。如果你不告诉播放器它的宽高和排列方式,播放器只能抓瞎。必须通过命令行硬塞参数:

ffplay -f rawvideo -pixel_format yuv420p -video_size 1920x1080 output.yuv

(注:如果你把一个 NV12 格式的文件,用 yuv420p 参数强行播放,画面就会出现红蓝互换或严重花屏。)

5.2 徒手写代码:YUV420P 与 NV12 互相转换

在移动端开发中,用 CPU 将下载的 I420 转换成显卡爱吃的 NV12 是一项极高频的工作。下面的 C++ 演示代码完美处理了带 Stride 填充的转换逻辑:

#include <iostream>
#include <cstdio>
#include <cstring>
#include <memory>

class YUVConverter {
public:
    // 构造函数传入 物理宽、物理高、以及内存实际步长 Stride
    YUVConverter(int width, int height, int stride);
    void readYUV420P(const char* url);
    void readNV12(const char* url);
    void convertToNV12(const char* url);
    void convertToYUV420P(const char* url);

private:
    int width_, height_, stride_;
    std::unique_ptr<unsigned char[]> y_data_;
    std::unique_ptr<unsigned char[]> u_data_;
    std::unique_ptr<unsigned char[]> v_data_;
};

YUVConverter::YUVConverter(int width, int height, int stride)
    : width_(width), height_(height), stride_(stride) {}

// ==========================================
// 读取 YUV420P (I420) 并剥离出纯净的 Y,U,V 分量
// 顺便去除了文件中的 Stride Padding
// ==========================================
void YUVConverter::readYUV420P(const char* url) {
    FILE* yuv_file = fopen(url, "rb");
    if (!yuv_file) return;

    // 含有 Stride 填充的原始文件总大小
    auto raw_data = std::make_unique<unsigned char[]>(stride_ * height_ * 3 / 2);
    y_data_ = std::make_unique<unsigned char[]>(width_ * height_);
    u_data_ = std::make_unique<unsigned char[]>(width_ * height_ / 4);
    v_data_ = std::make_unique<unsigned char[]>(width_ * height_ / 4);

    fread(raw_data.get(), 1, stride_ * height_ * 3 / 2, yuv_file);
    fclose(yuv_file);

    // 1. 提取 Y 平面
    for (int i = 0; i < height_; i++) {
        memcpy(y_data_.get() + i * width_, raw_data.get() + i * stride_, width_);
    }
    // 2. 提取 U 平面
    for (int i = 0; i < height_ / 2; i++) {
        memcpy(u_data_.get() + i * width_ / 2, raw_data.get() + stride_ * height_ + i * stride_ / 2, width_ / 2);
    }
    // 3. 提取 V 平面
    for (int i = 0; i < height_ / 2; i++) {
        memcpy(v_data_.get() + i * width_ / 2, raw_data.get() + stride_ * height_ * 5 / 4 + i * stride_ / 2, width_ / 2);
    }
}

// ==========================================
// 将干净的 Y,U,V 合成 NV12 交错格式并补回 Stride 写入文件
// ==========================================
void YUVConverter::convertToNV12(const char* url) {
    if (!y_data_ || !u_data_ || !v_data_) return;

    auto nv12_data = std::make_unique<unsigned char[]>(stride_ * height_ * 3 / 2);
    memset(nv12_data.get(), 0, stride_ * height_ * 3 / 2);

    // 1. 写入 Y 平面 (恢复 Stride 跨度)
    for (int i = 0; i < height_; i++) {
        memcpy(nv12_data.get() + i * stride_, y_data_.get() + i * width_, width_);
    }

    // 2. 将 U 和 V 交替写成 UVUV... 格式放入 Semi-Planar 区
    for (int i = 0; i < height_ / 2; i++) {
        for (int j = 0; j < width_ / 2; j++) {
            // NV12 格式:U 在前,V 在后
            nv12_data[stride_ * height_ + i * stride_ + j * 2]     = u_data_[i * width_ / 2 + j];
            nv12_data[stride_ * height_ + i * stride_ + j * 2 + 1] = v_data_[i * width_ / 2 + j];
        }
    }

    FILE* nv12_file = fopen(url, "wb");
    if (nv12_file) {
        fwrite(nv12_data.get(), 1, stride_ * height_ * 3 / 2, nv12_file);
        fclose(nv12_file);
    }
}

// ==========================================
// 读取 NV12 并剥离出纯净的 Y,U,V 分量
// ==========================================
void YUVConverter::readNV12(const char* url) {
    FILE* nv12_fp = fopen(url, "rb");
    if (!nv12_fp) {
        printf("file open failed...\n");
        return;
    }

    auto nv12_data = std::make_unique<unsigned char[]>(stride_ * height_ * 3 / 2);
    y_data_ = std::make_unique<unsigned char[]>(width_ * height_);
    u_data_ = std::make_unique<unsigned char[]>(width_ * height_ / 4);
    v_data_ = std::make_unique<unsigned char[]>(width_ * height_ / 4);

    fread(nv12_data.get(), 1, stride_ * height_ * 3 / 2, nv12_fp);
    fclose(nv12_fp);

    printf("开始读取NV12数据\n");
    // 1. 提取 Y 平面
    for (int i = 0; i < height_; i++) {
        memcpy(y_data_.get() + i * width_, nv12_data.get() + i * stride_, width_);
    }
    printf("Y通道读取完成,开始读取UV\n");
    // 2. 提取交错的 U 和 V
    for (int i = 0; i < height_ / 2; i++) {
        for (int j = 0; j < width_ / 2; j++) {
            u_data_[i * width_ / 2 + j] = nv12_data[stride_ * height_ + i * stride_ + j * 2];
            v_data_[i * width_ / 2 + j] = nv12_data[stride_ * height_ + i * stride_ + j * 2 + 1];
        }
    }
    printf("NV12数据读取完成\n");
}

// ==========================================
// 将干净的 Y,U,V 合成 YUV420P(I420) 并补回 Stride 写入文件
// ==========================================
void YUVConverter::convertToYUV420P(const char* url) {
    if (!y_data_ || !u_data_ || !v_data_) {
        printf("yuv数据为空\n");
        return;
    }

    auto yuv420p_data = std::make_unique<unsigned char[]>(stride_ * height_ * 3 / 2);
    memset(yuv420p_data.get(), 0, stride_ * height_ * 3 / 2);

    // 1. 写入 Y 平面
    for (int i = 0; i < height_; i++) {
        memcpy(yuv420p_data.get() + i * stride_, y_data_.get() + i * width_, width_);
    }
    // 2. 写入 U 平面
    for (int i = 0; i < height_ / 2; i++) {
        memcpy(yuv420p_data.get() + stride_ * height_ + i * stride_ / 2, u_data_.get() + i * width_ / 2, width_ / 2);
    }
    // 3. 写入 V 平面
    for (int i = 0; i < height_ / 2; i++) {
        memcpy(yuv420p_data.get() + stride_ * height_ * 5 / 4 + i * stride_ / 2, v_data_.get() + i * width_ / 2, width_ / 2);
    }

    FILE* yuv420p_fp = fopen(url, "wb");
    if (!yuv420p_fp) {
        printf("file open failed...\n");
        return;
    }
    fwrite(yuv420p_data.get(), 1, stride_ * height_ * 3 / 2, yuv420p_fp);
    fclose(yuv420p_fp);
    printf("yuv420p数据写入完成\n");
}

int main() {
    YUVConverter converter(1280, 720, 1280);

    // 方式1: 读取YUV420P,转换为NV12
    // converter.readYUV420P("../yuv420p.yuv");
    // converter.convertToNV12("../nv12.yuv");

    // 方式2: 读取NV12,转换为YUV420P
    converter.readNV12("../nv12_ffmpeg.yuv");
    converter.convertToYUV420P("../yuv420p_sw.yuv");

    return 0;
}

音视频数据流基础

共 5 篇

  1. H264流
  2. 音频流基础:PCM、WAV 与 AAC 编码深度解析
  3. 视频底层密码:YUV 颜色空间、内存对齐与格式转换揭秘
  4. 音视频容器格式剖析:MP4、FLV 与 TS
  5. FFMpeg 命令记录