宽高和像素都没变,PNG 为什么还能变小?
给网页导出一张 PNG,宽高没有缩小,检查像素也没有变化,文件却轻了很多。这样的优化究竟省掉了什么?反过来,为什么有些图片把压缩等级调到最高,体积仍然降不下来?
我用两份人工构造的 RGB 数据做了实验:一份有规律的颜色图样,一份高噪声对照。相同渐变像素、相同压缩等级下,文件从 93,425 字节变为 726 字节;再用 Sharp 导出,得到 571 字节。这些是本次输入的结果,不是所有 PNG 的压缩率承诺。下面解释差异来自哪里,以及如何证明优化没有悄悄改变自己关心的信息。
先把“没变”的对象说清楚
一张图至少有几种可以比较的东西:宽高、解码后的颜色数值、实际显示效果,以及整个文件的字节。只核对宽高,无法证明颜色没改;文件哈希不同,也无法直接证明画面不同。优化的目标应该先写清,才知道最后需要比较什么。
PNG 的过滤与压缩可以保留图像数据,但文件里不只有像素,也能携带附加信息。W3C:PNG 第三版规范
这次把条件收窄到 256×128、每个颜色通道 8 位、三个 RGB 通道、非交错的静态图。文件没有透明通道和色彩配置文件。生成器直接写入这些数值,再交给另一套解码实现读取,逐字节与原始 RGB 比较。这里的“一致”指全部颜色数值一致,没有把一次肉眼观察当作证明。
八个手工编码文件包含七个保持各自输入像素的例子,以及一个主动改变颜色的负对照。高噪声图与渐变图本来就不是同一张图,比较时分别使用自己的原始数据。除此之外,我还实际运行了一次 Sharp 导出。生成器、输入构造方法、文件哈希和独立验证结果都随文提供。
运行环境为 macOS 27.0.1、arm64、Python 3.9.6、zlib 1.2.12;独立解码和导出使用 Node.js 24.19.0、Sharp 0.35.4、libvips 8.18.6、libpng 1.6.58。这些是本机实际使用的版本,本文没有比较不同平台的速度,也没有对某个图片优化服务做排名。
同一级压缩,为什么能差约 128.7 倍
下面是有规律的输入。生成代码使用横坐标作为红色,纵坐标的两倍作为绿色,蓝色取两者之和对 256 的余数;对角线处因此有数值回绕。它是为分析编码构造的图样,不是照片样本。

裸 RGB 数组占 256×128×3 = 98,304 字节。生成器在每一行前再写一个过滤类型字节,送去压缩的数据因此是 98,432 字节。保存为 PNG 后的体积,则还受到编码结果和文件结构的影响。
| 文件 | 行过滤 | zlib 等级 | 文件字节数 | 与原始 RGB 完全一致 |
|---|---|---|---|---|
| gradient-none-0.png | None | 0 | 98,505 | 是 |
| gradient-none-9.png | None | 9 | 93,425 | 是 |
| gradient-sub-9.png | Sub | 9 | 726 | 是 |
zlib 的等级范围包含 0 到 9,0 不进行压缩,9 使用更高的压缩等级。这里的数字控制压缩过程,没有改变输入像素。zlib:压缩等级说明
表里最显著的改善却发生在两个等级都为 9 的文件之间。区别是 先怎样表示一行数据。PNG 的行过滤可逆;Sub 使用同一行左侧对应字节的差值,并按模 256 运算。对于本文的 RGB 布局,左侧对应值位于当前字节之前三个字节的位置。W3C:PNG 过滤规则,第 9 节
例如,两个相邻像素的 RGB 是 100,150,200 和 101,151,201,经过本脚本的 Sub 函数后成为 100,150,200,1,1,1。第一像素保留原值,后面表达的是变化量。解码时依据已恢复的左侧数值相加,就能还原第二像素。
这份输入的相邻颜色变化很有规律,差值序列里出现了大量重复。它把容易利用的规律交给了后续压缩,所以与直接交入原始行相比,体积差异很大。重复差值并没有表示多个像素被合并;逆运算仍然恢复每一个位置的原值。
模运算也不是把负差值强行截成零。第二个实测小例子从 250,251,255 走到 2,3,4,得到的后一个差值是 8,8,5,按同样规则加回去会发生回绕。完整脚本使用的函数如下,只适用于这里的三通道 8 位布局:
def sub_row(row):
return bytes((value - (row[i - 3] if i >= 3 else 0)) % 256
for i, value in enumerate(row))
因此,“过滤”在这里表示改变编码前的数值表达方式,不能按图片编辑器里的模糊滤镜来理解。这组结果也提醒我们:只调压缩等级、完全不看前面的表达方式,可能错过远大于等级变化本身的收益。
压缩数据不动,文件大小也能改变
从 726 字节的文件继续做两个独立改动:添加文本,或拆分图像数据块。两者都保持压缩后的图像数据载荷完全相同;我比较了拼接后的 IDAT 载荷哈希,并再次解码验证。
| 文件 | 改动 | IDAT 载荷字节数 | IDAT 块数 | 文件字节数 |
|---|---|---|---|---|
| gradient-sub-9.png | 基准 | 669 | 1 | 726 |
| gradient-sub-9-text.png | 添加 4096 个 ASCII 字符的注释 | 669 | 1 | 4842 |
| gradient-sub-9-split.png | 每个 IDAT 载荷最多 16 字节 | 669 | 42 | 1218 |
PNG 的块包含长度、类型、载荷与 CRC;多个 IDAT 块的载荷拼接成一个压缩流。W3C:块结构与压缩流,第 5、10 节
在本脚本的文本例子里,注释写入独立的 tEXt 块:4096 个文字字节,加上 Comment 关键词和分隔字节,再加块的结构开销,总共增加 4116 字节。所以 726 变成 4842,像素与 IDAT 都没有变。删除这段注释确实能缩小文件,同时也确实删除了注释信息。
分块例子增加了 41 个块,每个新增块的长度、类型和 CRC 合计 12 字节,因此多出 41×12 = 492 字节。它不是把每个小块重新压缩一次,而是把已经得到的同一个压缩流切开再包装。这说明,有时问题在文件包装方式,继续提高压缩等级并不能解决这部分开销。
不过,实验只处理自己添加的注释,不能据此建议把所有附加信息一律删除。是否要保留作者说明、来源信息或工作流程需要的字段,属于导出用途的决定。图像数值是否相同,与文件是否保留了全部原信息,是两个不同的检查目标。
两个对照,阻止结论被扩大
第一个对照用 SHAKE-256 输出构造高噪声 RGB 数据,输入长度仍为 98,304 字节。它是算法生成的测试数据,没有模拟相机传感器的完整噪声特性。

| 文件 | 行过滤 | 文件字节数 | 与各自原始 RGB 完全一致 |
|---|---|---|---|
| noise-none-9.png | None | 98,530 | 是 |
| noise-sub-9.png | Sub | 98,530 | 是 |
两份文件的体积相同,文件字节并不相同。这个输入在两种已测表达方式下,都没有获得渐变图那样的收益。它说明压缩效果与数据内容有关,不能把前面的倍率用作所有图片的预期;本文也没有穷举所有过滤与压缩策略,不能据此说噪声图已经达到最小编码。
第二个对照是在渐变图的零起点坐标 (128,64) 处,把红色字节翻转最低位,宽高保持不变。生成的 gradient-sub-9-changed.png 是有效 PNG,大小为 741 字节,但独立比较检测到 一个 RGB 字节不同。
它演示了应当判为不一致的结果:仅看宽高,这个文件会被当作“没变”;完整颜色比较则报告不相同。完整验证器先比较文件哈希、解码形状,再比较全部 RGB 字节,并记录不同字节的数量。哈希用来确认本次检查的是哪份文件,最终的像素判断使用实际字节比较。
把实验变成可用的导出步骤
为连接到真实工具,我还让 Sharp 读取 93,425 字节的渐变文件,使用最高压缩等级并开启自适应行过滤。验证器的异步函数中实际执行了下面的调用,input 和 output 是实验目录中对应的输入、输出路径:
await sharp(input)
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(output);
所得 gradient-sharp-optimized.png 为 571 字节;输入和输出都解码后,RGB 数组逐字节一致。这里没有指定缩放或调色板量化。量化会重新选择一组颜色来表示图像,可能改变原先的 RGB 数值。Sharp 文档将压缩等级、自适应过滤与调色板相关选项分别列出,不应把任何名叫“质量”的参数都理解成只调整无损压缩。Sharp:PNG 输出选项
这个结果比我固定使用 Sub 的文件小,说明成熟实现还能提供另一种有效编码。它没有证明 571 是这份图的理论下限,也没有测量导出耗时;如果图片已经用较合适的方式编码,再跑一次优化没有明显收益也很正常。
对实际工作,我建议先明确保留要求,再在副本上改变导出参数。准备网页插图时,通常希望保持目标尺寸与需要的颜色、透明效果;归档源文件时,还可能需要来源、注释等信息。两者应该采用各自明确的验收条件,不能只按最小文件数值选择结果。
尤其要查清工具默认行为。Sharp 的文档说明,未使用 keepMetadata 时,默认会转换到 sRGB 并移除元数据,包括 ICC 配置文件。Sharp:元数据保留说明
本次输入没有这些配置,所以只证明上述导出调用对本实验 RGB 数据有效。对于带色彩配置、透明通道、不同位深或动画的素材,需要覆盖那些实际信息的比较方法,不能直接用这份三通道验证器给出“全部无损”的结论。它故意只接受生成的实验图,避免把不支持的格式静默转换后仍报告通过。
可以下载 完整实验包,其中包括 Python 生成器、Node 验证器、全部图片,以及 生成记录 和 独立验证及导出结果。在 Python 3.9 以上、Node 能加载 Sharp 的环境中,解压后执行:
python3 png-size-lab.py png-size-lab
node png-size-verify.cjs png-size-lab
生成器只用 Python 标准库;验证器需要 Sharp。本机使用环境提供的安装,通过 GUMI_SHARP_MODULE 指定包的绝对路径,具体版本已记录在结果中。重新运行可以复现图样和验证步骤,不同编码库版本生成的文件体积未必完全相同,最终仍应检查实际结果。
一次可靠的优化报告,应当说明输入是什么、改变了哪些编码或附加信息、输出省了多少,以及哪些内容经过比较后仍然一致。遇到文件变小,先沿着行表达、压缩和块结构寻找解释;遇到文件不再变小,回到实际数据与保留要求。依据自己需要保留的内容与实际字节收益,就能选择合适的 PNG 导出方式。