4、色彩深度与格式:RGB565/RGB888格式转换、颜色空间、抖动算法
好,咱们今天聊点实在的。色彩深度和格式转换,这几乎是每个做LCD驱动的人都会碰到的坎。我记得刚入行那会儿,总觉得不就是把颜色数据倒腾一下嘛,能有多难?结果第一次在项目里做图片显示,颜色偏得离谱,客户直接打回来重做。从那以后,我再也不敢小看这个环节了。
4.1 色彩深度:你的屏到底能显示多少种颜色?
先说说色彩深度。说白了,就是每个像素用多少个bit来表示颜色。这个数字直接决定了你的屏幕能显示多少种颜色。
- 1-bit:黑白屏,每个像素要么黑要么白。现在基本见不到了。
- 8-bit:256色,老式手机那种效果。我早期做的一个低端手持设备就用过,显示图标还行,照片就惨不忍睹。
- 16-bit (RGB565):这是嵌入式领域的常青树。红色5位、绿色6位、蓝色5位,总共2^16=65536色。
- 18-bit (RGB666):每个颜色6位,共262144色。有些中端屏用这个。
- 24-bit (RGB888):每个颜色8位,总共1677万色。这是目前的主流,手机、平板基本都是这个级别。
你可能会问,为什么绿色要多一位?嗯,人眼对绿色最敏感,所以RGB565里绿色占了6位,这样视觉上会更自然。这是有科学依据的,不是我瞎编的。
4.2 RGB565与RGB888格式转换
这是实际项目里最常遇到的场景。你的MCU或者GPU可能只支持RGB888,但你的LCD屏驱动IC只吃RGB565。或者反过来。怎么办?硬转呗。
4.2.1 RGB888 → RGB565(降位宽)
这个转换是有损的。因为你要把8位的数据砍成5位或6位。最简单的做法就是直接截断低位:
// RGB888 to RGB565 直接截断
uint16_t rgb888_to_rgb565(uint8_t r, uint8_t g, uint8_t b) {
uint16_t r5 = (r >> 3) & 0x1F; // 取高5位
uint16_t g6 = (g >> 2) & 0x3F; // 取高6位
uint16_t b5 = (b >> 3) & 0x1F; // 取高5位
return (r5 << 11) | (g6 << 5) | b5;
}
这样做的好处是快,一个移位加一个或运算就搞定了。但坏处也很明显——颜色精度丢失。举个例子,纯红色(255,0,0)转成RGB565后,再转回RGB888,会变成(248,0,0)。肉眼可能看不出来,但如果你做渐变或者图像处理,色阶断层就会很明显。
4.2.2 RGB565 → RGB888(升位宽)
这个转换是无损的,但需要做数据扩展。你不能简单地在低位补0,那样颜色会偏暗。正确的做法是低位复制高位:
// RGB565 to RGB888 低位复制高位
void rgb565_to_rgb888(uint16_t color, uint8_t *r, uint8_t *g, uint8_t *b) {
uint8_t r5 = (color >> 11) & 0x1F;
uint8_t g6 = (color >> 5) & 0x3F;
uint8_t b5 = color & 0x1F;
*r = (r5 << 3) | (r5 >> 2); // 高3位复制到低3位
*g = (g6 << 2) | (g6 >> 4); // 高2位复制到低2位
*b = (b5 << 3) | (b5 >> 2);
}
为什么要这样做?你想想看,5位的最大值是31,对应8位的255。31左移3位是248,离255还差7。把高3位复制到低位,就变成了248+7=255,完美映射。这个技巧在很多图像处理库里都能看到。
4.3 颜色空间:不只是RGB
说到颜色空间,很多人第一反应就是RGB。没错,LCD屏本身就是RGB三原色发光的,所以RGB是最直接的表示方式。但实际项目中,我们还会遇到其他颜色空间。
4.3.1 常见的颜色空间
| 颜色空间 | 说明 | 应用场景 |
|---|---|---|
| RGB | 红绿蓝三通道 | LCD显示、图像传感器 |
| YCbCr | 亮度+蓝色差+红色差 | 视频压缩、JPEG |
| HSV/HSL | 色相+饱和度+明度 | 图像处理、颜色选择器 |
| CMYK | 青+品+黄+黑 | 印刷领域 |
在手持终端里,最常见的非RGB颜色空间就是YCbCr。为什么?因为摄像头输出的原始数据很多就是YUV格式(YCbCr的变种)。你要在LCD上显示摄像头画面,就得做YUV到RGB的转换。
避坑指南:我曾经在一个项目里直接用了浮点运算做YUV转RGB,结果帧率直接掉到个位数。后来改成查表法,速度提升了10倍。记住,在嵌入式设备上,能查表就别计算,能用整数就别用浮点。
4.4 抖动算法:用有限的颜色骗过眼睛
好,终于到了我最想聊的部分——抖动算法。这东西说白了就是:你的屏只能显示65536种颜色,但你想让它看起来像1677万色。怎么办?骗眼睛。
人眼有个特性,对空间上相邻的像素颜色会做平均处理。抖动算法就是利用这个特性,用几个不同颜色的像素排列组合,模拟出中间色。
4.4.1 有序抖动(Ordered Dithering)
这是最简单的一种抖动。用一个固定的阈值矩阵,和像素值做比较,决定是显示亮色还是暗色。最经典的是Bayer矩阵:
// 2x2 Bayer矩阵
const uint8_t bayer2[2][2] = {
{0, 2},
{3, 1}
};
// 4x4 Bayer矩阵
const uint8_t bayer4[4][4] = {
{ 0, 8, 2, 10},
{12, 4, 14, 6},
{ 3, 11, 1, 9},
{15, 7, 13, 5}
};
// 应用抖动
uint8_t dither_pixel(uint8_t pixel, uint8_t x, uint8_t y) {
uint8_t threshold = bayer4[y & 3][x & 3];
// 将阈值从[0,15]映射到[0,255]
threshold = (threshold * 255) / 16;
return (pixel > threshold) ? 255 : 0;
}
这个算法的好处是速度快,每个像素只需要一次查表和一次比较。坏处是会产生固定的纹理图案,在某些场景下比较明显。
4.4.2 误差扩散抖动(Error Diffusion Dithering)
这个就高级一些了。它会把当前像素的量化误差,按一定比例扩散到周围的像素上。最著名的是Floyd-Steinberg算法:
// Floyd-Steinberg 误差扩散
void floyd_steinberg(uint8_t *image, int width, int height) {
int x, y;
for (y = 0; y < height; y++) {
for (x = 0; x < width; x++) {
uint8_t old_pixel = image[y * width + x];
uint8_t new_pixel = (old_pixel > 128) ? 255 : 0;
image[y * width + x] = new_pixel;
int error = old_pixel - new_pixel;
// 向右扩散 7/16
if (x + 1 < width)
image[y * width + x + 1] += error * 7 / 16;
// 向左下扩散 3/16
if (x > 0 && y + 1 < height)
image[(y + 1) * width + x - 1] += error * 3 / 16;
// 向下扩散 5/16
if (y + 1 < height)
image[(y + 1) * width + x] += error * 5 / 16;
// 向右下扩散 1/16
if (x + 1 < width && y + 1 < height)
image[(y + 1) * width + x + 1] += error * 1 / 16;
}
}
}
效果比有序抖动好很多,没有固定的纹理图案。但代价是计算量大,而且只能从左到右、从上到下扫描,不适合并行处理。
注意:误差扩散算法在处理图像边缘时要注意边界检查,否则数组越界会导致程序崩溃。我曾经在这个坑里栽过跟头,调试了一下午才发现是边界没处理好。
4.4.3 实际项目中的选择
我个人习惯是这样选的:
- 如果MCU性能很弱(比如Cortex-M0),用有序抖动,速度快,效果也还行。
- 如果MCU性能中等(Cortex-M3/M4),用Floyd-Steinberg,效果更好。
- 如果MCU有DSP或者硬件加速,可以考虑更复杂的算法,比如Stucki或者Jarvis-Judice-Ninke。
嗯,这里要注意一点。抖动算法不是万能的。如果你的原始图像颜色深度就很低(比如8位256色),再怎么抖也出不来照片级的效果。所以,能保留原始色彩深度就尽量保留,抖动只是最后的补救手段。
好了,关于色彩深度和格式转换,今天就聊这么多。下一节我们会讲帧缓冲管理和DMA传输,那才是真正考验系统性能的地方。