一文教你如何用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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章