11 行 Python 拆 PNG 块,解压后的字节数能手算

要在商品图链路里加一道 PNG 有损压缩,评审时最难答的问题不是能省多少,而是省下的那部分到底是哪一步给的。压缩器自己只报一个总数:oxipng 最后一行写着 Total saved,pngquant 只说写出了一张几色的图。光看这两个数说不清改动的影响面。颜色被改了和数据压得更紧了是两回事:前者动了像素,后者没动。我干脆把 PNG 拆开看块,下面这段 Python 一共 11 行,只用标准库 struct 和 zlib。它读出颜色类型、调色板色数、IDAT 总字节,还有 IDAT 解压后的原始字节。我拿一张 512×512 的透明图标跑了三次。pngquant 量化之后 IDAT 从 23,294 字节降到 4,015 字节。解压后的原始字节从 1,049,088 降到 262,656。再交给 oxipng 文件只多省 348 字节,这 348 字节全部出在 IDAT 里。更有用的是解压后那个数,它能用宽高和颜色类型直接算出来。算得对上,就能反过来判断压缩器动没动颜色类型。

拆块脚本只用标准库

PNG 的结构很规整:开头是固定的 8 字节签名,后面是一个接一个的块。每块依次是 4 字节长度、4 字节类型、数据本身和 4 字节 CRC。像素数据可能被切成好几个 IDAT 块。把它们按顺序拼起来才是一整段 zlib 流。解压出来就是带过滤器字节的原始扫描行。脚本就做这件事:从第 8 字节开始逐块往后跳。IHDR 里取宽、高、位深和颜色类型。PLTE 的长度除以 3 就是调色板项数。tRNS 的长度就是写了几项透明度。IDAT 全部拼好以后统一解压。

python 复制代码
import zlib, struct, sys
d = open(sys.argv[1], 'rb').read(); p = 8; idat = b''
while p < len(d):
    n, = struct.unpack('>I', d[p:p+4]); t = d[p+4:p+8].decode()
    if t == 'IHDR': w, h, bd, ct = struct.unpack('>IIBB', d[p+8:p+18]); print(f'IHDR {w}x{h} bit_depth={bd} color_type={ct}')
    elif t == 'PLTE': print(f'PLTE {n//3} colors')
    elif t == 'tRNS': print(f'tRNS {n} entries')
    elif t == 'IDAT': idat += d[p+8:p+8+n]
    else: print(f'{t} {n} bytes')
    p += 12 + n
print(f'IDAT total {len(idat)} bytes, inflated {len(zlib.decompress(idat))} bytes, file {len(d)} bytes')

几处写法是故意偷懒的:CRC 没有校验,p += 12 + n 直接跳过。这里只关心字节数。文件真坏了 zlib 解压那一步自己会报错。其他块统一走 else 分支,只打印类型和长度。输出里那行「IEND 0 bytes」就是这么来的。它排在 IDAT 汇总前面,因为 IDAT 那行要等整个文件扫完才打印。PLTE 和 tRNS 我只打了项数。想看 tRNS 每一项是多少,把那一行的 d[p+8:p+8+n] 转成列表打出来就行。

量化前后各跑一次

样本是 Wikimedia Commons 上一张公有领域的透明图标 Afro Mobile Icon。它是 512×512 的 RGBA 图,文件 23,375 字节。下面的 icon.png 是原图。icon-q.png 是 pngquant 3.0.3 用 --quality=65-80 --speed 4 量化的结果。icon-qo.png 是把 icon-q.png 再交给 oxipng 10.2.1 用 -o 4 --strip safe 重压的结果。我跑的机器是一台 Apple M4 的 Mac。每个只跑一次。下面是三次的完整输出。

python 复制代码
$ python3 chunks.py icon.png
IHDR 512x512 bit_depth=8 color_type=6
IEND 0 bytes
IDAT total 23294 bytes, inflated 1049088 bytes, file 23375 bytes
$ python3 chunks.py icon-q.png
IHDR 512x512 bit_depth=8 color_type=3
PLTE 23 colors
tRNS 6 entries
IEND 0 bytes
IDAT total 4015 bytes, inflated 262656 bytes, file 4171 bytes
$ python3 chunks.py icon-qo.png
IHDR 512x512 bit_depth=8 color_type=3
PLTE 23 colors
tRNS 6 entries
IEND 0 bytes
IDAT total 3667 bytes, inflated 262656 bytes, file 3823 bytes

先看第一次到第二次:color_type 从 6 变成 3,也就是从 RGBA 变成了调色板图。新出现的 PLTE 只有 23 项、tRNS 写了 6 项。pngquant 的日志里写着这张图原本有 1,035 种颜色。量化后只剩 23 色,这一步改的是像素,属于有损。解压后的原始字节从 1,049,088 掉到 262,656。IDAT 从 23,294 掉到 4,015。再看第二次到第三次:color_type、PLTE、tRNS 三个结构字段一个没变。解压后也还是 262,656 字节。变的只有 IDAT,从 4,015 到 3,667 差了 348 字节。文件从 4,171 到 3,823 差的也是 348。oxipng 那次运行最后一行报的 Total saved 正好是 348 bytes。量化之后这一轮重压只是把同一段原始数据压得更紧。像素和调色板都没碰。这个比例在透明图上不是孤例:6 张透明 PNG 合计下来 pngquant 65--80 一步省掉 77.0%。之后再跑 oxipng 只多省 2.1 个百分点。

只做无损的话,透明图能省的很有限。同一张 Afro 原图直接交给 oxipng 是 15,363 字节。换成 zopflipng 是 15,023 字节。拆出来两者的 color_type 都还是 6,解压后都还是 1,049,088。差别全在 IDAT 上,分别是 15,306 和 14,966。这张图的 alpha 有 240 档。边缘真有半透明。去 alpha、转调色板这类无损缩减一个都用不上,只能在过滤器和 deflate 上抠。6 张透明图合计 oxipng 无损省 11.4%,zopflipng 省 14.4%。这两个单次的数和量化那一步的 77.0% 不在一个量级。

解压后的字节数可以手算

inflated 这一列看着像压缩器的成绩。其实它跟压缩器没关系。PNG 的原始扫描行每行开头有 1 个过滤器类型字节,后面跟这一行的像素。解压后的长度就等于「高 ×(宽 × 每像素字节数 + 1)」。Afro 是 512×512 的 8 位 RGBA,每像素 4 字节。512 ×(512 × 4 + 1)= 1,049,088 和第一次输出一字不差。量化成 8 位调色板以后每像素只占 1 字节索引。512 ×(512 + 1)= 262,656,也对得上。

每行开头那个字节就是过滤器类型:0 到 4 分别是 None、Sub、Up、Average、Paeth。下面的过滤器分布是另外统计的,上面那 11 行没做这一步。要加也不难:解压后按「每行字节数 + 1」切开,取每段第一个字节计数就行。Afro 原图 512 行里 None 94 行、Sub 206 行、Up 26 行、Average 33 行、Paeth 153 行。oxipng 重压后 512 行全换成了 None。pngquant 的输出也全是 None。过滤器怎么换都不影响解压后的长度,每行永远只占 1 个字节。这个数只由宽高、颜色类型和位深决定。同一张图不管谁来压、压得多狠,只要颜色类型和位深一样,它就一样。这个公式只对非隔行扫描的图成立,IHDR 数据里第 13 个字节是隔行标志,上面的脚本没有读它。本文这几张图的数字全都和公式对得上,说明它们都不是 Adam7 隔行图。

换一张照片类 PNG 看得更清楚。Steve Selvin 那张是 904×901 的 RGBA,但 alpha 全是 255,等于带了一个没用的透明通道。原图解压后 3,258,917 字节,就是 901 ×(904 × 4 + 1)。oxipng 默认参数压完颜色类型从 RGBA 变成 RGB,解压后是 2,444,413。这正好是 901 ×(904 × 3 + 1)。这一步没改任何像素值,只是去掉了多余的通道,属于无损的颜色类型缩减。

给 oxipng 加上 --nx 就能把这类缩减关掉。它只会重选过滤器、重做 deflate。这时文件是 1,090,639 字节。解压后仍是 3,258,917。一个字节没变。变的是每行用的过滤器:原图有 774 行用 Average,重压后 Up 占了 436 行。pngquant 量化成 232 色调色板以后解压后是 815,405,也就是 901 × 905。IDAT 是 415,784 字节。再交给 oxipng 后 IDAT 变成 386,013,解压后还是 815,405。这一轮还顺手去掉了 gAMA 块。其他块从 50 字节变成 34 字节。这是 --strip safe 的行为。跟像素无关。拆块时看到辅助块少了不用紧张。PNG 规范里块类型首字母大写的是关键块,小写的是辅助块,gAMA、sRGB、pHYs 都属于后者。照片那张量化前后都留着 sRGB,只少了 gAMA。我猜是因为有 sRGB 时 gAMA 本来就会被忽略,oxipng 才敢删它,这一点我没去翻它的源码确认。

截图一样对得上:The Book 是一张 1873×1571 的 RGBA 截图。原图解压后 11,771,503 字节,等于 1571 ×(1873 × 4 + 1)。去掉 alpha 后是 8,829,020,量化后是 2,944,054,每一个都能按公式算出来。所以一个输出的 inflated 变了,别急着读成压得好,先去对 color_type 和位深,这个数一变就说明颜色的存法变了。位深低于 8 时公式里的「宽 × 每像素字节」要换成「宽 × 位深 ÷ 8,向上取整」,因为一个字节里装了不止一个像素。透明组里 pngquant 给 Blue star 写的就是 4 位调色板,inflated 落到 2,213,134,比原图 17,682,489 的四分之一还低,差的那部分就出在这里。

两列数字能看出压缩器做了什么

把 inflated 和 IDAT 两列放在一起看,基本能分出三种情况。第一种是 inflated 没变、只有 IDAT 变小。这说明只是换了过滤器或 deflate 参数。像素一点没动。第二种是 inflated 变小,color_type 从 6 变成 2。这说明去掉了全不透明的 alpha。像素同样没动。第三种是 color_type 变成 3。这时要再看调色板项数和原图实际用了几种颜色。原图本来就不到 256 色的话,转调色板可以是无损的。Alekszej 那张灰度照片就属于这种,原图只出现过 222 种颜色,oxipng 不合并任何颜色就能存成 222 项调色板(407,150 → 167,225 字节)。原图上千色、调色板只剩几十项的那就是量化了。Afro 就是 1,035 色到 23 色。

同一个颜色类型下体积还差在留了几色。pngquant 不设 quality 的默认档在 Afro 上留了 256 色,文件 8,079 字节。65--80 档是 23 色 4,171 字节。两份解压后都是 262,656 字节,IDAT 却差了将近一倍。原始数据一样长,索引值种类越少 deflate 越好压。所以比较两份有损输出,先看各自留了几色,再看是哪个压缩器给的。

这套读法也能拿去看别人家的输出,不用知道对方的算法。iLoveIMG 压出来的 Afro 我也拆过。调色板 23 项、tRNS 7 项、IDAT 3,767 字节、解压后 262,656 字节。再交给 oxipng 一个字节都没省下来。The Book 那张它输出的是 256 色调色板。文件 459,187 字节,IDAT 458,350。解压后 2,944,054,和公式对得上。再跑 oxipng 同样省 0 字节。本机 pngquant 再 oxipng 那一份 IDAT 是 455,590。两边差得不多。tRNS 项数比 PLTE 少也不是缺数据。PNG 规范允许 tRNS 只写到最后一个带透明的项,没写到的默认完全不透明。pngquant 那份 Afro 是 23 色配 6 项 tRNS,iLoveIMG 那份是 23 色配 7 项,都是这个写法。能从块里读出来的就这些:它做了调色板量化,oxipng -o 4 在它的 deflate 上也挤不出余量。它内部用的什么工具块里看不出来。我也不去猜。

在浏览器里压图,我用的是图映 ImgIng imging.cn/ 的图片压缩。选入 PNG 后界面默认开着「8 位减色压缩」,看名字也是先减色再压这条路。它内部怎么选色、怎么重压我不清楚,这篇没拿它的输出做拆块。

这些数只说明体积

前面写的都是实测:环境是 Mac 电脑(Apple M4),每次只跑一次。pngquant 3.0.3 和 oxipng 10.2.1 用的是上面写的参数。字节数全是精确值,单位就是字节,没换算成 KB。贴输出的只有 Afro 一张。Steve Selvin、The Book、Alekszej 和 iLoveIMG 那几组数也都是各一张图、各跑一次。画质没有比,PSNR 和 SSIM 也都没算。留下 23 色的那张看起来怎么样。这篇不下结论。照片 PNG 那几个数也不能外推。Steve Selvin 是「RGBA 但全不透明」,Alekszej 是「颜色其实很少」,两张都是特例。换一张正常的 RGB 照片,无损那一步多半省不出这么多,这句是推断。

下面几条是推断。没有单独验证过。第一条,原图文件比 IDAT 多 81 字节。扣掉签名 8 字节、IHDR 块 25 字节、IEND 块 12 字节,还剩 36 字节。这正好是 3 个 IDAT 块头加 CRC。我猜原图的像素数据被切成了 3 个 IDAT 块。脚本把它们拼起来了。输出里看不出块数。同样的算法套到 icon-q.png 上,4,171 减 4,015 是 156 字节。再扣掉 PLTE 块 81 字节和 tRNS 块 18 字节还剩 12 字节,刚好一个块头。pngquant 只写了一个 IDAT。第二条,调色板图上的过滤器怎么选,单张有正有负。oxipng 在有的图上换成混合过滤器更小,有的图上留着 None 反而更小。我没找到能写成规律的东西。第三条是量化之后 deflate 为什么更好压。我的理解是索引值只有 23 种,相同的字节序列重复得多,LZ77 能找到更多匹配。这只是从原理上推的。我没逐行去看匹配长度。

放到工程里,这个脚本更适合当一道便宜的前置检查,当不了验收。链路里某一步如果标的是无损,可以先拆块确认 color_type、位深和 inflated 有没有变。没变就说明像素没被动过。变了也不一定有损。去 alpha 和无损转调色板都会让它变。这时候要么解码后逐像素比一次,要么对调色板项数和原图实际色数。逐像素比得用 Pillow 这类解码库,跑起来比拆块重得多。我会把拆块放在前面,只对结构变了的那部分图做解码比对。拆块只用到标准库,图不离开构建机,放进内网流水线不牵扯外发。真要在链路里加有损那一步,评审材料里最好写上留了几色。同样是调色板图。23 色和 256 色的体积能差将近一倍。

想在自己的链路里复现,把上面那 11 行存成 chunks.py 后拿原图和压缩后的图各跑一次。先对 color_type 和 inflated,再看 IDAT。inflated 不变就说明这次改动没动像素。inflated 变了就按「高 ×(宽 × 每像素字节数 + 1)」算一遍,看颜色类型和位深是怎么变的。图多的时候在外面套一层循环,把文件名、color_type、IDAT、inflated 四个数打成一行,贴进评审表里比对着看就够了。

样本图来自 Wikimedia Commons:Afro Mobile Icon(Ben plc,公有领域)、Steve Selvin(UC Berkeley Public Health,CC BY 4.0)、2020-06-29 Screenshot The Book(Stefan Kühn,CC0)、Alekszej Fedoricsev(Javelin Bayraktar,CC0)。

相关推荐
coding漫漫长路10 分钟前
PNG压缩先看图是哪一类,先无损再量化多半白做
前端
用户2576807632110 分钟前
搜「洗牙」为什么搜不到「洁治」
前端
费曼学习法17 分钟前
Agent 死循环的根因不在模型:错误分类、退避、熔断、快照四层实现
前端
修炼的dance18 分钟前
同一张PNG换五种压法,体积差在颜色数上
前端
Lank_M19 分钟前
PNG 有损压缩省下的体积,大头不在 deflate
前端
用户老李41 分钟前
相机模型与标定自学教程 02:外参 \[R | t] 与完整投影链路
前端
用户老李1 小时前
无线电自学教程 03:电路基础——从欧姆定律到 LC 谐振
前端
申君健24864182772021 小时前
ESM 源码发布 + 显式子路径导出
前端
大龄秃头程序员1 小时前
实践 iOS Touch 响应链:从 sendEvent 到 nextResponder 的完整闭环
前端