我用脚本造了一张测试照片,4000×3000,左半边是米色墙面,上面写着「门牌 18-3」,右半边是蓝天和一个黄太阳。EXIF 里放了一组 GPS,坐标是虚构的海面点,另外照着相机的习惯塞了一张 160×120 的 IFD1 缩略图。然后做图片管线里最常见的一步:用 Pillow 裁掉左半,只留右边 2000×3000,为了「保留拍摄信息」,把原图的 EXIF 字节原样传给 save(exif=...)。结果图里门牌确实没了。可是用 piexif 把 IFD1 那张缩略图读出来放大,门牌还在,是裁剪前的整幅画面,同一个文件里的 PixelXDimension 也还写着 4000。
自造样本总会被问一句「真文件也这样吗」。我从 Wikimedia Commons 找了一张 CC0 照片「Avions à l'aéroport de Madrid - 2015」,iPhone 6 拍的,3264×2448,EXIF 64 个字段,GPS 纬度 40°29′27.76″N,是马德里机场的停机坪。同样只留左半 1632×2448,原 EXIF 写回:缩略图仍是 160×120 的整幅停机坪,PixelXDimension 停在 3264,GPS 的 14 个字段一个没少。两份样本表现一致,说明不是我样本造得巧,是 EXIF 的结构决定的。

EXIF 在 JPEG 里分几块
我做过三年编解码,看这类问题的习惯是先把字节摊开。EXIF 住在 JPEG 的 APP1 段,以 Exif\0\0 开头,后面是一个完整的 TIFF 结构。IFD0 放主图的 Make、Model、Orientation,里面有两个指针,一个指向 Exif IFD(拍摄时间、PixelXDimension/YDimension 在这里),一个指向 GPS IFD。IFD0 末尾还有一个「下一个 IFD 偏移」,指向 IFD1,IFD1 用 0x0201 和 0x0202 两个标签记下一段 JPEG 数据的偏移和长度,那就是缩略图。Exif 2.32 第 4.5.4 节原文是 "The 1st IFD may be used to record a thumbnail image.",用词是 may,缩略图可有可无,没有时 IFD0 的下一个偏移写 0。规范还限定整个 APP1 不超过 64 KB,所以缩略图都很小,马德里那张 12,258 字节,我的样本只有 1,452 字节。
站在解码器这边看,IFD1 里那段数据是一条独立的 JPEG 码流,挂在另一条 JPEG 码流的头部。主图的像素解码走的是后面的扫描数据,用不到这张缩略图,你对像素做的裁剪、缩放、调色都碰不到它。它和主图唯一的关系是当初相机同时生成了两者,之后谁也不管谁。顺手记一个细节:马德里那张的 IFD1 有 6 项,Compression=6、72 dpi 的分辨率都老老实实写着,我造的样本 IFD1 只有偏移和长度两项,解析器一样认。
带不带过去,看管线怎么写
「重编码一次元数据就没了」只对一部分管线成立。我拿另一张脚本造的样本(34 个 EXIF 字段,Orientation=6,4032×3024)跑过一轮:Pillow 默认 save() 出来 0 个字段,转 PNG、WebP 也是 0;Chromium 里 drawImage 到 canvas 再 toBlob,EXIF、GPS、XMP 一样都不带。macOS 的 sips 反过来,转 JPG 34 个字段全在,-Z 1600 缩放后还剩 33 个,GPS 都在。sips 裁剪那张门牌样本,GPS 留着,缩略图丢了;马德里那张 sips -Z 1200 后剩 57 个字段,GPS 在,缩略图没了。同样叫「处理一下」,结果差得很远,所以我不按工具名猜,都读回产物看。
保留元数据从来是某一行代码的显式选择,麻烦在最省事的保留方式就是整块照抄。开头那两份样本的「照抄版」就是这么来的(节选):
python
raw = im.info["exif"] # APP1 里的整块 EXIF,原样照抄
im.crop(box_fn(im.size)).save(dst, "JPEG", quality=92, exif=raw)
im.info["exif"] 拿到的是 APP1 的原始字节,Pillow 写回时把它当不透明的一块,不知道你裁过图,也不会替你改任何字段。Orientation 是同一类坑:那张 Orientation=6 的样本,先 ImageOps.exif_transpose 把像素转正成 3024×4032,再把事先存下的原 EXIF 写回,标签还是 6,认标签的查看器会在已经转正的像素上再转 90°。浏览器 <img> 的 image-orientation 初始值就是 from-image(MDN),这种文件在网页上一样是歪的。
回写前逐项改
我现在不碰原始字节,用 piexif 把 EXIF 拆成 0th、Exif、GPS、1st 几组 dict,按处理结果逐项改完再 dump。需要动的是四样:像素转正后 Orientation 改成 1;PixelX/YDimension 改成输出的真实宽高;IFD1 缩略图用处理后的像素重做,或者整组删掉;GPS 按业务需要删。修正函数在 Python 3.9、Pillow 11.3、piexif 上跑过。前三样都是一两行赋值:exif_transpose 转正像素后把 0th 里的 Orientation 写成 1,Exif 组里的 PixelX/YDimension 写成 im.width、im.height,要删 GPS 就把 GPS 组置成空 dict。真正要多写几行的是缩略图(节选):
python
if thumb == "rebuild" and exif["thumbnail"]: # 原来有 IFD1 才重建
t = im.copy()
t.thumbnail((160, 160))
buf = io.BytesIO()
t.convert("RGB").save(buf, "JPEG", quality=75)
exif["thumbnail"] = buf.getvalue()
重建就是拿处理后的像素缩到 160 以内,编成一段 JPEG 塞回 thumbnail,piexif 在 dump 时会重新排好 IFD1 的偏移和长度;选择删的话,把 thumbnail 置空、1st 清成空 dict 即可。
我把原样照抄和修正两种写法分别跑在门牌样本、马德里那张和 Orientation=6 那张上,再逐个读回。照抄版就是开头那个问题:门牌样本像素 2000 宽,PixelX 还写着 4000,缩略图 160×120 横图配一张竖图;马德里那张像素 1632×2448,PixelX/Y 停在 3264×2448,GPS 14 个字段、160×120 缩略图都原样在。修正版两张的 PixelX/Y 都和像素一致,缩略图是 107×160,GPS 按默认保留;马德里那张再加上删 GPS、删缩略图,读回 GPS 0 个字段、没有缩略图;Orientation=6 那张转正后是 3024×4032,Orientation 归 1,ICC 也还在。107×160 的比例和裁剪后的竖图一致,但光看尺寸我还不放心,又取了缩略图左上区域的一个像素:照抄版是 (200, 180, 153),米色墙;修正版是 (120, 170, 219),蓝天。门牌那块确实不在了。
重建还是删,我看下游。压缩缩略图是 Exif 2.0(1997 年)才加进规范的,本来就是可选项,删掉不违反规范。我理解它当初主要服务相机回放、文件浏览这类快速预览,现在的查看器有多少还读它,我没逐个验证。字节上的差别倒是实打实:马德里那张原图 APP1 是 16,382 字节,重建缩略图后是 6,543 字节(新缩略图 4,563 字节,quality 75),缩略图和 GPS 都删掉只剩 1,589 字节。对一张七十多万字节的输出来说这点差别无所谓。输出要给人存档的,我留一张和主图一致的缩略图;要往外发的,我倾向于直接删,少一样东西就少一处要核对。
脚本第一版有个错。当时 rebuild 分支不看原图有没有 IFD1,一律生成,结果 Orientation=6 那张原本没有缩略图,被凭空加了一张 120×160。多数查看器不在乎,可「修正元数据」变成了「新增元数据」,改成原来有才重建。另外两处是跑出来才知道的:ICC 不在 EXIF 里,是单独的 APP2 段,只传 exif= 的话输出没有 ICC,所以要单独取 icc_profile 带上;清 GPS 时我起初还手动删 IFD0 里的 GPSTag 指针,实测 piexif 在 GPS 为空时会自己去掉它(马德里原图里这个指针是 1598,dump 后读回为 None),那行就删了。
没装 piexif 的机器,ExifTool 官方手册给了导出缩略图的写法 exiftool -b -ThumbnailImage image.jpg > thumbnail.jpg,也给了替换缩略图的 exiftool '-ThumbnailImage<=thumb.jpg' dst.jpg。我本机没装 ExifTool,这两条没跑过,是照手册抄的。查自家管线时它最省事:对输出文件导一张缩略图,打开看画面对不对。
还有一块我没拆,iPhone 的 MakerNote 是厂商私有格式,里面有没有和画面相关的数据,这次没验证,不下结论。我现在 review 图片处理代码,第一件事是全局搜 exif=,看后面跟的是原始字节还是改过的 dict,跟原始字节的都按这四项过一遍。