一张 JPEG 或 PNG 文件在磁盘上是一段按特定语法排列的字节。要把这段字节变成屏幕上可以显示的像素,需要经过一条由若干步骤组成的解码管线。管线的输入是压缩码流,输出是位于某个色彩空间、某个位深、按特定通道顺序排列的像素数组。
解码器并不关心文件从哪里来,它只关心字节的语法:先识别文件类型,再按该类型定义的容器结构定位压缩数据,然后熵解码、反变换、组装成像素。理解这条管线可以解释两个常见的现象:同一张图片在不同工具中显示出的颜色可能不一致,以及打开大图时内存占用为何会明显高于文件体积。
文件头识别与容器、编码的分离
多数图像格式以固定长度的魔数开头,解码器读取前若干字节与已知签名比对即可确定格式:
- PNG:
89 50 4E 47 0D 0A 1A 0A - JPEG:以
FF D8起始,以FF D9结束 - GIF:ASCII 文本
GIF87a或GIF89a - WebP:
RIFF之后跟WEBP
识别出格式之后,还要区分「容器」与「编码」两层。JPEG 的容器是一串以 FF 开头的标记段,其中承载压缩数据的是 SOS 之后的熵编码码流;PNG 采用长度—类型—数据—校验四段式的块结构,IDAT 块承载 zlib 压缩后的扫描行数据。容器回答「数据在哪、有多大、属于哪一部分」,编码回答「这些数据如何还原成样本」。关于扫描的组织方式,可参见渐进式 JPEG 与交错 PNG 的编码原理。
/* JPEG 标记段遍历:标记 2 字节 + 长度 2 字节(长度字段不含标记本身) */
while (pos < len) {
if (data[pos] != 0xFF) { pos++; continue; } /* 填充字节 */
uint8_t marker = data[pos + 1];
if (marker == 0xD8 || marker == 0xD9) { pos += 2; continue; } /* SOI / EOI */
uint16_t seg_len = ((uint16_t)data[pos + 2] << 8) | data[pos + 3];
handle_segment(marker, data + pos + 4, seg_len - 2);
pos += 2 + seg_len;
}
熵解码与逆量化
JPEG 的压缩数据先经霍夫曼编码(基线)或算术编码,配合游程编码把量化后的 DCT 系数写成变长码。熵解码把这些码还原成系数块,块尺寸通常为 8×8。随后进行逆量化:把系数乘以量化表中的对应值,再做逆离散余弦变换,得到空间域样本。
逆量化是误差已经固化的环节。量化发生在编码端,解码端只能按同一张量化表还原,误差无法消除,因此反复「另存为 JPEG」会累积新的量化误差。
PNG 一侧的路径不同。IDAT 数据经 zlib 解压后得到的是滤波后的扫描行,解码器要按行识别滤波器类型并做逆滤波。PNG 定义了 Sub、Up、Average、Paeth 等滤波器以提高压缩率,逆滤波依赖上一行或左侧样本,必须按行顺序推进,因此这一环节不易任意并行。
从 YCbCr 到 RGB
JPEG 的样本通常存放在 YCbCr 空间中:Y 表示亮度,Cb 与 Cr 表示蓝色差与红色差。显示器需要的是 RGB,于是解码流程中还要做一次矩阵变换。BT.601 与 BT.709 使用不同的系数:
| 标准 | 亮度定义(近似系数) | 常见出处 |
|---|---|---|
| BT.601 | 0.299 R + 0.587 G + 0.114 B | 标清电视时代的色度定义,部分 JPEG 码流按此标注 |
| BT.709 | 0.2126 R + 0.7152 G + 0.0722 B | 高清电视的色度定义 |
两者都对 Y 与色差做有限范围的偏移与缩放(一种常见做法是把 16–235 映射到 0–255)。系数或范围选错,画面就会出现色偏或对比度变化。整数实现通常用定点运算加移位代替浮点:
/* 简化示意:BT.601 全范围的定点近似,系数为缩放后的整数 */
int r = (y * 256 + 359 * (cr - 128) + 128) >> 8;
int g = (y * 256 - 88 * (cb - 128) - 183 * (cr - 128) + 128) >> 8;
int b = (y * 256 + 454 * (cb - 128) + 128) >> 8;
不同实现采用的缩放位数与舍入方式并不相同,上面只是定点思路的示意。
色度子采样与信息损失
人眼对亮度细节的分辨力通常高于对色度,因此多数格式在存储前对色度通道做下采样。记号「4:2:0」描述的是采样比例:
| 采样格式 | 水平方向 | 垂直方向 | 每个 2×2 亮度像素块对应的色度样本 |
|---|---|---|---|
| 4:4:4 | 不降采样 | 不降采样 | 4 |
| 4:2:2 | 减半 | 不降采样 | 2 |
| 4:2:0 | 减半 | 减半 | 1 |
还原时解码器要把色度上采样回亮度分辨率,常用做法是在邻域内做插值。这一步不可逆:被丢弃的色度信息无法由插值复原,因此在饱和度高、细节密集的边缘(例如彩色文字的轮廓)容易出现色边。
色彩空间、色域与传递函数
样本数值本身没有颜色含义,要配合色彩空间与传递函数才能确定颜色。sRGB 与 Display P3 都定义了白点、原色坐标与传递函数,但原色坐标与可表示范围不同,同一组 RGB 数值在两者中对应的实际颜色也不同。因此解码器需要读取嵌入的 ICC 描述文件或标准声明,再决定如何解释这些数值。
传递函数描述数值与相对亮度之间的非线性映射,sRGB 的传递函数近似为 γ≈2.2 的幂函数与一段线性段的组合。解码得到的样本通常位于编码域,如果直接在这些数值上做加权平均,结果会与在相对亮度域中运算不同,这也是图像缩放插值算法要先转入线性域的原因之一。
alpha 与位深
带 alpha 通道的图像有预乘与非预乘两种表示。预乘指把 RGB 各通道先乘以 alpha,使半透明像素在逐项相加合成时得到正确结果;非预乘数据在合成前必须先乘上 alpha,否则边缘会出现偏暗的轮廓。
位深决定每个通道可用的离散级别。8 位通道提供 256 级;解码器内部常以更高位深(如 16 位定点)完成矩阵变换与缩放,再转回 8 位输出,以减少中间步骤的舍入误差。提高内部位深不会增加原始信息,但能减少处理环节引入的新误差。
小结
解码管线把压缩码流还原为像素:识别容器 → 熵解码 → 逆量化与逆变换 → 色度上采样 → 色彩空间变换 → 组装像素。每一步都有自己的误差来源:量化误差在编码端固定,色度子采样丢弃了部分信息,矩阵系数与传递函数的选取决定了颜色如何被解释,位深与舍入则影响处理精度。