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再右移3位,效果会好一些。代价是多一次加法运算,对现代MCU来说几乎没影响。

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传输,那才是真正考验系统性能的地方。