图片文件结构与PNG编码
图像存储
颜色在计算机的存储
- 颜色模式
RGB色彩模式是颜色模式之一(除此之外还有YUV模式和CMYK模式等等)
在实际的开发中,我们可能还会遇到RGBA模式,其中的A代表的是alpha通道,一般表示透明度,值从0%-100%,0-表示完全透明,100-表示完全不透明。
RGB(255, 128, 196):有3个颜色分量,分别是R的值255,G的值128,B的值196,这三个分量分别用了8位存储。咱们前面提到过8位存储,可以存储256个值。
关于RGB的颜色表示还有一种16进制表示方式,比如刚才的RGB(255,128,196)换算成16进制为#FF80C4
- 像素
每一个像素都有一个明确的位置和被分配的色彩值
- 分辨率(Resolution)
又称解析度,可以细分为显示分辨率、图像分辨率、打印分辨率和扫描分辨率。
显示分辨率:分辨率 4000x3000
图像分辨率: 常用单位,dpi(点每英寸),ppi(像素每英寸)。1英寸=2.54厘米(cm),它的实质是描述了图像的真实大小。比如“水平分辨率 72dpi”
- 图像文件格式
BMP(Bitmap):是Windows操作系统中的标准图像文件格式,采用位映射存储格式,无压缩,BMP文件的图像深度可选1、4、8、24位。BMP最大的特点就是没有压缩(所以它的图像大小你可以直接算出来)
JPEG:联合图像专家小组(Joint Photographic Experts Group,JPEG)是一种针对图像的有损压缩标准方法。使用的一种失真压缩标准方法,24 bit真彩色,不支持动画、不支持透明色。
PNG:便携式网络图形(Portable Network Graphics,PNG)是一种无损压缩的位图图形格式格式是无损(不失真)数据压缩的,PNG格式有8位、24位、32位三种形式,其中8位PNG支持两种不同的透明形式(索引透明和alpha透明)。
GIF:图像互换格式(Graphics Interchange Format,GIF)是一种无损压缩的位图图形格式,采取8位色重现图像。
图片的基本概念
- 位图(Bitmap)
又叫格栅图或者点阵图,是利用像素阵列来表示的图像。位图的每个像素都被分配了特定的位置和颜色值,颜色值可以由RGB组合或者灰度值表示。
- 位深度(Bit Depth)
每个像素用来表示颜色信息的位数,位深度越大,颜色越逼真,占用空间也就越大。例如,位深度为1的位图的像素只能为黑色或者白色,位深度为8的图像像素有2^8^(即256)个可能的颜色值。
- 颜色通道
RGB图像的像素由红绿蓝三个颜色通道组成,8位/通道的图像中,每个通道有256个可能值,因此每个像素有24位可能的值,通常将24位RGB位图成为真色彩位图。
色深度与位深度
色深度:一个颜色通道的位数,单位为 位/通道 。常有8bit、10bit
位深度:一个像素的位数
- Alpha通道
在原有的图片编码基础上,添加像素的透明度信息,因为通常把RGB三种颜色信息分别成为三种颜色的通道,所以透明度信息也成为Alpha通道。
- 矢量图(Vector)
用点、直线或者多边形等基于数学方程的几何图形表示的图像。
位图与矢量图
位图的放大实际上是每个像素的放大,单个像素的放大会使线条和形状参差不齐,导致我们看到的图片“模糊不清晰”。而矢量图通过路径和颜色来记录图片信息,放大之后并不会失真。
位图处理已经标准化,并且硬编码到显卡内,GPU能够处理位图。但矢量图的处理只能依靠CPU。
PNG编码与解码
PNG文件结构
PNG图像格式文件由文件署名和数据块(chunk)组成。
文件署名域(Signature)
一张 PNG 图片二进制数据的开头必须是这 8 字节:
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
转换为 10 进制是 137, 80, 78, 71, 13, 10, 26, 10
。
(Linux下我们可以用file命令直接查看文件的实际格式,但是他本质上也是利用文件头标志来进行文件类型判断的。)
数据块(Chunks)
有两种类型的数据块,一种是称为关键数据块(critical chunk);另一种叫做辅助数据块(ancillary chunks)。
每个数据块都包含图片的描述信息,数据块由四个部分组成:
名称 | 长度 | 说明 |
---|---|---|
Length | 4 bytes | 指定 Chunk Data 的长度 |
Chunk Type | 4 bytes | 指定数据块类型 |
Chunk Data | 可变 | 按照Chunk Type存放指定数据 |
CRC | 4 bytes | 检查是否有错误的循环冗余代码 |
这里要注意 Length 保存的是该数据块中 Chunk Data 的长度,也就是说,一个数据块的长度应该为 Length 的值加上12 bytes。
Chunk Type 由4个字符组成,:第一个字符是否大写(critical),决定了该数据块是否是关键(critical)数据块;第二个字符大写表示公开,小写表示私有;第三个字符规定必须大写;第四个字符大写则表示只能在关键数据块不变时被复制,小写则表示任何情况下能复制。
关键数据块
关键数据块定义了4个标准数据块,每个PNG文件都必须包含它们。
1、文件头数据块IHDR(header chunk)
包含有PNG文件中存储的图像数据的基本信息,并要作为第一个数据块出现在PNG数据流中,而且一个PNG数据流中只能有一个文件头数据块。它用13个字节按照顺序来表示图像数据的基本信息:
域的名称 | 长度 | 说明 |
---|---|---|
Width | 4 bytes | 图片宽度,以像素为单位 |
Height | 4 bytes | 图片高度,以像素为单位 |
Bit Depth | 1 byte | 图像深度 |
Color Type | 1 byte | 颜色类型 |
Compression Method | 1 byte | 压缩算法 |
Filter Method | 1 byte | 滤波方法 |
Interlace Method | 1 byte | 扫描方法 |
Bit Depth
代表图像深度。索引彩色图像:1,2,4或8;灰度图像:1,2,4,8或16;真彩色图像:8或16;Color Type
(颜色类型)PNG 图片一共有 5 种色彩类型,0
代表灰度颜色,2
代表用 RGB 表示颜色,即(R, G, B)
,3
代表用色板表示颜色,4
代表灰度和透明度来表示颜色,6
代表用 RGB 和透明度表示颜色,即(R, G, B, A)
。色板的色彩类型里,每个像素是由 1 个色彩通道表示的。Co2,mpression Method
代表了压缩算法。目前只支持0
,表示 deflate/inflate。(Deflate/inflate 是一种结合了 LZ77 和霍夫曼编码的无损压缩算法,被广泛运用于7-zip
,zlib
,gzip
等场景。)Filter Method
代表在压缩前应用的过滤函数类型,目前只支持0
。过滤函数类型0
里面包括了 5 种过滤函数。Interlace Method
代表图片数据是否经过交错,0
代表非隔行扫描,1
代表 Adam7。
2、调色板数据块PLTE(palette chunk)
它包含有与索引彩色图像(indexed-color image)相关的彩色变换数据,它仅与索引彩色图像有关,如果存在,要放在图像数据块(image data chunk)之前。真彩色的PNG数据流也可以有调色板数据块,目的是便于非真彩色显示程序用它来量化图像数据,从而显示该图像。结构如下:
颜色 | 字节 | 意义 |
---|---|---|
Red | 1 byte | 0 = 黑色, 255 = 红 |
Green | 1 byte | 0 = 黑色, 255 = 绿色 |
Blue | 1 byte | 0 = 黑色, 255 = 蓝色 |
PLTE数据块是定义图像的palette信息,PLTE可以包含1~256个palette信息,每一个palette信息由3个字节组成,因此palette数据块所包含的最大字节数为768,palette的长度应该是3的倍数,否则,这将是一个非法的palette。
对于索引图像,palette信息是必须的,palette的颜色索引从0开始编号,然后是1、2……,palette的颜色数不能超过色深中规定的颜色数(如图像色深为4的时候,调色板中的颜色数不可以超过2^4=16),否则,这将导致PNG图像不合法。
3、图像数据块IDAT(image data chunk)
存储实际的图片数据,一个图片数据流可能包含多个连续顺序的 IDAT 数据块。创建 IDAT 数据块的步骤:
- 将扫描获得的图片信息及其大小储存,具体的图片信息和大小规则在 IHDR 中保存
- 采取 IHDR 指定的过滤方法,对图像数据进行过滤。目前只有定义了method 0
- 采取 IHDR 指定的压缩方法,对过滤的数据进行压缩
如果想要读取图片信息,将上面三个步骤逆序执行即可。
4、图像结束数据IEND(image trailer chunk)
它用来标记PNG文件或者数据流已经结束,并且必须要放在文件的尾部。
如果我们仔细观察PNG文件,我们会发现,文件的结尾12个字符看起来总应该是这样的:
00 00 00 00 49 45 4E 44 AE 42 60 82
不难明白,由于数据块结构的定义,IEND数据块的长度总是0(00 00 00 00
,除非人为加入信息),数据标识总是IEND(49 45 4E 44
),因此,CRC码也总是AE 42 60 82
。
表示数据块开始的IHDR必须放在最前面, 表示PNG文件结束的IEND数据块放在最后面,其他数据块的存放顺序没有限制。
辅助数据块
(cv查表)
符号 | 说明 |
---|---|
bKGD | 指定默认的背景颜色 |
cHRM | 校准色度 |
dSIG | 数字签名 |
eXIf | Exif metadata 信息 |
gAMA | 指定亮度的伽马校准值2, |
hIST | 色彩直方图,估算颜色的使用频率 |
iCCP | ICC color profile |
iTXt | 以键值对方式存储可能的压缩文本和翻译信息 |
pHYs | 指定像素大小或图片比例 |
sBIT | 除了指定位深度(8,16 等等)外,保存原始数据以便恢复 |
sPLT | 提出建议使用的调色板,可能有多个 |
sRGB | 指明是否使用sRGB color space |
sTER | 储存立体视图(stereoscopic)的相关信息, |
tEXt | 保存作者等键值对的文本信息 |
tIME | 上次修改时间 |
tRNS | 保存透明信息,分为不透明、全透明、Alpha |
zTXt | 保存压缩后的文本信息以及对应的压缩方式标识 |
压缩
PNG的压缩分为两个步骤:预解析(过滤)和压缩
隔行扫描
隔行扫描(Interlacing),也称交错扫描(interleaving),是一种位图编码的方式,编码时不严格按照相邻行进行。当连接速度比较慢时,采用隔行扫描的图片可以渐进地进行显示,即先显示较为模糊的版本,然后逐渐变清晰。如图:
我们常用的 GIF、PNG、JPEG 都采用了隔行扫描的方式,其中 PNG 采取的是二维、七通道的Adam7,二维表示扫描支持竖直和水平方向,7代表一张图片会被拆分为7个子图片。
和 GIF 采取的一维、四通道算法相比,PNG 在网速相同的情况下,尤其是网速较慢时,能够更快地显示出图片的基本外貌。
Filter(过滤)
PNG编码使用差分对原始像素数据进行Filter,该过程无任何压缩损失,并且完全可逆。对图像来说,存储残差所需的比特远远小于实际图像所需,这也是差分编码的收益来源
- 差分编码
[2,3,4,5,6,7,8]会变成[2,1,1,1,1,1,1],其中 [2, 3-2=1, 4-3=1, 5-4=1, 6-5=1, 7-6=1, 8-7=1]
如果数据是线性相关的(也就是说,值与前一个值有一些很小的差异),那么最终会将数据集的值转换为大量重复的小值,这些小值更容易被压缩。 PNG文件格式使用称为“filtering”的delta编码格式。基本上,对于每一个像素扫描线,当前像素都是按照与左边像素、上面像素和上面左侧像素的某种关系进行编码的。
对于不同的内容,须使用不同的Filter类型来提升压缩收益,过滤的重点是将大多数转换后的字节将聚集在零附近,从而为压缩引擎提供更小、更可预测的字节值范围来处理。
为了便于描述,规定了相邻像素
相对当前像素
的方位:左(A)、上(B)、左上(C)。
C | B |
---|---|
A | X |
上文提到的 5 种过滤函数为:
过滤类型 | 存储值(O)=实际值(X)-预测值(R) |
---|---|
0 | O = X |
1 | O = X - A |
2 | O = X - B |
3 | O = X - (A+B)/2 |
4 | Peath(A,B,C) |
Peath(A,B,C): Paeth预测器(A,B,C的线性函数)
Peath具体计算为
1 | base = AboveRow[j] + LeftCol[i] – AboveRow[-1] |
选择合适的过滤器?
没有。每一行进行增量测试压缩,保存最小的结果,然后对下一行重复,对于20行的图像相当于对整个图像进行五次过滤和压缩。对于将被传输和解码多次的图像来说,这可能是一个合理的权衡。
过滤器也很少用于低位深度(灰度)图像,尽管在极少数情况下将此类图像提升到 8 位然后过滤是有效的。但是,一般来说,过滤器类型 None 是最好的。
一些接近最优的经验法则:比如对调色板图像以及低于8位的灰度图像不使用过滤器;对于其他的图像,选择最小化绝对差和的滤波器;不是使用256模除(%),而是使用标准的带符号数学,然后获取abs值,并将它们全部添加到给定行中,然后比较其他筛选器类型的和,选择给出最小和的过滤器。
Deflate
正式的压缩阶段使用 Deflate 进行压缩,它由 Huffman 编码 和 LZ77 压缩构成。Deflate是一种压缩数据流的算法,任何需要流式压缩的地方都可以用(但我并没有去了解它具体的实现,所以就略过吧)
解码
(图片和数据均来自一步一步解码 PNG 图片)
👆 这是一张我们接下去要解码的图片,但它太小了,放大了展示给大家看下。👇
二进制数据如下
0 ~ 3 | 4 ~ 7 | 8 ~ 11 | 12 ~ 15 | |
---|---|---|---|---|
0 ~ 15 | 137, 80, 78, 71 |
13, 10, 26, 10 |
0, 0, 0, 13 |
73, 72, 68, 82 |
16 ~ 31 | 0, 0, 0, 2 |
0, 0, 0, 2 |
2, 3, 0, 0 |
0, 15, 216, 229 |
32 ~ 47 | 183, 0, 0, 0 |
1, 115, 82, 71 |
66, 1, 217, 201 |
44, 127, 0, 0 |
48 ~ 63 | 0, 9, 112, 72 |
89, 115, 0, 0 |
11, 19, 0, 0 |
11, 19, 1, 0 |
64 ~ 79 | 154, 156, 24, 0 |
0, 0, 12, 80 |
76, 84, 69, 255 |
0, 0, 0, 255 |
80 ~ 95 | 0, 0, 0, 255 |
255, 255, 255, 251 |
0, 96, 246, 0 |
0, 0, 4, 116 |
96 ~ 111 | 82, 78, 83, 255 |
255, 255, 127, 128 |
144, 197, 89, 0 |
0, 0, 12, 73 |
112 ~ 127 | 68, 65, 84, 120 |
156, 99, 16, 96 |
216, 0, 0, 0 |
228, 0, 193, 39 |
128 ~ 143 | 168, 232, 87, 0 |
0, 0, 0, 73 |
69, 78, 68, 174 |
66, 96, 130 |
每个表格的单元格内有 4 字节数据,每个字节由 8 位组成,1 位代表的是 0
或者 1
的一个数字。
- 签名部分
0 ~ 3 | 4 ~ 7 |
---|---|
137, 80, 78, 71 |
13, 10, 26, 10 |
0x89, 0x50, 0x4e, 0x47 |
0x0d, 0x0a, 0x1a, 0x0a |
这张图片的前 8 个字节满足PNG签名的要求。
- 文件头数据块部分
8 ~ 11 | 12 ~ 15 |
---|---|
0, 0, 0, 13 |
73, 72, 68, 82 |
长度 | 类型 |
13 |
IHDR |
16 ~ 19 | 20 ~ 23 | 24 ~ 27 | 28 |
---|---|---|---|
0, 0, 0, 2 |
0, 0, 0, 2 |
2, 3, 0, 0 |
0 |
width |
height |
depth , colorType , compression , filter |
interlace |
2 |
2 |
2, 3, 0, 0 |
0 |
29 ~ 32 |
---|
15, 216, 229, 183 |
CRC32 校验和 |
- PLTE部分
75 ~ 78 | 79 ~ 82 | 83 ~ 86 |
---|---|---|
255, 0, 0, 0 |
255, 0, 0, 0 |
255, 255, 255, 255 |
色板中包含的数据是 RGB 数据,以 R, G, B
的形式保存,这里一共 12 字节,表示了 4 个色块。得到的色板信息如下:
色板
1 | [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]] |
- tRNS部分
99 ~ 102 |
---|
255, 255, 255, 127 |
这个数据块为色板提供透明信息,每个字节表示一个色块的透明信息。与色板组合后的色板如下:
色板
1 | [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 127]] |
- IDAT部分
我们重点关注图像数据块的解码部分。
107 ~ 110 | 111 ~ 114 |
---|---|
0, 0, 0, 12 |
73, 68, 65, 84 |
长度 | 类型 |
12 |
IDAT |
声明 12 字节的 IDAT
像素数据
115 ~ 118 | 119 ~ 122 | 123 ~ 126 |
---|---|---|
120, 156, 99, 16 |
96, 216, 0, 0 |
0, 228, 0, 193 |
所有行的像素数据会经过 deflate 压缩算法压缩。所以,需要对这里的像素数据解压,这里直接使用了 zlib.inflate()
函数。
解压出来的像素数据是 Uint8Array:0, 16, 0, 176
。
扫描线 Scanline
一根扫描线包含图片一行像素的数据。我们知道这张图片的高度是 2,也就是像素数据中有 2 行扫描线。
一根扫描线由 1 字节的过滤函数标记和像素信息组成。像素信息一个接一个地排列,中间没有多余的空位。如果扫描线长度不足以填满字节的位数,最后几位会被补齐。一根扫描线的结构如下:
过滤函数 | 像素…[补齐…] |
---|---|
8 位 |
每像素位数 * 每行像素数 + 补齐 |
色彩类型 - 色彩通道 - 通道深度 - 每像素位数
色彩类型 | 色彩 | 每像素通道数 | 通道深度 | 每像素位数 |
---|---|---|---|---|
0 |
灰度 | 1 | 1, 2, 4, 8, 16 | 1, 2, 4, 8, 16 |
2 |
真彩色(RGB) | 3 | 8, 16 | 24, 48 |
3 |
色板 | 1 | 1, 2, 4, 8 | 1, 2, 4, 8 |
4 |
灰度和透明度 | 2 | 8, 16 | 16, 32 |
6 |
色彩色和透明度(RGBA) | 4 | 8, 16 | 32, 64 |
这张图片的色彩类型是 3
,所以每个像素包含 1
个色彩通道。又因为图片的通道深度是 2
,所以我们知道每个像素是用 2
位来表示的。
解码扫描线
行 | 过滤函数 | 像素…[补齐…] |
---|---|---|
8 位 |
每像素 2 位 * 2 像素 + 4 位补齐 = 8 位 |
|
0 |
0 |
00010000 (16 ) |
1 |
0 |
10110000 (176 ) |
这张图片里面的过滤函数 0
表示这张图数据未经过滤。所以我们只要保留原始数据就行了。
扫描线像素
行 | 第 1 列 | 第 2 列 | 补齐… |
---|---|---|---|
0 |
00 |
01 |
0000 |
1 |
10 |
11 |
0000 |
这里每个像素中的数据表示了这个像素的颜色在色板中的索引。根据色板,我们可以还原出图片的像素信息:[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 127]]
。
图片像素
行\列 | 0 |
1 |
---|---|---|
0 |
(255, 0, 0, 255) |
(0, 255, 0, 255) |
1 |
(0, 0, 255, 255) |
(255, 255, 255, 127) |
最后可以得到这样一个PNG图片信息:
1 | imageData = { |
参考
PNG (Portable Network Graphics) Specification http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html
PNG文件格式 https://rimson.top/png-format/
PNG文件格式详解 https://blog.mythsman.com/post/5d2d62b4a2005d74040ef7eb/
Portable Network Graphics (PNG) Specification (Second Edition) https://www.w3.org/TR/PNG/
一步一步解码 PNG 图片 https://vivaxyblog.github.io/2019/12/07/decode-a-png-image-with-javascript-cn.html