Blurring a license plate is one line of OpenCV. Knowing where the plate is — in 10,000 photos, at every angle, on every kind of car — is the actual job. And once it's done, the question nobody plans for arrives: which of those 10,000 should a person actually look at before they go public? This walkthrough covers both. It uses the API4AI Image Anonymization API, which finds and blurs plates (and faces, if you want) and leaves the rest of the photo untouched. The script at the end runs a whole folder, keeps the folder structure, survives rate limits and failures, and writes a CSV of every plate it hid — which turns out to be the most useful part. It's for archives, rental fleets, marketplace uploads, street photography, datasets: anywhere the scene has to stay and only the plate has to go. If you're a car dealer who wants the background replaced as well, skip to the end — there's a no-code route for that. One request curl -X POST "https://api4ai.cloud/img-anonymization/v1/results?mode=hide-clp" \ -H "X-API-KEY: $API4AI_KEY" \ -F "image=@car.jpg" mode=hide-clp means "hide car license plates". The other mode is hide-face. Leave mode out entirely and the API hides everything it supports — plates and faces, which is worth knowing before you run a folder of photos where faces should stay. Repeat the parameter to be explicit about both: ?mode=hide-clp&mode=hide-face. The photo goes in as multipart form data: a file in image, or a public link in url. What comes back This is the example from the docs, trimmed: { "results": [ { "status": { "code": "ok", "message": "Success" }, "name": "car.jpg", "width": 1024, "height": 768, "entities": [ { "kind": "image", "name": "anonymized-image", "format": "PNG", "image": "iVBORw0KGgoAAAA...YII=" }, { "kind": "objects", "name": "hidden-objects", "objects": [ { "box": [0.7458, 0.6651, 0.1189, 0.0745], "entities": [ { "kind": "classes", "name": "classes", "classes": { "License plate": 0.5641 } } ] } ] } ] } ] } Two entities: image — the anonymized photo, base64-encoded, in format (the docs say it's usually the same as the input format, so a JPEG in usually comes back as a JPEG). objects — every object that was hidden, with a bounding box in normalized [x, y, width, height] coordinates and a confidence between 0 and 1. Look at that confidence in the docs' own example: 0.56. The plate was found and blurred, but the model wasn't sure about it. Hold on to that — it's what makes the review step at the end work. Four things that catch people A photo the service can't read still returns HTTP 200. status.code is "failure" and status.message says why. If you only call raise_for_status(), those files silently vanish from your output folder. Pick entities by kind, not by position. Code that reads entities[0] works until it doesn't. Limits: JPEG, PNG or PDF, under 16 MB, at most 4096×4096. A PDF is split into pages and each page is processed separately, so results can hold more than one entry. Faces are included by default. Say mode=hide-clp when you mean plates only. The script Eighty-odd lines, standard library plus requests. It walks the source folder recursively, sends eight photos at a time, writes each result to the same relative path under the output folder, and logs every hidden plate to plates.csv. """Blur license plates in a folder of photos, keep the scene, log what was hidden. export API4AI_KEY=... # from portal.api4.ai python3 blur_plates.py photos/ blurred/ Writes each result under blurred/ with the same relative path, and a blurred/plates.csv with one row per hidden plate (or per photo with none). """ import base64 import csv import os import pathlib import sys import time from concurrent.futures import ThreadPoolExecutor import requests URL = "https://api4ai.cloud/img-anonymization/v1/results" KEY = os.environ["API4AI_KEY"] PHOTOS = {".jpg", ".jpeg", ".png"} EXT = {"JPEG": ".jpg", "PNG": ".png"} src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) def call_api(photo, tries=4): for attempt in range(tries): with photo.open("rb") as f: r = requests.post(URL, params={"mode": "hide-clp"}, # plates only headers={"X-API-KEY": KEY}, files={"image": f}, timeout=120) if r.status_code in (429, 500, 502, 503, 504) and attempt < tries - 1: time.sleep(2 ** attempt) # 1, 2, 4 s continue r.raise_for_status() return r.json()["results"][0] def blur(photo): rel = photo.relative_to(src) done = [p for p in (dst / rel).parent.glob(rel.stem + ".*") if p.suffix != ".csv"] if done: return rel, "skipped", [] result = call_api(photo) # A photo the service can't read comes back as HTTP 200 with "failure". if result["status"]["code"] != "ok": return rel, "failed: " + result["status"]["message"], [] by_kind = {e["kind"]: e for e in result["entities"]} image = by_kind["image"] out = dst / rel.with_suffix(EXT.get(image["format"], ".png")) out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(base64.b64decode(image["image"])) plates = [(o["box"], max(o["entities"][0]["classes"].values())) for o in by_kind.get("objects", {}).get("objects", [])] return rel, "ok", plates photos = sorted(p for p in src.rglob("*") if p.suffix.lower() in PHOTOS) dst.mkdir(parents=True, exist_ok=True) counts = {"ok": 0, "skipped": 0, "failed": 0} log_path = dst / "plates.csv" new_log = not log_path.exists() with ThreadPoolExecutor(max_workers=8) as pool, open(log_path, "a", newline="") as log: writer = csv.writer(log) if new_log: writer.writerow(["file", "status", "plates", "box_xywh", "confidence"]) for rel, status, plates in pool.map(blur, photos): counts[status.split(":")[0]] += 1 if status == "skipped": continue if not plates: writer.writerow([rel, status, 0, "", ""]) for box, confidence in plates: writer.writerow([rel, status, len(plates), " ".join(f"{v:.3f}" for v in box), f"{confidence:.2f}"]) print(f"{status:>7} {len(plates)} plate(s) {rel}") print(f"\n{len(photos)} photos: {counts['ok']} blurred, " f"{counts['skipped']} already done, {counts['failed']} failed") Run it: export API4AI_KEY=... python3 blur_plates.py photos/ blurred/ What it takes care of: Resume. A photo that already has an output file is skipped, so after a crash, a failure or a Ctrl-C you just run it again. Failed photos have no output, so they get retried automatically. Rate limits and hiccups. A 429 or a 5xx waits 1, 2, then 4 seconds and tries again before giving up. Failures you can see. Anything that comes back as "failure" is counted, printed and logged with its message, not dropped. Structure. photos/2024/lot-3/car.jpg ends up at blurred/2024/lot-3/car.jpg. The count at the end. Blurred plus already-done plus failed should equal what went in. If it doesn't, something's wrong, and you know before anyone else does. Eight workers is a starting point, not a tuned number. If you start seeing a lot of 429s, lower it. The part that matters: which photos to check Here's the problem with 10,000 blurred photos: nobody is going to look at 10,000 photos. But you also can't publish them without looking at any. The CSV gives you a way to pick. Two kinds of rows deserve a human: Low confidence. The docs' own example came back at 0.56. Nothing is necessarily wrong with a low score — it means the model was less sure, which is exactly where you want a second pair of eyes. Zero plates in a photo that should have one. A front or rear shot of a car where nothing was hidden is either a car without a visible plate, or a plate that wasn't found. Only a person can tell which. import csv rows = list(csv.DictReader(open("blurred/plates.csv"))) check = [r for r in rows if r["status"] == "ok" and (r["plates"] == "0" or float(r["confidence"] or 1) < 0.6)] for r in sorted(check, key=lambda r: float(r["confidence"] or 0)): print(r["plates"], r["confidence"] or "-", r["file"]) print(f"{len(check)} of {len(rows)} rows to look at") The 0.6 threshold is arbitrary — pick one, look at what it gives you, and move it. The point is that the review list is now a few hundred files ranked by how unsure the model was, not ten thousand in alphabetical order. The boxes help too. They're normalized, so x * width and y * height give you pixels, and you can crop a thumbnail of each hidden region into a contact sheet and flick through it in a minute. What it costs The API is pay-as-you-go at $25 per 1,000 requests. One photo is one request, so 10,000 photos is $250. Keys and billing are on the API4AI developer portal, and the full reference is in the Image Anonymization docs. If you don't want to write code One or two photos: the Image Anonymization API page has a free demo. Upload a photo and see the result. A dealer's listing photos: if you want the background replaced as well — every car on the same white or brand-color backdrop — CarBG does the cut-out and the plate in one pass, 60 photos at a time or a .zip. The background is removed either way there, so it's the wrong tool if you need to keep the scene. There's a longer comparison of the three routes in How to Blur License Plates in Car Photos. What are you anonymizing, and how are you checking the results today — spot checks, a second model, or not at all?