An event photo platform where attendees scan their face once and get back only the photos they're in — built on RetinaFace detection, ArcFace embeddings, and an async Celery pipeline that processes uploads at scale.
Galleria solves a problem every event photographer creates without meaning to: hundreds of photos get uploaded somewhere, and every attendee has to scroll through all of them to find the ten they're actually in. Galleria replaces that with a face scan. An organizer uploads photos in bulk, Galleria detects and embeds every face in them asynchronously, and an attendee holds their phone up to their face once to get back a filtered gallery of only the photos they appear in.
Under the hood, every photo goes through face detection, quality filtering, and embedding extraction as a background job, so uploading hundreds of photos doesn't block the API or the organizer's dashboard. When an attendee scans their face, that one embedding gets compared against every embedding stored for the event using cosine similarity, deduplicated down to one match per photo, and returned sorted by confidence — all in well under a second even at thousands of faces per event.
Searching thousands of face embeddings fast enough to feel instant
A single event can hold thousands of stored face embeddings — 1,000 photos at 8 faces average is 8,000 512-dimensional vectors. Comparing a user's scan against each one individually, in a loop, would be slow enough to make the "scan and wait" experience feel broken. The fix was loading all of an event's embeddings into a single NumPy matrix, normalizing once, and running the comparison as one batched matrix multiplication instead of N separate calls. That gets the full search — all 8,000 comparisons — under 100ms on CPU, and it's also where deduplication happens: a photo with five faces produces five embedding matches for the same person, and only the highest score per photo is kept.
Keeping CPU-bound face detection off the request path
RetinaFace and ArcFace running through DeepFace/TensorFlow are CPU-heavy, and running them inline in the FastAPI process — even as a background task — would compete with the web server for the same process's resources and risk losing a job entirely if the process restarted mid-run. Photo processing runs in a separate Celery worker pool instead, with `concurrency=2` since detection is CPU-bound, `prefetch_multiplier=1` so a worker isn't holding multiple jobs at once, and `acks_late=True` so a task isn't marked complete until it actually finishes — a worker crash mid-photo means the job gets retried, not silently dropped.
Tuning detection thresholds to avoid false matches
RetinaFace will happily detect a face that's too small, too blurry, or too far in the background to ever match reliably — and storing a bad embedding just means a wasted comparison at best and a false match at worst. Detections are filtered before an embedding is ever extracted: confidence below 0.9 or a bounding box under 80×80 pixels gets discarded. On the matching side, the similarity threshold defaults to 0.6 rather than something looser — the tradeoff was deliberate, since returning someone else's photo in an attendee's gallery is a worse failure than occasionally missing a legitimate match.
Splitting relational data from embeddings across two databases
Photos, users, and events are relational — foreign keys, joins, membership constraints. Face embeddings are a different shape of data entirely: a variable number of 512-float documents per photo, always queried scoped to one event, with metadata (bounding box, detection confidence, model version) attached to each one. PostgreSQL could technically store a float array, but it isn't built to scope high-volume vector queries by event or to make an eventual move to approximate nearest-neighbor search straightforward. Keeping embeddings in MongoDB, indexed on `(event_id, photo_id)`, kept both databases doing the kind of query they're actually good at.
Letting people search without creating an account or storing their face
Not every attendee wants to register just to find their own photos. Anonymous users can scan their face and get a gallery back without an account — but that meant deciding what happens to a biometric embedding for someone who never opted into having it stored. The answer was: nothing persists. The embedding is used once to compute matches, the matched photo IDs are cached in Redis behind a `scan_token` with a 2-hour TTL, and the embedding itself is discarded. A registered user's embedding is saved to their profile so they don't have to rescan on a return visit; an anonymous user's isn't, on purpose.
Working with biometric data changes what "it works" means. It's not enough for the matching to be accurate — I had to think about what happens to the embedding after the match, who can see it, and how long it sticks around, for every path through the system, not just the happy one. The anonymous-scan design came out of that: storing nothing by default and making persistence something a user opts into, rather than something that happens automatically because it's convenient to build.
The Celery pipeline also changed how I think about "background task" as a default. It's easy to reach for a framework's built-in background task runner because it's already there. Once the job is CPU-bound and can't be allowed to silently fail, that convenience stops being worth it — isolating the work in its own process pool with explicit retry and acknowledgment semantics is a decision I now make early, not something I retrofit after a job gets lost in production.