按下快门的瞬间,相机就把拍摄参数、时间甚至经纬度写进了文件——跟着字节流走,让照片自己开口说话
核心方法论:追踪数据流
「看照片的人相信屏幕上显示的拍摄信息,我只看字节。一张 JPEG 从 FFD8 开始,APP1 段里躺着 EXIF 的 TIFF 结构,XMP 的 XML 包紧跟其后——信息在文件里怎么排布,真相就怎么流动。这一章,我们跟着数据流,把元数据从头走到尾:它存在哪、存了什么、怎么读出来、又该怎么防它泄露隐私。」
前两章我们一直在看像素:位图是像素矩阵,JPEG 是压缩后的像素,RAW 是相机传感器的原始像素。但一张数码照片文件里,装的不只是像素——还有一大块关于这张照片的信息:它是什么牌子的相机拍的、快门多少、光圈多少、ISO 多少、几点几分拍的、甚至在哪拍的。相机在按下快门的瞬间,就把这些信息自动写进了文件。这种「关于数据的数据」,就叫元数据(metadata)。说人话:像素是照片的「肉体」,元数据是照片的「身份证」。
元数据不是可有可无的装饰。几万张照片的图库,靠拍摄时间归档;电商平台要按拍摄参数筛选商品图;图片管理系统要用 Orientation 标签自动把竖拍的照片转正;新闻图片要靠标题、作者、关键词流通。对服务端图像处理来说,元数据更是躲不开:转码时要决定保不保留元数据、缩略图可以直接用文件里内嵌的缩略图、隐私合规要先把 GPS 擦掉。所以这一章在分部一(图像基础与格式)里压轴,专门把「像素之外的那部分字节」讲透。
图像元数据主要有三家:EXIF(相机拍摄参数,标签表固定,摄影行业事实标准)、IPTC(新闻行业的老标准,存标题、说明、作者、关键词,全称 Information Interchange Model 信息交换模型)、XMP(Adobe 提出的可扩展元数据平台,用 XML 组织,谁都能往里加字段)。一个 JPEG 文件可能同时住着这三家:EXIF 在 APP1 段,IPTC 藏在 APP13 段的 Photoshop 资源块里,XMP 也在 APP1 段——只不过签名不同。先别急,下面我们用「追踪数据流」的方法,从文件头开始一字节一字节看。
# xxd 看 JPEG 文件头 32 字节:元数据就藏在图像数据之前
$ xxd -l 32 photo.jpg
00000000: ffd8 ffe1 8d1d 4578 6966 0000 4949 2a00 .......Exif..II*.
00000010: 0800 0000 0000 0000 5a01 0100 5a01 0100 ........Z...Z...
# 逐字节解读:
# ffd8 SOI 标记:JPEG 图像从这里开始
# ffe1 8d1d APP1 段:0x8d1d = 36125 字节长的元数据段
# 4578 6966 "Exif\0\0":6 字节的 EXIF 签名
# 4949 "II":Intel 小端字节序(大端则是 "MM")
# 2a00 魔数 42:说明后面是 TIFF 结构
# 08000000 第一个 IFD(图像文件目录)的偏移 = 8
不用写任何程序,file 命令就能把这段数据流读出来——它靠的就是解析 APP1 段里的 EXIF 结构:
$ file photo.jpg
photo.jpg: JPEG image data, Exif standard: [TIFF image data,
little-endian, direntries=5, manufacturer=Canon, model=Canon EOS 5D Mark III],
baseline, precision 8, 5760x3840, components 3
| 格式 | 谁在用 | 组织方式 | 能扩展吗 |
|---|---|---|---|
| EXIF | 相机 / 摄影行业 | 二进制标签表(TIFF 结构) | 不能:标签编号表固定,厂商只能塞私有 MakerNote |
| IPTC | 新闻 / 图库行业 | 二进制数据集(IIM 老标准) | 基本不能:数据集编号固定 |
| XMP | Adobe 生态 / 通用 | XML(RDF 序列化) | 能:任意命名空间,任意自定义字段 |
追踪数据流有三板斧,本章会反复用:① xxd 直接看字节(验证「元数据到底在不在、在哪」);② 命令行工具读标签(exiv2 / exiftool,快速看人话版);③ 用库解析容器(Exiv2,程序化处理)。先会看字节,再看工具输出,最后写代码——顺序不能反,否则你永远不知道工具给你的信息是从哪来的。
EXIF(Exchangeable Image File Format,可交换图像文件格式)是相机行业的元数据标准:1995 年由日本电子工业协会(JEIDA)提出,如今由相机与影像产品协会(CIPA)维护,现行版本是 Exif 2.32(标准号 CIPA DC-008)。说人话:它是数码相机、手机厂商共同遵守的一张「拍摄信息表」——拍照那一刻,快门、光圈、ISO、时间、位置,按约定好的位置写进文件,任何软件都能读出来。
注意一个关键事实:EXIF 不是一种独立格式,它是一套「住进 JPEG 的 TIFF 结构」。第 2 章我们讲过 TIFF 的标签(tag)结构,第 3 章又见到 TIFF-EP 藏在 RAW 文件里——现在 EXIF 在 JPEG 里用的还是同一套:APP1 段里 Exif\0\0 签名之后,紧跟一个 TIFF 头(字节序 + 魔数 42 + IFD 偏移),然后是长长的 IFD 链(Image File Directory,图像文件目录)。这就是为什么柯南说「同一套结构,三张面孔」:TIFF、TIFF-EP、EXIF,底层都是 IFD + 标签。追踪数据流,就是从 IFD0 出发,顺着指针一条条把目录走完。
IFD 本身是「2 字节条目数 + 条目数组 + 4 字节下一 IFD 偏移」的表格。每个条目固定 12 字节,拆成四段:Tag 编号(2 字节,这条信息是什么)、数据类型(2 字节,值怎么解释)、值的数量(4 字节)、值或偏移(4 字节,值短就直接内联,长了存到别处给偏移)。一条「相机型号 = Canon EOS 5D Mark III」就是这么存下来的。JPEG 里的 EXIF 一般有四张表:IFD0(设备信息:Make/Model/Orientation…,并带着指向子目录的指针)、Exif SubIFD(拍摄参数:曝光、光圈、ISO、时间…)、GPS IFD(经纬度)、IFD1(内嵌缩略图)。
# EXIF 在 JPEG 里的存放结构(数据流:SOI -> APP1 -> TIFF 头 -> IFD 链)
JPEG 文件
├── FFD8 SOI(图像开始)
├── FFE1 APP1 段:EXIF 数据(段长 0x8D1D)
│ ├── "Exif\0\0" 6 字节签名
│ ├── "II" 2A00 TIFF 头:小端字节序 + 魔数 42
│ ├── 00000008 第一个 IFD 偏移 = 8
│ ├── IFD0 设备信息(Make/Model/Orientation…)
│ │ ├── 0x8769 指针 -> Exif SubIFD(曝光/光圈/ISO/时间…)
│ │ ├── 0x8825 指针 -> GPS IFD(经纬度/海拔…)
│ │ └── 指针 -> IFD1(内嵌缩略图)
├── FFE0 APP0 段:JFIF(可选)
├── FFDA SOS(扫描开始,之后才是压缩的像素数据)
└── FFD9 EOI(图像结束)
# IFD 条目解剖(每条固定 12 字节):
┌────────────┬────────────┬──────────────┬──────────────────┐
│ Tag 编号 │ 数据类型 │ 值的数量 │ 值 / 指向值的偏移 │
│ 2 字节 │ 2 字节 │ 4 字节 │ 4 字节 │
└────────────┴────────────┴──────────────┴──────────────────┘
下面这张表是摄影师最常用的 EXIF 标签——键名用 Exiv2 的写法(组.标签名),编号是 IFD 条目里的真实 Tag 值。你会发现 EXIF 存的信息分三类:设备(谁拍的)、拍摄参数(怎么拍的)、时间与位置(何时何地拍的):
| Exiv2 键名 | Tag 编号 | 含义 | 典型值 |
|---|---|---|---|
Exif.Image.Make | 0x010F | 相机制造商 | Canon |
Exif.Image.Model | 0x0110 | 相机型号 | Canon EOS 5D Mark III |
Exif.Image.Orientation | 0x0112 | 方向(转正靠它) | 1 = 正常 |
Exif.Photo.ExposureTime | 0x829A | 曝光时间(快门) | 1/250 s |
Exif.Photo.FNumber | 0x829D | 光圈 | F5.6 |
Exif.Photo.ISOSpeedRatings | 0x8827 | ISO 感光度 | 100 |
Exif.Photo.DateTimeOriginal | 0x9003 | 拍摄时间 | 2026:05:01 14:30:00 |
Exif.Photo.FocalLength | 0x920A | 焦距 | 50.0 mm |
Exif.Photo.PixelXDimension | 0xA002 | 像素宽 | 5760 |
Exif.GPSInfo.GPSLatitude | 0x0002 | 纬度(度分秒) | 30deg 32' 45.55" N |
Exif.GPSInfo.GPSLongitude | 0x0004 | 经度(度分秒) | 114deg 17' 59.93" E |
值怎么解释,由「数据类型」决定。EXIF 的数据类型编号是 TIFF 规范定的,最常用的五种:BYTE(8 位整数)、ASCII(字符串)、SHORT(16 位整数)、LONG(32 位整数)、RATIONAL(两个 32 位整数拼成的分数)。曝光时间 1/250 就是 RATIONAL:分子 1、分母 250 分开存。这也是一个典型的「数据流」细节——你在看图软件里看到的 "1/250",文件里其实是两个整数。
# 用 exiftool 看一张真实照片的 EXIF(它把二进制解析成了人话)
$ exiftool photo.jpg
Exif Byte Order : Little-endian (II, II)
Make : Canon
Camera Model Name : Canon EOS 5D Mark III
Orientation : Horizontal (normal)
Exposure Time : 1/250
F Number : 5.6
ISO : 100
Focal Length : 50.0 mm
Date/Time Original : 2026:05:01 14:30:00
GPS Latitude : 30 deg 32' 45.55" N
GPS Longitude : 114 deg 17' 59.93" E
注意 DateTimeOriginal 的格式:2026:05:01 14:30:00——时间之间用冒号而不是横线,这是 EXIF 的老传统(ASCII 字符串直接存,省得再定义日期格式)。很多程序解析时都要先处理这个「冒号日期」。看到怪格式先别急着骂,多半是标准历史遗留。
EXIF 有个天生的短板:标签表是固定的。Tag 编号只有那么多,想存个「这张照片属于哪个拍摄项目」「授权给谁用了」这类自定义字段,EXIF 里没有位置——厂商只能各自往私有 MakerNote 里塞,互不通用。Adobe 在 2001 年推出 XMP(eXtensible Metadata Platform,可扩展元数据平台)来解决这个问题,2012 年它成为国际标准 ISO 16684-1。说人话:XMP 是一套「给元数据定格式」的开放框架,用 XML 写,任何行业、任何公司都能注册自己的字段,互不冲突。
XMP 和 EXIF 有四个根本差异。第一,没有标签编号,用路径式键名:EXIF 靠 0x9003 这种编号查表,XMP 直接写 Xmp.dc.title、Xmp.xmp.Rating 这种「命名空间.属性名」。第二,命名空间机制:每个字段都声明自己来自哪本「字典」(一个 URI),不同字典里同名不同义的字段井水不犯河水。第三,跨格式统一:JPEG、TIFF、PNG、PDF、视频都能嵌 XMP,甚至单独存成 .xmp 侧车文件(sidecar),不动原图就能改元数据。第四,可扩展:想加字段就注册个命名空间,不需要任何人批准。
XMP 的数据流长这样:在 JPEG 里它同样住在 APP1 段,但签名不同——EXIF 是 Exif\0\0,XMP 是字符串 http://ns.adobe.com/xap/1.0/\0(XMP 最初代号 XAP),签名之后就是一个完整的 XML 包。XMP 包的结构基于 RDF(资源描述框架):一个 rdf:RDF 根元素,里面每个 rdf:Description 描述一组属性,属性值支持简单值、数组(rdf:Seq 有序列表)、语言变体(rdf:Alt 多语言标题)等类型。下面是 Exiv2 官方示例同款结构的 XMP 片段:
# XMP 包示例:rdf:RDF 根 + rdf:Description 属性组(缩略,真实结构)
<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="x-default">黄鹤楼日落</rdf:li>
</rdf:Alt>
</dc:title>
<dc:creator>
<rdf:Seq>
<rdf:li>Helios</rdf:li>
</rdf:Seq>
</dc:creator>
<xmp:Rating>5</xmp:Rating>
<xmp:CreateDate>2026-05-01T14:30:00+08:00</xmp:CreateDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
用 xxd 验证 XMP 的签名——注意 APP1 段里不再是 Exif 而是完整的命名空间字符串,紧接着就是 <?xpacket 开头的 XML 包(EF BB BF 是 UTF-8 的 BOM):
$ xxd -l 64 photo_xmp.jpg
00000000: ffd8 ffe1 91a2 6874 7470 3a2f 2f6e 732e ....http://ns.
00000010: 6164 6f62 652e 636f 6d2f 7861 702f 312e adobe.com/xap/1.
00000020: 3000 3c3f 7870 6163 6b65 7420 6265 6769 0.<?xpacket begi
00000030: 6e3d 22ef bbbf 2220 6964 3d22 5735 4d30 n="..." id="W5M0
| 命名空间前缀 | URI | 典型属性 |
|---|---|---|
| dc | http://purl.org/dc/elements/1.1/ | title 标题 / creator 作者 / description 描述 / subject 关键词 |
| xmp | http://ns.adobe.com/xap/1.0/ | CreateDate 创建时间 / Rating 评级 / CreatorTool 工具 |
| xmpMM | http://ns.adobe.com/xap/1.0/mm/ | DocumentID 文档标识 / History 修改历史(资源管理) |
| photoshop | http://ns.adobe.com/photoshop/1.0/ | DateCreated / City / Country(PS 里的文件信息) |
| tiff / exif | http://ns.adobe.com/tiff/1.0/ 等 | EXIF/TIFF 标签的 XMP 版(同信息、两套容器) |
把命名空间想成「字典」:XMP 里每个属性必须声明自己来自哪本字典(URI)。同一本字典里,dc:title 永远指标题;换一本字典,同名属性就是另一个意思。所以解析 XMP 时先看 xmlns 声明,再看属性名——这也是追踪数据流:属性值之前,先追到它的命名空间。
Exiv2 是图像元数据领域最常用的开源 C++ 库:能读取和写入 EXIF、IPTC、XMP 三类元数据,附带一个同名的命令行工具,官网 exiv2.org,采用 GPL-2.0-or-later 开源许可。素材笔记(2020 年的《图片处理库总结》)里记录过它的示例骨架——打开图片、读元数据、遍历容器。但注意一个版本差异:笔记里的 Exiv2::Image::AutoPtr 是 0.27 及以前的老 API(本质是 C++98 时代的 std::auto_ptr),现行稳定版 0.28.x 已把它移除,统一返回 std::unique_ptr——写法更简单了,流程骨架没变:open() → readMetadata() → 遍历容器。这正是「素材提供真实流程、版本按现行官方文档写」的典型例子。
Exiv2 的核心是 Exiv2::Image 类:它代表「一个图片文件及其元数据」,具体格式(JPEG/TIFF/PNG/WebP/RAW…)由 Exiv2::ImageFactory::open() 工厂按文件内容(不是扩展名)自动识别,返回对应格式的派生类。打开后调用 readMetadata() 把文件里的元数据读进内存容器,exifData() 拿到 Exiv2::ExifData(每条是一个 Exiv2::Metadatum,提供 key()、tag()、typeName()、toString() 等方法)。下面是按官方示例 exifprint.cpp 精简的完整程序:
// exifprint.cpp —— 打印一张图片的全部 EXIF 元数据
// 以 exiv2.org 官方示例 exifprint.cpp 为蓝本精简(Exiv2 0.28.x API)
#include <exiv2/exiv2.hpp>
#include <iomanip>
#include <iostream>
int main(int argc, char* const argv[])
{
if (argc != 2) {
std::cerr << "用法: exifprint <图片路径>" << std::endl;
return 1;
}
try {
// 打开图片:工厂按文件内容(而非扩展名)识别格式
auto image = Exiv2::ImageFactory::open(argv[1]);
if (!image) {
std::cerr << "无法打开文件" << std::endl;
return 1;
}
// 读取元数据到内存容器
image->readMetadata();
Exiv2::ExifData& exifData = image->exifData();
if (exifData.empty()) {
std::cout << "这张图片没有 EXIF 数据" << std::endl;
return 0;
}
// 遍历:每条元数据是一个 Exiv2::Metadatum(键 + 值)
for (const auto& m : exifData) {
std::cout << std::left << std::setw(40) << m.key()
<< " 0x" << std::hex << std::setw(4) << std::setfill('0')
<< m.tag() << std::dec << std::setfill(' ')
<< " " << std::setw(9) << m.typeName()
<< " " << m.toString() << std::endl;
}
} catch (Exiv2::Error& e) {
std::cout << "Exiv2 异常: " << e.what() << std::endl;
return 1;
}
return 0;
}
编译链接用 pkg-config 最省事(libexiv2-dev 开发包装好后自带 exiv2.pc),运行输出长这样——键名、十六进制 Tag 编号、类型、值,和 4.2 节的表一一对应:
# 安装(三选一):macOS / Debian/Ubuntu / vcpkg
$ brew install exiv2
$ sudo apt install libexiv2-dev exiv2
# 编译并运行
$ g++ exifprint.cpp -o exifprint $(pkg-config --cflags --libs exiv2)
$ ./exifprint photo.jpg
Exif.Image.Make 0x010f Ascii Canon
Exif.Image.Model 0x0110 Ascii Canon EOS 5D Mark III
Exif.Photo.ExposureTime 0x829a Rational 1/250 s
Exif.Photo.FNumber 0x829d Rational F5.6
Exif.Photo.ISOSpeedRatings 0x8827 Short 100
Exif.Photo.DateTimeOriginal 0x9003 Ascii 2026:05:01 14:30:00
Exif.Photo.FocalLength 0x920a Rational 50.0 mm
Exif.Photo.PixelXDimension 0xa002 Long 5760
Exif.GPSInfo.GPSLatitude 0x0002 Rational 30deg 32' 45.55"
Exif.GPSInfo.GPSLongitude 0x0004 Rational 114deg 17' 59.93" E
读 XMP 和读 EXIF 几乎一样,只是换成 xmpData() 容器——键名变成 Xmp.命名空间.属性 的路径式写法,正好呼应 4.3 节:XMP 没有标签编号,键本身就是定位方式。写入元数据也简单:给容器的键直接赋值,再 writeMetadata() 写回文件。官方 addmoddel 示例就是这么干的:
// 读取 XMP:容器换成 XmpData,遍历方式与 ExifData 相同
Exiv2::XmpData& xmpData = image->xmpData();
for (const auto& m : xmpData) {
std::cout << m.key() << " = " << m.toString() << std::endl;
}
// 输出示例:
// Xmp.dc.title = 黄鹤楼日落
// Xmp.dc.creator = Helios
// Xmp.xmp.Rating = 5
// 写入元数据:给 ExifData 的键赋值,再写回文件
Exiv2::ExifData& exifData = image->exifData();
exifData["Exif.Image.Artist"] = "Helios";
exifData["Exif.Image.ImageDescription"] = "拍摄于黄鹤楼";
image->writeMetadata();
记住 Exiv2 的三步节奏:open(打开文件)→ readMetadata(把元数据读进内存容器)→ 遍历/修改容器。修改容器之后文件并不会自动变——必须再调 writeMetadata() 落盘。另外 ImageFactory::open 按内容识别格式,所以把 .jpg 改名成 .png,Exiv2 照样能读出 EXIF——追踪数据流的人从不信扩展名。
写 C++ 程序之前,命令行工具是看元数据最快的入口——两个主力:exiv2(Exiv2 库自带的命令行程序)和 exiftool(Phil Harvey 用 Perl 写的「元数据瑞士军刀」,官网 exiftool.org)。exiv2 的定位是「轻量、与 C++ API 行为一致」:默认打印摘要,-p t 打印翻译后的值(人话版),-p v 打印原始值,还能用 -M 直接在命令行改元数据:
# exiv2 命令行:读
$ exiv2 photo.jpg # 默认:文件信息 + 元数据摘要
$ exiv2 -pt photo.jpg # -p t:翻译后的(interpreted)值,如 1/250 s
$ exiv2 -pv photo.jpg # -p v:原始值(直接看容器里存的字节解释)
$ exiv2 -h # 全部选项
# exiv2 命令行:写(-M 直接给命令,-m 从文件读命令)
$ exiv2 -M"set Exif.Image.Artist Helios" photo.jpg
$ exiv2 -m commands.txt photo.jpg
exiftool 则是另一个量级:它能读写的元数据格式包括 EXIF、GPS、IPTC、XMP、ICC 色彩配置文件、Photoshop 资源块等,还能解码几十个相机品牌的私有 MakerNote(厂商藏在标准标签之外的独家信息),支持数百种文件格式(含音视频)。它最常用的场景是批量处理:一个命令扫整个目录、CSV/JSON 输出、从一个文件向另一个文件复制元数据(-tagsFromFile)。下面这组命令是日常高频操作:
# exiftool:读
$ exiftool photo.jpg # 全部元数据(EXIF/IPTC/XMP 汇总)
$ exiftool -g photo.jpg # 按组显示(EXIF / GPS / XMP / Composite…)
$ exiftool -DateTimeOriginal photo.jpg # 只读一个标签
$ exiftool -j photo.jpg # JSON 输出,方便脚本解析
# exiftool:写
$ exiftool -Artist="Helios" photo.jpg # 写入标签(默认生成 photo.jpg_original 备份)
$ exiftool -overwrite_original -Artist="Helios" photo.jpg # 覆盖原文件,不留备份
$ exiftool -GPSLatitude=30.5 -GPSLongitude=114.3 photo.jpg # 写入 GPS
$ exiftool -all= photo.jpg # 删除全部元数据(-all= 置空)
| 维度 | exiv2(命令行) | exiftool |
|---|---|---|
| 出身 | C++ 库 Exiv2 自带的命令行程序 | Phil Harvey 的 Perl 程序 |
| 元数据格式 | EXIF / IPTC / XMP 读写 | EXIF / IPTC / XMP / ICC / MakerNote 等数十种 |
| 文件格式 | JPEG / TIFF / PNG / WebP / 常见 RAW 等 | 数百种(含音视频) |
| 典型用法 | 轻量查看/修改;与 C++ API 行为一致 | 批量处理、JSON/CSV 输出、跨文件复制元数据 |
| 安装 | brew install exiv2 / apt install exiv2 | brew install exiftool / 官网下载单文件 |
工具输出再漂亮,也只是 APP1 段里那些字节的「翻译」。遇到可疑的元数据问题(比如某张图读不出 EXIF),先回 4.1 的 xxd 三板斧:看看 APP1 段到底在不在、签名是不是被破坏了。工具会骗你,字节不会。
最后把前面的工具串成三个真实场景。场景一:按拍摄时间归档——旅行回来几千张 IMG_*.jpg,文件名毫无信息量,用 exiftool 读出每张的 DateTimeOriginal 重命名,相册立刻按时间排好。注意 -s -s -s 让 exiftool 只输出值本身(去掉标签名和引号),这是脚本里最常用的取值姿势:
#!/bin/bash
# 按拍摄时间把 IMG_*.jpg 重命名为 2026_05_01_14_30_00.jpg
for f in *.jpg; do
# -s -s -s:只输出值本身,形如 "2026:05:01 14:30:00"
t=$(exiftool -s -s -s -DateTimeOriginal "$f")
if [ -n "$t" ]; then
name=$(echo "$t" | tr ': ' '__') # 冒号和空格换成下划线
mv "$f" "${name}.jpg"
echo "$f -> ${name}.jpg"
fi
done
场景二:批量导出 GPS 做轨迹——照片里带的经纬度可以还原一整天的行程。exiftool 的 -csv 输出一行一条记录,正好喂给地图工具或 Excel 做轨迹图。这背后就是 4.2 节那条数据流:每张照片的 GPS IFD 里,度分秒三个 RATIONAL 被读出来换算成十进制度数(度 + 分/60 + 秒/3600):
# 导出整个目录的拍摄时间 + GPS 到 CSV
$ exiftool -csv -DateTimeOriginal -GPSLatitude -GPSLongitude ~/photos/*.jpg > gps.csv
$ head -3 gps.csv
SourceFile,DateTimeOriginal,GPSLatitude,GPSLongitude
/home/helios/photos/photo1.jpg,2026:05:01 14:30:00,30 deg 32' 45.55" N,114 deg 17' 59.93" E
/home/helios/photos/photo2.jpg,2026:05:02 09:12:33,30 deg 35' 12.10" N,114 deg 20' 41.20" E
场景三:隐私擦除——这是元数据最需要警惕的一面。很多社交平台和分享工具会读取 EXIF,在帖子上自动显示「拍摄于某地」;一张原图发出去,DateTimeOriginal + GPS 就精确暴露了你家地址和作息时间。发布到公开渠道前,用 -all= 把 EXIF、IPTC、XMP 一次性清空(注意:-all= 连色彩配置、缩略图这些也删,只留像素)。擦完再用 exiftool 验证:
# 发布前擦除全部元数据(生成 photo.jpg_original 备份)
$ exiftool -all= photo.jpg
1 image files updated
# 不留备份:-overwrite_original 直接覆盖原文件
$ exiftool -all= -overwrite_original photo.jpg
1 image files updated
# 验证:再读一次,EXIF/GPS 全部消失
$ exiftool photo.jpg
ExifTool Version Number : 13.59
File Name : photo.jpg
File Size : 8.7 MB
File Type : JPEG
MIME Type : image/jpeg
# ……只有文件本身的信息,没有任何拍摄参数了
隐私擦除要「发前再擦」:很多平台在上传链路里就会偷偷读 EXIF(用于自动定位、按时间归档),一旦上传,擦除就来不及了。另外 -all= 会把 XMP、ICC 色彩配置、内嵌缩略图一起删掉——如果只是不想暴露位置,可以只删 GPS 组:exiftool -GPS:all= photo.jpg,把拍摄参数留下。
到这里,分部一(图像基础与格式)就收尾了:第 1 章像素与资源、第 2 章文件格式、第 3 章 RAW 与 DNG、本章元数据——四章合起来,你对「一个图像文件里到底有什么」已经有了完整的地图。下一章(第 5 章)进入分部二「图像处理库实战」,六库对比里,Exiv2 将以「元数据专业户」的身份第一个登场,然后 LibRaw、libvips、OpenCV 依次接棒。先记住柯南的收尾箴言:读元数据永远从字节开始,从数据流里找真相——这个习惯到第 13、14 章的链接排错和崩溃调试时,还会救你一命。
本章的四块拼图——EXIF(固定标签表)、XMP(可扩展命名空间)、Exiv2(程序化读取)、exiftool(命令行批量)——就是图像元数据世界的完整版图。做题之前先默写一遍数据流:JPEG 文件 → APP1 段 → Exif\0\0 签名 → TIFF 头 → IFD0 → Exif SubIFD / GPS IFD / IFD1。这条线画得出来,本章就过关了。
找一张手机拍的照片(或网上下载样张),用 xxd -l 64 照片.jpg 看文件头,回答三个问题:(a) 前两个字节是什么标记?(b) 在什么偏移位置能看到 Exif 签名?签名后面紧跟的 4949 2a00 是什么?(c) 如果这张照片没有 EXIF,xxd 里会看到什么?
对照 4.1 节的逐字节解读:SOI 标记是 FFD8;APP1 段从偏移 2 开始;4949 是 "II"(小端),2a00 是魔数 42。没 EXIF 的照片 APP1 段里就不是 Exif 开头(可能是 XMP 签名,或者根本没有 APP1)。
(a) ffd8,即 SOI(Start of Image,图像开始)标记。(b) 偏移 6 开始能看到 45 78 69 66 00 00(即 Exif\0\0 签名,前面 4 字节是 APP1 标记 ffe1 和段长);签名后的 4949 是字节序标识 "II"(小端),2a00 是 TIFF 魔数 42——说明 EXIF 是一套住在 JPEG 里的 TIFF 结构。(c) 看不到 Exif 签名:可能 APP1 段装的是 XMP(http://ns.adobe.com/xap/1.0/ 开头),也可能直接没有 APP1 段,ffe0(JFIF)之后直接就是图像数据。用 file 命令对照验证:输出里没有 Exif standard: 字样。
用 exiftool(或 exiv2 -pt)读取一张照片,挑出 5 个拍摄参数并解释其含义与单位:曝光时间、光圈、ISO、焦距、拍摄时间。再回答:为什么一张 1/250 s、F5.6、ISO 100 的照片是「大晴天户外」的典型设置?
先想三者的物理关系:曝光量 = 光圈进光量 × 快门时间 × 感光度。1/250 s 快门较快、F5.6 光圈不大、ISO 100 感光度很低——三个参数都「不激进」,说明环境光很足。
曝光时间(ExposureTime,秒):快门打开多久,1/250 s 属于较快的快门;光圈(FNumber):F5.6 表示进光孔径,数字越大进光越少;ISO(ISOSpeedRatings):感光度,100 是低增益、画质最好;焦距(FocalLength,mm):50.0 mm 是标准镜头视角;拍摄时间(DateTimeOriginal):2026:05:01 14:30:00 是相机记录的拍摄时刻。三者「都不激进」意味着环境光充足:快门不用放慢、光圈不用开大、ISO 不用拉高——这正是大晴天户外正午到下午的典型参数组合。反过来,夜景照片通常能看到 1/30 s 以下的慢快门、F1.8 左右的大光圈和 1600+ 的高 ISO。
你在一家图片服务公司工作,需要给每张图片存两个新字段:①「拍摄任务编号」(如 PROJ-2026-0421)②「AI 检测的图片分类」(如 landscape / portrait)。请分析:这两个字段用 EXIF 存还是用 XMP 存?各自会遇到什么问题?最终方案是什么?
EXIF 标签表是固定的,Exif.Image.* 和 Exif.Photo.* 里没有「任务编号」和「图片分类」这两个位置;XMP 可以注册自己的命名空间。想想 4.3 节「字典」的比喻。
用 EXIF 存:没有现成标签——只能借用不相关的标签(如 Exif.Image.ImageDescription 塞任务编号),语义错位、别的软件读不懂、还可能被误当成照片描述;更糟的是 EXIF 标签表没有扩展通道,硬塞会破坏标准兼容性。用 XMP 存:注册一个公司命名空间(如 http://example.com/photo-mgmt/1.0/),定义 jobId 和 aiCategory 两个属性,任何 XMP 工具都能读、不与其他命名空间冲突、还能随时加新字段。结论:标准的拍摄参数归 EXIF,业务自定义字段归 XMP——这也是行业惯例(Lightroom 的评级、图库的关键词体系都走 XMP)。代价是 XMP 是 XML,体积比二进制标签大,对海量图片存储略不友好。
基于 4.4 节的 exifprint.cpp 写一个升级版程序 exifstats.cpp:遍历图片的 ExifData,统计并打印三样东西——①EXIF 总条目数;②按组分类计数(键名 Exif.Image.*、Exif.Photo.*、Exif.GPSInfo.* 各几条);③打印拍摄时间 Exif.Photo.DateTimeOriginal 的值。提示:组的判断可以看 m.groupName() 或键名的第二段。
用 std::map<std::string, int> 按组计数;Metadatum 有 groupName() 方法返回组名(如 "Image"、"Photo"、"GPSInfo")。取单个标签可以像写入那样用下标:exifData["Exif.Photo.DateTimeOriginal"]。
# exifstats.cpp 核心逻辑(节选)
#include <exiv2/exiv2.hpp>
#include <iostream>
#include <map>
int main(int argc, char* const argv[])
{
if (argc != 2) return 1;
try {
auto image = Exiv2::ImageFactory::open(argv[1]);
image->readMetadata();
Exiv2::ExifData& exifData = image->exifData();
std::map<std::string, int> byGroup;
for (const auto& m : exifData) {
byGroup[m.groupName()]++;
}
std::cout << "EXIF 条目总数: " << exifData.size() << std::endl;
for (const auto& g : byGroup) {
std::cout << " " << g.first << ": " << g.second << " 条" << std::endl;
}
std::cout << "拍摄时间: "
<< exifData["Exif.Photo.DateTimeOriginal"].toString()
<< std::endl;
} catch (Exiv2::Error& e) {
std::cout << "Exiv2 异常: " << e.what() << std::endl;
}
return 0;
}
运行示例:./exifstats photo.jpg 会输出类似「EXIF 条目总数: 42 / Image: 8 条 / Photo: 26 条 / GPSInfo: 8 条 / 拍摄时间: 2026:05:01 14:30:00」。小坑:groupName() 返回的是组名不带 "Exif." 前缀(Image 而非 Exif.Image),计数逻辑以实际输出为准。