Four things you touch.
One file they all agree about.
A Swift app that measures, culls, learns and retouches; a Python engine that every Swift port is tested against; a web workbench for the phone; and a command line for scripts. They do not call each other, but for one fallback described below. They share a directory — one SQLite file and a folder of thumbnails — whose format is written down and tested from both languages.
This page describes what exists as of October 2026, not what is planned. The planned parts are drawn dashed and are listed at the bottom with what each one is waiting for.
7Photo is the product. photocull is the internal codename of its
engine and command, and the name of the cache folder each shoot carries,
.photocull/; code identifiers on this page keep that name.
As built
The line to read is the gold one on the left. The app never asks anything for its data. It opens the SQLite file itself and reads thumbnails off disk, which is why a grid cell is a millisecond and why nothing in the culling loop waits on Python, a socket or another process. Measuring a shoot no longer needs Python either: the app scans in Swift on Core ML and Vision — the 435-frame test wedding in under a minute, the models on the Neural Engine — groups the people, judges by the rules or by a taste it learned from your verdicts, and retouches. The engine on the right is where that arithmetic was first written, and the reference each Swift port is checked against.
Who talks to whom, and how
Every connection on the diagram, with the mechanism and the rule that governs it. There are fewer than it looks: the Swift and Python halves have no connection to each other except through the files, and one fallback described below.
| From → to | Mechanism | The rule |
|---|---|---|
Mac app → photocull.db |
SQLite C API, in-process, about 230 lines of Swift | Browsing reads with PRAGMA query_only; verdicts are written only as human. A scan writes the engine's tables with the engine's DDL, replacing the cache whole, and suggests verdicts as model under the engine's guard. Never through a server. |
Mac app → thumbs/ |
ImageIO, an actor that keeps the books while decodes run detached | Never decode an original to draw a grid. Two sizes from one decode. |
| Mac app → originals | ImageIO; Foundation for the sidecar XML | Decoded once each to scan (a RAW by its embedded preview, as the engine reads it), and again only when zoomed past what the preview holds — "1:1" means the original's pixels. Never written over: export merges an .xmp beside it, and Save Full Size on a retouch writes a JPEG into Retouched/ beside it. |
| Mac app → its models | Core ML, from the bundle's Resources; Vision | InsightFace's face models, CLIP's image and text towers and NIMA, each converted from the engine's own ONNX and checked against it, and stored in 16-bit floats so Core ML runs them on the Neural Engine rather than the CPU. Cloned into the bundle copy-on-write (~520 MB), so a copy of the app runs with no repository and no Python. |
| Mac app → catalogue | SQLite, catalogue.db in Application Support |
Every shoot this Mac knows, the drive each is on, albums and people across shoots. It belongs to no shoot, so it lives outside them; a shoot on an unplugged drive is still listed. |
| Mac app → engine | A subprocess: photocull scan --json (JSON lines on stdout), then photocull score and photocull people; or photocull encode-text --serve for a search's words |
A fallback only, when the converted models are not found; a text model that is present but fails to load is reported. A bundle built with the models inside does not take this path. |
| Browser → web workbench | HTTP: JSON for data, JPEG for images | Bound to 127.0.0.1 by default, --tailscale or --lan on request. There is no password, so where it is bound is the access control. Reached from a phone over the LAN; over Tailscale not yet tested. |
| Web workbench → engine | Python import; long jobs on threads with a polled progress endpoint | Job state lives in memory and is lost if the process stops. |
| Shell → command line | argv in, text and an exit code out | Every batch operation is a command, so every batch operation is scriptable. |
Engine → photocull.db |
Python's sqlite3, through one Store class |
Measurements are rewritten wholesale in a transaction. A model verdict may never overwrite a human one — (uid, source) is the key. |
| Engine → originals | OpenCV and Pillow to read; exiftool as a subprocess to write sidecars |
Never written. The only output near an original is an .xmp beside it. |
| Swift ↔ Python | Nothing directly, but for the fallback above. The files above. | The schema is the interface. Swift is tested against fixtures and golden values the engine wrote; Python is tested against verdicts Swift wrote. |
Why the app does not go through a server
The tidy design would make the Mac app an HTTP client of its own backend: one code path, clean separation. It would also undo the measurement the whole design rests on. A grid cell is 1.0 ms from the cache; an HTTP round trip in front of that puts back exactly what was wrong with the browser version.
Separation of concerns is not separation of processes. The app and the engine are separated by a schema, which is a stricter boundary than an API, because it is written down and enforced by tests in both languages.
Why two writers are safe
The app and the engine can both write photocull.db while the other has
it open. SQLite in WAL mode is built for exactly that — any number of readers, one
writer at a time, a three-second wait instead of an error. And the one table that
cannot be recomputed, decision, is keyed so that a write from each
side can never collide: yours are human, a suggestion from either
engine is model, and a model's are forbidden from touching yours.
Found while building it: a read-only SQLite connection cannot
open a database in WAL mode, because WAL needs a shared-memory file a read-only
connection is not allowed to create. The app would have refused to open any
library it had ever judged. "Read only" is therefore spelled as an ordinary
connection plus PRAGMA query_only, which SQLite enforces per
connection and which actually works.
What lives in the file
Four type decisions, each made for the Swift reader rather than the Python writer, and each pinned by a test because every one of them fails quietly.
| Data | Stored as | Why |
|---|---|---|
| Face and image vectors | BLOB of little-endian float32 | Exactly the memory layout Swift wants; one memcpy. JSON would triple the file and cost a parse. |
| Capture time | integer microseconds | Sorts and compares identically in both languages. It is a wall clock with no time zone — the camera never recorded one — and the app formats it in UTC so nothing shifts it by the machine's offset. |
| Booleans | 0 and 1 | SQLite has none, and pretending otherwise invites a string 'true'. |
| Perceptual hash | the unsigned bits reinterpreted as signed | SQLite's integer is signed and a hash with its top bit set does not fit. Hamming distance reads bits, so it does not care. |
Identity
A photograph's uid is derived from its picture, not its file: for a
JPEG or PNG a BLAKE2b hash of the segments that say how it decodes and of its image
data — the first and last 64 KB of it and its length — so a rating or keywords
another program writes into the original leave it unchanged. Every other format is
hashed whole, and when a rewrite changes its uid the scan carries its
verdicts across by evidence: the same path, size, capture time and perceptual hash,
or nothing. A file renamed, moved within its shoot or restored from a backup keeps
its verdicts, and two copies of it in one shoot are one photograph, with a row each
in file. A RAW and the JPEG beside it with the same name are one
photograph too, measured from the JPEG, the RAW its companion. Every
identity a photograph leaves is written to uid_move, so albums in the
catalogue follow it whichever engine moved it. A
separate revision, from the size and modification time, answers the
other question — has this file changed, so is the measurement stale. The first
version conflated the two, which is correct for one machine and nothing else. Swift computes the same uid as the engine
(BLAKE2b ported, since CryptoKit has none): 435 of 435 on the test wedding.
What changes next
Most of what this section used to list has happened. The app scans, judges, groups people, learns a taste and retouches in Swift, and the engine has become what the plan said it would: Swift is what ships; Python is the laboratory, and the oracle every port is tested against. What is left is the phone, and a build that can be handed to somebody else.
| Change | Waiting for |
|---|---|
A Swift daemon under launchd replaces the Python workbench for the phone | A real test away from home over Tailscale, with the Mac asleep. The workbench has been used from a phone over the LAN; the test the bet rests on is still owed. Building the daemon to find out whether remote access is pleasant would be paying for the answer before asking. |
| A signed, notarised build with an icon | An Xcode project. app/make-app.sh builds app/.build/7Photo.app, with the models inside, as development scaffolding: it runs anywhere it is copied, but it is unsigned. |
| The face models | A decision. InsightFace's models are for non-commercial research only, and nearly every public face-recognition model is too; Apple has no equivalent for identity, which is why Vision is an extra signal rather than the detector. Any change moves every identity vector, and names are carried across shoots by comparing them, so it comes with a deliberate rescan. |
| Linux and Windows | Nothing; they are parked. The tier that would serve them is the Python one, and no Swift would leave the Mac. |