iPhone 照片/视频整理工具: 按拍摄时间归档到 年/月/,自动去重,保留 Live Photo 配对。
- 按 Exif 时间归档 — ExifTool 持久进程 (
-stay_open) 批量预热 → Pillow 兜底 → 文件 mtime 最后兜底;几千张照片省去几千次冷启动,提速 ~10x - 字节去重 (size 短路) — 先按文件大小分桶,size 唯一的文件直接放行,只对同 size 文件算 MD5
- 视觉去重 (可选
--phash) — 同一张图不同压缩/分辨率也能识别;BK-tree 索引让查询接近 O(log N),万张照片可用 - Live Photo + AAE 配对 —
IMG_xxxx.HEIC/IMG_xxxx.MOV/ 编辑变体IMG_Exxxx.AAE自动归到同组 - 跨卷原子移动 — 同盘
os.replace;跨盘 copy → fsync → rename → unlink 两阶段,断电不留半成品 - 断点续跑 — 状态写入
target/.organizer_state.json,中断后下次自动跳过已处理 - 批量容错 — 单点失败不中断主流程,末尾打印完整 FAIL/ERROR 清单
- 并发哈希 —
--workers控制线程数,IO 密集场景 2-4x 加速 - Dry-run 预演 —
--dry-run只打印不移动
# 基础用法
python organizer.py \
--source "D:/AHAHA/DCIM/Input_Photos" \
--target "D:/AHAHA/DCIM/Organized_Photos" \
--duplicates "D:/AHAHA/DCIM/Duplicates"
# 先预演看一眼,再实际跑
python organizer.py --source ... --target ... --duplicates ... --dry-run
# 启用视觉去重 + 8 线程并发
python organizer.py --source ... --target ... --duplicates ... --phash --workers 8
# 中断后续跑 (默认开启);想从头开始用 --no-resume
python organizer.py --source ... --target ... --duplicates ... --no-resumeconda env create -f environment.yml
conda activate image或手动:
pip install Pillow
pip install imagehash # 仅 --phash 时需要ExifTool: 仓库已自带 Windows 版(./exiftool.exe + ./exiftool_files/,根据 Artistic/GPL 协议再分发),开箱即用,无需另行下载。
如需升级或换平台版本,从 https://exiftool.org/ 下载后覆盖到项目根即可。detect_default_exiftool() 也兼容旧约定 ./exiftool/exiftool.exe。
没有 ExifTool 时只能依赖 Pillow,HEIC/视频时间会读不到,自动降级到文件 mtime。
| Code | 含义 | 用法 |
|---|---|---|
| 0 | 全部成功 | PowerShell if ($LASTEXITCODE -eq 0) { ... } |
| 1 | 有业务失败 (例如个别文件读不到时间) | 主流程已完成,需查清单 |
| 2 | 脚本异常 | 需人工排查 |
| 130 | 用户 Ctrl+C 中断 | 状态已存盘,可续跑 |
Image/
├── organizer.py # 主入口 (新版)
├── README.md
├── environment.yml
├── exiftool/ # 自行下载放置
│ └── exiftool.exe
└── legacy/ # 旧版本,仅供参考,不再维护
└── README.md
为什么 size 短路重要: 全量 MD5 每个文件都要读完整内容。流程是 stat → 按 size 分桶 → 唯一 size 直接放行 → 只对同 size 桶 (含历史 size) 算 MD5。照片几乎不可能字节数完全一样而内容不同,所以唯一 size 的文件可以零 IO 放行。状态文件持久化 {size: [hash...]},断点续跑时跨次去重也走 size 短路。
为什么 ExifTool 要持久进程: ExifTool 是 Perl 脚本,单次冷启动 ~200-500ms。旧实现每文件 fork 一次,几千张照片光启动就要十几分钟,远超 MD5 与 IO 时间。新实现走 -stay_open True -@ - 协议:单常驻进程通过 stdin 接收文件路径,stdout 返回 JSON,启动开销摊销到 0。Organizer.run 在 size 短路决定后批量预热(prewarm,每批 100 文件),结果填到 DateResolver._cache;后续 resolve() 命中缓存零开销,未命中也走同一 daemon 单文件查询。daemon 崩溃时自动重启 (max 3 次),超限后整体降级到 Pillow/mtime,不影响主流程。关键陷阱:stderr 必须 DEVNULL(exiftool 默认把 "X image files read" 写到 stderr,合并到 stdout 会污染 JSON 解析);同时不能加 -q——-q 会把 {ready} sentinel 也压制,导致 readline 永久阻塞。
为什么 pHash 要 BK-tree: 旧实现每张新图与历史所有 phash 线性比对,复杂度 O(N²)。1 万张照片需要 ~5000 万次 hamming 计算,慢且占 CPU。BK-tree 利用三角不等式 |d(x,p) − d(y,p)| ≤ d(x,y) 剪枝,节点按"到父的距离"分桶,查询时只递归到 [d − threshold, d + threshold] 桶。实测 500 节点查询正确性与线性扫描完全等价(test_pruning_does_not_miss_neighbors),实际复杂度接近 O(log N) — 小阈值(≤4)尤其有效。
为什么 IMG_E 前缀要归一: iPhone 编辑过的照片 sidecar 是 IMG_E1234.AAE,主图却是 IMG_1234.HEIC,stem 不同。配对前对 stem 做 IMG_E → IMG_ 归一化,否则 AAE 会被当成独立 primary 单独归档。
为什么 Live Photo 要配对: HEIC 主图和 MOV 实况视频的 Exif 时间常常差几秒,独立按时间归档会被分到不同月份目录 (跨月拍摄时尤其明显),破坏 iPhone 重新导入时的实况照片关联。
为什么 move 要分同卷/跨卷: shutil.move 在跨卷时退化成 copy + unlink (非原子),中途断电会留下"目标半成品 + 源完好",下次扫描会再次"成功归档"源文件,造成永久残留。本工具同卷走 os.replace 单步原子;跨卷用 copy → fsync → rename → unlink 两阶段,任何中断点都可恢复 (.partial 残留可识别清理;rename 后崩溃则源残留会在下次扫描被识别为字节重复进 duplicates)。
为什么要断点续跑: 几千张照片处理一次要十几分钟,中途网络盘卡顿、Ctrl+C、电脑休眠都会丢进度。状态文件记录 size→hash 字典和源路径,下次启动直接跳过。
# 在 conda lang 环境下
pytest tests/ -v覆盖:group_files (含 IMG_E AAE 配对回归)、size 短路 决策、_atomic_move 同卷/跨卷/失败清理、ExifToolDaemon 协议/崩溃重启/close 幂等、DateResolver 缓存命中与 daemon 失败降级、PHashBKTree 插入/查询/剪枝正确性 + Organizer 视觉去重集成。共 47 用例。
| 文档 | 受众 | 内容 |
|---|---|---|
| README.md | 所有人 | 上手与使用 |
| docs/功能介绍.md | 用户 | 全功能清单与场景 |
| docs/项目技术分析文档.md | 维护者 | 架构、模块、算法 |
| docs/项目交接文档.md | 接手人 | 待办、踩坑、路线图 |
| CONTRIBUTING.md | 贡献者 | 开发流程与规范 |
| CHANGELOG.md | 所有人 | 版本变更记录 |