0%

C++逐字节读取JPEG的Exif信息

  1. 概述
    1. JPEG Marker
    2. JFIF APP0
    3. EXIF APP1
      1. APP1的格式
      2. TIFF头
      3. IFD
      4. IF Entry
      5. IFD中的缩略图
  2. 尝试读取GNSS信息
    1. 数据格式说明
    2. 示例
    3. C++代码实现要点记录
      1. 1. 二进制数据读取与转换
      2. 2. IMU姿态信息存放的格式问题
      3. 3. XMP字符串的位置

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-Marker

红色部分是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)

示例:

image-20211017184546812

这幅图像的JFIF版本为1.01,像素密度单位为每英寸像素,水平和垂直像素密度都为C0(192dpi),不包含缩略图。

另一个示例:

image-20211017184854682

这幅图像的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 II0x002aMM0x2a00
偏移 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数据区域的例子

image-20211024163321594

可以看到,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 Tag0x0103的数据(Compression)指明了缩略图的存储格式:1为TIFF,6为JPEG;

Compression == 6时:

  • 0x0201指明了缩略图的偏移
  • 0x0202指明了缩略图所占字节数

Compression == 1时:

  • 0x0111指明偏移
  • 0x0117指明长度
  • 0x0106为1时,是RGB形式存储的TIFF,为6时,时YCbCr形式的TIFF

下面是一个IFD1的例子(对齐方式为II):

image-20211018025203855

第一个IF Entry的标签即01 03,格式3对应Unsigned short,长度为1,数据大小为2x1=2 < 4,因此数据存放在IF Entry的最后四字节中,得到值为6,因此缩略图是JPEG格式;02 01指明了其偏移的位置为0x07B0,从“Exif字符串”之后的第一个字符加上这个偏移量(0x1E + 0x07B0 = 0x07CE),看到在0x07CE的位置出现了FF D802 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

示例

image-20211024163321594

在该示例中,IFD0的第12个IF Entry标签为88 25,数据项0x06C0说明GNSS数据区域的偏移量位于0x1E + 0x06C0 = 0x06DE,我们来到0x06DE的位置,看到如下结果:

image-20211024165623900

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 001F 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
2
3
4
5
6
7
/*
* 数据解析函数(从无符号字符数组中解析数据)
* bAlignIntel == true: 小端存储 II 0x2a00
* bAlignIntel == false: 大端存储 MM 0x002a
*/
template <typename T, bool bAlignIntel>
T parse(const byte* buf);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <>
uint8_t ImageMetaReader::parse<uint8_t, true>(const byte* buf) { return *buf; }
template <>
uint8_t ImageMetaReader::parse<uint8_t, false>(const byte* buf) { return *buf; }

template <>
uint16_t ImageMetaReader::parse<uint16_t, true>(const byte* buf) { return *(buf + 1) << 8 | *buf; }
template <>
uint16_t ImageMetaReader::parse<uint16_t, false>(const byte* buf) { return *buf << 8 | *(buf + 1); }

template <>
uint32_t ImageMetaReader::parse<uint32_t, true>(const byte* buf) { return *(buf + 3) << 24 | *(buf + 2) << 16 | *(buf + 1) << 8 | *buf; }
template <>
uint32_t ImageMetaReader::parse<uint32_t, false>(const byte* buf) { return *buf << 24 | *(buf + 1) << 16 | *(buf + 2) << 8 | *(buf + 3); }

通过位运算<<)移动指定位数,注意1字节等于8位,然后用按位与|)实现累加。

2. IMU姿态信息存放的格式问题

由于在JPEG影像中存放IMU姿态信息的方式还未成为一种标准,且使用范围较小,因此可能存在多种存储形式。

IMU信息一般存放在XMP数据中,读取方式是首先将XMP字符串解析为XML,然后读取对应的字段或属性,有些照片中,IMU信息存放在属性中,而有些存放在子节点中,如下图所示:

image-20211024181148829

因此读取时,要进行区分:

1
2
!get_xml_child_value(rdf_node, "<xmlattr>.drone-dji:GpsLatitude", posInfo.GnssLatitude)
&& !get_xml_child_value(rdf_node, "drone-dji:GpsLatitude", posInfo.GnssLatitude);

3. XMP字符串的位置

XMP字符串的位置也非常不固定,有些照片按照EXIF的标准,存放在EXIF数据区域中,以标签02 BC指定位置;

image-20211024181818381

而有的照片直接开了一块APP1(FF E1),然后直接存放XMP字符串;

image-20211024181938856

还有的照片压根没有EXIF数据块,却在FF E1中存放了XMP字符串;

image-20211024182010608

当然也有啥都没有的……

为了处理以上几种情况,采用了以下判断逻辑:

  • 首先定义两个数据,GNSS信息和IMU信息
  • 在文件中查找APP1(FF E1)
  • 找到后判断是否为EXIF
    • 如果是EXIF数据块,则遍历所有数据条目(IF Entry)
      • 找到GNSS后,若GNSS信息为空,则填充,不为空就略过
      • 找到XMP后,若IMU信息为空,则尝试解析出IMU数据,不为空就略过
    • 如果不是EXIF数据块,且IMU信息为空,则尝试解析出IMU数据,否则略过
  • 处理完一个APP1后,若IMU信息或GNSS信息有一个为空,则继续向后查找APP1,直到两个数据均不为空(或者到达文件尾)

以上处理逻辑实现位于ImageMetaReader.cpp的函数read_pos_from_jpeg_file()中。