ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

EXIF信息提取实战:照片元数据、GPS坐标与时间线归档

EXIF信息提取实战:照片元数据、GPS坐标与时间线归档

简介:这是一份围绕照片EXIF元数据读取与展示的实用工具及源代码包,面向摄影爱好者、图像处理初学者和C#开发者,可用于查看拍摄时间、光圈、快门、ISO、GPS、镜头与设备型号等信息,辅助验证照片、复盘拍摄参数或调整后期处理。压缩包共46个文件,以C#源码、解决方案、项目配置、依赖库和可执行程序为主,整体约1.6MB,既保留可直接运行的成品,也呈现WinForms项目的组织方式与依赖关系。已有801人学习下载,说明其在EXIF查看与图像元数据处理场景中具有一定参考意义。适合用来了解解析组件如何接入EXIF读取流程,以及单张或批量读取、界面展示、结果导出的实现思路;对想研究图像元数据处理、学习桌面小工具结构或在此基础上二次开发的人,都能获得较具体的实现参考。

1. 手机相册里那堆时间错乱的照片,问题多半在 EXIF 上

整理几年积攒下来的照片库时,最让人抓狂的不是文件多,而是时间线全乱:同一批照片,有的显示 2019 年,有的显示导入电脑那天,还有一批连时间都没有。这些信息不在文件名里,也不在文件大小里,全藏在一段叫 EXIF 的二进制数据里,跟着照片一起走。提取照片的 EXIF 信息,说到底就是把这段数据从 JPEG、HEIC 的容器里拆出来,还原成拍摄时间、机型、镜头、光圈快门、GPS 坐标这些可读字段。它适合三类人:要按拍摄时间重建相册的、要做图像取证或素材溯源的、要把上万张照片做成结构化台账的。下面按“结构 → 提取 → 坐标 → 踩坑 → 归档”的顺序拆,每一步都能直接跑。

2. 拆开 EXIF 的容器结构:三个 IFD 和它们的取值规则

想稳定提取,先得知道这段数据长什么样。直接上手调库,遇到空字段、乱码、时间差八小时,往往说不清是库的问题还是数据的问题。把容器结构看一遍,后面排错能省一半时间。

2.1 一张 JPEG 里 EXIF 到底存在哪

JPEG 是由一串 marker 段拼起来的。文件开头是FFD8(SOI),紧接着一般就是FFE1(APP1),EXIF 就装在这个段里。APP1 的载荷以Exif\x00\x00六个字节开头,后面紧跟一个 TIFF 头——注意,EXIF 本质上是 TIFF 格式的一小块,不是 JPEG 自己的结构。TIFF 头的头 8 个字节定义了三件事:字节序(II表示小端,MM表示大端)、固定魔数 42(0x002A,用来确认这是合法 TIFF)、以及第一个 IFD(IFD0)相对 TIFF 头起点的偏移量。

这个偏移是“相对 TIFF 头起点”而不是相对文件开头,很多自己写解析器的人第一次都栽在这上面。APP1 段还有大小限制,单段最大 64KB,所以厂商在塞 MakerNote 和缩略图时会精打细算,字段被截断的情况真实存在。

下面这段代码不解码像素,只在文件头部找 APP1 并把 TIFF 头读出来,用来确认文件到底有没有 EXIF、字节序是什么:

import struct def read_tiff_header(path, head_bytes=64 * 1024): # 只读文件头,APP1 段最大 64KB,不必加载整张图 with open(path, "rb") as f: data = f.read(head_bytes) if data[:2] != b"\xff\xd8": return None, "不是 JPEG(PNG/HEIC 走另外的路径)" idx = data.find(b"Exif\x00\x00") if idx < 0: return None, "没有 APP1/Exif 段" base = idx + 6 # TIFF 头起点 bo = data[base:base + 2] endian = "<" if bo == b"II" else ">" if bo == b"MM" else None if endian is None: return None, "字节序标记异常,文件可能被截断" magic, ifd0_offset = struct.unpack(endian + "HI", data[base + 2:base + 8]) if magic != 42: return None, f"TIFF 魔数不对:{magic}" return {"endian": endian, "base": base, "ifd0_offset": ifd0_offset}, None

逻辑很直白:定位Exif\x00\x00,往后 6 个字节就是 TIFF 头,读出字节序和 IFD0 偏移。参数head_bytes默认 64KB,是因为 APP1 段本身有上限,再往后读纯属浪费 IO;如果照片的 EXIF 特别大(带大缩略图的机型),可以临时调到 128KB 验证一下,但正常情况 64KB 足够。

2.2 IFD0、Exif SubIFD、GPS IFD 各管什么

IFD 是“目录项数组”:开头 2 字节是本 IFD 有多少个条目,然后每个条目固定 12 字节——2 字节 tag、2 字节数据类型、4 字节数量、4 字节的值或偏移。这里有个关键规则:如果数据的实际长度不超过 4 字节,就直接塞在这 4 字节里;超过 4 字节,这 4 字节存的是相对 TIFF 头起点的偏移。ASCII 字符串、多个 RATIONAL 组成的坐标,全都是走偏移。

IFD入口方式典型字段实际用途
IFD0TIFF 头直接指向Make、Model、Software、DateTime、Orientation、XResolution设备与基础属性
Exif SubIFDIFD0 中 tag0x8769DateTimeOriginal、ExposureTime、FNumber、ISO、FocalLength、LensModel、OffsetTimeOriginal真正的拍摄参数
GPS IFDIFD0 中 tag0x8825GPSLatitude、GPSLatitudeRef、GPSAltitude、GPSTimeStamp位置与海拔
Interop IFDExif SubIFD 中 tag0xA005InteropIndex、InteropVersion兼容性标识
IFD1IFD0 末尾的 next 指针JPEGInterchangeFormat、缩略图数据内嵌缩略图

最容易踩的是时间字段。DateTime(0x0132)在 IFD0 里,语义上更接近“文件最后被写入的时间”;DateTimeOriginal(0x9003)在 Exif SubIFD 里,才是快门按下的那一刻;DateTimeDigitized(0x9004)是数字化时间,手机拍的照片通常和 Original 一致,扫描件则可能差很远。三个字段格式都是2019:08:12 17:23:41——日期部分用冒号分隔,直接丢给datetime.fromisoformat()会抛异常,必须用strptime("%Y:%m:%d %H:%M:%S")。

还有一个隐性坑:这三个时间字段本身不带时区。规范里另有OffsetTime、OffsetTimeOriginal、OffsetTimeDigitized三个字段记录+08:00这样的偏移,但很多机型根本不写。没有偏移时,只能按相机所在地推断,绝不能默认按 UTC 处理,否则整条时间线会平移几个小时。

2.3 先用命令行把字段全量打出来

写代码之前,我习惯先用现成工具把单张照片的字段全量倒出来,建立一个“这个机型会写哪些字段”的基线。这一步能避免后面把“字段本来就不存在”误判成“代码写错了”。

# 全量输出,带分组名和原始 tag 名,方便对照规范 exiftool -G1 -a -s -time:all -exif:all -gps:all ./IMG_0001.jpg

参数逐个说:-G1在每行前面加一级分组名([IFD0]、[ExifIFD]、[GPS]),一眼看出字段属于哪个 IFD;-a允许同名 tag 重复出现,某些机型会写两个DateTimeOriginal;-s用短名而不是描述性长名,方便和代码里的键名对照;-time:all、-exif:all、-gps:all是分组过滤器,只看这几类。

确认结构没问题后,改用 JSON 输出,作为后续比对的基准:

exiftool -j -G1 -n -charset filename=utf8 ./IMG_0001.jpg > one.json

-j输出 JSON 数组,-n关闭“人性化显示”——这一项很关键,不加的话 GPS 会输出成39 deg 54' 26.99" N这种带单位的字符串,加了才是纯数字的十进制度,海拔也是纯米数。-charset filename=utf8处理中文路径,不写这个参数,带中文目录的照片会直接报文件找不到。

拿到这份 JSON,就能确定这个机型到底提供了哪些字段,再决定代码里哪些字段要做缺省兜底。

3. Python 侧的三条提取路线:Pillow、exifread、exiftool 的取舍

能提取 EXIF 的库不少,真正在生产里用得住的主要是三条:Pillow 自带的getexif、纯 Python 的 exifread、以及外部可执行文件 exiftool。三者不是替代关系,混着用最省事。

3.1 三条路线的能力边界

维度Pillowexifreadexiftool
安装方式pip install pillowpip install exifread独立可执行文件
主 IFD 读取getexif()Image *前缀全量
SubIFD 读取get_ifd(0x8769)EXIF *前缀全量
MakerNote 解析基本不解析details=True时部分解析解析最完整
HEIC/HEIF需额外注册 opener不支持支持
批量速度中快(只读文件头)慢(进程启动开销)
写回能力有限不支持支持

选型结论:批量提取字段用 exifread 或 Pillow,遇到解析不了的怪文件单张丢给 exiftool 兜底,需要回写时只有 exiftool 靠谱。不要试图用一个库解决所有问题,尤其是 MakerNote 这种厂商私有区块,各家偏移基准不统一(有的相对文件头,有的相对 TIFF 头),自己解析纯属给自己找麻烦。

3.2 Pillow 路线:getexif 与 get_ifd 的正确打开方式

Pillow 的坑集中在“子 IFD 不在主字典里”。getexif()返回的是 IFD0 的内容,DateTimeOriginal这类字段需要用get_ifd()单独取。早期版本里getexif()会把子 IFD 摊平进主字典,行为不一致,所以别依赖“遍历主字典就能拿到全部字段”这种写法。

from PIL import Image, ExifTags import pillow_heif # 手机原相机导出的 HEIC 需要先注册 opener,否则 Image.open 直接报错 pillow_heif.register_heif_opener() IFD_EXIF = 0x8769 IFD_GPS = 0x8825 def extract_with_pillow(path): with Image.open(path) as im: # 懒加载,不调用 im.load() 就不会解码像素 exif = im.getexif() main = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()} sub_raw = exif.get_ifd(IFD_EXIF) # 拿不到时返回空 dict,不会抛异常 gps_raw = exif.get_ifd(IFD_GPS) sub = {ExifTags.TAGS.get(k, k): v for k, v in sub_raw.items()} gps = {ExifTags.GPSTAGS.get(k, k): v for k, v in gps_raw.items()} return {"ifd0": main, "exif": sub, "gps": gps}

要点说明:Image.open()只读文件头,不触发像素解码,所以批量跑几千张不会把内存吃爆;get_ifd()传入的是 tag 号常量,别传字符串;ExifTags.GPSTAGS和ExifTags.TAGS是两套独立的映射表,GPS 字段名必须用前者查,用错了会拿到一堆数字 tag 号。

返回值里的数值是 Pillow 自己的类型:有理数会被转成IFDRational,可以直接float();但坐标是三元素元组,得逐个转换。这一点在下一章处理 GPS 时会用到。

3.3 exifread 与 exiftool:批量场景下的取舍

exifread 的优势是快和稳。它用键名前缀区分 IFD,Image开头是 IFD0,EXIF开头是 SubIFD,GPS开头是 GPS IFD。process_file的details=False会跳过 MakerNote 解析,这是批量场景下的默认选择——MakerNote 数据结构不统一,解析它除了慢,还容易在某些机型上直接抛异常中断整个批处理。

import exifread from datetime import datetime def parse_with_exifread(path): with open(path, "rb") as f: # details=False 跳过 MakerNote,速度快且不易崩 tags = exifread.process_file(f, details=False) def get(key): v = tags.get(key) return str(v).strip() if v is not None else None dt_raw = get("EXIF DateTimeOriginal") or get("Image DateTime") dt = None if dt_raw: try: dt = datetime.strptime(dt_raw, "%Y:%m:%d %H:%M:%S") except ValueError: dt = None # 有些设备写的是 "0000:00:00 00:00:00" return { "datetime_original": dt, "datetime_source": "EXIF" if get("EXIF DateTimeOriginal") else ("IFD0" if dt_raw else None), "offset": get("EXIF OffsetTimeOriginal"), "make": get("Image Make"), "model": get("Image Model"), "orientation": get("Image Orientation"), "exposure": get("EXIF ExposureTime"), "fnumber": get("EXIF FNumber"), "iso": get("EXIF ISOSpeedRatings"), }

这里datetime_source字段是我后来加的:当DateTimeOriginal缺失、退回用DateTime时,必须把这个来源记下来,否则后续按时间归档时,一批“文件写入时间”会混进“拍摄时间”里,时间线看着正常,实际上全是错的。strptime的异常捕获也别省,部分设备会写全零时间。

exiftool 的批量调用适合“一次性出报表”:

exiftool -csv -r -n -d "%Y-%m-%dT%H:%M:%S" \ -FileName -Directory -Make -Model \ -DateTimeOriginal -OffsetTimeOriginal \ -ExposureTime -FNumber -ISO -FocalLength \ -Orientation -GPSLatitude -GPSLongitude -GPSAltitude \ ./photos > exif_dump.csv

-csv输出带表头的逗号分隔文件,-r递归子目录,-n输出纯数字,-d自定义时间格式——不加-d的话,时间会原样输出成带冒号的 EXIF 格式,后续用 pandas 或表格软件解析时还得再洗一遍。文件列表里每一项都是一列,顺序和命令行里写的顺序一致。这条路线的代价是每处理一张都要 fork 一次进程,几万张照片会明显变慢,建议按目录分片并行。

3.4 把结果规整成统一的 CSV

三个来源的字段名各不相同,落盘前必须统一成一套 schema,不然后面写查询逻辑要疯。

import csv FIELDS = [ "path", "datetime_original", "datetime_source", "offset", "make", "model", "orientation", "gps_lat", "gps_lon", "gps_alt", ] def write_csv(rows, out_path): # utf-8-sig 带 BOM,直接用表格软件打开不会中文乱码 with open(out_path, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore") w.writeheader() w.writerows(rows)

extrasaction="ignore"让多出来的字段被静默丢弃,避免某个机型多写一个字段就整批报错;utf-8-sig的 BOM 是给表格软件看的,程序读取时用utf-8-sig也能正常解析。字段顺序固定下来后,后续做 diff、做增量更新都方便。

4. GPS 与方向字段:从度分秒到能落到地图上的数字

位置和时间是 EXIF 里价值最高的两类数据,也是错得最离谱的两类。时间错了顶多排序乱,坐标错了直接把人定位到海里。

4.1 GPS IFD 的原始存储格式

GPS 坐标不是直接存十进制度的。GPSLatitude是一个包含三个 RATIONAL 的数组,分别表示度、分、秒,每个 RATIONAL 是“分子/分母”两个 4 字节整数。方向由GPSLatitudeRef单独存,值是 ASCII 字符N或S;经度同理,GPSLongitudeRef是E或W。

海拔稍微绕一点:GPSAltitude是 RATIONAL 米数,但正负由GPSAltitudeRef决定,0表示海平面以上,1表示海平面以下。只读GPSAltitude不读 Ref,地下车库拍的照片海拔会变成正数。

时间字段也值得一提:GPSTimeStamp存的是 UTC 的时分秒(三个 RATIONAL),GPSDateStamp是YYYY:MM:DD格式的日期。这两个字段合起来才是完整的 UTC 时刻,和DateTimeOriginal(本地时间)对照,可以反推出拍摄地的时区偏移,这是补OffsetTimeOriginal缺失的一个实用手段。

4.2 度分秒转十进制度与符号判定

from fractions import Fraction def _to_float(v): # Pillow 给 IFDRational,exifread 给 Ratio,字符串形式统一兜底 try: return float(v) except (TypeError, ValueError): return float(Fraction(str(v))) def dms_to_decimal(dms, ref): if not dms or len(dms) != 3: return None d, m, s = (_to_float(x) for x in dms) dec = d + m / 60.0 + s / 3600.0 # 南纬和西经为负,Ref 缺失时按正数处理并打标记 if str(ref).strip().upper() in ("S", "W"): dec = -dec return round(dec, 7) def gps_to_wgs84(gps): lat = dms_to_decimal(gps.get("GPSLatitude"), gps.get("GPSLatitudeRef")) lon = dms_to_decimal(gps.get("GPSLongitude"), gps.get("GPSLongitudeRef")) if lat is None or lon is None: return None alt = gps.get("GPSAltitude") alt_ref = gps.get("GPSAltitudeRef", 0) alt_m = None if alt is not None: alt_m = _to_float(alt) if int(alt_ref) == 1: alt_m = -alt_m return {"lat": lat, "lon": lon, "alt": alt_m}

保留 7 位小数是有讲究的:纬度 1 度约 111 公里,1e-7 度大约 1.1 厘米,比大多数手机 GPS 的实际精度(通常 3~10 米)细得多,再往后留位没有意义,只会让 CSV 变长。_to_float里的兜底分支是给 exifread 准备的,它返回的 Ratio 对象在str()之后是"39/1"这种形式,直接float()会崩。

验证方法别偷懒:挑几张在明确地点拍的照片,把转换结果贴进任意地图服务的坐标搜索框,看落点是否对得上。我一般会选一个有明显地标的点,误差超过 50 米就要回头查是不是 Ref 丢了,或者只取了度、丢了分秒。

提示:不同地图服务使用的坐标基准不同,EXIF 里存的是 WGS-84,直接叠加到某些地图上会有数百米量级的系统性偏移,需要先做基准转换再落图。这一步不做,你会以为是提取代码错了。

4.3 Orientation 与其余字段别浪费

Orientation只有 1~8 八个合法值,但它决定了照片该不该转。竖拍的照片在传感器上其实是横着存的,靠这个字段告诉查看器“显示时旋转 90 度”。批量处理时如果不读它,缩略图会有一半是躺着的。

值含义显示时需要顺时针旋转
1正常0°
2水平镜像0° + 水平翻转
3旋转 180°180°
4垂直镜像180° + 垂直翻转
5主对角线镜像90° + 翻转
6顺时针 90°90°
7副对角线镜像270° + 翻转
8逆时针 90°270°

实战里不要自己写这八个分支的旋转逻辑,Pillow 的ImageOps.exif_transpose()一次搞定,它读完 Orientation 后既旋转像素又把该字段重置为 1,避免二次旋转。5 和 7 这两个镜像值是最容易写错的,自己实现十有八九会把翻转方向搞反。

另外几个字段值得顺手捞出来:GPSHPositioningError是水平定位误差(米),可以用来过滤掉精度差到没意义的坐标;GPSImgDirection是拍摄朝向,做街景类素材分析时有用;LensModel在 SubIFD 里,对做镜头统计的人比机身型号更有价值。

5. 避坑与常见问题排查:五个让我返工的坑

这些坑的共同点是:代码不报错,数据看着也有,但结论是错的。下面按“现象 → 原因 → 解决”记。

5.1 字段层面的三个坑

坑一:一半照片的DateTimeOriginal是空的。现象是同一批导入的照片,原图有完整时间,另一部分只有文件名。原因是照片经过了社交软件转发或截图——这些流程会重新编码 JPEG,把 APP1 段整个丢掉,EXIF 自然就没了。解决办法是分两条路:先按文件大小、分辨率、文件名前缀判断哪些是原图,原图走 EXIF,非原图退回到文件修改时间并在datetime_source里标成mtime,归档时单独放一个目录,绝不混进按拍摄时间排序的主线。

坑二:时间整体差 8 小时。现象是提取出来的时间比实际拍摄时间早或晚整整一个时区。原因是有代码默认把DateTimeOriginal当 UTC 处理,或者反过来,把GPSTimeStamp(确实是 UTC)当本地时间直接用。DateTimeOriginal是本地时间,不带时区;GPSTimeStamp是 UTC。两者语义完全不同。解决方式是:优先读OffsetTimeOriginal,有就按它换算成绝对时刻;没有就用同一条记录的GPSDateStamp+GPSTimeStamp和DateTimeOriginal做差,反推偏移;两者都没有,就保留本地时间并标记offset=unknown,不要擅自加减。

坑三:个别机型的照片解析直接抛异常。现象是批量跑到某几张就崩,单独打开看又没问题。原因基本都指向 MakerNote(tag0x927C):厂商私有区块的偏移基准不统一,有的相对文件头,有的相对 TIFF 头,还有的在不同固件版本里改了长度定义。解决办法是批量时关掉 MakerNote 解析(exifread 用details=False,Pillow 本身就不深入解析),把这些文件单独挑出来交给 exiftool 处理。exiftool 内部维护了大量机型的偏移修正规则,这是它最不可替代的地方。

5.2 环境与性能层面的两个坑

坑四:跑几千张就卡死或内存暴涨。现象是脚本跑到一两千张时越来越慢,最后被系统杀掉。原因多半是用了im.load()或者对每张图做了convert("RGB")——这会解码整幅像素,一张 4000×3000 的照片解码后就是几十 MB。解决方式很明确:只读文件头,Image.open()之后立刻getexif(),然后关掉,绝不碰像素;需要并行的用ProcessPoolExecutor而不是线程池,因为解析是 CPU 密集型,GIL 会拖死线程方案;并发数别开满,磁盘 IO 会先成为瓶颈,一般取 CPU 核数的 1~2 倍就够。

坑五:HEIC 文件全部报“无法识别的图像格式”。现象是手机直出的照片一张都读不了,换成导出过的 JPEG 就正常。原因是 HEIC 是 ISOBMFF 容器,EXIF 放在metabox 的iinf/iloc引用里,结构和 JPEG 的 APP1 段完全不同,Pillow 原生不支持。解决办法是装pillow-heif并执行register_heif_opener()注册解码器。注意注册要在所有Image.open之前完成,写在模块顶层而不是函数内部,否则多进程环境下子进程可能没注册上。

6. 从提取到归档:给这批数据加一道校验和重命名闭环

提取本身只是第一步。真正决定这份数据能不能用的,是提取之后有没有做校验。我现在的习惯是:任何一批 EXIF 出来,先过一遍规则校验,把可疑记录单独列出来人工看,确认无误再进归档流程。

校验规则不需要复杂,覆盖四类就够:时间是否落在合理区间(比如 1990 年到当前时间之间,排除全零和未来时间)、经纬度是否成对出现且落在合法值域、Orientation 是否在 1~8、GPS 坐标是否超出范围。任何一条不过,就把文件名和问题类型写进issues.csv,主流程继续跑,不中断。

from datetime import datetime def validate(row, now=None): now = now or datetime.now() issues = [] dt = row.get("datetime_original") if dt is None: issues.append("missing_datetime") elif dt.year < 1990 or dt > now: issues.append("suspicious_datetime") lat, lon = row.get("gps_lat"), row.get("gps_lon") if (lat is None) != (lon is None): issues.append("partial_gps") # 只有一半,比没有更危险 if lat is not None and not (-90 <= lat <= 90 and -180 <= lon <= 180): issues.append("gps_out_of_range") if row.get("orientation") not in (None, *range(1, 9)): issues.append("bad_orientation") return issues

校验过了再归档,重命名规则用YYYYMMDD_HHMMSS_原名,目录按年和月分层。关键有两点:默认dry_run=True,先打印不落盘,人工扫一眼路径分布再执行;复制用shutil.copy2而不是copy,因为它会把原始 mtime 和权限一起带过去,万一后面还要靠 mtime 兜底,数据还在。目标文件重名时加_dup后缀而不是覆盖——照片库里的同名文件,十有八九是两次导入的同一张,覆盖了就少一份证据。

import shutil from pathlib import Path def archive(row, src, root, dry_run=True): dt = row["datetime_original"] if dt is None: return None dst_dir = Path(root) / f"{dt:%Y}" / f"{dt:%Y-%m}" dst = dst_dir / f"{dt:%Y%m%d_%H%M%S}_{Path(src).name}" if dry_run: print(f"[dry-run] {src} -> {dst}") return dst dst_dir.mkdir(parents=True, exist_ok=True) if dst.exists(): dst = dst.with_name(f"{dst.stem}_dup{dst.suffix}") shutil.copy2(src, dst) # 保留 mtime 与权限 return dst

有一个细节值得单独提:dry_run输出里如果出现大量指向同一个年月的记录,或者大量落到“当年当月”,基本可以判断这批数据的datetime_source是mtime而不是真正的拍摄时间——这种情况直接归档等于把错误固化,必须先回头查提取环节。

从那以后,我每次处理一批照片,都会先跑一遍dry-run看路径分布,再抽 10 张对着手机相册核时间,最后才真正落盘。这个流程多花五分钟,能省掉一整天回滚的功夫。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进