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_transposerotates 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.
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)
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.


Top comments (0)