最近,有位读者大人在后台反馈:在参加一场面试的时候,面试官要求他用 shader 实现图像格式 RGB 转 YUV ,他听了之后一脸懵,然后悻悻地对面试官说,他只用 shader 做过 YUV 转 RGB,不知道 RGB 转 YUV 是个什么思路。
针对他的这个疑惑,今天专门写文章介绍一下如何使用 OpenGL 实现 RGB 到 YUV 的图像格式转换,帮助读者大人化解此类问题。
有读者大人让推荐一个 YUV 看图软件,由于手头的工具没法分享出来,又在 Github 上找了一圈发现这一类开源软件用起来都不少 BUG 。
最后发现一款免费的商业软件 YUV Viewer ,用起来还行。
https://www.elecard.com/products/video-analysis/yuv-viewer
就是下载起来比较慢,我这里给读者大人已经下载好了,【字节流动】 后台回复关键字 yuvViewer 即可获取。
使用 shader 实现 RGB 到 YUV 的图像格式转换有什么使用场景呢?在生产环境中使用极为普遍。
前文曾经介绍过 Android OpenGL 渲染图像的读取方式 ,分别是 glReadPixels、 PBO、 ImageReader 以及 HardwareBuffer 。
glReadPixels 大家经常用来读取 RGBA 格式的图像,那么我用它来读取 YUV 格式的图像行不行呢? 答案是肯定的,这就要用到 shader 来实现 RGB 到 YUV 的图像格式转换。
glReadPixels 性能瓶颈一般出现在大分辨率图像的读取,在生产环境中通用的优化方法是在 shader 中将处理完成的 RGBA 转成 YUV (一般是 YUYV),然后基于 RGBA 的格式读出 YUV 图像,这样传输数据量会降低一半,性能提升明显,不用考虑兼容性问题。
这一节先做个铺垫简单介绍下 YUV 转 RGB 实现,在前面的文章中曾经介绍过 OpenGL 实现 YUV 的渲染 ,实际上就是利用 shader 实现了 YUV(NV21) 到 RGBA 的转换,然后渲染到屏幕上。
以渲染 NV21 格式的图像为例,下面是 (4x4) NV21 图像的 YUV 排布:
(0 ~ 3) Y00 Y01 Y02 Y03 (4 ~ 7) Y10 Y11 Y12 Y13 (8 ~ 11) Y20 Y21 Y22 Y23 (12 ~ 15) Y30 Y31 Y32 Y33 (16 ~ 19) V00 U00 V01 U01 (20 ~ 23) V10 U10 V11 U11
YUV 渲染步骤:
片段着色器脚本:
#version 300 es precision mediump float; in vec2 v_texCoord; layout(location = 0) out vec4 outColor; uniform sampler2D y_texture; uniform sampler2D uv_texture; void main() vec3 yuv; yuv.x = texture(y_texture, v_texCoord).r; yuv.y = texture(uv_texture, v_texCoord).a-0.5; yuv.z = texture(uv_texture, v_texCoord).r-0.5; vec3 rgb =mat3( 1.0, 1.0, 1.0, 0.0, -0.344, 1.770, 1.403, -0.714, 0.0) * yuv; outColor = vec4(rgb, 1); }
y_texture 和 uv_texture 分别是 NV21 Y Plane 和 UV Plane 纹理的采样器,对两个纹理采样之后组成一个(y,u,v)三维向量,之后左乘变换矩阵转换为(r,g,b)三维向量。
上面 YUV 转 RGB shader 中,面试官喜欢问的问题(一脸坏笑):为什么 UV 分量要减去 0.5 啊?
(迷之自信)答曰:因为归一化。 YUV 格式图像 UV 分量的默认值分别是 127 ,Y 分量默认值是 0 ,8 个 bit 位的取值范围是 0 ~ 255,由于在 shader 中纹理采样值需要进行归一化,所以 UV 分量的采样值需要分别减去 0.5 ,确保 YUV 到 RGB 正确转换。
需要注意的是 OpenGL ES 实现 YUV 渲染需要用到 GL_LUMINANCE 和 GL_LUMINANCE_ALPHA 格式的纹理,其中 GL_LUMINANCE 纹理用来加载 NV21 Y Plane 的数据,GL_LUMINANCE_ALPHA 纹理用来加载 UV Plane 的数据, 这一点很重要,初学的读者大人请好好捋一捋。
关于 shader 实现 YUV 转 RGB (NV21、NV12、I410 格式图像渲染)可以参考文章: OpenGL ES 3.0 开发(三):YUV 渲染 和 FFmpeg 播放器视频渲染优化 ,本文主要重点讲 shader 如何实现 RGB 转 YUV 。
来到本文的重点,那么如何利用 shader 实现 RGB 转 YUV 呢?
前面小节已经提到,先说下一个简单的思路: 先将 RGBA 按照公式转换为 YUV 如(YUYV),然后将 YUYV 按照 RGBA 进行排布,最后使用 glReadPixels 读取 YUYV 数据,由于 YUYV 数据量为 RGBA 的一半,需要注意输出 buffer 的大小,以及 viewport 的宽度(宽度为原来的一半)。
RGB to YUV 的转换公式:
开门见山,先贴实现 RGBA 转 YUV 的 shader 脚本:
#version 300 es precision mediump float; in vec2 v_texCoord; layout(location = 0) out vec4 outColor; uniform sampler2D s_TextureMap;//RGBA 纹理 uniform float u_Offset;//采样偏移 //RGB to YUV //Y = 0.299R + 0.587G + 0.114B //U = -0.147R - 0.289G + 0.436B //V = 0.615R - 0.515G - 0.100B const vec3 COEF_Y = vec3( 0.299, 0.587, 0.114); const vec3 COEF_U = vec3(-0.147, -0.289, 0.436); const vec3 COEF_V = vec3( 0.615, -0.515, -0.100); void main() vec2 texelOffset = vec2(u_Offset, 0.0); vec4 color0 = texture(s_TextureMap, v_texCoord); //偏移 offset 采样 vec4 color1 = texture(s_TextureMap, v_texCoord + texelOffset); float y0 = dot(color0.rgb, COEF_Y); float u0 = dot(color0.rgb, COEF_U) + 0.5; float v0 = dot(color0.rgb, COEF_V) + 0.5; float y1 = dot(color1.rgb, COEF_Y); outColor = vec4(y0, u0, y1, v0); }
shader 实现 RGB 转 YUV 原理图:
我们要将 RGBA 转成 YUYV,数据量相比于 RGBA 少了一半,这就相当于将两个像素点合并成一个像素点。
如图所示,我们在 shader 中执行两次采样,RGBA 像素(R0,G0,B0,A0)转换为(Y0,U0,V0),像素(R1,G1,B1,A1)转换为(Y1),然后组合成(Y0,U0,Y1,V0),这样 8 个字节表示的 2 个 RGBA 像素就转换为 4 个字节表示的 2 个 YUYV 像素。
转换成 YUYV 时数据量减半,那么 glViewPort 时 width 变为原来的一半,同样 glReadPixels 时 width 也变为原来的一半。
实现 RGBA 转成 YUYV 要保证原图分辨率不变,建议使用 FBO 离屏渲染 ,这里注意绑定给 FBO 的纹理是用来容纳 YUYV 数据,其宽度应该设置为原图的一半。
bool RGB2YUVSample::CreateFrameBufferObj() // 创建并初始化 FBO 纹理 glGenTextures(1, &m_FboTextureId); glBindTexture(GL_TEXTURE_2D, m_FboTextureId); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, GL_NONE); // 创建并初始化 FBO glGenFramebuffers(1, &m_FboId); glBindFramebuffer(GL_FRAMEBUFFER, m_FboId); glBindTexture(GL_TEXTURE_2D, m_FboTextureId); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_FboTextureId, 0); //FBO 纹理是用来容纳 YUYV 数据,其宽度应该设置为原图的一半 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, m_RenderImage.width / 2, m_RenderImage.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); if (glCheckFramebufferStatus(GL_FRAMEBUFFER)!= GL_FRAMEBUFFER_COMPLETE) { LOGCATE("RGB2YUVSample::CreateFrameBufferObj glCheckFramebufferStatus status != GL_FRAMEBUFFER_COMPLETE"); return false; glBindTexture(GL_TEXTURE_2D, GL_NONE); glBindFramebuffer(GL_FRAMEBUFFER, GL_NONE); return true; }
离屏渲染和读取 YUYV 数据:
glBindFramebuffer(GL_FRAMEBUFFER, m_FboId); // 渲染成 yuyv 宽度像素减半,glviewport 宽度减半 glViewport(0, 0, m_RenderImage.width / 2, m_RenderImage.height); glUseProgram(m_FboProgramObj); glBindVertexArray(m_VaoIds[1]); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, m_ImageTextureId); glUniform1i(m_FboSamplerLoc, 0); //参考原理图,偏移量应该设置为 1/(width / 2) * 1/2 = 1 / width; 理论上纹素的一半 float texelOffset = (float) (1.f / (float) m_RenderImage.width); GLUtils::setFloat(m_FboProgramObj, "u_Offset", texelOffset); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, (const void *)0); glBindVertexArray(0); glBindTexture(GL_TEXTURE_2D, 0); //YUYV buffer = width * height * 2; 转换成 YUYV 时数据量减半,注意 buffer uint8_t *pBuffer = new uint8_t[m_RenderImage.width * m_RenderImage.height * 2]; NativeImage nativeImage = m_RenderImage; nativeImage.format = IMAGE_FORMAT_YUYV; nativeImage.ppPlane[0] = pBuffer; //glReadPixels 时 width 变为原来的一半