Tapa las caras antes de compartir
Un detector de 1,3 MB encuentra las caras de una foto en tu pestaña. Escribe el pixelado que las tapa antes de que la imagen salga de tu equipo.
FACES.faces trae los recuadros en bruto que UltraFace propuso para nuestra foto de una terraza: { score, box }, con box = [x1, y1, x2, y2] entre 0 y 1. Una imagen es { width, height, data }, con data un Uint8ClampedArray de píxeles RGBA: el píxel (x, y) empieza en (y * width + x) * 4. Escribe cuatro funciones:
toPixelRect(box, width, height, margin): agranda cada lado del recuadro margin veces su ancho (o su alto), pásalo a píxeles con Math.floor a la izquierda y arriba y Math.ceil a la derecha y abajo, recórtalo a la imagen y devuelve { x, y, w, h }.
pixelate(img, rect, block): devuelve una imagen **nueva** en la que, dentro de rect, cada celda de block × block píxeles (contadas desde la esquina de rect; las del borde pueden ser más pequeñas) toma la media redondeada de su R, su G y su B. El alfa y lo que queda fuera no cambian.
selectFaces(candidates, minScore): las de score >= minScore, de mayor a menor, sin las que solapan con una ya aceptada con iou > 0.3 (iou ya está escrita).
anonymise(img, faces, opts): aplica pixelate a cada cara con toPixelRect(face.box, img.width, img.height, opts.margin) y celdas de opts.block. La vista previa tapa las caras de la foto y deja mover los tres valores.
Retos 0/5
- Del recuadro del modelo a píxeles: margen, redondeo y bordes
- El pixelado: la media de cada celda, el alfa intacto, el original sin tocar
- Celdas cortadas en el borde del recuadro y nada fuera de él
- Las caras de la terraza: cuatro con 0,5 y solo dos con 0,998
- Con las caras del modelo (tu foto) o la muestra: cada cara tapada y el resto intacto
function toPixelRect(box, width, height, margin) {
// agranda cada lado margin veces el tamaño del recuadro y recórtalo a la imagen; floor a la izquierda y arriba, ceil a la derecha y abajo
return { x: 0, y: 0, w: 0, h: 0 };
}
function pixelate(img, rect, block) {
// una imagen NUEVA: dentro de rect, cada celda de block x block toma el color medio redondeado de sus píxeles; el alfa y el resto no cambian
return img;
}
function selectFaces(candidates, minScore) {
// score >= minScore, de mayor a menor; descarta el recuadro que solape con uno aceptado con iou > 0.3
return candidates;
}
function anonymise(img, faces, opts) {
// pixela cada cara: toPixelRect(face.box, img.width, img.height, opts.margin) en celdas de opts.block px
return img;
}
console.log(selectFaces(FACES.faces, 0.5).length, 'caras');La idea
Antes de publicar la foto de un evento, de una obra o de la cámara de una tienda, las caras de quien no ha dado permiso tienen que quedar irreconocibles. A mano no escala, y un servicio en la nube significa enviar la foto entera, con las caras, a un tercero. Aquí lo hace un detector de 1,3 MB en tu pestaña y la foto no sale de tu equipo.
UltraFace no devuelve «cuatro caras». Devuelve 4.420 recuadros fijos, cada uno con su confianza, y varios se encienden sobre la misma cara. El primer paso es el de siempre: umbral de confianza y supresión de no máximos. Después viene lo propio de la privacidad: agrandar el recuadro con un margen, porque el detector se ciñe a la cara y deja fuera pelo, orejas y barbilla, y pixelar con bloques grandes. Un bloque de 2 px se deja leer; uno de 12 px sobre una cara de 80 px ya no.
Lo que medimos el 19-10-2026 en el navegador del DGX Spark (ONNX Runtime Web, WebAssembly, un hilo): de 10 a 17 ms por foto de 1024 × 768. En la terraza propone 47 recuadros con confianza ≥ 0,3, todos sobre las cuatro personas de la mesa, y tras el NMS quedan cuatro caras. No marca a las personas desenfocadas del interior del café. Y si subes el umbral a 0,998, dos de las cuatro caras se quedan sin tapar: aquí el error caro es la cara que no se ve, así que el umbral se baja, no se sube, y una persona revisa el resultado antes de publicar.
Pixelar no es cifrar. Con bloques pequeños hay modelos que reconocen a la persona igualmente; un bloque grande o un rectángulo sólido es lo seguro. Y tapar caras reduce los datos personales de una foto, pero no sustituye al permiso cuando la ley lo pide.
Hazlo en tu máquina
Con Python y Pillow, sobre los recuadros en bruto y la foto de este ejercicio (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')
" Para profundizar: la referencia de GitHub (en inglés) →
¿Esto en producción, con tus datos? Hablemos 15 minutos →