Flagship project
OGphotos
- Problem
- A Google Takeout export is full of exact copies, burst shots and near-identical photos, and the usual tools for sorting it either upload your library to a cloud service or only catch byte-identical files.
- Approach
- A desktop app that runs a local FastAPI server with an HTMX interface in the browser. It finds exact duplicates by SHA-256, near-duplicates by perceptual hash, groups similar shots with EmbeddingGemma 2 image embeddings, clusters faces with InsightFace, and suggests the best photo in each group.
- Result
- Released as v0.2.0 with installers for Windows, macOS and Linux. It runs fully offline after a one-time model download, detects an NVIDIA or Apple Silicon GPU and falls back to the CPU, and never deletes a file except through Empty Trash.
- My role
- Sole author (2026). Designed and built the app, the analysis pipeline, the installers and the release workflow.
- Stack
- Python 3.11
- FastAPI + Uvicorn
- Jinja2 + HTMX
- SQLite (WAL) + SQLAlchemy 2
- sentence-transformers
- EmbeddingGemma 2
- PyTorch (CUDA / MPS / CPU)
- InsightFace + onnxruntime
- NumPy
- OpenCV
- Pillow + imagehash
- uv
- GitHub Actions
- Links
01
Architecture
Step 1 of 8: First launch picks the right build for the machine
1First launch picks the right build for the machine
The launch script installs a private Python environment with uv. On Windows and Linux it asks nvidia-smi for the driver version and compute capability and installs the CUDA builds of PyTorch only if the GPU can use them; otherwise it installs the CPU builds. Apple Silicon uses its GPU through MPS. A setup page then asks where to keep the library and downloads the models once, showing each model's licence.
2Import: exact copies and dates
The Takeout folder is walked read-only and every file is copied in; nothing in the source is changed. SHA-256 is computed while the file streams, so Takeout's year-folder and album-folder copies of the same photo are stored once. The date taken comes from EXIF, then the Takeout .json sidecar, then the file time, and re-importing a folder repairs dates that were only guessed the first time.
3Near-duplicates by perceptual hash
Each image also gets a 64-bit perceptual hash. All stored hashes sit in memory as a NumPy array, and a new photo within a Hamming distance of 8 of an existing one is reported as similar right in the import results.
4A background worker embeds every photo
A resumable worker embeds photos in batches with EmbeddingGemma 2, truncated to 512 dimensions and L2-normalised, and measures sharpness as Laplacian variance. It runs in bfloat16 on CUDA when supported and float32 otherwise, never float16, which produced NaN embeddings; any NaN marks the file as failed. On a GPU the batch is halved after an out-of-memory error.
5Similar shots are grouped with complete linkage
Two photos count as similar if their perceptual hashes are within 8 bits or their embeddings have cosine similarity of at least 0.92, unless the user marked them "not similar". A photo joins a group only if it is similar to every member already in it, and groups are never merged automatically. Search is a brute-force cosine over an in-memory matrix.
6Review with a suggested best photo
In each group the best photo is starred by 0.5 × sharpness + 0.3 × resolution + 0.2 × file size, each min-max normalised within the group. Review is keyboard-driven: 1–9 toggles a photo, Enter applies, K keeps the best and trashes the rest.
7Trash moves files; only Empty Trash deletes
Trashing moves a file into the data folder's trash/ and removes it from the indexes; restoring never overwrites an existing file. Empty Trash is the only code path that deletes photos: it needs an explicit confirm flag, removes database rows before files, and refuses any path that resolves outside trash/.
8Faces become people
A separate worker detects faces with InsightFace buffalo_l (SCRFD detector, ArcFace 512-d embeddings) on onnxruntime, keeping faces with a score of at least 0.6 and at least 60 px. A face joins a person when its nearest neighbours agree at cosine ≥ 0.45; a new person needs 3 agreeing faces. A full recluster keeps faces the user assigned and people they named fixed.
02
Key decisions
Complete linkage instead of joining on any match
The first rule, join a group if similar to any member, chained A≈B≈C≈D on a real library of about 3,000 photos: very large groups with member pairs at cosine 0.79 and one group spanning years. Requiring similarity to every member keeps groups small and each one spans minutes. The cost is that some near-duplicates stay ungrouped, because groups are never merged automatically.
Half the vision tokens for a CPU-friendly speed
On a mid-range laptop CPU, embedding took 16 s per image at the default 280 vision tokens and 7 s at 140, and the two settings' similarity matrices correlated at 0.994. 140 is the default; the budget is a setting, so a GPU machine can raise it.
Brute-force NumPy search instead of a vector database
10,000 photos × 512 float32 dimensions is about 20 MB, so a single matrix product in memory is fast enough and adds no dependency or index to keep in sync with SQLite. It is a linear scan, so a much larger library would need an approximate index.
A localhost web app instead of a native GUI
FastAPI with Jinja2 and HTMX needs no frontend build and runs the same on all three platforms. A server on 127.0.0.1 can still be reached by any web page in the browser, so non-GET requests whose Origin does not match the Host, or that are marked cross-site, are refused with 403.
03
Results
- Cross-platform installers built by GitHub Actions on each release: a Windows x64 installer, a macOS 14+ Apple Silicon dmg and a Linux tarball for x86_64 and aarch64; each is installed, launched and health-checked in CI before release.
- Fully offline after the one-time model download: the app listens only on 127.0.0.1, with no telemetry and no update checks.
- GPU auto-detection with CPU fallback: CUDA builds on a capable NVIDIA GPU, MPS on Apple Silicon, CPU everywhere else, with an environment variable to override.
- Never deletes user files outside Empty Trash, and never modifies the imported Takeout folder.
- Ruff and 55 pytest test files run on Ubuntu, Windows and macOS for every change.
04
What I'd do next
- Sign the installers; today Windows and macOS show a warning on first launch.
- Use stricter linkage for very large face clusters: look-alike people (for example family members) can still merge into one person.
- Count same-person splits systematically; face thresholds were tuned on a partial scan of one library.
- Support Windows on ARM and Intel Macs, which have no installer yet.