Cuando la SEP Zacatecas nos pidió un sistema de control de acceso biométrico para el CBTis No. 001 de Fresnillo, la decisión arquitectónica más importante no fue qué modelo usar ni qué lenguaje — fue dónde ejecutar la inferencia. La respuesta definió el proyecto entero: en el edge, sobre una Raspberry Pi Zero 2W, sin enviar imágenes ni descriptores a la nube. Esta es la historia técnica de por qué, y cómo.

El problema

Mil doscientos alumnos, dos turnos, una ventana de entrada de veinte minutos. El sistema anterior era una lista impresa que dos prefectos paloteaban manualmente en la puerta. Costo real: cuarenta minutos diarios de tiempo humano por turno, errores de transcripción del 8 %, y cero visibilidad para los padres de familia sobre si su hijo llegó al plantel.

Los kioscos comerciales mexicanos que vimos — Hikvision DS-K1T343, ZKTeco SpeedFace-V5L — resuelven el hardware pero fallan en dos dimensiones críticas para México: cuestan de $5,000 a $8,000 MXN por punto de acceso, y su telemetría vive en servidores chinos sin garantías bajo la Ley Federal de Protección de Datos Personales en Posesión de Particulares (LFPDPPP). Una escuela pública no puede firmar eso.

La decisión: edge, no nube

La tentación inicial fue construir un sistema con reconocimiento facial en la nube — Google Cloud Vision, AWS Rekognition, o face-api.js con los frames enviados vía WebSocket. Técnicamente más simple, más rápido de entregar, con hardware del cliente siendo sólo un navegador.

Tres restricciones lo descartaron:

  • Privacidad. El Artículo 9 de la LFPDPPP clasifica biometría como dato sensible. Enviar descriptores faciales de menores a servidores extranjeros es un infierno de compliance, aún con consentimiento.
  • Conectividad. Una escuela pública mexicana pierde red de cinco a doce veces al día. Un kiosco que deja de funcionar cada vez que Telmex parpadea es un kiosco inservible.
  • Latencia. Con cuatrocientos alumnos entrando en veinte minutos, cada ciclo de reconocimiento tiene un presupuesto de tres segundos. Un round-trip a us-central1 más la inferencia quema dos de esos tres segundos antes de decidir nada.

El edge es la fuente de verdad; la nube es réplica eventual. Ese orden invierte lo que nos enseñaron, pero es el único que sobrevive al apagón del miércoles por la tarde.

El edge resolvía los tres: la biometría vive y muere en el plantel, el modo offline es natural, y la latencia depende sólo de la CPU local.

El hardware: Pi Zero 2W

Elegimos la Raspberry Pi Zero 2W por una razón que parece banal: es la SBC ARM comercial más barata con 512 MB de RAM y quad-core a 1 GHz. A $806 MXN por unidad, cabe en el presupuesto de compras menores de una escuela pública sin pasar por comité.

Stack físico completo:

bom.mdmarkdown
Raspberry Pi Zero 2W           806.44 MXN
Fuente 5V 3A Tecneu            139.56 MXN
microSD ADATA 128GB            378.00 MXN
Cámara Logitech C270 HD        486.00 MXN
Pantalla SPI 3.5" ILI9486      474.05 MXN
Cable OTG Soku V8              159.99 MXN
PAM8403 + bocinas 50mm         225.91 MXN
Headers + adaptador HDMI       ~160 MXN
Case 3D impreso                ~400 MXN
----------------------------------------
Total proyectado             ~3,225 MXN

Dos veces más barato que la opción Hikvision equivalente, con código fuente 100 % auditable y modificable.

El pipeline de seis etapas

El flujo completo de reconocimiento, desde que el alumno se acerca hasta que queda asentado su registro, es una pipeline de seis pasos en Python. Nada de JavaScript, nada de navegador — puro CPU ARM con modelos ONNX cuantizados:

Etapa 1: captura con ffmpeg

La cámara USB Logitech C270 entrega YUYV a 640×480, 30 fps. Usamos ffmpeg en modo warm-up para descartar los primeros cuatro frames (el sensor CMOS tarda ~150 ms en estabilizar el gain), y capturamos el quinto:

capture.pypython
import subprocess

def capture_frame(device="/dev/video0", warmup=4) -> bytes:
    cmd = [
        "ffmpeg", "-f", "v4l2", "-i", device,
        "-vf", f"select=gte(n\\,{warmup})",
        "-vframes", "1", "-f", "image2pipe",
        "-vcodec", "mjpeg", "-loglevel", "error", "-"
    ]
    result = subprocess.run(cmd, capture_output=True, timeout=10)
    return result.stdout

Etapa 2: detección con YuNet

YuNet es un detector facial del OpenCV Model Zoo: MobileNetV2 cuantizado a 228 KB que devuelve bounding box más cinco landmarks faciales (ojos, nariz, comisuras) por cada rostro en el frame. Es increíblemente rápido en ARM — lo corremos en ~80 ms en la Pi Zero 2W.

detect.pypython
import cv2

detector = cv2.FaceDetectorYN.create(
    model="yunet_n_320_320.onnx",
    config="",
    input_size=(320, 320),
    score_threshold=0.6,
    nms_threshold=0.3,
    top_k=5000,
)

def detect(img_bgr):
    detector.setInputSize((img_bgr.shape[1], img_bgr.shape[0]))
    _, faces = detector.detect(img_bgr)
    if faces is None:
        return []
    return [{"bbox": f[:4], "landmarks": f[4:14]} for f in faces]

Score típico observado en producción: 0.92 – 0.95. Abajo de 0.6 es descarte automático — probablemente es un poster, un reflejo, o el alumno no está viendo la cámara.

Etapa 3: alineación facial

SFace — el modelo de embedding — requiere rostros a 112×112 RGB alineados. Los landmarks de YuNet sirven para esto: rotamos y escalamos la imagen con una transformación afín para que los ojos queden horizontales a una distancia canónica. Esto sube la precisión de match del 92 % al 99 % en nuestros benchmarks internos.

Etapa 4: embedding con SFace

SFace es una ResNet-50 optimizada de 37 MB que convierte el rostro alineado en un descriptor de 128 dimensiones float32. 512 bytes por persona. Ese es el "DNA digital" que comparamos contra la base de alumnos registrados.

embed.pypython
import cv2
import numpy as np

recognizer = cv2.FaceRecognizerSF.create(
    model="face_recognition_sface_2021dec.onnx",
    config="",
)

def embed(img_bgr, face) -> np.ndarray:
    aligned = recognizer.alignCrop(img_bgr, face)
    descriptor = recognizer.feature(aligned)
    return descriptor.flatten()  # shape: (128,)

Etapa 5: match 1:N con doble validación

Aquí está una decisión que tomamos por accidente y resultó crítica. SFace expone dos métricas de distancia distintas: coseno y L2. Los tutoriales usan una, pero en producción, con iluminación variable y diferencias de edad entre la foto de registro y el alumno actual, una sola métrica genera falsos positivos cada 15 – 20 registros. Demasiado.

La solución fue exigir que ambas métricas clasifiquen como match simultáneamente:

match.pypython
def match(descriptor_query, candidates):
    best = None
    best_cos = 0.0
    for student_id, desc_stored in candidates:
        cos = recognizer.match(
            descriptor_query, desc_stored,
            cv2.FaceRecognizerSF_FR_COSINE
        )
        l2 = recognizer.match(
            descriptor_query, desc_stored,
            cv2.FaceRecognizerSF_FR_NORM_L2
        )
        # Doble validación: AMBAS métricas deben superar umbral
        if cos > 0.363 and l2 < 1.128:
            if cos > best_cos:
                best_cos = cos
                best = (student_id, cos, l2)
    return best
Por qué doble umbral funciona

Coseno y L2 capturan variaciones diferentes del rostro. Coseno premia similitud de dirección del vector (independiente de la magnitud); L2 penaliza diferencias absolutas. Un poster o una fotografía impresa típicamente pasan una pero fallan la otra — falsos positivos que disparaban notificaciones erróneas a los padres, algo devastador para la confianza del sistema.

Los umbrales 0.363 coseno y 1.128 L2 no son arbitrarios — los calibramos con 200 muestras etiquetadas: 100 positivas (el alumno real) y 100 negativas (otro alumno, poster, foto del celular). Los valores son el punto donde FAR < 0.1% y FRR ≈ 1%.

Etapa 6: persistencia local + sync Firebase

Un match exitoso genera un registro. Lo escribimos primero a SQLite local, después a Firestore. Ese orden importa:

persist.pypython
import sqlite3
from firebase_admin import firestore

def record_access(student_id, confidence, timestamp):
    # 1. Local primero (sub-ms, nunca falla)
    conn = sqlite3.connect("/var/lib/keyon/local.db")
    conn.execute(
        "INSERT INTO ingresos (matricula, ts, confidence, synced) "
        "VALUES (?, ?, ?, 0)",
        (student_id, timestamp, confidence),
    )
    conn.commit()

    # 2. Cloud después (best-effort)
    try:
        db = firestore.client()
        db.collection("ingresos_cbtis").add({
            "identificador": student_id,
            "fecha": timestamp.date().isoformat(),
            "hora": timestamp.time().isoformat(),
            "origen": "terminal_pi",
            "confianza": confidence,
        })
        conn.execute(
            "UPDATE ingresos SET synced = 1 WHERE ts = ?",
            (timestamp,),
        )
        conn.commit()
    except Exception:
        # El sync loop de background lo recupera luego
        pass

Si Firebase está caído, el registro ya existe en SQLite. Un sync_loop separado revisa cada minuto WHERE synced = 0 y reintenta con backoff exponencial. El sistema sobrevive cortes de red de horas sin perder un solo registro.

Resultados medidos en producción

Después de la primera semana de operación continua en CBTis No. 001, los números reales (medidos con 3,847 registros efectivos):

metrics.mdmarkdown
Latencia detect + match         2.5 s (mediana)
Latencia end-to-end             3.5 s (mediana)
Precisión (match correcto)      98.8 %
Falsos positivos                0.12 %
Temperatura CPU en operación    50-55 °C (throttling a 80 °C)
RAM usada                       225 MB de 416 MB disponibles
Boot a operativa (post-reboot)  49 s
Consumo energético              3 W promedio

Para ponerlo en contexto: Hikvision DS-K1T343 reporta 3.0 s de latencia, 98.5 % de precisión, y 12 W de consumo. Terminal Pro v2 iguala el desempeño al 70 % del costo y un cuarto del consumo eléctrico.

Lecciones que aprendimos

Tres cosas que no aparecen en los tutoriales de face recognition y que nos costaron tiempo descubrir:

1. La alineación no es opcional

Saltarse la alineación facial (pasarle a SFace el crop directo de YuNet sin rotar) baja la precisión del 99 % al 91 %. Es tentador porque ahorra ~20 ms por inferencia, pero introduce tantos falsos positivos que el sistema se vuelve impráctico. Alineación es infraestructura, no optimización.

2. El warm-up de la cámara USB es crítico

Capturar el primer frame de una cámara USB recién abierta produce imágenes sub-expuestas el 40 % del tiempo. YuNet no detecta nada, el sistema reporta "no hay persona", el alumno se frustra. La solución fue capturar siempre el frame 5 y descartar los 4 anteriores; el tiempo extra (~130 ms) se paga de sobra con la estabilidad.

3. SQLite antes que Firestore

Diseñar el sistema como "primero Firebase, si falla guardar local" genera race conditions y pérdidas. La inversión — local primero, cloud después — es trivial de implementar y hace el sistema determinísticamente resiliente. El edge es la fuente de verdad; la nube es réplica eventual.

Dónde continuar

El código completo de KEYON Terminal Pro v2 está abierto en github.com/santirivera-oss/keyon-terminal. La documentación técnica completa, incluyendo el modelo Firestore, el pipeline de monitoreo por heartbeat y la arquitectura de 3 capas, vive en exara.uk/docs.

Si estás construyendo algo similar — sea para una escuela, una empresa, o un condominio — y quieres evitar los mismos pozos que nosotros caímos, escríbenos. Cada deploy enseña algo nuevo.