Jun 2

PNG与PNG的压缩

Lrdcq , 2017/06/02 19:51 , 程序 , 閱讀(4210) , Via 本站原創
在前端和App,现在最常用的图片资源文件,不用想肯定是PNG了。而作为客户端优化的重头戏,压缩PNG资源大小总是我们最关心的东西。有这么一个网站,tinypng,是现在主流压缩png的工具/服务中压缩率最高的没有之一,大家都很喜欢它。不过不开心的是,这个网站提供的是在线服务(虽然有接口访问)并且量大是要收费的。因此,我们需要寻求一个自己适用的PNG压缩方案,就算无法实现,也能对相关内容有更深入的了解。

PNG的构成

压缩PNG,我们首先要了解png的构成。谷歌一下可以参看png格式白皮书(https://www.w3.org/TR/PNG/),我们了解到以下基本信息:

PNG基本格式

png最基本的格式非常简单,只有一个png头和一大堆数据块(chunk)。

1. png头是完全固定的8个字节:0x89504E47 0x0D0A1A0A,里面包含平时看到的PNG三个字符。
2. 下面是复数个数据块,每个数据块包涵长度(ulong),标题(char[4]),数据,和crc32的校验码(long),可以直接写出输出数据块的方法:
def write_ chunk(f, title, data):
  f.write(struct.pack('>L',len(data)))
  f.write(title)
  f.write(data)
  f.write(struct.pack('>l',binascii.crc32(title + data)))

结构如下:

點擊在新視窗中瀏覽此圖片

当然,这个结构只是png的封装框架,并没有实际业务意义。我们可以考虑一下,作为一个图片最少需要什么:

1. 长宽信息
2. 每个像素的颜色

有了这两部分,就可以称为一个基本并且最小的图片了。因此回到白皮书上的内容,作为PNG的主要数据,我们最少需要这么两个Chunk,分别是IHDR,图片header块和IDAT,图片数据块。

IHDR

作为图片实际文件头信息,其中的信息是固定切明确的,数据不长直接上图:

點擊在新視窗中瀏覽此圖片

除了长宽下面最关键的是图片类型和颜色深度。

1. PNG图片类型分为几大类,分别是:
  - 0:灰度图像
  - 2:彩色图像
  - 3:索引彩色图像
  - 4:带alpha灰度图像
  - 6:带alpha彩色图像
灰度图我们使用的比较少,因此事实上最常见的应该是2,6,3这三种了,接下来会沿着这三种图像类型讲解。

2. 而颜色深度呢,是指的每个通道颜色的表示位数,比如我们常见的颜色如#ff0000,红色通道用0xff表示的,这就是8位。因此事实上非灰度图的一般图片,颜色深度都是常用的8。毕竟16位的颜色深度一般的显示器也显示不出来嘛。

后面三个数据,压缩方法一般是0,就是默认的压缩算法,下文解释;过滤器和隔行扫描就是更高级的功能了,而且对文件压缩没啥帮助,所以在此不提。

IDAT

图像的实际数据,PNG就是无损压缩,所以和上面想的一样,把每个像素的颜色罗列在一起就可以了。其实特定的规定是没一行开始有一个byte的标志为,暂时当它是0x00吧。所以实际储存的数据看起来是:

0x00 RGB RGB RGB RGB
0x00 RGB RGB RGB RGB
0x00 RGB RGB RGB RGB
0x00 RGB RGB RGB RGB

这样的东西。当然,这是原始数据,这些数据实际储存的时候经过了一次压缩,压缩算法虽然是通过上门那个header那儿制定的,不过业界事实上只实现了一个标准算法,即zlib的deflate算法(至于zlib是怎么回事儿,这又是一段孽缘了),就当是一个普通的无损压缩算法好了。最后,IDAT中储存的,就是压缩后的像素颜色数据了。

另外,每一行的0x00其实是一个当前行算法滤镜的枚举,一共有5种滤镜,比如如果是0x01的话是减法滤镜,每一个像素储存的颜色是 display - last 得到的数。不同的计算方法可以使得每一行实际储存的颜色数据有一些不同,当然,长度还是不会变的。

完成Hello World

有了以上信息,最后还有一个IEND,而且没有数据内容的块作为表示png文件的结束,我们就可以着手编写一个最基本的png了,直接看代码:
#!/usr/bin/python

import sys,os
import struct
import binascii
import zlib

def write_block(f, title, data):
  f.write(struct.pack('>L',len(data)))
  f.write(title)
  f.write(data)
  f.write(struct.pack('>l',binascii.crc32(title + data)))

f = open("hello.png", "wb")  

#png

f.write(struct.pack('>L',0x89504E47))
f.write(struct.pack('>L',0x0D0A1A0A))

#IHDR

ihdr = ""

ihdr += struct.pack('>L',0x00000002) #w
ihdr += struct.pack('>L',0x00000002) #h
ihdr += struct.pack('B',0x08) #depth
ihdr += struct.pack('B',0x02) #type

ihdr += struct.pack('B',0x00) #compression 
ihdr += struct.pack('B',0x00) #filter
ihdr += struct.pack('B',0x00) #interlace

write_block(f, "IHDR", ihdr)

#IDAT

idat = ""

idat += struct.pack('B',0x00)
idat += struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0x00)
idat += struct.pack('B',0x00) + struct.pack('B',0xff) + struct.pack('B',0x00)
idat += struct.pack('B',0x00)
idat += struct.pack('B',0x00) + struct.pack('B',0xff) + struct.pack('B',0x00)
idat += struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0x00)

idat = zlib.compress(idat)

write_block(f, "IDAT", idat)

#IEND

write_block(f, "IEND", "")

f.close()

这里绘制了一个2x2的彩色图像(type=2)图像,其中4个像素的颜色分别是0xff0000,0x00ff00,0x00ff00,0xff0000,运行一下,就看到我们想要的东西了。

加上透明通道

刚才的图像是type=2没有alpha通道的,因此这里换成6这张图片就变成带透明通道的了。带透明通道后IDAT段储存的颜色将多出alpha数据,比如:
0x00 RGBA RGBA RGBA RGBA
因此对应我们稍作改动就可以完成了
idat += struct.pack('B',0x00)
idat += struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0x00) + struct.pack('B',0xff)
idat += struct.pack('B',0x00) + struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0xff)
idat += struct.pack('B',0x00)
idat += struct.pack('B',0x00) + struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0x33)
idat += struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0x00) + struct.pack('B',0x33)


PNG8

接下来就提到PNG8了,png8是各大png压缩主打的东西,即type=3的索引彩色图像。这种格式和gif类似,在图像数据前用了一个表格储存颜色,最多256个颜色。然后实际的像素数据就直接引用颜色索引就可以了。虽然颜色数大大的减少了,但是像素颜色数据一下子减少了66%,是非常爽的。

回到png,我们需要多一个块来储存颜色索引,是PLTE块,它的内容也非常非常简单,把所有的颜色罗列在一起就可以了,顺序数就是索引。因此,我们在helloworld上继续添加:
#PLTE

plte = ""

plte += struct.pack('B',0xff) + struct.pack('B',0x00) + struct.pack('B',0x00)
plte += struct.pack('B',0x00) + struct.pack('B',0xff) + struct.pack('B',0x00)

write_block(f, "PLTE", plte)

然后实际数据段改为填写索引id就可以了:
idat += struct.pack('B',0x00)
idat += struct.pack('B',0x01)
idat += struct.pack('B',0x00)

idat += struct.pack('B',0x00)
idat += struct.pack('B',0x00)
idat += struct.pack('B',0x01)


PNG8Alpha

png的索引彩色图像一开始设计的时候是没有考虑alpha的,然后需要来了,还是得加上。怎么办,不得已,我们再添加一个块,专门来描述每一个索引颜色的alpha值。因此继续改动helloworld,加上了描述索引颜色透明度的tRNS块:
#tRNS

srns = ""

srns += struct.pack('B',0xff)
srns += struct.pack('B',0xff)
srns += struct.pack('B',0x33)
srns += struct.pack('B',0x33)

write_block(f, "tRNS", srns)

当然,不同的透明度是不同的颜色,现在我们需要4个颜色来实现。

小结

到此为止,我们得到了世界上99%的png的基本格式的结构,世界上99%的png我们拆开看都逃不出这个结构,依赖这些信息,我们也可以进行接下来的分析了。

PNG压缩

裁剪无用块

从简入繁,最简单的,我们看到,普通的png加上IEND必要块有4块,png8多个PLTE块,带alpha的再多一个tRNS块,其它的多半是无关紧要的。标准的png可能有如下块:

點擊在新視窗中瀏覽此圖片

可以看到,其中大部分非必要块主要是描述了拍摄的原始信息(如图像γ数据(gamma)块,图像直方图数据块,物理像素尺寸数据块),或者是辅助信息(各种文本信息数据块)之类的,这些东西在图像用于调整处理(PS)或者归档的时候意义巨大,但是对于web或者app使用,完全没有意义。比如ps默认保存出来的png,最少20kb,除了这些甚至还夹带了图层信息,对于我们来说是完全没用的。

还有是IDAT本身,PNG的图像数据本身是可以放在多个IDAT中以实现渐进加载等效果,没必要的话把它们合并到同一张图像上能很大程度上增加IDAT压缩率减小IDAT段大小。

所以作为PNG压缩的第一步,无用块删删删。

颜色优化

首先,我们的目标是把png都转换为png8。毕竟我们的实际使用场景大部分都是图标啊小icon啊什么的,现在又都是扁平化设计,颜色数肯定不多,因此压256色绰绰有余,而且显然,颜色数越少PLTE长度越短,并且IDAT数据复杂度降低压缩率也会增加。

除了这个角度,非常简单的icon,甚至8位颜色深度也没必要了,4,2,1往下降都是没问题的,不过对于颜色数本来就少的图像,这个做法难度高而且减小大小并不多,因此比较得不偿失。

当然,到底怎么把图像的颜色数压下来,这就和png无关是图像领域的另一个大坑了。

IDAT结构优化

IDAT的原生数据会通过deflate算法无损压缩,这种常见的算法意味着,原始数据中重复元素越多,压缩率就越高。那原始数据怎么优化呢,这就是一开始提到的IDAT行算法滤镜提供的功能了。算法滤镜的设计目的就是“The purpose of these filters is to prepare the image data for optimum compression.”

一共有4种算法滤镜:

- 1:Sub滤镜 储存数据 = 显示数据 - 左数据
- 2:Up滤镜 储存数据 = 显示数据 - 上数据
- 3:Average滤镜 储存数据 = 显示数据 - floor((左数据 + 上数据) / 2)
- 4:Paeth滤镜 储存数据 = 显示数据 - PaethPredictor(左数据, 上数据, 左上数据) magic多项式

比如,减滤镜非常适合过滤渐变性较强的数据,越稳定的渐变可以转换为非常单一的数据以方便压缩(一个减法且持续是#010000的数据可以成为从#000000到#ff0000的渐变)。双向和四向平均滤镜则用在更复杂的场景。

这个也是一个大坑,并且从实际上看做这个优化非常困难。

使用工具

分析结束,可以看到PNG的压缩点都需要大量图形学算法支持,清晰了解这一情况,我们还是选择使用工具来解决问题吧。压缩PNG的工具,除了最流行的tinypng网站,就要数开源的pngquant了(https://pngquant.org/)。因此,我们首先把pngquant和需要对标的tinypng放在一起比较。

小图压缩

提供的是颜色数远低于256的图,使用tinypng压缩,和用pngquant在30和90两个质量数进行压缩,结果如下:

點擊在新視窗中瀏覽此圖片

注意到pngquant的图多包涵gAMA和sRGB两段,删除后和tinypng大小一致。

1. 颜色数低于256的图,两者都没进行有损压缩,直接转换为了png8。
2. pngquant转换时保留了和颜色有关的多余块(比如gamma信息),因此比tinypng稍大,删除后完全一致。

大图压缩

提供了一张颜色丰富的图进行类似测试,结果如下:

點擊在新視窗中瀏覽此圖片
點擊在新視窗中瀏覽此圖片

1. 都尝试压缩为了png8
2. 其实有损压缩算法上不分伯仲,差距不大,和大小相比基本是一分钱一分货
3. tinypng明显用了较多的平滑处理,使得画面细节相对柔和很多,没有明显的锯齿感,感官确实优于pngquant

结论

在web和app开发的大部分场景下,对于颜色数小于256的图像,用pngquant和用tinypng处理完全没有区别,pngquant需要增加脚本处理掉多余块,但是肯定比使用在线且受限的tinypng方便。

在处理复杂图像上,本身将复杂图像处理为png8是否合适就有待商榷,如果确实要这么做,tinypng优于pngquant。
关键词:png , png解析
logo