3、调色板优化:真彩色转索引色,颜色少了,视觉不能差
真彩色图(24位,1600万色)在嵌入式系统里就是个“存储杀手”。一张320×240的图片,不压缩直接存,要225KB。对于Flash通常只有几MB的MCU来说,这太奢侈了。
所以我们要做一件事:把1600万种颜色压缩到256种甚至更少。这就是调色板优化,也叫颜色量化。说白了,就是给图片“减色”,但减完还得让人眼看不出明显区别。
我早年做一个小型温控器项目,Flash只剩64KB,UI图片却要放30多张。当时硬着头皮用真彩色,结果固件塞不进去。后来老老实实做了调色板优化,每张图从200KB降到30KB,视觉上几乎没差别。嗯,从那以后我就再也没在嵌入式项目里用过真彩色。
3.1 为什么能减色?人眼的“弱点”
真彩色图里很多颜色,人眼根本分不清。比如一片蓝天,可能有几百种蓝色渐变,但你盯着看,感觉就是“蓝色”。
人眼对亮度变化敏感,对色度变化相对迟钝。所以我们可以把相近的颜色合并,用少数几个“代表色”来近似整张图。
这就是调色板优化的理论基础:用有限个颜色,通过合理的分布和抖动,欺骗人眼。
3.2 核心算法:中位切分法
颜色量化的算法有很多,但嵌入式场景下,我最推荐中位切分法(Median Cut)。它简单、高效、效果稳定。
算法思路很直观:
- 把所有像素的颜色看作三维空间(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,视觉质量几乎无损。