The thing that clicked today: I touched almost every part of the project at once, no single big feature, more like five threads braided together, each going recon then build then verify then ship. Writing it down so I remember what actually moved.
Built / shipped
Renamed the project to Loupe, for real. The two repos finally got the names I'd been calling them in my head: the app became loupe, the pipeline became loupe-pipeline. This had been sitting as a planning doc for a while. I'd even talked myself out of a physical rename earlier in favor of symlinks. This time I reversed that and just did it: stop everything, move the directories, fix the hardcoded sibling path in seven app files, restart, verify the app still serves live data from both database layers. It does. Felt good to stop apologizing for the old names.
Map and Cutting Room went live. Promoted two things from buried sub-tabs to real top-level pages. The Map draws a bundled, public-domain basemap (no external map tiles, no API calls at all) under ~48k geotagged points with clustering and a time scrubber; selecting a place opens a preview card before you commit to reviewing it. The Cutting Room turns the old "candidates" filter toggle into a designed page: six rule piles with real reclaim numbers, contact-sheet strips, a "review all" path into the existing flow.
The People view. This is the one I'm most excited about. The pipeline had already detected faces across the library and stored an embedding for each one (a list of numbers that captures what the face looks like, so two embeddings can be compared), but nothing in the app ever read them. Totally new ground. The Faces module loads every embedding into memory the first time it is asked, and "find more faces like this" is a similarity comparison of one face against all of them. At this library size that is a single matrix operation; it did not need a dedicated search library. Phase 1 was read-only suggestions; Phase 2 added the confirm/reject/snowball loop, the first writes the app makes to that database. Confirm a face, it becomes an anchor, the next round of suggestions gets better. I validated it by rendering montages of my own top matches: the top ~100 are unambiguously me, and quality only starts drifting around a similarity of 0.87.
A real brand. Redrew the identity around a "loupe-cup" lens-on-film mark and pushed it everywhere: the animated loading spinner, the static header mark, and a self-contained SVG favicon plus a full icon set (16 through 512 pixels). Same icons shipped to the live davidgomez.cc marketing site so the tab looks identical in both places. Also changed the page title to the tagline: Loupe: look · cull · loop. And I wrote up the whole CSS/SVG design system as a dossier so the visual language is documented, not just vibes.
The onboarding front door. Started the setup flow that picks where your library lives and, at a high level, handles connecting to your photo library / signing in. The whole thing is reachable only on my home network, never from the public side. (More on what I'm deliberately not detailing below.)
Problems & fixes
- The People button "did nothing." The click handler was wired correctly. The view just rendered behind the main app because it wasn't a full-screen overlay like every other working view. Matching the overlay pattern made it appear. Lesson: a dead-looking button is as often a layering problem as a broken click handler.
- Face crops were going to hammer the network storage. Every original lives on a slow network mount. Face crops come from the existing thumbnail cache instead, only reaching for hi-res on tiny group faces, and each crop is cached by face id.
- The face embeddings were not normalized. The comparison only works if every embedding is scaled to the same length first, and the ones coming out of the face model were not, so the similarity scores were nonsense. Found it the way you'd expect: by getting nonsense scores first, and then learning why.
- Favicon rendering with no rasterizer. No SVG-to-PNG tool installed and no way to sudo one in, but a graphics library was already present, so the whole icon set got rendered through a throwaway virtual environment.
Decisions
- Faces lives in its own module; embeddings load once into memory; writes touch only the assignment and rejection tables, never the detected-face rows or embeddings, gated behind a pre-write backup and a byte-identical check after.
- Per-person rejections needed their own table, because the assignments table keys on the face, which can't express "this face is not person A but might still be person B."
- Map uses an offline place lookup and a bundled basemap: zero external map API calls, by design.
- Committed the favicon PNGs into the repo (un-ignored them) so a fresh clone renders icons with no build step.
Learned
- A restart only helps if the running process is newer than your edit: verify the served code, not just that you issued a restart.
- When checking that images loaded in a headless browser, count successful network responses, not
<img>tags. The map library creates tile elements even when their source 404s. - Safari's Cmd-Shift-R is Reader mode, not hard refresh, and favicons live in a cache that survives normal reloads. Cost me a few "why isn't it updating" minutes.
Still open / next
- The People view wants a Phase 3: creating new people, manually seeding someone with zero anchors, and filtering out the tiniest faces that confuse the threshold.
- The map loads about 48,800 geotagged points where I expected closer to 61k. Need to reconcile where the gap is.
- The setup/onboarding flow has a piece I still have to drive by hand on my home network, and a security review I want done before I take it any further. Treating that thread with extra care.