Face-Me is a full-stack photo organization app that detects faces in uploaded images, extracts embeddings, clusters similar faces into people, and lets users browse the results in a dashboard.
This repository is organized as a small monorepo:
frontend/contains the Next.js web app.backend/contains the FastAPI API, the background worker runtime, the database models, and the face-processing pipeline.docker-compose.ymlwires local Postgres, the API container, the worker container, and the frontend together.scripts/andbackend/scripts/contain smoke tests for end-to-end verification.
At a high level, a user signs in, uploads photos, starts a processing job, and then sees the photos grouped by person. Under the hood the app:
- Accepts uploads from the browser or imports images from Google Drive.
- Stores original images in local disk storage or Azure Blob Storage.
- Creates persistent
ImageandPipelineJobrecords in the database. - Runs a background worker that downloads the images, detects faces with InsightFace, and stores cropped face thumbnails plus embeddings.
- Runs DBSCAN clustering across that user's embeddings.
- Exposes clusters, images, and asset URLs back to the frontend for browsing and renaming.
flowchart LR
Browser[Browser] --> Frontend[Next.js frontend]
Frontend -->|/api rewrite| API[FastAPI API]
API <--> DB[(PostgreSQL)]
API <--> Storage[(Local filesystem or Azure Blob)]
API --> Queue[(Database queue or Azure Queue)]
Queue --> Worker[Worker runtime]
Worker --> Storage
Worker --> DB
Worker --> ML[InsightFace + ONNX Runtime + DBSCAN]
| Layer | Technology | Notes |
|---|---|---|
| Frontend | Next.js 16, React 19, TypeScript | App Router app with client-side auth gating and /api rewrites |
| UI | Tailwind CSS v4, shadcn/ui, Radix UI, Framer Motion, Lucide | Dark-first dashboard and marketing site |
| API | FastAPI, SQLAlchemy, Alembic | Cookie-based auth, upload orchestration, asset serving, job APIs |
| Worker | FastAPI worker app + threaded runtime | Runs the face-processing pipeline and queue polling |
| Database | PostgreSQL in normal development and production | SQLite is only used in tests/smoke checks |
| Storage | Local filesystem or Azure Blob Storage | Originals and cropped faces are stored separately |
| Queue | Database polling or Azure Queue Storage | Database queue is the local default |
| ML pipeline | InsightFace, ONNX Runtime, OpenCV, NumPy, scikit-learn | CPU inference and DBSCAN clustering |
| Deployment | Docker, Docker Compose, Azure Container Apps, Vercel | Current production-oriented topology |
.
├── backend/
│ ├── app/
│ │ ├── routers/ # HTTP routes
│ │ ├── services/ # storage, queue, drive, clustering, face engine
│ │ ├── auth.py # session creation and cookie auth
│ │ ├── config.py # env loading and defaults
│ │ ├── database.py # SQLAlchemy engine/session
│ │ ├── main.py # API entrypoint
│ │ ├── worker_app.py # worker health app entrypoint
│ │ └── worker_runtime.py # polling loop
│ ├── alembic/ # database migrations
│ ├── tests/ # backend unittest suite
│ ├── scripts/ # backend smoke test
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/app/ # Next.js routes
│ ├── src/components/ # dashboard layout, dropzone, UI primitives
│ ├── src/lib/ # auth context and API client
│ ├── Dockerfile
│ └── package.json
├── docker-compose.yml
├── scripts/smoke_test.py
└── README.md
- The frontend calls
POST /api/auth/registerorPOST /api/auth/login. - The backend hashes the password with bcrypt, creates a random session token, stores only the SHA-256 hash in the
sessionstable, and sets an HTTP-only cookie. - On page load the frontend auth context calls
GET /api/auth/meand keeps the current user in memory. - Protected pages are client-gated. Anything outside
/,/signin, and/signupredirects to/signinwhen no session is present.
- The upload page collects files locally in the browser.
- The frontend calls
POST /api/uploads/initiatewith file metadata only. - The backend validates MIME type and size, then returns one upload target per file.
- In local mode the target is an authenticated backend route like
PUT /api/uploads/local/uploads/{storage_key}. - In Azure mode the target is a signed Blob Storage URL with the required headers.
- The browser uploads file bytes directly to the returned target.
- The frontend then calls
POST /api/uploads/complete. - The backend verifies that each uploaded object exists in storage and creates
Imagerows with metadata such as original filename, source, content type, byte size, and status.
- The frontend calls
POST /api/pipeline. - The backend creates a
pipeline_jobsrecord with statusqueued, a short generated job ID, a JSON payload, and zeroed progress. - The queue service is notified:
- In database mode there is no external message; the worker simply polls queued rows.
- In Azure mode the worker reads from Azure Queue Storage.
- The worker claims the job and updates status/progress.
process_images()downloads original image bytes from storage.- OpenCV decodes the image and InsightFace detects faces plus normalized embeddings.
- Each detected face is cropped, resized to
160x160, JPEG-encoded, written to thefacesstorage area, and persisted in thefacestable alongside the embedding bytes and bounding box JSON. - Images are marked
processedon success orfailedwithprocessing_errorif detection fails.
- After face extraction, the worker runs DBSCAN with cosine distance across all face embeddings for that user.
- Noise points receive no cluster ID and show up in the ungrouped view.
- For each non-noise cluster, the backend creates a
Clusterrow and picks the representative face with the highest detection score.
- The upload page polls
GET /api/jobs/{job_id}until the job iscompletedorfailed. - Dashboard pages fetch:
GET /api/imagesGET /api/clustersGET /api/clusters/{id}GET /api/ungrouped
- The backend returns asset URLs rather than raw filenames.
- In local storage mode asset URLs point back to authenticated backend routes under
/api/assets/.... - In Azure storage mode asset URLs are SAS-signed Blob URLs, so the browser can fetch the objects directly from Azure.
The frontend is responsible for:
- marketing and onboarding pages
- sign in and registration
- upload orchestration
- job-status polling
- dashboard stats
- cluster browsing and renaming
It does not perform face processing itself. All heavy work happens in the backend worker.
| Route | Auth | Purpose |
|---|---|---|
/ |
Public | Landing page with product explanation and CTA |
/signin |
Public | Sign-in form |
/signup |
Public | Registration form |
/dashboard |
Protected | Overview page with image/cluster/ungrouped counts |
/dashboard/upload |
Protected | File upload flow and pipeline progress UI |
/dashboard/gallery |
Protected | Cluster list with client-side search |
/dashboard/gallery/[id] |
Protected | Cluster detail page and rename action |
- All browser requests use relative
/api/*paths. frontend/next.config.tsrewrites those paths toAPI_ORIGIN, which keeps the browser talking to the frontend origin while the server proxies the request to the API.- The frontend API client always uses
credentials: "include"so the session cookie participates in auth. - The app uses a small amount of global state:
AuthProviderstoresuser,loading,refreshUser, andlogout.
- Everything else is page-local React state. There is no React Query, Redux, Zustand, or server-action data layer.
- App Router layout in
frontend/src/app/layout.tsx - custom theme tokens in
frontend/src/app/globals.css - shadcn/ui primitives in
frontend/src/components/ui/* - dashboard shell in
frontend/src/components/layout/dashboard-layout.tsx - drag-and-drop upload input in
frontend/src/components/dropzone.tsx
- Google Drive import is implemented in the API client, but the current upload page ships with
GOOGLE_DRIVE_ENABLED = false, so the Drive UI is intentionally disabled. - The client exports
mergeClusters()insrc/lib/api.ts, but there is currently no merge UI wired to it. - Images are rendered with plain
<img>tags using backend-provided URLs, not Next.jsImage. - The dashboard is client-rendered after auth bootstrap; there is no server-side route protection at the Next layer.
The backend handles:
- user registration, login, logout, and session validation
- image upload orchestration
- Google Drive ingestion
- pipeline job persistence
- queue polling and background execution
- face detection, embedding extraction, and crop generation
- face clustering
- serving or signing original and cropped assets
The backend image is used in two modes:
- API mode:
APP_MODULE=app.main:app - Worker mode:
APP_MODULE=app.worker_app:app
This split lets you run the same Docker image twice in production.
| Area | Endpoints | Purpose |
|---|---|---|
| Auth | POST /api/auth/register, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me |
Session lifecycle |
| Uploads | POST /api/uploads/initiate, PUT /api/uploads/local/uploads/{storage_key}, POST /api/uploads/complete |
Upload orchestration |
| Import | POST /api/import/drive |
Pull public Google Drive files/folders into storage |
| Images | GET /api/images |
List uploaded/imported images |
| Pipeline | POST /api/pipeline, GET /api/jobs/{job_id} |
Start work and poll status |
| Clusters | GET /api/clusters, GET /api/clusters/{cluster_id}, PATCH /api/clusters/{cluster_id}, POST /api/clusters/merge, GET /api/ungrouped |
Cluster browsing and curation |
| Assets | GET /api/assets/uploads/{storage_key}, GET /api/assets/faces/{storage_key} |
Authenticated local asset serving |
| Health | GET /api/health/live, GET /api/health/ready |
Liveness and readiness |
| Module | Responsibility |
|---|---|
app/auth.py |
Password hashing, session creation, cookie management, current-user lookup |
app/services/storage.py |
Local disk and Azure Blob storage backends |
app/services/queue.py |
Database queue and Azure Queue abstractions |
app/services/drive.py |
Public Google Drive file/folder download and storage upload |
app/services/face_engine.py |
InsightFace model loading, image decode, face crop + embedding extraction |
app/services/cluster.py |
DBSCAN clustering and representative face selection |
app/services/pipeline.py |
Job creation, serialization, and pipeline execution |
app/worker_runtime.py |
Threaded poll loop that receives and processes jobs |
| Table | Purpose |
|---|---|
users |
User identity and password hash |
sessions |
Hashed session tokens, expiry, and last-seen timestamp |
images |
Original uploaded/imported photos and processing state |
faces |
Detected face crops, embeddings, bounding boxes, cluster linkage |
clusters |
Per-user grouped identities and representative face |
pipeline_jobs |
Persistent async job metadata, progress, payload, result, and errors |
STORAGE_PROVIDER=local- Originals live under
LOCAL_STORAGE_ROOT/uploads - Face crops live under
LOCAL_STORAGE_ROOT/faces - Asset URLs are served through authenticated backend routes
STORAGE_PROVIDER=azure- Upload initiation returns signed Azure Blob PUT URLs
- Image and face reads return SAS-signed Azure Blob GET URLs
- The backend still stores metadata in Postgres; only the binary assets move to Blob Storage
QUEUE_PROVIDER=database- Jobs are stored in
pipeline_jobs - the queue receiver claims the oldest queued row and marks it
running app.mainstarts an embedded worker thread automatically in this mode
QUEUE_PROVIDER=azure- job IDs are pushed to Azure Queue Storage
app.worker_appis the intended long-running consumer
- InsightFace runs on
CPUExecutionProvider - face detection size defaults to
640x640 - face crops are resized to
160x160 - embeddings are stored as raw float32 bytes
- clustering uses
DBSCAN(eps=0.5, min_samples=2, metric="cosine")
SECRET_KEYexists in config, but the current auth path does not depend on it for session signing because sessions are stored server-side in the database.- The worker prewarms the InsightFace model on startup, which improves steady-state latency but makes cold starts heavier.
- Local development with the database queue starts an in-process worker in the API and can also run a separate worker service. That means local compose may have more than one consumer, which is acceptable because jobs are claimed by updating the database status.
There are three separate configuration surfaces:
- root
.env.example: variables consumed bydocker-compose.yml frontend/.env.example: frontend-only configbackend/.env.example: backend-only config
| Variable | Purpose |
|---|---|
SECRET_KEY |
Passed through to backend containers |
CORS_ORIGINS |
Backend CORS allowlist |
GOOGLE_API_KEY |
Optional Drive folder import support |
STORAGE_PROVIDER |
local or azure |
QUEUE_PROVIDER |
database or azure |
API_ORIGIN |
Backend origin the frontend should proxy to |
| Variable | Required | Description |
|---|---|---|
API_ORIGIN |
Yes | Destination used by Next.js rewrites for /api/* |
| Variable | Description |
|---|---|
DATABASE_URL |
SQLAlchemy connection string |
ENVIRONMENT |
Optional environment label, defaults to development |
SECRET_KEY |
Reserved application secret; currently not used directly by the DB-backed session flow |
CORS_ORIGINS |
Comma-separated origin allowlist |
SESSION_COOKIE_NAME |
Cookie name for session auth |
SESSION_COOKIE_DOMAIN |
Cookie domain, usually blank locally and your public frontend domain in production |
SESSION_COOKIE_SAMESITE |
Cookie SameSite policy |
SESSION_COOKIE_SECURE |
true in HTTPS production, false locally |
SESSION_MAX_AGE_SECONDS |
Session lifetime |
SIGNED_URL_TTL_SECONDS |
TTL for Azure SAS read URLs |
| Variable | Description |
|---|---|
STORAGE_PROVIDER |
local or azure |
LOCAL_STORAGE_ROOT |
Root directory for local storage |
AZURE_STORAGE_CONNECTION_STRING |
Optional Blob Storage connection string |
AZURE_STORAGE_ACCOUNT_URL |
Optional Blob account URL used with managed identity |
AZURE_UPLOADS_CONTAINER |
Blob container for original uploads |
AZURE_FACES_CONTAINER |
Blob container for cropped faces |
| Variable | Description |
|---|---|
QUEUE_PROVIDER |
database or azure |
AZURE_QUEUE_CONNECTION_STRING |
Optional Azure Queue connection string |
AZURE_QUEUE_ACCOUNT_URL |
Optional queue account URL used with managed identity |
AZURE_QUEUE_NAME |
Queue name |
WORKER_POLL_INTERVAL_SECONDS |
Poll interval when idle |
QUEUE_VISIBILITY_TIMEOUT |
Azure Queue visibility timeout |
| Variable | Description |
|---|---|
GOOGLE_API_KEY |
Required for public Drive folder imports |
INSIGHTFACE_MODEL_NAME |
InsightFace model family, default buffalo_l |
INSIGHTFACE_DET_WIDTH |
Detection input width |
INSIGHTFACE_DET_HEIGHT |
Detection input height |
INSIGHTFACE_MODEL_ROOT |
Optional model cache override |
| Variable | Description |
|---|---|
APP_MODULE |
Uvicorn import path, app.main:app or app.worker_app:app |
UVICORN_WORKERS |
Uvicorn worker count |
PREWARM_MODEL |
Docker build arg that controls prewarming during image build |
INSIGHTFACE_HOME |
Dockerfile model cache path |
- Node.js 20+
- npm 10+
- Python 3.11+
- Docker Desktop or another Docker-compatible runtime if you want the compose workflow
- PostgreSQL 16 if you want to run the services outside Docker
This is the easiest way to get the whole stack up.
-
Copy the root env file:
cp .env.example .env
-
Start Postgres first:
docker compose up -d db
-
Run migrations once:
docker compose run --rm backend alembic -c alembic.ini upgrade head
-
Start the application stack:
docker compose up --build
-
Open the apps:
- frontend:
http://localhost:3000 - backend API:
http://localhost:8000 - worker health app:
http://localhost:8001
- frontend:
You can use your own local Postgres or the compose database service:
docker compose up -d dbpython3 -m venv .venv
source .venv/bin/activate
cd backend
pip install -r requirements.txt
cp .env.example .env
alembic -c alembic.ini upgrade head
uvicorn app.main:app --reload --port 8000Important local behavior:
- with
QUEUE_PROVIDER=database, runningapp.mainalready starts the worker thread in-process - running
uvicorn app.worker_app:app --reload --port 8001is optional locally and mainly useful when you want to mirror production topology or test the worker health endpoint
cd frontend
npm install
cp .env.example .env.local
npm run devThe frontend expects API_ORIGIN=http://localhost:8000 in local development.
- In manual local mode, the backend default storage root is
${repo}/storageunlessLOCAL_STORAGE_ROOToverrides it. - In Docker Compose, uploads and face crops are stored in named Docker volumes.
cd backend
source ../.venv/bin/activate
python -m unittest discover -s tests
python scripts/smoke_test.pycd frontend
npm run lint
npm run buildThere is also a root-level smoke test that bootstraps the backend package path for quick end-to-end verification:
source .venv/bin/activate
python scripts/smoke_test.py- Backend tests use temporary SQLite plus local filesystem storage.
- The expensive ML stages are patched with fake implementations.
- That means the tests verify auth, upload orchestration, job state transitions, asset access, and cluster CRUD flow.
- They do not benchmark or validate real face-recognition quality.
The codebase is already structured for this topology:
- frontend deployed separately, typically to Vercel
- backend Docker image deployed twice to Azure Container Apps:
- one API app
- one worker app
- PostgreSQL as the system of record
- Azure Blob Storage for binaries
- Azure Queue Storage for background jobs
- Build the frontend from
frontend/ - set
API_ORIGINto the public backend API base URL - deploy to Vercel or any Node-compatible platform that supports Next.js standalone output
- the repo includes frontend CI in
frontend/.github/workflows/ci.yml, but not a frontend deployment workflow
- Build from
backend/Dockerfile - the Docker image uses Python 3.11 slim and installs the native libraries needed by OpenCV/InsightFace
- by default the image prewarms the model during build when
PREWARM_MODEL=true - the same image can run as:
- API:
APP_MODULE=app.main:app - Worker:
APP_MODULE=app.worker_app:app
- API:
- Provision PostgreSQL, Azure Blob containers, and Azure Queue Storage.
- Build and push the backend image.
- Run
alembic -c alembic.ini upgrade headagainst the production database. - Deploy the API container with:
APP_MODULE=app.main:appSTORAGE_PROVIDER=azureQUEUE_PROVIDER=azure- cookie and CORS variables set for the public frontend domain
- Deploy the worker container with:
APP_MODULE=app.worker_app:app- the same database/storage/queue settings
- Deploy the frontend with
API_ORIGINpointing at the backend API.
backend/.github/workflows/ci.yml- installs backend dependencies
- runs unit tests
- runs the smoke test
- compile-checks the Python package
backend/.github/workflows/deploy.yml- logs into Azure
- builds and pushes the backend image to ACR
- runs migrations
- updates Azure Container Apps
frontend/.github/workflows/ci.yml- runs
npm ci - runs
npm run lint - runs
npm run build
- runs
These are useful to know before open-sourcing or extending the project:
- The upload UI currently hides Google Drive import even though the backend endpoint exists.
- Cluster merge exists at the API layer but is not currently exposed in the UI.
- A full recluster deletes and recreates clusters for that user. That means custom labels and manual merges are not durable across future full pipeline runs.
- The face pipeline runs on CPU by default, so large batches can take time.
- The frontend has lint/build validation but no dedicated component or end-to-end test suite.
- The backend uses server-stored session cookies, not JWTs.
- Do not publish real
.envfiles. This repo now treats nested.envfiles as ignored. - Do not publish real photo data, generated face crops, or local storage directories.
backend/.env.exampleshould contain placeholders only. If any previously checked-in credentials were real, rotate them before making the repository public.- If you plan to open-source the project, add:
- a license
- a contribution guide
- a privacy notice explaining how face data is handled
- a clear disclaimer about biometric/face-processing compliance requirements in your jurisdiction
If you only want the shortest path:
cp .env.example .env
docker compose up -d db
docker compose run --rm backend alembic -c alembic.ini upgrade head
docker compose up --buildThen open http://localhost:3000.