源码解读③:不依赖 PIL 手写 EXIF 解析——前任.skill 照片时间线提取原理
【免费下载链接】ex-skill前任 skill项目地址: https://gitcode.com/gh_mirrors/exsk/ex-skill
前任.skill(ex-skill)是一个把"前任"蒸馏成可对话 AI Skill 的开源项目,而 photo_analyzer.py 负责从照片的 EXIF 元数据中提取拍摄时间、生成一张"照片时间线"。本篇源码解读带你读懂它如何不依赖 PIL / Pillow,仅用 Python 标准库手写 EXIF 解析,几十行代码就从 JPEG 的原始字节里"钓"出了拍摄日期 📷
为什么要手写 EXIF 解析?
大多数同学提取照片拍摄时间,第一反应是from PIL import Image或装个piexif。但前任.skill 选择了另一条路:只 import 标准库(os、struct、re、datetime、pathlib)。
这样设计带来三个好处:
- 零安装:用户 clone 下来就能跑,
photo_analyzer.py不需要任何pip install。 - 跨平台稳:不挑 Python 版本,也不受 Pillow 编译、架构兼容的影响。
- 职责单一:它只关心"这张照片是什么时候拍的",不解析照片像素内容(内容分析交给 Claude 的
Read工具),所以能保持极轻量。
隐私说明:该工具只提取 EXIF 中的日期等元数据,不读取、不上传照片内容,所有处理都在本地完成。
requirements.txt里其实把 Pillow 作为可选依赖注释掉了,仅在需要更好支持 HEIC / TIFF 时才启用:
# Optional: Better EXIF support for more image formats (HEIC, TIFF, etc.) # Pillow>=10.0.0👉 参见 requirements.txt
先看懂 JPEG 结构:SOI、APP1 与 EOI
手写 EXIF 解析的前提,是理解 JPEG 文件的"分段"布局。一个 JPEG 由若干**段(segment)**拼接而成,每段都以0xFF开头的"标记"(marker)开始:
| 标记字节 | 名称 | 含义 |
|---|---|---|
FF D8 | SOI | Start Of Image,文件起始标记 |
FF E1 | APP1 | 应用段,EXIF 信息通常藏在这里 |
FF DA | SOS | Start Of Scan,真正的图像数据从这里开始 |
FF D9 | EOI | End Of Image,文件结束标记 |
每段标记之后,紧跟一个2 字节大端长度(它包含自身这 2 个字节)。所以一段的实际载荷长度是length - 2。
理解了这个结构,整个解析逻辑就清晰了:从 SOI 开始,一段一段往后跳,直到跳进 APP1,把里面的字节读出来找日期。
核心:逐段扫描的 while 循环
整个"找 EXIF"的过程浓缩在 extract_exif_date() 里。先看它如何校验文件头并逐段游走:
with open(file_path, "rb") as f: # 1) 校验 JPEG 起始标记,不是 JPEG 直接放弃 if f.read(2) != b"\xff\xd8": return None while True: marker = f.read(2) if marker[0] != 0xFF: break if marker[1] == 0xE1: # 2) 命中 APP1,EXIF 在这 length = struct.unpack(">H", f.read(2))[0] exif_data = f.read(length - 2) ... # 3) 在 exif_data 里找日期 elif marker[1] in (0xD9, 0xDA): # 4) 到 EOI / SOS,结束 break else: # 5) 其他段,读长度并跳过 length = struct.unpack(">H", f.read(2))[0] f.seek(length - 2, 1)三个关键细节:
struct.unpack(">H", ...):>表示大端(网络字节序),H表示无符号短整型(2 字节),正好读出段的长度。f.seek(length - 2, 1):1代表相对当前位置,把文件指针一次性跳过无关段,效率很高。- 外层
try/except直接pass:遇到任何异常(比如非 JPEG、文件损坏)都优雅地返回None,绝不中断整个扫描。
这段逻辑是全文的"骨架",对应 photo_analyzer.py。
用正则从原始字节里"钓"出拍摄日期
有意思的地方来了。拿到exif_data后,代码并没有老老实实去解析 TIFF 的 IFD 目录结构,而是直接在原始字节里正则匹配日期串:
# DateTimeOriginal 在 EXIF 里就是 "YYYY:MM:DD HH:MM:SS" date_pattern = rb"\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}" matches = re.findall(date_pattern, exif_data) if matches: return matches[0].decode("ascii")👉 参见 photo_analyzer.py
为什么这样能成?因为 EXIF 里的拍摄时间标签DateTimeOriginal(tag0x9003)本身就是按YYYY:MM:DD HH:MM:SS这个 ASCII 格式存储的。与其费劲解析二进制结构,不如用一行正则"钓"出第一处匹配——简单、鲁棒、零依赖。这是典型的"够用就好"工程取舍。
EXIF 拿不到时的兜底:文件修改时间
现实里很多照片会被压缩、转发,EXIF 信息被抹掉。get_photo_date() 为此设计了一个降级兜底:EXIF 优先,拿不到就退回文件的修改时间。
exif_date = extract_exif_date(str(file_path)) if exif_date: return datetime.strptime(exif_date, "%Y:%m:%d %H:%M:%S").strftime("%Y-%m-%d") # 兜底:文件修改时间 mtime = file_path.stat().st_mtime return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")这一层保证了时间线永远不会空——即便所有 EXIF 都丢了,照片依然能按修改时间排进时间轴里。
按日期分组,生成 Markdown 时间线
日期拿到后,剩下的就是"整理成人类能读的表格":
- scan_photos() 递归遍历目录,按扩展名过滤出图片(支持的格式 覆盖 jpg / heic / webp / tiff 等)。
- group_by_date() 用
defaultdict按日期聚合并排序。 - format_output() 输出 Markdown,每天最多列 10 张,避免一天几百张刷屏。
跑完你得到的时间线长这样:
# 照片时间线 目录:~/Photos/with_her 总照片数:128 跨度:2022-03-14 ~ 2024-09-02 --- ## 2022-03-14(5 张) - IMG_0021.jpg(3102 KB) - IMG_0022.jpg(2871 KB) ... ## 2022-08-02(12 张) - ... 还有 2 张时间线本身不含任何照片内容,只回答一个问题:"这些照片,分别属于哪一天。"后续再由 AI 按日期回看具体照片,补全记忆。
三条命令跑通照片时间线 🚀
想亲眼看看它工作,克隆仓库后对着一个照片文件夹执行即可:
# 克隆仓库(本地只读使用) git clone https://gitcode.com/gh_mirrors/exsk/ex-skill # 扫描照片目录,把时间线写到文件 python3 ex-skill/tools/photo_analyzer.py --dir ~/Photos/with_her --output /tmp/photo_timeline.txt # 直接打印到终端 python3 ex-skill/tools/photo_analyzer.py --dir ~/Photos/with_her在 Claude Code 里,它被 SKILL.md 的"方式 C:照片"流程调用,时间线生成后再由Read工具读回来。
相关源码地图
photo_analyzer.py 只是 tools/ 下七把"瑞士军刀"之一,它们共同把各类原材料解析成 AI 可用的文本:
| 模块 | 作用 |
|---|---|
| tools/photo_analyzer.py | 照片 EXIF 元数据 → 时间线(本篇主角) |
| tools/wechat_parser.py | 微信聊天记录解析 |
| tools/imessage_parser.py | iMessage / chat.db 解析 |
| tools/sms_parser.py | 短信解析 |
| tools/social_media_parser.py | 微博 / 豆瓣 / 小红书 / Instagram 解析 |
| tools/skill_writer.py | 生成与管理 Skill 文件 |
| tools/version_manager.py | 版本存档与回滚 |
时间线最终会汇入 prompts/memories_analyzer.md 的"关系时间线"维度,成为共同记忆的一部分,示例见 exes/example_xiaomei/memories.md。
小结:不依赖 PIL 的手写 EXIF 解析,靠的是一条清晰思路——按 JPEG 标记逐段游走 → 在 APP1 里正则钓日期 → 拿不到就用修改时间兜底 → 按日期分组出时间线。代码很短,却把"零依赖、鲁棒、够用"三件事都做到了,这正是它值得被单独解读的原因 ✨
【免费下载链接】ex-skill前任 skill项目地址: https://gitcode.com/gh_mirrors/exsk/ex-skill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考