4. RGB与YUV转换:ITU-R BT.601标准、ITU-R BT.709标准、转换矩阵推导、Python实现

好,咱们进入正题。RGB和YUV的转换,说白了就是给颜色换了个坐标系。RGB是给显示器看的,YUV是给人眼和压缩算法看的。我刚开始做视频处理时,总觉得这俩不就是个矩阵乘法嘛,有啥难的?直到有一次在嵌入式设备上跑转换,颜色全偏了,排查了一整天……嗯,原来是标准选错了。

这一节,咱们就把两个最主流的转换标准——BT.601和BT.709——彻底讲透。包括矩阵怎么来的、代码怎么写、坑在哪。

4.1 为什么需要两个标准?

你可能会问:一个转换公式不够用吗?

答案很简单:因为显示设备不一样。

  • BT.601:针对标准清晰度(SD,Standard Definition),比如老式DVD、标清电视。它的色彩空间基于CRT荧光粉特性。
  • BT.709:针对高清晰度(HD,High Definition),比如蓝光、高清电视、现代显示器。它的色域更宽,亮度权重也不同。

我个人习惯:只要不确定源素材是标清还是高清,一律按BT.709处理。因为现在绝大多数内容都是高清,而且709的兼容性更好。但如果你在做老视频修复项目,BT.601就绕不开了。

核心区别一句话:BT.601的绿色权重更高(因为老CRT的绿色荧光粉效率低,需要补偿),BT.709更接近人眼对亮度的真实感知。

4.2 转换矩阵推导——从RGB到YUV

先别急着背公式。咱们从物理意义出发,把矩阵推导一遍。

Y(亮度)是RGB三通道的加权和。权重怎么定?

人眼对绿色最敏感,对蓝色最不敏感。所以Y的公式大致是:

Y = a * R + b * G + c * B

其中 a + b + c = 1,且 b > a > c。

那U和V呢?它们是色差信号:

U = B - Y
V = R - Y

但直接这么用,动态范围会超出[0, 1]区间,所以需要缩放和偏移。最终标准化的矩阵形式是:

BT.601 矩阵(SD)

分量 R G B 偏移
Y 0.299 0.587 0.114 0
U (Cb) -0.169 -0.331 0.500 128
V (Cr) 0.500 -0.419 -0.081 128

BT.709 矩阵(HD)

分量 R G B 偏移
Y 0.213 0.715 0.072 0
U (Cb) -0.117 -0.386 0.503 128
V (Cr) 0.511 -0.428 -0.083 128

我的小技巧:记不住系数没关系。你只需要记住BT.709的绿色权重是0.715,比BT.601的0.587高出一截。这就是高清标准更接近人眼感知的关键。

4.3 知识体系结构图

下面这张图帮你理清整个转换流程和标准选择逻辑:

RGB ↔ YUV 转换知识体系 RGB 像素数据 BT.601 标准 标清 (SD) · 老式设备 BT.709 标准 高清 (HD) · 现代设备 Y = 0.299R + 0.587G + 0.114B U = -0.169R - 0.331G + 0.500B + 128 Y = 0.213R + 0.715G + 0.072B U = -0.117R - 0.386G + 0.503B + 128 YUV 数据 Y: 亮度 · U/V: 色差 ⚠ 选择标准时:源素材分辨率 ≤ 720×576 用 BT.601,否则用 BT.709 不确定时默认 BT.709,兼容性更好

4.4 Python实现——手写转换函数

理论讲完了,上代码。我个人习惯把转换封装成独立的函数,方便单元测试和复用。

import numpy as np

def rgb_to_yuv_bt601(rgb):
    """
    RGB to YUV 转换 (BT.601标准)
    输入: rgb (H, W, 3) 或 (N, 3) 数组,值范围 [0, 255]
    输出: yuv (H, W, 3) 或 (N, 3) 数组,Y范围 [0, 255], U/V范围 [0, 255]
    """
    rgb = rgb.astype(np.float32)
    
    # 转换矩阵 (BT.601)
    mat = np.array([
        [ 0.299,  0.587,  0.114],
        [-0.169, -0.331,  0.500],
        [ 0.500, -0.419, -0.081]
    ])
    
    yuv = np.dot(rgb, mat.T)
    yuv[:, :, 0] = yuv[:, :, 0]           # Y 不需要偏移
    yuv[:, :, 1] = yuv[:, :, 1] + 128.0   # U 加偏移
    yuv[:, :, 2] = yuv[:, :, 2] + 128.0   # V 加偏移
    
    return np.clip(yuv, 0, 255).astype(np.uint8)

def rgb_to_yuv_bt709(rgb):
    """
    RGB to YUV 转换 (BT.709标准)
    """
    rgb = rgb.astype(np.float32)
    
    mat = np.array([
        [ 0.213,  0.715,  0.072],
        [-0.117, -0.386,  0.503],
        [ 0.511, -0.428, -0.083]
    ])
    
    yuv = np.dot(rgb, mat.T)
    yuv[:, :, 0] = yuv[:, :, 0]
    yuv[:, :, 1] = yuv[:, :, 1] + 128.0
    yuv[:, :, 2] = yuv[:, :, 2] + 128.0
    
    return np.clip(yuv, 0, 255).astype(np.uint8)

我的习惯:用np.dot做矩阵乘法,比手写三层循环快两个数量级。处理1080p视频时,这点优化能省下好几秒。

4.5 YUV转RGB——逆矩阵

有正向就有逆向。YUV转RGB其实就是求逆矩阵。不过要注意,偏移量要先减掉再乘矩阵。

def yuv_to_rgb_bt601(yuv):
    """
    YUV to RGB 转换 (BT.601标准)
    """
    yuv = yuv.astype(np.float32)
    
    # 先去掉U/V的偏移
    yuv[:, :, 1] -= 128.0
    yuv[:, :, 2] -= 128.0
    
    # 逆矩阵 (BT.601)
    inv_mat = np.array([
        [1.000,  0.000,  1.403],
        [1.000, -0.344, -0.714],
        [1.000,  1.773,  0.000]
    ])
    
    rgb = np.dot(yuv, inv_mat.T)
    return np.clip(rgb, 0, 255).astype(np.uint8)

我曾经踩过的坑:逆矩阵的系数不是简单取倒数!一定要用线性代数求逆,或者直接用标准文档里的系数。我见过有人把正向矩阵的每个元素取倒数当逆矩阵用,结果颜色完全乱套。

4.6 实战对比——两个标准的效果差异

咱们用一组典型颜色来看看两个标准的差异:

RGB输入 BT.601 Y BT.709 Y 差异
纯红 (255,0,0) 76 54 BT.709更暗,因为红色权重更低
纯绿 (0,255,0) 150 182 BT.709更亮,绿色权重更高
纯蓝 (0,0,255) 29 18 BT.709更暗,蓝色权重更低
灰色 (128,128,128) 128 128 无差异,中性灰不受影响

看到了吧?纯绿在BT.709下亮度高出32个灰度级。这就是为什么高清视频的绿色看起来更「通透」——其实不是错觉,是数学决定的。

4.7 避坑指南

最后分享几个我实际项目中遇到的坑:

  • 数据范围搞混:有些库用[0,1]浮点,有些用[0,255]整数。矩阵系数是按[0,1]推导的,如果用[0,255]输入,记得先归一化,或者把矩阵系数乘以255。
  • U/V偏移忘加:正向转换时U和V要加128,逆向要先减128。我见过有人只加不减,结果图像整体偏蓝。
  • 标准混用:用BT.601的矩阵处理高清素材,或者反过来。颜色会偏,而且不是整体偏,是某些颜色偏得离谱。
  • 整数溢出:转换过程中中间值可能超出[0,255],一定要做clip。我早期代码没加clip,结果某些高饱和颜色出现「反色」效果。

一句话总结:RGB转YUV,选对标准、用对矩阵、别忘了偏移和clip。做到这四点,颜色转换基本不会出问题。