Blur faces before you share
A 1.3 MB detector finds the faces in a photo inside your tab. Write the pixelation that covers them before the picture leaves your machine.
FACES.faces holds the raw boxes UltraFace proposed for our photo of a café terrace: { score, box }, with box = [x1, y1, x2, y2] between 0 and 1. A picture is { width, height, data }, with data a Uint8ClampedArray of RGBA pixels: pixel (x, y) starts at (y * width + x) * 4. Write four functions:
toPixelRect(box, width, height, margin): grow each side of the box by margin times its width (or height), turn it into pixels with Math.floor on the left and top and Math.ceil on the right and bottom, clip it to the picture and return { x, y, w, h }.
pixelate(img, rect, block): return a **new** picture in which, inside rect, each cell of block × block pixels (counted from the corner of rect; the ones at the edge can be smaller) takes the rounded mean of its R, its G and its B. Alpha and everything outside stay as they were.
selectFaces(candidates, minScore): the ones with score >= minScore, highest first, without those that overlap an accepted one with iou > 0.3 (iou is already written).
anonymise(img, faces, opts): apply pixelate to every face with toPixelRect(face.box, img.width, img.height, opts.margin) and cells of opts.block. The preview covers the faces in the photo and lets you move all three values.
Challenges 0/5
- From the model's box to pixels: margin, rounding and edges
- Pixelation: each cell its mean, alpha intact, the original untouched
- Cells cut at the edge of the box, and nothing outside it
- The faces on the terrace: four at 0.5 and only two at 0.998
- With the model's faces (your photo) or the sample: every face covered, the rest intact
function toPixelRect(box, width, height, margin) {
// grow each side by margin times the box size, clamp to the picture; floor the left and top, ceil the right and bottom
return { x: 0, y: 0, w: 0, h: 0 };
}
function pixelate(img, rect, block) {
// a NEW image: inside rect, each block x block cell gets the rounded mean colour of its pixels; alpha and the rest untouched
return img;
}
function selectFaces(candidates, minScore) {
// score >= minScore, highest first; drop a box that overlaps an accepted one with iou > 0.3
return candidates;
}
function anonymise(img, faces, opts) {
// pixelate every face: toPixelRect(face.box, img.width, img.height, opts.margin) in cells of opts.block px
return img;
}
console.log(selectFaces(FACES.faces, 0.5).length, 'caras');The idea
Before you publish a photo of an event, a building site or a shop camera, the faces of people who have not agreed must become unrecognisable. By hand it does not scale, and a cloud service means sending the whole photo, faces included, to a third party. Here a 1.3 MB detector does it in your tab and the photo never leaves your machine.
UltraFace does not return "four faces". It returns 4,420 fixed boxes, each with a confidence, and several light up on the same face. The first step is the usual one: a confidence threshold and non-maximum suppression. Then comes the privacy part: grow the box by a margin, because the detector hugs the face and leaves out hair, ears and chin, and pixelate with large blocks. A 2 px block can still be read; a 12 px block over an 80 px face cannot.
What we measured on 2026-10-19 in the browser on the DGX Spark (ONNX Runtime Web, WebAssembly, one thread): 10 to 17 ms per 1024 × 768 photo. On the terrace it proposes 47 boxes with confidence ≥ 0.3, all of them on the four people at the table, and after NMS four faces remain. It does not mark the blurred people inside the café. And if you raise the threshold to 0.998, two of the four faces stay uncovered: here the costly error is the face that is missed, so the threshold goes down, not up, and a person checks the result before it is published.
Pixelating is not encrypting. With small blocks some models still recognise the person; a large block or a solid rectangle is the safe choice. And covering faces reduces the personal data in a photo, but it does not replace consent where the law asks for it.
Run it on your machine
With Python and Pillow, on this exercise's raw boxes and photo (CC BY 4.0):
curl -O https://vorluxai.com/data/labs/caras-terraza.json -O https://vorluxai.com/data/labs/terraza-cafe.webp
python3 -c "
import json
from PIL import Image
d = json.load(open('caras-terraza.json'))
def iou(a, b):
ix = max(0, min(a[2], b[2]) - max(a[0], b[0])); iy = max(0, min(a[3], b[3]) - max(a[1], b[1])); i = ix * iy
return i / ((a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - i) if i else 0
kept = []
for f in sorted((f for f in d['faces'] if f['score'] >= 0.5), key=lambda f: -f['score']):
if all(iou(k['box'], f['box']) <= 0.3 for k in kept): kept.append(f)
im = Image.open('terraza-cafe.webp').convert('RGB'); W, H = im.size
for f in kept:
x1, y1, x2, y2 = f['box']; mx, my = 0.15 * (x2 - x1), 0.15 * (y2 - y1)
r = (max(0, int((x1 - mx) * W)), max(0, int((y1 - my) * H)), min(W, int((x2 + mx) * W) + 1), min(H, int((y2 + my) * H) + 1))
face = im.crop(r); small = face.resize((max(1, face.width // 12), max(1, face.height // 12)), Image.BILINEAR)
im.paste(small.resize(face.size, Image.NEAREST), r[:2])
im.save('terraza-tapada.jpg'); print(len(kept), 'caras tapadas -> terraza-tapada.jpg')
" Go deeper: the GitHub reference →
This in production, with your data? Let's talk for 15 minutes →