2. 常见嵌入式图片格式解析
嵌入式GUI开发中,图片格式的选择直接影响资源占用和显示效率。我见过不少团队在项目初期随便选个格式,结果后期内存爆了、解码太慢,改起来非常痛苦。今天咱们就把BMP、JPEG、PNG这三种最常见的格式掰开揉碎,看看它们到底是怎么存图片的。
核心观点:没有最好的格式,只有最合适的格式。BMP简单直接,JPEG压缩率高但有损,PNG无损且支持透明。理解它们的原理,你才能做出正确的选择。
2.1 BMP格式详解
BMP是Windows时代的老将了。它最大的特点就是——简单。说白了就是把像素数据原封不动地存下来,顶多加个文件头描述一下宽高和颜色深度。我最早做单片机GUI时,用的就是BMP,因为解码代码几十行就能搞定。
文件结构三件套
一个标准的BMP文件由三部分组成:文件头、信息头、像素数据。咱们一个一个看。
| 组成部分 | 大小 | 关键字段 | 说明 |
|---|---|---|---|
| BMP文件头 | 14字节 | bfType, bfSize, bfOffBits | 标识"BM"、文件总大小、数据偏移 |
| 位图信息头 | 40字节 | biWidth, biHeight, biBitCount | 图像宽高、颜色深度、压缩方式 |
| 像素数据 | 可变 | RGB/BGR像素阵列 | 从下到上、从左到右存储 |
避坑提醒:BMP的像素数据是从左下角开始存储的!也就是说,文件里的第一行像素对应的是图片的最下面一行。我曾经在移植一个图片显示驱动时,忘了这个细节,结果图片上下颠倒显示了一个星期才找到原因。
来看一个实际的BMP文件头解析代码。我习惯用结构体来映射文件布局,这样读起来最直观:
// BMP文件头结构体(14字节)
typedef struct {
uint16_t bfType; // 固定为0x4D42,即"BM"
uint32_t bfSize; // 整个BMP文件的大小(字节)
uint16_t bfReserved1; // 保留,必须为0
uint16_t bfReserved2; // 保留,必须为0
uint32_t bfOffBits; // 从文件头到像素数据的偏移量
} __attribute__((packed)) BMPFileHeader;
// 位图信息头结构体(40字节)
typedef struct {
uint32_t biSize; // 本结构体大小,固定40
int32_t biWidth; // 图像宽度(像素)
int32_t biHeight; // 图像高度(像素),正数表示倒置
uint16_t biPlanes; // 颜色平面数,固定为1
uint16_t biBitCount; // 每像素位数:1/4/8/16/24/32
uint32_t biCompression; // 压缩方式:0=不压缩
uint32_t biSizeImage; // 像素数据大小(字节)
int32_t biXPelsPerMeter; // 水平分辨率
int32_t biYPelsPerMeter; // 垂直分辨率
uint32_t biClrUsed; // 实际使用的颜色数
uint32_t biClrImportant; // 重要颜色数
} __attribute__((packed)) BMPInfoHeader;
这里有个细节要注意:__attribute__((packed)) 告诉编译器不要做内存对齐。如果不加这个,结构体成员之间可能会有填充字节,读出来的数据就全乱了。嗯,嵌入式开发里这种坑特别多。
像素数据的存储规则
24位BMP每个像素用3个字节表示,顺序是BGR(蓝、绿、红),不是我们熟悉的RGB。32位BMP则多了一个Alpha通道,顺序是BGRA。
还有一个容易忽略的点——行对齐。BMP要求每行像素数据的字节数必须是4的倍数。如果不够,会在行尾补0。计算公式如下:
// 计算每行实际占用的字节数(含对齐填充)
uint32_t rowSize = ((biBitCount * biWidth + 31) / 32) * 4;
// 或者更直观的写法:
uint32_t rowSize = (biWidth * (biBitCount / 8) + 3) & ~3;
注意:如果你直接按 biWidth * (biBitCount/8) 来读取每行数据,遇到宽度不是4的倍数时,读出来的像素会错位。我见过有人因为这个bug,图片显示出来是一道一道的斜纹,排查了两天才发现是行对齐的问题。
2.2 JPEG格式原理
JPEG和BMP完全是两个世界。BMP是"所见即所得",JPEG则是"能省就省"。它利用人眼对高频细节不敏感的特点,丢掉一部分人眼察觉不到的信息,从而实现极高的压缩比。
JPEG的压缩流程可以概括为三个步骤:离散余弦变换(DCT)→ 量化 → 霍夫曼编码。咱们一个一个说。
离散余弦变换(DCT)
JPEG先把图像分成8×8的小块,然后对每个块做DCT变换。DCT的作用是把空间域的像素值转换到频率域。变换后得到一个8×8的系数矩阵,左上角是低频分量(人眼敏感),右下角是高频分量(人眼不敏感)。
公式我就不写了,网上一搜一大把。你只需要记住:DCT本身是无损的,真正的"有损"发生在下一步。
量化——压缩的关键
量化是JPEG唯一的有损步骤。它用一个量化表去除以DCT系数,然后取整。高频分量的量化值通常很大,除完以后很多系数直接变成0。这就是JPEG能大幅压缩的原因。
标准JPEG库提供了两张量化表——一张给亮度用,一张给色度用。亮度表对高频更宽容,因为人眼对亮度变化更敏感:
| 亮度量化表(部分) | 色度量化表(部分) |
|---|---|
| 16, 11, 10, 16, 24, 40, 51, 61 | 17, 18, 24, 47, 99, 99, 99, 99 |
| 12, 12, 14, 19, 26, 58, 60, 55 | 18, 21, 26, 66, 99, 99, 99, 99 |
| 14, 13, 16, 24, 40, 57, 69, 56 | 24, 26, 56, 99, 99, 99, 99, 99 |
| 14, 17, 22, 29, 51, 87, 80, 62 | 47, 66, 99, 99, 99, 99, 99, 99 |
你想想看,色度量化表右下角全是99,这意味着高频色度信息几乎全被干掉了。但人眼就是看不出来,这就是JPEG的聪明之处。
经验之谈:在嵌入式设备上解码JPEG,如果MCU没有硬件DCT加速器,纯软件解码会非常慢。我做过一个项目,用STM32F4软解一张800×480的JPEG图片,花了将近2秒钟。后来换成硬件JPEG解码器,直接降到50毫秒。所以,如果你的产品需要频繁显示JPEG图片,一定要选带硬件解码的芯片。
霍夫曼编码
量化之后,系数矩阵变得很稀疏(很多0)。JPEG用Zig-Zag扫描把这些系数排成一维序列,然后用游程编码(RLE)和霍夫曼编码进一步压缩。
霍夫曼编码的原理很简单:出现频率高的数据用短码字,出现频率低的用长码字。JPEG标准里预定义了两套霍夫曼表(DC表和AC表),也可以根据图像内容自定义。不过嵌入式场景下,用标准表就够了,省去建表的开销。
2.3 PNG格式原理
PNG是BMP的"进化版"。它保留了BMP的完整像素信息(无损),但通过行过滤和DEFLATE压缩把文件体积大幅缩小。而且PNG支持Alpha通道透明,这在UI开发中几乎是刚需。
行过滤——让数据更"好压"
PNG在压缩之前,先对每一行像素做一次"过滤"。过滤的目的是让相邻像素之间的差值变小,这样后续的DEFLATE压缩效率更高。
PNG定义了5种过滤模式:
| 过滤类型 | 名称 | 计算方法 | 适用场景 |
|---|---|---|---|
| 0 | None | 不处理,原始数据 | 随机噪声图像 |
| 1 | Sub | 当前像素 - 左边像素 | 水平渐变图像 |
| 2 | Up | 当前像素 - 上方像素 | 垂直渐变图像 |
| 3 | Average | 当前像素 - (左+上)/2 | 平滑过渡图像 |
| 4 | Paeth | 当前像素 - Paeth预测值 | 复杂纹理图像 |
Paeth预测器稍微复杂一点。它用左边、上边、左上角三个像素做线性预测,取最接近真实值的那个。我当年第一次看Paeth算法时觉得挺玄乎,后来发现其实就是个简单的边界检测器。
// Paeth预测器实现
uint8_t PaethPredictor(uint8_t a, uint8_t b, uint8_t c) {
// a = 左边像素, b = 上方像素, c = 左上角像素
int16_t p = a + b - c; // 初始预测值
int16_t pa = abs(p - a); // 与左边的差异
int16_t pb = abs(p - b); // 与上边的差异
int16_t pc = abs(p - c); // 与左上角的差异
// 返回差异最小的那个
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
注意:PNG解码时,每一行的第一个字节表示该行使用的过滤类型。解码器必须按行逐像素还原,不能跳着读。我曾经优化解码速度时想并行处理多行,结果发现过滤依赖上一行的数据,根本没法并行——除非你把整张图先解压到内存里。
DEFLATE压缩——核心引擎
过滤之后的数据会送入DEFLATE压缩器。DEFLATE是PNG的"心脏",它结合了LZ77算法和霍夫曼编码。
LZ77的原理是:在已经处理过的数据中寻找重复的字符串,用"距离+长度"的引用代替重复内容。比如"ABCDABCD"可以压缩成"ABCD(4,4)",意思是向前4个字节复制4个字节。
LZ77处理完后,再用霍夫曼编码对结果进行二次压缩。这个组合拳打下来,PNG的压缩率通常能达到BMP的2~5倍,而且完全无损。
关键数据:一张640×480的32位BMP图片大约1.17MB,同样的内容存成PNG通常只有200~400KB。如果图片有大面积纯色区域(比如UI按钮),PNG甚至能压到几十KB。
嵌入式PNG解码的注意事项
在资源受限的MCU上解码PNG,有几个地方要特别留意:
- 内存开销大:DEFLATE解压需要维护一个32KB的滑动窗口。如果你的MCU只有64KB RAM,光这个窗口就占了一半。
- 逐行处理:PNG是按行压缩的,解压时也是一行一行来。你可以边解压边显示,不用等整张图都解完。这个特性对嵌入式很友好。
- Alpha通道:PNG的透明信息存在额外的Alpha通道里。如果你的LCD不支持Alpha混合,需要软件做合成,这会消耗不少CPU。
我个人建议,在资源紧张的嵌入式设备上,尽量用PNG8(8位索引色)代替PNG32。PNG8只有256种颜色,文件更小,解码更快,而且同样支持Alpha透明。对于UI图标来说,256色完全够用。
好了,三种格式的原理就讲到这里。BMP是基础,JPEG是"有损压缩"的代表,PNG是"无损压缩"的标杆。理解它们各自的优缺点,你在做嵌入式GUI选型时就能少走很多弯路。