DEV Community

Cover image for Blur Faces in Photos Automatically with Python (GDPR)
AI Engine
AI Engine

Posted on Originally published at ai-engine.net

Blur Faces in Photos Automatically with Python (GDPR)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)