3、图片压缩进阶:调色板压缩(PNG8)、索引色模式、抖动算法
上一章我们聊了基础的无损压缩,说白了就是把文件里的冗余信息给挤掉。但嵌入式系统里,光靠这个远远不够。你想想看,一张 24 位真彩色图片,一个像素就要 3 个字节,320×240 的分辨率就是 225KB。对于只有几百 KB Flash 的单片机来说,这简直是灾难。
所以,我们得动真格的了——从颜色数量上开刀。这就是调色板压缩的核心思想。
3.1 从真彩色到索引色:一场颜色数量的革命
真彩色(True Color)用 24 位表示一个像素,RGB 各占 8 位,总共能显示 1600 万种颜色。听起来很美好,但嵌入式 GUI 根本不需要这么多。一个按钮、一个图标、一段文字,用 16 种颜色就够了。
索引色模式(Indexed Color)就是干这个的。它维护一个颜色表(调色板),每个像素只存一个索引值,指向调色板里的某个颜色。
| 颜色模式 | 每像素位数 | 调色板大小 | 320×240 大小 |
|---|---|---|---|
| 真彩色 24 位 | 24 | 无 | 225 KB |
| 索引色 8 位 | 8 | 256 色 | 75 KB + 768 字节 |
| 索引色 4 位 | 4 | 16 色 | 37.5 KB + 48 字节 |
| 索引色 1 位 | 1 | 2 色 | 9.4 KB + 6 字节 |
看到没?从 225KB 降到 37.5KB,压缩比 6:1。而且这还没算上 PNG 的额外压缩。我做过一个项目,一个 128×64 的单色 OLED 屏,用 1 位索引色,整张图片才 1KB 出头。这在单片机上是质的飞跃。
核心要点:索引色模式的核心是「用索引换空间」。颜色数量越少,每像素位数越低,存储空间越小。但代价是颜色精度下降。
3.2 PNG8:嵌入式 GUI 的黄金格式
PNG8 就是 8 位索引色的 PNG 格式。它最多支持 256 种颜色,配合调色板,在嵌入式领域几乎是万能格式。
为什么是 8 位?因为 8 位刚好一个字节,MCU 处理起来最方便。你想想看,如果每像素 4 位,你得做位运算去拼字节,处理起来麻烦不少。8 位直接按字节读取,效率最高。
PNG8 的压缩流程是这样的:
- 颜色量化:把 24 位真彩色图片的颜色数量降到 256 色以内。
- 调色板生成:选出最能代表原图的 256 种颜色,建立索引表。
- 像素映射:每个像素找到最接近的调色板颜色,记录索引值。
- PNG 编码:对索引数据做 Deflate 压缩,生成最终的 PNG8 文件。
我个人习惯用 pngquant 这个工具来做转换。它比 Photoshop 的「存储为 Web 格式」要智能得多。
# 将真彩色 PNG 转为 256 色 PNG8
pngquant --quality=65-80 --speed=1 --output=output.png input.png
# 转为 16 色(4 位索引)
pngquant --colors=16 --output=output_16.png input.png
我的经验:在嵌入式项目里,我一般先用 256 色,如果 Flash 空间吃紧,再降到 64 色或 16 色。但要注意,颜色越少,图片质量下降越明显。UI 图标可以降到 16 色,但照片类素材至少需要 128 色以上。
3.3 颜色量化算法:怎么选出最合适的颜色?
颜色量化不是随便挑 256 种颜色就完事了。它要保证选出来的颜色能最大程度还原原图。常用的算法有几种:
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 中位切分法 | 递归切分颜色立方体,使每个子立方体包含相同数量的像素 | 速度快,实现简单 | 颜色分布不均匀时效果差 |
| 八叉树量化 | 用八叉树结构统计颜色分布,合并叶子节点 | 颜色还原度高 | 内存占用大 |
| K-Means 聚类 | 迭代聚类,找到 K 个颜色中心 | 效果最好 | 速度慢,不适合实时处理 |
| 流行色算法 | 统计颜色直方图,取出现频率最高的颜色 | 简单粗暴 | 容易丢失细节颜色 |
我在实际项目中,大部分情况用中位切分法就够了。只有在处理渐变色图片时,才会切换到八叉树量化。K-Means 虽然效果好,但计算量太大,不适合在 MCU 上跑。
3.4 抖动算法:用视觉欺骗弥补颜色不足
好,现在问题来了。你把一张 1600 万色的照片降到 16 色,颜色断层会非常明显。原本平滑的渐变变成了一个个色块,看着就像马赛克。
这时候就需要抖动算法(Dithering)出场了。它的原理很简单:用有限颜色的像素排列,在视觉上模拟出更多颜色。
举个例子。你只有黑色和白色两种颜色,但想显示灰色。怎么办?黑白黑白交替排列,远看就是灰色。这就是抖动的基本思想。
常见的抖动算法有:
- 有序抖动(Ordered Dithering):用固定矩阵做阈值比较,速度快,适合 MCU。
- Floyd-Steinberg 抖动:把误差扩散到相邻像素,效果最好,但计算量大。
- 误差扩散抖动:Floyd-Steinberg 的变种,有 Sierra、Jarvis 等。
我画了一张图,帮你理清这些算法的关系:
3.5 Floyd-Steinberg 抖动:效果最好的误差扩散算法
Floyd-Steinberg 抖动是误差扩散算法里最经典的一个。它的核心思想是:把当前像素的颜色误差按一定比例扩散到右边和下边的像素。
误差分配比例是这样的:
当前像素 [X] → 右方 7/16
→ 左下 3/16
→ 下方 5/16
→ 右下 1/16
具体实现代码:
void floyd_steinberg_dither(uint8_t *image, int width, int height,
uint8_t *palette, int palette_size) {
int x, y, c;
int r_err, g_err, b_err;
int old_r, old_g, old_b;
int new_r, new_g, new_b;
int nearest;
for (y = 0; y < height; y++) {
for (x = 0; x < width; x++) {
// 读取当前像素
old_r = image[(y * width + x) * 3 + 0];
old_g = image[(y * width + x) * 3 + 1];
old_b = image[(y * width + x) * 3 + 2];
// 找到调色板里最接近的颜色
nearest = find_nearest_color(old_r, old_g, old_b,
palette, palette_size);
new_r = palette[nearest * 3 + 0];
new_g = palette[nearest * 3 + 1];
new_b = palette[nearest * 3 + 2];
// 写入索引值
image[(y * width + x)] = nearest;
// 计算误差
r_err = old_r - new_r;
g_err = old_g - new_g;
b_err = old_b - new_b;
// 扩散误差到相邻像素
// 右方 7/16
if (x + 1 < width) {
add_error(image, x+1, y, r_err*7/16, g_err*7/16, b_err*7/16);
}
// 左下 3/16
if (x - 1 >= 0 && y + 1 < height) {
add_error(image, x-1, y+1, r_err*3/16, g_err*3/16, b_err*3/16);
}
// 下方 5/16
if (y + 1 < height) {
add_error(image, x, y+1, r_err*5/16, g_err*5/16, b_err*5/16);
}
// 右下 1/16
if (x + 1 < width && y + 1 < height) {
add_error(image, x+1, y+1, r_err*1/16, g_err*1/16, b_err*1/16);
}
}
}
}
我曾经踩过的坑:在 STM32F4 上跑 Floyd-Steinberg 抖动,没注意误差扩散会修改原始像素数据。结果下一帧显示时,图片已经面目全非了。切记:抖动算法会破坏原始数据,一定要在副本上操作,或者一次性处理完就输出。
3.6 有序抖动:MCU 上的最佳选择
如果你要在 MCU 上实时做抖动,有序抖动是唯一的选择。它不需要误差扩散,只需要一个固定的 Bayer 矩阵做阈值比较。
4×4 的 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 ordered_dither(uint8_t gray, int x, int y, int bits) {
int threshold = bayer4[y & 3][x & 3];
int levels = 1 << bits;
int step = 256 / levels;
// 把灰度值映射到目标色阶
int quantized = (gray + threshold * step / 16) / step;
if (quantized >= levels) quantized = levels - 1;
return quantized;
}
这段代码在 Cortex-M0 上跑,处理一个像素只需要几十个时钟周期。我做过测试,320×240 的图片,有序抖动只需要 2ms 左右,而 Floyd-Steinberg 要 50ms 以上。
我的建议:在嵌入式 GUI 里,图标和按钮用有序抖动就够了。只有全屏背景图或者照片预览,才值得用 Floyd-Steinberg。而且我建议在 PC 端预处理,把抖动后的图片直接烧到 Flash 里,MCU 只管显示,不做实时抖动。
3.7 实战:用 Python 实现调色板压缩与抖动
最后,我分享一个我在项目中实际用过的 Python 脚本。它可以把任意图片转为 16 色索引色 PNG,并应用 Floyd-Steinberg 抖动。
import numpy as np
from PIL import Image
def convert_to_indexed(input_path, output_path, num_colors=16, dither=True):
# 打开图片
img = Image.open(input_path).convert('RGB')
# 颜色量化:用中位切分法找到最佳调色板
quantized = img.quantize(colors=num_colors, method=Image.MEDIANCUT)
if dither:
# 应用 Floyd-Steinberg 抖动
quantized = img.quantize(colors=num_colors,
method=Image.MEDIANCUT,
dither=Image.FLOYDSTEINBERG)
# 保存为 PNG8
quantized.save(output_path, format='PNG')
# 提取调色板
palette = quantized.getpalette()[:num_colors * 3]
return quantized, palette
# 使用示例
img, palette = convert_to_indexed('input.png', 'output_16.png',
num_colors=16, dither=True)
print(f"调色板颜色数: {len(palette) // 3}")
print(f"图片大小: {img.size}")
这个脚本我用了好几年,从 STM32F4 到 ESP32,从 16 色到 256 色,基本覆盖了大部分嵌入式 GUI 场景。你只需要根据 Flash 空间和显示效果,调整 num_colors 参数就行。
嗯,调色板压缩和抖动算法就聊到这里。说白了,就是用有限的颜色资源,通过算法「骗」过人的眼睛。在嵌入式系统里,这种「视觉欺骗」是必不可少的技能。