/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
本文定位:这是一篇隐私自查 / 数字取证 / 组织合规视角的技术文章,只讨论自己持有或已获书面授权的图片。文中所有示例图均为程序自造的测试图,坐标取自公开地标,不指向任何个人位置;所有命令均为读取、核查与清理,不含定位他人、跟踪、人肉或批量抓取社交平台图片做画像的方法。第八节单独列了法律与平台边界,请先读那一节再动手。
实测环境:Python 3.11.1 / Pillow 12.1.0 / piexif 1.1.3 / exifread 3.5.1 / exiftool 12.76 / Ubuntu 22.04。文中所有"输出"均为该环境真实运行结果,非示意数据。
一、先说结论:风险不在"能不能读",在"默认就写进去了"
先给结论,再拆解。
第一,读取门槛低到可以忽略。 一行 exiftool 或十行 Python 就能把一张照片的元数据全掏出来。所以任何"别人应该不会去看吧"的假设都不成立——防护不能建立在"读取很难"上。
第二,藏的不只是经纬度。 一张带完整 EXIF 的照片,通常同时写进去了设备厂商与型号、镜头、机身序列号、精确到秒的拍摄时间、编辑软件、作者与版权字段,以及一张裁剪前的完整缩略图。这些字段单独看不起眼,拼起来足以还原"谁、在什么时候、用什么设备、在哪个位置"。
第三,杠杆在"发出去之前",不在事后补救。 图片一旦以原文件形式离开你的设备(邮件附件、网盘分享、论坛附件、自建站、对象存储直链),元数据就跟着走了;事后再去删链接、改权限,副本已经不在你手里。真正有效的做法是把剥离元数据做成上传/发布流水线里的默认一步。
1.1 一张表看清"会泄露什么"
| 位置 | GPSLatitude / GPSLongitude / GPSAltitude | 拍摄点坐标,精度可达米级 | 高 |
| 时间 | DateTimeOriginal / GPSTimeStamp / GPSDateStamp | 精确到秒的拍摄时刻、作息规律 | 高 |
| 设备 | Make / Model / LensModel / BodySerialNumber | 机型、镜头,甚至机身唯一序列号 | 中高 |
| 归属 | Artist / Copyright / UserComment | 作者名、版权声明、自定义备注 | 中 |
| 软件 | Software / ProcessingSoftware | 手机固件版本、修图软件与版本 | 中 |
| 画面 | ThumbnailImage(IFD1) | 裁剪前的完整画面,可能含被裁掉的人脸/文字/屏幕 | 高(最易忽略) |
| 参数 | FNumber / ExposureTime / ISO / Flash | 拍摄条件,辅助判断室内外、时间 | 低 |
| 编辑 | XMP / IPTC / ICC | 后期历史、关键词、标题、色彩配置 | 中 |
1.2 一个必须分清的前提:不是所有"发图"都会带 EXIF
这是最容易被自媒体文章写错的地方,先说准:
| 微信/朋友圈非原图、微博压缩图等 | 多数被剥离 | 平台会重编码、重建文件,EXIF 一般随之丢失 |
| 微信/邮件/网盘的「原图」 | 保留 | 传的是原始文件字节,元数据原样带走 |
| 论坛/博客附件、工单附件、对象存储直链 | 保留 | 服务端通常只做存储,不改写文件内容 |
| 自建站、CDN 直接托管 IMG_1234.jpg | 保留 | 除非上传环节主动剥离 |
| 截图、屏幕录制 | 通常没有 GPS | 系统截图一般不写相机元数据,但可能带软件/时间字段 |
| HEIC / Live Photo / 视频 | 各自有独立容器 | 见第七节 |
所以准确的说法是:风险集中在「原图」和「文件原样对外」这两类场景,而不是"发张照片就会被定位"。搞清楚这一点,才不会要么过度恐慌、要么完全不管。
二、照片里到底藏了什么:EXIF 的结构
2.1 元数据不是一个段落,是几套互相独立的容器
很多人以为"EXIF 就是照片里的一小块信息",实际上一个图片文件里可能同时存在下面几套东西,它们各写各的、各删各的:
| EXIF(IFD0 / ExifIFD / GPS / IFD1) | 相机、手机 | 设备、参数、时间、GPS、缩略图 | 一般会被清 |
| XMP | Adobe 系列、部分手机 | 编辑历史、标签、评级、裁剪记录 | 容易漏 |
| IPTC | 图片社、编辑软件 | 标题、作者、版权、关键词 | 容易漏 |
| ICC Profile | 相机 / 修图软件 | 色彩空间配置 | 常被误删(影响颜色) |
| JFIF / APP0 | 编码库 | 密度、版本 | 一般无害 |
| 文件本身 | 文件系统 | 文件名、mtime、路径 | 与图片内容无关但同样泄露 |
这也是为什么只删 GPS 是不够的:GPS 清了,XMP 里可能还留着一份位置,IPTC 里还留着作者名。
2.2 EXIF 内部的四个 IFD,用真实输出看最直观
用 exiftool -a -G1 -s 看分组(-G1 显示一级组名,-a 允许重复标签,-s 输出短名)。下面是对一张程序自造的测试图的真实输出节选:
[File] FileType : JPEG
[File] ExifByteOrder : Big-endian (Motorola, MM)
[JFIF] JFIFVersion : 1.01
[IFD0] Make : DemoCam
[IFD0] Model : DC-1000
[IFD0] Software : DemoFW 1.2
[IFD0] ModifyDate : 2026:02:11 09:32:18
[IFD0] Artist : luosiqiang
[IFD0] Copyright : demo only
[ExifIFD] ExposureTime : 1/125
[ExifIFD] FNumber : 1.8
[ExifIFD] ISO : 200
[ExifIFD] DateTimeOriginal : 2026:02:11 09:32:18
[ExifIFD] UserComment : self-made test image
[ExifIFD] SerialNumber : SN-DEMO-0001
[ExifIFD] LensModel : 24mm f/1.8
[GPS] GPSLatitudeRef : North
[GPS] GPSLatitude : 39 deg 54' 15.12"
[GPS] GPSLongitudeRef : East
[GPS] GPSLongitude : 116 deg 24' 26.64"
[GPS] GPSAltitude : 44 m
[GPS] GPSTimeStamp : 09:32:18
[GPS] GPSDateStamp : 2026:02:11
[IFD1] ThumbnailOffset : 693
[IFD1] ThumbnailLength : 1485
[IFD1] ThumbnailImage : (Binary data 1485 bytes, use -b option to extract)
[Composite] GPSPosition : 39 deg 54' 15.12" N, 116 deg 24' 26.64" E
四个 IFD 的分工:
| IFD0(0th) | 图像本身与设备基本信息 | Make Model Software Artist Copyright ModifyDate |
| ExifIFD | 拍摄参数与原始时间 | DateTimeOriginal ExposureTime ISO LensModel SerialNumber |
| GPS IFD | 定位信息 | GPSLatitude GPSLongitude GPSAltitude GPSTimeStamp GPSDateStamp |
| IFD1(1st) | 缩略图 | ThumbnailOffset ThumbnailLength ThumbnailImage |
[Composite] 是 exiftool 自己算出来的派生标签(比如把度分秒拼成 GPSPosition),文件里并不实际存在,别把它当成"又多存了一份"。
【图 1】
2.3 哪些格式能带 EXIF
| JPEG / TIFF | ✅ 原生 | 最常见,APP1 段 |
| HEIC / HEIF | ✅ | iPhone 默认格式,需支持 HEIF 的工具 |
| PNG | ✅(eXIf 块) | 部分老工具读不到,容易误判"没有元数据" |
| WebP | ✅(RIFF/EXIF) | 支持程度依工具版本 |
| RAW(CR2/NEF/ARW/DNG) | ✅ 且字段极多 | 专业相机,常含机身序列号 |
| GIF / BMP | ❌ 基本不带 | 别在这类格式上白费劲 |
| 经过重编码的压缩图 | ⚠️ 视实现 | 有的清空、有的复制部分字段,不要赌 |
2.4 最容易被忽略的一项:IFD1 缩略图
很多"隐私清理"教程只讲 GPS,但 IFD1 里那张缩略图才是隐蔽大户。它的原始用途是给相机屏幕和文件管理器快速预览用,所以往往是裁剪、修图之前的原始画面。
我实测了一个典型场景:把一张 1200×800 的图(角落写着一行文字)裁成 600×400 的"发布版",但保留带 IFD1 缩略图的 EXIF:
主图(已裁剪): (600, 400), 9360 bytes
导出缩略图 : (320, 213), 4669 bytes
用一行命令就能把缩略图抠出来:
exiftool -b -ThumbnailImage cropped.jpg > thumbnail.jpg
导出的 320×213 缩略图里,完整画面连同角落那行被"裁掉"的文字都还在。也就是说,你以为裁掉了敏感内容,只要 EXIF 没清,原画面还挂在文件里。这一条在第六节的清理方案里必须覆盖。
三、四条读取路径:从零配置到工程化
按投入从低到高排,四条路径各有适用场景。
| A 图形界面 | 零 | 临时看一两张 | ❌ 只显示部分字段 |
| B exiftool | 装一个包 | 日常核查、批量、导出缩略图 | ✅ 最全 |
| C Python(Pillow / piexif / exifread) | 装库 | 集成进脚本、自动化 | ✅ 可编程 |
| D 在线查看网站 | 零 | —— | ⚠️ 不推荐,见 3.4 |
3.1 路径 A:系统自带,先看个大概
| Windows | 右键文件 → 属性 → 详细信息 | 相机型号、拍摄时间、部分 GPS(Win10/11 常显示为度分秒) |
| macOS | 预览 → 工具 → 显示检查器 → GPS 标签页 | 经纬度、海拔,可直接跳地图 |
| iOS / Android | 相册 → 详情 / 信息 | 拍摄时间、位置(部分机型) |
| Linux | file、identify -verbose(ImageMagick) | 基础信息 |
局限:图形界面通常只渲染一部分标准字段,XMP、IPTC、MakerNote、IFD1 缩略图这些基本看不到。所以它适合"确认有没有",不适合"确认清没清干净"。
3.2 路径 B:exiftool,事实标准
安装:
# Debian / Ubuntu
sudo apt-get install -y libimage-exiftool-perl
# macOS
brew install exiftool
# 验证
exiftool -ver # 本文实测:12.76
常用命令表(建议直接收藏):
| 看全部 | exiftool photo.jpg |
| 看分组 + 短名(排查最常用) | exiftool -a -G1 -s photo.jpg |
| 只看 GPS | exiftool -GPS* -s photo.jpg |
| GPS 输出十进制度 | exiftool -GPSLatitude -GPSLongitude -n -s photo.jpg |
| 自定义坐标格式 | exiftool -c "%.6f" -GPSPosition photo.jpg |
| 时间格式化 | exiftool -d "%Y-%m-%d %H:%M:%S" -DateTimeOriginal photo.jpg |
| 导出缩略图 | exiftool -b -ThumbnailImage photo.jpg > thumb.jpg |
| 批量导出成 CSV | exiftool -csv -r ./photos > report.csv |
| 批量按时间重命名 | exiftool "-FileName<DateTimeOriginal" -d "%Y%m%d_%H%M%S%%-c.%%e" ./photos |
| 只删 GPS | exiftool -gps:all= -overwrite_original photo.jpg |
| 全清 | exiftool -all= -overwrite_original photo.jpg |
小技巧:-n 会关闭"友好格式化",直接输出数值,写脚本时必加。实测:
$ exiftool -a -G1 -s -n -GPSLatitude -GPSLongitude -GPSAltitude exif_demo.jpg
[GPS] GPSLatitude : 39.9042
[Composite] GPSLatitude : 39.9042
[GPS] GPSLongitude : 116.4074
[Composite] GPSLongitude : 116.4074
[GPS] GPSAltitude : 44
[Composite] GPSAltitude : 44
不加 -n 时是 39 deg 54' 15.12" 这种度分秒文本,程序不好解析——这是新手写批处理时最常踩的坑。
3.3 路径 C:Python 三件套
先装库:
python3 -m pip install Pillow piexif exifread
三个库定位不同,别混着用:
| Pillow | 零额外依赖、能同时处理像素 | GPS 需自己按 tag 号解析 | 快速读、边读边处理图像 |
| piexif | 读写 EXIF 结构最完整,能无损删除 | 只管 EXIF | 清理、构造、精确修改 |
| exifread | 容错好、字段友好、读 RAW 方便 | 只读、不擅长写 | 批量扫目录、脏文件容错 |
C1 · Pillow:十行拿到 GPS 原始值
from PIL import Image, ExifTags
GPS_IFD = 34853 # ExifTags.IFD.GPSInfo 的数值
with Image.open("photo.jpg") as im:
exif = im.getexif()
print("== IFD0 / 基础 ==")
for tag_id, value in exif.items():
name = ExifTags.TAGS.get(tag_id, tag_id)
print(f" {name:<20}: {value}")
gps = exif.get_ifd(GPS_IFD) # Pillow ≥ 8.2 支持
print("== GPS IFD ==")
for tag_id, value in gps.items():
name = ExifTags.GPSTAGS.get(tag_id, tag_id)
print(f" {name:<20}: {value}")
对同一张测试图的真实输出:
== IFD0 / 基础 ==
Make : DemoCam
Model : DC-1000
Software : DemoFW 1.2
DateTime : 2026:02:11 09:32:18
Artist : luosiqiang
Copyright : demo only
GPSInfo : 392
ExifOffset : 218
== GPS IFD ==
GPSLatitudeRef : N
GPSLatitude : (39.0, 54.0, 15.12)
GPSLongitudeRef : E
GPSLongitude : (116.0, 24.0, 26.64)
GPSAltitudeRef : b'\\x00'
GPSAltitude : 44.0
GPSTimeStamp : (9.0, 32.0, 18.0)
GPSDateStamp : 2026:02:11
注意 GPSInfo : 392 是个偏移量而不是数据本身,必须 get_ifd() 进去才拿得到真的标签——这个数字骗过不少人。
C2 · piexif:能读能删,结构最清楚
import piexif
d = piexif.load("photo.jpg")
for ifd in ("0th", "Exif", "GPS", "1st"):
print(f"[{ifd}] {len(d[ifd])} tags")
print("缩略图字节数:", len(d.get("thumbnail") or b""))
print("厂商:", d["0th"].get(piexif.ImageIFD.Make))
print("原始时间:", d["Exif"].get(piexif.ExifIFD.DateTimeOriginal))
print("GPS 原始:", d["GPS"].get(piexif.GPSIFD.GPSLatitude))
真实输出:
[0th] 8 tags
[Exif] 7 tags
[GPS] 8 tags
[1st] 3 tags
缩略图字节数: 1485
厂商: b'DemoCam'
原始时间: b'2026:02:11 09:32:18'
GPS 原始: ((39, 1), (54, 1), (378, 25))
注意 GPS 是三个有理数(分子, 分母),不是浮点数。(378, 25) 表示 15.12 秒。下一节专门讲怎么换算。
C3 · exifread:批量扫目录最省事
import exifread
with open("photo.jpg", "rb") as fh:
tags = exifread.process_file(fh, details=False)
for k in ("Image Make", "Image Model", "EXIF DateTimeOriginal",
"GPS GPSLatitude", "GPS GPSLatitudeRef", "GPS GPSLongitude"):
print(f"{k:<26}: {tags.get(k)}")
details=False 会跳过缩略图等大块内容,扫几千张图时明显更快;文件损坏时它也比另外两个更不容易抛异常。
C4 · 一个可以直接抄走的"发图前自查"函数
把上面的能力收成一个函数,按风险分级打印,发图前跑一遍就知道这张能不能发:
from PIL import Image, ExifTags
from pathlib import Path
HIGH = {"GPSInfo", "GPSLatitude", "GPSLongitude", "GPSDateStamp", "GPSTimeStamp",
"DateTimeOriginal", "DateTime", "Artist", "Copyright",
"BodySerialNumber", "SerialNumber", "UserComment"}
MID = {"Make", "Model", "Software", "LensModel", "ProcessingSoftware"}
def audit(path):
p = Path(path)
out = {"高危": [], "中危": [], "其他": []}
with Image.open(p) as im:
ex = im.getexif()
named = {ExifTags.TAGS.get(k, str(k)): v for k, v in ex.items()}
gps = ex.get_ifd(34853) if 34853 in ex else {}
# Pillow 用 GPSTAGS 映射 GPS IFD 的标签号
named.update({ExifTags.GPSTAGS.get(k, str(k)): v
for k, v in gps.items()})
# 注意:IFD1 缩略图 Pillow 读不到,需按 6.2 用 exiftool 单独复核
for k, v in named.items():
if k in HIGH:
out["高危"].append((k, v))
elif k in MID:
out["中危"].append((k, v))
else:
out["其他"].append((k, v))
print(f"📄 {p.name}")
for level in ("高危", "中危", "其他"):
print(f" [{level}] {len(out[level])} 项")
for k, v in out[level][:6]:
print(f" – {k}: {str(v)[:60]}")
return out
if __name__ == "__main__":
import sys
for f in sys.argv[1:]:
audit(f)
对同一张测试图的真实输出(节选):
📄 exif_demo.jpg
[高危] 4 项
– GPSInfo: 392
– DateTime: 2026:02:11 09:32:18
– Copyright: demo only
– Artist: luosiqiang
[中危] 3 项
– Make: DemoCam
– Model: DC-1000
– Software: DemoFW 1.2
[其他] 11 项
– GPSLatitude: (39.0, 54.0, 15.12)
– GPSLongitudeRef: E
GPSInfo: 392 里的 392 是 GPS IFD 在文件中的偏移量,不是坐标值;函数内部用 get_ifd(34853) 已经把真实标签取出来并合并了,所以看到这一项就可以判定"这张图带定位信息"。
这段是审查骨架,生产环境建议再加上 XMP/IPTC 检查(见 3.2 的 -a -G1 全量输出)和统一的清理动作,别只靠 Pillow。
3.4 路径 D:在线 EXIF 查看网站——本文不推荐
搜"EXIF 查看"能搜到一堆网页工具,点一下确实方便,但有个绕不过去的逻辑问题:
你把一张可能含敏感信息的照片,上传到了一个第三方服务器,就为了看看它含不含敏感信息。
这本身就是一次数据外发。对方是否留存、是否用于训练、传输是否加密,你都无法验证。所以:
- 自查用本地 exiftool 或本地脚本,零外发;
- 确需用在线工具,只传已经清理过、不含任何敏感内容的样图;
- 涉及工作素材、客户资料、证件、未公开现场照片时,一律本地处理。
四、GPS 怎么从"三个分数"变成地图坐标
这是全文最常被搜到的一段,也是最容易算错的一段。
【图 2】
4.1 存储格式:三个 rational + 两个方向参考
EXIF 里纬度/经度不是浮点数,而是度、分、秒各一个有理数,再加一个方向字母:
GPSLatitudeRef : b'N'
GPSLatitude : ((39, 1), (54, 1), (378, 25)) # 39° 54' 15.12"
GPSLongitudeRef : b'E'
GPSLongitude : ((116, 1), (24, 1), (666, 25)) # 116° 24' 26.64"
(378, 25) = 378/25 = 15.12 秒。方向由 GPSLatitudeRef(N/S)和 GPSLongitudeRef(E/W)决定,南纬、西经要取负——这一步漏了,坐标会跑到地球另一边。
4.2 换算代码(可直接跑)
import piexif
def rational_to_float(r):
"""piexif 的 (numerator, denominator) → float"""
return r[0] / r[1] if isinstance(r, tuple) else float(r)
def dms_to_decimal(dms, ref):
"""[(度), (分), (秒)] + 方向 → 十进制度"""
deg = rational_to_float(dms[0])
minute = rational_to_float(dms[1])
second = rational_to_float(dms[2])
val = deg + minute / 60 + second / 3600
return -val if ref in (b"S", b"W", "S", "W") else val
def read_gps(path):
g = piexif.load(path)["GPS"]
if piexif.GPSIFD.GPSLatitude not in g:
return None
lat = dms_to_decimal(g[piexif.GPSIFD.GPSLatitude],
g.get(piexif.GPSIFD.GPSLatitudeRef, b"N"))
lon = dms_to_decimal(g[piexif.GPSIFD.GPSLongitude],
g.get(piexif.GPSIFD.GPSLongitudeRef, b"E"))
alt = None
if piexif.GPSIFD.GPSAltitude in g:
alt = rational_to_float(g[piexif.GPSIFD.GPSAltitude])
if g.get(piexif.GPSIFD.GPSAltitudeRef) == 1:
alt = -alt
return round(lat, 6), round(lon, 6), alt
if __name__ == "__main__":
print(read_gps("photo.jpg"))
对测试图的真实输出(print(read_gps(…)) 返回 (纬度, 经度, 海拔)):
read_gps() = (39.9042, 116.4074, 44.0)
对照一下换算过程,输入是 4.1 那三个有理数:
GPSLatitude 原始 : ((39, 1), (54, 1), (378, 25)) # 39° 54' 15.12"
GPSLatitudeRef : b'N'
deg=39 minute=54 second=378/25=15.12
→ 39 + 54/60 + 15.12/3600 = 39.904200
把 return 里的 round(…, 6) 去掉就是完整精度;做取证记录时建议保留原始 rational 值,避免反复舍入。
4.3 一个国内读者必踩的坑:WGS84 ≠ GCJ-02
EXIF 里的坐标是设备按 WGS84(GPS 原始坐标系)写入的。而国内主流地图服务用的是加密偏移后的坐标系:
| Google 地图(境外)、OSM | WGS84 | 正确 |
| 高德、腾讯地图 | GCJ-02("火星坐标") | 偏移几百米 |
| 百度地图 | BD-09(在 GCJ-02 上再偏一次) | 偏移更大 |
| 设备 GPS 原始值 | WGS84 | 基准 |
我实测了北京公开地标这一点(WGS84 116.407400, 39.904200)的转换结果:
EXIF 里存的是 WGS84 : lng=116.407400, lat=39.904200
转 GCJ-02(高德/腾讯) : lng=116.413642, lat=39.905603
不转换直接贴国内地图,约偏移: 东 533 m / 北 155 m / 直线约 555 m
555 米——足够让一个"定位到某栋楼"的结论变成"定位到隔壁街区"。写取证报告或做位置还原时,这个偏差必须说明,否则结论站不住。
标准转换函数(WGS84 → GCJ-02):
import math
def wgs84_to_gcj02(lng, lat):
a, ee = 6378245.0, 0.00669342162296594323
def tlat(x, y):
r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x))
r += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0
r += (20.0*math.sin(y*math.pi) + 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0
r += (160.0*math.sin(y/12.0*math.pi) + 320*math.sin(y*math.pi/30.0)) * 2.0/3.0
return r
def tlng(x, y):
r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x))
r += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0
r += (20.0*math.sin(x*math.pi) + 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0
r += (150.0*math.sin(x/12.0*math.pi) + 300.0*math.sin(x/30.0*math.pi)) * 2.0/3.0
return r
dlat, dlng = tlat(lng – 105.0, lat – 35.0), tlng(lng – 105.0, lat – 35.0)
radlat = lat / 180.0 * math.pi
magic = 1 – ee * math.sin(radlat) ** 2
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((a * (1 – ee)) / (magic * sqrtmagic) * math.pi)
dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi)
return round(lng + dlng, 6), round(lat + dlat, 6)
生成地图链接时,按目标服务选坐标系:
lat, lon, _ = read_gps("photo.jpg")
g_lng, g_lat = wgs84_to_gcj02(lon, lat)
print("高德:", f"https://uri.amap.com/marker?position={g_lng},{g_lat}")
print("OSM :", f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}")
4.4 三个配套的注意点
- GPSTimeStamp 是 UTC,DateTimeOriginal 通常是设备本地时间,两者可能差 8 小时。做时间线时先统一时区,别把"早上 9 点"读成"下午 5 点"。
- GPSAltitudeRef:0 表示海平面以上,1 表示以下,别忘了取负。
- 精度不是无限的:民用 GPS 常见几米到十几米误差,室内/高楼区更差;GPSHPositioningError 等字段(部分新机型有)会给估计误差。写结论时不要写成"精确到门牌号"。
五、三个实战场景(均为自查 / 授权取证)
5.1 场景一:发图前自查——十秒钟决定"这张能不能发"
最容易落地的用法。把 3.3 的 audit() 挂成一个 shell 别名或文件右键动作,发原图之前跑一下:
python3 exif_audit.py IMG_20260211_093218.jpg
输出会直接告诉你有没有高危字段。判定规则建议这样定:
| 有 GPS* / Artist / Copyright / SerialNumber | 先清理再发 |
| 只有 Make / Model / 曝光参数 | 视发布范围决定,对外建议一并清 |
| 有 IFD1 缩略图 | 必须清,见 2.4 |
| 什么都没有 | 可发,但仍建议确认是"真没有"而不是"工具没读到" |
提醒:这里的"什么都没有"要用 exiftool -a -G1 -s 复核一次。PNG 的 eXIf 块、XMP 段,Pillow 不一定读得到(见第七节)。
5.2 场景二:授权取证里的批量时间线还原
这是合法取证里非常标准的一类用途:手上有一批已获授权的图片(比如自家监控导出、企业内部调查中依法取得的材料),需要还原事件先后顺序。
# 递归导出全部元数据到 CSV,时间统一格式
exiftool -csv -r -d "%Y-%m-%d %H:%M:%S" \\
-FileName -DateTimeOriginal -GPSLatitude -GPSLongitude \\
./evidence > timeline.csv
再用几行 Python 排序:
import csv
from datetime import datetime
rows = []
with open("timeline.csv", encoding="utf-8-sig") as fh:
for r in csv.DictReader(fh):
t = r.get("DateTimeOriginal", "").strip()
if not t:
continue
try:
dt = datetime.strptime(t, "%Y-%m-%d %H:%M:%S")
except ValueError:
continue
rows.append((dt, r["SourceFile"], r.get("GPSLatitude", ""),
r.get("GPSLongitude", "")))
for dt, name, lat, lon in sorted(rows)[:20]:
print(f"{dt} {name} ({lat}, {lon})")
取证场景的三条硬要求(比代码重要):
5.3 场景三:缩略图复核——你以为裁掉的,其实还在
2.4 讲了原理,这里给完整复核流程:
# 1) 看有没有缩略图
exiftool -a -G1 -s photo.jpg | grep -i thumbnail
# [IFD1] ThumbnailLength : 1485
# [IFD1] ThumbnailImage : (Binary data 1485 bytes, use -b option to extract)
# 2) 导出来
exiftool -b -ThumbnailImage photo.jpg > thumb.jpg
# 3) 看一眼(人眼判断有没有被裁掉的敏感内容)
file thumb.jpg
实测案例数据:
主图(已裁剪): (600, 400), 9360 bytes
导出缩略图 : (320, 213), 4669 bytes
缩略图里仍能看到完整画面(含角落文字)
结论:裁剪 ≠ 清除。任何"我把敏感区域裁掉就安全了"的判断都不成立,除非同时剥离 EXIF。
5.4 顺带澄清一个反例
不是所有图片都带 GPS。系统截图、大部分压缩后的聊天图、某些编辑软件导出的图通常没有定位信息。所以看到"照片能定位"不要反向推成"任何一张图都能定位"——先读,再判断,别凭印象下结论。这也是自查脚本存在的意义。
六、清理与防护:把剥离做成默认动作
6.1 三种清理方式,实测对比
用同一张 26151 字节的测试图,三种方式各跑一遍,再用 exiftool 复核残留敏感标签数:
| 原图 | —— | 26151 B | 17 | —— | ✅ |
| piexif.remove() | 见下 | 23993 B | 0 | ❌ 无损 | ✅ |
| exiftool -all= | 见下 | 23975 B | 0 | ❌ 无损 | ⚠️ 默认会删,可加 -icc_profile:all 保留 |
| Pillow 重存 | 见下 | 23884 B | 0 | ✅ 有损 | ❌ 丢失 |
真实复核输出:
原图 26151 bytes 残留敏感标签: 17
piexif.remove() 23993 bytes 残留敏感标签: 0 无 ✅
exiftool -all= 23975 bytes 残留敏感标签: 0 无 ✅
Pillow 重存 23884 bytes 残留敏感标签: 0 无 ✅
【图 3】
方式 1 · piexif.remove()(无损,Python 内首选)
import piexif
piexif.remove("photo.jpg", "photo_clean.jpg") # 只删 EXIF 段,不改像素
优点:不动像素、不重编码、画质零损失,适合脚本里逐张处理。
局限:只管 EXIF,XMP / IPTC 需要另外处理。
方式 2 · exiftool -all=(最彻底,批量首选)
# 单张(原地覆盖,先备份!)
exiftool -all= -overwrite_original photo.jpg
# 批量,同时保留色彩配置避免偏色
exiftool -all= -tagsfromfile @ -icc_profile:all \\
-overwrite_original -r ./photos
# 只删 GPS,保留其余参数(修图工作流常用)
exiftool -gps:all= -overwrite_original photo.jpg
-all= 会连 XMP、IPTC、缩略图一起清掉,是最不容易漏的一项。注意 -overwrite_original 会直接覆盖原文件,不加这个参数时 exiftool 会生成 photo.jpg_original 备份——生产环境建议先不加,确认没问题再删备份。
方式 3 · Pillow 重存(不推荐,除非你本来就要转码)
from PIL import Image
im = Image.open("photo.jpg")
im.save("photo_clean.jpg", "JPEG", quality=92) # 不传 exif= 即丢弃
会重新编码,有画质损失,且 ICC 一并丢失可能偏色。只在你本来就要缩放/转格式时顺带用,不要专门拿它当清理手段。
6.2 清理之后必须复核
清理动作本身也可能失败(文件被占用、格式不支持、只读),所以清理 + 复核要成对出现:
exiftool -a -G1 -s photo_clean.jpg | grep -Ei "GPS|Artist|Serial|Thumbnail|XMP|IPTC"
# 期望:无输出
把它写进脚本,失败就告警,别靠人眼。
6.3 服务端:把剥离做成上传网关的默认一步
个人自查靠自觉,组织级防护靠流程。一个最小可用的处理函数:
from PIL import Image
import piexif, io, hashlib
def sanitize_upload(raw: bytes, *, max_side: int = 2048) -> bytes:
"""接收上传字节,返回已剥离元数据 + 可选缩放的 JPEG 字节。
设计要点:原始字节不落盘;只把清理后的版本交给存储/CDN。"""
im = Image.open(io.BytesIO(raw))
im = im.convert("RGB") # 去掉 alpha / 调色板等边角情况
if max(im.size) > max_side: # 顺带缩图,缩小攻击面
im.thumbnail((max_side, max_side), Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, "JPEG", quality=88, optimize=True) # 不写 exif / ICC 之外的元数据
clean = buf.getvalue()
# 复核:输出不含任何 EXIF / GPS 段,失败即拒绝入库(实测通过)
leftover = piexif.load(clean)
assert not leftover["GPS"] and not leftover["0th"] and not leftover["Exif"], \\
"元数据清理失败,禁止入库"
return clean
实测:26151 bytes 输入 → 11348 bytes 输出,piexif.load() 六个 IFD 全为 0,复核断言通过。注意这个函数为了重编码会损失画质,且会丢 ICC;业务若必须保真,改用 6.1 的 exiftool -all=(无损)并在网关层调用,而不是在 Python 里转码。
工程上四条建议:
6.4 拍摄侧:从源头关掉定位
| iOS | 设置 → 隐私与安全性 → 定位服务 → 相机 → 设为「永不」或「使用期间」按需 |
| Android | 相机 App 设置里关「位置标签 / GPS 标记」;系统层面可收回相机定位权限 |
| 相机 | 菜单里关「GPS 记录 / 地理标记」,外接 GPS 模块同理 |
| 微信 / 邮件 / 网盘 | 发送时不选「原图」;确需原图,先清理再传 |
| 截图 / 录屏 | 一般无 GPS,但注意画面内容本身,以及文件名的可识别信息 |
6.5 发布侧清单(可直接抄进 SOP)
- ☐ 发图前跑一次本地自查脚本,高危字段清零
- ☐ 需要发原图时,先 exiftool -all= 再传
- ☐ 复核一次清理结果(-a -G1 -s | grep,期望无输出)
- ☐ 确认没有 IFD1 缩略图残留
- ☐ 文件名不含姓名、工号、地址、日期等可识别信息(IMG_张三_20260211.jpg 这类要改)
- ☐ 组织场景:确认上传网关已默认剥离,且线上抽查通过
七、十个常见坑
| 1 | 「社交平台会帮我清,不用管」 | 不要赌。不同平台、不同版本、原图/非原图行为不一致,一律自己清 |
| 2 | 清完发现颜色变了 | -all= 会连带删 ICC;需要时用 -tagsfromfile @ -icc_profile:all 保留或重写 sRGB |
| 3 | 换 HEIC / PNG / WebP 后工具读不到 | 换支持该容器的工具(exiftool 覆盖最全),别误判成"没有元数据" |
| 4 | 只处理了 .jpg,漏了视频 / Live Photo | MOV/MP4 有独立元数据容器,Live Photo 是"图+视频"成对,要单独处理 |
| 5 | 以为截图一定有 GPS | 多数截图没有定位,先读再判断 |
| 6 | 时间差 8 小时 | GPSTimeStamp 是 UTC,DateTimeOriginal 常为本地时间,统一时区再排序 |
| 7 | 云端同步 / 修图软件又写回元数据 | 清理放在最后一步(发布前),而不是流程开头 |
| 8 | 用 Windows「属性」确认"已清空" | 图形界面看不到 XMP/IPTC/缩略图,用 exiftool -a -G1 -s 复核 |
| 9 | 只删 GPS,忘了 XMP/IPTC | 用 -all=,或逐项确认三类容器都清了 |
| 10 | 以为裁剪、压缩就等于清除 | 有些工具会复制元数据到新文件;裁剪对 IFD1 缩略图尤其无效(见 2.4) |
八、合规与边界:这一节比代码重要
技术是中性的,但用在哪、对谁用,决定了它合不合规。这一节请当硬性约束读。
8.1 法律层面:GPS 属于敏感个人信息
按《中华人民共和国个人信息保护法》,行踪轨迹属于敏感个人信息,处理需满足特定目的、充分必要、单独同意与严格保护措施。一张带 GPS + 精确时间戳的照片,结合设备序列号、作者字段,具备识别特定自然人的能力,不能当普通"技术参数"看待。
要点:
- 处理他人照片的元数据,需有合法基础(本人同意、合同履行、法定义务或依法授权的调查程序);
- 组织内部做合规审查,要有制度依据与授权范围,并把处理记录留痕;
- 本文不提供法律意见,涉及具体案件请咨询专业律师。
8.2 本文支持与不支持的用法
| 检查自己照片发出去会带什么 | 用他人照片定位其住址、单位、常去地点 |
| 组织内对自有素材做合规审查 | 跟踪、蹲守、骚扰、人肉 |
| 合法授权下的取证与证据固定 | 批量抓取社交平台公开图片做人物画像/轨迹还原 |
| 在上传流水线里默认剥离元数据 | 绕过平台限制获取他人原图 |
| 科普、教学、防御性研究 | 把提取结果公开发布或用于交易 |
一句话:读取能力只用于"看清自己暴露了什么"和"有授权的调查",不用于"看清别人在哪"。
8.3 取证场景的额外要求
- 授权链书面化:谁授权、范围、期限、用途;
- 证据保全:原件只读、先算 SHA-256、工作副本分析、全程记录工具版本与命令;
- 最小必要:只提取与案件相关的字段,不做无关扩散;
- 结论表述:注明坐标系、误差范围、时区,避免"精确定位到某人"这类超出证据能力的断言。
8.4 内容层面的合规(发这篇文章本身也要守)
| AI 参与程度声明 | 如实标注,不标「纯原创」 | front matter ai_assisted: true,正文说明程序生成测试图 |
| AIGC 标识 | 2025-09-01 起,AI 生成的文本/图片/音视频须显式标识 + 隐式元数据,不得删改 | 本文插图为程序绘制示意图,非 AI 生成写实内容;如后续替换为 AI 图须显式标注 |
| 信息来源标注 | 引用标准/法规/工具版本须标来源 | 已标注工具版本、法规名称、实测环境 |
| 虚构 / 演绎标识 | 演示数据须说明「示例/自造」 | 测试图、坐标、设备型号均为自造,开头已声明 |
| 不提供可复用攻击方法 | 平台专项红线 | 全文仅读取、核查、清理与加固,无入侵/绕过步骤 |
| 广告标识 | 带货/测评附链接须标「广告」 | 本文无带货、无购物链接,不涉及 |
平台规则与法规会更新(例如 AIGC 标识细则、各平台对元数据处理的口径),以官方最新发布为准。
九、写在最后
把全文收成一句话:
元数据是「默认写入、默认不显眼、默认跟着文件走」的东西。所以防护不能靠提醒,得靠流程。
三件最值得先做的事:
顺带回应一个常被问到的问题:清理元数据会不会让照片失去价值? 对绝大多数发布场景,不会。EXIF 里的曝光参数、机身序列号、缩略图,对读者没有任何意义;真正需要保留拍摄参数的场景(摄影作品归档、取证原件),把元数据单独导出成结构化记录保存即可,不需要让它跟着对外发布的图片一起走。把「展示用副本」和「归档用原件」分开,是最干净的解法。
落地清单
| P0 | 本地安装 exiftool,对最近要发的图跑一次 -a -G1 -s | 自查 | 先看清现状 |
| P0 | 发原图一律 exiftool -all= + 复核 | 发布 | 高危字段归零 |
| P0 | 检查 IFD1 缩略图,裁剪图也要清 | 发布 | 堵住最易漏的口子 |
| P1 | 把 3.3 的自查脚本挂进日常发布流程 | 工具 | 从靠自觉变成靠流程 |
| P1 | 拍摄端关闭相机定位权限 | 源头 | 少写进去一份 |
| P1 | 文件名去掉姓名/工号/地址 | 发布 | 降低关联识别 |
| P2 | 上传网关默认剥离元数据,原件不落盘 | 工程 | 组织级收敛 |
| P2 | 线上图片每周随机抽查 + 告警 | 运营 | 防绕过、防回归 |
| P2 | 归档原件与展示副本分离,元数据单独存档 | 治理 | 兼顾取证与发布 |
延伸方向:这一篇讲的是图片。同一套思路可以平移到 PDF / Office 文档元数据(作者、修订记录、隐藏批注、打印路径)、音视频容器元数据(MOV/MP4 的 moov 地理标签)、以及日志与截图里的隐性信息(时间戳、主机名、内网 IP)。如果要做成系列,下一篇适合写「文档文件里的隐藏信息」。
合规说明:本文所有命令均为读取、核查、清理与加固操作,不含可对他人实施定位、跟踪或绕过平台限制的方法;示例数据与坐标均为自造或公开地标,不指向任何个人。涉及个人信息处理与取证的具体场景,请以现行法律法规与官方最新规则为准,本文不构成法律意见。





