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 的压缩流程是这样的:

  1. 颜色量化:把 24 位真彩色图片的颜色数量降到 256 色以内。
  2. 调色板生成:选出最能代表原图的 256 种颜色,建立索引表。
  3. 像素映射:每个像素找到最接近的调色板颜色,记录索引值。
  4. 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 等。

我画了一张图,帮你理清这些算法的关系:

抖动算法分类与选择 抖动算法 有序抖动 误差扩散抖动 Bayer 矩阵 固定阈值比较 Floyd-Steinberg Sierra / Jarvis 选择建议: • MCU 性能有限 → 优先用有序抖动(Bayer 矩阵),计算量小,效果好 • 图片质量要求高 → 用 Floyd-Steinberg,但需要离线转换 • 实时显示场景 → 避免误差扩散,计算量太大 • 图标/按钮 → 可以不用抖动,直接取最近颜色

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 参数就行。

嗯,调色板压缩和抖动算法就聊到这里。说白了,就是用有限的颜色资源,通过算法「骗」过人的眼睛。在嵌入式系统里,这种「视觉欺骗」是必不可少的技能。