移除照片 Exif 中的 GPSInfo
氣息遮斷 A+

Category Tech

最近聽了 Podcast What's on Django TV tonight?Pre-commit Hooks for Python Devs
發現了 exif-stripper 這個酷東西

而且開發者竟然是今年有來 PyCon TW 的 Stefanie ⁉️
在 PyCon JP 的會後會也有稍微跟她聊到一下天

另外還要感謝 Stefanie 在 節目中 有提到邊緣專案 commitizen
她應該沒想到 PyCon TW 的 Development Sprint 坐她後面的就是 commitizen 的維護者吧 😆
雖然那次我是帶 airflow 就是了

進入正題

Exif 是什麼?
簡單來說它是照片中儲存 metadata 的格式
除了照片本身外,照片的檔案通常很多的 metadata
裡面可能會記錄在什麼時候拍的,用什麼裝置拍的
最重要的是拍攝的地點在哪 (也就是標題提到的 GPSInfo)
洩漏 GPS 資訊到網路上就有可能讓有心人士抓到你的行蹤
Stefanie 也寫了 Mind Your Image Metadata 解釋為什麼這件事很重要

我對這件事一直都稍微有些注意
在匯出照片放上部落格的時候,都會確定我沒有選到 Location Information

export-location

我一直有在想應該要寫個工具自動確認並清除
如果有 pre-commit hook 就更好了
但我就爛,一直懶惰遲遲沒有動手
直到找到 exif-stripper

不過 exif-stripper 會把整個 Exif 都清掉
我覺得其他東西留著也還好
再加上當初在我的部落格引入 exif-stripper 的時候
可能哪裡設定錯了,圖片會一直被 pre-commit hook 修改,導致 pre-commit 的檢查永遠不會過
為了寫這篇文章,後來追了原始碼,看起來是不該發生啊 🤔
真的不知道當初怎麼了

不過我已經寫完自己的版本了,所以就想說算了

# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "pillow>=11.0.0",
# ]
# ///
from collections.abc import Sequence
import glob
import os

from PIL.Image import UnidentifiedImageError, open as pil_open
from PIL.ExifTags import Base as ExifBase


def get_exif_tag_ids_by_names(names: Sequence[str]) -> set[int]:
    return {getattr(ExifBase, name).value for name in names}


filenames = [path for path in glob.glob("content/images/**", recursive=True) if os.path.isfile(path)]
exif_tags_to_remove = ["GPSInfo"]
for name in filenames:
    try:
        with pil_open(name) as im:
            tag_ids = get_exif_tag_ids_by_names(exif_tags_to_remove)
            for tag_id in tag_ids:
                if im.getexif().get(tag_id) and im._exif:
                    im._exif[tag_id] = None
    except (FileNotFoundError, UnidentifiedImageError):
        continue

這個簡短的 script 有遵守 PEP 723 寫好相依套件
所以可以用 uv 直接跑
exif-stripper 最大的不同是我只有指定要清除掉 GPSInfo
雖然目前要清除的 Exif tag 跟檔案路徑是寫死的
但邏輯本身應該是蠻彈性的
之後有時間可以把它貢獻回 exif-stripper
目前我只有先把它加到我自己的部落格而已 Add task to remove gps from exif #39

為了想要只刪除 GPSInfo 我回去追了 Pillow 原始碼
發現 class Exif 本身繼承了 MutableMapping
運作起來會有點像是 dict
所以它的 clear 並非 Pillow 實作要去刪除哪些 Exif 的 tag
而是繼承了 MutableMapping 的 clear 把所有資料刪掉

def clear(self):
    "D.clear() -> None.  Remove all items from D."
    try:
        while True:
            self.popitem()
    except KeyError:
        pass

呼叫到的 popitem 則是透過 Pillow 實作的 __iter____getitem__ 決定要移除的資料

def popitem(self):
    """D.popitem() -> (k, v), remove and return some (key, value) pair
    as a 2-tuple; but raise KeyError if D is empty.
    """
    try:
        key = next(iter(self))
    except StopIteration:
        raise KeyError from None
    value = self[key]
    del self[key]
    return key, value

為什麼追著追著就追回 CPython
窩也不知道

氣息遮斷是什麼?

氣息遮斷是 Fate 裡面 Assassin 職階大多具有且等級較高的能力
顧名思義就是一種消除氣息讓自己好進行暗殺的能力
當然邊緣人如我也是自帶這種消除存在感的能力
但我沒有要參加聖杯戰爭,所以也沒什麼用

Assassin

不過透過這篇文章學到如何把照片中的 GPS 資訊刪除
避免太常被奇怪的人麥當勞歡樂送,就能得到氣息遮斷 B 的證書哦(並沒有