Never miss a birthday post again!
Last year, I changed my trusty phone of 7+ years: archiving 100K+ photos to a local disk 😅! And while my iPhone may have built-in People features now, my laptop does not. All I wanted was to choose a friend's face and wish them happy birthday 🎉 without uploading my entire life to the cloud!
So... I built Face Finder. Just provide a directory, and buffalo_sc will detect + cluster every face: allowing you to pick from the people you know instead of filesystem hell. You can name them, merge duplicates, and even search for photos of 2+ people together!
It's built to run locally, so nothing ever leaves the container. 🔒📴
P.S. You could probably tell from my terrible naming skills, but the images in the screenshot AREN'T actually mine! They come from the lfw-deepfunneled dataset on Kaggle.
- 🐳 Docker Desktop (macOS/Windows) or Docker Engine + Compose plugin (Linux).
- 💪 At least 4 CPU cores / 4 GB RAM free for Docker.
- 🖼️ A local folder (with subfolders? sure!) of photos you want to label by face.
Clone the repository, then open a
terminal in the project's root folder — the one containing docker-compose.yml.
Two ways, pick one:
Option A — env var (recommended, no file edits) 🌱:
export PHOTOS_HOST_DIR=/absolute/path/to/your/photos(Windows PowerShell: $env:PHOTOS_HOST_DIR="C:\Users\you\Pictures\Family")
Option B — edit docker-compose.yml directly ✍️:
volumes:
- /absolute/path/to/your/photos:/data/photos:roEither way, this mount is read-only (:ro) 🛡️ — the engine cannot modify,
rename, or delete your source files.
docker compose up --buildFirst build takes a few minutes ⏳ — it installs dependencies and downloads the SCRFD/ArcFace model weights once, at build time. After that, the container never needs network access again 🚫🌐.
Leave this terminal running. Once you see Uvicorn running on http://0.0.0.0:8000, it's ready ✅.
Go to http://localhost:8000 in your browser 🌍.
- Click Scan library 📸. Progress shows in the status pill top-right. For 10,000+ photos on CPU, budget roughly 10–30 minutes depending on your machine (target throughput is 5–25 images/sec per the spec).
- When it finishes, click Re-cluster 🧩. This groups all detected faces into people using DBSCAN over cosine distance.
- The People grid populates with a card per detected individual 👤, sorted by how many photos they appear in.
- 🖱️ Open a person — click their card to see every photo they appear in.
- 🏷️ Name them — type a name in the field at the top of their gallery, click Save name.
- 🔀 Merge duplicates — if the same person got split into two cards, open one, click Merge into…, and pick the other. Their faces combine into one card.
- ❌ Remove a bad match — hover a face thumbnail in someone's gallery and click the × in the corner to unassign that one face (it won't delete the photo, just the association).
- 🔍 See where a face was detected — click any thumbnail to open the full photo with a bounding box drawn around the selected person (highlighted) and any other detected faces (dimmed).
Want photos containing several specific people at once — not just one? Perfect for finding that one group photo from a birthday party 🎂🥳.
- Open any person's gallery.
- Click Find together…. A panel opens listing every other identified person as a chip.
- Click chips to pick who else must appear alongside the person whose gallery you're in. Selected chips highlight ✨.
- Click Apply filter. The gallery narrows to only photos where all selected people appear together — an exact match, not "any of them." An active-filter bar appears above the grid showing who's selected.
- Click Clear filter (in the bar, or Clear inside the panel) to drop back to that person's full gallery.
This runs entirely off already-indexed faces — no rescan or recluster needed ⚡, works instantly against whatever's currently in the database.
Drop new files into the same folder, then click Scan library again 🔄. Unchanged files are skipped via hash + modified-time comparison — only new or changed files get re-processed. Click Re-cluster afterward to fold any new faces into existing (or new) people.
docker compose down # stop, keep your indexed data
docker compose up # restart without re-downloading models
docker compose down -v # stop AND wipe the indexed database + thumbnailsYour index (SQLite DB + face thumbnails) lives in the named Docker volume
face_app_data, separate from your photos, so restarting the container
never touches your original files 🗂️.
docker-compose.yml # orchestration: ro photo mount, rw data volume
Dockerfile # build-time model download, runtime = zero egress
requirements.txt
app/
main.py # FastAPI routes (scan/cluster/people/photos/together)
db.py # SQLite + sqlite-vec schema and connection
ingest.py # traversal, hashing, EXIF fix, thumbnailing
face_engine.py # SCRFD detection + ArcFace embedding (InsightFace)
cluster.py # DBSCAN over cosine distance
static/ # single-page web UI (vanilla JS, no build step)
Default DBSCAN settings live in app/cluster.py:
EPS = 0.6 # cosine distance threshold — lower = stricter matches
MIN_SAMPLES = 3 # faces needed before a group counts as a "person"If you're getting the same person split across multiple cards, raise
EPS slightly (e.g. 0.65) and rebuild. If different people are getting
merged together, lower it (e.g. 0.5). After editing, rebuild with
docker compose up --build and click Re-cluster in the UI — no
need to re-scan.
Pull up their face, grab a photo, send it their way 🥳📩.