Phone galleries, family archives, a photographer's backlog: where are all the photos of this person? Here is how to group photos by face in Python: detect every face, match it in a face repository, and sort the library into one album per person. No model to download, no dlib to compile.
Every number below comes from a live run on 24 photos from two outings, a camping trip and a beach picnic.
Want to sort your own library? Get a key for the Face Analyzer API.
Step 1: create a face repository
import io import os import shutil import requests from concurrent.futures import ThreadPoolExecutor from PIL import Image, ImageOps BASE = "https://faceanalyzer-ai.p.rapidapi.com" HEADERS = { "x-rapidapi-host": "faceanalyzer-ai.p.rapidapi.com", "x-rapidapi-key": "YOUR_API_KEY", } LIBRARY = "my-photo-library" MIN_SIMILARITY = 90 r = requests.post( f"{BASE}/create-facial-repository", headers=HEADERS, data={"repository_id": LIBRARY}, ) print(r.json()) Step 2: detect and crop every face
def detect_faces(path): """Return the photo path and the bounding box of every face in it.""" with open(path, "rb") as f: r = requests.post(f"{BASE}/faceanalysis", headers=HEADERS, files={"image": f}) return path, [face["boundingBox"] for face in r.json()["body"]["faces"]] def crop_face(path, box, margin=0.4): """Cut one face out of the photo, with a margin, as JPEG bytes.""" im = ImageOps.exif_transpose(Image.open(path)).convert("RGB") w, h = im.size x1, y1 = box["topLeft"]["x"] * w, box["topLeft"]["y"] * h x2, y2 = box["bottomRight"]["x"] * w, box["bottomRight"]["y"] * h mx, my = (x2 - x1) * margin, (y2 - y1) * margin face = im.crop(( int(max(0, x1 - mx)), int(max(0, y1 - my)), int(min(w, x2 + mx)), int(min(h, y2 + my)), )) buf = io.BytesIO() face.save(buf, "JPEG", quality=92) return buf.getvalue() The repository search looks at the main face of the image it receives, so each face is sent as its own crop.
Step 3: match each face or start a new person
def who_is(face_jpg): """Return the label of the closest known person, or None for a new face.""" r = requests.post( f"{BASE}/search-face-in-repository", headers=HEADERS, files={"image": ("face.jpg", face_jpg)}, data={"repository_id": LIBRARY}, ) matches = [ m for m in r.json()["body"].get("FaceMatches", []) if m["Similarity"] >= MIN_SIMILARITY ] if not matches: return None return max(matches, key=lambda m: m["Similarity"])["ExternalImageId"] def remember(face_jpg, person): """Save the face under the person's label, so later photos match it too.""" requests.post( f"{BASE}/save-face-in-repository", headers=HEADERS, files={"image": ("face.jpg", face_jpg)}, data={"repository_id": LIBRARY, "external_id": person, "max_faces": "1"}, ) people = {} # person label -> names of the photos they appear in def sort_photos(folder): """Detect faces in parallel, then match each face to a person in order.""" paths = sorted(os.path.join(folder, n) for n in os.listdir(folder)) with ThreadPoolExecutor(max_workers=4) as pool: detected = list(pool.map(detect_faces, paths)) for path, boxes in detected: for box in boxes: face_jpg = crop_face(path, box) person = who_is(face_jpg) if person is None: person = f"person_{len(people) + 1}" people[person] = set() remember(face_jpg, person) people[person].add(path) return sum(len(boxes) for _, boxes in detected) faces = sort_photos("photos") print(faces, "faces,", len(people), "face groups") # 54 faces, 16 face groups Step 4: one album per person
MIN_PHOTOS = 3 def build_albums(out_dir="albums"): """Copy each photo into the album of every person in it.""" shutil.rmtree(out_dir, ignore_errors=True) for person, paths in people.items(): album = person if len(paths) >= MIN_PHOTOS else "unsorted" os.makedirs(os.path.join(out_dir, album), exist_ok=True) for path in paths: shutil.copy(path, os.path.join(out_dir, album, os.path.basename(path))) for album in sorted(os.listdir(out_dir)): print(album, len(os.listdir(os.path.join(out_dir, album))), "photos") build_albums() person_10 6 photos person_12 4 photos person_13 4 photos person_14 5 photos person_2 6 photos person_3 6 photos person_5 7 photos person_6 5 photos person_9 3 photos unsorted 7 photos Nine albums for the nine people: the two campers and the seven picnic friends. Every album contains a single person, checked by eye. Faces seen once or twice (a head turned away, a passer-by out of focus) go to unsorted.
Step 5: new photos join the right album
before = {person: set(paths) for person, paths in people.items()} sort_photos("new_photos") for person, paths in people.items(): for path in sorted(paths - before.get(person, set())): print(os.path.basename(path), "->", person) build_albums() IMG_5064932.jpg -> person_3 IMG_8021122.jpg -> person_9 Under 3 seconds, no re-clustering.
Why not face_recognition + DBSCAN?
| face_recognition + DBSCAN | Face Analyzer API | |
|---|---|---|
| Install | dlib source build (CMake, C++) | requests and Pillow |
| Last release | 1.3.0, February 2020 | hosted |
| New photos | re-cluster, labels can change | one search per face, stable labels |
| Tuning | eps and min_samples | one similarity threshold |
| Children | README warns it mixes them up at 0.6 | same code for every face |
Production tips
- One repository per user, deleted when they turn the feature off
- Face grouping is biometric processing under GDPR Article 9: make it opt-in
- Let users merge and rename albums
- Run the first pass as a background job
Full guide: group photos by face on ai-engine.net.
Sources: face_recognition, dlib on PyPI, GDPR Article 9, test photos from Pexels (Kamaji Ogino, Thirdman).
👉 Grab a key from the Face Analyzer API page and sort your own photos.

