起因
早上起來,看到有人問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