你接触的四样东西。
它们共同认定的一个文件。
一个负责度量、挑片、学习和修图的 Swift app;一个 Python 引擎,每个 Swift 移植版都要拿它来对照测试;一个给手机用的浏览器工作台;还有一个供脚本调用的命令行。 它们彼此不调用,下文说明的一个后备路径除外。它们共享一个目录——一个 SQLite 文件加一个缩略图文件夹——其格式有成文定义,并且两种语言都对它做了测试。
本页描述的是截至 2026 年 10 月已经存在的东西,而不是计划中的东西。计划中的部分以虚线绘制, 并列在页面底部,注明各自还在等什么。
7Photo 是产品名。photocull 是其引擎和命令的内部代号,也是每个拍摄随身携带的缓存文件夹的名字
.photocull/;本页的代码标识符沿用这个名字。
实际建成的样子
要看的是左边那条金色的线。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 和 1 | SQLite 没有布尔类型,假装有只会招来一个字符串 '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 是实验室,也是每个移植版对照测试的标准答案。剩下的是手机,以及一个可以交给别人的构建。
| 改动 | 在等什么 |
|---|---|
用一个 launchd 下的 Swift 守护进程,取代给手机用的 Python 工作台 | 一次真正离家、经 Tailscale、Mac 处于睡眠状态的测试。工作台已经在局域网内用手机用过;这个赌注所依赖的那次测试还欠着。为了弄清远程访问好不好用而先把守护进程做出来,等于还没提问就先为答案付了钱。 |
| 一个带图标、已签名并经过公证的构建 | 一个 Xcode 项目。app/make-app.sh 会构建出内置模型的 app/.build/7Photo.app,作为开发脚手架:拷到哪里都能运行,但没有签名。 |
| 人脸模型 | 一个决定。InsightFace 的模型仅限非商业研究使用,几乎所有公开的人脸识别模型也是如此;Apple 没有用于身份识别的对应能力,这也是为什么 Vision 只是一个额外信号而不是检测器。任何更换都会改变每一个身份向量,而姓名正是靠比较这些向量在各个拍摄之间传递的,所以更换必然伴随一次有计划的重新扫描。 |
| Linux 和 Windows | 什么也不等;它们被搁置了。能服务它们的是 Python 那一层,而 Swift 不会离开 Mac。 |