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 整体转换流程
下面这张图展示了从输入到输出的完整数据流:
4.8 避坑指南
最后,分享几个我踩过的坑:
- 表没对齐:升位表用
const声明时,别忘了加__attribute__((aligned(4))),否则某些 MCU 的 DMA 传输会出问题。 - 字节序搞反:RGB565 在内存中有小端和大端两种排列方式。我遇到过摄像头输出的是大端,但屏驱 IC 认小端,结果颜色全乱套。转换前先确认字节序。
- Alpha 通道:如果源数据带 Alpha,别把 Alpha 当颜色分量转了。RGB565 没有 Alpha,转之前先剥离。
- 查表越界:降位查表时,输入值范围是 0~255,表长度必须是 256。少一个元素,查到最后一位就崩了。
我曾经在一个量产项目里,因为降位表少写了最后一个元素,导致纯白像素(255,255,255)转换后变成黑色。整批屏幕在白色界面下出现随机黑点,排查了三天才定位到是表越界。从那以后,我写查表代码都会加静态断言检查表长度。
嗯,RGB565 与 RGB888 的互转,说难不难,但细节决定成败。查表优化是嵌入式显示领域最经典的“空间换时间”案例,掌握了它,你就能在性能和资源之间找到最佳平衡点。