你接触的四样东西。
它们共同认定的一个文件。

一个负责度量、挑片、学习和修图的 Swift app;一个 Python 引擎,每个 Swift 移植版都要拿它来对照测试;一个给手机用的浏览器工作台;还有一个供脚本调用的命令行。 它们彼此不调用,下文说明的一个后备路径除外。它们共享一个目录——一个 SQLite 文件加一个缩略图文件夹——其格式有成文定义,并且两种语言都对它做了测试。

本页描述的是截至 2026 年 10 月已经存在的东西,而不是计划中的东西。计划中的部分以虚线绘制, 并列在页面底部,注明各自还在等什么。

7Photo 是产品名。photocull 是其引擎和命令的内部代号,也是每个拍摄随身携带的缓存文件夹的名字 .photocull/;本页的代码标识符沿用这个名字。

实际建成的样子

7Photo 架构(已建成) 顶部是 macOS app、浏览器工作台和命令行。 app 在 Swift 中基于 Core ML 和 Vision 完成度量、判断和修图,并直接读写共享缓存; 浏览器工作台和命令行都经由 Python 引擎。缓存——一个 SQLite 数据库、一个缩略图目录和一份 判断备份——位于中间。原始照片位于底部,从不被覆盖;XMP 旁车文件写在它们旁边, 保存的修图则写进它们旁边的 Retouched 文件夹。 SWIFT · macOS 14+ 7Photo.app 约 15,000 行 · 不用任何第三方包 7Photo app SwiftUI · AppKit 侧栏 · 网格 · 单张 · 对比 · 修图 PhotocullCore 库 Connection —— 通过 C API 访问 SQLite Library · Decisions · People · Albums Measure Core ML · Vision · Judging · Retouch Thumbnails (ImageIO) · Search · Catalogue Core ML 模型内置于 bundle · 无需 Python 任意浏览器 手机 · 平板 · 笔记本 820px 以下改用拇指按钮 PYTHON · FastAPI 浏览器工作台 photocull review 23 个路由 · 单个 HTML 文件,无构建步骤 JobRunner —— 扫描在进程内的线程里跑 SHELL 终端 · 脚本 · cron 一切批处理都是一条命令 PYTHON · Typer 命令行 scan · score · learn · people · scenes duplicates · search · editstyle · export backup · retouch · review · encode-text PYTHON 3.12 · 实验室 引擎 photocull —— 每个 Swift 移植版都以它为标准答案 OpenCV · NumPy 清晰度、曝光、场景 ONNX Runtime + CoreML InsightFace · CLIP · NIMA SciPy · scikit-learn 聚类 · Bradley–Terry exiftool 子进程调用 契约 —— 两种语言唯一共享的东西 .photocull/ 格式定义在 tables.py · 两边都有测试 photocull.db SQLite · WAL · 多个读者,同一时刻只有一个写者 feature一张照片 · 53 列 face一张人脸 · 向量存为 BLOB file一个文件 · 副本算同一张 decision(uid, source) person姓名、优先级、质心 keyword关键词,写入 dc:subject thumbs/ uid.g.jpg512px · 网格用 uid.jpg1400px · 单张用 Swift 和 Python 用同样的文件名, 所以任何一方都能替另一方填缓存 decisions.jsonl 唯一一张无法重新计算的表 的备份 已排序、可 diff,放心提交进版本库 你的文件 —— 存储卡 · SSD · NAS 原片:只读,从不移动,从不覆盖 身份是画面字节的哈希(JPEG、PNG 取图像数据,其他格式取整个文件),改名、移动、恢复或被别的程序写入星级,判断都还在 IMG_0001.CR3 IMG_0001.xmp ← 合并,从不替换 Retouched/ ← 仅在 Save Full Size 时 HTTP · JSON + JPEG argv · exit 直接导入 直接导入 SQLite C API,进程内 浏览时 query_only · 扫描时写入 ImageIO · 每格 1.0 ms 写入 读像素 · 写 .xmp ImageIO · 扫描、1:1 · .xmp、Retouched/
Swift Python 浏览器 共享缓存 计划中

要看的是左边那条金色的线。app 取数据从不求人。它自己打开 SQLite 文件,自己从磁盘读缩略图,所以一个网格单元只要一毫秒,挑片循环里也没有任何东西在等 Python、等 socket 或等另一个进程。度量一个拍摄也不再需要 Python:app 在 Swift 里基于 Core ML 和 Vision 完成扫描——435 张照片的测试婚礼不到一分钟,模型跑在神经网络引擎上——再把人物分组,按规则或按从你的判断里学到的口味做判断,然后修图。右边的引擎是这些算法最早写成的地方,也是每个 Swift 移植版用来核对的基准。

谁和谁对话,怎么对话

图上的每一条连接,连同它的机制和约束它的规则。 实际上比看起来少:Swift 和 Python 两半之间除了文件,以及下文说明的一个后备路径,没有任何连接。

从 → 到机制规则
Mac app → photocull.db SQLite C API,进程内,约 230 行 Swift 浏览时以 PRAGMA query_only 读取;判断只以 human 身份写入。扫描用引擎的 DDL 写引擎的表,整体替换缓存,并在引擎的保护规则下以 model 身份给出建议判断。从不经过服务器。
Mac app → thumbs/ ImageIO,由一个 actor 记账,解码在后台独立运行 绝不为了画网格去解码原片。一次解码出两种尺寸。
Mac app → 原片 ImageIO;旁车文件的 XML 用 Foundation 处理 扫描时每张解码一次(RAW 取其内嵌预览,与引擎的读法一致),只有放大超出预览所含的细节时才再解码——“1:1”指的是原片的像素。从不覆盖:导出时在旁边合并一个 .xmp,修图里的 Save Full Size 则把一张 JPEG 写进旁边的 Retouched/。
Mac app → 它的模型 Core ML,取自 bundle 的 Resources;Vision InsightFace 的人脸模型、CLIP 的图像与文本塔以及 NIMA,都从引擎自己的 ONNX 转换而来并与之逐一核对,以 16 位浮点存储,好让 Core ML 把它们放到神经网络引擎上而不是 CPU 上跑。以写时复制方式克隆进 bundle(约 520 MB),所以 app 的任何一份拷贝都能在没有代码仓库、没有 Python 的情况下运行。
Mac app → 全局目录 SQLite,位于 Application Support 的 catalogue.db 这台 Mac 知道的每个拍摄、各自所在的硬盘、跨拍摄的相册和人物。它不属于任何一个拍摄,所以放在拍摄之外;即使硬盘没插上,上面的拍摄也照样列出。
Mac app → 引擎 子进程:photocull scan --json(stdout 输出 JSON lines),然后 photocull score 和 photocull people;搜索文字时用 photocull encode-text --serve 仅作后备,在找不到转换后的模型时启用;文本模型存在却加载失败,会如实报错。内置模型构建的 bundle 不走这条路。
浏览器 → 浏览器工作台 HTTP:数据用 JSON,图像用 JPEG 默认绑定 127.0.0.1,按需加 --tailscale 或 --lan。没有密码,所以绑定在哪里就是访问控制。已在局域网内用手机访问过;经 Tailscale 尚未测试。
浏览器工作台 → 引擎 Python import;长任务跑在线程上,通过一个可轮询的进度接口查看 任务状态只存在内存里,进程一停就丢。
Shell → 命令行 argv 进,文本和退出码出 每个批处理操作都是一条命令,所以每个批处理操作都能写进脚本。
引擎 → photocull.db Python 的 sqlite3,经由唯一的 Store 类 度量数据在一个事务里整体重写。模型的判断永远不能覆盖人的判断——键是 (uid, source)。
引擎 → 原片 读用 OpenCV 和 Pillow;写旁车文件用 exiftool 子进程 从不写入。原片附近唯一的输出是旁边的一个 .xmp。
Swift ↔ Python 没有直接连接,上面那个后备路径除外。靠的是上面那些文件。 schema 就是接口。Swift 用引擎写出的测试数据和黄金值来测试;Python 用 Swift 写下的判断来测试。

为什么 app 不经过服务器

规整的设计会让 Mac app 成为自己后端的 HTTP 客户端:一条代码路径,干净的分层。 可它也会推翻整个设计赖以成立的那个测量结果。从缓存读一个网格单元是 1.0 ms;在前面加一次 HTTP 往返,浏览器版本的毛病就原样回来了。

关注点分离不等于进程分离。app 和引擎之间隔着的是一套 schema,这是比 API 更严格的边界,因为它有成文定义,并由两种语言的测试来强制执行。

为什么两个写者是安全的

app 和引擎都可以在对方打开 photocull.db 时写入它。WAL 模式下的 SQLite 正是为此而设计的——任意数量的读者,同一时刻一个写者,遇到冲突等三秒而不是报错。而唯一无法重新计算的那张表 decision,其键的设计让两边的写入永远不会相撞:你的判断是 human,任一引擎给出的建议是 model,而模型的判断被禁止碰你的判断。

做的时候才发现:只读的 SQLite 连接打不开 WAL 模式的数据库,因为 WAL 需要一个共享内存文件,而只读连接不允许创建它。这样一来,app 会拒绝打开任何一个它曾经做过判断的图库。所以“只读”的实际写法是:一个普通连接加上 PRAGMA query_only,SQLite 按连接强制执行它,而且它真的管用。

文件里装着什么

四个类型上的决定,每一个都是为 Swift 读者而不是 Python 写者做的, 每一个都有测试钉住,因为它们出错时都不声不响。

数据存储为原因
人脸与图像向量小端 float32 的 BLOB正好是 Swift 想要的内存布局;一次 memcpy 就够。用 JSON 文件会大三倍,还得解析。
拍摄时间整数微秒在两种语言里排序和比较的结果完全一致。它是不带时区的墙上时间——相机本来就没记录时区——app 按 UTC 格式化它,这样就不会被机器的时区偏移挪动。
布尔值0 和 1SQLite 没有布尔类型,假装有只会招来一个字符串 'true'。
感知哈希无符号位按有符号数重新解释SQLite 的整数是有符号的,最高位为 1 的哈希放不进去。汉明距离只看位,所以无所谓。

身份

一张照片的 uid 取自它的画面,而不是它的文件:对 JPEG 或 PNG,是对描述其解码方式的段落以及图像数据——取首尾各 64 KB 和总长度——做 BLAKE2b 哈希,因此别的程序往原片里写入星级或关键词,它都不变。其他格式则对整个文件做哈希;当一次改写改变了它的 uid,扫描会凭证据把判断带过去:路径、大小、拍摄时间和感知哈希都相同才算,否则不带。改了名、在拍摄内移动过、或从备份恢复的文件都保留判断;同一个拍摄里的两份拷贝算作一张照片,在 file 里各占一行。同名的 RAW 和旁边的 JPEG 也算一张照片,以 JPEG 来度量,RAW 作为它的 companion。照片留下的每一个身份都写入 uid_move,所以全局目录里的相册不论是哪个引擎移动的都能跟上。另有一个独立的 revision,由大小和修改时间得出,回答另一个问题——这个文件变了没有,度量是否过期。第一版把两者混为一谈,在一台机器上没问题,换个地方就不行。Swift 算出的 uid 与引擎相同(BLAKE2b 是移植过来的,因为 CryptoKit 没有它):测试婚礼上 435 张对上 435 张。

接下来要变的

这一节过去列出的大部分东西已经实现了。app 在 Swift 里完成扫描、判断、人物分组、学习口味和修图,引擎也变成了计划中说的样子:交付的是 Swift;Python 是实验室,也是每个移植版对照测试的标准答案。剩下的是手机,以及一个可以交给别人的构建。

7Photo 架构(计划中) 计划中的形态。已建成的 Mac app 基于 Apple 的框架做度量, 并直接读取缓存。一个由 launchd 运行、尚未构建的 Swift 守护进程将为手机提供服务, 并取代 Python 浏览器工作台。Python 引擎继续作为实验室,以及核对每个 Swift 移植版的标准答案。缓存不变。 SWIFT · 产品本身 7Photo.app 扫描、判断、学习、修图 不需要 Python,也不需要代码仓库 已构建 · 未签名,未公证 任意浏览器 手机,人不在家时 SWIFT · launchd photocull-serve 读取类接口,用 Swift 实现 窗口关掉、App Nap 之后依然在跑 等离家的 Tailscale 测试做完再说 PYTHON · 实验室 引擎,作为标准答案 命令行和浏览器工作台 新的度量先在这里试 每个 Swift 移植版都拿它来核对, 用测试数据,也用一次真实拍摄 守护进程上线后,工作台可能退役 APPLE 框架 度量搬到了这里 Core ML —— InsightFace、CLIP、NIMA Vision —— 人脸拍摄质量 ImageIO —— 解码 · Accelerate 在像素结果取决于它的地方,沿用 OpenCV 的算法 不变 .photocull/ 同一个文件,同一套 schema,同一条“谁的判断说了算”的规则 正是这一点,让上面每项改动都能一次只动一块 HTTP 直接读,和现在一样 读取缓存 写入度量
改动在等什么
用一个 launchd 下的 Swift 守护进程,取代给手机用的 Python 工作台一次真正离家、经 Tailscale、Mac 处于睡眠状态的测试。工作台已经在局域网内用手机用过;这个赌注所依赖的那次测试还欠着。为了弄清远程访问好不好用而先把守护进程做出来,等于还没提问就先为答案付了钱。
一个带图标、已签名并经过公证的构建一个 Xcode 项目。app/make-app.sh 会构建出内置模型的 app/.build/7Photo.app,作为开发脚手架:拷到哪里都能运行,但没有签名。
人脸模型一个决定。InsightFace 的模型仅限非商业研究使用,几乎所有公开的人脸识别模型也是如此;Apple 没有用于身份识别的对应能力,这也是为什么 Vision 只是一个额外信号而不是检测器。任何更换都会改变每一个身份向量,而姓名正是靠比较这些向量在各个拍摄之间传递的,所以更换必然伴随一次有计划的重新扫描。
Linux 和 Windows什么也不等;它们被搁置了。能服务它们的是 Python 那一层,而 Swift 不会离开 Mac。