1 概述

YUV是一种广泛应用于视频和图像处理的颜色空间格式,它通过将亮度和色度分离来表示颜色信息,以提高压缩效率和适应人眼感知。为了深入了解YUV格式及其应用,本文将分为几个部分来讨论。首先,我们将探讨YUV格式的采样方式和存储格式,以及不同采样方式对图像质量和压缩效率的影响。其次,我们将研究RGB到YUV和YUV到RGB的转换方法,包括转换公式和矩阵运算,以便在不同颜色空间之间进行准确的转换。最后会介绍如何利用OpenGL或Vulkan 来处理和渲染YUV格式的图像或视频数据,将讨论使用着色器程序和纹理贴图技术来实现YUV数据的采样、转换和渲染的方法。

2 YUV介绍

YUV,“Y” 表示明亮度(Luminance),也就是像素的灰阶值,还含有较多的绿色通道量。因此需要单纯的 Y 分量就可以显示出完整的黑白图像。“U” 和 “V” 分别表示色度(Chrominance)和浓度(Chroma),用于描述色彩饱和度,U 分量是蓝色通道与 Y(亮度)的差值,V 分量是红色通道与 Y(亮度)的差值。

U/V两个分量的变化区间见下图:

img

YUV也经常称为YCbCr,两者本质上没有区别。YUV 主要是用在彩色电视中,用于模拟信号表示,优点是可以兼容老式黑白电视,只需要YUV格式中的Y分量就可以输出黑白图像。YCbCr主要用在数字视频、图像的压缩和传输,例如H264、HEVC、MPEG均采用此格式。

YUV 色彩编码模型在计算机系统应用中也被广泛采用,它可以有效地降低图片数据的空间占用,提高数据处理效率,对于需要快速传输或数据量较大的图像数据,YUV 格式是一个理想的选择。

比如,一张1600x1600像素的照片,RGB 和 YUV(采用YUV420方式采样)格式的空间占用对比:

img img

一张图片是由N个像素组成的,在 1600x1600 分辨率的图片中,我们可以理解为水平方向有 1600 个像素,垂直方向也有 1600 个像素,大概拥有250万像素。图片在显示时,每个像素的颜色信息通常使用 RGB 线性编码格式来表示,也就是说,每个像素的颜色可以由 R、G、B 三个分量线性排列组成(图示是R8G8B8格式)。

image-20240410170729708

对于 YUV 格式来说,虽然每个像素也由Y、U、V 三个分量组成,但这三个分量的排列方式并不是线性的,而是按照位置特定排列的。根据采样方式和排列方式的不同,我们可以将 YUV 格式分为不同的类型。

2.1.1 采样方式

常见的YUV格式根据采样方式可以分为三种:

  • YUV444:每1个Y分量都对应一套UV分量,每个像素占用(Y+U+V=8+8+8=24bit)3个字节。
  • YUV422:每2个Y分量对应一套UV分量,每个像素占用(Y+0.5U+0.5V=8+4+4=16bit)2个字节。
  • YUV420:每4个Y分量对应一套UV分量,每个像素占用(Y+0.25U+0.25V=8+2+2=12bit)1.5字节,该采样方式节省较多存储空间,因此也最常用。

(1)YUV444

image-20240410170753677

上图是YUV444的采样模型,左图和右图都可以看出来每个像素点都有一对UV分量,这就相当于没有做色度二次采样,即每一个Y对应一组UV分量。

(2)YUV422

image-20240410170902503

上图是YUV422的采样模型,左图可以看出来在竖直方向上每一个像素都有UV分量,而在水平方向上每两个像素共用一对UV分量;在右图的表现就是每行两个黑两个白。即每两个Y对应一组UV分量。

(3)YUV420

image-20240410170827387

上图是YUV420的采样模型,左图可以看出来四个像素点共用一对UV分量,在水平方向上每两个像素共用一对UV分量,在竖直方向上也是每两个像素包含一对UV分量;在右图的表现就是第一行两个黑两个白,第二行全白。即每四个Y对应一组UV分量。

2.1.2 排列方式

在采样方式不同基础上,我们可以再根据排列方式的不同分为以下三类:

  • 平面(Planar)排列:YUV分量全部分开存放。
  • 半平面(Semi-Planar)排列:Y分量单独存放,UV交错存放。
  • 打包(Packed)排列:又称为Interleaved排列,YUV分量分别交错存放。

(1)Planar Format

Planar的YUV格式,分为3个平面。平面存储格式先连续存储所有像素点的Y,紧接着存储所有像素点的U或V,最后存储剩下的U或者V。例如YU12(也叫I420,属于YUV420采样模型,每4个Y分量共用一组UV分量)的排列方式如下:

image-20240410170931638

(2)Semi-Planar Format

Semi-Planar的YUV格式,半平面存储格式,分为2个平面,也就是先连续存储所有的Y分量,再交错存储U和V分量。例如NV12(属于YUV420sp采样模型,每4个Y分量共用一组UV分量)的排列方式如下:

image-20240410170941841

NV12还有一个兄弟格式,称为NV21,其实就是UV调换。即:

image-20240410170949379

(3)Packed Format

这种格式下YUV数据是交错存储的,例如YUV分量的排列顺序是VYUY,即两个Y分量共用一组UV,属于YUV 422的一种。

image-20240410171003778

2.1.3 常见的YUV格式

分类 格式 排列方式 空间占用
YUV422p I422 Y+0.5U+0.5V YUV 422 Planar 的一种,YUV 分量分别存放,先是 w * h 长度的 Y,后面跟 w * h * 0.5 长度的 U, 最后是 w * h * 0.5 长度的 V,总长度为 w * h * 2。
YV16 Y+0.5V+0.5U YUV 422 Planar 的一种,YUV 分量分别存放,先是 w * h 长度的 Y,后面跟 w * h * 0.5 长度的 V, 最后是 w * h * 0.5 长度的 U,总长度为 w * h * 2。与 I422 不同的是,YV16 是先 V 后 U。
YUV422sp NV16 Y+0.5UV YUV 422 Semi-Planar 的一种,Y 分量单独存放,UV 分量交错存放,UV 在排列的时候,从 U 开始。总长度为 w * h * 2。
NV61 Y+0.5VU YUV 422 Semi-Planar 的一种,Y 分量单独存放,UV 分量交错存放,UV 在排列的时候,从 V 开始。总长度为 w * h * 2。
YUV420p YUV12/I420 Y+0.25U+0.25V YUV 420 Planar 的一种,YUV 分量分别存放,先是 w * h 长度的 Y,后面跟 w * h * 0.25 长度的 U, 最后是 w * h * 0.25 长度的 V,总长度为 w * h * 1.5。
YV12 Y+0.25V+0.25U YUV 420 Planar 的一种,YUV 分量分别存放,先是 w * h 长度的 Y,后面跟 w * h * 0.25 长度的 V, 最后是 w * h * 0.25 长度的 U,总长度为 w * h * 1.5。与 I420 不同的是,YV12 是先 V 后 U。
YUV420sp NV12 Y+0.25UV YUV 420 Semi-Planar 的一种,Y 分量单独存放,UV 分量交错存放,UV 在排列的时候,从 U 开始。总长度为 w * h * 1.5。
NV21 Y+0.25VU YUV 420 Semi-Planar 的一种,Y 分量单独存放,UV 分量交错存放,与 NV12 不同的是,UV 在排列的时候,从 V 开始。总长度为 w * h * 1.5。

3 YUV和RGB互转

YUV 和 RGB 之间的格式转换可以通过矩阵乘法来实现,相当于给每个分量乘以一个常量。

$$F_r = M * F_s$$

不过,由于 YUV 格式存在不同的数字电视输出标准,因此在进行 YUV 和 RGB 之间的转换时,需要考虑不同的协议和标准。常见的 YUV 格式转换协议有BT.601、BT.709和BT.2020三种,这些协议在 YUV 和 RGB 之间的转换过程中系数会稍有不同。

下文如未特殊说明,YUV格式均属于NV12或NV21。

3.1 YUV转RGB

  1. BT.601(标准数字电视SDTV)

    image-20240410165651980

  2. BT.709(高清数字电视HDTV)

    image-20240410165907559

  3. BT.2020(超高清数字电视UHDTV)

    image-20240410171205074

注意:

  1. 在 YUV 和 RGB 之间的转换公式中,U 和 V 分量都减去了 0.5,这是因为 Y、U、V 和 RGB 的取值范围不同。YUV 格式中的 Y 分量的取值范围是 [0, 1],U 和 V 分量的取值范围是 [-0.5, 0.5]。而 RGB 格式中,R、G、B 三个分量的取值范围都是 [0, 1]。为了将 YUV 格式转换为 RGB 格式,我们需要将 U 和 V 分量的取值范围映射到 [0, 1] 的范围内。

    $$Y,R,G,B \in \mathbf[0,1]\ UV \in \mathbf[-0.5,0.5]$$

  2. 如果为了避免浮点计算丢失精度,可以先将各个分量乘以255(0xFF)转为unsigned char计算,后续再根据需要转为浮点型。如果使用 BT.601 协议标准,每个像素都需要执行转换,YUV -> RGB 的核心转换代码如下:

(1)普通浮点型计算

bool YUV2RGB(...){
    ...
    ...
    for(int col = 0; col < pixel_width; col++){
        for(int row = 0; row < pixel_height; row++){
            uint8_t Y = ...;
            uint8_t U = ...;
            uint8_t V = ...;
            
            //NV21
            //if(nv21){
            //    uint_8 temp = U;
            //    U = V;
            //    V = temp;
            //}
            
            float R = Y + 1.4075f * (V - 128);
            float G = Y - 0.3455f * (U - 128) - 0.7169f * (V - 128);
            float B = Y + 1.7790f * (U - 128);
            
            R = clamp(R, 0x00, 0xFF);    // 需要限制范围到0~255(浮点型:0-1)
            G = clamp(G, 0x00, 0xFF);    // 需要限制范围到0~255(浮点型:0-1)
            B = clamp(B, 0x00, 0xFF);    // 需要限制范围到0~255(浮点型:0-1)
        }
    }
    ...
}

(2)矩阵计算

image-20240410171123417

struct vec3{
    float x, y, z;
};

struct mat3{
    float v[3 * 3];
};

static void mat3MulVec3(mat3 mat, vec3 v, vec3 *outV){
    outV->x = mat.v[0] * v.x + mat.v[1] * v.y + mat.v[2] * v.z;
    outV->y = mat.v[3] * v.x + mat.v[4] * v.y + mat.v[5] * v.z;
    outV->z = mat.v[6] * v.x + mat.v[7] * v.y + mat.v[8] * v.z;
}

bool YUV2RGB(...){
    ...
    ...
    for(int col = 0; col < pixel_width; col++){
        for(int row = 0; row < pixel_height; row++){
            uint8_t Y = ...;
            uint8_t U = ...;
            uint8_t V = ...;
            
            //NV21
            //if(nv21){
            //    uint_8 temp = U;
            //    U = V;
            //    V = temp;
            //}

            
            vec3 yuv = { (float)Y, (float)(U - 128), (float)(V - 128) };
            mat3 mat = {
                    1, 0, 1.4075f,
                    1, -0.3455f, -0.7169f,
                    1, 1.779f, 0
            };
            vec3 rgb{};
            mat3MulVec3(mat, yuv, &rgb);
            float R = rgb.x;
            float G = rgb.y;
            float B = rgb.z;
            
            R = clamp(R, 0x00, 0xFF);    // 需要限制范围到0~255(浮点型:0-1)
            G = clamp(G, 0x00, 0xFF);    // 需要限制范围到0~255(浮点型:0-1)
            B = clamp(B, 0x00, 0xFF);    // 需要限制范围到0~255(浮点型:0-1)
        }
    }
    ...
}

(3)为了防止每个像素都要进行浮点运算,可以将系数乘[0-255]用字典存储,以减少计算耗时。

static float RGBYUV14075[256], RGBYUV03455[256], RGBYUV07169[256], RGBYUV17790[256];
static void initQueryTable(){
    int i;
    for (i = 0; i < 256; i++) RGBYUV14075[i] = (float)1.4075 * (i - 128);
    for (i = 0; i < 256; i++) RGBYUV03455[i] = (float)0.3455 * (i - 128);
    for (i = 0; i < 256; i++) RGBYUV07169[i] = (float)0.7169 * (i - 128);
    for (i = 0; i < 256; i++) RGBYUV17790[i] = (float)1.7790 * (i - 128);
}

bool YUV2RGB(...){
    ...
    ...
    for(int col = 0; col < pixel_width; col++){
        for(int row = 0; row < pixel_height; row++){
            uint8_t Y = ...;
            uint8_t U = ...;
            uint8_t V = ...;
            
            //NV21
            //if(nv21){
            //    uint_8 temp = U;
            //    U = V;
            //    V = temp;
            //}
            
            float R = Y + RGBYUV14075[V];
            float G = Y - RGBYUV03455[U] - RGBYUV07169[V];
            float B = Y + RGBYUV17790[U];
            
            R = clamp(R, 0x00, 0xFF);
            G = clamp(G, 0x00, 0xFF);
            B = clamp(B, 0x00, 0xFF);
       }
    }
    ...
}

3.2 RGB转YUV

  1. BT.601(标准数字电视SDTV)

    image-20240410171331055

  2. BT.709(高清数字电视HDTV)

    image-20240410171343039

  3. BT.2020(超高清数字电视UHDTV)

    image-20240410171357823

使用BT.601协议,RGB -> NV12(YUV420采样模型)核心转换代码如下:

uint8_t *yBuffer = yuvBuffer;                                  // Y数据段
uint8_t *uvBuffer = yuvBuffer + pixel_width * pixel_height;    // UV数据段
uint8_t *rgbIndex = rgbBuffer;

bool RGB2YUV(...){
    for(int col = 0; col < pixel_width; col++){
        for(int row = 0; row < pixel_height; row++){
            uint8_t R = *rgbIndex++;
            uint8_t G = *rgbIndex++;
            uint8_t B = *rgbIndex++;
            
            float Y = YUVRGB02990[R] + YUVRGB05870[G] + YUVRGB01140[B];
            Y = clamp(Y, 0x00, 0xFF);
            //设置Y的值
            *yBuffer++ = (uint8_t) Y;
            
            //采样 2x2
            if(w % 2 == 0 && h % 2 == 0){
                float U = (-YUVRGB01690[R] - YUVRGB03310[G] + YUVRGB05000[B]) + 128;
                float V = (YUVRGB05000[R] - YUVRGB04190[G] - YUVRGB00810[B]) + 128;
            
                U = clamp(U, 0x00, 0xFF);
                V = clamp(V, 0x00, 0xFF);
                
                //NV21
                //if(nv21){
                //    uint_8 temp = U;
                //    U = V;
                //    V = temp;
                //}
                
                // 设置U,V的值
                *uvBuffer++ = (uint8_t) (U);
                *uvBuffer++ = (uint8_t) (V);
            } 
        }
    }
    ..
}

3.3 GPU渲染

在#3章节中,如果使用CPU来执行YUV和RGB互转,在1600x1600分辨率下,需要约40-60毫秒的处理时间。但是,如果使用GPU来处理呢?使用GPU进行处理时,通常会获得更快的处理速度,因为GPU在并行计算方面表现更为出色,能够同时处理多个像素。

当我们需要将摄像设备返回的画面渲染到屏幕上时,设备通常会返回RAW或YUV格式的数据,这些数据可以通过GPU渲染而实现预览功能。此外,我们还可以将图像数据采样到一个畸变网格中,这样可以实现Video-see through(VST)的基础效果,实时显示相机捕捉到的内容,并在需要时应用畸变校正和其他增强效果。这样的处理过程通常需要借助GPU的计算能力来实现高效渲染和实时响应。

使用YUVviewer打开一个NV12图像。

img img

使用 OpenGL/Vulkan 两种API完成渲染,渲染结果如下:

image-20240410171435452

img

GPU渲染参考以下四个步骤:

  1. 渲染API的前置步骤,OpenGL 或Vulkan 初始化,需要支持纹理渲染。
  2. 读取YUV格式数据,数据可以来自文件或内存。因为NV12/NV21属于YUV420sp模型,只有Y和UV两个平面,所以读取这两个平面,然后分别为其创建各自的纹理。
  3. 编写渲染着色器(Shader),给Y+UV两个平面分别采样,并使用#3章节中公式将其转换为RGB。
  4. 初始化一个四边形网格,并将步骤#2中创建的两个纹理合成并应用给这个网格。

img img

4 OpenGL渲染NV12/NV21格式

4.1 读取NV12/NV21数据

  1. 我们可以定义一个YUV纹理类,记录YUV数据的宽高、数据和帧数等,并提供读取/销毁等操作。

这里需要注意的是this->frameSize = width * height * 3 / 2,这里 * 1.5的原因是2.2章节中提到的,NV12/NV21的数据总长度为w * h * 1.5。

class YuvTexture{
public:
    YuvTexture() = delete;
    ~YuvTexture(){
        free(data);
    }
    YuvTexture(const std::string &filePath, uint32_t width, uint32_t height){
        this->filePath = filePath;
        this->width = width;
        this->height = height;
        this->frameSize = width * height * 3 / 2;
        this->currentFrameIndex = 0;
    }
    
    void read(){
        //TODO 
    }
    
    void destroy(){
        free(data);
        //回收Y+UV两个Texture
    }
    
public:
    GLuint texYId;             //Y纹理ID
    GLuint texUVId;            //UV纹理ID
private:
    uint32_t width, height;    //YUV图片宽高
    size_t dataSize;           //数据长度
    uint8_t *data;             //YUV数据
    
    uint32_t frameSize;        //每帧的数据长度
    int currentFrameIndex;     //当前帧
    int totalFrameCount;       //总帧数
};
  1. 然后实现read()函数。
void read(){
    FILE *fp = fopen(filePath.c_str(),"rb");
    if (fp){
        fseek(fp, 0, SEEK_END);
        dataSize = ftell(fp);
        this->data = static_cast<uint8_t *>(malloc(sizeof(uint8_t) * dataSize));
        fseek(fp, 0, SEEK_SET);
        fread(data, 1, dataSize, fp);
        fclose(fp);
        fp = nullptr;
    }
    this->totalFrameCount = dataSize / frameSize;
    
    //创建 Y Texture
    glGenTextures(1, &texYId);
    glBindTexture(GL_TEXTURE_2D, texYId);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
    //创建UV Texture
    glGenTextures(1, &texUVId);
    glBindTexture(GL_TEXTURE_2D, texUVId);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glBindTexture(GL_TEXTURE_2D, 0);
}

第一部分是IO读取,这部分数据就是为了得到data byte数据。

第二部分使用glGenTexturesOpenGL API创建Y+UV两个纹理。

  1. 以上创建好纹理之后,我们还需要将Y、UV两个平面数据载入纹理。
void updateTexture(){
    if(currentFrameIndex >= 0 && currentFrameIndex < totalFrameCount){
        uint8_t *buffer = data + currentFrameIndex * frameSize;
        glBindTexture(GL_TEXTURE_2D, texYId);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, buffer);
        glBindTexture(GL_TEXTURE_2D, 0);

        glBindTexture(GL_TEXTURE_2D, texUVId);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RG, width / 2, height / 2, 0, GL_RG, GL_UNSIGNED_BYTE, buffer + width * height);
        glBindTexture(GL_TEXTURE_2D, 0);
    } else {
        currentFrameIndex = 0;
    }
}

其核心处理在于glTexImage2DAPI,该API的定义如下:

glTexImage2D(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void *pixels);
  • target::指定纹理绑定目标,我们采用默认的2D纹理。因此设置为GL_TEXTURE_2D。
  • level:多级渐远纹理的级别。
  • internalformat: 告诉OpenGL希望把纹理储存为何种格式。对于Y平面来讲,数据段每个像素就只有一个Y,所以我们填GL_RED单一分量;对于UV平面,因为是两个分量值,所以我们填GL_RG
  • width:纹理宽度,因NV12/NV21属于YUV420采样方式,所以对于Y平面,宽高就是width * height;对于UV平面来说,宽高是width/2 * height / 2。
  • border:应该总是被设为0(历史遗留的问题)。
  • format/type:定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
  • 最后一个参数是真正的图像数据。

重要参数说明:

  1. Y平面纹理格式中,我们使用了 GL_RED 格式,表示只需要一个纹理通道,在着色器中,texture(tex_y, v_texcoord).r可以用来取出来这单一通道数据。
  2. 在OpenGL3.3版本之前,还可以用 GL_LUMINANCE 来代替 GL_RED 表示单一通道颜色值,用 GL_LUMINANCE_ALPHA 来代替 GL_RG 两个通道颜色值。只是OpenGL3.3之后,这两个枚举被移除了,并不能使用。

4.2 着色器

着色器(Shader)是运行在GPU上的小程序,这些小程序为图形渲染管线的某个特定部分而运行。我们在CPU准备好网格、纹理等数据后,由CPU将数据提交给GPU完成。GPU处理图像有一个流水线的概念,着色器就是GPU执行某些流程时的逻辑定义。最简单的情况下我们需要顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)两个着色器配合完成渲染,这两个着色器可以简单理解为:

  1. 顶点着色器就是执行这一流程时每个顶点要执行一次。伪代码:
for(Vertex v : vetices){
    // 顶点处理
    v.postion = ?;
    v.color=?;
    v.textcoord=?;
    ...
    ...
}
  1. 片元着色器就是每个像素(片元)都要执行一次。伪代码:
for(pixel v : pixels){
    pixel = {r = 0.1, g = 0.2, b = 0.3};
    ...
    ...
}

对于YUV数据,我们不在CPU侧进行RGB转换,所以需要两个纹理采样,然后在着色器以使用矩阵乘法完成颜色转换。以下是YUV渲染的片元着色器样例。

需要注意的是,着色器中的矩阵是列主序,需要将矩阵行主序转为列主序。

#version 330 core

in vec2 v_texcoord;

uniform sampler2D tex_y;    //Y平面纹理采样,对应我们上述的glGenTextures(1, &texYId);
uniform sampler2D tex_uv;   //UV平面纹理采样,对应我们上述的glGenTextures(1, &texUVId);

out vec4 FragColor;
void main(){
    vec3 yuv;
    yuv.x = texture(tex_y, v_texcoord).r;
    yuv.y = texture(tex_uv, v_texcoord).g - 0.5;
    yuv.z = texture(tex_uv, v_texcoord).r - 0.5;
    highp vec3 rgb = mat3( 1,       1,      1,             //BT.601
                           0,     -0.3455,  1.779,    
                           1.4075, -0.7169,  0) * yuv; 

    //highp vec3 rgb = mat3( 1,       1,      1,              //BT.709
    //                       0,     -0.1868,  1.856,    
    //                       1.5478, -0.4680,  0) * yuv; 

    //highp vec3 rgb = mat3( 1,       1,      1,              // BT.2020
    //                       0,     -0.1645,  1.8814,
    //                       1.4746, -0.5713,  0) * yuv; 

     FragColor = vec4(rgb, 1);
}

对于顶点着色器,只需要传递网格顶点位置和纹理UV坐标即可。

#version 330 core
layout (location = 0) in vec2 a_position;    //网格顶点位置
layout (location = 2) in vec2 a_texcoord;    //网格UV坐标
out vec2 v_texcoord;
void main(){
  gl_Position = vec4(a_position, 0, 1);
  v_texcoord = a_texcoord;
}

4.3 创建网格体并应用Y+UV纹理

本案例需要将YUV纹理平铺显示到应用窗口,所以只用一个四边形网格,并且范围是[-1,1]。

image-20240410171549322

image-20240410171555712

初始化网格。

void initMesh() {
    float vertexPos[] = {
            -1.0f, 1.0f,
            -1.0f, -1.0f,
            1.0f, -1.0f,
            1.0f, 1.0f,
    };

    float vertexTexcoord[] = {
            0, 0,
            0, 1,
            1, 1,
            1, 0
    };

    int indices[] = {
            0, 1, 2,
            0, 2, 3
    };

    glGenVertexArrays(1, &vao);
    glGenBuffers(VBO_SIZE, vbo);
    glGenBuffers(1, &ebo);

    glBindVertexArray(vao);
    glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPos), vertexPos, GL_STATIC_DRAW);
    GLuint  loc = glGetAttribLocation(program, "a_position");
    glEnableVertexAttribArray(loc);
    glVertexAttribPointer(loc, 2, GL_FLOAT, false, 0, (void*) 0);

    glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertexTexcoord), vertexTexcoord, GL_STATIC_DRAW);
    loc = glGetAttribLocation(program, "a_texcoord");
    glEnableVertexAttribArray(loc);
    glVertexAttribPointer(loc, 2, GL_FLOAT, false, 0, (void*) 0);

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    glBindVertexArray(0);
}

5 Vulkan渲染NV12/NV21格式

Vulkan中默认已有YUV的实现方案,在其内部称为“YCbCr”。比如VKFormat中就有关于YUV的一些格式:

img

另外还区分了不同标准和协议。

img

因此,使用Vulkan渲染NV12/NV21有两种方案。

  1. 第一种方案是利用Vulkan自带的**VkSamplerYcbcrConversionInfo**来进行颜色格式转换。可以初始化这个对象,应用 YUV 纹理,并可实现自动的颜色格式转换。这种方法利用了Vulkan的内置功能,简化了颜色格式转换的流程,并提供了一种方便的方式来处理YUV数据。
  2. 第二种方案和OpenGL实现方式一致,需要自行创建 Y、UV 两个平面纹理,然后利用片元着色器实现色彩转换。而第一种方案因为是Vulkan内置转换,我们不能对其做更多控制,比如反转 U、V 分量、改变色彩饱和度、将NV12转换到NV21格式等。

本文为了和OpenGL实现方式一致,所以只探讨第二种实现方案,第一种方案可以参考YCbCr Sampler in Vulkan 示例。

5.1 前提

Vulkan 和 OpenGL 都是渲染API,那么在底层图形渲染管线都是一致的,只是Vulkan相对更底层、更复杂。

以下是在渲染YUV数据需要用的 OpenGL、Vulkan 几个比较重要的对象对比。

对象 OpenGL Vulkan 描述
纹理 GLTexture VkImage 纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节。本文中纹理其实就是NV12/NV21的两个平面。在Vulkan中,可以理解为VkImage就是OpenGL中的GLTexture,但需要通过VkImageView对Image对象进行绑定和访问,以实现纹理的采样和使用。
设置纹理 glTexImage2D vkCmdCopyBufferToImage 在OpenGL中,使用glTexImage2D函数可以直接将颜色字节数据推送给纹理对象,可以使用该函数来指定纹理的数据格式、大小和像素数据等。但是在Vulkan中,需要通过创建VkBuffer对象来存储颜色字节数据,并将其复制到纹理对象VkImage中。
Y平面纹理格式 GL_RED VK_FORMAT_R8_UNORM Y平面的有效数据就是Y本身,所以只需要一个分量。对于OpenGL来说,就可以直接传递RED红色单通道,使用红色通道作为亮度数据;对于Vulkan来说,红色单通道可以用R8_UNORM来代替。
UV平面纹理格式 GL_RG VK_FORMAT_R8G8_UNORM UV平面的有效数据U和V分量,需要两个通道数据。对于OpenGL来说,就可以直接传递RG两个通道;对于Vulkan来说,两通道可以用R8G8_UNORM来代替。

5.2 读取NV12/NV21数据

对于Vulkan来说,纹理信息不像OpenGL一样只需要一个GLuint textureId,而是需要以下几个对象来表示。

struct{
    VkImage mImg = VK_NULL_HANDLE;                // image,采样时不可以直接使用,需要由ImageView绑定使用
    VkDeviceMemory mMemory = VK_NULL_HANDLE;      // image所占用的内存
    VkImageView mImgView = VK_NULL_HANDLE;        // 绑定使用
    VkSampler mSampler = VK_NULL_HANDLE;          // 采样配置
} aVkImage;

因此,对于NV12/NV21的Y、UV两个平面,可以写作:

struct VkYUVImage{
    struct{
        VkImage mImg = VK_NULL_HANDLE;
        VkDeviceMemory mMemory = VK_NULL_HANDLE;
        VkImageView mImgView = VK_NULL_HANDLE;
        VkSampler mSampler = VK_NULL_HANDLE;
    } yPlane;

    struct{
        VkImage mImg = VK_NULL_HANDLE;
        VkDeviceMemory mMemory = VK_NULL_HANDLE;
        VkImageView mImgView = VK_NULL_HANDLE;
        VkSampler mSampler = VK_NULL_HANDLE;
    } uvPlane;
};

然后跟OpenGL一样,对上述两个纹理进行初始化。

void read(){
    FILE *fp = fopen(filePath.c_str(),"rb");
    if (fp){
        fseek(fp, 0, SEEK_END);
        dataSize = ftell(fp);
        this->data = static_cast<uint8_t *>(malloc(sizeof(uint8_t) * dataSize));
        fseek(fp, 0, SEEK_SET);
        fread(data, 1, dataSize, fp);
        fclose(fp);
        fp = nullptr;
    }
    this->totalFrameCount = dataSize / frameSize;
    
    //VkBuffer 
    VkBuffer imgBuffer = createVkBufferFromData(dataSize, data);
    
    //创建 Y Texture
    creayImage(VK_FORMAT_R8_UNORM, width, height, imgBuffer, 
               &yPlane.mImg, &yPlane.mImgView, &yPlane.mSampler);
    
    
    //创建UV Texture
    creayImage(VK_FORMAT_R8G8_UNORM, width / 2, height / 2, imgBuffer, 
               &uvPlane.mImg, &uvPlane.mImgView, &uvPlane.mSampler);
}
  1. 创建 VkImage;
  2. 申请 VkImage内存并绑定;
  3. 创建 VkImageView;
  4. 创建 VkSampler,指定纹理环绕方式和采样方式等。
void createImage(VkFormat format, uint32_t width, uint32_t height; VkBuffer imgBuffer, 
        VkImage *outImg, VkDeviceMemory *outMemory, VkImageView *outImgView, VkSampler *outSampler){
    //1. create image
    VkImageCreateInfo imageInfo = {
            .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
            .pNext = nullptr,
            .flags = 0,
            .imageType = VK_IMAGE_TYPE_2D,
            .format = format,
            .extent = VkExtent3D{ width, height, 1 },
            .mipLevels = 1,
            .arrayLayers = 1,
            .samples = VK_SAMPLE_COUNT_1_BIT,
            .tiling = VK_IMAGE_TILING_LINEAR,
            .usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
            .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
            .queueFamilyIndexCount = 0,
            .pQueueFamilyIndices = nullptr,
            .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED
    };
    CALL_VK(vkCreateImage(device, &imageInfo, VK_ALLOC, outImg));
    
    // allocate Image memory and bind.
    VkMemoryRequirements memReqs{};
    vkGetImageMemoryRequirements(device, *outImg, &memReqs);
    uint32_t memoryTypeIndex;
    const VkMemoryPropertyFlags memoryPropertyFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
    bool bret = vk_get_memory_type( //
            mVkBundle,              // vk_bundle
            memReqs.memoryTypeBits, // type_bits
            memoryPropertyFlags,    // memory_props
            &memoryTypeIndex);      // out_type_id
    if (!bret) {
        VK_ERROR(mVkBundle, "vk_get_memory_type: false\n\tFailed to find a matching memory type.");
        return;
    }
    
    VkMemoryAllocateInfo memoryAllocateInfo = {
            .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
            .pNext = nullptr,
            .allocationSize = memReqs.size,
            .memoryTypeIndex = memoryTypeIndex
    };
    CALL_VK(vkAllocateMemory(device, &memoryAllocateInfo, VK_ALLOC, outMemory));
    vkBindImageMemory(device, *outImg, *outMemory, 0);
    
    transition_image_layout(mVkBundle, mCommandPool, *outImg, format,
                            VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, cmdBuffer);
    
    //2. create image view
    VkImageViewCreateInfo imgViewInfo = {
            .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
            .pNext = nullptr,
            .flags = 0,
            .image = *outImg,
            .viewType = VK_IMAGE_VIEW_TYPE_2D,
            .format = format,
            .components = {
                    .r = VK_COMPONENT_SWIZZLE_IDENTITY,
                    .g = VK_COMPONENT_SWIZZLE_IDENTITY,
                    .b = VK_COMPONENT_SWIZZLE_IDENTITY,
                    .a = VK_COMPONENT_SWIZZLE_IDENTITY
            },
            .subresourceRange = {
                    .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
                    .baseMipLevel = 0,
                    .levelCount = 1,
                    .baseArrayLayer = 0,
                    .layerCount = 1
            }
    };
    CALL_VK(vkCreateImageView(device, &imgViewInfo, VK_ALLOC, outImgView));
    
    //3. create sampler
    VkSamplerCreateInfo samplerCreateInfo = {
            .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
            .pNext = nullptr,
            .flags = 0,
            .magFilter = VK_FILTER_NEAREST,
            .minFilter = VK_FILTER_NEAREST,
            .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
            .addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
            .addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
            .addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
            .mipLodBias = 0.0f,
            .anisotropyEnable = VK_FALSE,
            .maxAnisotropy = 1.0f,
            .compareEnable = VK_FALSE,
            .compareOp = VK_COMPARE_OP_NEVER,
            .minLod = 0.0f,
            .maxLod = 1.0f,
            .borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE,
            .unnormalizedCoordinates = VK_FALSE
    };
    CALL_VK(vkCreateSampler(device, &samplerCreateInfo, VK_ALLOC, outSampler));

    //4. copy buffer to image
    VkBufferImageCopy bufferCopyRegions ={
        .bufferOffset = bufferOffset,
        .bufferRowLength = 0,
        .bufferImageHeight = 0,
        .imageSubresource = {
                .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
                .mipLevel = 0,
                .baseArrayLayer = 0,
                .layerCount = 1,
        },
        .imageOffset = {0, 0, 0},
        .imageExtent = { IMAGE_WIDTH, IMAGE_HEIGHT, 1},
    };
    vkCmdCopyBufferToImage(cmdBuffer, mImgBuffer, mCameraImage.yImg.mImg, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &bufferCopyRegions);
    bufferCopyRegions.bufferOffset = bufferOffset + IMAGE_WIDTH * IMAGE_HEIGHT;
    bufferCopyRegions.imageExtent = { IMAGE_WIDTH / 2, IMAGE_HEIGHT / 2, 1 };
    vkCmdCopyBufferToImage(cmdBuffer, mImgBuffer, mCameraImage.uvImg.mImg, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &bufferCopyRegions);
}

5.3 创建网格体并应用Y+UV纹理

void initMesh() {
    float vertexPos[] = {
            -1.0f, 1.0f,
            -1.0f, -1.0f,
            1.0f, -1.0f,
            1.0f, 1.0f,
    };

    float vertexTexcoord[] = {
            0, 0,
            0, 1,
            1, 1,
            1, 0
    };

    int indices[] = {
            0, 1, 2,
            0, 2, 3
    };
    
    createBuffer(physicalMemoType, device, cmdPool, queue, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
             VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, sizeof(Vertex) * vertices.size(), vertices.data(),
             &vertexBuffer, &vertexMemory);
             
    createBuffer(physicalMemoType, device, cmdPool, queue, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT,
             VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, sizeof(uint32_t) * indices.size(), indices.data(),
             &indexBuffer, &indexMemory);
}

源码地址:https://gitee.com/xingchen0085/yuv2gl

6 参考

[1] 维基百科. https://en.wikipedia.org/wiki/YUV

[2] 维基百科. https://en.wikipedia.org/wiki/Chroma_subsampling

[3] Videolan. https://wiki.videolan.org/YUV

[4] FOURCC. https://www.fourcc.org/

[5] 参天de小树. BT601/BT709/BT2020 YUV2RGB RGB2YUV 公式

[6] YUV 数据格式详解

[7] [纹理 - LearnOpenGL CN](https://learnopengl-cn.github.io/01 Getting started/06 Textures/)

[8] YCbCr Sampler in Vulkan

[9] 知乎 RAW、RGB、YUV 图像格式区别

[10] YUV颜色编码解析

[11] 【图像处理】RGB、YUV (YCbCr) 图像表示详解_小丫么小阿豪的博客-CSDN博客