3、调色板优化:真彩色转索引色,颜色少了,视觉不能差

真彩色图(24位,1600万色)在嵌入式系统里就是个“存储杀手”。一张320×240的图片,不压缩直接存,要225KB。对于Flash通常只有几MB的MCU来说,这太奢侈了。

所以我们要做一件事:把1600万种颜色压缩到256种甚至更少。这就是调色板优化,也叫颜色量化。说白了,就是给图片“减色”,但减完还得让人眼看不出明显区别。

我早年做一个小型温控器项目,Flash只剩64KB,UI图片却要放30多张。当时硬着头皮用真彩色,结果固件塞不进去。后来老老实实做了调色板优化,每张图从200KB降到30KB,视觉上几乎没差别。嗯,从那以后我就再也没在嵌入式项目里用过真彩色。

3.1 为什么能减色?人眼的“弱点”

真彩色图里很多颜色,人眼根本分不清。比如一片蓝天,可能有几百种蓝色渐变,但你盯着看,感觉就是“蓝色”。

人眼对亮度变化敏感,对色度变化相对迟钝。所以我们可以把相近的颜色合并,用少数几个“代表色”来近似整张图。

这就是调色板优化的理论基础:用有限个颜色,通过合理的分布和抖动,欺骗人眼。

3.2 核心算法:中位切分法

颜色量化的算法有很多,但嵌入式场景下,我最推荐中位切分法(Median Cut)。它简单、高效、效果稳定。

算法思路很直观:

  1. 把所有像素的颜色看作三维空间(R、G、B)里的点。
  2. 找到颜色分布最“分散”的那个维度(比如红色范围最大)。
  3. 沿着这个维度,把所有颜色点按值排序,从中间切一刀,分成两个子立方体。
  4. 对每个子立方体重复上述过程,直到切出256个(或目标数量)子立方体。
  5. 每个子立方体里所有颜色的平均值,就是调色板里的一个颜色。

我画了一张流程图,帮你理解这个过程:

中位切分法流程图 输入真彩色图(24位) 统计颜色分布(RGB立方体) 找到最大范围维度(R/G/B) 中位切分 → 两个子立方体 达到目标色数(如256色)? 否,继续切分 是 生成调色板

关键点:中位切分法每次都在颜色最“分散”的维度上切,保证每个子立方体里的颜色数量大致相等。这样调色板里的颜色分布最合理,不会出现某些颜色被过度压缩、某些颜色又浪费的情况。

3.3 抖动:用“错觉”弥补颜色不足

调色板只有256色,但原图可能有几千种颜色。直接映射,会出现明显的色块和条纹,尤其是渐变区域。这时候就需要抖动(Dithering)。

抖动的原理很简单:用几个相邻像素的不同颜色,混合出人眼感觉的“中间色”。比如没有灰色,就用黑白像素交替排列,远处看就是灰色。

嵌入式里最常用的是Floyd-Steinberg抖动算法。它把当前像素的量化误差,按固定比例“扩散”到右边和下边的像素:

方向 扩散比例
当前像素右边 7/16
下一行左边 3/16
下一行正下方 5/16
下一行右边 1/16

我做过对比测试:同一张照片,256色不加抖动,人脸皮肤上全是色块;加了Floyd-Steinberg抖动,几乎看不出和原图的区别。代价只是多了几行代码,运行时间增加不到10%。

我的建议:在嵌入式GUI里,只要调色板颜色数少于256,就一定要开抖动。尤其是照片、渐变背景、图标阴影这些区域,抖动带来的视觉提升非常明显。

3.4 嵌入式实现:内存和速度的平衡

在PC上做颜色量化,随便用OpenCV几行代码搞定。但在MCU上,你得考虑内存和算力。

中位切分法需要统计所有像素的颜色分布。一张320×240的图,有76800个像素。每个像素3字节,就是230KB。很多MCU没有这么大RAM。

我的做法是降采样统计:

  • 不统计每个像素,而是每隔2-4个像素采样一次。
  • 颜色空间从256级量化到32级或64级。
  • 统计结果用直方图数组存储,而不是链表。

下面是我在一个Cortex-M4项目里用的代码片段:

// 降采样统计颜色直方图
#define HIST_BITS  5   // 32级量化
#define HIST_SIZE  (1 << (HIST_BITS * 3))  // 32*32*32 = 32768

uint32_t hist[HIST_SIZE] = {0};

void build_histogram(uint8_t *img, int w, int h, int step) {
    for (int y = 0; y < h; y += step) {
        for (int x = 0; x < w; x += step) {
            int idx = (y * w + x) * 3;
            int r = img[idx] >> (8 - HIST_BITS);
            int g = img[idx+1] >> (8 - HIST_BITS);
            int b = img[idx+2] >> (8 - HIST_BITS);
            int bin = (r << (HIST_BITS*2)) | (g << HIST_BITS) | b;
            hist[bin]++;
        }
    }
}

这样直方图数组只需要32768个uint32_t,约128KB。如果MCU还嫌大,可以把HIST_BITS降到4(16级量化),数组变成4096个,只要16KB。效果差别不大。

注意:降采样步长不要超过4。步长8以上,统计结果会严重偏离真实分布,导致调色板质量急剧下降。我曾经为了省时间设了步长16,结果生成的调色板完全不能用,人脸变成绿色。嗯,那次教训挺深刻的。

3.5 调色板排序:影响查找速度

调色板生成后,每个像素要找到最接近的调色板颜色。最简单的办法是遍历256色,计算欧氏距离。但76800个像素 × 256次比较,就是近2000万次运算。在低端MCU上,这能卡住好几秒。

我一般用八叉树(Octree)来加速查找。把调色板颜色建一棵八叉树,查找时从根节点往下走,最多8层就能找到最近色。复杂度从O(n)降到O(log n)。

如果不想写八叉树,还有一个更简单的办法:按亮度排序。把调色板颜色按亮度(0.299R + 0.587G + 0.114B)排序,查找时先估算目标像素的亮度,在附近几个颜色里找最近色。虽然不如八叉树精确,但代码量少,速度也快。

查找方法 速度 内存占用 代码复杂度
全遍历(256色) 慢 极低 极低
亮度排序+局部搜索 中 低 低
八叉树 快 中 中

3.6 实际效果:一张图省下80%空间

拿我最近做的一个智能家居面板项目举例。UI里有一张背景图,分辨率480×272,24位真彩色,原始大小约391KB。

经过调色板优化:

  • 颜色数降到256色(8位索引)
  • 加上调色板本身(256×3字节 = 768字节)
  • 总大小:480×272×1 + 768 ≈ 131KB
  • 压缩比:66%

如果降到16色(4位索引):

  • 总大小:480×272×0.5 + 48 ≈ 65KB
  • 压缩比:83%
  • 视觉上,16色加抖动,非专业人士基本看不出区别

总结一下:调色板优化是嵌入式GUI资源压缩里性价比最高的手段。中位切分法生成调色板,Floyd-Steinberg抖动弥补颜色不足,降采样统计解决内存问题。三步走完,图片体积能缩到原来的1/5到1/3,视觉质量几乎无损。