照片归档整理 SOP
1. 模式判定(开工第一步,必做)
先判断走哪条路,再进入流程。术语见 references/terminology.md。
| 模式 A:迁移合并 | 模式 B:筛选归档 | |
|---|---|---|
| 目标 | 多来源目录 → 共享相册 | 散乱照片 → 精选主题相册 |
| 动作 | 迁移,一张不丢 | 筛选,大量剔除 |
| 命名 | YYYYMM-事件-来源 |
YYYYMM-事件名(AI整理) |
| 判定去留 | 能否推断日期 | 日期 + 内容语义 |
| 特有模块 | 来源后缀映射、待溯源协议 | 规则过滤、质量精选、语义清理、上下限 |
判定问题:「这些照片是都要保留,还是只要好看的?」——前者走 A,后者走 B。
判定不清时直接问用户,不要假设。
两套模式共用第 3 章的 P0–P2、P8,以及第 7–10 章全部。
1.1 模式串联(B → A)
两种模式可以先后作用于同一批照片:先用 B 从散乱照片中精选出 YYYYMM-事件名(AI整理),再用 A 把这些结果合并进更大的共享相册大区(如 家人照片/)。串联时遵守四条规则:
| # | 规则 | 说明 |
|---|---|---|
| 1 | 保留源文件夹信息 | 模式 A 合并时主要做日期识别,尽量保留源文件夹已有的语义信息,不推倒重来 |
| 2 | 已知来源不重复追加 | (AI整理) 本身即一种来源标记。识别到它即视为来源已知,不再追加 -成员A/-成员B 等额外后缀 |
| 3 | 来源为非必需项 | 是否在文件夹名上体现来源,须询问使用者;不询问时默认不追加 |
| 4 | 语义不重复识别 | 模式 A 仅在文件夹名无法识别语义时,才对图片内容做事件/场景识别。凡已由 B 命名过的,直接沿用 |
命名过渡(规则 1 + 2 + 3 的合成结果):
B 产出: 201809-动物园孔雀(AI整理)
↓ 进入 A,日期已识别、语义已明确、来源已知
A 结果: 201809-动物园孔雀(AI整理) ← 保持不变
✗ 不得变成 201809-动物园孔雀-成员B
需要在 A 阶段附加来源时(如确有 成员A/成员B 之分),先问用户;用户确认后才追加,且对带 (AI整理) 的目录一律跳过。
2. 四区模型
| 区域 | 权限 | 要点 |
|---|---|---|
| 源区 source | 只读,仅 P8 可删 | 删前必须过双闸门 |
| 归档区 archive | 只增不改 | 写入必须幂等(md5 索引去重) |
| 隔离区 quarantine | 可写 | 软删除区,按轮次建子目录 |
| 备份区 backup | 全程只读 | 删除源区的唯一凭据 |
关键:归档区与隔离区可以存在同名文件夹(混合事件部分剔除时)。判定去留必须按文件 md5,不能按文件夹名。
3. SOP 主流程
按顺序执行,标注 [自动] 的可直接跑,标注 [需确认] 的必须先取得用户同意。
| 步 | 动作 | 决策权 | 关键判据 / 参数 |
|---|---|---|---|
| P0 | 确认范围与规则:源区/归档区/备份区路径、模式、命名模板、上限 N、下限 | [需确认] |
打印计划再动手 |
| P1 | 只读扫描:数量、扩展名、时间跨度、大小分布 | [自动] |
绝不修改任何文件 |
| P2 | 校验备份:模式 A 创建备份;模式 B 校验既有备份的字节级完整性 | [自动] |
备份不完整则终止 |
| P3 | 规则过滤:大小越界、非目标格式 | [自动] |
默认 1MB–100MB、图片白名单 |
| P4 | 日期推断 | [自动] |
见 date-inference.md;禁用 mtime/ctime;插值须 LOO ≥95% |
| P5 | 事件聚类:连续天数 ≤2 切会话 → Ward 聚类切分子事件 → <5 张回收 | [自动] |
禁止跨月合并 |
| P6 | 质量精选:按质量分降序保留前 N 张 | [自动] |
近重复抑制仅在超限时启用 |
| P7 | 归档写入:YYYYMM-事件名/ |
[自动] |
幂等;事件名(模式 B)必须由人命名 |
| P8 | 源区回收:删除已归档且已备份的文件 | [需确认] |
双闸门,见第 8 章 |
模式 B 在 P7 之后通常还要走第 4 章的语义清理循环,多轮迭代。
模式 A 附加流程(脚本 ↔ 步骤映射)
模式 A 在共用 P0–P2、P8 之外,走这条独立的迁移链路:
| 步 | 脚本 | 关键参数 | 说明 |
|---|---|---|---|
| A1 迁移合并 | migrate.py |
--root --merge-map --birth-years --apply |
按日期推断结果分发到 YYYYMM-事件-来源;无强信号者进 待溯源-来源 |
| A2 扩展名修复 | fix_ext.py |
--root --apply |
magic byte 判定,见 naming-rules.md |
| A3 命名归一化 | normalize_names.py |
--root --apply |
各种变体统一为 YYYYMM- 前缀 |
| A4 松散文件归组 | organize_loose.py |
--root --target --apply |
根目录散落文件并入目标或建 YYYYMM-视频/照片-来源 |
| A5 待溯源处理 | analyze_pending.py → 询问用户 → apply_pending.py |
见第 6 章 | 禁止硬猜 |
| A6 核验 | verify.py |
--root --backup |
md5 内容守恒 + 结构检查 |
模式 B 不使用 migrate.py 系列,改用下一节的 6 个脚本。
3.1 最小可行路径(首次使用先跑这条)
不必一次启用全部能力。首次使用或照片量不大时,跳过语义清理与判别器,只跑主干:
python scripts/date_infer.py --root SRC --out dated.json --apply
python scripts/extract_features.py --dated dated.json --out feats.json \
--visual-out visual.npy --apply
python scripts/cluster_events.py --feats feats.json --visual visual.npy \
--out events.json --apply
# 人工命名事件 → 按 events.json 复制进归档区(YYYYMM-事件名/)
python scripts/verify_gates.py --archive ARCHIVE --cap 40 --min-files 3
跑通后再逐步加能力:
| 想解决的问题 | 追加的能力 | 参考章节 |
|---|---|---|
| 照片太多,每组只要最好的 N 张 | 按 q_rank 截断 |
第 3 章 P6、quality-scoring.md |
| 混着截图、文档、票据 | 语义清理 + 文档判别器 | 第 4 章 |
| 混着办公/培训场景照片 | 语义清理 + 场景判别器 | 第 4 章 |
| 要删源文件腾空间 | 双闸门 + safe_delete.py |
第 8 章 |
4. 语义清理循环(模式 B)
当无法查看图像时,按此循环逐轮收敛:
① 文件夹名分档 → ② 训练判别器打分 → ③ 生成联系表 → ④ 用户分档确认 → ⑤ 移入隔离区 → ⑥ 六道闸检验
↑ │
└────────────────── 上一轮的保留集/剔除集 = 下一轮的标注 ◀──────────────────────┘
第 ③ 步用 make_contact_sheet.py 生成自包含 HTML(点击可看大图,红框标出 ≥ 阈值的项),交用户逐张确认:
python scripts/make_contact_sheet.py --scored scores.json --out review.html \
--mark 0.55 --only-marked --apply
三类清理目标(难度递增):
- 非照片——文档、截图、票据、PPT。最易识别。
- 场景不对——会议室人像是真照片但属办公场景,文档判别器抓不到,需另训场景判别器。
- 边界内容——路牌、产品包装、游戏屏。识别成本高于收益,通常放弃。
判别器配方见 references/classifier-recipes.md。四条铁律:
- 标注零成本:正样本取上一轮保留集,负样本取上一轮剔除集
- 阈值禁止自动选,"召回最大化"会误杀六成真实照片
- 保护名单:纯背景照片(证件照、制服照、棚拍)必被误判,分数再高也不删
- 分数只是提议,批量删除前必须分档提问并给出具体数字
5. 信息补偿机制
无法查看图像时,按下表逐项用可复算的客观数据替代视觉判断。每个通道都必须记录其失效边界,便于事后追溯错在哪一步。
| 缺什么 | 用什么补 | 精度 | 失效边界 |
|---|---|---|---|
| 时间 | 五层客观信号 + 序号插值(LOO 验证) | 覆盖率 97.7% | 无 EXIF 且文件名无规律的扫描件 |
| 语义 | 文件夹名(人命名)+ 12 维特征判别器 | AUC 0.85–0.87 | 纯背景照片必误判 → 保护名单 |
| 质量 | 清晰度/分辨率/熵/色彩/曝光 加权 | 排序可靠 | 绝对值不可跨数据集比较;艺术虚化、夜景会误判 |
| 去留 | 人在回路:联系表 + 分档 + 数字 | — | 用户不看则阻塞 |
| 来源 | ctime 分布审计 | 精确到批次 | 同盘移动会重置 ctime |
每一轮都在为下一轮生产标注数据,所以多轮迭代是越做越准,不是重复劳动。
6. 人机协作协议
决策权分配
| 环节 | 谁定 | 理由 |
|---|---|---|
| 大小/格式过滤 | 机器 | 确定性规则 |
| 日期推断 | 机器 | 客观信号,有 LOO 验证 |
| 事件聚类 | 机器 | 无监督,有上下限约束 |
| 质量排序 | 机器 | 可量化 |
| 判别器阈值 | 人 | 决定误杀率 |
| 事件命名 | 人 | 需要看图,机器无法完成 |
| 事件去留(办公 vs 家庭) | 人 | 语义判断,机器只能给分 |
| 累计上限 N | 人 | 价值取舍 |
| 批量销毁 | 人(二次确认) | 不可逆 |
提问规范
批量操作前必须提问,且每个选项都要带具体数字。
反例:「要合并还是独立建组?」
正例:「合并去重每组不超 40 张(补 87 张)/ 合并不设累计上限(补 731 张)/ 第二轮独立成组(补 731 张,需新建 35 个文件夹并命名)」
选项数量 2–4 个,用户可用「其他」补充。
待溯源协议(模式 A)
对无法推断日期的文件,严格执行三步,禁止硬猜:
- 分析:挖命名线索(子文件夹名、文件名日期碎片、人物/地点关键词),产出含
confidence和needs_user的分析表 - 询问:把
needs_user=yes的项提交用户 - 执行:按用户建议移动,默认 dry-run
7. 约束体系
硬约束(不可违反)
- 禁用文件系统时间推断日期(云盘同步会篡改)
- 所有剔除先入隔离区,不硬删
- 销毁必须过双闸门(见第 8 章)
- 日期/事件不可推断时排除,绝不猜测
- 备份区全程只读
软约束(用户可调,默认值)
| 项 | 默认 | 说明 |
|---|---|---|
| 单事件上限 N | 40 | 本次实战放宽到 60、80 |
| 事件成立下限 | 5 张 | 不足则并入最近邻 |
| 文件夹保留下限 | 3 张 | 不足则删除该文件夹 |
| 近重复阈值 | 0.035 | 连拍去重 |
| 判别器阈值 | 0.55 | 需按对照表人工确认 |
| 会话切分间隔 | 2 天 | 同月内 |
8. 风险控制与异常处置
8.0 验证隔离原则(前提约定)
已完成整理的目录列为只读区,任何验证、测试、演示都不得直接操作它们。
需要跑通流程或复现问题时,复制子集到独立临时目录(如 .workbuddy/_test/),在副本上执行,结束后清理副本。理由:
- 整理成果往往只有「归档区 + 备份区」两份,副本上出错还有退路,正本上出错不可逆;
- 脚本在真实数据上跑通之前,行为未经验证;
- 分批删除、md5 索引这类操作一旦路径算错,在正本上的代价无法承受。
8.1 风险控制五件套
- 隔离区(软删除)——所有剔除先移入
quarantine/<轮次>/,随时可整体还原。 - 幂等——写入按 md5 索引去重,脚本可重复执行。
- 分批——销毁操作每批 50 个,失败不影响其他批次。
- 可追溯——每轮导出删除清单 CSV(源文件、备份文件、md5、大小、归档位置)。
- 双闸门——删除源文件必须同时满足:
- 该文件 md5 已存在于归档区
- 备份区存在 md5 完全一致的镜像
任一不满足 → 跳过并记入 unverified。容忍度 0。
8.2 异常处置
| 异常 | 判据 | 动作 | 回退点 |
|---|---|---|---|
| 脚本中途崩溃 | 非 0 退出 / 无输出 | 每步产物均已落盘,直接重跑该步(幂等);并行段自动按片转串行 | 重跑当前步骤 |
| 并行段整批失败 | 大量 WARN/FAIL | 减小 --chunk 与 --workers,或转串行 |
重跑当前步骤 |
| 用户在中途要求改上限 N | — | 只需重跑 P6 之后;P4/P5 产物可复用 | P6 |
| 用户在中途要求改判别阈值 | — | 只需重跑打分与分档,无需重跑特征提取 | 语义清理第 ② 步 |
| 判别器 AUC < 0.75 | train_classifier.py 会警告 |
不得用于自动删除,结果只能作为排序提议交人工复核 | — |
| 判别器 AUC < 0.65 | 脚本中止并拒绝输出(--force 可覆盖) |
放弃自动方案,改走纯人工分档(联系表复核) | — |
| 日期覆盖率低于预期(< 85%) | date_infer.py 输出 |
先查 EXIF 是否被剥离;不要放宽 LOO 门槛去硬凑 | P4 |
| 隔离区误剔了要保留的 | 人工发现 | 从 quarantine/<轮次>/ 按原始文件夹名移回归档区 |
— |
| 磁盘空间不足 | 写入报 ENOSPC | 归档区是复制不是移动,需预留与源区相当的容量;分批轮次处理 | — |
| 归档区出现不明批次 | ctime 审计发现 | 必须追查来源,确认无害后才保留;禁止静默忽略 | — |
9. 检验闸门(每轮必跑六道)
| # | 闸门 | 通过标准 |
|---|---|---|
| 1 | 内容守恒 | expected_keep − actual = 0 |
| 2 | 去重 | 归档区内部 md5 重复 0 组 |
| 3 | 上下限 | 无超限(特批除外)/ 无 <3 张 / 无空文件夹 |
| 4 | 备份闸门 | N/N 校验通过,0 不可校验 |
| 5 | 隔离区对账 | 唯一 md5 数 == 预期移除数(不等通常是脚本重复执行,先按 md5 去重) |
| 6 | ctime 审计 | 各批次写入量与预期一致;不明批次必须追查来源 |
详细判据与对账模板见 references/verification-gates.md。
10. 环境适配(Windows 沙箱)
三条必须记住,完整清单见 references/pitfalls.md:
| 场景 | 用法 |
|---|---|
| 删文件 | find "<绝对路径>" -maxdepth 0 -type f -delete(os.remove/shutil.rmtree 会被 safe-delete hook 拦) |
| 删目录 | shutil.move 移入隔离区(os.rmdir 同样被拦) |
| 并行处理 | 按 50 片循环,每片 Pool(8),异常转串行;Pool(12) 跑几千张会静默崩溃 |
其他:路径清单用 newline='\n';临时脚本写工作目录(/tmp 会解析成 D:\tmp);脚本勿命名 copy.py/select.py。
11. 资源索引
按需加载,不要一次全读:
| 文件 | 内容 | 何时加载 |
|---|---|---|
references/terminology.md |
术语、四区模型、两种模式对照 | 开工前必读 |
references/date-inference.md |
五层日期信号、插值参数与 LOO 验证、合理性闸门 | 进入 P4 时 |
references/naming-rules.md |
两种命名模板、归一化规则、magic byte 扩展名修复 | 进入 P7 时;模式 A 开工前 |
references/classifier-recipes.md |
判别器配方、两个已验证实例、保护名单 | 进入语义清理循环时 |
references/quality-scoring.md |
质量分 7 个分量、权重、z-score 归一化、已知局限 | 进入 P6 时;对淘汰结果有异议时 |
references/verification-gates.md |
六道闸详细判据、对账代码模板 | 每轮结束必读 |
references/pitfalls.md |
环境坑 7 条 + 方法坑 11 条 | 报错时;Windows 下开工前 |
references/report-template.md |
各阶段报告与清单模板 | 产出报告/删除清单时 |
references/worked-example.md |
6,763 张家庭照片完整实录(模式 B) | 首次执行模式 B 时;不确定参数取值时 |
references/scripts-guide.md |
脚本总表:模式 B 7 个 + 模式 A 9 个、参数、典型链路、失败处理 | 执行任何脚本步骤前 |
scripts/_common.py |
两模式共用的基础库 | 需要改脚本行为时 |
12. 依赖
Python 3.10+、Pillow、pillow-heif(HEIC 必需,需 register_heif_opener())、numpy、scikit-learn。
所有脚本默认 dry-run,加 --apply 才执行写操作。