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 知识体系结构图
下面这张图帮你理清整个转换流程和标准选择逻辑:
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。做到这四点,颜色转换基本不会出问题。