https://en.wikipedia.org/wiki/JPEG
EXIF Tags - https://exiftool.org/TagNames/EXIF.html
JPEG文件格式解析(一) Exif 与 JFIF - 云+社区 - 腾讯云
mayanklahiri/easyexif: Tiny ISO-compliant C++ EXIF parsing library, third-party dependency free.
16进制数据查看工具:VS Code插件 - hexdump
概述
JPEG格式保存的图片在头部使用EXIF格式记录了大量的元数据,包括宽高、描述、分辨率、缩略图、定姿定位信息等。Exif信息存放的位置遵循一定的数据结构,按照字节进行存放,本文介绍如何逐字节读取Exif信息。
名词解释:
- JPEG(Joint Picture Expert Group):联合图像专家组
- JFIF(JPEG File Interchange Format):JPEG文件交换格式
- EXIF(Exchange Image File Format):图像文件交换格式
JPEG Marker
JPEG Marker即JPEG标志,是JPEG格式的文件中用于分块存储数据的标志位。本文需要用到的JPEG标志:
名称 | 载荷 | 标志 | 说明 |
---|---|---|---|
SOI | 无 | 0xFFD8 |
图像头 |
APP0~APP15 | 可变 | 0xFFE0~0xFFEF |
特定信息的存放块 |
APP0 | 可变 | 0xFFE0 |
APP0存放块,存放JFIF信息 |
APP1 | 可变 | 0xFFE1 |
APP1存放块,存放Exif信息 |
EOI | 无 | 0xFFD9 |
图像尾 |
还有一些其他的JPEG标志,统一格式为:Marker
(2字节)+Size
(2字节)+Data
(长度+2等于Size指定的字节数):
名称 | 标志 | 载荷 | 说明 | 注释 |
---|---|---|---|---|
SOF0 | 0xFFC0 |
可变 | 帧开始(基线DCT(离散余弦变化)) | 表明这是一个基于基线DCT的JPEG图像,并指定了宽/高/组件数量/组件子采样的信息 |
SOF2 | 0xFFC2 |
可变 | 帧开始(渐进式DCT) | 表明这是一个基于渐进式DCT的JPEG图像,并指定了宽/高/组件数量/组件子采样的信息 |
DHT | 0xFFC4 |
可变 | 定义哈夫曼表(可能有多个) | 指明一个或多个哈夫曼表 |
DQT | 0xFFDB |
可变 | 定义量化表(可能有多个) | 指明一个或多个量化表 |
DRI | 0xFFDD |
4字节 | 定义重新开始的间隔 | Specifies the interval between RSTn markers, in Minimum Coded Units (MCUs). This marker is followed by two bytes indicating the fixed size so it can be treated like any other variable size segment. |
RSTn | 0xFFD0~0xFFD7 |
无 | 重新开始 | Inserted every r macroblocks, where r is the restart interval set by a DRI marker. Not used if there was no DRI marker. The low three bits of the marker code cycle in value from 0 to 7. |
SOS | 0xFFDA |
可变 | 扫描开始 | 开始从上到下扫描图像。在基线 DCT JPEG 图像中,通常只有一次扫描。渐进式 DCT JPEG 图像通常包含多次扫描。此标记指定它将包含哪个数据切片,并且紧随其后的是熵编码数据。 |
COM | 0xFFFE |
无 | 注解 | 包含了一段文本注解 |
一个JPEG图像二进制数据的例子:
红色部分是JPEG中存放的不同数据块的标志位,紧跟着的2字节(黄色部分)指定了载荷大小,这2字节加上后面的数据长度,正好等于载荷大小。
JFIF APP0
APP0(JPEG Marker为FF E0)以JFIF的格式存放缩略图,用得比较少,记录一下字段的意思:
字段 | 字节数 | 说明 |
---|---|---|
APP0标记 | 2字节 | 0xFFE0 |
载荷大小 | 2字节 | APP0数据载荷的大小 |
JFIF标识符 | 5字节 | 字符串:JFIF,4A 46 49 46 00 |
JFIF版本 | 2字节 | 主版本 + 次版本(01 01 表示1.01) |
密度单位 | 1字节 | 像素密度字段的单位(00 无单位,01 每英寸像素,02 每厘米像素) |
Xdensity | 2字节 | 水平像素密度单位。不得为0 |
Ydensity | 2字节 | 垂直像素密度单位。不得为0 |
Xthumbnail | 1字节 | 嵌入的RGB缩略图的水平像素数(宽)。可以为0 |
Ythumbnail | 1字节 | 嵌入的RGB缩略图的垂直像素数(高)。可以为0 |
缩略图数据 | 3×n字节 | 未压缩的24位RGB(每个通道8位)光栅缩略图数据,按照R/G/B顺序排列,n为像素数(n=Xthumbnail×Ythumbnail) |
示例:
这幅图像的JFIF版本为1.01,像素密度单位为每英寸像素,水平和垂直像素密度都为C0
(192dpi),不包含缩略图。
另一个示例:
这幅图像的JFIF版本为1.01,不包含缩略图。像素密度单位未在JFIF数据块中给出,但由于下一个数据块是FF E1
(EXIF APP1),因此有可能将该信息存放在其中。
EXIF APP1
APP1的格式
EXIF信息字段以FF E1
开头,数据结构比较简单,但是存放的信息量非常大,每一条数据都是按照标签(tag)、格式(format)、数据量(size)和数据(data)的格式存放,一条数据称为一个Entry,多条数据组成一个目录,该目录称作Image File Directory(IFD)
不同的标签代表不同的信息,可以在EXIF Tags - https://exiftool.org/TagNames/EXIF.html查到
EXIF的数据结构说明如下所示:
字段 | 字节数 | 说明 |
---|---|---|
APP1标记 | 2字节 | FF E1 |
长度 | 2字节 | APP1的载荷大小 |
Exif标识符 | 6字节 | 字符串:Exif,45 78 69 66 00 00 |
TIFF头信息 | 8字节 | 见后续表格 |
IFDn | 不固定 | 第一个数据存储区,包括:数据条数、每一条数据、下一个IFD的偏移量、数据存储区 |
缩略图 | 不固定 | 格式为JPEG(开头0xFFD8 ,结尾0xFFD9 )或TIFF(TIFF头信息+若干个IFD) |
在APP1中的所有IFD存储的偏移量,均以Exif标识符字符串的结尾作为起点。(也就是TIFF头信息的起始位置)
TIFF头
TIFF头信息遵循TIFF图像格式的存储方式,存放了数据字节对齐方式、到第一个IFD的偏移量的信息,格式如下:
字段 | 字节数 | 说明 |
---|---|---|
对齐方式 | 2 | “II”或“MM”,II 表示数字存储遵循 intel 的字节序,即小端存储,MM 表示数据存储遵循 Motorola 的字节序,即大端存储 |
标志位 | 2 | II 为0x002a ,MM 为0x2a00 |
偏移 | 4 | 到IFD区域的偏移 |
IFD
IFD作为存放数据的目录,由TIFF头指定第一个IFD,之后的IFD由前一个IFD末尾的偏移量(依旧是以TIFF头为依据)告知,直到这个偏移量为0,其数据结构如下:
字段 | 字节数 | 说明 |
---|---|---|
Entry数量 | 2 | 记录了当前目录下数据的条数 |
若干条数据 | 每条12字节 | 2字节标识(tag),2字节格式(format),4字节数据量(size),4字节数据(data) |
… | … | … |
下一个IFD的偏移量 | 4字节 | 下一个IFD的偏移,若为0,则说明当前IFD为最后一个 |
数据区域 | 不固定 | 由于每一条数据固定12字节,数据区仅4字节,存储量有限,有些数据存放在这个区域中 |
下面是一个EXIF数据区域的例子
可以看到,FF E1
之后2字节(0x5CDF
)给出了该数据区域的总大小,紧跟着是Exif\0\0
字符串,TIFF头中指明了该区域中的数据对齐方式为II
,且到第一个IFD的偏移是8(从Exif\0\0
结尾往后数)。
进入IFD,前面2字节给出了该IFD中IF Entry的数量(00 0C = 12
),接着就是12个IF Entry,在这12个入口之后4个字节指定了下一个IFD的偏移量,再往后就是该IFD的数据区域了。
IF Entry
IFD中的每一条数据称作IF Entry,长度固定为12字节。由2字节标识符、2字节数据格式、4字节数据量和4字节数据构成。
对于每一条数据,若数据量大于4字节,则存放在IFD最后的数据区域中,4字节记录实际数据的偏移,判断数据量的依据是中间两个字段:格式和数据量。不同格式的数据所占字节数不同,参照下表:
数据类型标识 | 数据类型 | 所占字节数 |
---|---|---|
01 |
Unsigned Byte | 1 |
02 |
ASCII String | 1 |
03 |
Unsigned Short | 2 |
04 |
Unsigned Long | 4 |
05 |
Unsigned Rational | 8 |
06 |
Signed Byte | 1 |
07 |
Undefined | 1 |
08 |
Signed Short | 2 |
09 |
Signed Long | 4 |
0A |
Signed Rational | 8 |
0B |
Single Float | 4 |
0C |
Double Float | 8 |
IFD中的缩略图
通常来说,在多个IFD之后会有一个缩略图(也可能没有),缩略图的格式(JPEG或TIFF)、位置(偏移)、大小通常在IFD1中给出。
在IFD1中,Exif Tag
为0x0103
的数据(Compression
)指明了缩略图的存储格式:1为TIFF,6为JPEG;
当Compression == 6
时:
0x0201
指明了缩略图的偏移0x0202
指明了缩略图所占字节数
当Compression == 1
时:
0x0111
指明偏移0x0117
指明长度0x0106
为1时,是RGB形式存储的TIFF,为6时,时YCbCr形式的TIFF
下面是一个IFD1的例子(对齐方式为II
):
第一个IF Entry的标签即01 03
,格式3对应Unsigned short,长度为1,数据大小为2x1=2 < 4
,因此数据存放在IF Entry的最后四字节中,得到值为6,因此缩略图是JPEG格式;02 01
指明了其偏移的位置为0x07B0
,从“Exif字符串”之后的第一个字符加上这个偏移量(0x1E + 0x07B0 = 0x07CE
),看到在0x07CE
的位置出现了FF D8
;02 02
指明了缩略图大小为0x5527=21799
字节;IFD1的另外三个字段分别为01 1A
(X Resolution),01 1B
(Y Resolution)和01 28
(ResolutionUnit),数据存不下放到后面的数据区域中去了,可以看到图片的X和Y方向的分辨率均为72dpi(分辨率单位2表示inches)。
尝试读取GNSS信息
数据格式说明
影像的GNSS定位信息存放在Exif数据块中,其入口是标签为88 25
的IF Entry(参考),该IF Entry指定了GNSS数据区域的偏移位置。
在GNSS数据区域中,遵循IFD的数据结构,前两个字节为IF Entry的数量,紧跟着这个数量的IF Entry,每个12字节,仍然为2字节标签、2字节格式、4字节长度、4字节数据或偏移。标签与数据的对应关系可以查阅:https://exiftool.org/TagNames/GPS.html。
示例
在该示例中,IFD0的第12个IF Entry标签为88 25
,数据项0x06C0
说明GNSS数据区域的偏移量位于0x1E + 0x06C0 = 0x06DE
,我们来到0x06DE
的位置,看到如下结果:
00 07
表明GNSS数据区域有7条数据,参阅https://exiftool.org/TagNames/GPS.html可知,经纬高程存放在标签为01到06的数据条目中。以纬度为例,0x01
指定其南北纬(N or S),0x02
指定其纬度数据。
第二条数据为纬度参考:01 00 | 02 00 | 02 00 00 00 | 4E 00 00 00
,数据格式2对应ASCII字符,每个1字节,长度为2,总体数据占1*2 < 4
字节,因此4E 00 00 00
为其对应的数据,ASCII码的4E = 78
对应字符N
,因此得到GNSS定位信息的纬度为北纬;
第三条数据为纬度数据:02 00 | 0A 00 | 03 00 00 00 | 1A 07 00 00
,数据格式A对应有理数,每个8字节,且长度为3,总体数据占8*3 > 4
字节,因此数据项1A 07 00 00
为实际数据的偏移量。由该示例中的第一图可知,偏移量的参照位置为1E
,因此纬度数据的实际位置为:0x071A + 0x1E = 0x0738
,从这个位置取8*3
字节的数据,分别为1E 00 00 00 01 00 00 00
,1F 00 00 00 01 00 00
, A0 2F 43 1D 80 96 98 00
,这三个数据分别对应“度”“分”“秒”,数据格式为有理数(有理数的前四位为分子,后四位为分母),计算后可得,该纬度数据为:30° 31′ 49.0942368″
C++代码实现要点记录
1. 二进制数据读取与转换
查看图像的二进制文件可以看到,其每个字节存放的是00-FF的数据,在C++的数据结构中unsigned char
与其对应,因此我们可以定义数据类型byte
来存放二进制数据。
1 | typedef unsigned char byte; |
从二进制数据解析出指定位数的数据,定义模板函数,通过不同的数据类型传入进行解析:
1 | /* |
1 | template <> |
通过位运算(<<
)移动指定位数,注意1字节等于8位,然后用按位与(|
)实现累加。
2. IMU姿态信息存放的格式问题
由于在JPEG影像中存放IMU姿态信息的方式还未成为一种标准,且使用范围较小,因此可能存在多种存储形式。
IMU信息一般存放在XMP数据中,读取方式是首先将XMP字符串解析为XML,然后读取对应的字段或属性,有些照片中,IMU信息存放在属性中,而有些存放在子节点中,如下图所示:
因此读取时,要进行区分:
1 | !get_xml_child_value(rdf_node, "<xmlattr>.drone-dji:GpsLatitude", posInfo.GnssLatitude) |
3. XMP字符串的位置
XMP字符串的位置也非常不固定,有些照片按照EXIF的标准,存放在EXIF数据区域中,以标签02 BC
指定位置;
而有的照片直接开了一块APP1(FF E1),然后直接存放XMP字符串;
还有的照片压根没有EXIF数据块,却在FF E1中存放了XMP字符串;
当然也有啥都没有的……
为了处理以上几种情况,采用了以下判断逻辑:
- 首先定义两个数据,GNSS信息和IMU信息
- 在文件中查找APP1(FF E1)
- 找到后判断是否为EXIF
- 如果是EXIF数据块,则遍历所有数据条目(IF Entry)
- 找到GNSS后,若GNSS信息为空,则填充,不为空就略过
- 找到XMP后,若IMU信息为空,则尝试解析出IMU数据,不为空就略过
- 如果不是EXIF数据块,且IMU信息为空,则尝试解析出IMU数据,否则略过
- 如果是EXIF数据块,则遍历所有数据条目(IF Entry)
- 处理完一个APP1后,若IMU信息或GNSS信息有一个为空,则继续向后查找APP1,直到两个数据均不为空(或者到达文件尾)
以上处理逻辑实现位于ImageMetaReader.cpp
的函数read_pos_from_jpeg_file()
中。