photo-skill 是做什么的?
脚本位于此文件旁的 scripts/ 目录;下文用 $S 表示该路径,但实际命令中需写出绝对路径,因为 shell 变量无法在 Bash 调用间传递。照片库默认位于 ~/Pictures/Photos Library.photoslibrary,除非 PHOTOS_LIBRARY 另有指定。
核心理念:代码负责排序和获取,模型负责查看和评判。 SQL、osxphotos 和嵌入索引将 10 万张照片的图库缩小到几百个候选;联系表(contact sheets)将这些候选照片以带标签的网格形式呈现给你;你(或子代理)评判每个图块并为每个 ID 写下评判结果;然后脚本检查每个 ID 是否都得到了唯一一个答案。
首次在 Mac 上使用
自行运行 sh $S/setup.sh(添加 --mcp 可安装可选的 MCP 服务器,--index 可安装夜间索引任务)。该脚本会检查 uv、sqlite3、命令行工具和照片库,预热 osxphotos 和 Pillow,并通过拍摄快照来验证全磁盘访问权限。全磁盘访问是唯一需要用户手动授权的步骤:系统设置 → 隐私与安全性 → 全磁盘访问 → 开启 Claude 运行的应用程序(Terminal、Ghostty、Claude 应用等),然后退出并重新打开该应用。
规则
- 除非照片所有者在此次对话中要求更改(导入、日期、隐藏、收藏、相册),否则始终为只读。只读也排除了
osxphotos query --add-to-album以及 osxphotos 的import、batch-edit、timewarp、push-exif、sync、add-locations等命令。 - 切勿打开实时的
Photos.sqlite,即使只读也不行:SQLite 会写入其-shm文件。请查询由sh $S/snapshot.sh <dir>创建的 APFS 克隆(即时,免费)。切勿在.photoslibrary内部写入。 - 从 iCloud 下载原片是可以的。 启用“优化 Mac 存储”时,大多数原片仅存在于 iCloud 中(在一个约 10 万项的照片库中,仅 1.4% 在本地磁盘上),但每个项目都有本地预览。预览图对于联系表和“是/否”判断足够,但对于读取文字或人脸则太小;使用
photokit download获取原片(每张约 1.7 秒)。 - 照片是私密的。 请在会话暂存区或已加入 gitignore 的文件夹中操作,切勿提交照片,也不要在未经许可的情况下发布他人的照片(网页、PR、上传)。若需将照片移出本机,请去除 EXIF(尤其是 GPS 信息!):通过 Pillow 重新保存即可。
选择工具
| 需求 | 使用方式 | 速度 |
|---|---|---|
| 计数、日期、相册、人物、已删除/隐藏、GPS 区域、Apple 评分 | 对快照运行 sqlite3(reference.md 中有查询示例) | 即时 |
| 画面内容(“红色鸟居”)、相似图片、近重复 | photo_index.sh search / like / query(见下文) | 每次调用约 15 秒 |
| Apple 自带的标签、AI 描述、地名、已命名人物、美学评分 | 运行一次 photo_meta.py → 然后 grep JSONL 文件 | 30 秒 + 每项毫秒级 |
| 地点、标签、照片中的文字、基于 GPS 半径的查询(来自常驻进程) | 可选的 apple-photos MCP(setup.sh --mcp)。始终传递 library 参数 | 首次调用 30-40 秒,之后 <1 秒 |
| 通过 Shell 执行相同筛选,获取 JSON 元数据 | uvx --from osxphotos osxphotos query --library "$HOME/Pictures/Photos Library.photoslibrary" --mute ... --json | 每次调用约 30 秒 |
| 多次 osxphotos 查询 | 一个脚本:uv run --no-project --with osxphotos,osxphotos.PhotosDB(dbfile=LIB) 只加载一次 | 30 秒一次 |
| 查看照片 | ID → uuid_sheets.sh → 读取联系表 | 秒级 |
| 全分辨率文件 | photokit download(会复用已在磁盘上的文件) | 每张从 iCloud 下载约 1.7 秒 |
| 普通图片文件夹(非 Apple 照片) | sheets.py DIR,以及 photo_index.sh build DIR | |
| 更改照片库 | import.py、set_dates.py、set_flags.swift(见下文) |
始终给 osxphotos 和 MCP 传递照片库路径(--library / "library" / PhotosDB(dbfile=...))。不传递路径时,它们会在照片应用偏好设置(位于其应用容器内)中查找“最后打开的照片库”;macOS 会阻止该读取操作,背后隐藏着一个“允许访问其他应用的数据”提示(该提示在终端中永远不会显示),导致调用永远挂起(CPU 0%)。将 --library 指向快照也可以,但那样会缺失标签且文件路径不正确。
哪些项目计入统计
ZTRASHEDSTATE = 0始终如此。ZKIND = 0表示照片,1表示视频。ZSAVEDASSETTYPE:6 = 照片所有者的 iCloud 图库(其手机上的),3 = 已导入,4 = 共享相册中其他人的照片,12 = 通过信息共享给你的照片。“我拍的照片”意味着 6 或 3。共享项目会出现在日期范围内,因此如果包含它们,请说明。ZHIDDEN = 1的项目默认会包含在计数中;如果有隐藏项目出现,请指出。- 日期:在
datetime(ZDATECREATED+978307200,'unixepoch','localtime')上使用>= 'YYYY-MM-DD'且< 'next day'。between '…-20' and '…-22'会丢失大部分 22 日的数据。每年同一天:strftime('%m-%d', …) = '10-31'。'localtime'是当前 Mac 的时区;如果要使用拍摄时的时区,请加上ZADDITIONALASSETATTRIBUTES.ZTIMEZONEOFFSET。 - 无位置信息的照片会使用
-180.0,因此 GPS 筛选会静默地将其排除;请说明有多少张被排除。 - 未检测到人脸:
not exists (select 1 from ZDETECTEDFACE f where f.ZASSETFORFACE = a.Z_PK)。这会漏掉远处的人群和后脑勺,因此在发布任何内容之前仍需查看。
查看照片
sh $S/snapshot.sh <scratch>/db- 直接将 ID 查询的结果导入联系表生成器。切勿将 ID 列表打印到对话中:
sqlite3 <scratch>/db/Photos.sqlite "select ZUUID from ZASSET where ZTRASHEDSTATE=0 and ZKIND=0 and datetime(ZDATECREATED+978307200,'unixepoch','localtime') >= '2024-10-31' and datetime(ZDATECREATED+978307200,'unixepoch','localtime') < '2024-11-01'" | sh $S/uuid_sheets.sh <scratch>/sheets
这会生成 6×5 网格、236 像素大小的图块,标记为 0001、0002……(ID 是全局的,不限于单个联系表,因此两个联系表的图块不会混淆),同时生成一个 manifest.json(ID → 文件,以及跳过原因)和 missing.txt。脚本会优先使用本地磁盘上的原片,如果没有则使用最大的本地预览,同时也会在共享相册和信息文件夹中查找。如果 missing.txt 非空,请报告:照片永远不应静默丢失。对于文件夹:uv run --no-project --with pillow python -I $S/sheets.py OUT DIR(可使用 --cols、--rows、--tile 调整大小;单个联系表的长边应保持在约 2,500 像素以下)。
- 少量联系表: 自行阅读。
- 大量联系表: 分发给子代理,每个子代理分配一定范围的联系表。使用视觉能力强的模型(测试中发现最小的模型过于宽容)。提示中必须要求每个图块输出一行 JSONL(
{"id": "0007", "verdict": ...}),并告知子代理逐图块查看,自行读取图像,而不是编写代码。然后运行python3 -I $S/check_verdicts.py manifest.json out/*.jsonl:该脚本会检测缺失、重复或伪造的 ID,并标记出所有图块都得到相同答案的联系表(这通常意味着子代理草草了事);对于这类联系表请亲自查看。 - 对于任何会更改文件的操作,请运行两轮,仅在两轮一致时才执行操作,然后检查每个将要更改的图块。
- 在项目的 README 中记录所用模型、代理数量和大致 token 数。
根据画面内容搜索
私有索引为每张照片存储一个嵌入向量(默认使用 SigLIP 2),基于本地预览构建,因此不需要从 iCloud 下载任何内容。索引位于 ~/Library/Application Support/photo-skill/index(可通过 PHOTO_INDEX_DIR 环境变量覆盖),绝不会存储在 git 中。实测结果:在 M4 上从 Apple 照片库构建索引约 28 张/秒(10 万张照片约需 1 小时,另加一次约 400 MB 模型下载),从 JPEG 文件夹构建约 70 张/秒。
sh $S/photo_index.sh status
sh $S/photo_index.sh build # Apple 照片库;仅添加新增内容,可中断恢复
sh $S/photo_index.sh build ~/Pictures/scans # 或任意文件夹(使用其自己的 PHOTO_INDEX_DIR)
sh $S/photo_index.sh search "a red torii gate" 60 | cut -f1 | sh $S/uuid_sheets.sh <scratch>/hits
sh $S/photo_index.sh like <scratch>/examples.txt 60 # 最接近任何示例 ID 的图片
sh $S/photo_index.sh query --text "a bowl of ramen" --text "noodle soup" --minus "a menu" --within <scratch>/trip.txt --top 40
输出格式为 id<TAB>score,按分数从高到低排列。索引负责排序;你负责评判。 始终阅读联系表:查询“自动售货机”也可能返回便利店冰箱和售票机。
- 分数。 SigLIP 文本到照片的余弦相似度较小(匹配较好时约为 0.10–0.20);只有排序才有意义,因此切勿设置阈值。使用默认模型时照片到照片的相似度:≥ 0.97 为同一张照片,≥ 0.95 为同一时刻(连拍、重拍),~0.90 为不同拍摄角度下的同一地点,~0.85 为同一类场景。更换模型后请重新测量这些值。
- 先筛选,再排序。 日期、地点、相册、人物和共享/个人照片的区别来自快照上的 SQL 查询;将这些 ID 写入文件并传递
--within参数。这比要求嵌入模型搜索“11 月京都的照片”效果更好。 - 像写描述一样措辞(“a photo of a bowl of ramen” 或 “a bowl of ramen”),而不是关键词。多个
--text表述会被取平均;--minus用于排除某个概念。 - 结合 Apple 的元数据。
photo_meta.py会输出meta.jsonl,包含 Apple 照片自身的标签、AI 描述、地名、已命名人物和美学评分。它在地点和人物方面表现很好(地名如“… Shrine”),但在外观方面较弱(描述通常比较笼统:“一群人站在建筑物前”)。可以先 grep 元数据,再与索引搜索的结果进行并集或交集运算。其 OCR 字段(text)在 macOS 26 上的测试中返回为空;如需搜索照片中的文字,请使用 MCP 的文本搜索或下载原片后自行读取。 - 近重复检测: 使用
like并提供一个示例,然后读取分数;≥ 0.95 的一对通常是连拍。切勿仅根据名称+文件大小去重(副本会被重命名并重新编码)。 - 更换模型(
PHOTO_INDEX_MODEL,任何 SigLIP/SigLIP 2 检查点):索引会在model.txt中记录其模型,并拒绝混合使用;请在新目录的新PHOTO_INDEX_DIR中构建新索引。README.md中提供了模型对比。
夜间增量更新(可选):sh $S/photo_index.sh install-nightly(或 setup.sh --index)会安装一个 launchd 后台任务,在每天 3:30 添加当天新增的照片。launchd 任务没有终端会话的全磁盘访问权限,因此该任务会启动一个微型启动器(photo-index-runner,由 index_runner.c 编译而来),该启动器需要单独授予全磁盘访问权限:命令会打开系统设置的全磁盘访问页面并在 Finder 中显示该文件;用户需要点击 + 号选择该文件并启用权限。可通过 launchctl kickstart gui/$(id -u)/local.photo-skill.index 检查其运行状态,然后执行 photo_index.sh status。如果日志中出现“Operation not permitted”,说明权限缺失。重新运行 install-nightly 会重写任务文件,但保留启动器(因为重新编译会丢失权限)。
PhotoKit 辅助工具
每次会话编译一次:swiftc -O $S/photokit.swift -o <scratch>/photokit。首次运行时会在 macOS 弹窗中请求照片访问权限,需要 Mac 用户手动允许。
<scratch>/photokit download OUT_DIR < uuids.txt 接受纯 UUID 列表,允许从 iCloud 下载,打印每个文件的路径,并将失败信息输出到 stderr。Messages(通过信息共享给你的照片)项目对 PhotoKit 和 osxphotos 均不可见:它们只有预览图可用。
import ALBUM FILE...完全跳过 AppleScript 脚本控制(没有每启动次数限制,没有会卡在后面的错误对话框),输出file<TAB>localIdentifier;请记录该信息。测试有限:对于数千个文件,建议先尝试几百个,然后对照新的快照检查结果。date尚未测试;在测试完成前请使用set_dates.py。
修改照片库(仅在用户要求时)
- 在照片库外暂存副本(
cp -c克隆是免费的)并先修正:使用 exiftool 处理日期和Orientation标签、视频Rotation。 python3 -I $S/import.py --album NAME --log LOG.jsonl [--dry-run] FILES...(或从 stdin 读取路径):每次调用处理 50 个文件,每处理 1,500 个文件重启一次照片应用,在第一批较少数量的文件后停止,可从日志文件恢复。如果它停止,说明照片应用有一个错误对话框打开:请用户关闭该对话框,然后重新运行。- 为没有嵌入日期的文件设置日期:
python3 -I $S/set_dates.py < id<TAB>YYYY-MM-DD HH:MM:SS。 - 隐藏/收藏(AppleScript 无法隐藏):
swift $S/set_flags.swift < id<TAB>hide<TAB>fav。隐藏的资源会从获取结果中消失,因此“仍然可见:0”表示操作成功。 - 对照新的快照进行验证:所有记录的 ID 都已存在,没有已删除的,相册计数正确。
将照片制作成影片
如需制作幻灯片、蒙太奇或其他使用照片的视频,请使用 video-skill 技能(github.com/kylefritz/video-skill;如果未安装,可克隆到 ~/.claude/skills/video-skill)。本技能的作用:找到并评判镜头,使用 photokit download 下载原片(预览图对于 1080p 来说太小),重新保存时去除 EXIF 信息,并在放入影片前全尺寸查看每张照片中的人物。video-skill 读取视频的方式与本技能读取照片的方式相同,因此其帧联系表可以与 check_verdicts.py 配合使用。
注意事项
- 照片的日期是从 2001-01-01 开始的秒数:需加上
978307200。比较datetime(...)字符串;将数字与strftime('%s', ...)(文本类型)进行比较会静默地不匹配任何结果。 ZCLOUDLOCALSTATE并不表示原片在磁盘上;请检查文件本身是否存在。- 没有方向传感器的旧相机拍摄的竖屏照片可能被存储为横屏;使用 EXIF 的
Orientation标签修复(无损)。 - 照片中人脸坐标的原点在左下角:
y = (1 - center_y) * height。 - 打开 Dropbox 或 iCloud Drive 的占位符文件会永久阻塞。请先检查
st_blocks > 0。 - 在最新的
Photos.sqlite旁边如果存在过时的-wal文件,会回放覆盖;snapshot.sh在创建快照前会先清除旧文件。 /private/tmp暂存区会在会话之间被清空;请将长期运行的工作保存在项目文件夹中。