PNG压缩先看图是哪一类,先无损再量化多半白做

0 阅读46分钟

摘要:给自己的小站搭 PNG 压缩流水线,很容易先想到挑哪个压缩器,手边的候选是 pngquant、oxipng、zopflipng 这几个。我拿 12 张公开授权的 PNG 跑了一遍:透明图 6 张,照片和截图各 3 张。结论是挑压缩器之前要先知道图是哪一类。透明图的无损空间只有 11.4%,体积几乎全靠量化拿。照片和截图里藏着没用的 alpha 和很少的颜色数。光靠无损就能省两成多。顺序上先无损再量化的那一步在 12 张里有 8 张没留下一个字节。按类分流、只量化被规则判成透明的图,12 张合计省 36.0%。全部量化能省 71.2%,中间这段差距要自己拿主意。

版本声明:数据在 2026-11-05 测得。机器是一台 Apple M4 的 Mac(macOS 26.5.2)。用到三个命令行工具:pngquant 3.0.3、oxipng 10.2.1、zopflipng(zopfli 1.0.3)。分类脚本用 Python 3 加 Pillow 11.3.0。参数全文固定:oxipng 是 -o 4 --strip safe。pngquant 是 --quality=65-80 --speed 4。zopflipng 是 -y --filters=01234mepb。文中体积一律是文件的实际字节数,不换算成 KB。百分比没有特别说明时都相对原图。

适用边界:所有数字只对这 12 张图、这台机器、单次运行成立。透明组 6 张,照片组和截图组各只有 3 张,其中照片组还有 2 张是「RGBA 但 alpha 全是 255」。数字波动会很大。全文只比字节,没做任何画质评分。量化之后看起来差多少这篇回答不了。后面那套分流规则是我按这 12 张推出来的做法和假设,不是通用标准。

文章目录

一、选压缩器之前为什么要先问图是哪一类

  • 这次用的 12 张图和三个工具
  • 三组图省下的体积来自不同的步骤

二、给一张 PNG 分类要读哪几样东西

  • alpha 的最小值决定它算不算透明图
  • 颜色数有没有超过 256
  • 分类脚本和 12 张图的结果
  • 一张照片被规则判成了透明图

三、每一类图在哪一步省下体积

  • 透明图的体积几乎全靠量化
  • 照片 PNG 先拿掉没用的 alpha
  • 一张截图两档量化都用满256色

四、流水线里先无损还是先量化

  • 同一张图跑四种顺序
  • 先做的那遍无损多半被量化重写了
  • 量化之后那遍无损按什么算

五、按类分流的一种排法

  • 分流规则只认两类
  • 保守一点的代价有多大
  • 分流的账按组拆开看
  • 照片 PNG 换格式这条没测

六、压过的图会不会被流水线再压一遍

  • 已压过的文件再过一遍流水线
  • 记下哪些文件已经处理过

七、现成工具放在流水线的哪一段

八、测量方法自身的坑

九、适用边界与风险提示

十、还没解决的问题

参考资料

一、选压缩器之前为什么要先问图是哪一类

给小站做图片处理,最省事的想法是找一个「压得最狠」的工具,在 pngquant、oxipng、zopflipng 里挑一个当默认,所有 PNG 一股脑丢进去。可这三个工具干的不是同一件事。pngquant 做量化,它把颜色合并进一张调色板,像素会变。oxipng 和 zopflipng 只做无损优化,不碰像素值。它们只在 deflate 压缩、行过滤器和颜色类型上找余地。拿它们比谁压得小,就像拿换轮胎和调胎压比谁更省油。答案取决于车本来是什么状况。

PNG 也是这样,图本来是什么状况决定了哪一步有东西可省。一张带抗锯齿边缘的图标和一张截图在 PNG 里的存法可以完全不同。图标的 alpha 通道每一档都在用。这次的截图里就有两张带着从头到尾都是 255 的 alpha,它白占四分之一的原始数据。这篇先不比工具,先比图。

这次用的 12 张图和三个工具

样本是 12 张来自 Wikimedia Commons 的 PNG。许可是公有领域、CC0、CC BY 和 CC BY-SA,作者署名在文末。按内容分是图标、插画、照片、截图各 3 张。按透明与否合成三组,透明图 6 张就是图标加插画。它们的 alpha 从 0 到 255,边缘有真的半透明,合计 501,130 字节。照片类 PNG 3 张,合计 2,063,192 字节。截图 3 张,合计 1,890,089 字节。三组加起来是 4,454,411 字节。

照片组要单独交代一句。3 张照片全是 RGBA,其中 Alekszej 和 Steve Selvin 两张的 alpha 全是 255。这等于带了一个用不上的透明通道。Directiva 的 alpha 只落在 234~255 这个窄区间,出现过的取值一共 17 个(含 255)。截图组里 Talend 是 RGB。另外两张也是 RGBA 加全 255 的 alpha。真实站点里的图未必是这个比例。后面写分流规则时这一点会反复用到。

工具就是开头说的三个,oxipng 多跑了一个 --nx 档。这个开关会关掉位深、颜色类型、调色板排序这类无损缩减,只剩 deflate 和过滤器。拿它和默认档一对比就能把「重压」和「换存法」两部分拆开。

三组图省下的体积来自不同的步骤

先看总表。每格是该组压完的合计字节,括号里是相对原图省下的比例。

组原图oxipng 只重压oxipng 默认zopflipngpngquant 65–80pngquant 后再 oxipng
透明 6 张501,130444,175(11.4%)444,175(11.4%)429,153(14.4%)115,382(77.0%)104,707(79.1%)
照片 3 张2,063,1921,685,606(18.3%)1,551,821(24.8%)1,526,978(26.0%)694,735(66.3%)612,968(70.3%)
截图 3 张1,890,0891,615,228(14.5%)1,440,266(23.8%)1,492,922(21.0%)601,760(68.2%)565,455(70.1%)

先看透明组前两列,只重压和默认档的结果一模一样,都是 444,175 字节。oxipng 默认会做的那些颜色类型缩减,在 6 张透明图上一个都用不上。照片组和截图组就不一样了,默认档比只重压分别多省 6.5 和 9.3 个百分点。多出来的这部分来自换存法。

透明 PNG 和照片 PNG 两组在两种顺序下各一步省下多少

图里 9 张样本取自 Wikimedia Commons(许可为 CC0、公有领域、CC BY、CC BY-SA),作者列在文末。

这张图要看每条横条的颜色构成。上半部分是透明图,「先无损、再量化」那条里蓝色的只重压占 11.4%,深绿的量化占 67.7%。中间没有浅蓝的无损缩减段。「先量化、再重压」那条里量化一步就是 77.0%。后面橙色的再压只有 2.1%。下半部分是照片,「先无损」那条多出一截 6.5% 的浅蓝,这是去掉多余 alpha 这类无损缩减。先量化的话量化占 66.3%,再压占 4.0%。两种顺序的总长度在每组里一样:透明 79.1%,照片 70.3%。

这张图是把最终结果按步骤拆账。「先无损、再量化」那一条并不是真按这个顺序跑出来的。真按这个顺序跑会怎样,第四章单独测了。结果和拆账不太一样。

二、给一张 PNG 分类要读哪几样东西

分类的目的很实际:它决定这张图进流水线以后走哪条路。做这个决定用不着读懂整个 PNG 规范,读三样东西就够。

alpha 的最小值决定它算不算透明图

一张 RGBA 图有没有真的透明要看 alpha 通道的最小值。最小值是 255 说明每个像素都完全不透明。这个通道可以整个拿掉,像素颜色一点不变,原始数据从每像素 4 字节变成 3 字节。最小值低于 255 说明至少有一些像素是透明或半透明的。这时候去掉 alpha 就会改图。这个判断成本很低,解码一次、扫一遍 alpha 通道就有结果。最大值我也顺手读出来,它用来发现那种「整张只有一点点透明」的怪图。后面就碰到了一张。

颜色数有没有超过 256

第二样是图里实际出现了多少种颜色。PNG 的调色板最多 256 项。颜色数不超过 256 的图可以无损改存成调色板。每个像素只存一个调色板索引。这一步完全不动像素,属于无损缩减而不是量化。这次的 Steve Selvin 那张照片有 243,197 种颜色,远远超过 256。灰度照片、扁平插画、某些图表就未必会超。照片组里那张 Alekszej 只有 222 种颜色。

第三样是文件原本的颜色类型:RGB、RGBA 还是调色板。它本身不决定走哪条路,用处是分辨「这张图是不是带了一个用不上的东西」。RGB 图的 alpha 自然全是 255。打标签时要和「RGBA 但 alpha 全 255」分开。后者才是白占体积的那一类。

分类脚本和 12 张图的结果

我写了一段脚本,就读上面三样。它按 alpha 最小值、颜色类型、是否不超过 256 色给每张图打一个标签。颜色数用的是 Pillow 的 getcolors。给它的上限是 256,超过就返回 None,输出里只区分「具体几色」和「超过 256」。

import sys
from pathlib import Path
from PIL import Image

def kind_of(p):
    im = Image.open(p)
    rgba = im.convert("RGBA")
    a_lo, a_hi = rgba.getchannel("A").getextrema()
    few = rgba.getcolors(maxcolors=256)        # 超过 256 色返回 None
    ncol = len(few) if few else ">256"
    if a_lo < 255:
        tag = "真透明"
    elif im.mode in ("RGBA", "LA"):
        tag = "alpha全255"
    else:
        tag = "不透明"
    if a_lo == 255 and few:
        tag += "+色少"
    return im.mode, f"{a_lo}-{a_hi}", ncol, tag

if __name__ == "__main__":
    for f in sorted(Path(sys.argv[1]).glob("*.png")):
        mode, arange, ncol, tag = kind_of(f)
        print(f"{f.stem[:18]:<18} {f.stat().st_size:>9} {mode:<5} a={arange:<8} 色={ncol!s:<5} {tag}")

12 张图放在同一个目录里,我跑了一次,完整输出如下。

icon-Adaptability_     40369 RGBA  a=0-255    色=>256  真透明
icon-Afro_Mobile_I     23375 RGBA  a=0-255    色=>256  真透明
icon-Blue_star_ico    120393 RGBA  a=0-255    色=>256  真透明
illustration-Adeno     58718 RGBA  a=0-255    色=>256  真透明
illustration-Anime    220260 RGBA  a=0-255    色=>256  真透明
illustration-Apple     38015 RGBA  a=0-255    色=>256  真透明
photo-Alekszej_Fed    407150 RGBA  a=255-255  色=222   alpha全255+色少
photo-Directiva_ju    408175 RGBA  a=234-255  色=>256  真透明
photo-Steve_Selvin   1247867 RGBA  a=255-255  色=>256  alpha全255
screenshot-2011_Ta    312818 RGB   a=255-255  色=>256  不透明
screenshot-2019_06     85871 RGBA  a=255-255  色=>256  alpha全255
screenshot-2020_06   1491400 RGBA  a=255-255  色=>256  alpha全255

代码说明:convert("RGBA") 把 RGB 和调色板图都统一成四通道再读 alpha。RGB 图读出来自然是 255 到 255。标签里要再用原始 mode 区分「本来就没 alpha」和「有 alpha 但全是 255」。getcolors(maxcolors=256) 在颜色超过上限时返回 None。这是 Pillow 的约定行为,不是出错。文件名前面的 icon、illustration、photo、screenshot 是整理样本时加的前缀。脚本没有用它,标签完全来自像素。

6 张图标和插画都是「真透明」,这在意料之中。照片组和截图组里有 4 张是「RGBA 但 alpha 全 255」。只有 Talend 那张截图是正经的 RGB。12 张里有三分之一的文件带着一个没用的 alpha。这个比例不能外推,它只说明在这 12 张里这不是孤例。

一张照片被规则判成了透明图

输出里最值得停下来看的是第 8 行。Directiva 是一张合影照片,按内容我把它归在照片组。脚本给它打的却是「真透明」,因为它的 alpha 最小值是 234。

这张图的 alpha 只在 234 到 255 之间,一共 17 档,没有一个像素是全透明的。它大概率不是设计上就要透明的图。它更像是导出时被什么软件带上了一点透明度。可按规则说,它确实有半透明像素。把 alpha 拿掉以后它叠在非白背景上的样子就会变。「alpha 最小值低于 255 就算透明」这条规则判得没错。它只是和「人眼看这是一张照片」对不上。

对流水线来说 Directiva 会被分到透明图那条路上去量化。第五章算分流结果时它就在量化那一边。加一个阈值也是个办法,比如 alpha 最小值高于 200 就当不透明处理。可那样会改像素,等于用规则替用户做了一个画质决定。阈值怎么定我没测。我的打算是让规则保持简单,把这类图单独列出来给人看一眼,后面那段分流脚本还没做这一步。

三、每一类图在哪一步省下体积

分好类以后再回到第一章那张表。这次按类看每一步各拿走了多少。

透明图的体积几乎全靠量化

透明组的无损空间很窄,oxipng 只重压和默认档都是 11.4%。zopflipng 也只到 14.4%。原因前面说过,6 张图的 alpha 都有真半透明,颜色也都超过 256 种。去 alpha 和无损转调色板这两条路都走不通。剩下的只有重新挑过滤器、把 deflate 压得更紧。

量化一上来就是另一个量级,pngquant 65–80 一步省 77.0%。量化输出再交给 oxipng 重压一遍,相对原图又多出 2.1 个百分点。拿 Afro 那张 512×512 的图标看看块里的变化。原图是 RGBA,实际用了 1,035 种颜色。IDAT 解压后的原始数据是 1,049,088 字节。量化后它变成 23 色的调色板图。解压后的原始数据降到 262,656 字节。512 行,每行 1 个过滤器字节加 512 个索引字节,算下来正好是这个数。交给 deflate 的输入先缩成了原来的四分之一左右。文件从 23,375 字节降到 4,171 字节。

透明图上还有一个容易忽略的变量:量化档位。pngquant 不给 --quality 时走默认档。Afro 在默认档下保留了 256 色。文件 8,079 字节,几乎是 65–80 档 4,171 字节的两倍。换到默认档,6 张透明图每张都比 65–80 档大,倍数在 1.36 到 1.94 之间。Blue star 那张是 40,678 对 25,402。透明图挑压缩器不如挑档位。不同工具之间的差距很可能比同一个工具两个档位的差距还小。这句是推断,我手上能对照的第三方输出只有 3 张。

照片 PNG 先拿掉没用的 alpha

照片组的无损空间宽得多,oxipng 默认档省 24.8%。其中 18.3 个百分点来自重压。剩下 6.5 个百分点来自无损缩减,即换存法。

换存法的两个例子都很直观。Steve Selvin(904×901)存成了 RGBA,alpha 却每个像素都是 255,oxipng 把它改存成 RGB。IDAT 解压后的原始字节从 3,258,917 降到 2,444,413。文件从 1,247,867 字节降到 1,011,425 字节。只重压(--nx)的话文件是 1,090,639 字节。两者之间那 79,214 字节就是去 alpha 换来的。Alekszej 更极端,它是一张灰度照片,实际只有 222 种颜色。oxipng 直接把它换成 222 项的调色板来存。文件从 407,150 字节降到 167,225 字节(省 58.9%),像素一个没变。

量化在照片组上同样是大头,pngquant 65–80 一步省 66.3%。Steve Selvin 那张从 243,197 种颜色压到 232 色。这里得多想一步。一张照片被压到两百来种颜色以后渐变和皮肤上很可能出现色带。这次没比画质,它看起来怎么样我说不出,能说的只有字节上省了这么多。

一张截图两档量化都用满256色

截图组的无损部分是重压 14.5 加无损缩减 9.3,合计 23.8%。3 张里有 2 张是 alpha 全 255 的 RGBA,无损缩减应该主要来自这两张,逐张拆分我没做。The Book 那张 1873×1571 的截图改存成 RGB 以后 oxipng 输出 1,121,084 字节。比原图的 1,491,400 字节少了将近四分之一。

截图有一个和透明图相反的现象。量化档位几乎不影响结果。The Book 在 65–80 档和默认档下都保留了 256 色。文件是 486,607 对 486,605,只差 2 字节。我的理解是截图里有文字抗锯齿和界面渐变,颜色本来就多。两档都只能用满 256 色。至少在这张截图上,挑档位意义不大,要不要量化才是真正的选择。

四、流水线里先无损还是先量化

分类和每类的账算清楚后就轮到流水线的顺序了。直觉上先无损再量化更稳:先把不改像素的那部分省掉再动像素。第一章那张拆账图也是按这个顺序画的。可拆账图只说明最终结果里各步占多少。它没回答真按这个顺序跑会怎样。

同一张图跑四种顺序

我又写了一段脚本,对每张图跑四种输出:只无损、只量化、先量化再无损、先无损再量化。最后按文件名前缀分组加总。这里的分组用的是前缀而不是上一章的标签,原因第八章再说。

import subprocess, sys
from pathlib import Path

OXI = ["oxipng", "-o", "4", "--strip", "safe", "-q", "--out"]
PQ = ["pngquant", "--quality=65-80", "--speed", "4", "--force", "--output"]

def run(cmd):
    return subprocess.run(cmd, capture_output=True).returncode

def size(p):
    return Path(p).stat().st_size

src = Path(sys.argv[1]); out = Path(sys.argv[2]); out.mkdir(exist_ok=True)
tot = {}
for f in sorted(src.glob("*.png")):
    grp = f.stem.split("-")[0]
    o, q, qo, oq = (out / f"{f.stem}.{x}.png" for x in ("o", "q", "qo", "oq"))
    run(OXI + [str(o), str(f)])
    rc = run(PQ + [str(q), str(f)])
    run(OXI + [str(qo), str(q)])
    run(PQ + [str(oq), str(o)])
    row = [size(f), size(o), size(q), size(qo), size(oq)]
    print(f"{f.stem[:14]:<14} rc={rc}", *[f"{v:>9}" for v in row])
    t = tot.setdefault(grp, [0] * 5)
    for i, v in enumerate(row):
        t[i] += v
print("组合计        原图 / 无损 / 量化 / 量化→无损 / 无损→量化")
for g, t in tot.items():
    print(f"{g:<13}", " ".join(f"{v:>9}" for v in t))

完整输出如下。每行依次是原图、只无损、只量化、量化→无损、无损→量化的字节数。

icon-Adaptabil rc=0     40369     25828      7600      6954      7563
icon-Afro_Mobi rc=0     23375     15363      4171      3823      4171
icon-Blue_star rc=0    120393     97037     25402     20719     24121
illustration-A rc=0     58718     55605     13620     12894     13499
illustration-A rc=0    220260    217261     54292     50468     54292
illustration-A rc=0     38015     33081     10297      9849     10297
photo-Alekszej rc=0    407150    167225    131963    100267    131481
photo-Directiv rc=0    408175    373171    145573    125889    145573
photo-Steve_Se rc=0   1247867   1011425    417199    386812    417199
screenshot-201 rc=0    312818    254520     85816     80772     85816
screenshot-201 rc=0     85871     64662     29337     28256     29337
screenshot-202 rc=0   1491400   1121084    486607    456427    486607
组合计        原图 / 无损 / 量化 / 量化→无损 / 无损→量化
icon             184137    138228     37173     31496     35855
illustration     316993    305947     78209     73211     78088
photo           2063192   1551821    694735    612968    694253
screenshot      1890089   1440266    601760    565455    601760

代码说明:rc 是 pngquant 的返回码。12 张都是 0,说明 65–80 这个质量区间在每张图上都达得到,没有因为质量不够而放弃写文件。--force 让重复运行时能覆盖已有的输出。grp 取文件名第一个横杠前的前缀。图标和插画因此分成了两组,透明组 6 张要把 icon 和 illustration 两行加起来看。合计是 501,130、444,175、115,382、104,707、113,943。前四个数和第一章总表对得上。

先做的那遍无损多半被量化重写了

看最后两列,「量化→无损」在 12 张里每一张都是四种输出中最小的。「无损→量化」和「只量化」相比有 8 张的文件字节数完全相同。它们是 Afro、Anime、Apple、Directiva、Steve Selvin 和 3 张截图。剩下 4 张略小一点:Adaptability 7,600 到 7,563,Blue star 25,402 到 24,121,Adenosine 13,620 到 13,499,Alekszej 131,963 到 131,481。

道理不难想通:量化会把像素重新映射到一张新调色板上。写文件时 pngquant 又自己压了一遍 deflate。前面无损那一步挑过的过滤器和压紧过的 deflate 流在这时全部作废。前一步唯一可能留下影响的是它改了量化的输入。比如去掉 alpha 或换了存法以后 pngquant 读到的数据略有不同。那 4 张略小大概就是这个原因,具体机制我没查。按组看「无损→量化」的透明组是 113,943 字节(省 77.3%)。照片组 694,253 字节(省 66.4%)。截图组 601,760 字节(省 68.2%)。这和只量化的 77.0%、66.3%、68.2% 几乎一样。

流水线里只要有量化,前面再挂一遍无损基本是白跑。该挂的位置在量化之后。

量化之后那遍无损按什么算

量化之后再跑 oxipng 相对原图只多省透明 2.1、照片 4.0、截图 1.9 个百分点。光看这几个数这一步像是可有可无。换个基准算就不同了,照片组从 694,735 降到 612,968。相对 pngquant 的输出又省了 11.8%。最终放到线上的就是这个输出文件。用户每次加载付的流量也按它算。

按流量付带宽钱的小站更该按后一种算法看。它是最后一道工序,省下的每个字节都直接落在最终文件上。oxipng 这一步也不改像素,加上它没有画质风险。我会这么排:量化后接一遍无损,前面不挂。

五、按类分流的一种排法

前面几章的数加起来可以推出一套分流规则。这套是我按这 12 张图推出来的做法,有两条前提假设。一是透明图大多是图标、插画这类界面素材,为了体积可以接受量化。二是照片和截图更可能是用户上传的原图或者要保留细节的内容。不知道画质后果的时候我不想动它们的像素。

分流规则只认两类

规则很简单:标签是「真透明」的走量化再无损,其余的只走无损。下面这段直接复用了第二章脚本里的 kind_of 函数。

import subprocess, sys
from pathlib import Path
from sort_png import kind_of

def oxi(src, dst):
    subprocess.run(["oxipng", "-o", "4", "--strip", "safe", "-q", "--out", dst, src])

def quant(src, dst):
    subprocess.run(["pngquant", "--quality=65-80", "--speed", "4", "--force", "--output", dst, src])

src_dir, dst_dir = Path(sys.argv[1]), Path(sys.argv[2])
dst_dir.mkdir(exist_ok=True)
before = after = 0
for f in sorted(src_dir.glob("*.png")):
    tag = kind_of(f)[3]
    dst = dst_dir / f.name
    if tag == "真透明":
        quant(f, dst.with_suffix(".tmp.png"))
        oxi(dst.with_suffix(".tmp.png"), dst)
        dst.with_suffix(".tmp.png").unlink()
        how = "量化→无损"
    else:
        oxi(f, dst)
        how = "只无损"
    before += f.stat().st_size; after += dst.stat().st_size
    print(f"{f.stem[:18]:<18} {tag:<14} {how:<6} {f.stat().st_size:>9} -> {dst.stat().st_size:>9}")
print(f"合计 {before} -> {after},省 {1 - after / before:.1%}")

完整输出如下。

icon-Adaptability_ 真透明            量化→无损      40369 ->      6954
icon-Afro_Mobile_I 真透明            量化→无损      23375 ->      3823
icon-Blue_star_ico 真透明            量化→无损     120393 ->     20719
illustration-Adeno 真透明            量化→无损      58718 ->     12894
illustration-Anime 真透明            量化→无损     220260 ->     50468
illustration-Apple 真透明            量化→无损      38015 ->      9849
photo-Alekszej_Fed alpha全255+色少   只无损       407150 ->    167225
photo-Directiva_ju 真透明            量化→无损     408175 ->    125889
photo-Steve_Selvin alpha全255      只无损      1247867 ->   1011425
screenshot-2011_Ta 不透明            只无损       312818 ->    254520
screenshot-2019_06 alpha全255      只无损        85871 ->     64662
screenshot-2020_06 alpha全255      只无损      1491400 ->   1121084
合计 4454411 -> 2849512,省 36.0%

代码说明:量化的中间结果先写成 .tmp.png。再交给 oxipng 输出最终文件,随后删掉中间文件。这样输出目录里只留最终产物。分流只看标签不看文件名前缀,Directiva 就按「真透明」走了量化那条路。中文标签那几列对不齐,是因为中文字符在等宽终端里占两格而格式化宽度按一格算,不影响数字。

每张图走哪条路、压成多少都能和第四章那张表的「量化→无损」列或「只无损」列逐个对上。分流本身没有引入新的变化。

保守一点的代价有多大

把三种做法摆在一起算账,12 张全部只无损,合计 3,436,262 字节(省 22.9%)。按上面的规则分流是 2,849,512 字节(省 36.0%)。全部量化再无损是 1,283,130 字节(省 71.2%)。

分流里两部分的账也拆开看:7 张走量化的图是 6 张透明加 Directiva。它们从 909,305 字节降到 230,596 字节(省 74.6%)。5 张只无损的图从 3,545,106 字节降到 2,618,916 字节(省 26.1%)。整体只省 36.0% 是因为体积大头都在那 5 张里。单是 Steve Selvin 和 The Book 两张就占了原图总量的一半多。

36.0% 和 71.2% 之间这段差距就是「不动照片和截图的像素」要付的代价。值不值要看三样东西:这些图被加载多少次、带宽怎么计费、量化后的画质能不能接受。前两样每个站自己能算。第三样这篇没测,我不替谁下结论。我倾向于先按保守的规则跑。等给照片和截图补上画质对比再决定要不要把它们挪到量化那条路上。

Directiva 那张值得单独算一下。走量化它是 125,889 字节(省 69.2%)。如果被当成不透明照片只做无损,它是 373,171 字节,只省 8.6%。它的 alpha 不全是 255。oxipng 拿不掉这个通道。它的颜色又超过 256 种,无损缩减一样也用不上,无损这边就省得特别少。一张图被分到哪一类,结果能差出好几倍。我想把这类边界图单独拎出来给人看就是这个原因。

分流的账按组拆开看

36.0% 这个总数是三块拼起来的。12 张一共省下 1,604,899 字节。6 张透明图省了 396,423 字节,占 24.7%。Directiva 一张就省了 282,286 字节,占 17.6%。剩下 5 张只走无损的图省了 926,190 字节,占 57.7%。

这个比例和直觉有点拧着。量化那条路单张省得狠,透明图大多省到七成以上,可透明图本身小,6 张加起来才 501,130 字节。只无损那条路单张省得少,却因为图大,贡献了一大半的字节。体积账最后落在绝对字节上,不是百分比上。在这 12 张里,盯住最大的那几张比纠结小图标压到第几档更有用。

5 张只无损的图各自省得也不一样。Alekszej 省 58.9%,它只有 222 色,能无损改存成调色板。The Book 省 24.8%,OpenProject 省 24.7%,两张都是 RGBA 加全 255 的 alpha。Steve Selvin 省 18.9%,Talend 省 18.6%。Talend 本来就是 RGB,没有 alpha 可拿,只剩重压。Steve Selvin 虽然去掉了 alpha,但它是照片,我猜是像素杂、重压的余地本来就小。

Steve Selvin 和 The Book 两张的原图合计 2,739,267 字节,占 12 张原图的 61.5%。它们只走无损一共省了 606,758 字节。分流规则怎么处理这两张,基本决定了整体数字落在哪。以后如果补上画质对比、愿意让照片和截图也走量化,我会先挪这种又大又占比高的图,而不是一次全量切换。这是做法上的打算,没有实测过。

再按文件名前缀把三组各算一遍。照片组 3 张按分流规则合计 1,304,539 字节,省 36.8%。其中 Alekszej 和 Steve Selvin 只无损,Directiva 走了量化。照片组如果按内容全部只走无损,就是第一章总表里的 1,551,821 字节、省 24.8%。两者之间的差几乎都来自 Directiva 被规则分到了量化那边。截图组 3 张都只无损,结果就是总表里的 1,440,266 字节、省 23.8%。透明组 6 张都走量化再无损,结果就是 104,707 字节、省 79.1%。

照片 PNG 换格式这条没测

还有一条路这次完全没碰。照片存成 PNG 本来就未必划算。换成 JPEG 或 WebP 也许比在 PNG 里折腾省得多。可换格式牵扯到透明能不能保留、引用的地方要不要改扩展名、画质怎么比。这几样这次都没测,我不想凭印象给数字。站里照片 PNG 占大头的话这条路值得单独测一次。

六、压过的图会不会被流水线再压一遍

搭流水线还有一个容易漏掉的问题。已经处理过的图下次构建会不会被再处理一遍?如果构建脚本是扫一遍图片目录全部重跑,压过的文件又写回原位,下一次它就会作为「原图」再进流水线。

已压过的文件再过一遍流水线

我拿两张已经走过「量化→无损」的输出试了一下。一张是 Afro 的图标(3,823 字节、23 色)。另一张是 Steve Selvin 的照片(386,812 字节、232 色)。脚本对每张做三件事:带 --skip-if-larger 再量化一次,不带再量化一次,再跑一次 oxipng。

import subprocess, sys
from pathlib import Path
from PIL import Image

def colors(p):
    return len(Image.open(p).convert("RGBA").getcolors(1 << 20))

def pq(src, dst, guard):
    cmd = ["pngquant", "--quality=65-80", "--speed", "4", "--force", "--output", dst, src]
    if guard:
        cmd.insert(1, "--skip-if-larger")
    return subprocess.run(cmd).returncode

for done in sys.argv[1:]:
    stem = Path(done).stem
    print(f"{done}: {Path(done).stat().st_size} B, {colors(done)} 色")
    for guard in (True, False):
        dst = f"{stem}.re{'-guard' if guard else ''}.png"
        rc = pq(done, dst, guard)
        if Path(dst).exists():
            print(f"  再量化 guard={guard} rc={rc} -> {Path(dst).stat().st_size} B, {colors(dst)} 色")
        else:
            print(f"  再量化 guard={guard} rc={rc} -> 没写文件")
    subprocess.run(["oxipng", "-o", "4", "--strip", "safe", "-q", "--out", f"{stem}.re-oxi.png", done])
    print(f"  再 oxipng -> {Path(stem + '.re-oxi.png').stat().st_size} B")

两张图依次叫 logo-done.png 和 pic-done.png。完整输出如下。

logo-done.png: 3823 B, 23 色
  再量化 guard=True rc=98 -> 没写文件
  再量化 guard=False rc=0 -> 3838 B, 16 色
  再 oxipng -> 3823 B
pic-done.png: 386812 B, 232 色
  再量化 guard=True rc=98 -> 没写文件
  再量化 guard=False rc=0 -> 369937 B, 145 色
  再 oxipng -> 386812 B

代码说明:colors 统计的是解码成 RGBA 后实际出现的颜色组合数。上限给到 2 的 20 次方,足够覆盖这两张图。guard 为真时在命令里插入 --skip-if-larger。pngquant 返回 98 表示没有生成文件。脚本用「输出文件存不存在」来判断,没有只看返回码。

这组结果说明了三件事。第一,在这两张上 oxipng 表现得是幂等的,再跑一遍字节数完全不变。重复跑只浪费构建时间,不会把图越压越坏。

第二,不带保护开关的 pngquant 不幂等。图标从 23 色变成 16 色,文件反而大了 15 字节。照片从 232 色变成 145 色,文件又小了 4.4%。两张图的像素都变了,照片这种情况最危险。体积在往下走,看着像「又优化了一点」。实际是在丢颜色,这次只跑了一轮。

第三,带 --skip-if-larger 时两张都没写文件。连那张会变小的照片也被挡住了。pngquant 帮助里对这个开关的说明是「只在比原文件小时才保存」。可 369,937 明明比 386,812 小。它为什么挡住了一个更小的结果我没读源码,原因没查。能确定的只有这次的现象:这个开关挡住了两次重复量化,判定条件却不只是比原文件小。它不能当成防重复量化的保证。

记下哪些文件已经处理过

防重复处理更稳的办法,是别让流水线靠图本身去猜它压没压过。我能想到三种做法,它们都是假设,没在真实站点上跑过。

第一种是原图和产物分两个目录。流水线只读原图目录、只写产物目录。产物永远不会回流成输入,这最省事,代价是仓库里多存一份原图。第二种是维护一份清单,记下每个原图的哈希和它对应的产物。构建时哈希没变就跳过。这样连 oxipng 那种幂等但费时的步骤也一起省了,适合图多的站。第三种是在文件名里加后缀,比如处理过的图叫 xxx.min.png。流水线跳过带后缀的文件。它很简单,可改名会牵扯到所有引用它的地方。

我倾向于第一种。多存一份原图花的是磁盘,排查「这张图为什么越来越糊」花的是人,前者便宜得多。

七、现成工具放在流水线的哪一段

不是每张图都会走命令行。外包客户发来的 banner、自己随手截的图,有可能先在网页工具里压过一遍才进仓库。这类文件进流水线时分类脚本照样会读它。它已经被量化过了,再走量化那条路就是上一章说的重复量化。

拿网页工具的输出做个检查是值得的。我手上有 3 张 iLoveIMG 的输出。块里看全是调色板图:Afro 23 色、Anime 32 色、The Book 256 色。交给 oxipng 三张都是 0 字节可省。它内部用什么算法我不知道,从块里猜,这 3 张像是量化之后又做过无损优化,这只是推断。TinyPNG 官网自己的说法是靠减少颜色数来压 PNG。我没拿到它的输出,不往下推。

不想在本机装命令行工具的那部分图,我用的是图映 ImgIng imging.cn/ 的图片压缩,机器还是这台 Apple M4 Mac。它在电脑浏览器里本地处理,支持批量。内部细节我不清楚,我猜它和这类浏览器端压缩工具一样,走的也是减色再重压的路子。它的输出进流水线时我会和 iLoveIMG 的输出一样当作「已量化」处理。

落到流水线上我给分类再加一条判断。文件本身已经是调色板图(颜色类型 3)的直接只走无损。少数本来就用调色板存的原图会因此错过量化。这个我能接受,它们颜色本来就不超过 256,再量化的收益有限。这条规则没在这 12 张上测,因为 12 张原图里没有一张是调色板图。

八、测量方法自身的坑

这一章把会影响读数的地方列出来。每一条都是读前面那些数字之前该先知道的。

分组口径有两套。 第一章和第四章按文件名前缀分组,Directiva 在照片组。第二章和第五章按 alpha 规则分类,Directiva 在透明那一边。前缀分组对应图的内容,规则分类对应流水线实际怎么走。两套口径下说的「照片」不是同一批图。按前缀,照片是 Alekszej、Directiva、Steve Selvin 三张。按规则,「alpha 全 255」和「不透明」那 5 张里是 2 张照片加 3 张截图,Directiva 不在里面。第五章的「7 张走量化」不是第一章的「透明 6 张」,「5 张只无损」也不是照片组加截图组的 6 张。读数字时先看是哪一套再去对表。两套我没有合成一套,因为它们回答的问题不同。前缀分组用来看这类内容在哪一步省,规则分类用来看流水线这样排会落到什么结果。

alpha 档数的口径。 文中说 Directiva 的 alpha 有 17 档,Afro 原图有 240 档。这里的「档」是图里实际出现的不同 alpha 取值的个数,0 和 255 只要出现就各算一档。Afro 量化到 23 色以后 alpha 是 7 档,这 7 档里同样包含全透明的 0 和完全不透明的 255。分类脚本本身不数档数,只读最小值和最大值,档数是另外拆块时数的。「最小值 234」和「17 档」是两个量。前者决定分类,后者说明它的透明度只在很窄的范围里变。拿档数去和别处的数比,要先确认对方是不是也把 0 和 255 算了进去。

只比了字节,没比画质。 量化动了像素,省得多不等于更好。照片从 243,197 种颜色压到 232 色,截图保留 256 色。看起来有没有色带这次一张都没比。pngquant 的 65–80 是它自己估的质量区间,返回码 0 只说明它自己认为达到了,不等于人眼看着没问题。第五章把照片和截图留在无损那边,就是因为画质这一项是空白。

样本少,而且只跑了一次。 透明组 6 张,照片和截图各 3 张。照片组的 3 张里有 2 张其实是不透明的 RGBA。照片组无损能省 24.8%,这个数被这两张拉得很高。换一批真正用 RGB 存的照片,很可能只剩重压那部分。所有命令都只跑了一次,没有重复运行。体积是确定的文件字节,不像耗时那样会抖。可同一个工具在不同次运行里是不是逐字节输出一致,我没有专门验证。能对上的只有两处:第四章的组合计和第一章总表一致,第五章的分流输出和第四章逐张一致。这只说明这两处的调用前后对上了,推不到别的参数和别的图上。

zopflipng 的数和 2026-10-29 那次不一样。 参数相同。照片组这次是 1,526,978,10-29 是 1,624,722。截图组这次是 1,492,922,10-29 是 1,584,237。同样两次测量里,oxipng、pngquant 和两者串联的数字完全一致,偏偏 zopflipng 单独压原图这一项变了。原因没查,我只把这一列当单次结果看。文中没有任何结论建立在 zopflipng 的数上。第一章总表列它,只是让人看到无损这边另一个工具的量级。

--skip-if-larger 返回 98 只记了现象。 第六章那两个返回码 98 是真实输出,两张图都没有写出文件。照片那张不带开关时会得到更小的文件,带了开关反而没写。帮助里那句说明和这个现象对不上,具体判定条件这次没查,文里也不猜。流水线里用到这个开关时,能确定的只有两件事。返回码 98 时没有输出文件,脚本要按文件存不存在来处理。它不能当作防重复量化的依据。

「色数」在文中有两种意思。 一种是解码后图里实际出现的颜色组合数,比如 Afro 原图 1,035 种、Steve Selvin 243,197 种。另一种是调色板里写了几项,比如 pngquant 输出的 23 色、232 色。前者数的是像素,后者数的是文件里的表。一张调色板图实际用到的颜色不会多于调色板项数,但两者不一定相等。第六章那段脚本里的「几色」用的是前一种,它把图解码成 RGBA 再数。拿 23 色和 16 色比的时候,两边是同一种数法,可以直接比。拿它和别处说的「调色板 256 项」比就要小心。

无损那一列里混着元数据。 oxipng 用的是 --strip safe,它会去掉一部分辅助块。Steve Selvin 原图里 IHDR、PLTE、IDAT、IEND 以外的块共 50 字节(sRGB、gAMA、pHYs),oxipng 输出里剩 34 字节,zopflipng 输出里是 0。这几十字节放在一百多万字节的图上可以忽略,可在几 KB 的小图标上就不一定。读「只无损省了多少」时要知道,里面有一小部分不是压缩省的,是删掉的。

脚本输出里文件名被截断了。 第四章的脚本只打印文件名前 14 个字符,三张插画都显示成 illustration-A,两张截图有两行都是 screenshot-201。第二章和第五章截到 18 个字符,那里还分得开。逐张对数字时得按行序去对原文件名,不能只看那一列。这是我为了让输出排得下截的,图省事,留了个坑。

脚本没有检查每一步是否成功。 第四章那段脚本在读文件大小之前,只记了 pngquant 的返回码,没有在失败时跳过。加上 --force 会覆盖旧文件,如果某一次量化没写出文件,脚本读到的可能是上一次运行留下的旧输出,也可能直接报错。这次 12 张返回码都是 0,没有踩到。拿它去跑别的图之前,应该先补上「返回码不为 0 或文件不存在就跳过这一行」的判断。

颜色数上限是脚本定的。 分类脚本给 getcolors 的上限是 256,超过只显示「超过 256」,这对分类够用。可它说不出一张图到底是 300 色还是 30 万色。想按颜色数再细分就得把上限调大。

百分比的基准要看清。 全文百分比默认相对原图,只有第四章那个 11.8% 相对 pngquant 的输出。照片组量化后再跑 oxipng,相对原图是 4.0 个百分点,相对量化输出是 11.8%,两个数说的是同一件事。第五章还有一种占比,是各部分省下的字节占总共省下字节的比例,比如 5 张只无损的图占 57.7%。它和「相对原图省了多少」分母不同,不能直接相加,也不能拿来互相比。

九、适用边界与风险提示

所有数字只对这 12 张 Wikimedia Commons 图成立。条件是这台 Apple M4 Mac、pngquant 3.0.3、oxipng 10.2.1、单次运行。换工具版本或换参数结果都会变。换 pngquant 的质量区间变化尤其大。测试只在 macOS 上跑过,Linux 服务器上同版本的工具是不是逐字节一致没测。

这次 12 张图 pngquant 的返回码都是 0,65–80 这个区间在每张图上都达到了。换一批图就未必。流水线里每一次调用 pngquant,都该按返回码和输出文件存不存在分开处理。没写出文件时退回只无损那条路。这一条这次没有实际触发过,是按第六章的现象推出来的做法。

第五章的分流规则建立在两条假设上:透明图可以接受量化,照片和截图不动像素。这两条都是我的取舍,不是测出来的结论。透明图如果是对边缘要求很高的品牌 logo,第一条就可能反过来。照片如果只是对细节不敏感的缩略图,第二条也可能反过来。

分类规则会把边界图分错方向。Directiva 这次被分到量化那边,一张图就贡献了分流总节省的 17.6%。规则分错一张图,总数会明显变化,而且是往「省得更多」的方向变,只看数字的人不会觉得哪里不对。这类图在别的图库里有多少,从这 12 张推不出来。比较稳的做法是让分类脚本把 alpha 最小值不是 255、但又很接近 255 的图单独打印出来,人看过再定走哪条路。「很接近」的线划在哪,这次同样没测。

样本都来自 Wikimedia Commons,许可里有 CC BY 和 CC BY-SA。拿它们做测试、在文中引用数字没有问题。如果要把压缩后的产物放到自己的站上,署名和相同方式共享的要求照样跟着图走,不会因为文件被重新编码就消失。

量化会改像素,在任何流水线里都要单独对待。保留一份原图是我觉得最不该省的。第六章那个重复量化的例子说明,原图一旦丢了,流水线每多跑一轮图就可能多丢一些颜色。从文件体积上还看不出来。

无损那条路也会改文件的格式,只是不改像素。oxipng 把 alpha 全 255 的 RGBA 改存成 RGB,把 222 色的灰度照片改存成调色板图。像素一个没变,可颜色类型变了。站里如果有别的代码读这些图,并且默认 PNG 一定是 RGBA,比如直接按四通道取数据,换存法以后就可能出错。这一点这次没测,是按颜色类型的变化推出来的风险。接进流水线之前,最好先查一遍有没有代码对颜色类型做了假设。

工具版本也是流水线的一部分。这次的结果绑定在 pngquant 3.0.3 和 oxipng 10.2.1 上。版本一升,同一张原图压出来的字节可能不一样,旧产物和新产物就不再是同一套口径。我的做法是把版本写死在构建环境里,升版本时整批重跑一遍,而不是让新图和旧图混着两个版本的结果。这是做法上的假设,这次没有拿两个版本对比过。

这篇只讨论 PNG 进、PNG 出这一种情况。输入里没有 JPEG 和 WebP,输出也没有换格式。分辨率一张都没改,缩放这一步不在流水线里。真实的小站往往会先按展示尺寸缩图再压缩。缩过的图颜色数和 alpha 分布都会变,分类结果也可能跟着变。先缩还是先分类,这次没测。我会把缩放放在分类前面,因为最终落到线上的是缩过的图,分类理应看它。这同样只是做法上的假设。

分流规则只看了像素,没有看图的用途。同一张透明图,当页面角落的小图标和当首屏的大插画,能接受的量化程度可能完全不同。规则里没有地方表达这种差别。要表达它,流水线就得知道每张图用在哪,这需要额外的配置或者约定的目录结构。这次的 12 张没有用途信息,这一层只能留给各站自己补。

这次的结论也没有考虑原图从哪来。12 张都是公开图库里现成的文件,它们各自经过什么软件导出不清楚。照片组有两张带着全 255 的 alpha,截图组也有两张。这种情况在自己站里是多是少取决于素材来源和导出习惯,我没有数据。如果站里的图大多出自同一个设计软件的同一套导出设置,分类结果很可能高度集中在某一类上。那样的话分流规则可以更简单,甚至只留一条路。先拿自己的图跑一遍第二章的分类脚本看看分布,再决定流水线要几条分支,是我觉得最省事的起点。

字节省下来以后能不能变成带宽账单上的数字,还要看图是怎么发出去的。如果站点前面有 CDN 或图片服务会再处理一遍,比如按设备转格式、重新压缩,流水线省下的字节就未必原样到达用户。这一层这次完全没碰,我只能说到文件为止。

文中没有任何耗时数据。搭流水线前最好拿自己的图量一下处理时间,再决定哪些步骤放进每次构建。

十、还没解决的问题

最大的空缺是画质,照片和截图能不能也走量化得先有一套画质对比。至少把量化前后的图并排看一遍,最好再算一个客观指标。这一步做完第五章那段 36.0% 到 71.2% 的差距才真正能拿主意。

第二个是 Directiva 这种边界图。alpha 只有一点点不透明的照片该当透明图量化,还是先把 alpha 拉满再当照片处理?后者会改像素,前者可能浪费量化的空间。阈值定在哪我还没有答案。

第三个是 --skip-if-larger 的判定条件。弄清楚它为什么挡住一个更小的结果才能判断它在流水线里能不能当保险用。弄清之前不该依赖它,防重复处理用目录分离更稳。

第四个是重复处理只测了一轮。已压过的图再量化一次,图标从 23 色掉到 16 色,照片从 232 色掉到 145 色。再跑第二轮、第三轮会不会继续往下掉,掉到哪里停住,这次没有跑。要判断「忘了防重复」的后果有多严重,得把同一张图连续过几轮流水线,看颜色数和字节怎么走。

参考资料

  • pngquant 3.0.3 命令行帮助(--quality、--skip-if-larger、--force 等参数说明)。
  • oxipng 10.2.1 命令行帮助(-o、--strip safe、--nx 参数说明)。
  • zopfli 项目中的 zopflipng 工具说明(--filters 参数)。
  • W3C PNG Specification(颜色类型、PLTE、tRNS、IDAT 与行过滤器的定义)。
  • Pillow 文档 Image.getcolors 与 Image.getextrema。
  • TinyPNG 官网说明(2026-11-05 查阅):PNG 通过减少颜色数来压缩。

样本署名(Wikimedia Commons,作者按 Commons 元数据):