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

发布时间:2026/10/23 6:17:44
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 段拼起来的。文件开头是FFD8SOI紧接着一般就是FFE1APP1EXIF 就装在这个段里。APP1 的载荷以Exif\x00\x00六个字节开头后面紧跟一个 TIFF 头——注意EXIF 本质上是 TIFF 格式的一小块不是 JPEG 自己的结构。TIFF 头的头 8 个字节定义了三件事字节序II表示小端MM表示大端、固定魔数 420x002A用来确认这是合法 TIFF、以及第一个 IFDIFD0相对 TIFF 头起点的偏移量。这个偏移是“相对 TIFF 头起点”而不是相对文件开头很多自己写解析器的人第一次都栽在这上面。APP1 段还有大小限制单段最大 64KB所以厂商在塞 MakerNote 和缩略图时会精打细算字段被截断的情况真实存在。下面这段代码不解码像素只在文件头部找 APP1 并把 TIFF 头读出来用来确认文件到底有没有 EXIF、字节序是什么import struct def read_tiff_header(path, head_bytes64 * 1024): # 只读文件头APP1 段最大 64KB不必加载整张图 with open(path, rb) as f: data f.read(head_bytes) if data[:2] ! b\xff\xd8: return None, 不是 JPEGPNG/HEIC 走另外的路径 idx data.find(bExif\x00\x00) if idx 0: return None, 没有 APP1/Exif 段 base idx 6 # TIFF 头起点 bo data[base:base 2] endian if bo bII else if bo bMM 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, fTIFF 魔数不对{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、缩略图数据内嵌缩略图最容易踩的是时间字段。DateTime0x0132在 IFD0 里语义上更接近“文件最后被写入的时间”DateTimeOriginal0x9003在 Exif SubIFD 里才是快门按下的那一刻DateTimeDigitized0x9004是数字化时间手机拍的照片通常和 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 filenameutf8 ./IMG_0001.jpg one.json-j输出 JSON 数组-n关闭“人性化显示”——这一项很关键不加的话 GPS 会输出成39 deg 54 26.99 N这种带单位的字符串加了才是纯数字的十进制度海拔也是纯米数。-charset filenameutf8处理中文路径不写这个参数带中文目录的照片会直接报文件找不到。拿到这份 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 解析基本不解析detailsTrue时部分解析解析最完整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 的优势是快和稳。它用键名前缀区分 IFDImage开头是 IFD0EXIF开头是 SubIFDGPS开头是 GPS IFD。process_file的detailsFalse会跳过 MakerNote 解析这是批量场景下的默认选择——MakerNote 数据结构不统一解析它除了慢还容易在某些机型上直接抛异常中断整个批处理。import exifread from datetime import datetime def parse_with_exifread(path): with open(path, rb) as f: # detailsFalse 跳过 MakerNote速度快且不易崩 tags exifread.process_file(f, detailsFalse) 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, encodingutf-8-sig) as f: w csv.DictWriter(f, fieldnamesFIELDS, extrasactionignore) w.writeheader() w.writerows(rows)extrasactionignore让多出来的字段被静默丢弃避免某个机型多写一个字段就整批报错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 的时分秒三个 RATIONALGPSDateStamp是YYYY:MM:DD格式的日期。这两个字段合起来才是完整的 UTC 时刻和DateTimeOriginal本地时间对照可以反推出拍摄地的时区偏移这是补OffsetTimeOriginal缺失的一个实用手段。4.2 度分秒转十进制度与符号判定from fractions import Fraction def _to_float(v): # Pillow 给 IFDRationalexifread 给 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 的实际精度通常 310 米细得多再往后留位没有意义只会让 CSV 变长。_to_float里的兜底分支是给 exifread 准备的它返回的 Ratio 对象在str()之后是39/1这种形式直接float()会崩。验证方法别偷懒挑几张在明确地点拍的照片把转换结果贴进任意地图服务的坐标搜索框看落点是否对得上。我一般会选一个有明显地标的点误差超过 50 米就要回头查是不是 Ref 丢了或者只取了度、丢了分秒。提示不同地图服务使用的坐标基准不同EXIF 里存的是 WGS-84直接叠加到某些地图上会有数百米量级的系统性偏移需要先做基准转换再落图。这一步不做你会以为是提取代码错了。4.3 Orientation 与其余字段别浪费Orientation只有 18 八个合法值但它决定了照片该不该转。竖拍的照片在传感器上其实是横着存的靠这个字段告诉查看器“显示时旋转 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有就按它换算成绝对时刻没有就用同一条记录的GPSDateStampGPSTimeStamp和DateTimeOriginal做差反推偏移两者都没有就保留本地时间并标记offsetunknown不要擅自加减。坑三个别机型的照片解析直接抛异常。现象是批量跑到某几张就崩单独打开看又没问题。原因基本都指向 MakerNotetag0x927C厂商私有区块的偏移基准不统一有的相对文件头有的相对 TIFF 头还有的在不同固件版本里改了长度定义。解决办法是批量时关掉 MakerNote 解析exifread 用detailsFalsePillow 本身就不深入解析把这些文件单独挑出来交给 exiftool 处理。exiftool 内部维护了大量机型的偏移修正规则这是它最不可替代的地方。5.2 环境与性能层面的两个坑坑四跑几千张就卡死或内存暴涨。现象是脚本跑到一两千张时越来越慢最后被系统杀掉。原因多半是用了im.load()或者对每张图做了convert(RGB)——这会解码整幅像素一张 4000×3000 的照片解码后就是几十 MB。解决方式很明确只读文件头Image.open()之后立刻getexif()然后关掉绝不碰像素需要并行的用ProcessPoolExecutor而不是线程池因为解析是 CPU 密集型GIL 会拖死线程方案并发数别开满磁盘 IO 会先成为瓶颈一般取 CPU 核数的 12 倍就够。坑五HEIC 文件全部报“无法识别的图像格式”。现象是手机直出的照片一张都读不了换成导出过的 JPEG 就正常。原因是 HEIC 是 ISOBMFF 容器EXIF 放在metabox 的iinf/iloc引用里结构和 JPEG 的 APP1 段完全不同Pillow 原生不支持。解决办法是装pillow-heif并执行register_heif_opener()注册解码器。注意注册要在所有Image.open之前完成写在模块顶层而不是函数内部否则多进程环境下子进程可能没注册上。6. 从提取到归档给这批数据加一道校验和重命名闭环提取本身只是第一步。真正决定这份数据能不能用的是提取之后有没有做校验。我现在的习惯是任何一批 EXIF 出来先过一遍规则校验把可疑记录单独列出来人工看确认无误再进归档流程。校验规则不需要复杂覆盖四类就够时间是否落在合理区间比如 1990 年到当前时间之间排除全零和未来时间、经纬度是否成对出现且落在合法值域、Orientation 是否在 18、GPS 坐标是否超出范围。任何一条不过就把文件名和问题类型写进issues.csv主流程继续跑不中断。from datetime import datetime def validate(row, nowNone): 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_runTrue先打印不落盘人工扫一眼路径分布再执行复制用shutil.copy2而不是copy因为它会把原始 mtime 和权限一起带过去万一后面还要靠 mtime 兜底数据还在。目标文件重名时加_dup后缀而不是覆盖——照片库里的同名文件十有八九是两次导入的同一张覆盖了就少一份证据。import shutil from pathlib import Path def archive(row, src, root, dry_runTrue): 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(parentsTrue, exist_okTrue) 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 张对着手机相册核时间最后才真正落盘。这个流程多花五分钟能省掉一整天回滚的功夫。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询