1. .jpg, .png, .gif
说到图片,我们首先会想到,几种常见图片格式,如:.jpg, .png, .gif 等。
但当我门在说图片的格式时,除了在说图片文件的后缀不同,还有什么不同呢?
事实上,图片的格式,在技术上,是指图片所遵循的压缩标准。更准确地说,是数字图像的压缩标准(计算机上的图片都是数字图像,即由 0 和 1 构成的二进制数字图像文件)。
可能会有人不明白,为什么图片的格式是压缩标准? 图片为什么要压缩? 难道存储在我们个人电脑的图片都是压缩的?
没错,不管是存储在我们个人电脑,手机,还是在网络上图片其实都是经过压缩后的图片数据。
那么,压缩前的原始图像数据又是什么样的? 以及为什么要对图像进行压缩?
2. 原始图像数据
不管是什么格式,或采用什么样的压缩标准,原始的图像数据其实都是一样的,而且也符合我门直观的理解。
例如,一张 4 × 4 (宽度和高度都是 4 个像素)的彩色图片,未压缩的的原始图像数据,就是一个 4 × 4 矩形网格,每一个网格代表一个像素。
而彩色图片的每一个像素,又是由 红,绿,蓝 三基色构成,如下图右边所示,红绿蓝,对应于 r g b 三个数值,也就是我常说的 RGB 色彩模式。
RGB,我们在计算机视觉领域,又称为颜色通道,彩色图像有三个通道值,每个颜色通道,都是一个 0~255 的整数值,占用一个字节(Byte)的存储空间。
因此,我们很容易计算上面这张 4×4 彩色图片占用的存储空间为 4 × 4 × 3 = 48 字节 (Bytes) 。换算成我们熟悉的 KB,就是 48 / 1024 = 0.046875 KB,不到 0.1 KB。
事实上,我们很少见到这么小的图片,甚至在我们的个人电脑和手机上,根本无法正常看到这么小的图片。这里为了方便理解和计算,做了技术上的处理,而不是真实看到的图片大小。
拓展:按照在电脑上常用的分辨率 72 ppi (Pixels Per Inch:像素每英寸),即 每 2.54 厘米 容纳 72 个像素,或者说,一个像素占用的屏幕尺寸是 0.35 毫米,那么上面 4 × 4 图片,在屏幕上 1:1 显示,占用屏幕的物理尺寸只有 1.4 × 1.4 毫米。显然,用肉眼是无法看清的。
在理解一张 4 × 4 的彩色图片占用存储空间大小,我们同样的方式计算如下,320 × 320 的彩色图片,这个大小在我们日常生活,也不算一张大图,相当于我们用作微信头像的大小。
我相信我们可以很快得出结果,320 × 320 × 3 = 300 KB ,相当于上面 4 × 4 图片的 6000 多倍。
可能大家觉得这张图片还不算大。我测算,用自己的 iPhone 8 Plus
正常拍摄一张手机照片,它的大小是 3024 × 4032
,这样一张图片在未压缩的情况下,所占用的存储空间大小是 3024 × 4032 × 3 = 35 MB 。而实际,如下图,在我的 Mac 上看到的图片, 只有 6.8 M ,也就是说,我们在使用手机拍摄照片后,在保存在相册之前,相机程序已经自动对我们拍摄的照片照片进行了压缩,这里的压缩比是 35 / 6.8 = 5,压缩比并不是一个固定值,也就是说同样大小的不同照片,在经过相同的压缩处理后,占用磁盘的空间也是不一样的。
事实上,图像压缩在数字图像处理领域,是应用最为普遍的和成功的,大部分图片查看器,编辑器,网页浏览器,等与图片相关的应用程序,乃至,开发人员使用图片处理库,底层都使用了图像的压缩和解压缩算法,并且对于用户,或者上层的应用开发人员,是完全透明的,以至于我们觉察不到它的存在。
PS: 图像的压缩和解压缩,也称之为,编码和解码,其实是同一个意思,并且适用于数字视频的编解码
3. 图像压缩
如果,大家对上文,将手机拍摄的一张原始图像是 35 MB 压缩保存后是 6.8MB ,没有太大的概念。
那么,我们不妨再用电影举例,一部宽高为 720 × 480(彩色),帧率为 30 帧/秒,时长为 2 小时的电影,其未压缩前的大小是:
720 × 480 × 3 (字节/像素) × 30 (帧/秒) × 3600 (秒/小时) × 2小时 = 209 GB
不考虑音频,电影的画面,本质就是由一张张连续显示的图像构成,每一副图像,我们称之为一帧)
也就是一个 1TB 的移动硬盘,只能装下不到 5 部这样的清晰度一般的电影。这显然是不能接受,也与我们日常生活对电影存储的认知不符。
因此,我们要感到庆幸,对图像和视频的压缩算法,无时无刻不在为我们的数字生活服务。我们没有觉察到,但一定不能忽视它的存在。
3.1 存储在磁盘上真实图像的二进制数据
事实上,图像的压缩或编码,本质就是为了解决图像在存储和网络传输过程的空间消耗,让有限的磁盘和网络带宽,存储和传送海量的数字图像和视频提供了技术后盾。
那么压缩后的图片数据到底长啥样?
我们依然使用前文用到的那只可爱的 小狗狗 图片,它在我电脑上文件名为 dog.jpeg。
我们知道,不同于普通文本文件,图片在计算机里存储形式,是二进制文件。
在 linux 和 MacOS 系统上,我们可以借助一个命令行工具 hexdump
来查看任何二进制文件,包括图片。
读者,可以将下面这张图片 保存到 自己的电脑上。
在命令行界面,进入 dog.jpeg 文件所在目录,运行如下命令:
hexdump dog.jpeg
# 输出结果如下(中间数据已省略,只显示开头和结尾各两行):
# 0000000 ff d8 ff e0 00 10 4a 46 49 46 00 01 01 00 00 48
# 0000010 00 48 00 00 ff e1 00 8c 45 78 69 66 00 00 4d 4d
# ....
# 0004780 04 12 48 f5 a5 70 0b 82 18 7c 8c 30 39 cf 4e be
# 0004790 f5 11 82 30 c3 f7 47 00 12 39 3c 50 08 ff d9
Tips: 如果想对显示的格式进行控制,可以尝试增加如下选项,格式化显示输出:
hexdump -e '16/1 "%02X "' -e '"\n"' dog.jpeghexdump -e '16/1 "%02X " " | "' -e '16/1 "%_p" "\n"' dog.jpeg
我们看到的输出如下图所示(中间内容省略,这里只截取了开头和结尾各两行):
图中,红线框圈住的部分,是图片数据的字节流编址,可以看作是为了查看方便,添加的行号,红框右边的才是图片的真实存储字节流,并且每行显示 16 个字节。当然不管是“行号”还是图片数据,为了显示的简介性,默认都是用了16进制。
这里我忽略红框中的“行号”,只关注图片字节流数据。
这里要注意的是,图中数据是一行行显示的,并且每行中,字节间都有空格,其实,这里还是为了方便查看才这样显示的,真实存储的数据并非一行一行,字节间也没有空格,所谓字节流,就是图片数据字节都是连续不间断的,串成一条线,在程序里,体现为一个一维的字节数组。
为了验证这点,我们不妨用实践说话。
在一台已经安装了Python(MacOS 内置了 Python 2)机器,启动命令行,输入 python
进入 python 交互式编辑环境。使用如下 python 代码,查看 图片 dog.jpeg 的二进制字节流。
with open("dog.jpeg", "rb") as f:image_bytes_data = f.read()image_bytes_data[:16]
image_bytes_data[-16:]
运行输出如下:
image_bytes_data 以字节为单位,保存着图片二进制数据,可以使用切片,查看前 16 个字节 和最后 16 个字节。通过与前文使用 hexdump
查看的数据对比,可以看出是一致的。细心的读者可能发现,在 hexdump
显示结果的最后一行,只有 15 个字节。因此,这里看到最后 16 个字节,是从倒数第二行最后一个字节开始的。
3.2 图像二进制数据格式
我们已经知道如何通过命令行工具 hexdump
和 python 脚本查看图片的二进制数据,并且我们知道这不是图片原始的二维RGB阵列数据,而是经过压缩后,方便存储和网络传输用的一维二进制字节流。
那么这些字节数据,到底代表什么意思,我们使用的图片应用程序如何根据这些数据,解压缩或解码,还原成,计算机显示器可以显示的二维 RGB 像素阵列呢?
本文仅仅对字节流数据组成格式,各部分代表的含义进行简单介绍,以对图片存储数据解码有个基本认识,对于解码部分的完整实现,超出本文的讨论范围,感兴趣,推荐参考专业书籍或开源图片编解码器。
3.2.1 标记数据
首先,还是引用前文的数据截图:
我们注意到用橙色线框框着的两个部分,ff d8
表示图片数据开始,英文缩写 SOI (Start Of Image),ff d9
表示图片数据的结尾,英文缩写 EOI (End Of Image)。
我们不难发现,两者都是以 ff
开头。事实上,图片存储的数据,大体只包含两类数据,一类是 ff
开头,后跟1个字节, 这个字节既不能等于 0
也不能等于 ff
,表示不同类型的标记(Marker)数据,而剩下的就是图片的压缩数据或编码数据。
由于标记数据记录着图片的元数据,同时决定了,图片压缩数据如何解码。因此我们重点介绍标记数据。
为了通过编程实践,更好地理解不同的标记数据,我们根据已经掌握的标记数据特点,即,标记数据都是以 ff
开头,后跟一个, 既不能等于 0
也不能等于 ff
的字节 类型。编写如下 python 脚本来提取图片中的标记数据。
Warm Tips:如下代码,请在 python3 环境下运行
将以下 python 脚本复制,保存到文件 view_dog_marker_data.py
# 从磁盘读取图片二进制字节流数据
with open('dog.jpeg', 'rb') as f:image_data = f.read()image_data = ['%x' % image_data[i] for i in range(len(image_data))]# 解析标记数据,保存到字典 tagmarker
tagmarker = dict()
tag = ''
tag_start = False
data_start = False
for i, b in enumerate(image_data):if len(b) == 1:b = '0' + bif b == 'ff':tag_start = Truecontinueif tag_start:if b != 'ff' and (b != '00'):tag = 'ff' + bif not tag in tagmarker:tagmarker[tag] = list()tag_start = Falsedata_start = Truecontinueelse:tag_start = Falsetagmarker[tag].append('ff')if data_start:tagmarker[tag].append(b)# 查看解析后,有那些标记数据,以及对应数据的长度
for tag, arr in tagmarker.items():s = len(arr)if s == 0:print(tag)continueprint("{}:\t{}\tbytes".format(tag, s))
使用 python3 运行脚本:
python view_dog_marker_data.py
输出结果如下:
ffd8
ffe0: 16 bytes
ffe1: 140 bytes
ffed: 56 bytes
ffc0: 17 bytes
ffc4: 424 bytes
ffdb: 134 bytes
ffdd: 4 bytes
ffda: 457 bytes
ffd0: 2391 bytes
ffd1: 2129 bytes
ffd2: 2571 bytes
ffd3: 2008 bytes
ffd4: 1936 bytes
ffd5: 1977 bytes
ffd6: 1951 bytes
ffd7: 2058 bytes
ffd9
3.2.2 标记数据字段含义
我们看到,除了已经知道的 ffd8
和 ffd9
分别表示图片的开头和结尾,并且后面没有内容数据,其他我们还不知道的标记数据,后面都有不同长度的内容数据。
下面直接给出,这些标记数据对应字段:
字节码 | 标记符 |
---|---|
ffd8 | SOI* |
ffe0 … ffef | APP0 … APP15 |
ffdb | DQT |
ffc0 ffc1 ffc2 ffc3 | SOF0 SOF1 SOF2 SOF3 |
ffc5 ffc6 ffc7 | SOF5 SOF6 SOF7 |
ffc8 ffc9 ffca ffcb | JPG SOF9 SOF10 SOF11 |
ffcd ffce ffcf | SOF13 SOF14 SOF15 |
ffc4 | DHT |
ffcc | DAC |
ffd0 … ffd7 | RSTm* |
ffda | SOS |
ffdb | DQT |
ffdc | DNL |
ffdd | DRI |
ffde | DHP |
ffdf | EXP |
fff0 … fffd | JPG0 … JPG13 |
fffe | COM |
ff01 | TEM* |
ff02 … ffbf | RES |
ffd9 | EOI* |
为了在前面的脚本基础上,得到字节码对应的标记符,创建如下字典:
tag_map = {"ffd8": "SOI","ffc4": "DHT",'ffc8': "JPG",'ffcc': "DAC","ffda": "SOS","ffdb": "DQT","ffdc": "DNL","ffdd": "DRI","ffde": "DHP","ffdf": "EXP","fffe": "COM","ff01": "TEM","ffd9": "EOI"
}for i in range(16):tag = 'ffe%x' % itag_map[tag] = 'APP'+str(i)for i in [0, 1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15]:tag = 'ffc%x' % itag_map[tag] = 'SOF'+str(i)for i in range(8):tag = 'ffd%x' % itag_map[tag] = 'RST'+str(i)for i in range(14):tag = 'fff%x' % itag_map[tag] = 'JPG'+str(i)map_tag = { v: k for k, v in tag_map.items() }
通过字典,将字节码替换成标记字段,查看解析结果:
for tag, arr in tagmarker.items():s = len(arr)if tag in tag_map:tag = tag_map[tag]if s == 0:print(tag)continueprint("{}:\t{}\tbytes".format(tag, s))
输出结果如下:
SOI
APP0: 16 bytes
APP1: 140 bytes
APP13: 56 bytes
SOF0: 17 bytes
DHT: 424 bytes
DQT: 134 bytes
DRI: 4 bytes
SOS: 457 bytes
RST0: 2391 bytes
RST1: 2129 bytes
RST2: 2571 bytes
RST3: 2008 bytes
RST4: 1936 bytes
RST5: 1977 bytes
RST6: 1951 bytes
RST7: 2058 bytes
EOI
到此为止,我们完成了对图像存储数据的初步解析,进一步解析出各标记数据的详细信息,以及图片压缩数据,期待后续更新。
附件
下图为 国际电信联盟 (INTERNATIONAL TELECOMMUNICATION UNION) 发布的静态图像数字压缩和编码规范中,关于标记码的分配表,也是本文解析图片标记数据,的参考依据。感兴趣,可以通过此表,了解到本文未详尽的内容,如关于标记数据的描述说明。
参考
- [1] T.81 page 34
- [2] JPEG File Interchange Format
坚持写专栏不易,如果觉得本文对你有帮助,记得点个赞。感谢支持!
- 个人网站: https://kenblog.top
- github 站点: https://kenblikylee.github.io
- 掘金: https://juejin.im/user/5bd2b8b25188252a784d19d7
- 知乎: https://www.zhihu.com/people/kenbliky/posts
微信扫描二维码 获取最新技术原创