4、色彩空间转换:RGB565与RGB888的互转算法与查表优化

色彩空间转换,说白了就是让不同位深的颜色数据能互相“听懂”。

做显示驱动这些年,我遇到过最典型的场景:MCU 端采集的摄像头数据是 RGB565,但 OLED 屏的驱动 IC 只认 RGB888。不转?画面直接偏色到没法看。反过来,从 PC 端传过来的 24 位真彩图要往低端 LCD 上刷,也得砍成 16 位。

这一章,咱们就把 RGB565 和 RGB888 互转的算法讲透,再聊聊怎么用查表法把性能压榨到极致。

4.1 为什么需要互转?

先看两个常见场景:

  • 场景 A(升位):嵌入式摄像头输出 RGB565,但 OLED 屏需要 RGB888 驱动。直接填充低位会丢失细节。
  • 场景 B(降位):PC 端 24 位 BMP 图要刷到 16 位 LCD 上。直接截断高位,颜色过渡会出现明显断层。

我早期做一款智能手表时,就踩过降位的坑。当时图省事直接右移截断,结果天空渐变图出现了一圈一圈的色环,被测试同事追着改了一周。

核心原则:升位要“补”,降位要“扩”。补得不对会偏色,扩得不好会丢细节。

4.2 RGB565 与 RGB888 的位分布

先记住这两个格式的位宽分配:

格式 R 位宽 G 位宽 B 位宽 总位宽
RGB565 5 bit 6 bit 5 bit 16 bit
RGB888 8 bit 8 bit 8 bit 24 bit

你想想看,5 bit 能表示 32 级亮度,8 bit 能表示 256 级。从 32 级映射到 256 级,不是简单补零就能解决的。

4.3 升位算法:RGB565 → RGB888

最直接的做法是左移补零。比如 R 分量:

R8 = (R5 << 3) & 0xFF;

但这样有个问题:5 bit 的最大值 31(0x1F),左移 3 位变成 248(0xF8),离 255 还差 7。说白了,亮度范围没对齐。

正确的做法是“高位复制低位”:

R8 = (R5 << 3) | (R5 >> 2);
G8 = (G6 << 2) | (G6 >> 4);
B8 = (B5 << 3) | (B5 >> 2);

为什么这样?因为 5 bit 的高 3 位复制到低 3 位,刚好填满 8 bit。G 分量是 6 bit,需要复制高 2 位到低 2 位。

小技巧:我习惯把这种操作叫“镜像填充”。它能让 0x1F 映射到 0xFF,0x00 映射到 0x00,中间值也均匀分布。

4.4 降位算法:RGB888 → RGB565

降位比升位更敏感。直接右移截断,会丢失低位的细节信息。

简单截断(不推荐):

R5 = (R8 >> 3) & 0x1F;
G6 = (G8 >> 2) & 0x3F;
B5 = (B8 >> 3) & 0x1F;

这样做,颜色值 0x08 和 0x0F 都会被映射成 0x01,丢失了 4 级灰度差异。在渐变区域,肉眼能看出色块。

带舍入的截断(推荐):

R5 = ((R8 + 4) >> 3) & 0x1F;
G6 = ((G8 + 2) >> 2) & 0x3F;
B5 = ((B8 + 4) >> 3) & 0x1F;

加 4 或加 2 相当于四舍五入,能减少量化误差。我曾经在车载仪表盘项目里对比过这两种方式,带舍入的版本在色阶测试图上几乎看不出断层。

注意:降位时不要用查表法做“抖动”,除非你确定屏幕的刷新率和人眼响应能配合。否则,抖动带来的噪点比截断更难看。

4.5 查表优化:用空间换时间

实时视频流里,每个像素都做移位和或运算,CPU 开销不小。尤其是低端 MCU,一次转换可能吃掉 30% 的算力。

查表法,说白了就是提前算好所有可能的映射结果,运行时直接查。

4.5.1 升位查表

R 分量从 5 bit 到 8 bit,一共只有 32 种输入。提前建一个 32 字节的表:

const uint8_t r5_to_r8[32] = {
    0, 8, 16, 24, 33, 41, 49, 57,
    66, 74, 82, 90, 99, 107, 115, 123,
    132, 140, 148, 156, 165, 173, 181, 189,
    198, 206, 214, 222, 231, 239, 247, 255
};

G 分量 6 bit 到 8 bit,64 个条目:

const uint8_t g6_to_g8[64] = {
    0, 4, 8, 12, 16, 20, 24, 28,
    32, 36, 40, 44, 48, 52, 56, 60,
    65, 69, 73, 77, 81, 85, 89, 93,
    97, 101, 105, 109, 113, 117, 121, 125,
    130, 134, 138, 142, 146, 150, 154, 158,
    162, 166, 170, 174, 178, 182, 186, 190,
    195, 199, 203, 207, 211, 215, 219, 223,
    227, 231, 235, 239, 243, 247, 251, 255
};

运行时直接:

R8 = r5_to_r8[(pixel16 >> 11) & 0x1F];
G8 = g6_to_g8[(pixel16 >> 5) & 0x3F];
B8 = r5_to_r8[pixel16 & 0x1F];

没有移位运算,没有或操作,只有三次查表和三次赋值。我在 STM32F4 上实测,查表法比计算法快了 4 倍以上。

4.5.2 降位查表

降位时,R 和 B 从 256 种输入映射到 32 种输出,G 映射到 64 种。表的大小分别是 256 字节和 256 字节:

const uint8_t r8_to_r5[256] = {
    0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
    // ... 中间省略 ...
    31, 31, 31, 31, 31, 31, 31, 31
};

查表时:

R5 = r8_to_r5[R8];
G6 = g8_to_g6[G8];
B5 = r8_to_r5[B8];
pixel16 = (R5 << 11) | (G6 << 5) | B5;

性能对比(基于 Cortex-M4 @168MHz,1024×768 图像):

方法 升位耗时 降位耗时
计算法(移位+或) 38 ms 35 ms
查表法 9 ms 11 ms

4.6 查表法的内存权衡

查表法快,但吃内存。升位表只需要 32+64+32 = 128 字节,几乎可以忽略。降位表需要 256+256+256 = 768 字节,对大部分 MCU 来说也不大。

但如果你做的是双缓冲全帧转换,还要考虑临时缓冲区的开销。我建议:

  • RAM 小于 8 KB 的 MCU:只用升位查表,降位用带舍入的计算法。
  • RAM 大于 16 KB 的 MCU:全部用查表,表放在常量区(.rodata),不占 RAM。

我的习惯:把表定义成 const 并放在 Flash 里。这样既享受了查表速度,又不占用宝贵的 SRAM。

4.7 整体转换流程

下面这张图展示了从输入到输出的完整数据流:

RGB565 ↔ RGB888 转换流程 输入像素 判断转换方向 升位:565→888 降位:888→565 查 r5_to_r8 表 查 g6_to_g8 表 查 b5_to_b8 表 查 r8_to_r5 表 查 g8_to_g6 表 查 b8_to_b5 表 输出像素

4.8 避坑指南

最后,分享几个我踩过的坑:

  • 表没对齐:升位表用 const 声明时,别忘了加 __attribute__((aligned(4))),否则某些 MCU 的 DMA 传输会出问题。
  • 字节序搞反:RGB565 在内存中有小端和大端两种排列方式。我遇到过摄像头输出的是大端,但屏驱 IC 认小端,结果颜色全乱套。转换前先确认字节序。
  • Alpha 通道:如果源数据带 Alpha,别把 Alpha 当颜色分量转了。RGB565 没有 Alpha,转之前先剥离。
  • 查表越界:降位查表时,输入值范围是 0~255,表长度必须是 256。少一个元素,查到最后一位就崩了。

我曾经在一个量产项目里,因为降位表少写了最后一个元素,导致纯白像素(255,255,255)转换后变成黑色。整批屏幕在白色界面下出现随机黑点,排查了三天才定位到是表越界。从那以后,我写查表代码都会加静态断言检查表长度。

嗯,RGB565 与 RGB888 的互转,说难不难,但细节决定成败。查表优化是嵌入式显示领域最经典的“空间换时间”案例,掌握了它,你就能在性能和资源之间找到最佳平衡点。