Find lost pets by photo. Someone photographs a stray; the backend segments the animal, embeds it, and compares that embedding against pets reported missing nearby. The owner gets a push notification with a similarity score and, once they confirm the match, the sighting location.
- Backend — FastAPI + PostgreSQL/pgvector, YOLOv8m-seg for detection and DINOv2 ViT-L/14 for 1024-dimensional embeddings.
- Mobile — Flutter (Android), Riverpod, go_router, Firebase Cloud Messaging.
| Python | 3.11 |
| PostgreSQL | 16 with the vector extension (pgvector/pgvector:pg16) |
| Flutter | 3.11+ |
| Docker | for the containerised stacks |
Both models run server-side. Inference is CPU-viable but slow; a CUDA GPU is strongly recommended for anything beyond local testing.
cp .env.example .env # then edit JWT_SECRET_KEY at minimum
docker compose up -d db
alembic upgrade head
uvicorn src.main:app --reload --port 8010Or run the whole stack in containers:
docker compose up -d --buildThe API is then on http://localhost:8010, with interactive docs at /docs
(disabled automatically when ENVIRONMENT=production).
cd mobile
flutter pub get
flutter run --dart-define=API_HOST=http://10.0.2.2:8010 # 10.0.2.2 = host, from the Android emulatorAPI_HOST sets the compiled-in default. It can also be changed at runtime from
the in-app Settings screen, which stays reachable while signed out —
a stale address would otherwise make login impossible to recover from.
cp .env.example .env.prod # fill in real secrets
mkdir -p secrets
cp /path/to/firebase-key.json secrets/firebase-credentials.json
docker compose -f docker-compose.prod.yml up -d --buildThe production stack differs from development in ways that matter:
| Development | Production | |
|---|---|---|
| Source | bind-mounted, --reload |
baked into the image |
| Postgres | published on 127.0.0.1:9876 |
reachable only on the compose network |
| TLS | none | Caddy, automatic Let's Encrypt |
| Migrations | run by hand | migrate service, before the app starts |
| Scheduler | in-process | its own single-instance service |
| Rate limits | per-process counters | shared through Redis |
| Firebase key | file in the repo root | Docker secret |
| API docs | /docs |
disabled |
Set DOMAIN and ACME_EMAIL in .env.prod; Caddy provisions the certificate
on first boot, which needs ports 80 and 443 reachable from the internet.
The app refuses to start with ENVIRONMENT=production while any example
credential is still in place — the default JWT secret, the default database
password, or DEBUG=true. Generate a real secret with:
python -c "import secrets; print(secrets.token_urlsafe(48))"Each uvicorn worker loads its own copy of DINOv2 ViT-L/14, about 1.2 GB
resident. Budget roughly 1.5 GB per WEB_CONCURRENCY step and set it
deliberately; the default is 2.
Model weights are baked into the image at build time
(scripts/prefetch_models.py), so a container start needs no network and no
warm-up download.
| Path | Meaning |
|---|---|
/health |
Liveness — the process is up. |
/health/ready |
Readiness — the database answers and both models are resident. Returns 503 otherwise. |
Point your orchestrator's readiness probe at /health/ready; the Docker
HEALTHCHECK already does.
keytool -genkey -v -keystore upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias upload
cp mobile/android/key.properties.example mobile/android/key.properties
# fill in the passwords and the keystore pathkey.properties is gitignored. Without it the release build is unsigned and
says so — it no longer silently falls back to debug keys. Keep the keystore
safe: losing it means you can never publish an update to the same listing.
cd mobile
flutter build appbundle --release --dart-define=API_HOST=https://your.domainA new sighting runs through src/services/matching_service.py:
- A bounding box at
MAX_SEARCH_RADIUS_KM(10 km) narrows candidate declarations, then an exact haversine check keeps those whose ownsearch_radius_kmactually reaches the sighting. - Species from YOLO filters further, when it detected one.
- pgvector cosine similarity runs against the pet's aggregate embedding, falling back to per-image maximum where no aggregate exists.
- The final score is
0.7 × image similarity + 0.3 × distance score, where distance decays asexp(-km / 3). Above 0.70 aMatchrow is created and the owner is notified.
A declaration's search radius widens on its own as time passes — 0.5 km on day
0, 1 km after a day, 2 km after three, 5 km after a week
(src/services/radius_expansion.py).
EMBEDDING_DIM must equal the width of the vector(N) columns and the
embedder's output. DINOv2 ViT-L/14 produces 1024. Changing the model means a
migration to re-type every vector column plus a full re-embed
(scripts/reembed_all.py) — the stored vectors are meaningless in another
model's space.
Photos are served by an authenticated route (GET /api/v1/media/{path}), not a
public static mount. A request is authorised against the row that owns the
file: you may fetch your own pets' photos, sightings you reported, and sightings
matched against one of your pets. Everything else is a 404 — whether a given
image exists is itself information about someone else's pet.
Stored paths are relative and POSIX (pets/<id>/file.jpg). Migration 007
normalises rows written before that.
Access tokens last 30 minutes; refresh tokens are recorded server-side in
refresh_tokens and rotate on every use. Presenting an already-rotated
token is treated as a stolen-token replay and revokes every outstanding session
for that user. POST /api/v1/auth/logout revokes the presented token, or all of
them when the body is omitted.
scripts/run_scheduler.py runs radius expansion and refresh-token cleanup in a
single dedicated process. Every API container sets RUN_SCHEDULER=false; a
second scheduler would double-expand every search radius.
pip install -e ".[dev]"
pytest -qThe suite needs no database, GPU or network. CI additionally applies every
migration up, down and up again against a real pgvector instance, builds the
Docker image, and runs flutter analyze and flutter test.
alembic upgrade head # apply migrations
alembic downgrade -1 # roll back one
python -m scripts.reembed_all # re-embed every pet image after a model change
python -m scripts.seed_db # development fixturesLogs go to stdout; set LOG_JSON=true in a deployment so a log shipper can
parse them.