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

7Photo architecture as built The macOS app, the web workbench and the command line sit at the top. The app measures, judges and retouches in Swift on Core ML and Vision, and reads and writes the shared cache directly; the web workbench and the command line both go through the Python engine. The cache — a SQLite database, a thumbnail directory and a decision backup — sits in the middle. The original photographs sit at the bottom and are never overwritten; XMP sidecars are written beside them, and a saved retouch goes into a Retouched folder beside them. SWIFT · macOS 14+ 7Photo.app about 15,000 lines · no third-party packages 7Photo app SwiftUI · AppKit Sidebar · Grid · Frame · Compare · Retouch PhotocullCore library Connection — SQLite through its C API Library · Decisions · People · Albums Measure Core ML · Vision · Judging · Retouch Thumbnails (ImageIO) · Search · Catalogue Core ML models inside the bundle · needs no Python ANY BROWSER Phone · tablet · laptop thumb buttons below 820px PYTHON · FastAPI Web workbench photocull review 23 routes · one HTML file, no build step JobRunner — scans run in-process threads SHELL Terminal · scripts · cron everything batch is a command PYTHON · Typer The command line scan · score · learn · people · scenes duplicates · search · editstyle · export backup · retouch · review · encode-text PYTHON 3.12 · the laboratory The engine, photocull — the oracle every Swift port is tested against OpenCV · NumPy sharpness, exposure, scene ONNX Runtime + CoreML InsightFace · CLIP · NIMA SciPy · scikit-learn clusters · Bradley–Terry exiftool a subprocess THE CONTRACT — the only thing the two languages share .photocull/ format in tables.py · tested from both sides photocull.db SQLite · WAL · several readers, one writer at a time featurea photograph · 53 columns facea face · vector as BLOB filea file · copies are one decision(uid, source) personname, priority, centroid keywordwords, to dc:subject thumbs/ uid.g.jpg512px · the grid uid.jpg1400px · a frame same names from Swift and Python, so either fills the other's cache decisions.jsonl the backup of the one table that cannot be recomputed sorted, diffable, commit it YOUR FILES — card · SSD · NAS The originals: read, never moved, never written over identity is a hash of the picture's bytes — the image data of a JPEG or PNG, the whole file otherwise — so a verdict survives a rename, a move, a restore, or a rating written in by another program IMG_0001.CR3 IMG_0001.xmp ← merged, never replaced Retouched/ ← only on Save Full Size HTTP · JSON + JPEG argv · exit imports it imports it SQLite C API, in-process browses query_only · a scan writes it ImageIO · 1.0 ms a cell writes reads pixels · writes .xmp ImageIO · scan, 1:1 · .xmp, Retouched/
Swift Python browser the shared cache planned

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 → toMechanismThe 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.

DataStored asWhy
Face and image vectorsBLOB of little-endian float32Exactly the memory layout Swift wants; one memcpy. JSON would triple the file and cost a parse.
Capture timeinteger microsecondsSorts 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.
Booleans0 and 1SQLite has none, and pretending otherwise invites a string 'true'.
Perceptual hashthe unsigned bits reinterpreted as signedSQLite'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.

7Photo architecture, planned The planned shape. The Mac app, built, measures on Apple's frameworks and reads the cache directly. A Swift daemon run by launchd, not yet built, would serve the phone and replace the Python web workbench. The Python engine stays as the laboratory and the oracle that checks every Swift port. The cache is unchanged. SWIFT · the product 7Photo.app scans, judges, learns, retouches no Python, no repository needed built · unsigned, not notarised ANY BROWSER Phone, away from home SWIFT · launchd photocull-serve the read endpoints, in Swift survives the window closing and App Nap after the Tailscale test, away from home PYTHON · the laboratory The engine, as the oracle the CLI and the web workbench new measures are tried here first every Swift port is checked against it, on fixtures and on a real shoot the workbench may retire once the daemon serves APPLE FRAMEWORKS Where the measuring went Core ML — InsightFace, CLIP, NIMA Vision — face capture quality ImageIO — decoding · Accelerate OpenCV's arithmetic, where the pixels depend on it UNCHANGED .photocull/ the same file, the same schema, the same rule about whose verdict wins this is what made every change above possible one piece at a time HTTP direct, as today reads the cache writes measurements
ChangeWaiting for
A Swift daemon under launchd replaces the Python workbench for the phoneA 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 iconAn 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 modelsA 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 WindowsNothing; they are parked. The tier that would serve them is the Python one, and no Swift would leave the Mac.