专注于做有价值的技术原创

0%

JPEG 图片存储格式与元数据解析

1. .jpg, .png, .gif

说到图片,我们首先会想到,几种常见图片格式,如:.jpg, .png, .gif 等。

但当我门在说图片的格式时,除了在说图片文件的后缀不同,还有什么不同呢?

事实上,图片的格式,在技术上,是指图片所遵循的压缩标准。更准确地说,是数字图像的压缩标准(计算机上的图片都是数字图像,即由 0 和 1 构成的二进制数字图像文件)。

可能会有人不明白,为什么图片的格式是压缩标准? 图片为什么要压缩? 难道存储在我们个人电脑的图片都是压缩的?

没错,不管是存储在我们个人电脑,手机,还是在网络上图片其实都是经过压缩后的图片数据。

那么,压缩前的原始图像数据又是什么样的? 以及为什么要对图像进行压缩?

2. 原始图像数据

不管是什么格式,或采用什么样的压缩标准,原始的图像数据其实都是一样的,而且也符合我门直观的理解。

例如,一张 4 × 4 (宽度和高度都是 4 个像素)的彩色图片,未压缩的的原始图像数据,就是一个 4 × 4 矩形网格,每一个网格代表一个像素。

而彩色图片的每一个像素,又是由 红,绿,蓝 三基色构成,如下图右边所示,红绿蓝,对应于 r g b 三个数值,也就是我常说的 RGB 色彩模式。

4乘4 彩色图片,这里进行了放大处理,右边和左边是同一张图,为像素标注了 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

我相信我们可以很快得出结果,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 来查看任何二进制文件,包括图片。

读者,可以将下面这张图片 保存到 自己的电脑上。

320 × 320

在命令行界面,进入 dog.jpeg 文件所在目录,运行如下命令:

1
2
3
4
5
6
7
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: 如果想对显示的格式进行控制,可以尝试增加如下选项,格式化显示输出:

1
2
3
hexdump -e '16/1 "%02X "' -e '"\n"' dog.jpeg

hexdump -e '16/1 "%02X " " | "' -e '16/1 "%_p" "\n"' dog.jpeg

我们看到的输出如下图所示(中间内容省略,这里只截取了开头和结尾各两行):

图中,红线框圈住的部分,是图片数据的字节流编址,可以看作是为了查看方便,添加的行号,红框右边的才是图片的真实存储字节流,并且每行显示 16 个字节。当然不管是“行号”还是图片数据,为了显示的简介性,默认都是用了16进制。

这里我忽略红框中的“行号”,只关注图片字节流数据。

这里要注意的是,图中数据是一行行显示的,并且每行中,字节间都有空格,其实,这里还是为了方便查看才这样显示的,真实存储的数据并非一行一行,字节间也没有空格,所谓字节流,就是图片数据字节都是连续不间断的,串成一条线,在程序里,体现为一个一维的字节数组。

为了验证这点,我们不妨用实践说话。

在一台已经安装了Python(MacOS 内置了 Python 2)机器,启动命令行,输入 python 进入 python 交互式编辑环境。使用如下 python 代码,查看 图片 dog.jpeg 的二进制字节流。

1
2
3
4
5
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 从磁盘读取图片二进制字节流数据
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' + b
if b == 'ff':
tag_start = True
continue
if tag_start:
if b != 'ff' and (b != '00'):
tag = 'ff' + b
if not tag in tagmarker:
tagmarker[tag] = list()
tag_start = False
data_start = True
continue
else:
tag_start = False
tagmarker[tag].append('ff')
if data_start:
tagmarker[tag].append(b)

# 查看解析后,有那些标记数据,以及对应数据的长度
for tag, arr in tagmarker.items():
s = len(arr)
if s == 0:
print(tag)
continue
print("{}:\t{}\tbytes".format(tag, s))

使用 python3 运行脚本:

1
python view_dog_marker_data.py

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 标记数据字段含义

我们看到,除了已经知道的 ffd8ffd9 分别表示图片的开头和结尾,并且后面没有内容数据,其他我们还不知道的标记数据,后面都有不同长度的内容数据。

下面直接给出,这些标记数据对应字段:

字节码 标记符
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*

为了在前面的脚本基础上,得到字节码对应的标记符,创建如下字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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' % i
tag_map[tag] = 'APP'+str(i)

for i in [0, 1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15]:
tag = 'ffc%x' % i
tag_map[tag] = 'SOF'+str(i)

for i in range(8):
tag = 'ffd%x' % i
tag_map[tag] = 'RST'+str(i)

for i in range(14):
tag = 'fff%x' % i
tag_map[tag] = 'JPG'+str(i)

map_tag = { v: k for k, v in tag_map.items() }

通过字典,将字节码替换成标记字段,查看解析结果:

1
2
3
4
5
6
7
8
9
for tag, arr in tagmarker.items():
s = len(arr)
if tag in tag_map:
tag = tag_map[tag]
if s == 0:
print(tag)
continue

print("{}:\t{}\tbytes".format(tag, s))

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
青笔 wechat
我是一条小青蛇 我有很多小秘密