减色后的 PNG 里,deflate 还剩多少压缩空间

3 阅读44分钟

摘要

pngquant 把 PNG 减成调色板图之后,文件里的 deflate 数据流并没有压到头。我把 12 张维基共享资源的 CC 许可 PNG 逐步拆开看。减色后再跑 oxipng,四类图在原始体积的基础上多省 1.6 到 4.0 个百分点。12 张合计多省 2.89 个百分点,耗时合计 4.7 秒。换成 zopflipng 完整模式,合计多省 2.65 个百分点。耗时 258.7 秒。省下的字节一部分来自滤镜重选,一部分来自更用力的 deflate。另有少量来自附加块。三者在不同图上各占上风。全文给出分步体积、逐行滤镜统计和像素逐字节核对,也记录了图映批量输出的 4 张调色板 PNG 的同类观察。

版本声明

本文所有数字来自 2026-10-29 在一台 Apple M4 的 macOS 上完成的单次实测。工具版本是 pngquant 3.0.3、oxipng 10.2.1 和 zopflipng(zopfli 1.0.3_1)。图映批量压缩那部分在 Chromium 149 里运行。体积一律是实际字节数,不换算成 KiB 或 KB。工具打印 KiB 时,我取括号里的字节数。样本是维基共享资源上的 12 张 CC 许可或公有领域 PNG,署名见文末参考资料。第一轮实测里 zopflipng 带着快速模式参数。我在补测时发现并更正,文中会单独讲。

适用边界

这篇文章讨论的是已经被 pngquant 减成调色板的 PNG 再做无损优化能拿回多少。它不适用于直接对 24 位或 32 位真彩 PNG 做无损优化,也没有比较减色之后的画质。样本只有 12 张。没有 16 位图、隔行扫描图和动画 PNG。耗时是单次墙钟,没有重复取中位数。凡是我没读过源码、没拆开验证的原因,文中都写成推断而不当结论。

文章目录

减色以后的 PNG 为什么还能再压

  • 一个图标再跑一遍无损工具少了 646 字节
  • 有损工具改像素而无损工具改写法

PNG 的字节是怎么一层层包出来的

  • 一张图先拆成块再拆成滤镜行
  • 减色先把未压缩数据缩到八分之一

调色板图的 deflate 还剩多少空间

  • 减色工具写出的数据流已接近 zlib 的 9 级
  • 无损工具再拿走四类图各自的一截
  • 换一个分母排名就变了

省下的字节来自滤镜还是 deflate

  • 多数图的滤镜一行都没动
  • 滤镜和 deflate 在不同图上各占上风
  • 压得越狠的图 deflate 越有空间

除了滤镜和 deflate 还有哪些字节可省

第一轮 zopflipng 为什么几乎没省

更用力的 deflate 到底在做什么

多等的几十秒换回多少字节

先减色还是先无损

图映批量输出的调色板图还有空间吗

无损到底有多无损

有损和无损各该管哪一段

这一步该放在流水线的哪里

测量方法自身的坑

适用边界与风险提示

还有哪些没弄清

动手之前先查哪几样

参考资料

减色以后的 PNG 为什么还能再压

一个图标再跑一遍无损工具少了 646 字节

我做这组实测的起点是一个很小的数。样本里有一张 512 乘 512 的图标,原始 40,369 字节。pngquant 用 65 到 80 的质量区间把它减成调色板图,输出 7,600 字节。这已经比原图小了八成。接着我把这份输出交给 oxipng,用 4 级优化加安全剥离。它又省下 646 字节,输出 6,954 字节。省下的部分占减色结果的 8.50%。

这个数让我停了一下。pngquant 是成熟的量化工具。它写文件时不会不压缩,输出里本来就有一条 deflate 数据流。已经压缩过的数据流,换个程序再压一遍还能少近一成。这说明第一个程序没有压到头。我想弄清三件事:这一成只在小图标上有还是调色板 PNG 普遍都有、它到底来自哪里、值不值得为它在构建流程里多加一步。

先交代样本。样本是维基共享资源上取来的 12 张 PNG。分四类,每类三张,依次是截图、图标、插画和照片类 PNG。截图类合计 1,890,089 字节。图标类 184,137 字节。插画类 316,993 字节。照片类 2,063,192 字节。12 张加起来 4,454,411 字节。作者与许可都在文末署名清单里。我只用这些图做体积和像素层面的观察,不评价画面。

有损工具改像素而无损工具改写法

这篇文章里“有损”和“无损”两个词会反复出现,先把分工说死。有损指的是 pngquant 这一步。它把图片里成千上万种颜色并成最多 256 种,再把每个像素改成指向调色板的序号。这一步改变了像素值,不可逆。画质好坏由质量参数决定。无损指的是 oxipng 和 zopflipng 这一类。它们不碰像素,只改变同一组像素写进文件的方式。这包括每一行用哪种滤镜、位深用几位、调色板有没有多余项,以及 deflate 数据流怎么编。

两步改的东西不在同一层。先后顺序和各自能拿到多少收益,都不是显而易见的。后面几章我会把一个 PNG 文件拆开看每一层的字节落在哪里。再用同一批样本逐层验证。

PNG 的字节是怎么一层层包出来的

一张图先拆成块再拆成滤镜行

PNG 文件是一串块。开头固定 8 个字节的签名。后面依次是 IHDR,记录宽高、位深和颜色类型。调色板图有 PLTE,最多记 256 项调色板。带透明度的调色板图还有 tRNS,记每一项的透明度。真正的像素数据放在一个或多个 IDAT 块里,最后用 IEND 收尾。所有 IDAT 块的内容首尾拼起来,是一条 zlib 数据流。解开以后才是图像数据。

图像数据按行排列。每一行最前面多出一个字节,叫滤镜类型。取值 0 到 4。它们依次是不滤镜、取左边像素的差、取上边像素的差、取左边与上边平均值的差和 Paeth 预测。滤镜只是把每个字节换成它和邻居的差。重复的数因此更集中,后面的 deflate 就更好压。滤镜本身不减少一个字节。真正减少字节的是 deflate。它先用滑动窗口找前面出现过的重复串,再用 Huffman 编码给高频符号分配短码。压缩工具能动的旋钮因此只有几个。每行选哪种滤镜。位深和调色板能不能缩。deflate 的匹配与分块怎么选。还有要不要丢掉非必需的块。

举个小例子帮助理解滤镜在做什么。假设调色板图相邻两行的序号是“3 3 3 3 5 5 9 9”和“3 3 3 3 5 5 9 9”,两行完全一样。对第二行用 Up 滤镜。每个字节减去上一行同一位置的字节,得到八个 0。八个字节变成一长串 0。deflate 只需要记一个“0 重复八次”的引用,几乎不花空间。Sub 滤镜是减去左边的字节。同一行里连续相同的序号会变成 0,颜色边界处会出现一个非零的差。Paeth 在左、上、左上三个邻居里挑最接近的那个当预测值,对边缘多的图有用。每一行可以自己选滤镜。选哪个由编码器决定,规范不管。

我写了一个很短的脚本。它把 PNG 解到这一层,数出每种滤镜各用了几行。再把解开的未压缩数据用 zlib 的 9 级重压一遍,当作一把参照尺。脚本只用标准库。

import sys, struct, zlib
from collections import Counter

def peek(path):
    blob = open(path, "rb").read()
    pos, idat, head, pal = 8, b"", None, 0
    while pos < len(blob):
        n, = struct.unpack(">I", blob[pos:pos+4])
        tag, body = blob[pos+4:pos+8], blob[pos+8:pos+8+n]
        if tag == b"IHDR": head = struct.unpack(">IIBBBBB", body)
        if tag == b"PLTE": pal = n // 3
        if tag == b"IDAT": idat += body
        pos += 12 + n
    w, h, depth, ctype = head[:4]
    per_px = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[ctype] * depth
    stride = (w * per_px + 7) // 8 + 1
    raw = zlib.decompress(idat)
    kinds = Counter(raw[i * stride] for i in range(h))
    return dict(file=len(blob), idat=len(idat), raw=len(raw), pal=pal,
                filters=[kinds[k] for k in range(5)], zlib9=len(zlib.compress(raw, 9)))

for p in sys.argv[1:]:
    print(p, peek(p))

我拿同一张照片类 PNG(719 乘 533)的四个版本喂给它。依次是原图、pngquant 输出、oxipng 处理后和 zopflipng 完整模式处理后。

照片-原图.png {'file': 408175, 'idat': 408084, 'raw': 1533441, 'pal': 0, 'filters': [0, 0, 533, 0, 0], 'zlib9': 401354}
照片-减色.png {'file': 145573, 'idat': 144635, 'raw': 383760, 'pal': 205, 'filters': [533, 0, 0, 0, 0], 'zlib9': 143201}
照片-oxipng.png {'file': 125889, 'idat': 125171, 'raw': 383760, 'pal': 205, 'filters': [0, 80, 332, 0, 121], 'zlib9': 134149}
照片-zopflipng.png {'file': 133893, 'idat': 133209, 'raw': 383760, 'pal': 205, 'filters': [533, 0, 0, 0, 0], 'zlib9': 143161}

代码说明:filters 是 0 到 4 号滤镜各自占多少行。raw 是 IDAT 解压后的字节数。zlib9 是同一份未压缩数据用 Python 的 zlib 9 级重压的字节数。读法是横着比四行。原图 533 行全用 2 号滤镜,也就是取上边像素的差。pngquant 输出有 205 色。533 行全用 0 号,也就是不滤镜。oxipng 改成了 80 行 Sub、332 行 Up 和 121 行 Paeth,IDAT 从 144,635 降到 125,171。zopflipng 我给了全部滤镜策略。它最后仍是 533 行 0 号,IDAT 降到 133,209。这四行的差异,后面会反复用到。

减色先把未压缩数据缩到八分之一

再看减色这一步本身对数据量做了什么。样本里最大的图标类 PNG 是一个 2153 乘 2053 的蓝色星形图标,原始 120,393 字节。它是 32 位 RGBA,IDAT 解压后的未压缩数据有 17,682,489 字节。pngquant 把它减到只有 6 种颜色的调色板图,位深降到 4 位。未压缩数据变成 2,213,134 字节,约是原来的八分之一。IDAT 同时从 119,034 字节降到 23,975 字节。

有一个细节我起初没想到。原图的未压缩数据与 IDAT 之比是 148.6 比 1,减色之后是 92.3 比 1。减色把输入缩了八倍,但 deflate 对每个输入字节的压缩倍数反而降了。原图的 RGBA 像素有大片相同的颜色,deflate 早就把这些重复吃掉了。减色去掉的,主要是 deflate 本来就压得很小的那部分。最终文件只缩到约五分之一,没有缩到八分之一。这是我对比值变化的解释,属于推断。没有在别的图上分别验证。

调色板图的 deflate 还剩多少空间

减色工具写出的数据流已接近 zlib 的 9 级

回到最初的问题:pngquant 写出的 IDAT 离“压到头”有多远。我手里有两把尺子。一把是 Python 的 zlib 9 级。另一把是后面要用的 oxipng 和 zopflipng。先用第一把。对 12 张里的四张,把 pngquant 输出的 IDAT 和 zlib 9 级重压同一份未压缩数据的字节数放一起。

pngquant 输出IDAT 字节同一份数据 zlib 9 级重压
Adaptability 图标7,4157,346
Afro 图标4,0154,036
蓝星图标23,97524,522
Anime 插画54,02855,183

四张的差距都在 2.3% 以内,有的 pngquant 略大,有的略小。我据此判断,pngquant 写出的数据流和 zlib 的高档位在同一个水平上。它为什么会这样、内部用的是哪个库,我没有读过源码因而不下断言。

再看原图。照片类里那张 719 乘 533 的图,原图 IDAT 是 408,084 字节。我把解压后的数据用 zlib 默认级别重压一遍,得到的也是 408,084 字节且一个字节都不差。这个吻合有可能只是巧合,于是换了一张验证。Anime 插画的原图 IDAT 是 220,164 字节,用 zlib 9 级重压得到的也是 220,164 字节。两个吻合都是单点观察。我只能说这两张原图的 IDAT 和 zlib 对应级别的输出字节数相同。它们有可能是 zlib 一族的编码器生成的,这只是推测。其余 10 张我没有逐张核对。

无损工具再拿走四类图各自的一截

两把尺子都不是下限。真正的下限要看专门的无损工具。我对每一张 pngquant 输出各跑一遍 oxipng 的 4 级优化,按类汇总。下面的百分点是相对该类原始体积的。

类(3 张合计)原始pngquant 之后再跑 oxipng累计省比 pngquant 多省耗时
截图1,890,089601,760565,45570.1%1.9 个百分点1.6 秒
图标184,13737,17331,49682.9%3.1 个百分点0.9 秒
插画316,99378,20973,21176.9%1.6 个百分点1.4 秒
照片2,063,192694,735612,96870.3%4.0 个百分点0.8 秒

12 张合计的原始体积是 4,454,411。pngquant 之后是 1,411,877。再跑 oxipng 之后是 1,283,130。多省 2.89 个百分点,合计耗时 4.7 秒。这是四种处理里最便宜的。四类的额外收益从 1.6 到 4.0 个百分点不等。照片最高,插画最低。我没有查这个排序是不是有规律。样本只有每类 3 张,不拿它下结论。

换一个分母排名就变了

上面的百分点是相对原始体积算的。这个分母适合回答“整条流水线最终省了多少”。但我关心的是无损这一步对减色输出的效果,分母应该换成 pngquant 输出的体积。换算之后,同样的 oxipng 在四类图上的收益变成截图 6.0%、图标 15.3%、插画 6.4%、照片 11.8%。

排名变了。按原始体积算,照片 4.0 排第一。图标 3.1 排第二。按减色后的体积算,图标 15.3% 排第一。照片 11.8% 排第二。原因不复杂。图标类减色之后只剩 37,173 字节,三张加起来比一张照片还小。同样省下几千字节,占减色输出的比例就高。占原始体积的比例却不显眼。换分母不是为了凑一个好看的数字。别人说“无损再省 X%”的时候,先问这个 X 的分母是什么。

还有一个细节值得说。照片类合计省下 81,767 字节。其中 Alekszej 那一张就占了 31,696 字节,接近四成。它的 pngquant 输出是 131,963 字节,oxipng 后是 100,267 字节。体积少了 24.0%。同类的另两张分别少了 13.5% 和 7.3%。“照片类省得多”因此有相当一部分是一张图拉高的均值。这一点在样本局限里还会再提。

省下的字节来自滤镜还是 deflate

多数图的滤镜一行都没动

前面那段脚本输出已经露出一个线索。pngquant 输出的滤镜全是 0 号。我把 12 张都统计了一遍,pngquant 输出的每一行无一例外都是 0 号滤镜。oxipng 处理后,有 4 张图的滤镜被改了。它们是三张照片类 PNG 和一张截图,就是 OpenProject 那张。另外 8 张滤镜仍然全是 0 号。

这个分组给了我一个干净的实验条件。对那 8 张滤镜没动的图,oxipng 的收益不会来自滤镜,只可能来自 deflate 数据流重新编、位深与调色板的调整,以及附加块的剥离。把 oxipng 的收益和 zopflipng 完整模式放在这 8 张图上并排看,数字都是相对减色后体积的。

图pngquant 输出oxipng 少了zopflipng 完整少了zopflipng 多拿
图标 Adaptability7,6008.5%8.9%0.4 个百分点
图标 Afro4,1718.3%11.0%2.7 个百分点
图标蓝星25,40218.4%22.2%3.8 个百分点
插画 ATP13,6205.3%6.5%1.2 个百分点
插画 Anime54,2927.0%12.8%5.8 个百分点
插画 Apple10,2974.4%5.7%1.3 个百分点
截图 Talend85,8165.9%6.8%0.9 个百分点
截图 The Book486,6076.2%6.4%0.2 个百分点

八张都是 zopflipng 完整模式更小,多拿的幅度从 0.2 到 5.8 个百分点。这说明在滤镜不动的前提下,deflate 这一层确实还有空间。oxipng 的 4 级只拿走了其中一部分。oxipng 的帮助文字把 zopfli 描述成“慢得多但更强”的压缩器,要求显式开启。我据此推断,oxipng 默认用的是另一种更快的 deflate 实现。这一点没有去读源码证实。

为了看清“更用力的 deflate”到底能拿多少,我拿 Anime 这张图做了四种处理并把耗时一起测出来。

for mode in "-q" ""; do
  s=$(date +%s)
  zopflipng -y $mode --filters=01234mepb 插画-减色.png out.png > /dev/null
  echo "zopflipng ${mode:-完整模式}: $(stat -f %z out.png) 字节, $(( $(date +%s) - s )) 秒"
done
cp 插画-减色.png b.png; s=$(date +%s); oxipng -o 4 --strip safe -q b.png
echo "oxipng -o 4: $(stat -f %z b.png) 字节, $(( $(date +%s) - s )) 秒"
cp 插画-减色.png c.png; s=$(date +%s); oxipng -o 4 --strip safe --zopfli -q c.png
echo "oxipng -o 4 --zopfli: $(stat -f %z c.png) 字节, $(( $(date +%s) - s )) 秒"
zopflipng -q: 54292 字节, 6 秒
zopflipng 完整模式: 47346 字节, 65 秒
oxipng -o 4: 50468 字节, 1 秒
oxipng -o 4 --zopfli: 47333 字节, 17 秒

代码说明:输入是 pngquant 输出的那张插画,54,292 字节。四行输出里,zopflipng -q 的结果和输入一模一样,都是 54,292。zopflipng 在结果比输入大时会保留原文件,后面单独讲。完整模式 47,346 字节,用了 65 秒。oxipng 的 4 级用 1 秒降到 50,468。oxipng 加 --zopfli 降到 47,333,用了 17 秒。它比 zopflipng 完整模式少用 48 秒,字节数还少 13。这条结果和滤镜没有关系。这张图四个版本的滤镜都是 0 号。

滤镜和 deflate 在不同图上各占上风

另外四张是滤镜被 oxipng 动过的图。数据要读得更小心,因为滤镜和 deflate 的贡献混在了一起。

图pngquant 输出oxipng 后少了zopflipng 完整少了oxipng 选的滤镜
照片 Alekszej131,963100,26724.0%118,19810.4%Sub 15、Up 502、Average 229、Paeth 218
照片 Directiva145,573125,88913.5%133,8938.0%Sub 80、Up 332、Paeth 121
照片 Steve Selvin417,199386,8127.3%379,5379.0%901 行全 Sub
截图 OpenProject29,33728,2563.7%26,4539.8%不滤镜 235、Up 645、Paeth 63

表里 zopflipng 完整模式的滤镜四张全是 0 号。前两张照片上滤镜的贡献远大于 deflate 的差别。Steve Selvin 的 901 行都被 oxipng 改成了 Sub,但 zopflipng 在不动滤镜的情况下反而更小。OpenProject 那张的 oxipng 收益是 12 张里最低的,zopflipng 不动滤镜却拿到近三倍的收益。

滤镜并不能一概而论地说“动了就更好”。两张照片上滤镜的收益压过了 deflate。另外两张里 deflate 的更用力反而更值。两件事拆不开的原因也写在这里。oxipng 的滤镜选择和它的 deflate 绑在一次运行里,我没有单独测“只换滤镜、deflate 固定”的对照。要把这两件事拆干净,需要一个能固定 deflate 只改滤镜的实验。这是本文的缺口之一。

怎么解释调色板图上用 Sub 或 Up 能得到收益?我的推断是这样的。调色板序号不是亮度,相邻序号的差本身没有视觉意义。但在大片同色的区域,Up 滤镜会把“和上一行完全相同”的像素变成一串 0。deflate 对一长串 0 的压缩效率很高。这个解释只是推断,我没有统计这些图里零字节的占比。

压得越狠的图 deflate 越有空间

12 张图的滤镜和 deflate 谁占上风,我换一个角度再排一遍。把每张 pngquant 输出的未压缩数据字节数除以它的 IDAT 字节数,得到这条数据流的压缩倍数。从小到大排,前四张依次是 Steve Selvin 2.0、Directiva 2.7、Alekszej 5.6 和 The Book 6.1。后四张依次是 OpenProject 62.2、Afro 65.4、Anime 69.8 和蓝星 92.3。中间四张在 9.3 到 20.0 之间,分别是 Apple、ATP、Adaptability 和 Talend。

倍数最高的四张是大面积平涂的截图、图标和插画。zopflipng 完整模式把它们的 IDAT 缩小了 9.8%、11.4%、12.7% 和 18.0%。倍数低于 10 的五张,缩小幅度是 5.7% 到 9.9%。中间三张是 6.2%、8.3% 和 6.7%。粗看是压得越狠的图,更用力的 deflate 越有得拿。我对这个现象的解释是推断。数据里重复的串越长越多,匹配怎么切分的选择余地就越大。穷举式的搜索才有机会找到更短的写法。照片类的调色板序号本来就杂,能选的余地小。这只是一个排序观察。12 张样本做不了相关性检验,我也没有去数每张图里的匹配长度分布。

放在一起读,滤镜与 deflate 的分工就清楚一点。倍数低、像素杂的图,值钱的是滤镜。把序号换成差值之后,能出现更多重复。倍数高、大片平涂的图,滤镜没什么可换的。值钱的是 deflate 的搜索深度。这或许能解释为什么 oxipng 在照片上拿得多,而 zopfli 在插画和图标上拿得多。这同样是推断。

除了滤镜和 deflate 还有哪些字节可省

前面把收益归到滤镜和 deflate 两处,其实还漏了两个小口子。第一个是 IDAT 之外的块。我把 12 份 pngquant 输出的文件体积减去 IDAT 字节数,合计是 8,654 字节。这些是签名、IHDR、PLTE、tRNS、IEND 和别的附加块。oxipng 处理之后同一个差值合计降到 4,689 字节,少了 3,965 字节。12 份文件一共少了 128,747 字节,剥离附加块的贡献约占 3.1%。比例不大,个别图上却很显眼。蓝星图标的这个差值从 1,427 字节降到 122 字节,一下少了 1,305 字节。它这一份的总收益是 4,683 字节,附加块占了近三成。这些附加块里装的是什么,我没有逐个解开。只知道它们不是图像数据。

第二个口子是位深与颜色类型。12 份 pngquant 输出的位深都已经取到了调色板大小允许的最小值。13 色和 6 色的图标用 4 位。其余 10 张的调色板在 23 到 256 项之间,用 8 位。oxipng 在这批输出上因此没有位深可降。但后面讲到的那个批量压缩工具的蓝星输出就不是这样,只有 16 项调色板却按 8 位存。这才让位深这一层出现了空间。

另有一个我没想到的变化出现在 zopflipng 身上。照片类的 Alekszej 那张,pngquant 输出是 23 项调色板的 8 位图。zopflipng 完整模式处理后颜色类型变成了 0,也就是没有调色板的 8 位灰度图。我用后面的像素脚本核对过,转成 RGBA 之后与 pngquant 输出逐字节一致。这说明这张图的 23 种颜色其实都是灰色,可以无损地改成灰度。oxipng 在这张图上没有做这个转换。它靠重选滤镜拿到了更大的收益。无损工具之间的差别,不只在 deflate 的力度上。它们各自会尝试的“等价改写”也不一样。

第一轮 zopflipng 为什么几乎没省

我最初的实测里,zopflipng 的成绩很差。同样的 12 张 pngquant 输出按类汇总,再跑 zopflipng 多省的只有 0.0 到 0.5 个百分点。3 张合计耗时 3.5 到 19.5 秒。我当时的判断是 zopflipng 在这个场景下不划算。原因我猜是它只重压 deflate,oxipng 则同时做了调色板和位深的缩减。这个判断写进了第一轮记录。

补测时我发现事情不对。那一轮的 Anime 记录里 zopflipng 的输出是 55,969 字节,比输入的 54,292 还大。工具打印了“Preserving original PNG since it was smaller”,把原文件留了下来。而我这次不加 -q 直接跑,得到 47,346。同一张图、同样的滤镜策略,结果相差 7,000 多字节。回头去看第一轮用的命令,里面带着 -q。zopflipng 的帮助文字写得很直白。-q 是“快速但压得不够好”,设计给“只试滤镜和颜色类型”的场景用。第一轮把它当成了 zopflipng 的正常输出。写命令记录时又漏掉了这个参数,所以一直没有发现。这个教训本身很普通,一个参数漏记,让一个工具在我的记录里白白背了锅。读别人的对比时,我现在会先找他有没有写清每个工具的完整命令。

快速模式下 12 张里有 5 张输出没有变小,原样保留。它们是 Afro 图标、Anime 插画、Apple 插画、Talend 截图和 OpenProject 截图。换成完整模式,同一批图合计从 1,411,877 降到 1,293,698。比 pngquant 之后少了 8.37%。相对 12 张原始体积,是多省 2.65 个百分点。按类看,截图 2.1、图标 3.7、插画 2.7、照片 3.1 个百分点。

这件事纠正了一个具体结论。第一轮里“zopflipng 只多省 0.0 到 0.5 个百分点”是快速模式的数字,不能代表 zopflipng。我现在读到的版本是这样。完整模式下,它在滤镜不动的 8 张图上都比 oxipng 默认档更小。在滤镜被 oxipng 改动过的三张照片上,它输两张、赢一张。它是否一直需要“多等”,下一章单独算账。

更用力的 deflate 到底在做什么

“更用力”这个词在工具说明里很模糊。我把它落到 deflate 的规格上看一下。deflate 的数据流由若干个块组成。块里的每个符号要么是一个原始字节,要么是一对“回头多远、复制多长”的引用。回头最远 32,768 字节,复制长度 3 到 258 字节。每个块再用自己的 Huffman 表给符号编码。编码器能做的取舍有两类。一是怎么把输入切成“字面量加引用”的序列。同一段输入有许多种切法,字节数各不相同。二是块在哪里切开,每块配一张什么样的 Huffman 表。

普通的快速实现采用贪心或有限回看的策略,几乎不花时间就能得到一个合理的切法。zopfli 的思路是反复迭代。先按一种切法统计各符号的代价,再用这份代价重新找最优切法。如此重复,直到收敛或达到迭代次数。zopflipng 的帮助文字里写着迭代次数默认对小文件是 15,对大文件是 5。这个默认值给我一个线索。The Book 那张截图 486,607 字节,是 12 张里最大的几张之一。它在 zopflipng 完整模式下只比 oxipng 默认档多拿 0.2 个百分点,可能与大文件迭代次数少有关系。这是我对一个单点数据的推断,没有用 --iterations 去验证。

还有一点值得记下来。deflate 的输出格式是固定的,任何一个兼容的解码器都能读。“压得更狠”因此不需要解码端配合。这就是无损优化能直接换掉文件、浏览器照常显示的原因。这也解释了前面的像素核对。改变的只是怎么写,不是写了什么。

多等的几十秒换回多少字节

把四种无损处理的合计放到一起。12 张 pngquant 输出合计 1,411,877 字节,相对原始体积 4,454,411 的额外节省和合计耗时如下。

处理合计体积多省的百分点合计耗时
oxipng 4 级1,283,1302.894.7 秒
oxipng 4 级加 zopfli1,262,5933.3563.4 秒
zopflipng 快速模式1,398,2660.3141.6 秒
zopflipng 完整模式1,293,6982.65258.7 秒

先看最便宜的这一档。oxipng 4 级用 4.7 秒拿到了 2.89 个百分点,折合每秒约 0.6 个百分点。加上 zopfli 之后多拿了 0.46 个百分点,也就是 20,537 字节。代价是 58.7 秒。如果这一步只在构建时跑一次,约一分钟可以接受。如果它挂在每次提交都要跑的检查里,一次要多等一分钟。这笔账就不一样了。zopflipng 完整模式的 258.7 秒更贵,拿到的还少于 oxipng 加 zopfli。在这批样本上我看不出单独用它的理由,除非你需要它的某个选项。

换个算法,用“每秒换回多少字节”来比。oxipng 4 级共省下 128,747 字节,平均每秒 27,393 字节。加上 zopfli 共省下 149,284 字节,平均每秒 2,355 字节。zopflipng 完整模式共省下 118,179 字节,平均每秒 457 字节。快速模式共省下 13,611 字节,平均每秒 327 字节。四个数最高与最低相差八十多倍。我愿意把前两档当日常选项,后两档要有理由才开。

按单张看,耗时随体积和复杂度走。耗时最长的几个是这些:蓝星图标 oxipng 加 zopfli 用了 10.1 秒,zopflipng 完整模式用了 43.4 秒。Anime 插画是 16.8 秒和 66.3 秒。The Book 截图是 10.1 秒和 45.3 秒。这些都是单次,不同次之间会有几秒的漂移。Anime 一张的完整模式我前后跑过几次,在 65 到 69 秒之间。

我对这笔账的取舍是这样。开发期的反复构建用 oxipng 的 4 级,发布前的一次性处理才考虑加 zopfli。理由不在耗时本身:前者的收益占后者的八成多(2.89 比 3.35,约 86%)。耗时却只有后者的百分之七。这个比值只在这 12 张上成立。

先减色还是先无损

顺序问题用四类图的几个数就能说明。下面都是 pngquant 65 到 80 的结果,单位是字节。

类pngquant 单独先 oxipng 再 pngquant先 zopflipng 再 pngquant先 pngquant 再 oxipng
截图601,760601,760601,739565,455
图标37,17335,85535,81331,496
插画78,20978,08878,03873,211
照片694,735694,253694,103612,968

以照片类为例,先无损再减色比 pngquant 单独处理只少了 482 字节和 632 字节,基本等于没做。原因很可能是 pngquant 并不沿用输入文件里的 deflate 数据流。它读到像素之后重新量化、重新编码,前面那一步的优化被整个丢弃。这是我对这个现象的解释。我没读过 pngquant 的代码,只看到了输出体积这一个事实。

四类都是先减色后无损才有额外收益。

顺序实验我只做了先后两种排法,没有测中间夹其他步骤的情形。比如减色之后先缩放再无损,缩放会生成新的像素,这时无损的收益需要重新测。本文的结论只覆盖减色之后直接做无损这一条路径。

图标那一行有个我没弄明白的小异常。先无损再减色时图标类比单独 pngquant 少了 1,318 字节,约 3.5%。截图和插画几乎没有变化。如果无损只改编码、不改像素,pngquant 看到的输入应该一样。输出也应该一样。但图标类的输出变了。一个可能的原因是 oxipng 把全彩图标转成了调色板格式,改变了 pngquant 的输入路径。我没有验证,只能写成猜测。

图映批量输出的调色板图还有空间吗

前面都是我自己用命令行工具减色的结果。同一批 12 张图,我还用图映 ImgIng(imging.cn/)在电脑浏览器里的批量… PNG,抓包没有看到携带文件内容的请求。这一块编码层我没看过实现,下面只写从输出文件读到的现象。

这一章还有一个范围要交代清楚。我测的是批量压缩里统一输出 PNG 这个选项,没有拆过它的智能推荐模式,也没有测单张 PNG 压缩页的颜色数滑杆。文中关于这些输出的所有观察,因此只适用于这 4 张调色板输出。

12 张里只有 4 张的输出是调色板 PNG,分别是三张图标和 Anime 插画。另外 8 张里有 7 张输出还是 RGBA,而且体积比原图大。剩下 1 张被保留了原件。本文只讨论那 4 张调色板输出。另外 8 张和这一章的问题无关。

这 4 张输出我交给 oxipng 的 4 级优化,结果如下。

文件图映输出再 oxipng少了调色板项数
Adaptability 图标9,3466,30132.6%51
Afro 图标8,3245,94628.6%239
Anime 插画90,04061,74831.4%256
蓝星图标43,21623,57145.5%16

这四个降幅都高于我自己的 12 份 pngquant 输出再跑 oxipng 的全部结果,后者在 3.7% 到 24.0% 之间。

我用上面那个脚本把这 4 张输出拆开,滤镜全是 0 号。再拿 IDAT 去比同一份未压缩数据经 zlib 9 级重压的字节数,Adaptability 是 9,061 对 7,244,Afro 是 7,287 对 6,091,Anime 是 88,935 对 69,027,蓝星是 41,790 对 29,389。输出的 IDAT 比 zlib 9 级大 19.6% 到 42.2%。这和 pngquant 的输出对 zlib 9 级的差距在 2.3% 以内相比,差了一个数量级。

再看 oxipng 的动作。蓝星的调色板只有 16 项。它的输出却按 8 位存储,未压缩数据有 4,422,162 字节。oxipng 把它改成 4 位。未压缩数据降到 2,213,134 字节,约减半。Afro 的调色板从 239 项缩到 238 项,应该是有一项没被用到。

把两条路线并排看,也有一处我没想明白。同样的四张原图,我自己 pngquant 加 oxipng 的结果依次是 6,954、3,823、50,468 和 20,719 字节,对应批量压缩路线的 6,301、5,946、61,748 和 23,571 字节。后三张的批量压缩输出更大,调色板项数也确实多得多,Afro 是 239 对 23,Anime 是 256 对 27,蓝星是 16 对 6。体积顺序和颜色数顺序一致。Adaptability 却反过来,批量压缩输出有 51 项而我的只有 13 项,这条路线最后反而小了 653 字节。颜色多了体积却更小,我没有查原因,也没有对比这两份输出的画面差别。

读完这些我只敢写到“这 4 张调色板输出的 deflate 压缩档位看起来比 zlib 9 级低,位深也有可降的余地”。至于它内部为什么这样,是为了速度、为了兼容还是别的取舍,我没有看过因而不下断言。还要强调一条:我自己的 pngquant 输出和批量压缩的输出颜色数不同,画质我没有比较,两者的体积不能直接排名。这一章的结论只限于“这个批量压缩输出的这 4 张调色板 PNG 可以再做无损优化”。样本 4 张,单次。

无损到底有多无损

前面所有“少了多少字节”的前提,是无损工具没有改动像素。这一点我不想凭工具的宣传,直接用代码核对。下面的脚本把两张图都转成 RGBA,再比较字节是否完全相同。

import sys
from PIL import Image

def pixels(path):
    with Image.open(path) as im:
        return im.convert("RGBA").tobytes()

base = sys.argv[1]
for other in sys.argv[2:]:
    print(other, "像素完全一致" if pixels(base) == pixels(other) else "像素有差异")
照片-oxipng.png 像素完全一致
照片-zopflipng.png 像素完全一致

代码说明:第一个参数是 pngquant 的输出,后面是它被无损工具处理后的版本。脚本逐字节比较转成 RGBA 之后的像素。上面是照片这一组。我对 12 张的 oxipng、oxipng 加 zopfli、zopflipng 快速模式和完整模式,共 48 个输出都做了同样的比较且差异为 0。批量压缩的 4 张调色板输出和各自 oxipng 之后的版本,也是像素完全一致。

这里有个小提醒。转 RGBA 比较,把完全透明像素底下的 RGB 值也比进去了。连“透明处藏着的颜色”都没有变。有些优化工具有选项可以清掉全透明像素的颜色来换更小的体积。本次没有打开它,所以不涉及。

有损和无损各该管哪一段

把前面的观察放在一起,我对两类压缩的分工是这样理解的。有损这一步只负责决定“像素应该是什么”,也是整条流水线里唯一会影响画质的地方。它的参数需要人看图决定。无损这一步只负责决定“这些像素怎么写进文件”。它的结果可以用代码证明不影响像素。因此可以放心放进自动化流程,不需要人看图。

这个分工有个很实际的推论。两步的验收标准应该不一样。有损那一步只看体积不够,要补画质对比。否则一个会让图变难看的参数可能在体积上得满分。无损那一步不需要画质。只需要像素一致性断言,再加体积和耗时两个指标。我把它写成两句话:减色输出要过画质关口,无损输出要过像素一致关口。

落到实现上,这两道关口都很便宜。像素一致性断言就是前面那十几行脚本。12 张图一起比,不需要读图的人参与。体积与耗时的记录,用 stat 和 date 就能拿到。只有画质那一道需要人看或者需要一个客观指标,而这一道恰好应该留给有损那一步。把两类压缩拆成两个明确的步骤。每一步配自己的验收,比一个“压缩图片”的大黑盒好维护得多。

两道关口的失败方式也不一样。像素一致性断言失败只会有一种原因,就是工具或参数出了问题,处理办法是回退到上一步的文件并报错。画质关口失败则可能是图本身不适合这个质量区间,需要人来决定是放宽参数还是保留原图。把这两种失败混在同一条报错里,排查时就不知道该先找工具还是先找图。这是我对流水线设计的判断,没有拿真实项目验证过。

再说回开头那个问题。调色板 PNG 里的 deflate 还剩多少空间。在这 12 张上,pngquant 之后用 oxipng 4 级再省 2.89 个百分点。用 oxipng 加 zopfli 再省 3.35 个百分点。按减色后的体积算,是 9.1% 和 10.6%。空间是有的。大小取决于你愿意为它付多少时间,也取决于图本身。同一个工具在不同图上的收益,从 3.7% 到 24.0% 不等。

这一步该放在流水线的哪里

收益和耗时都有了,剩下是放在哪里的问题。我按三个位置想了一遍。其中的耗时外推是线性估算,没有实测过大批量。

第一个位置是提交前的钩子。这里的要求是快。oxipng 的 4 级在 12 张上合计 4.7 秒,平均每张 0.39 秒。放进去感知不强。加 zopfli 之后平均每张 5.3 秒,一次提交带几十张图就要等几分钟。我不会放在这里。第二个位置是持续集成里的构建步骤。按线性外推,1000 张图用 oxipng 4 级加 zopfli 要 5,300 秒左右。约一个半小时。这个量级需要并行或增量,只处理新增和变更的文件。第三个位置是发布前的一次性批处理。耗时最不敏感,适合放最重的参数。

另一个问题是幂等。我把 12 张 oxipng 输出再用同样参数跑一遍,12 个文件的字节与第一次完全相同。合计仍然是 1,283,130。这意味着重复运行不会再改文件,也不会让哈希反复变化。增量判断可以直接比较输入和输出的哈希。这是在同一版本、同一参数下的观察。升级工具版本之后需要重新确认。

最后一个问题是顺序。前面测过,先减色后无损才有收益。流水线里减色因此要排在无损前面。无损这一步还应该是最后一道,后面不再接任何会重新编码图片的步骤。比如先无损优化,再交给某个会重新导出的工具裁剪或加水印。前面那一步就白做了。这条我是从 pngquant 重编码的观察推到整条流水线上的,属于推断。

测量方法自身的坑

这一章记录这次测量里我自己踩过或差点踩的几个坑。它们有的已经影响过结论。

第一个是 zopflipng 的快速模式,前面单独讲过。它的危害在于输出看起来完全正常。只是体积没变小,容易被当成工具不行。我现在会把工具参数连同版本一起写进每条记录。漏记参数的数据不再当作证据用。

第二个是分母。同一组数据,按原始体积算 oxipng 在照片上多省 4.0 个百分点。按减色后的体积算是 11.8%。任何一个“多省了 X”的说法,我现在都先要问分母是什么。

第三个是 IDAT 与文件体积的区别。我用 IDAT 字节数分析压缩数据流,但文件体积里还有签名、IHDR、PLTE、tRNS 和 IEND。Anime 的 pngquant 输出文件 54,292 字节,IDAT 54,028 字节,差 264 字节。照片 Directiva 的输出文件 145,573 字节,IDAT 144,635 字节,差 938 字节。其中 205 项调色板就占 615 字节。这些非 IDAT 的字节一般很小,但在几 KB 的小图标上会占到可见的比例。

第四个是参照尺的选择。我用 Python 的 zlib 9 级当尺子,但它不是下限。oxipng 和 zopfli 都能压到它下面。尺子只能说明这条数据流相对常见编码器的位置,不能说明还能压多少。

第五个是 zopflipng 对 tRNS 的改动。处理蓝星图标时,tRNS 的项数从 2 变成 3。处理 Anime 时从 9 变成 10。这意味着它在调色板和透明度上做了我没拆开的调整。我用逐像素比较确认了画面没变。但这个改动的原因我没查,也没单独统计它对体积的影响。

第六个是计时。所有耗时都是单次墙钟。没有控制后台任务,也没有重复取中位数。几个短到 0.05 秒的数,只能说明数量级。

第七个是类内差异。每类只有 3 张。其中一张的特例就能拉动整类的均值,照片类的 Alekszej 就是例子。分类汇总的百分点我只当作“这三张图加起来”的数,不当作这一类图的规律。

适用边界与风险提示

样本边界放第一位。12 张全部来自维基共享资源,作者和许可各不相同。不能代表手机截图、设计稿导出或扫描件。没有 16 位位深图、没有隔行扫描、没有动画 PNG,也没有检查过内嵌色彩配置文件。以上这些情形下的收益,本文没有数据。

参数边界放第二位。pngquant 只用了 65 到 80 的质量区间。另一档 80 到 95 只在第一轮里跑过体积,没有进入这次的拆解。oxipng 只用 4 级,没试过更高的级别。zopflipng 没加 -m,也没调迭代次数。换参数之后,上面所有排序都可能变。

还有一个不容易被注意的前提。我所有的无损对比都建立在同一份 pngquant 输出上,也就是说几个无损工具比较的是同一个输入。换成别的减色工具,调色板的排序方式、抖动方式和写文件时的压缩档位都可能不同,无损这一步的空间就会跟着变。这是推断,我没有换工具验证。

风险有三类,都属于使用层面的提醒。其一,oxipng 我用的是安全剥离。它会去掉部分非必需的块。具体去掉了哪些,我没有逐个统计。如果流程依赖 PNG 里的某些附加块,需要先在自己的文件上看一眼。其二,无损优化会让文件字节变化。凡是用文件哈希做缓存键、做版本比对或做签名校验的地方,在引入这一步之前要先想清楚。这是我从工程习惯出发的提醒,本次实测没有涉及。其三,有损那一步我没有比较画质。所有“省了多少”都建立在“你接受 pngquant 的输出”这个前提上。

时间边界也要写。所有数字都是 2026-10-29 当天的单次结果。工具版本升级之后,同样的参数可能给出不同的体积。

还有哪些没弄清

第一件事是滤镜与 deflate 各自的贡献。在滤镜被 oxipng 改过的 4 张图上,我拆不开两者。要做的实验是固定 deflate 实现,只替换滤镜。我没做。

第二件事是照片类的高收益。Alekszej 那一张降了 24.0%,是同类里最高的。但我没有查它为什么特别。可能与图本身有大片重复纹理有关。这只是猜测,没有证据。

第三件事是先无损再减色时图标类的 3.5% 差异。我猜是 oxipng 把真彩图转成了调色板,改变了 pngquant 的输入。但没有验证。

第四件事是 zopflipng 的 -m 和迭代次数。更多迭代能不能让 zopflipng 追上或超过 oxipng 加 zopfli,我没有测。不敢写“不如”。

第五件事是图映输出的 IDAT 为什么比 zlib 9 级大一截,位深为什么没有降到最小。这是编码层的设计选择。我没有看到实现,只能把现象记在这里。

第六件事是上面那个反例。Adaptability 的批量压缩输出调色板项数更多,两步无损之后体积却比我的路线小。颜色数和最终体积并不总是同向,我没有拆开原因。

第七件事是样本量,也是最大的一件。12 张图对一个规律来说太少,每类 3 张尤其如此。类间比较的稳定性我不能保证。如果有人用上千张图重做一遍,这些排序有可能改写。

动手之前先查哪几样

如果你想在自己的素材流水线里验证同样的问题,我会按这个顺序做。先挑几十张自己的真实图,不要用演示图。第一步,用 pngquant 减色并记下输出体积。第二步,用上面那个脚本拆开几张,看滤镜是不是全是 0 号。再看 IDAT 和 zlib 9 级差多少。这一步花不了一分钟,却能预判无损优化有没有空间。第三步,用 oxipng 4 级跑一遍。同时记体积和耗时,分母用 pngquant 输出的体积。第四步,用逐像素比较确认画面没变。同时对比处理前后“文件体积减去 IDAT”的差值,看看附加块被剥掉了多少。免得误以为全是压缩的功劳。第五步,如果耗时能接受,再试 oxipng 加 zopfli。对比多拿的字节和多花的时间。zopflipng 要用完整模式,别带 -q。

这五步做完,手里就有一张自己素材上的表。表里该看的是三列:减色后的体积、无损后的体积和耗时。拿到这张表以后,我会先看照片类和平涂类有没有分开统计。前面的数据显示这两类的空间来源不同,混在一起算均值会把差别抹掉。

参考资料