一文教你如何用Python读取图片GPS定位

起因

早上起来,看到有人问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个有特定数值的字节,它们对应的名字和数值如图所示。这些标签其实就是一组特定的数值,每个标签占两个字节,其中灰色的是必须有的结构,白色的根据情况有可能没有。

1584186733395.jpeg

Exif格式中可能存在的标签以及每个标签对应的值和含义如下:
image.png

APP1

因为我们关注的重点是APP1标签,所以我们看一下APP1的结构,其他标签等如DQT等示关于压缩数据的,如果需要处理图片内容的可以详细看,这里就忽略了。APP1有两种形式,一种是带缩略图信息的,另一种是不带缩略图信息的,分别如下面图和图所示:
Basic structure of jpg.jpeg

Structure with thumbnail.jpeg

我们已经直到APP1标签的值示0xFFE1;而Length记录了APP1段的长度,长度不能超过64KB,因为Length占用2个字节记录长度,因此最大子能表示64KB;Exif标示符占6个字节,内容是0x45 0x78 0x69 0x66 0x00 0x00,也就是Exif四个字符外加两个空字符;而TIFF头如下图所示:

TIFF Header.jpeg

TIFF头之后就是IFD和IFD的值。0th IFD主要存储主图的信息,1st IFD可能存储着缩略图的信息。

IFD(Image File Directory) 结构

JPEG图片的信息存储分为两部分:IFD和IFD Value。IFD是一个线索,通过这个线索可以找到IFD Value。举个栗子,IFD就是租房中介,IFD Value是房子。中介手上会有所有房子的信息,你找到了中介,就能直到所有房子的数量、户型、地址。
IFD由三部分组成:

  1. 第一部分:占据2个字节,这两个字节记录一共有多少条信息;
  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
  3. 第三部分占用4个细节,记录的是下一个IFD的偏移地址,如果没有下一个IFD了,这四个字节的值就是0x00000000。

IFD的结构如下:
IFD结构

IFD中Type的含义如下:
image.png

0th IFD中Tag的值和其对应意思如下:
1584175240316.jpeg

可以看到,上面的Tag表格中并为包含GPS信息,因为GPS自己有一个属于自己的IFD,在0th IFD中只有一个指向GPS IFD的指针:
image.png
GPS IFD.jpeg

有了上面的铺垫,我们就可以开始编程了。

Coding

下面是我根据我的理解写的代码,额外用到了一个numpy库,平常工作中用的比较多,更重要的是它能够将图片按照我的想法读取进内存。
代码的思路是是这样:

  1. 确定这是一张JPG图片:通过SOI标签确定,这个标签在文件的最开头,内容是0xFFD8;
  2. 找到APP1标签,理论上APP1标签是要紧跟着SOI标签的,但是我在查看图片二进制内容的时候发现并不是所有的照片都这样,因此使用搜索APP1的方法;
  3. 找到0th IFD,按照上文中的解释来找;
  4. 把找到的标签的、TIFF头的地址都记录下来
  5. 获取所有的IFD;
  6. 看找到的IFD有没有GPS IFD的指针
  7. 通过GPS IFD的指针找到GPS IFD;
  8. 读取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

发布了45 篇原创文章 · 获赞 4 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/ZM_Yang/article/details/104888352