All articles

Guides

Build production face search with a selfie (Index + Search)

Not an API demo — the schema, pipeline, and edge cases a real face-search feature needs.

By SightRadar EngineeringUpdated 11 min read Markdown

TL;DR

To let a user upload a selfie and find every photo they appear in, you: index your photo library into a collection, store a mapping from the API's photoId back to your own records, then run a search with the selfie and rank the returned photo ids. The API handles detection and matching; your app owns image storage, the id mapping, consent, and deletion. This guide builds all of that with SightRadar's Index and Search operations.

"Find my photos from a selfie" is the headline use case for event and gallery products. The face API is the easy part — a production feature is mostly the data model and the edge cases around it. We'll build the whole thing.

Architecture

  • Client uploads photos (organizer) and a selfie (guest).
  • Object storage (S3/R2) holds the images; the API fetches them by URL.
  • App server calls the face API and owns the database.
  • Face API (/v1/collections/*) indexes faces and answers searches.
  • Database maps API identifiers → your photos, people, and tenant.
  • Background jobs run the bulk index and any deletions.

Data model

create table photo (
  id          text primary key,          -- your id; passed as photoId at index time
  event_id    text not null,             -- maps to the collection id
  storage_url text not null,
  indexed     boolean not null default false,
  face_count  int,
  created_at  timestamptz not null default now()
);

create table search_consent (
  guest_id    text not null,
  event_id    text not null,
  consented_at timestamptz not null,
  primary key (guest_id, event_id)
);
One collection per event keeps each tenant's faces un-cross-searchable.

Step 1 — Index the library

import os, requests
BASE, H = "https://api.sightradar.com", {"Authorization": f"Bearer {os.environ['SR_API_KEY']}"}

def index_event(event_id, photos):  # photos: [(photo_id, url), ...]
    requests.post(f"{BASE}/v1/collections", headers=H,
                  json={"collection_id": event_id})
    # up to 1000 photos per batch; per-photo results arrive on your webhook
    batch = [{"external_id": pid, "url": url} for pid, url in photos]
    # Idempotency-Key makes a retried batch safe — it never double-charges.
    r = requests.post(f"{BASE}/v1/batches",
                      headers={**H, "Idempotency-Key": f"index-{event_id}-v1"},
                      json={"collection_id": event_id, "op": "index", "photos": batch})
    return r.json()["batch_id"]
Batch index for the lower per-photo rate; mark rows indexed as you go.

Note: A photo with zero faces is a valid, charged, successful result — not an error. Record face_count = 0 and move on; don't build retry logic around faceless photos.

Step 2 — Search with a selfie

def find_my_photos(event_id, selfie_url, limit=50):
    r = requests.post(f"{BASE}/v1/collections/{event_id}/search", headers=H,
                      json={"url": selfie_url, "limit": limit})
    data = r.json()
    if data.get("reason") == "no_face":       # bad selfie — not charged for the engine call
        return {"error": "We couldn't find a face in that selfie. Try another photo."}
    return [m["photo_id"] for m in data["matches"]]  # ranked by similarity

Map those photo_ids back to storage_urls through your photo table and render the gallery. Show matches above your auto-reveal threshold immediately; put borderline matches behind a "confirm it's you" step so a stranger's photos are never auto-revealed. See choosing a face-match threshold for how to pick that line.

Step 3 — Deletion (do this from day one)

When a guest or organizer deletes an event, delete the faces from the collection and your rows. Deletion that only removes your DB rows but leaves faces indexed is the classic privacy bug.

def delete_event(event_id):
    # Delete the biometric data FIRST and confirm it succeeded. Only then drop
    # your local rows — otherwise a failed remote delete leaves faces indexed
    # while your DB says they're gone (the classic privacy bug).
    r = requests.delete(f"{BASE}/v1/collections/{event_id}", headers=H)
    r.raise_for_status()                       # abort (and retry) if this fails
    db.execute("delete from photo where event_id = %s", (event_id,))

Edge cases that bite in production

  • Multiple faces per photo — a group shot indexes several faces; one photo can match many guests.
  • Rotated iPhone photos — respect EXIF orientation before upload, or faces detect at the wrong angle.
  • HEIC/WebP — SightRadar decodes these natively, so you skip the transcode step most pipelines add for Rekognition.
  • Partial batch failure — check per-photo webhook results; re-index only the failures, with an idempotency key.
  • Tenant isolation — never search across collections; one event's guests must not match another's photos.

Index a folder of photos and run your first selfie search on trial credits.

Follow the quickstart

Frequently asked questions

How do I find every photo a person appears in?

Index your photo library into a collection so each detected face gets an embedding, then call search with the person's selfie. The API returns the photo ids that match, ranked by similarity. Map those ids back to your stored images to render the gallery. This is a 1:N search — one selfie against many indexed photos.

Do I need one collection per event or one big collection?

Prefer one collection per event or per tenant. It keeps each group's faces isolated (guests at one event can't match another event's photos), makes deletion a single call, and keeps searches fast and scoped. Creating collections is free; only face operations are billable.

What happens if the selfie has no detectable face?

The search returns a clear reason such as no_face with an empty match list, and you are not charged for the failed engine call. Handle it by asking the user for a clearer selfie rather than treating it as a server error.

How do I delete a person's face data?

Delete the faces from the collection via the API (DeleteFaces or DeleteCollection) and remove the corresponding rows in your own database in the same operation. Deleting only your database rows while leaving faces indexed is a privacy bug — propagate deletion to both.

Keep reading