Python批量解析照片EXIF元数据与GPS落库实践

发布时间:2026/10/23 6:22:31
Python批量解析照片EXIF元数据与GPS落库实践
简介面向摄影爱好者与图像处理开发者的 EXIF 元数据提取工具包可读取照片拍摄时间、光圈、快门、ISO、镜头型号与 GPS 经纬度支持单张或整文件夹批量查看、导出参数也适合作为 C# 桌面端调用元数据库的入门范例。压缩包共 46 个文件、约 1.6MB以 cs 源码、dll 依赖库、xml 配置、config 设置和 exe 可执行程序为主并保留 sln 解决方案、窗体设计文件与项目工程文件目录层次完整可直接运行或二次编译。已有 801 人学习下载。包内基于 MetadataExtractor 与 XmpCore 构建含窗体界面、程序入口和配置模块读者可据此理解 EXIF 字段解析、界面展示与结果导出的实现思路并扩展批量处理、隐私信息清理等功能适合想快速上手照片元数据处理的初中级开发者参考。1. 一批两万张照片第一件事不是打开看图软件接手一批两万张的现场照片第一件该做的事不是打开看图软件而是把每张照片的文件头读一遍。照片的 EXIF 里存着拍摄时间、机身型号、光圈快门、ISO、焦距还有一组经纬度——它本质上是一份跟着文件走的元数据清单不属于图像像素那部分。很多人以为提取 EXIF 就是调个库的事真跑起来会发现有一半照片读出来是空的GPS 拿到的是三个分数对象而不是数字时间字段还带冒号。这篇把照片 EXIF 的存储结构、三套 Python 读法、批量写入数据库和几个踩过的坑一次拆开适合做照片归档、内容审核、影像取证、旅拍时间线整理的人。2. 拆开 APP1 段EXIF 的 IFD 结构和四个指针在写任何代码之前先搞清楚 EXIF 到底放在文件的哪个字节区间。这决定了后面排查「为什么读出来是空」时你能判断是文件本身没有 EXIF还是库没读到。这两件事的处理方式完全不同前者只能放弃后者改两行代码就行。2.1 JPEG 里 EXIF 挂在哪一段JPEG 是一串以 0xFF 开头的标记段拼起来的。文件开头是FFD8SOI之后每个段都是FFxx 两字节长度 数据。EXIF 放在FFE1这个 APP1 段里段的开头是固定标识Exif\0\0紧跟其后的是一个完整的 TIFF 头。也就是说EXIF 内部其实是一份 TIFF 结构这也是为什么很多库的接口长得像在读 TIFF。这里有个容易忽略的细节APP1 段不止一种。XMP 元数据也塞在 APP1 里标识是http://ns.adobe.com/xap/1.0/\0。所以不能假设「第一个 APP1 就是 EXIF」正确做法是在文件头里搜Exif\0\0这个字节序列。实际项目里我见过修图软件导出的照片XMP 段排在 EXIF 前面按段序硬编码解析的脚本直接读出了乱码。下面是验证一张照片有没有 EXIF、字节序是大端还是小端的探针脚本import struct def peek_tiff_header(path): with open(path, rb) as f: data f.read(64 * 1024) # EXIF 段通常在文件前 64KB 内 start data.find(bExif\x00\x00) if start 0: raise ValueError(没有 APP1/Exif 段文件大概率被清洗过) tiff start 6 # 跳过 Exif\0\0tiff 指向 TIFF 头 order data[tiff:tiff 2] # II小端MM大端 endian if order bII else magic, ifd0_off struct.unpack(endian HI, data[tiff 2:tiff 8]) return order.decode(), magic, ifd0_off, tiff print(peek_tiff_header(sample.jpg)) # 典型输出: (II, 42, 8, 12)逻辑说明find用的是字节序列而不是段解析因为段解析要先处理长度字段的边界遇到不规范的文件容易越界四个返回值里order告诉你后面所有多字节整数怎么解magic必须是 420x002A这是一个自校验位读到别的值说明偏移算错了。ifd0_off是 IFD0 相对于 TIFF 头的偏移绝大多数相机写的是 8因为 TIFF 头本身就占 8 字节。tiff是 TIFF 头在整个文件里的绝对位置后面算任何偏移都要加上它这是手写解析最常见的错。2.2 IFD 与标签12 字节一条记录IFDImage File Directory是一个数组开头两字节是条目数量之后每条记录固定 12 字节最后 4 字节指向下一个 IFD 的偏移没有就是 0。12 字节的分配是标签号 2 字节、数据类型 2 字节、数量 4 字节、值或偏移 4 字节。关键在最后 4 字节如果值的总长度不超过 4 字节就直接存在这里超过 4 字节这里存的是指向真实数据的偏移。ASCII 字符串几乎总是超长的所以要跳一次。这也是为什么直接打印 IFD 原始值会看到一堆看起来像文件偏移的数字。数据类型只有有限的几种常用的如下类型码名称字节数典型用途1BYTE1版本号、方向值的低位2ASCII1型号、软件名、日期字符串3SHORT2Orientation、分辨率单位4LONG4子 IFD 指针、像素尺寸5RATIONAL8光圈、曝光时间、GPS 度分秒7UNDEFINED1厂商私有段、Exif 版本10SRATIONAL8曝光补偿等带符号值IFD0 里最重要的不是那些显式字段而是三个指针标签0x8769指向 Exif 子 IFD光圈、快门、ISO、拍摄时间都在这里0x8825指向 GPS 子 IFD0xA005指向互操作 IFD。很多人用_getexif()只能拿到 IFD0发现没有 ISO 和光圈就是没往下跳这一层。def list_sub_ifd(data, tiff, endian): ifd0_off struct.unpack(endian I, data[tiff 4:tiff 8])[0] base tiff ifd0_off count struct.unpack(endian H, data[base:base 2])[0] pointers {} for i in range(count): off base 2 i * 12 tag, typ, cnt struct.unpack(endian HHI, data[off:off 8]) if tag in (0x8769, 0x8825, 0xA005): # 这三个标签的值一定是 LONG直接取后 4 字节当偏移 pointers[hex(tag)] struct.unpack(endian I, data[off 8:off 12])[0] return count, pointers # 输出形如 (24, {0x8769: 214, 0x8825: 986})逻辑说明base是 IFD0 在文件里的绝对位置循环步长固定 12这三条指针标签的类型都是 LONG 且数量为 1总长刚好 4 字节所以值内联在记录末尾不用二次跳转——这是唯一能偷懒的一类标签。返回的偏移同样是相对于 TIFF 头的用的时候还要再加tiff。如果0x8825不存在说明这张照片压根没开定位不是读取失败。2.3 什么时候自己解析什么时候直接用库手写解析的现实价值只有两个一是在没有第三方依赖的环境里做最小可用提取二是当库返回空值时做诊断判断到底是文件缺段还是解析路径不对。日常业务里没必要重复造轮子成熟的库已经处理了字节序、越界、异常类型这些脏活。选型上我的习惯是批量扫描和字段审计用 exifread因为它保留原始标签名能看出哪些字段是相机真写了、哪些是软件补的需要改写、删除、回写 EXIF 用 piexif它把 IFD 结构原样暴露成字典只是顺手拿几个字段、又已经在用图像处理库就直接用 Pillow省一次依赖。三套都装也不冲突总共不到两兆。提示不管用哪套方案先跑一遍上面那个peek_tiff_header。文件连Exif\0\0都搜不到的时候换十个库也是空结果这一步能省掉大量无效排查。3. 三套 Python 读法Pillow、exifread、piexif 怎么选三个库的定位差别很大混着用会踩坑比如拿 Pillow 的_getexif()去读 GPS 会得到一串看不懂的整数键。这一章把三套方案的调用方式、返回结构和使用边界分别写清楚最后给一个能直接跑的批量扫描脚本。3.1 Pillow一次拿到常用字段Pillow 的优势是它本来就是图像库打开文件的同时就能拿到尺寸、格式顺手取 EXIF 不增加成本。用法上要注意区分getexif()和get_ifd()前者只给 IFD0后者才是往子 IFD 里跳。from PIL import Image, ExifTags def read_with_pillow(path): with Image.open(path) as im: raw im.getexif() # IFD0 base {ExifTags.TAGS.get(k, k): v for k, v in raw.items()} for tag_id, group in ((0x8769, exif), (0x8825, gps)): try: sub raw.get_ifd(tag_id) # 跳进子 IFD except KeyError: continue for k, v in sub.items(): base.setdefault(f{group}.{ExifTags.TAGS.get(k, f0x{k:04X})}, v) return base, im.size, im.format info, size, fmt read_with_pillow(sample.jpg) print(fmt, size, info.get(exif.DateTimeOriginal), info.get(gps.GPSLatitude))逻辑说明getexif()返回的是一个类似字典的对象键是整数标签号用ExifTags.TAGS映射成可读名。子 IFD 用get_ifd(标签号)取返回的仍然是整数键字典所以我统一加了exif.和gps.前缀避免 IFD0 和子 IFD 里同名标签互相覆盖DateTime和DateTimeOriginal就属于这种易混对。setdefault而不是是为了让 IFD0 里的原始值优先子 IFD 只补空缺。注意with语句包住整个读取过程因为 Pillow 是惰性加载出了with块再访问im的某些属性会报错。这套写法的边界是老版本 Pillow 没有get_ifd只能退回_getexif()加手工偏移不建议另外 Pillow 对 HEIC、部分相机私有段的解析能力有限遇到读不出的情况换 exifread 更省事。3.2 exifread保留原始标签名适合做字段审计exifread 的返回键是Image Make、EXIF FNumber、GPS GPSLatitude这种带前缀的字符串看起来啰嗦但它保留了来源分组的语义做字段覆盖度统计时特别好用——你能一眼看出这台机器到底写了哪些字段。import exifread def read_with_exifread(path): with open(path, rb) as f: tags exifread.process_file(f, detailsFalse) return {k: str(v) for k, v in tags.items()} t read_with_exifread(sample.jpg) print(t.get(EXIF DateTimeOriginal), t.get(GPS GPSLatitudeRef))逻辑说明detailsFalse是关键参数它跳过厂商私有段MakerNote的解析。私有段没有统一规范各家格式不同解析它既慢又容易抛异常而业务上真正需要的字段九成在标准段里。第二个隐含收益是速度关掉私有段解析后单张耗时通常能降一半以上。所有值都用str()强转因为 exifread 返回的是自定义的分数对象直接进 JSON 序列化会失败。需要私有段里某些数据比如镜头序列号、快门计数时才开detailsTrue而且必须包try/except坏文件在这一步抛异常的概率不低。3.3 piexif要写回、要缩略图时用它piexif 把 EXIF 按物理结构原样映射成字典四个键0th、Exif、GPS、1st分别对应 IFD0、Exif 子 IFD、GPS 子 IFD 和缩略图 IFD值全是整数标签号。读取只是它的一半用途真正的价值在于写回和删除。import piexif def strip_gps(src, dst): exif piexif.load(src) if not exif.get(GPS): return False removed len(exif[GPS]) exif[GPS] {} # 清空 GPS IFD piexif.insert(piexif.dump(exif), dst) # 重新打包并写入新文件 return removed n strip_gps(sample.jpg, clean.jpg) print(f清掉 {n} 个定位字段)逻辑说明load读出来的是可变的 dict直接改键值就行dump负责按类型重新编码成字节这一步会自动处理 RATIONAL 的分母、ASCII 的结尾空字节这些细节手写极易出错insert是原地写入字节流所以第二个参数必须是一个已经存在的目标文件路径不能凭空生成。清空 GPS 的做法是把整个子 IFD 置为空字典而不是逐条删除因为 GPS IFD 里还有GPSVersionID之类的必填项留着反而会在某些软件里触发解析异常。边界提醒dump对数据类型要求很严手工塞一个 Python float 进去会直接报类型错误必须用piexif.TYPES或分数元组。改写前务必备份原图因为insert是覆盖式写入。3.4 批量扫描遍历目录并落 CSV单张读取的代码都很短真正的工程量在批量和容错。下面这段是可以直接抄去改的扫描脚本重点是三个设计按扩展名预筛、单文件异常不影响整批、字段名固定输出保证 CSV 列稳定。import csv, pathlib from PIL import Image, ExifTags WANT [Make, Model, DateTimeOriginal, DateTime, FNumber, ExposureTime, ISOSpeedRatings, FocalLength, Orientation, Software] EXTS {.jpg, .jpeg, .tif, .tiff} def scan(root, out_csv): rows, bad [], 0 for p in pathlib.Path(root).rglob(*): if p.suffix.lower() not in EXTS: continue try: with Image.open(p) as im: raw im.getexif() row {path: str(p)} for k, v in raw.items(): name ExifTags.TAGS.get(k, str(k)) if name in WANT: row[name] v if not raw.get_ifd(0x8769).get(36867): bad 1 # 36867 DateTimeOriginal except Exception as e: row {path: str(p), error: type(e).__name__} bad 1 rows.append(row) with open(out_csv, w, newline, encodingutf-8-sig) as f: w csv.DictWriter(f, fieldnames[path] WANT [error], extrasactionignore) w.writeheader() w.writerows(rows) print(f共 {len(rows)} 张缺拍摄时间 {bad} 张)逻辑说明rglob(*)递归遍历所有层级配合扩展名白名单过滤避免把视频和 RAW 拖进来RAW 需要专门的解析器Pillow 打不开。try/except包住单文件处理异常时只记类型名不记完整堆栈因为在两万张的量级下堆栈会把日志撑爆而type(e).__name__足够定位问题种类。encodingutf-8-sig是为了让表格软件直接双击打开不乱码。extrasactionignore保证字典里多出来的键不会让写入报错。参数上唯一需要按情况调的是EXTS。如果来源里有 HEIC不要直接加进白名单先确认本地装了对应的解码插件否则每一张都会走异常分支把bad计数污染掉看不出真实的缺失率。4. 把字段变成可用数据GPS 换算、时间归一与批量落库读出来只是第一步。EXIF 的原始值是给相机看的不是给人看的GPS 是三个分数、时间是带冒号的字符串、方向是个 1 到 8 的整数。这一章处理转换和落地转换错了后面的排序、地图、去重全部跟着错。4.1 GPS三个分数怎么变成十进制度GPS 子 IFD 里GPSLatitude和GPSLongitude各是一个长度为 3 的 RATIONAL 数组依次是度、分、秒GPSLatitudeRef和GPSLongitudeRef是N/S/E/W单个字符。十进制的换算公式是 度 分/60 秒/3600南纬和西经取负。def dms_to_deg(dms, ref): d, m, s (float(x) for x in dms) deg d m / 60.0 s / 3600.0 return -deg if str(ref).upper() in (S, W) else deg lat dms_to_deg(gps[2], gps[1]) # 2GPSLatitude, 1GPSLatitudeRef lon dms_to_deg(gps[4], gps[3])逻辑说明float(x)这一步不能省Pillow 返回的是IFDRational对象exifread 返回的是自定义分数类两者直接做除法不会得到期望结果有些版本会抛类型错误有些会静默取整——后者更危险。ref用str().upper()兜底因为某些库返回的是 bytes 或者带空字节的字符串。度分秒的分母如果是 0相机没定位成功但写了字段float会抛ZeroDivisionError线上代码要在外面再包一层异常。还有一个业务层面的问题WGS-84 的原始坐标直接贴到公开底图上会有系统性偏移量级几十到几百米不等。要不要纠偏取决于用途做内部归档和时间线展示完全不必做精确位置标注才需要额外处理而且纠偏算法各家不一致跨底图混用会越纠越偏。4.2 时间与方向两个最容易翻车的字段时间字段的格式是2023:05:12 08:30:00日期部分用冒号分隔——这是 EXIF 规范的历史遗留直接datetime.strptime(s, %Y-%m-%d %H:%M:%S)必然失败。而且时间字段有三个DateTime文件最后修改时间很多软件会重写、DateTimeOriginal快门按下时间、DateTimeDigitized数字化时间。排序和去重必须用DateTimeOriginal用DateTime会导致整理过的照片全部变成同一天。from datetime import datetime def parse_exif_dt(s): if s is None: return None s str(s).strip(\x00 ).replace(-, :) for fmt in (%Y:%m:%d %H:%M:%S, %Y:%m:%d %H:%M): try: return datetime.strptime(s, fmt) except ValueError: continue return None逻辑说明先做strip(\x00 )因为 ASCII 类型字段按规范可能带结尾空字节某些机型还会补空格replace(-, :)是防御性处理少量软件导出的时间用短横线分隔我确实遇到过。两种格式按顺序试第二种是没有秒的机型写法。解析失败返回None而不是抛异常让上层决定是丢弃还是归档到「未知时间」桶里。方向字段Orientation是 1 到 8 的整数6 表示顺时针旋转 90 度8 表示逆时针 90 度3 表示 180 度。坑在于大多数图像处理操作不会自动应用这个标记所以你看到的缩略图和实际像素方向可能不一致。处理方式是旋转像素后把Orientation改回 1否则下一次读取会再转一次图就躺倒了。这条我吃过亏一批证件照被转了两次全部侧着。4.3 落库SQLite 建表与按时间线查询两万张照片用 CSV 能看不能查落到 SQLite 是最省事的选择——单文件、无服务、Python 标准库直接支持。表结构不用照抄 EXIF 的全部字段只留查询会用到的列。import sqlite3 DDL CREATE TABLE IF NOT EXISTS photo( path TEXT PRIMARY KEY, shot_at TEXT, camera TEXT, software TEXT, lat REAL, lon REAL, iso INTEGER, fnum REAL, shutter TEXT, focal REAL, orientation INTEGER ); CREATE INDEX IF NOT EXISTS idx_shot ON photo(shot_at); CREATE INDEX IF NOT EXISTS idx_cam ON photo(camera); conn sqlite3.connect(photos.db) conn.executescript(DDL) conn.execute(INSERT OR REPLACE INTO photo (path, shot_at, camera, software, lat, lon, iso, fnum, shutter, focal, orientation) VALUES (?,?,?,?,?,?,?,?,?,?,?), (row[path], dt.isoformat() if dt else None, model, row.get(Software), lat, lon, int(row.get(ISOSpeedRatings) or 0), float(row.get(FNumber) or 0), str(row.get(ExposureTime) or ), float(row.get(FocalLength) or 0), int(row.get(Orientation) or 1))) conn.commit()逻辑说明path做主键配合INSERT OR REPLACE让脚本可以反复跑而不会产生重复行这在增量扫描时很重要。shot_at存 ISO 格式字符串而不是时间戳因为 SQLite 的字符串比较本身就是字典序ISO 格式的字典序和时间序一致BETWEEN查询直接可用还省掉了时区转换的歧义。数值字段用or 0兜底因为 EXIF 缺失时这些键根本不存在float(None)会抛异常。建完索引后几个常用查询就很顺手了按天统计拍摄量能看出行程节奏按camera分组能发现同一批照片里混进了不同设备shot_at相同且camera相同的记录基本就是连拍可以直接去重。注意EXIF 里的时间是相机的本地时间不带时区。跨时区拍摄的照片混在一个库里排序会错位如果业务对时间敏感得额外记录时区或者依赖GPS换算别指望 EXIF 自己说清楚。5. 排查清单EXIF 读不出来、读不对的 5 种情形下面这五条都是实际跑批时撞出来的按出现频率排序。每条都按「现象 → 原因 → 解决」写遇到问题可以直接对照。现象一整批照片读出来是空字典。原因通常不在代码而在文件来源。社交软件和内容平台在上传时会重编码图片并剥离元数据这是默认行为截图工具、部分导出流程也会直接不写 EXIF。判断方法很直接跑一遍第 2 章的peek_tiff_header搜不到Exif\0\0就是文件本身没有。 解决方式是分层处理——扫描阶段就把「无 EXIF」和「读取失败」分开计数前者归档到独立目录后者才去看堆栈。混在一起统计会让你误以为解析代码有 bug。现象二Pillow 能拿到型号和时间但 ISO、光圈全是空。原因是只读了 IFD0。Image.getexif()返回的是第一层目录曝光参数、拍摄时间这些都在0x8769指向的 Exif 子 IFD 里必须再调get_ifd(0x8769)。 解决方式是统一写一个封装函数永远同时取 IFD0、Exif 子 IFD 和 GPS 子 IFD 三层合并后再返回。不要在不同脚本里各写一遍早晚会有一处漏掉。现象三GPS 字段拿到了但float()报类型错误或者结果是个整数。原因是库返回的是分数对象而不是 Python 数字不同库的对象类型还不一样。更隐蔽的情况是某些版本重载了除法但没重载浮点转换静默返回错误结果。 解决方式是转换前统一float(x)并且对分母为 0 的情况做异常捕获别假设相机写的一定合法。现象四缩略图和原图方向不一致或者旋转后越转越歪。原因是Orientation标记存在但显示端和编辑端的处理策略不同有的自动应用有的忽略。旋转像素后如果不把标记改回 1下一次读取会重复应用一次。 解决方式是建立一条硬规则——任何像素级旋转操作之后必须同步写回Orientation 1。这条规则建议直接写进函数里不依赖调用方记得。现象五扫到某个文件直接抛OSError: image file is truncated整批中断。原因是文件在传输或存储过程中损坏或者下载不完整。Pillow 默认会在这种文件上抛异常。 解决方式是两层防护单文件try/except包住同时对外层循环加进度日志这样中断时能知道停在哪一张。不要用ImageFile.LOAD_TRUNCATED_IMAGES True图省事它会让损坏文件返回半张图后面计算尺寸和方向全是错的问题被推到更难查的环节。6. 进阶用法先扫 64KB 文件头再决定要不要全量解析批量场景下真正的瓶颈不是解析本身而是无谓地打开文件。很多照片根本没有 EXIF或者只有十几字节的残段但Image.open()还是会去解析图像头、构建对象两万张跑下来能差出好几分钟。我的做法是先做一次极轻量的预筛只读文件前 64KB判断有没有Exif\0\0有才进完整解析流程。def has_exif(path, head64 * 1024): with open(path, rb) as f: return bExif\x00\x00 in f.read(head) # 主循环里先判一次能砍掉相当比例的完整解析 candidates [p for p in files if has_exif(p)]逻辑说明head参数默认 64KB 是一个经验值EXIF 段几乎总在文件最前面但少数机型会在前面塞大段厂商私有数据遇到可疑文件可以临时调到 256KB 复验。这个函数只做字节搜索不解析结构单张耗时在微秒级所以哪怕全部文件都扫一遍成本也可以忽略。配合前面的落库第二个进阶用法是用 EXIF 当索引做去重。同一天、同一秒、同一机型的三元组基本可以判定为连拍或重复导入把这三列建成联合索引后一条GROUP BY就能筛出来字段组合判定含义处理建议shot_at camera同一秒同机型大概率连拍保留首张其余标记为组内序号shot_at 文件大小同秒同大小重复导入按 path 哈希去重lat lon同一点位可能原地多拍不进地图打点只做数量统计第三个技巧是给缺失字段留出置信度。DateTimeOriginal缺失时退到DateTime再缺失退到文件系统 mtime但要在库里记一列time_source取值exif / file / mtime。这个列看起来多余等到做时间线展示时就知道值钱了——用户可以接受排序错位不能接受把不知道的时间当成真实拍摄时间展示出来。有个教训我印象很深早期跑一个照片归档脚本为了省事直接用DateTime排序结果一批用修图软件导出过的照片时间全部变成了导出那天时间线整个挤在末尾。我当时以为是 EXIF 读取库的问题换了两套库、手写了解析器最后发现字段本来就写的是导出时间代码一行没错。从那以后我每次做时间相关处理都强制走一遍「字段存在性检查 来源标记 抽样人工核对」这三步哪怕只有几十张也照做。希望帮到你。本文还有配套的精品资源点击获取