This article was written with AI.
Disclosure: I work on SightRadar, the API this team moved to. The figures below are the ones sellphotos.online reported. The full case study is at sightradar.com/customers/sellphotos.
sellphotos.online is a marketplace where event and sports photographers upload their shots and guests buy the ones they appear in. A guest uploads a selfie and the platform finds every photo that person is in. They process hundreds of thousands of photos a month across weddings, marathons and corporate events.
They ran this on AWS Rekognition and had two problems with it. The bill had grown to about $3,500 a month. And in crowd shots, the smaller faces in the back rows were often missed, so guests searched for themselves and came up empty. Those guests complained to the photographer, and the photographer complained to the platform.
They moved face search to SightRadar in about two days. This post is the playbook they followed, with code. Most of it applies whichever provider you move to.
What you are actually migrating
A selfie-search product on Rekognition is three things:
- A collection of faces indexed from every uploaded photo (
IndexFaces). - A search on every guest visit (
SearchFacesByImage). - A threshold that decides what counts as a match.
The first two are API calls, and they port. The third is a number tuned to one vendor's model, and it does not. Most bad migrations go wrong at the threshold, not at the API.
Step 1: test on one real event
They set up a test collection, indexed about 5,000 photos from a recent wedding, and compared search results against what Rekognition returned for the same photos.
Pick an event with the hard cases in it: group shots, people far from the camera, bad light. Every provider handles a well-lit portrait, so clean photos tell you nothing.
Their backend is mostly Python, so they used the Rekognition shim. It keeps boto3's method names, arguments and response shapes, so the change is the import and the constructor:
from sightradar_rekognition_shim import client # was: import boto3 rek = client(api_key="frs_...") # was: boto3.client("rekognition") rek.create_collection(CollectionId="test-wedding") rek.index_faces( CollectionId="test-wedding", Image={"URL": "https://cdn.example.com/wedding/0001.jpg"}, ExternalImageId="0001", ) Two things to know before you start:
- The shim maps
Image={"S3Object": ...}to a public S3 URL, which has to be fetchable. A private bucket needs a URL the API can fetch, such as a presigned one passed as{"URL": ...}. - The shim is Python only (PyPI). On Node or Java you map the calls to the REST API yourself, which is more work than swapping an import.
Step 2: batch re-index the back catalogue
This was the biggest chunk of work: hundreds of thousands of existing photos. Indexing them one synchronous call at a time is slow at that volume. Instead they used the batch endpoint, which takes up to 1,000 photo URLs per call and delivers one webhook per photo as results land. They scripted the submissions and let it run overnight.
import os import requests BASE = "https://api.sightradar.com" HEADERS = {"Authorization": f"Bearer {os.environ['SIGHTRADAR_API_KEY']}"} def submit(collection_id, photos, webhook_id): """photos: up to 1,000 dicts of {"url": ..., "external_id": ...}""" r = requests.post(f"{BASE}/v1/batches", headers=HEADERS, timeout=30, json={ "collection_id": collection_id, "op": "index", "webhook_endpoint_id": webhook_id, "photos": photos, }) r.raise_for_status() return r.json()["batch_id"] # all_photos: your catalogue as {"url", "external_id"} dicts; WEBHOOK_ID from POST /v1/webhooks for i in range(0, len(all_photos), 1000): print(submit("events", all_photos[i:i + 1000], WEBHOOK_ID)) Each webhook carries your external_id, a status, and on failure an error.code such as image_fetch_failed. Failed photos are not charged. Keep a list of them and resubmit once you have fixed the cause. The batch guide covers registering the endpoint and verifying signatures.
Plan for this step to take longer than you expect. It did for them. While it runs you are paying both providers, because live uploads still go to the old one. That double cost is short, but put it in the budget.
Step 3: run both providers side by side
For the next day, every guest search went to both providers and they compared the ranked results. The shim returns Rekognition-shaped responses, so the comparison is short:
import boto3 from sightradar_rekognition_shim import client aws = boto3.client("rekognition", region_name="us-east-1") sr = client() # reads SIGHTRADAR_API_KEY # Start loose on both sides: you are comparing what each provider finds, # not deciding matches yet. That is step 4. AWS_THRESHOLD = 70 SR_THRESHOLD = 50 # the shim divides by 100, so this is 0.5 cosine def photo_ids(rek, threshold, selfie): res = rek.search_faces_by_image( CollectionId="events", Image={"Bytes": selfie}, FaceMatchThreshold=threshold, MaxFaces=100, ) return {m["Face"]["ExternalImageId"] for m in res["FaceMatches"]} selfie = open("guest.jpg", "rb").read() a = photo_ids(aws, AWS_THRESHOLD, selfie) b = photo_ids(sr, SR_THRESHOLD, selfie) print(f"both: {len(a & b)} only AWS: {len(a - b)} only SightRadar: {len(b - a)}") Look at the "only" sets by eye. They tell you more than any aggregate number.
In their case, SightRadar found more faces in group shots. Its detector scans each photo at several scales, which catches smaller faces that a single-scale pass misses. The ranking order differed slightly in a few searches, but every face Rekognition found, SightRadar found too.
Step 4: re-derive the threshold
This is the step people skip, and then they blame the new provider when results look off.
Rekognition scores similarity from 0 to 100. SightRadar returns cosine similarity from 0 to 1. The shim converts the scale both ways, but converting the scale does not convert the decision. Two models put the same "same person" call at different scores, so a cut-off copied from Rekognition is wrong on the new model.
They spent a day on this with known match and non-match pairs from their test events. The method is short:
- Collect a few hundred same-person pairs and a few hundred different-person pairs from your own photos: same cameras, same venues, same lighting.
- Score every pair.
- Pick your cut-offs from the scores: accept above nearly all of the different-person scores, and review the band below that where most real matches still sit.
def score(url_a, url_b): r = requests.post(f"{BASE}/v1/compare", headers=HEADERS, timeout=30, json={"sourceUrl": url_a, "targetUrl": url_b}) r.raise_for_status() body = r.json() return body["similarity"] if body["face_found"] else None # same_person_pairs / different_person_pairs: lists of (url_a, url_b) you labelled by hand same = sorted(s for a, b in same_person_pairs if (s := score(a, b)) is not None) diff = sorted(s for a, b in different_person_pairs if (s := score(a, b)) is not None) # Accept automatically above the score that lets through at most ~1% of # different-person pairs. Below that, down to where 95% of real matches sit, # ask the guest "Is this you?" instead of hiding the photo. accept = diff[int(len(diff) * 0.99)] review = same[int(len(same) * 0.05)] print(f"accept >= {accept:.3f}; review band {min(review, accept):.3f} to {accept:.3f}") Each compare is billed as two photos ($0.00186 a pair), so 600 pairs costs about $1.12. Two bands usually beat a single cut-off for photo discovery, because a wrong suggestion is cheap when the guest confirms it. The threshold guide goes further on picking bands.
Step 5: cut over
By now every existing photo was indexed in both systems, so the cutover was a config change: new index calls and searches went to SightRadar. If you can afford it, keep the old collection for a while so rolling back is also just a config change.
What they reported afterwards
- The bill went from about $3,500 to about $1,550 a month, for the same photos and the same number of searches.
- Fewer "my photos didn't show up" tickets within the first month. They didn't track it formally; the support team noticed fewer of those complaints.
- Clearer visibility into costs: per-operation spend, search results and collection sizes in one console, instead of CloudWatch metrics and Cost Explorer tags.
For context on those numbers, here are the list prices. Rekognition's first US tier is $0.0010 per image. SightRadar's batch rate is $0.00062 per photo, 38% lower, and its real-time rate is $0.00093, 7% lower. Your saving depends mostly on how much of your volume can go through batch and on how many calls you make per photo. Run your own numbers with the pricing calculator before you decide anything.
When this is not a like-for-like swap
- You use more than faces. Labels, text detection, celebrity recognition, video and user vectors are not supported. The shim raises
NotImplementedErrorfor them instead of silently doing nothing. - You are on very high volume. Rekognition's per-image rate drops to $0.0008 after the first million images a month and lower after that. At that scale the gap narrows, and for real-time calls it can flip.
- You are not on Python. There is no shim for other AWS SDKs.
- You rely on final deletes. Deleting a collection is a restorable soft delete by default. Pass
Immediate=Truethrough the shim if you need Rekognition's finality.
Further reading
- The full case study: How sellphotos.online cut their face recognition bill
- Migrating from AWS Rekognition
- Rekognition shim reference
- Choosing a similarity threshold
- sellphotos.online
Disclosure again: I work on SightRadar. Results depend on your photo mix, your volume, and how carefully you calibrate thresholds.





