photo-pick · 批量照片挑选与去重
把几百到几千张照片压缩成"人工只需点几下"的复核流程:客观算法先算指标,AI 看拼图做初判,人看报告做终判。核心原则是宁可多留,绝不误删。
一、适用场景与边界
适用
- 旅游 / 团建 / 活动跟拍 / 家庭相册 / 风光与街拍,同一画面连拍多张,需要挑选代表作
- 数量级 100–5000 张,本地磁盘目录(jpg/jpeg/png/webp/bmp)
- 目标:出一份"保留清单 + 淘汰清单 + 每条理由",供人工复核后执行
不适用
- 需要按人脸聚类、按人物建相册(本 skill 不做人脸识别与人像归档)
- 云相册 / 仅在线链接(本 skill 依赖本地文件系统)
- 需要保留 RAW/原始画质的精修工作流(本 skill 只做挑选,不改原图)
硬约束(必须遵守)
- 手机 MTP 路径(此电脑\XXX\内部存储\DCIM\Camera)无法被命令行枚举,必须先让用户把照片复制到本地盘(如 E:\xxx\Camera),再开始处理
- 视频文件(mp4/mov)不参与挑选,只做数量统计,不删除
二、输入与输出
输入形式(二选一,目录方式为默认)
- 照片目录(推荐):
--src <目录>,脚本自行扫描并按时间排序 - 文件列表:把待选照片先复制/软链到一个目录,再走同一流程(脚本只认目录,不接散列表)
可选过滤:--after YYYY-MM-DD / --before YYYY-MM-DD(只处理旅游那几天)、--gap 秒(连拍分组间隔,默认 90)
工作目录结构(--work,产物全部落在里面,不污染原图)
<work>/
groups.json 预处理结果:分组 + 每张照片客观指标
thumbs/ 缩略图(长边 1280),供拼图与报告使用
tiles/ 拼图:连拍组每组装一张 + 单张组 8 张一拼
manifest.json 拼图与照片的对照表(AI 评审时按图索骥)
decisions.json AI 评审结论(标准化,见第五节)
照片挑选报告.html 最终交互报告(v4 形式)
输出形式(交付给用户的东西)
照片挑选报告.html—— 单文件、离线可开、数据内嵌、改判存 localStorage- 保留清单 / 淘汰清单 txt —— 报告内一键导出,格式
文件名 <TAB> 理由,每条都带一句判定理由,便于人工复核与追责 - (可选)
apply.py执行产物:精选/目录(复制保留)与待删除/目录(移动淘汰,可回退)
三、环境
PY="python3" # Windows 若 python3 不在 PATH,改用 ~/.workbuddy/binaries/python/envs/default/Scripts/python.exe
# 依赖:Pillow(已装则跳过)
"$PY" -m pip install pillow
Windows 下用 PowerShell / Git Bash 均可;脚本用 UTF-8 读写,路径含中文没问题。
四、标准流程(按步执行,不要跳步)
S0 · 确认四件事(用 AskUserQuestion 一次问完,带推荐项)
- 题材模式(决定后面所有判据,先问这一项)
-人像为主(默认):标准「清晰+人脸优先」,权重 清晰度 > 人脸状态 > 构图 > 光线
-风景为主:标准「清晰+构图优先」,权重 清晰度 > 构图 > 光线 > 拍摄时段,阈值按第六节「风景模式」放宽
-混合:整体用人像权重,遇到组内无人物时自动切到风景判据 - 一票否决项:按题材模式取(见第六节)
- 保留粒度:默认「每组 1 最佳 + 1 备选」
- 处理范围:默认「只处理指定日期区间」
用户没明确时按「人像为主」执行,并在报告标题与 decisions.json 的 criteria 字段里写清实际使用的模式(如 清晰+构图优先(风景))。
S1 · 预处理(客观指标 + 连拍分组 + 缩略图)
"$PY" scripts/prep_photos.py --src "E:\xxx\Camera" --work "D:\work\photo" \
--after 2026-10-01 --before 2026-10-06 --gap 90 --thumb 1280
产出 groups.json + thumbs/。读 stdout 的分组明细,确认组数与日期范围对得上。
性能参考:805 张约 5 分钟。PowerShell 后台任务有约 120 秒软限制,长任务请前台跑并给足 timeout(600000)。
S2 · 生成拼图(把看图次数压一个数量级)
"$PY" scripts/review_build.py --work "D:\work\photo"
产出 tiles/tile_g00XX.jpg(连拍组,每格角标 A | 尾号 | s=清晰度)与 tiles/singles_00X.jpg(单张组 8 张一拼)。
超大组(>15 张)单独处理,按清晰度取前 8 张拼大图:
"$PY" scripts/bigpick.py --work "D:\work\photo" --groups 75,134 --top 8
S3 · AI 评审(看拼图,逐组下结论)
按顺序 Read tiles/ 里的拼图,逐组输出:场景名、保留文件、淘汰文件、一句话理由。
- 连拍组:先按清晰度排序,再看人脸状态与构图,同组只留 1–2 张
- 单张组:只淘汰明显废片(糊、黑屏、误触、纯重复场景);拿不准就留
- 超大组无法逐张精读时,按清晰度 Top8 拼图选,其余默认保留(不因没看就删)
- 每张被淘汰的照片必须写理由,禁止只给文件名
S4 · 写 decisions.json(AI 结论的唯一交付格式)
见第五节规范。未列入 keep/delete 的照片一律默认保留,这条保证任何漏评审都不会误删。
S5 · 生成 v4 交互报告
"$PY" scripts/report_v4.py --work "D:\work\photo" \
--decisions "D:\work\photo\decisions.json" --out "照片挑选报告.html"
stdout 应打印 REPORT_V4_OK ... keep=X del=Y missing=Z。若打印"未找到 decisions.json"说明 S4 漏了。
S6 · 人工复核(用户在报告里完成)
用户双击 HTML 本地打开(不要从 http 预览页打开,浏览器会拦 file:// 链接),做最后的留/删改判,然后导出清单。
S7 · 执行(可选,且必须分步)
# 先复制保留项,零风险
"$PY" scripts/apply.py --work "D:\work\photo" --src "E:\xxx\Camera" --mode copy-keep
# 确认无误后再移动淘汰项到 待删除/(可回退),必须显式 --yes
"$PY" scripts/apply.py --work "D:\work\photo" --src "E:\xxx\Camera" --mode move-delete --yes
# 若以报告里人工改判后导出的清单为准
"$PY" scripts/apply.py --work "D:\work\photo" --src "E:\xxx\Camera" \
--mode move-delete --list "删除确认清单.txt" --yes
五、decisions.json 规范(AI 评审的标准化输出)
模板见 assets/decisions_template.json。
{
"title": "照片挑选 · 2026国庆 10/1-10/6",
"source_dir": "E:\\xxx\\Camera", // 原图目录,报告"打开原图"用
"criteria": "清晰+人脸优先",
"keep": [ {"name":"IMG_1.jpg","tag":"TOP1","reason":"本组最清晰(s=412),人物睁眼"}, "IMG_2.jpg" ],
"delete": [ {"name":"IMG_3.jpg","reason":"同组连拍重复,主体略糊(s=190)"} ],
"groups": { "12": {"scene":"景区正门合影","note":"连拍10张,取1最佳1备选"} }
}
keep/delete元素可以是纯文件名字符串,也可以是{name, tag, reason}对象tag常用值:TOP1(最佳)/备选/单张/单张·弱reason一句话写清判定依据(优先引用客观指标,如s=190)groups的 key 用字符串组号,提供场景名与说明,显示在报告每组标题上- 不在 keep/delete 中的照片 → 报告里标为保留,理由显示"未评审(默认保留)"
六、判定标准(默认值,S0 未另行约定时按此执行)
一票否决(出现即淘汰,除非该画面只有这一张)
通用:糊(主体明显失焦/拖影)、主体被切出画面、纯黑纯白废片、误触拍摄。
人像模式追加:闭眼、表情崩、手指或遮挡物挡住主体。
风景模式追加:水平线/地平线明显歪斜、纯天空或纯地面占画面 2/3 以上且无主体、天空死白无细节(云层层次全丢)、镜头脏点或眩光斑、前景杂物入镜(垃圾桶、电线杆、路人后脑勺)。
排序权重
- 人像模式:清晰度 > 人脸状态 > 构图 > 光线
- 风景模式:清晰度 > 构图 > 光线 > 拍摄时段(黄金时刻/蓝调优先于正午硬光)
前一项分出明显差距时,后面不再比较。
客观指标阈值(基于 1280px 缩略图计算,跨设备可比)
| 指标 | 含义 | 判读 |
|---|---|---|
sharp |
拉普拉斯方差(清晰度) | <100 糊;100–250 一般;250–600 清晰;>600 很锐 |
bright |
平均亮度 0–255 | <60 欠曝;60–90 偏暗;90–160 正常;160–190 偏亮;>190 过曝风险 |
over |
高光溢出像素占比 % | >5% 天空/人脸高光明显溢出 |
under |
暗部缺失像素占比 % | >15% 暗部基本没细节 |
指标只作辅助,最终以看图为准:sharp 高也可能是纹理丰富的糊片(如草地),sharp 低也可能是纯净背景的人像。
风景模式的阈值覆盖(题材选「风景为主」时必须按这张表判读)
| 指标 | 人像模式 | 风景模式 | 为什么放宽 |
|---|---|---|---|
sharp 判糊 |
<100 | <60 | 远景、天空、水面、雪地本身高频细节少,锐度天然偏低 |
over 判溢出 |
>5% | >15% | 风景照天空占比大,大面积高光是常态,5% 会满屏误报 |
under 判死黑 |
>15% | 维持 15% | 暗部缺失在风景里同样是硬伤,不放宽 |
基线数据(777 张人像为主的旅行照实测):sharp 中位 359 / P10 152,sharp<100 占 4%;over 中位 0.08%、P90 0.55%,over>5% 占 1%。
换纯风景集时这两项会整体上移,人像阈值直接套用会误杀,判读前先跑一遍分位数对照。
连拍去重(默认规则)
- 相邻照片时间间隔 ≤90 秒归为同一组(
--gap) - 组大小 2–4 张:留 1(表情/构图有明显差异时留 1+1 备选)
- 5–10 张:留 1–2 + 1 备选
- >10 张:留 2–3,覆盖不同机位或不同主体
- 同一组内构图相同、只有微小位移的,只留清晰度最高那张
主题相关性
- 明确废片(地面、天空、口袋误触、纯黑)直接淘汰
- 场景交代类照片(路牌、门票、菜单、全景)每组至少留 1 张,即使清晰度一般
- 人像模式:有人物的照片优先于纯风景,合影优先于单人,正脸优先于侧脸/背影
- 风景模式:有明确主体(山峰、建筑、一棵树、一条路)优先于空泛大全景;有前景层次优先于平铺直叙;同一机位保留构图差异最大的那张,而非锐度最高的那张
曝光
- 人像模式:过曝导致人脸细节丢失、或欠曝导致主体不可辨的淘汰;逆光但主体可辨的保留(后期可修)
- 风景模式:天空死白无层次、或暗部死黑无细节的淘汰;大光比但高光与暗部各有细节的保留(RAW 或手机 HDR 可拉回)
七、报告能力(v4 输出形式,必须严格实现)
每张照片都有三级查看:
1. 🔍 大图 —— 1280px 灯箱,←→ 键在同组内翻页,Esc 关闭
2. 📁 原图 —— 新窗口打开原始文件(file:/// + 原图绝对路径)
3. 📋 路径 —— 一键复制原图完整路径(clipboard API 不可用时自动降级到 execCommand / prompt)
其他交互:
- 每张照片左上角是复选框:勾选 = 删除,不勾 = 保留;按评审结果默认勾好(删的勾上、留的不勾),人工改判就是改勾选;删除项图片半透明;悬停浮出三级按钮
- 顶部筛选:全部 / 只看「删」/ 只看「留」;顶部实时统计 留 X · 删 Y · 缺失 Z
- 点照片复选框以外区域同样可切换留/删;组内「全留 / 全删」批量操作
- 灯箱内可直接改判并同步卡片复选框,鼠标悬停卡片看判定理由(title 属性含理由与指标)
- 一键导出「删除确认清单.txt」与「保留清单.txt」(含理由)
- 「恢复评审建议」按钮回滚到 decisions.json 的原始结论
- 改判自动写入 localStorage,刷新页面不丢
- 预处理失败(文件缺失/损坏)的照片单独列在顶部黄色警告区,不计入删除
八、安全红线
- 默认保留:任何未评审、看不清、拿不准的照片一律保留,理由写"未评审(默认保留)"
- 只移动,不删除:
apply.py的move-delete只把文件移进待删除/,清空动作由用户自己做 - 先复制后移动:先跑
copy-keep产出精选/,确认无误再处理淘汰项 - 执行前备份:涉及用户唯一副本时,先整目录备份再执行
- 小批量验证:
apply.py支持--dry-run,先干跑看清单再真跑 - 缺失文件单独标注:读取失败的照片进 MISSING 区并提示用户查原始设备,绝不因为读不到就判删除
九、已知坑与排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 手机路径枚举为空 | MTP 挂载,命令行不可见 | 让用户手动复制到本地盘 |
AttributeError: 'Stat' object has no attribute 'variance' |
Pillow 11.x 改名 | 脚本已用 getattr(st,"var",None) or getattr(st,"variance") 兼容 |
| 后台任务 2 分钟被杀 | PowerShell 后台软限制约 120 秒 | 前台跑,timeout 给到 600000 |
2>&1 把 stderr 包装成 NativeCommandError 中断管道 |
PS 5.1 行为 | 去掉 2>&1,或 2>$null |
cmd /c 被安全策略拦截 |
沙箱禁 cmd.exe | 用原生 PowerShell / Python 语法 |
| 报告里点「原图」没反应 | 从 http 预览页点 file:// 被浏览器拦 | 把 HTML 存本地,双击打开 |
| 拼图组号跳号(如没有 tile_g0016) | 单张组不生成连拍拼图 | 以 tiles/ 实际文件与 manifest.json 为准 |
| 统计数字对不上 | 读取失败的照片被算进总数 | 以 "thumb" in photo 过滤有效成员,失败项单独计入 missing |
docstring 里 \C 报 SyntaxWarning |
Windows 路径反斜杠转义 | 脚本 docstring 一律用 r-string |
十、可复现检查清单(每次执行完逐条确认)
- [ ]
prep_photos.py输出的范围照片数、组数、连拍组数与用户预期一致 - [ ]
tiles/拼图数量 = 连拍组数 + ceil(单张组数/8) - [ ] 题材模式已在 S0 确认,并写进
decisions.json的criteria;风景集按风景阈值判读,不套人像阈值 - [ ]
decisions.json中每条 delete 都带 reason;keep 中的代表作带 TOP1/备选标签 - [ ]
report_v4.py打印REPORT_V4_OK,且keep + del + missing = 范围内照片总数 - [ ] 报告顶部统计与 decisions.json 一致;MISSING 区已提示
- [ ] 报告在本地双击打开,三级查看(大图/原图/路径)逐项验证可用
- [ ] 执行阶段先
--dry-run,再copy-keep,最后move-delete --yes
> 复现性基线:同一份 groups.json + decisions.json 输入,任何人跑本报告脚本必须得到完全相同的 keep/del/missing 数字。本次基线(实测样本 805 张):photos=777 keep=343 del=434 missing=28。