Street shots for a city guide, user uploads, a training dataset, a CCTV clip someone asked for under GDPR: all of them are full of faces of people who never agreed to be published. Here is how to blur them automatically in Python: one API call finds every face, Pillow pixelates them, and the saved file drops the GPS metadata too.

All numbers below come from live calls on six crowded street photos and one party photo.

Want to run it on your own photos? Get a key for the Face Analyzer API.

Step 1: detect every face

import os import requests from concurrent.futures import ThreadPoolExecutor from PIL import Image, ImageFilter, ImageOps BASE = "https://faceanalyzer-ai.p.rapidapi.com" HEADERS = { "x-rapidapi-host": "faceanalyzer-ai.p.rapidapi.com", "x-rapidapi-key": "YOUR_API_KEY", } def detect_faces(path): """Return the bounding box of every face in the image (0 to 1 coordinates).""" with open(path, "rb") as f: r = requests.post(f"{BASE}/faceanalysis", headers=HEADERS, files={"image": f}) return [face["boundingBox"] for face in r.json()["body"]["faces"]] boxes = detect_faces("photos/covered_market.jpg") print(len(boxes), "faces") # 21 faces 

A photo without anyone in it returns {"faces": []}, so no special case is needed.

Step 2: pixelate and blur each face

A light Gaussian blur can leave a face recognizable. Shrinking each face to an 8x8 grid first removes the detail for good:

def blur_boxes(path, boxes, out_path, pad=0.25): """Pixelate and blur each face box, then save without the original metadata.""" im = ImageOps.exif_transpose(Image.open(path)).convert("RGB") w, h = im.size for b in boxes: x1, y1 = b["topLeft"]["x"] * w, b["topLeft"]["y"] * h x2, y2 = b["bottomRight"]["x"] * w, b["bottomRight"]["y"] * h px, py = max((x2 - x1) * pad, 4), max((y2 - y1) * pad, 4) box = ( int(max(0, x1 - px)), int(max(0, y1 - py)), int(min(w, x2 + px)), int(min(h, y2 + py)), ) face = im.crop(box) tiny = face.resize((8, 8)) # 8x8 blocks: the facial detail is gone  im.paste(tiny.resize(face.size, Image.NEAREST).filter(ImageFilter.GaussianBlur(4)), box) im.save(out_path, quality=90) # no exif= argument: GPS and camera data are dropped 
  • Metadata is dropped: a test photo with GPS coordinates came out with an empty EXIF block.
  • Phone photos stay upright: the API reads the orientation tag, and exif_transpose rotates the pixels the same way.
  • Tiny faces stay covered: the 4-pixel minimum margin handles faces a few pixels wide.

Step 3: a whole folder in parallel

def anonymize(path, out_dir="blurred"): """Detect and blur every face in one photo.""" boxes = detect_faces(path) blur_boxes(path, boxes, os.path.join(out_dir, os.path.basename(path))) return os.path.basename(path), len(boxes) os.makedirs("blurred", exist_ok=True) photos = sorted(os.path.join("photos", n) for n in os.listdir("photos")) with ThreadPoolExecutor(max_workers=4) as pool: for name, count in pool.map(anonymize, photos): print(name, count, "faces blurred") 
city_market.jpg 17 faces blurred covered_market.jpg 21 faces blurred crowded_street.jpg 22 faces blurred london_street.jpg 19 faces blurred road_crossing.jpg 24 faces blurred zurich_crossing.jpg 20 faces blurred 

123 faces in about 3.5 seconds.

A crowded covered market before and after automatic face blurring: 21 faces pixelated

Why not OpenCV Haar cascades?

Same photos, the usual haarcascade_frontalface_default.xml with scaleFactor=1.1, minNeighbors=5:

Photo Haar cascade (boxes) Face Analyzer API (faces)
city_market.jpg 0 17
covered_market.jpg 8, 3 not faces 21
crowded_street.jpg 4 22
london_street.jpg 1 19
road_crossing.jpg 3 24
zurich_crossing.jpg 3 20
party.jpg (5 people) 7, 2 not faces 5

The cascade boxed a shop sign, a pair of shorts, a snack packet, a coat and a shoe. For anonymization, a missed face is the failure that matters: it gets published as is.

Blur everyone except one person

For GDPR access requests, the requester gets a copy where everyone else is blurred. /compare-faces splits the faces of the scene into matchedFaces and unmatchedFaces:

def blur_all_except(reference_path, photo_path, out_path): """Blur every face in the photo except the person in the reference photo.""" with open(reference_path, "rb") as ref, open(photo_path, "rb") as photo: r = requests.post( f"{BASE}/compare-faces", headers=HEADERS, files={"source_image": ref, "target_image": photo}, ) body = r.json()["body"] others = [face["boundingBox"] for face in body["unmatchedFaces"]] blur_boxes(photo_path, others, out_path) return len(body["matchedFaces"]), len(others) print(blur_all_except("requester.jpg", "party.jpg", "party_for_requester.jpg")) # (1, 4) 

A reference photo of a woman and a group photo where only her face stays visible

Blur matchedFaces instead to hide one person who withdrew consent.

Production tips

  • Blur before storage, not with CSS at display time
  • Check other clues too: name badges, plates, screens
  • Raise the margin for close-up portraits
  • Video: run the same function frame by frame

Full guide with the GDPR background (Recital 26, EDPB video guidelines): blur faces in photos on ai-engine.net.

Sources: GDPR Recital 26, EDPB Guidelines 3/2019, test photos from Pexels.

👉 Grab a key from the Face Analyzer API page and blur your own photos.