Essay
Atelier: a local-first photo culler that never touches your originals
A wedding photographer comes home with 20,000+ frames. Then it gets harder: the couple want every photo of themselves; an uncle wants his family's; a friend wants the three they're actually in. Twenty bursts of the same kiss, half with someone blinking; a few hundred guests scattered across the day. It's hours of squinting at near-identical thumbnails — and not just for the photographer.
Atelier is the tool I built for that — open source, in a weekend. Point it at a folder; it groups every face by person, groups near-identical shots into bursts, surfaces the strongest frame of each, and lets you bundle one person's photos to hand off. Nothing leaves your machine, and not one original file is ever moved, renamed, or modified.
The face model took an afternoon. The real work was everything that makes someone trust the tool with 400 GB of irreplaceable wedding photos: never lying about what it found, never leaking it, never losing their edits, never wedging halfway through.
The pipeline
It's four phases over a SQLite database — decode each photo once, then never touch the originals again.
Detection and recognition come from insightface's buffalo_l (RetinaFace + ArcFace). The reason it can cluster faces at all is the embedding space: on a real 1,798-photo reception, ArcFace put the same person's faces at ~0.98 cosine similarity and different people at ~0.15. (An earlier FaceNet backend only managed ~0.51 — too muddy to separate strangers from each other.) Bursts use a different signal entirely: a 384-d DINOv2 embedding of the whole scene, because two frames of the same moment look alike even when the faces don't.
Culling that's actually smart: one blink disqualifies the frame
Picking the best frame of a burst is where naïve scoring falls apart. Take the sharpest? You'll print the one where the bride blinked. Average everyone's eye-openness? A twelve-person group photo with one person mid-blink still scores fine — but you can never reshoot it, so that frame is dead.
The fix is to make the blink penalty scale with how many people are in the frame:
| People in frame | How eyes are scored | Why |
|---|---|---|
| 1 | that face's openness | a single mis-measured eye shouldn't sink a solo portrait |
| 2–3 | blend (½ min + ½ mean) | one blink matters, but not absolutely |
| 4+ | the minimum | one blink ruins a group shot you'll never get again |
A second rule sits on top: motion blur is a hard disqualifier. A frame with a great smile but camera shake gets its score crushed by 75%, because you can't print blur large no matter how good the moment was. Sharpness for that check is measured on the full-resolution pixels, not the downscaled copy used for detection — which is the whole reason each photo is held at two resolutions in memory.
You don't get one verdict, you get three. Every burst is tagged with its best (the group-aware score above), sharpest, and candid (off-axis, unposed) pick — so you skim three strong frames instead of twenty. It's not perfect, but it's right most of the time, and you're overruling a shortlist, not building one from scratch.
Then: get everyone their photos
Culling is only half the job — the other half is getting each person their photos. Because faces are already grouped by person, that turns into a few clicks: tag frames into a bucket as you review (number keys 1–9), or drop a whole person's photos into a bucket at once, then export the bucket to a folder.
Export copies the originals (never your edits, never the source) into a folder you zip and drop on Drive — one bucket per person, one link each. The same buckets let the couple sit down and pick the keepers of themselves without scrolling the whole event. The face grouping is what makes "give Aunt Maya her eighty photos" a thirty-second job.
Never lies: anchor human decisions on a key that survives
Clustering is a guess, so people correct it — merge two clusters that are the same uncle, split a toddler wrongly fused with his dad, rename "Person 17" to "Grandma." The trap: HDBSCAN rebuilds every person_id from scratch on the next run. Anything you keyed to that id silently reverts the moment you re-tune a threshold.
So every manual decision — merge, split, reassign, reject, rename — is stored against face_id, the one key that doesn't change between runs, and re-applied after clustering. Renames even survive id reshuffles by matching the new cluster to the old one with the highest face-membership overlap. The same lesson bit the print picks: they were first keyed on series_id (rebuilt every run), broke on regroup, and had to be re-keyed to the stable image id. The ML output is treated as advice; your edits are the source of truth.
Never leaks: unauthenticated, but defended
It's a single-user local tool, so there's no login. "No auth" is not the same as "no security," though — the real threats are a stray network bind and a malicious web page in your browser firing requests at localhost. Four layers handle it:
| Layer | Stops |
|---|---|
| Bind 127.0.0.1 only | the server ever being reachable off your machine |
| Host + Origin allowlist | a drive-by web page POSTing cross-origin to the API |
| Per-process CSRF token | forged writes — a cross-origin page can send a POST but can't read the token out of the HTML |
| Realpath export containment | path-escape: exports resolve symlinks and must sit under an allowed root |
The token is generated per process (it rotates on restart) and compared in constant time. None of this is theoretical hardening — each layer has a regression test, and the contributing guide has a section titled "Security model — please don't break it," because the one-line "convenience" change a future contributor reaches for is binding to 0.0.0.0.
Never wedges: a pipeline built to fail safely
Indexing tens of thousands of photos off an external drive will hit a corrupt file, a hung model download, a crash. The job is to make those survivable, not impossible.
Each phase runs as its own subprocess, so a model crash takes down a run, not the server. Every image is its own transaction: an exception rolls it back, marks it errored, and moves on — one poison file never aborts a 20,000-photo batch, and a re-run picks up exactly where it stopped. A watchdog kills any phase that goes silent (a stalled download) so the UI fails loudly instead of spinning forever, and a server restart reconciles any run left "running" by a crash to "interrupted."
The unglamorous parts that made it shippable
None of the above ships if the project is a pile of scripts. The boring decisions did the heavy lifting:
| Decision | What it bought |
|---|---|
| No build step (vanilla ES-module SPA) | Node is a dev-only linter; the web app is served verbatim from Flask — no bundler, no deploy artifact for a local tool |
| Lazy heavy imports | the clustering and scoring math is unit-testable with just numpy — no GPU, no 600 MB of models in CI |
| One SQLite DB per project | create is a file, delete is an rmtree; one wedding's people never bleed into another's |
| CI + 370 BDD scenarios | tested on two OSes × two Pythons; behavior documented in Gherkin as living spec |
The model was the easy part
Modern face recognition is a library call. What made Atelier worth using wasn't the embeddings — it was the resumable pipeline, the non-destructive edit model, the local-first boundary, and a scoring rule that understands a group photo. Building the demo took an afternoon. Building the version a photographer would hand 400 GB of someone's wedding to took the rest of the weekend — and that ratio is the whole point.
It's MIT-licensed and on GitHub. Local-first, no cloud, your originals never move.