YUV 如何转 RGB

YUV和RGB是两种常见的颜色编码格式,主要用于图像和视频处理。

RGB(红绿蓝)是一种基于加色模型的颜色表示方法,广泛用于显示设备(如屏幕)和图像处理。它由R(Red,红色)、G(Green,绿色)、B(Blue,蓝色)三个通道组成。每个通道通常用8位表示(0-255),因此RGB图像每个像素需要24位(3字节)来存储全彩信息。例如,纯红色的像素值为(255, 0, 0),白色为(255, 255, 255)。

RGB直接对应人眼感知的红、绿、蓝三原色,适合显示和渲染。每个像素存储三个分量,数据量较大,不适合视频压缩和传输。主要用于显示器、相机传感器、图像编辑软件等需要高保真的场景。

RGB 有两个变种。1.RGBA增加了Alpha通道,用于表示透明度。2.16位/通道或浮点格式用于高动态范围(HDR)图像。

YUV 是一种颜色编码方式,常见于视频压缩和传输(如MPEG、H.264)。它将颜色信息分为亮度(Y)和色度(U、V)两个部分。Y(Luma,亮度)表示图像的明暗信息(灰度值),不含颜色信息。Y值决定了图像的黑白效果。U(Cb,蓝色色度)表示蓝色分量与亮度的差值。V(Cr,红色色度)表示红色分量与亮度的差值。色度(U、V)结合亮度(Y)可以重建出完整的颜色信息。

人眼对亮度变化更敏感,对色度变化的感知较弱。因此,YUV允许对色度(U、V)进行亚采样(减少分辨率),从而显著降低数据量。亚采样常见有三种类型,4:4:4(YUV444):每个像素都有完整的Y、U、V分量,数据量与RGB相当。4:2:2(YUV422):每两个像素共享一组U、V值,色度数据减半,常用于高质量视频。4:2:0(YUV420):每四个像素(2x2块)共享一组U、V值,色度数据只有1/4,常见于视频压缩(如H.264、JPEG),这种采样方式是最常用的,如 NV12 / NV21。通过亚采样,YUV格式显著减少数据量,适合视频编码和传输。

YUV 适用于视频编码和解码(如MPEG、H.264、H.265)、电视广播、流媒体、图像压缩格式(如JPEG)。

YUV与RGB的比较

特性 RGB YUV
颜色表示 红、绿、蓝三原色 亮度(Y)+色度(U、V)
数据量 较大(每个像素3字节) 可通过亚采样减少数据量(如4:2:0)
人眼感知 直接对应显示颜色 亮度优先,色度可压缩
应用场景 显示、图像编辑 视频压缩、传输、广播
转换复杂度 直接使用,无需转换 需要与RGB相互转换
压缩效率 较低 较高

YUV 转 RGB 有多种标准(BT.601、BT.709)。下面用 BT.601 标准(SD 视频):

假设 YUV 范围:

  • Y ∈ [0, 255]
  • U ∈ [0, 255]
  • V ∈ [0, 255]

转换公式:

\(\begin{cases} R = Y + 1.402 \times (V - 128) \\ G = Y - 0.344136 \times (U - 128) - 0.714136 \times (V - 128) \\ B = Y + 1.772 \times (U - 128) \end{cases}\)

注意:

  • 转换后需要 截断,保证 R/G/B ∈ [0, 255]。
  • 如果使用浮点计算,效果最好。整型近似也可,但会有误差。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun yuvToRgb(y: Int, u: Int, v: Int): Int {
val c = y - 16
val d = u - 128
val e = v - 128

var r = (1.164 * c + 1.596 * e).toInt()
var g = (1.164 * c - 0.392 * d - 0.813 * e).toInt()
var b = (1.164 * c + 2.017 * d).toInt()

r = r.coerceIn(0, 255)
g = g.coerceIn(0, 255)
b = b.coerceIn(0, 255)

return (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}

通过预计算 YUV 到 RGB 的映射表,可以在转换时直接查表,避免重复计算。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val yuvToRgbLut = Array(256) { Array(256) { Array(256) { 0 } } }

fun initYuvToRgbLut() {
for (y in 0..255) {
for (u in 0..255) {
for (v in 0..255) {
val r = (y + 1.402 * (v - 128)).toInt().coerceIn(0, 255)
val g = (y - 0.344136 * (u - 128) - 0.714136 * (v - 128)).toInt().coerceIn(0, 255)
val b = (y + 1.772 * (u - 128)).toInt().coerceIn(0, 255)
yuvToRgbLut[y][u][v] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}
}
}
}

使用现代CPU的SIMD(Single Instruction, Multiple Data,单指令多数据)指令集(如SSE、AVX、NEON)可以加速YUV到RGB的转换。每个像素的转换公式相同,适合SIMD的并行处理。图像数据通常是连续的像素数组,可以一次性加载多个像素到SIMD寄存器中。SIMD指令(如SSE处理128位,AVX处理256位)允许同时处理多个像素(例如,SSE一次处理4个像素的YUV值,AVX可处理8个)。SIMD指令集(如SSE、AVX、NEON)通过宽寄存器和专用指令(如加法、乘法、打包/解包)并行执行这些运算,从而显著提高性能。

YUV数据通常存储为连续的Y、U、V分量(例如,YUV420格式中Y是单独的平面,U和V是交错或分开的平面)。需要确保数据加载到SIMD寄存器时是内存对齐的(16字节对齐用于SSE,32字节对齐用于AVX),以避免性能损失。视频处理中,YUV数据通常是8位无符号整数(uint8_t),RGB输出也是8位。SIMD整数指令(如_mm_add_epi16、 _mm_madd_epi16)可以高效处理这些数据。如果需要更高的精度(如浮点系数),可以使用32位浮点SIMD指令(如_mm_mul_ps、 _mm_add_ps),但需要额外的类型转换。YUV的Y范围通常是16-235,U、V范围是16-240,需在转换前进行偏移校正(例如,Y = Y - 16)。

将Y、U、V值加载到SIMD寄存器后,使用向量乘法和加法指令并行计算。例如 SSE 使用_mm_mul_ps进行系数乘法,_mm_add_ps进行加法。一次处理4个(SSE)或8个(AVX)像素的Y、U、V值,生成对应的R、G、B值。由于不同的亚采样的 Y、U、V 分量的比例不同,需要分别处理。4:4:4格式:每个像素都有独立的Y、U、V值,直接加载并处理。4:2:0格式:需要对U、V值进行插值或重复,如使用SIMD的广播指令(如_mm_set1_epi16)将单个U、V值复制到多个像素的寄存器中。对于高质量转换,可以使用SIMD指令实现双线性插值,平滑U、V值的过渡。

RGB值需限制在0-255范围内。SIMD提供饱和指令(如SSE的_mm_packus_epi16或NEON的vqmovn_u16)以防止溢出。使用对齐存储指令(如_mm_store_si128或_mm256_store_si256)将结果写回内存。

以下是一个简化的SSE实现,用于4:4:4 YUV到RGB的转换(假设输入是8位整数,输出也是8位):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <emmintrin.h> // SSE2

void yuv_to_rgb_sse(uint8_t* yuv, uint8_t* rgb, int width, int height) {
for (int i = 0; i < width * height; i += 4) {
// 加载4个像素的Y、U、V
__m128i y = _mm_loadu_si128((__m128i*)(yuv + i * 3)); // Y0, Y1, Y2, Y3
__m128i u = _mm_loadu_si128((__m128i*)(yuv + i * 3 + 1)); // U0, U1, U2, U3
__m128i v = _mm_loadu_si128((__m128i*)(yuv + i * 3 + 2)); // V0, V1, V2, V3

// 偏移校正:Y -= 16, U -= 128, V -= 128
y = _mm_sub_epi16(y, _mm_set1_epi16(16));
u = _mm_sub_epi16(u, _mm_set1_epi16(128));
v = _mm_sub_epi16(v, _mm_set1_epi16(128));

// 计算R = Y + 1.140V
__m128i r = _mm_add_epi16(y, _mm_mullo_epi16(v, _mm_set1_epi16(1.140 * 256))); // 固定点运算

// 计算G = Y - 0.395U - 0.581V
__m128i g = _mm_sub_epi16(y, _mm_add_epi16(
_mm_mullo_epi16(u, _mm_set1_epi16(0.395 * 256)),
_mm_mullo_epi16(v, _mm_set1_epi16(0.581 * 256))
));

// 计算B = Y + 2.032U
__m128i b = _mm_add_epi16(y, _mm_mullo_epi16(u, _mm_set1_epi16(2.032 * 256)));

// 饱和到0-255
r = _mm_packus_epi16(r, r);
g = _mm_packus_epi16(g, g);
b = _mm_packus_epi16(b, b);

// 存储到RGB(交错存储:R0, G0, B0, R1, G1, B1, ...)
// 需额外处理交错存储逻辑
_mm_storeu_si128((__m128i*)(rgb + i * 3), r); // 简化,实际需交错
}
}

FFmpeg的libswscale库已高度优化YUV到RGB转换,支持SIMD(SSE、AVX、NEON)。

在 Android 平台上,可直接使用 OpenGL Shader 进行转换。这个方案适合适合 Android 相机预览或视频播放器,支持 NV12/NV21。它的原理是把 YUV 数据上传成纹理,通过 Shader 在 GPU 上做 YUV→RGB 转换,性能极高,几乎不占 CPU。

顶点 Shader 示例代码:

1
2
3
4
5
6
7
8
9
// vertex_shader.glsl
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;

void main() {
gl_Position = aPosition;
vTexCoord = aTexCoord;
}

片元 Shader(支持 NV21/NV12)示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// fragment_shader.glsl
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D yTexture;
uniform sampler2D uvTexture;
uniform int isNV21; // 0 = NV12, 1 = NV21

void main() {
float y = texture2D(yTexture, vTexCoord).r;
vec2 uv = texture2D(uvTexture, vTexCoord).ra; // R->U, A->V for two-channel texture

float u = uv.x - 0.5;
float v = if(isNV21 == 1) uv.x - 0.5 else uv.y - 0.5;

float r = y + 1.402 * v;
float g = y - 0.344136 * u - 0.714136 * v;
float b = y + 1.772 * u;

gl_FragColor = vec4(r, g, b, 1.0);
}
UV 通道根据上传纹理时 NV12/NV21 数据安排,如果是 NV21 需交换 U/V。

上传 YUV 纹理示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fun uploadYUVTextures(
yData: ByteArray, uvData: ByteArray, width: Int, height: Int
): IntArray {
val textures = IntArray(2)
GLES20.glGenTextures(2, textures, 0)

// Y 纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0])
GLES20.glTexImage2D(
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE,
width, height, 0, GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, ByteBuffer.wrap(yData)
)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)

// UV 纹理 (宽/2 x 高/2)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[1])
GLES20.glTexImage2D(
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE_ALPHA,
width / 2, height / 2, 0, GLES20.GL_LUMINANCE_ALPHA, GLES20.GL_UNSIGNED_BYTE,
ByteBuffer.wrap(uvData)
)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)

return textures
}

获取 RGB 数据示例代码:

1
2
3
4
5
6
7
fun getRGBData(textureId: Int, width: Int, height: Int): ByteArray {
val rgbData = ByteArray(width * height * 4) // RGBA
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, framebufferId)
GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, ByteBuffer.wrap(rgbData))
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
return rgbData
}

通过 GPU 加速,1080p/4K 的视频可以实现实时的 YUV 到 RGB 的转换,极大地提高了性能和效率。通过调整纹理上传,可以支持不同的 YUV 格式,例如 NV12、NV21、I420 等,灵活扩展。在 Android 平台上,这种方法可以与视频播放器和相机预览无缝集成。