Exif 信息读取与批量遍历实战:从底层结构到 Python 性能优化
1. 从一张照片说起为什么我们需要读取 Exif你拍了一张照片发到群里朋友看了一眼就说“这是在城东那个湿地公园拍的吧下午四点多”你一愣——他根本不在现场。秘密就藏在照片文件本身里。每一张由手机或相机拍摄的 JPEG 图片除了你看到的画面像素还附带了一包“行李”里面记录了拍摄时间、设备型号、光圈快门、甚至经纬度坐标。这包行李就是ExifExchangeable Image File Format中文一般叫“可交换图像文件格式”的元数据。Exif 信息读取与遍历说白了就是两件事第一把图片里这包元数据完整地“拆”出来看看里面到底存了什么第二批量地对一堆图片做同样的操作把结果整理成可用的表格或报告。这件事听起来简单但实际做起来坑比想象中多得多。不同厂商写入的字段名不一样有的字段是 ASCII 有的却是 Rational 分数有的时间戳带时区有的不带GPS 坐标还分度分秒和十进制两套表示法。你要是不了解这些细节读出来的数据很可能是一团乱麻。这篇文章适合谁看如果你是做图像管理工具的开发者、需要整理大量素材的摄影师、做数据取证或内容审核的技术人员或者只是单纯好奇自己手机照片里藏了多少隐私信息那这篇内容都能帮到你。我会从 Exif 的底层结构讲起然后给出可直接运行的 Python 实操代码再重点分享批量遍历时的性能优化和踩坑经验。全程不废话都是我自己在项目里摸爬滚打总结出来的东西。2. Exif 的底层结构先搞懂它长什么样2.1 Exif 在文件里的物理位置要读取 Exif得先知道它藏在哪。以最常见的 JPEG 为例文件开头是FFD8SOI图像起始标记结尾是FFD9EOI图像结束标记。中间是一段段的“段”Segment每段以FF加一个标记字节开头。Exif 数据就藏在FFE1这个段里学名叫APP1Application Segment 1。APP1 段的结构是这样的先是 2 字节的段长度然后是 6 字节的固定标识Exif\0\0接着才是真正的 TIFF 格式数据。这个 TIFF 头很关键它决定了后面所有数据的字节序——开头两个字节如果是II0x4949就是小端序Intel如果是MM0x4D4D就是大端序Motorola。字节序搞错读出来的数字全是乱的这是新手最容易翻车的地方。提示PNG 图片的 Exif 存储方式完全不同它放在eXIf数据块里而且很多 PNG 压根没有 Exif。如果你要处理混合格式得分别对待。2.2 IFDExif 的目录索引机制TIFF 头之后是一个 4 字节的偏移量指向第一个IFDImage File Directory图像文件目录。IFD 就像一本书的目录页每个条目叫一个IFD Entry固定 12 字节结构如下字节偏移长度含义0-12 字节Tag 编号标识这个字段是什么2-32 字节数据类型ASCII、Short、Long、Rational 等4-74 字节数据个数Count8-114 字节值或值的偏移量这里有个精妙的设计如果数据不超过 4 字节就直接塞在最后 4 个字节里如果超过 4 字节这 4 个字节存的是一个偏移量指向数据真正存放的位置。我第一次手写解析器的时候没注意这点读字符串字段全是乱码排查了半天才发现是把偏移量当成了内容。Exif 通常有多个 IFD按层级组织IFD0主图像信息如厂商、型号、Exif SubIFD拍摄参数如光圈、快门、ISO、GPS IFD地理位置、IFD1缩略图信息。每个 IFD 结尾有一个 4 字节指针指向下一个 IFD形成链表。遍历的本质就是顺着这条链把每个 IFD 的每个 Entry 都读一遍。2.3 常见 Tag 与数据类型的对应关系不是所有字段都值得关注。实际项目里我通常重点抓这几类时间类DateTime0x0132、DateTimeOriginal0x9003、DateTimeDigitized0x9004设备类Make0x010F、Model0x0110、Software0x0131拍摄参数ExposureTime0x829A、FNumber0x829D、ISOSpeedRatings0x8827、FocalLength0x920AGPS 类GPSLatitude0x0002、GPSLatitudeRef0x0001、GPSLongitude0x0004、GPSLongitudeRef0x0003、GPSAltitude0x0006数据类型尤其要注意Rational它是两个 Long 组成的分数分子在前分母在后。比如 FNumber 读出来是28/10你得自己算成 2.8。GPS 坐标更麻烦是三个 Rational 组成的度、分、秒还要结合 Ref 字段判断南北纬和东西经。3. 工具选型为什么我不建议你从零手写解析器3.1 手写解析的诱惑与陷阱刚接触 Exif 的时候很多人包括我会想不就是按字节读吗我自己写一个多酷。我确实写过用 Python 的struct模块一个字节一个字节地 unpack。结果呢处理了 200 张图有 30 多张报错剩下的读出来字段缺失严重。问题出在不同厂商的 Exif 写入规范差异极大有的字段长度不对有的偏移量指向了文件外有的干脆是损坏的。手写解析器要处理所有这些边界情况工作量远超预期。手写唯一的优势是“零依赖”适合嵌入式或极端受限环境。但如果你是在做正常的桌面或服务端应用用成熟库是明智得多。省下来的时间拿去优化业务逻辑不香吗。3.2 主流库横向对比我实际用过的几个库各有脾气库名语言优点缺点PillowPython安装简单API 友好Exif 支持不完整GPS 解析弱exifreadPython纯 Python字段全支持遍历速度偏慢维护不活跃ExifToolPerl/命令行支持格式最全字段最权威需外部进程调用有性能开销piexifPython读写都支持轻量只处理 JPEG/TIFFAPI 略原始metadata-extractorJava稳定字段丰富仅 Java 生态我的选择策略是快速原型用 Pillow批量生产用 exifread 或 ExifTool。如果对字段完整性要求极高比如取证场景ExifTool 是事实标准没有之一。它的字段命名体系比如EXIF:DateTimeOriginal、GPS:GPSLatitude已经成为行业通用语言。3.3 安装与环境准备以 Python 为例我推荐这套组合pip install exifread Pillowexifread 负责深度解析Pillow 负责打开图片和做兜底。如果你要用 ExifTool去官网下载可执行文件放到 PATH 里即可Python 侧用subprocess调用。注意exifread 对 HEIC 格式支持有限如果你要处理苹果设备导出的 HEIC建议先用pillow-heif转成 JPEG或者直接用 ExifTool。4. 单张图片读取从能跑到跑对4.1 最小可用代码先上一个能跑的基础版本用 exifread 读取单张图片import exifread def read_exif_basic(path): with open(path, rb) as f: tags exifread.process_file(f, detailsFalse) for tag, value in tags.items(): print(f{tag}: {value}) return tags read_exif_basic(sample.jpg)detailsFalse表示只读基础字段跳过 MakerNote 等厂商私有数据速度快很多。如果你需要厂商特定信息比如快门次数改成detailsTrue但解析时间可能翻好几倍。4.2 关键字段的提取与格式化直接打印出来的值很多是 exifread 的封装对象不能直接用。我通常写一个规范化函数把常用字段转成 Python 原生类型from datetime import datetime def normalize_exif(tags): result {} # 时间字段 for key in [EXIF DateTimeOriginal, Image DateTime]: if key in tags: try: result[datetime] datetime.strptime( str(tags[key]), %Y:%m:%d %H:%M:%S ) break except ValueError: pass # 设备字段 result[make] str(tags.get(Image Make, )).strip() result[model] str(tags.get(Image Model, )).strip() # 拍摄参数 if EXIF FNumber in tags: result[f_number] tags[EXIF FNumber].values[0].num / \ tags[EXIF FNumber].values[0].den if EXIF ExposureTime in tags: result[exposure] tags[EXIF ExposureTime].values[0].num / \ tags[EXIF ExposureTime].values[0].den if EXIF ISOSpeedRatings in tags: result[iso] tags[EXIF ISOSpeedRatings].values[0] return result这里有个细节tags[EXIF FNumber].values[0]返回的是一个Ratio对象有.num和.den属性。直接float()转换在某些版本会报错老老实实做除法最稳。4.3 GPS 坐标的解析最容易出错的地方GPS 是 Exif 里最复杂也最有价值的部分。原始数据是三个 Rational度、分、秒加上一个 Ref 字段表示方向。转换公式是十进制度数 度 分/60 秒/3600如果 Ref 是 S南纬或 W西经结果取负。完整代码def parse_gps(tags): def to_deg(values): d values[0].num / values[0].den m values[1].num / values[1].den s values[2].num / values[2].den return d m / 60 s / 3600 lat lon None if GPS GPSLatitude in tags and GPS GPSLatitudeRef in tags: lat to_deg(tags[GPS GPSLatitude].values) if str(tags[GPS GPSLatitudeRef]) S: lat -lat if GPS GPSLongitude in tags and GPS GPSLongitudeRef in tags: lon to_deg(tags[GPS GPSLongitude].values) if str(tags[GPS GPSLongitudeRef]) W: lon -lon return lat, lon我踩过的坑有些手机写入的 GPS 秒数是0/0除零会崩。所以to_deg里最好加个判断分母为 0 时按 0 处理。另外部分安卓机型的 Ref 字段是空字符串这时候默认按北纬东经处理。5. 批量遍历性能与健壮性的双重考验5.1 遍历策略设计单张读取跑通后真正的挑战来了几千上万张图片怎么高效处理。我的基本策略是先扫描后解析用os.walk或pathlib先收集所有图片路径过滤掉非图片文件再统一处理。并发加速Exif 解析是 IO 密集加 CPU 密集的混合任务用concurrent.futures的进程池比线程池更有效因为 Python 的 GIL 会限制线程的 CPU 利用率。增量处理记录已处理的文件用文件路径加修改时间做 key下次运行跳过未变化的文件。from concurrent.futures import ProcessPoolExecutor from pathlib import Path def collect_images(root): exts {.jpg, .jpeg, .tif, .tiff, .png} return [p for p in Path(root).rglob(*) if p.suffix.lower() in exts] def process_batch(root, workers4): files collect_images(root) results [] with ProcessPoolExecutor(max_workersworkers) as executor: for res in executor.map(process_one, files, chunksize20): if res: results.append(res) return resultschunksize20是个经验值。太小了进程间通信开销大太大了单个进程内存压力大。我实测下来处理 1 万张 3MB 左右的 JPEG4 进程加 chunksize 20比单进程快 3.5 倍左右。5.2 异常处理坏图、无 Exif、编码错误批量处理最怕的就是一张坏图让整个任务崩掉。我的原则是任何单张图片的失败都不能影响整体流程。process_one函数必须包一层 try-exceptdef process_one(path): try: with open(path, rb) as f: tags exifread.process_file(f, detailsFalse) if not tags: return {path: str(path), status: no_exif} data normalize_exif(tags) lat, lon parse_gps(tags) data.update({path: str(path), lat: lat, lon: lon, status: ok}) return data except Exception as e: return {path: str(path), status: error, error: str(e)}常见的异常类型我整理成了一张速查表异常现象可能原因处理方式KeyError字段不存在用.get()或in判断ZeroDivisionErrorRational 分母为 0除法前判断分母UnicodeDecodeError字符串字段编码非 UTF-8用errorsignore解码读取超时文件损坏或超大设置超时跳过内存暴涨一次性加载所有结果分批写入磁盘5.3 结果输出CSV 还是数据库小规模几千张以内用 CSV 就够了方便用 Excel 打开检查。大规模建议直接写 SQLite支持索引和查询。我通常用 pandas 做中转import pandas as pd df pd.DataFrame(results) df.to_csv(exif_report.csv, indexFalse, encodingutf-8-sig)utf-8-sig这个编码很关键不加的话 Excel 打开中文会乱码。这是我被同事问过无数次的问题。6. 实战中的坑与排查技巧6.1 时间字段的时区陷阱Exif 的 DateTimeOriginal 字段不带时区信息。它记录的是相机本地时间。如果你在北京拍的照片字段值是2024:05:01 14:30:00但相机可能设置的是 UTC也可能是任意时区。做时间分析时千万别假设它是 UTC。我的做法是如果图片同时有 GPS 信息可以根据经纬度推断时区否则就原样保留不做转换。另外有些设备写入的时间格式不规范比如用-而不是:分隔或者月份是单个数字。解析时最好用正则先做一次清洗。6.2 厂商私有字段的读取MakerNote 是厂商存放私有数据的区域佳能、尼康、索尼各有各的格式没有统一标准。exifread 对部分厂商有支持但覆盖不全。如果你需要读取快门次数、镜头序列号这类信息ExifTool 是唯一靠谱的选择。调用方式import subprocess, json def read_with_exiftool(path): result subprocess.run( [exiftool, -j, -G, str(path)], capture_outputTrue, textTrue ) return json.loads(result.stdout)[0]-j输出 JSON-G给字段加组名前缀如EXIF:、GPS:避免字段名冲突。ExifTool 启动有固定开销约 100ms批量处理时建议一次传多个文件而不是循环调用。6.3 隐私清理与合规提醒读取 Exif 的反面就是清理 Exif。很多社交平台上传时会自动剥离但通过邮件、网盘分享原图时GPS 坐标就暴露了。如果你在做图片分享类产品默认剥离 GPS 字段应该是基本设计。清理用 piexif 很简单import piexif def strip_gps(path, output): exif_dict piexif.load(path) exif_dict[GPS] {} piexif.insert(piexif.dump(exif_dict), path, output)注意清理 Exif 会改变文件内容如果图片有数字签名或哈希校验清理后校验会失败。做取证类应用时要特别小心。6.4 常见问题速查问题排查方向解决读出来全是 None文件是否真的是 JPEG检查文件头 FFD8GPS 坐标偏差几公里度分秒转换公式错核对 60 和 3600批量处理卡死某张图超大或损坏加超时和文件大小限制字段名对不上库版本差异打印所有 tag 名对照中文乱码编码问题用 utf-8-sig 输出7. 进阶方向从读取到应用Exif 读取本身不是目的它通常是更大流程的一环。我做过几个有意思的应用场景可以给你一些启发。第一个是照片自动归档。读取 DateTimeOriginal按年月建立文件夹再根据 GPS 反查城市名调用逆地理编码服务归档路径就是2024/05/北京/。这个需求在摄影工作室特别常见几千张照片手动分类能累死人。第二个是设备统计报告。批量读取 Make 和 Model统计团队里谁用什么设备拍得最多哪个焦段用得最频繁。这个数据对器材采购决策很有参考价值。第三个是异常检测。比如检测图片的修改时间FileModifyDate和拍摄时间DateTimeOriginal差异过大可能说明图片被后期处理过。或者检测 GPS 坐标落在水域、无人区等不合理位置用于内容审核。这些应用的共同点是Exif 读取只是数据入口真正的价值在于后续的分析和决策。所以你在设计读取模块时就要考虑输出格式是否方便下游使用。我的建议是统一输出成扁平化的字典或 DataFrame字段名用英文小写下划线时间统一成 ISO 8601 格式坐标统一成十进制度数。这样下游无论是做可视化还是入库都不用再做转换。最后分享一个我个人的习惯每次做 Exif 相关的项目我都会先拿 20 张不同类型的图片不同品牌手机、不同相机、截图、网图、修过的图跑一遍把结果打印出来人工核对。这一步能提前发现 80% 的字段解析问题比写完整个流程再调试高效得多。Exif 这东西规范是一回事实际数据是另一回事只有拿真实样本喂出来的代码才靠得住。