起因
早上起来,看到有人问Python获取一张JPG格式图片拍摄的时候的GPS定位的代码。GPS应该说是个敏感的信息,既然有人想读取我们的信息,那么我们至少应该直到我们的敏感信息被保存在了哪里。
研究了一天,四处搜集文档,对着一张JPG格式文件的二进制代码,终于摸到了点门道。结论就是并不是所有的图片都带着GPS等信息,例如我们微信发送图片的时候,如果不发送原图,很多信息都会被抹除(抹除APP1标签,下文有介绍)。
这里顺路推荐一个Linux下查看二进制文件的一个命令行工具:hexedit,Ubuntu下使用命令sudo apt install hexedit就可以安装,虽然没有UltraEdit这种好用,但是对于查看文件也足够了。
写代码,肯定要先直到原理,因此下面需要简单介绍下JPEG这个东西。
JPG 简介
JPEG(Joint Photographic Experts Group,联合图像专家组)标准定义了一套对静态图片进行压缩的算法,用于对图像或者视频进行压缩。JPEG标准定义了四种操作,分别是顺序DCT(sequential Discrete Cosine Transform) 模式、渐进DCT(progressive DCT)模式、无损(Lossless)模式和分层(hierarchical)模式。根据不同模式会对原是图像进行多次扫描,每扫描一次就得到一帧。每一帧前面会添加有一些压缩的参数,例如量化表、霍夫曼编码表等。这套算法可以对图像数据或者视频数据进行压缩,但是却没有定义怎么将这些压缩后的数据通过一个图片格式保存。因此JFIF(JPEG File Interchange Format)就成了一个事实上的标准,它通过标签段的方式,为这些压缩数据提供了额外的信息。
另外还有一种表示JPEG图片的格式是Exif( Exchangeable image file Format),它并不是一个新的标准,而是通过组合已有标准而成的格式。对压缩数据的标准使用的是(ISO/IEC 10918-1),和JFIF的标准一样,只不过增加了一个额外表示信息的标签APP1,这个APP1的格式标准使用的是(TIFF Rev. 6.0)。因此他们俩主要区别就是附加信息的标签不同,JFIF的标签是APP0,而Exif的标签是APP1。而例如拍摄图片的所用的相继型号等信息,就是储存在APP1标签里面。
我们的主要目的是获取图片属性信息,因此我们主要介绍Exif的格式。
Exif图片的主要结构如图所示,被特定的标签被分成了一段一段的数据。所谓标签,就是2个有特定数值的字节,它们对应的名字和数值如图所示。这些标签其实就是一组特定的数值,每个标签占两个字节,其中灰色的是必须有的结构,白色的根据情况有可能没有。
Exif格式中可能存在的标签以及每个标签对应的值和含义如下:
APP1
因为我们关注的重点是APP1标签,所以我们看一下APP1的结构,其他标签等如DQT等示关于压缩数据的,如果需要处理图片内容的可以详细看,这里就忽略了。APP1有两种形式,一种是带缩略图信息的,另一种是不带缩略图信息的,分别如下面图和图所示:
我们已经直到APP1标签的值示0xFFE1
;而Length记录了APP1段的长度,长度不能超过64KB,因为Length占用2个字节记录长度,因此最大子能表示64KB;Exif标示符占6个字节,内容是0x45 0x78 0x69 0x66 0x00 0x00
,也就是Exif
四个字符外加两个空字符;而TIFF头如下图所示:
TIFF头之后就是IFD和IFD的值。0th IFD主要存储主图的信息,1st IFD可能存储着缩略图的信息。
IFD(Image File Directory) 结构
JPEG图片的信息存储分为两部分:IFD和IFD Value。IFD是一个线索,通过这个线索可以找到IFD Value。举个栗子,IFD就是租房中介,IFD Value是房子。中介手上会有所有房子的信息,你找到了中介,就能直到所有房子的数量、户型、地址。
IFD由三部分组成:
- 第一部分:占据2个字节,这两个字节记录一共有多少条信息;
- 根据第一部分记录的信息的数量,每条信占用12个字节,着十二个字节又可以分为四个部分:
1) Tag:2个字节表示这条信息的类型,比如是记录长、宽还是拍摄时间等信息;
2) Type:2个字节表示这些记录存储为二进制的信息怎么翻译,比如是翻译成整数、小数还是字符串等;
3)Count:4个字节表示这个记录的值有多少个,不一定示多少个字节。例如当值的类型示无符号整型的时候,因为一个无符号整型数值占用两字节,Count = 1就表示两个字节;
4) Offset:4个字节表示找到这条记录的偏移地址,以TIFF头为基准。如果记录的数使用着四个字节就装的下,那么Offset记录的就不是地址而是实际的值,如果所用到的字节小于4,那么从最左边的字节开始使用,也就是Offset的低位开始。例如存储一个SHORT类型1的时候,大端格式中着四个字节的内容就是0x0001 0000
,而小端格式就是0x0000 0000 0000 0001
。 - 第三部分占用4个细节,记录的是下一个IFD的偏移地址,如果没有下一个IFD了,这四个字节的值就是0x00000000。
IFD的结构如下:
IFD中Type的含义如下:
0th IFD中Tag的值和其对应意思如下:
可以看到,上面的Tag表格中并为包含GPS信息,因为GPS自己有一个属于自己的IFD,在0th IFD中只有一个指向GPS IFD的指针:
有了上面的铺垫,我们就可以开始编程了。
Coding
下面是我根据我的理解写的代码,额外用到了一个numpy
库,平常工作中用的比较多,更重要的是它能够将图片按照我的想法读取进内存。
代码的思路是是这样:
- 确定这是一张JPG图片:通过SOI标签确定,这个标签在文件的最开头,内容是
0xFFD8
; - 找到APP1标签,理论上APP1标签是要紧跟着SOI标签的,但是我在查看图片二进制内容的时候发现并不是所有的照片都这样,因此使用搜索APP1的方法;
- 找到0th IFD,按照上文中的解释来找;
- 把找到的标签的、TIFF头的地址都记录下来
- 获取所有的IFD;
- 看找到的IFD有没有GPS IFD的指针
- 通过GPS IFD的指针找到GPS IFD;
- 读取GPS信息
下面是代码实现,另外源代码在本人Github地址为:https://github.com/zmychou/jpg-info-extractor
时间很晚了,就先草草收场了。
import numpy as np
IMAGE = []
markers = {
'APP1': [0xFF, 0xE1],
'SOI': [0xFF, 0xD8]
}
class APP1(object):
attributes_name = {
271: 'Manufacturer',
272: 'Model',
306: 'Last Modify',
29: 'GPS Date',
305: 'Software'
}
byte_count = {
'ASCII': 1,
'BYTE': 1,
'SHORT': 2,
'LONG': 4,
'RATIONAL': 8
}
type_of_ifd = {
1: 'BYTE',
2: 'ASCII',
3: 'SHORT',
4: 'LONG',
5: 'RATIONAL'
}
class Field(object):
def __init__(self, _tag, _type, _count, _offset, _is_offset):
self.tag = _tag
self.type = _type
self.count = _count
self.offset = _offset
exif_identifier = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]
def __init__(self, img, app1_offset):
self._image = img
self.app1_offset = app1_offset
self._exif_identifier_offset = 4
self.tiff_header_length = 8
self.ifd_offset = 0
self.little_endian = [0x49, 0x49]
self.big_endian = [0x4D, 0x4D]
self.marker = [0xFF, 0xE1]
self.endian = self._image[self.app1_offset + 10: self.app1_offset + 12]
self.ifd_offset = self.zero_ifd_offset()
self.number_of_zero_ifd = self.get_number_of_fields()
def get_field(self, fields_num, ifd_offset, ):
fields = []
for i in range(fields_num):
start = ifd_offset + 2 + i * 12
raw_ifd = self._image[start: start + 12]
tag = self.read_bytes_in_value(raw_ifd[0: 2], False)
type = self.read_bytes_in_value(raw_ifd[2: 4], False)
count = self.read_bytes_in_value(raw_ifd[4: 8], False)
offset = self.read_bytes_in_value(raw_ifd[8: 12], False)
# todo: evaluate is_offset args
field = APP1.Field(tag, type, count, offset, False)
fields.append(field)
return fields
def get_fields(self):
fields = self.get_field(self.number_of_zero_ifd, self.ifd_offset)
# Get GPS info
for field in fields:
if field.tag == 0x8825:
self.gps_ifd_offset = field.offset + self.tiff_header_offset
break
gps_fields_raw = self._image[self.gps_ifd_offset: self.gps_ifd_offset + 2]
fields_of_gps = self.read_bytes_in_value(gps_fields_raw, False)
gps_fields = self.get_field(fields_of_gps, self.gps_ifd_offset)
fields.extend(gps_fields)
return fields
def read_attribute(self, field):
ifd_type = APP1.type_of_ifd[field.type]
byte_count = APP1.byte_count[ifd_type] * field.count
start = self.tiff_header_offset + field.offset
end = start + byte_count
raw = self._image[start: end]
if ifd_type == 'ASCII':
attr = [APP1.attributes_name[field.tag], ': ']
for r in raw:
attr.append(chr(r))
print(''.join(attr))
def get_number_of_fields(self):
raw = self._image[self.ifd_offset: self.ifd_offset + 2]
return self.read_bytes_in_value(raw, False)
def zero_ifd_offset(self):
raw = self._image[self.tiff_header_offset + 4: self.tiff_header_offset + 8]
return self.read_bytes_in_value(raw, False) + self.tiff_header_offset
def read_bytes_in_value(self, bytes, ignore_endian):
if not ignore_endian and self.is_little_endian:
bytes.reverse()
value = 0
for byte in bytes:
value = value << 8
value = value + byte
return value
@property
def exif_identifier_offset(self):
return self.app1_offset + self._exif_identifier_offset
@property
def tiff_header_offset(self):
return self.app1_offset + 10
@property
def is_little_endian(self):
endian = self.endian == self.little_endian
return endian
def compare_bytes(candidate, target):
return candidate == target
def find_marker(marker, start):
candidate = copy_bytes(start, 2)
return marker == candidate
def get_app1_marker_offset():
length = len(IMAGE)
for i in range(2, length, 1):
if find_marker(markers['APP1'], i):
exif_identifier = copy_bytes(i + 4, 6)
if compare_bytes(exif_identifier, APP1.exif_identifier):
return i
return -1
def copy_bytes(start, length):
if start > len(IMAGE) or start + length > len(IMAGE):
raise Exception('Copy bytes fail: out of range.')
return IMAGE[start: start + length]
def load_image(path):
n = np.fromfile(path, dtype=np.ubyte)
n = n.tolist()
return n
def main():
global IMAGE
IMAGE = load_image('1424054533.jpg')
if not find_marker(markers['SOI'], 0) :
print('Given image seems not a JPEG image.')
return
app1 = APP1(IMAGE, get_app1_marker_offset())
fields = app1.get_fields()
for field in fields:
app1.read_attribute(field)
if field.tag == 0x8825:
print('has GPS info')
if __name__ == '__main__':
main()
运行结果如下,获取到的图片的部分信息:
Model: MI 6
Software: sagit-user 9 PKQ1.190118.001 V11.0.2.0.PCACNXM release-keys
Last Modify: 2020:03:15 19:49:17
has GPS info
Manufacturer: Xiaomi
GPS Date: 2020:03:15
首发于个人微信公众号TensorBoy。微信扫描上方二维码或者微信搜索TensorBoy并关注,及时获取更多最新文章!
References
http://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf
http://lad.dsc.ufcg.edu.br/multimidia/jpegmarker.pdf
http://www.npes.org/pdf/TIFF-v6.pdf
https://www.w3.org/Graphics/JPEG/jfif3.pdf
http://www.fileformat.info/format/jpeg/egff.htm
https://tools.ietf.org/html/rfc2435