Control de herramientas con visión artificial en el Makespace

28 de agosto 2026ComentariospythonDavid Poza SuárezComentarios

Hace un tiempo os hablé de Makespace Madrid, la asociación maker de Tetuán donde paso buena parte de mi tiempo libre. Uno de los encantos (y a la vez de los quebraderos de cabeza) de un espacio compartido es precisamente eso: que es compartido. Las herramientas pasan por muchas manos, y por más carteles y buena voluntad que pongamos, al final del día siempre hay un destornillador que no ha vuelto a su sitio, unos alicates que aparecen en otro banco o directamente una herramienta que se ha "evaporado".

Este problema, que parece menor, tiene un coste real: tiempo perdido buscando, herramientas que se estropean o se pierden, y esa sensación de desorden que desmotiva. Así que decidí atacarlo con lo que mejor se me da: escribir código y trastear con electrónica.

El resultado es Workbench Organizer CV, un sistema que, al cerrar el makespace, hace una foto de cada banco de trabajo con una cámara y comprueba mediante visión artificial si cada herramienta está en su hueco. Si algo falta o está mal colocado, genera una imagen anotada, guarda un historial y lanza una notificación.

Ejemplo del tipo de salida que genera el sistema: cada herramienta se evalúa contra su hueco de referencia y se clasifica en ok / uncertain / misplaced

Ejemplo del tipo de salida que genera el sistema: cada herramienta se evalúa contra su hueco de referencia y se clasifica en ok / uncertain / misplaced

En este artículo quiero hacer un recorrido en profundidad: primero analizaré el problema y justificaré por qué he tomado cada decisión de arquitectura y de librerías; luego describiré la estructura del código usando el modelo C4; y por último me detendré con calma en el microservicio de visión artificial, explicando no solo qué hace el código sino la teoría que hay detrás de cada técnica que uso. Termino con la configuración parámetro a parámetro y con un análisis honesto de las limitaciones.

Tenéis todo el código en el repositorio: github.com/davidpoza/workbench-organizer-cv.


El problema, y por qué lo resuelvo así

Antes de escribir una sola línea conviene entender bien qué estamos resolviendo, porque de ahí salen todas las decisiones técnicas.

La necesidad es sencilla de enunciar: al cerrar el taller, saber si las herramientas están en su sitio. Pero tiene matices importantes que condicionan el diseño:

  • Los bancos tienen paneles de sombras (shadow boards): cada herramienta tiene su silueta dibujada en su hueco. Es decir, ya existe una "verdad de referencia" visual: cómo debería verse el banco cuando todo está en orden.
  • No necesito saber qué herramienta es (no es un problema de clasificación de objetos), sino si la herramienta que va en este hueco está o no está. Es un problema de comparación contra una referencia, no de reconocimiento.
  • Las condiciones son relativamente controladas: cámara fija, iluminación de taller, banco plano. No es la selva.

Decisión 1: visión artificial clásica, sin machine learning

La tentación moderna sería entrenar una red neuronal (un detector tipo YOLO, por ejemplo). Lo descarté a conciencia, y creo que es la decisión más importante del proyecto:

Un modelo de deep learning necesitaría un dataset etiquetado de cada herramienta en cada banco, reentrenarse cada vez que cambia una herramienta de sitio, una GPU o al menos una CPU capaz para inferir, y aun así sería una caja negra difícil de depurar cuando fallara.

Como ya tengo una imagen de referencia por banco (el estado "correcto"), el problema se reduce a: "¿se parece lo que veo ahora, en la región de esta herramienta, a lo que había en la referencia?". Y eso es justo lo que la visión artificial clásica —SSIM, correlación de plantillas, comparación de bordes— resuelve de maravilla, sin entrenar nada, de forma explicable (puedo decir exactamente por qué una herramienta ha dado 0.41) y corriendo en cualquier CPU modesta. Añadir una herramienta nueva es simplemente capturar una referencia y dibujar un polígono, no reentrenar un modelo.

Es la misma filosofía self-hosted y sin dependencias mágicas que ya defendí cuando monté el clasificador bayesiano del microservicio de comentarios: prefiero una técnica que entiendo y controlo a una que impresiona en el CV pero que no puedo depurar a las once de la noche.

Decisión 2: tres microservicios

Podría haberlo hecho todo en un único proceso, pero separé responsabilidades en tres servicios con fronteras muy claras, y hay una razón de peso para cada corte:

  • cv-service (Python): todo el cálculo de imagen. Es sin estado (stateless): recibe dos imágenes y un JSON, devuelve puntuaciones. No conoce cámaras, ni credenciales, ni configuración, ni toca el disco. ¿Por qué? Porque Python es el ecosistema natural de OpenCV/NumPy/scikit-image, y porque aislar el motor de visión lo hace trivial de testear (le metes dos imágenes sintéticas y compruebas el resultado) y de escalar.
  • api-service (Node.js + TypeScript): el cerebro. Autenticación, configuración, gestión de cámaras, captura de snapshots, persistencia, orquestación de inspecciones, notificaciones. Es el único que lee secretos y escribe en disco.
  • frontend (React + Vite, servido por Nginx): la interfaz en español, pensada para una tablet Android en horizontal en modo kiosco junto a la puerta.

La frontera "solo el API ve los secretos y el disco; el CV es una calculadora pura" no es estética: es una decisión de seguridad y de testabilidad. Si mañana quiero mover el CV a otra máquina con más CPU, no tengo que preocuparme de credenciales ni de rutas de ficheros.

Decisión 3: persistencia en ficheros JSON, sin base de datos

Para el volumen de datos de un makespace (un puñado de bancos, unas decenas de herramientas, un historial acotado), montar PostgreSQL sería usar un mazo para una chincheta. Uso ficheros JSON con escritura atómica y un mutex, más las imágenes en disco. Sin base de datos, sin cola de mensajes, sin Kubernetes. Menos piezas móviles = menos cosas que se rompen un domingo.

Decisión 4: cámaras ESP32-CAM por pull HTTP

Nada de RTSP ni de streaming. Cada cámara expone un JPEG por HTTP y es el servidor quien lo pide (pull) cuando lo necesita. Esto encaja perfectamente con hardware baratísimo como la ESP32-CAM y con ESPHome, y reduce la superficie de ataque: la cámara no empuja nada, solo responde cuando se le pregunta.


El hardware: ESP32-CAM + ESPHome

Placa ESP32-CAM: un microcontrolador con WiFi y una cámara OV2640 por poco más de lo que cuesta un café con leche. Fuente: Wikimedia Commons, CC BY-SA 4.0

Placa ESP32-CAM: un microcontrolador con WiFi y una cámara OV2640 por poco más de lo que cuesta un café con leche. Fuente: Wikimedia Commons, CC BY-SA 4.0

La ESP32-CAM es una plaquita con WiFi, una cámara OV2640 y un precio ridículo. La programo con ESPHome, que me permite configurarla de forma declarativa y, lo más importante para este proyecto, exponer un endpoint de snapshot:

esp32_camera:
  name: cam-banco-electronica
  # ... pines de la ESP32-CAM AI-Thinker ...

esp32_camera_web_server:
  - port: 8081
    mode: snapshot

Con esto, un simple GET http://<ip-camara>:8081/ devuelve un JPEG del banco. En el sistema doy de alta esa cámara como tipo esphome con autenticación none.

Para montarla en el banco imprimí en 3D esta carcasa con rótula tipo snap-fit para ESP32-CAM, que viene fenomenal porque la rótula permite orientar la cámara hacia el banco con precisión (algo clave, como veremos, para que la alineación funcione bien).

Seguridad: ESPHome no autentica el endpoint de snapshot. Cualquiera en la misma red puede ver la imagen. Por eso estas cámaras deben vivir en una VLAN / red IoT aislada, accesible únicamente desde el servidor. En mi caso ya tengo la red segmentada con pfSense, como conté en el artículo del makespace.


La arquitectura, con el modelo C4

Para explicar la estructura voy a usar el modelo C4 de Simon Brown, que describe un sistema en niveles de zoom progresivo: Contexto → Contenedores → Componentes → Código. Me parece la mejor forma de no perderse: empezamos con vista de pájaro y vamos bajando.

Nivel 1 — Contexto

En el nivel más alto solo interesa quién usa el sistema y con qué se relaciona:

Diagrama de contexto C4: el sistema, sus dos tipos de usuario y los sistemas externos con los que habla

Diagrama de contexto C4: el sistema, sus dos tipos de usuario y los sistemas externos con los que habla

Hay dos "actores" que disparan cosas:

  • El operario del makespace, que desde la tablet consulta el panel, gestiona cámaras y bancos, y calibra los umbrales.
  • La automatización de cierre: puede ser literalmente una palanca junto a la puerta, o una automatización de Home Assistant, que al cerrar el taller lanza la inspección de todos los bancos.

Y dos sistemas externos: las cámaras (de las que el sistema tira imágenes) y un webhook de notificaciones (al que el sistema empuja las incidencias).

Nivel 2 — Contenedores

Bajamos un nivel y abrimos la caja "sistema". Aparecen los tres microservicios y los almacenes de datos:

Diagrama de contenedores C4: frontend, api-service, cv-service y los ficheros de datos

Diagrama de contenedores C4: frontend, api-service, cv-service y los ficheros de datos

Fijaos en el detalle de los almacenes: solo el api-service monta el volumen de datos en lectura/escritura, y config/auth.json (que contiene los secretos) se monta como solo lectura. El cv-service no toca ningún fichero. Es la frontera de seguridad de la que hablaba, hecha explícita en el docker-compose.yml.

Nivel 3 — Componentes del api-service

Dentro del API, cada responsabilidad vive en su módulo:

Diagrama de componentes C4 del api-service

Diagrama de componentes C4 del api-service

  • auth: login con usuarios estáticos (bcrypt), sesión por JWT en cookie HttpOnly, y el trigger token de la palanca.
  • cameras + snapshot: captura de imágenes con protección SSRF, soporte de autenticación Digest y estabilización de fotogramas.
  • workbenches / tokens / webhooks / notifications: CRUD de bancos, tokens de API por banco, y el motor de notificaciones.
  • inspections: el orquestador, que es el corazón operativo; habla con las cámaras, con el CV y con la persistencia.
  • persistence y state: escritura atómica de los JSON y estado en memoria.

Nivel 3 — Componentes del cv-service

Y aquí está el protagonista de este artículo, el pipeline de visión desglosado en módulos independientes:

Diagrama de componentes C4 del cv-service: el pipeline de visión artificial

Diagrama de componentes C4 del cv-service: el pipeline de visión artificial

Cada caja verde es un módulo con una única responsabilidad y sus propios tests. Esta separación no es capricho: es lo que me permite razonar sobre cada técnica por separado y probarla con imágenes sintéticas. Vamos a recorrerlo entero, pero antes veamos cómo encaja todo en una inspección completa.


Anatomía de una inspección

Cuando se cierra el makespace y se dispara run-all, esto es lo que ocurre de principio a fin:

Diagrama de secuencia de una inspección completa

Diagrama de secuencia de una inspección completa

El orquestador (en api-service/src/inspections/orchestrator.ts) tiene tres propiedades que me importaban mucho:

Concurrencia acotada. Los bancos se inspeccionan en paralelo pero con un límite (cameraConcurrency), para no saturar la red ni el CV. Uso un pequeño pool:

const outcomes = await runWithConcurrency(workbenches, config.settings.cameraConcurrency, (wb) =>
  worker(wb, inspectionId),
);

Aislamiento de fallos parciales. Si una cámara no responde o el CV peta con un banco, ese banco se marca como not_evaluable pero el resto de la inspección continúa. Un fallo nunca cancela el lote. Cada inspectWorkbench está diseñado para no lanzar excepciones nunca: cualquier problema se convierte en un resultado.

Bloqueo por banco. Un banco no puede tener dos inspecciones simultáneas (imaginad la palanca y una automatización disparando a la vez). Un Mutex protege un Set de bancos bloqueados:

const locked = new Set<string>();
const lockMutex = new Mutex();

async function tryLock(id: string): Promise<boolean> {
  return lockMutex.runExclusive(() => {
    if (locked.has(id)) return false;
    locked.add(id);
    return true;
  });
}

Un principio que atraviesa todo el diseño y que quiero subrayar: el sistema nunca se inventa resultados positivos. Si la imagen no tiene calidad o no se puede alinear, el banco es not_evaluable. Prefiero mil veces un "no lo sé" honesto a un "todo correcto" falso que te haga cerrar el taller con una herramienta perdida.


El microservicio de visión, en profundidad

Vamos al lío. El cv-service es un FastAPI minúsculo en la superficie: expone POST /v1/compare, que recibe dos imágenes (referencia y captura actual) más un payload JSON con la definición de las herramientas, y devuelve el veredicto.

@app.post("/v1/compare", response_model=CompareResponse)
async def compare(
    reference: UploadFile = File(...),
    current: UploadFile = File(...),
    payload: str = Form(...),
) -> CompareResponse:
    payload_obj = ComparePayload.model_validate(json.loads(payload))
    ...
    result = run_compare(ref_bytes, cur_bytes, payload_obj)

Antes de tocar un solo píxel, Pydantic valida el payload (app/models.py). Y aquí ya hay teoría interesante. Cada herramienta se describe con un polígono de vértices normalizados, y valido su no-degeneración con la fórmula del área de Gauss (el shoelace o "lazo de zapato"):

def _shoelace_area(points: List[Vertex]) -> float:
    total = 0.0
    for i in range(len(points)):
        a = points[i]
        b = points[(i + 1) % len(points)]
        total += a.x * b.y - b.x * a.y
    return abs(total) / 2.0

Esta fórmula calcula el área de un polígono simple sumando los "productos cruzados" de vértices consecutivos; si el resultado es prácticamente cero, el polígono es degenerado (todos los puntos casi en línea) y lo rechazo. También exijo que los pesos de las métricas sumen 1 y que haya entre 3 y 24 vértices. Validar en la frontera, con tipos, evita que basura llegue al algoritmo.

El orquestador del pipeline es app/pipeline/compare.py. Su flujo es este:

Flujo completo del pipeline de visión, con las ramas de not_evaluable

Flujo completo del pipeline de visión, con las ramas de not_evaluable

Recorramos cada etapa.

1. Validación y decodificación

app/pipeline/validation.py decodifica los bytes a una imagen BGR. Antes de decodificar comprueba los magic bytes (los primeros bytes que identifican el formato de fichero):

_JPEG_MAGIC = b"\xff\xd8\xff"
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"

def looks_like_image(data: bytes) -> bool:
    return data.startswith(_JPEG_MAGIC) or data.startswith(_PNG_MAGIC)

Es una comprobación baratísima antes de gastar CPU en cv2.imdecode. Además valido dimensiones mínimas y máximas. La clave del módulo: nunca lanza excepciones; cualquier problema se reporta con valid=False y una razón, para que una imagen mala se maneje con elegancia.

2. Calidad de imagen: brillo y enfoque

app/pipeline/quality.py decide si la imagen es "de fiar". Dos medidas:

Brillo: simplemente la media de luminancia en escala de grises. Si es demasiado baja (banco a oscuras) o demasiado alta (sobreexpuesta, un foco reflejando), la imagen no sirve.

Enfoque / desenfoque: aquí está la joya, la varianza del Laplaciano:

def measure_blur(gray: np.ndarray) -> float:
    return float(cv2.Laplacian(gray, cv2.CV_64F).var())

El operador Laplaciano es la segunda derivada espacial de la imagen (la suma de las segundas derivadas en x y en y, ∂²f/∂x² + ∂²f/∂y²). Responde con fuerza en las transiciones bruscas (bordes) y con cero en las zonas planas. La intuición es preciosa: una imagen nítida tiene bordes marcados, así que su Laplaciano tiene valores muy dispares → varianza alta. Una imagen borrosa difumina esos bordes, el Laplaciano se aplana → varianza baja. Poniendo un umbral sobre esa varianza tengo un detector de desenfoque en una línea. Es una técnica clásica (popularizada por Pech-Pacheco et al.) y funciona sorprendentemente bien.

¿Por qué me importa tanto la calidad? Porque más adelante, aunque una herramienta puntúe alto, si la imagen no es de fiar no la doy por buena: la degrado a uncertain. Insisto: no inventar positivos.

3. Alineación: ORB + RANSAC + homografía

Este es, con diferencia, el módulo más denso (app/pipeline/alignment.py), y el más importante para que todo lo demás funcione. El problema: la cámara puede haberse movido un poco entre la captura de la referencia y la de hoy (alguien la rozó, la rótula cedió un milímetro). Si comparo píxel con píxel sin corregir eso, todo saldría mal. Necesito alinear la imagen actual sobre el marco de la referencia.

El proceso tiene cuatro fases y cada una tiene su teoría:

a) Detección de puntos característicos con ORB. ORB (Oriented FAST and Rotated BRIEF) es un detector-descriptor de features: encuentra puntos "interesantes" de la imagen (esquinas, texturas distintivas) y los describe con un vector binario invariante a rotación. Es el sustituto libre y rápido de SIFT/SURF (que estuvieron patentados). "Interesante" significa aquí un punto que se puede reencontrar de forma fiable en otra imagen de la misma escena.

orb = cv2.ORB_create(nfeatures=SETTINGS.orb_features)  # 1500 por defecto
kp1, des1 = orb.detectAndCompute(ref_gray, None)
kp2, des2 = orb.detectAndCompute(cur_gray, None)

b) Emparejamiento con test de Lowe. Comparo los descriptores de ambas imágenes con un BFMatcher (Brute Force) usando distancia de Hamming (la adecuada para descriptores binarios: cuenta bits distintos). Para cada punto busco sus dos mejores candidatos y aplico el ratio test de Lowe: solo me quedo con el emparejamiento si el mejor candidato es claramente mejor que el segundo (su distancia es menor que 0.75 veces la del segundo). Si los dos mejores están empatados, es que el punto es ambiguo y lo descarto.

raw = matcher.knnMatch(des1, des2, k=2)
good = [m for pair in raw if len(pair) == 2 for m, n in [pair]
        if m.distance < SETTINGS.lowe_ratio * n.distance]

c) Homografía con RANSAC. Con los buenos emparejamientos estimo una homografía: la matriz 3×3 que describe la transformación proyectiva entre los dos planos (cómo se mapea cada punto de la imagen actual al marco de la referencia). El problema es que aún quedan emparejamientos erróneos (outliers) que arruinarían un ajuste por mínimos cuadrados. Aquí entra RANSAC (Random Sample Consensus): en vez de ajustar con todos los puntos, prueba muchísimas veces con subconjuntos mínimos aleatorios y se queda con el modelo que más puntos "de acuerdo" (inliers) reúne. Es robustísimo frente a ruido.

h, mask = cv2.findHomography(dst, src, cv2.RANSAC, 5.0)

d) Comprobación de cordura geométrica. Que OpenCV me devuelva una matriz no significa que sea sensata. Un banco es aproximadamente plano y la cámara está fija, así que la transformación debe ser suave: sin reflexiones, sin escalados absurdos, sin perspectiva extrema. Compruebo el determinante de la submatriz de rotación/escala y los términos de perspectiva:

def _homography_is_sane(h):
    if abs(h[2, 0]) > 1e-2 or abs(h[2, 1]) > 1e-2:  # perspectiva excesiva
        return False
    det = float(np.linalg.det(h[0:2, 0:2]))
    if det <= 0.1 or det >= 10.0:  # reflexión o escala disparatada
        return False
    return True

Si la homografía es válida, deformo la imagen actual sobre el marco de la referencia con cv2.warpPerspective. Y aún exijo una ratio de inliers mínima: si de los emparejamientos buenos muy pocos acaban de acuerdo con la homografía, no me fío.

El plan B. ¿Y si no hay textura suficiente para estimar homografía (un banco muy limpio y liso da pocos features)? No me rindo a la primera: calculo el SSIM global entre ambas imágenes. Si son casi idénticas (desplazamiento despreciable), sigo adelante sin alinear; si son muy distintas, entonces sí marco not_evaluable. Otra vez la misma filosofía: seguir solo cuando puedo hacerlo con garantías.

4. Regiones: del polígono a los píxeles

Ya con las imágenes alineadas, app/pipeline/regions.py traduce cada polígono normalizado (coordenadas en 0..1, independientes de la resolución) a píxeles, y prepara tres cosas por herramienta:

  • El rectángulo delimitador (bounding rect) del polígono, para recortar.
  • Una máscara binaria del polígono (con cv2.fillPoly), para que solo los píxeles dentro del contorno cuenten.
  • Una expansión del rectángulo según el searchMargin, que da un pequeño margen de búsqueda alrededor.

¿Por qué polígonos y no simples rectángulos? Porque con cámaras muy angulares, una herramienta cerca del borde aparece deformada, y un rectángulo metería un montón de fondo (o de herramienta vecina) en la comparación. Un polígono ajustado al contorno de la silueta es mucho más preciso. Fue, de hecho, una mejora que introduje sobre el diseño inicial de bounding boxes rectangulares (las configuraciones antiguas se migran solas a un polígono de 4 vértices).

5. Las métricas: el corazón de la comparación

Llegamos al núcleo (app/pipeline/metrics.py). Por cada herramienta comparo su región en la referencia contra la región en la captura actual, y calculo tres métricas complementarias. La idea de usar tres y fusionarlas es que cada una captura un aspecto distinto del parecido, y donde una falla otra compensa.

Primero, normalización. Antes de comparar, ecualizo el contraste con CLAHE (Contrast Limited Adaptive Histogram Equalization) y aplico un ligero desenfoque gaussiano:

def normalize(gray):
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    return cv2.GaussianBlur(clahe.apply(gray), (3, 3), 0)

CLAHE ecualiza el histograma por regiones (a diferencia de la ecualización global), lo que la hace robusta frente a cambios de iluminación no uniformes (una sombra que cae sobre media herramienta). El límite de recorte evita amplificar ruido. El desenfoque suave elimina ruido de sensor de alta frecuencia. Con esto, dos fotos de la misma herramienta con luz distinta se parecen mucho más.

Métrica 1 — SSIM (Structural Similarity Index). El SSIM no compara píxel a píxel como haría un error cuadrático medio, sino que compara luminancia, contraste y estructura en ventanas locales, imitando cómo percibe el sistema visual humano. Da un valor entre -1 y 1 (aquí lo recorto a 0..1). Es la métrica reina para "¿estas dos imágenes se parecen estructuralmente?". La saco de scikit-image:

from skimage.metrics import structural_similarity as ssim
value = ssim(ref_norm, cur_norm)

Métrica 2 — Similitud de bordes. Extraigo los bordes de ambas regiones con Canny y mido cuánto se solapan. Aquí uso el coeficiente de Dice (equivalente al F1 entre dos conjuntos de píxeles), que es dos veces la intersección dividida entre la suma de ambos conjuntos: Dice = 2·|A ∩ B| / (|A| + |B|).

def edge_similarity(ref_edges, cur_edges):
    a = cv2.dilate(ref_edges, kernel) > 0
    b = cv2.dilate(cur_edges, kernel) > 0
    inter = int(np.logical_and(a, b).sum())
    return float(2.0 * inter / (a.sum() + b.sum()))

El detalle fino: dilato los bordes antes de compararlos, para tolerar desalineaciones de 1-2 píxeles (nunca la alineación es perfecta). Esta métrica es muy buena para detectar presencia/ausencia de forma: si la herramienta no está, sus bordes característicos desaparecen y el Dice se desploma.

Para visualizarlo, esto es exactamente lo que hace el cv2.Canny(gray, 60, 160) del código sobre una región:

Extracción de bordes con Canny (umbrales 60/160, los mismos que usa el código). Imagen base de ejemplo de Wikimedia Commons

Extracción de bordes con Canny (umbrales 60/160, los mismos que usa el código). Imagen base de ejemplo de Wikimedia Commons

Métrica 3 — Correlación de plantilla. Uso cv2.matchTemplate con el método TM_CCOEFF_NORMED, que desliza la región de referencia (la "plantilla") sobre la región de búsqueda de la captura y calcula la correlación cruzada normalizada en cada posición, quedándose con el máximo. La normalización la hace robusta frente a cambios globales de brillo. El truco de usar una región de búsqueda ligeramente mayor (el searchMargin) es que tolera que la herramienta esté un pelín desplazada dentro de su hueco:

result = cv2.matchTemplate(search_region, ref_region, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(result)

El detalle que lo cambia todo: la máscara. Las tres métricas se calculan restringidas a los píxeles dentro del polígono. Para SSIM y bordes, relleno el exterior de la máscara con el valor medio interior (un relleno "neutro"), en lugar de con ceros, para no inyectar un borde artificial durísimo en el límite del polígono que falsearía las métricas. La correlación se calcula solo sobre los píxeles de dentro (correlación de Pearson enmascarada). Esto es lo que permite usar polígonos ajustados sin que el fondo contamine el resultado.

6. Fusión y clasificación

app/pipeline/scoring.py es deliberadamente trivial: una combinación lineal ponderada de las tres métricas, con pesos configurables que suman 1:

score = (weights["ssim"] * metrics.ssim
       + weights["edgeSimilarity"] * metrics.edge_similarity
       + weights["templateCorrelation"] * metrics.template_correlation)

Por defecto SSIM pesa 0.4, bordes 0.35 y plantilla 0.25. Que sea simple y transparente es una feature, no una carencia: puedo explicar exactamente de dónde sale cada score.

Y app/pipeline/classification.py traduce ese score en un estado, con una banda de incertidumbre:

def classify(score, threshold, uncertainty_margin):
    if score >= threshold:
        return "ok"
    if score >= threshold - uncertainty_margin:
        return "uncertain"
    return "misplaced"

Esa banda uncertain entre "bien" y "mal" es importante: en la frontera, en vez de arriesgarme a un falso positivo o negativo, marco la herramienta como "dudosa" para que un humano le eche un ojo. Y recordad el matiz de antes: aunque el score supere el umbral, si la calidad de la imagen no era aceptable, degrado el ok a uncertain.

7. Anotación

Por último, app/pipeline/annotation.py dibuja cada polígono sobre la imagen con su color según estado (verde ok, rojo misplaced, naranja uncertain, gris not_evaluable) y una etiqueta con el nombre y el score. Esa imagen anotada se codifica en Base64 y viaja en la respuesta; el API la decodifica y la guarda como fichero (nunca se persiste Base64 dentro del JSON). Es justo el estilo de la imagen de portada de este artículo.


Configuración: todos los parámetros, uno a uno

Una de las cosas de las que más orgulloso estoy es de que todo es configurable sin tocar código. Hay tres niveles: variables de entorno (.env), la configuración del sistema (data/config.json) y los secretos (config/auth.json). Vamos con calma.

Modelo de datos

Así se relacionan las entidades de config.json:

Modelo de datos de config.json: settings, cámaras, bancos y herramientas

Modelo de datos de config.json: settings, cámaras, bancos y herramientas

Variables de entorno (.env)

Controlan el despliegue y afinan el motor de visión sin recompilar:

Variable Qué hace Por defecto
TZ Zona horaria de todos los contenedores (sella los timestamps) Europe/Madrid
FRONTEND_PORT Puerto público de Nginx 8080
API_PORT Puerto interno de Express (no se publica) 3000
CV_SERVICE_URL URL interna del CV en la red de Compose http://cv:8000
CV_TIMEOUT_MS Timeout de la petición al CV 30000
JWT_SECURE_COOKIE Marca Secure en la cookie de sesión (activar tras HTTPS) false
JWT_EXPIRES_SECONDS Duración de la sesión 43200 (12 h)
LOGIN_RATE_LIMIT_WINDOW_MS / LOGIN_RATE_LIMIT_MAX Ventana y máximo de intentos de login 900000 / 10
WEBHOOK_RATE_LIMIT_WINDOW_MS / WEBHOOK_RATE_LIMIT_MAX Rate limit de los webhooks por token 60000 / 30
CORS_ALLOWED_ORIGINS Orígenes CORS permitidos (vacío en despliegue mismo-origen) (vacío)
MAX_JSON_BYTES / MAX_UPLOAD_BYTES Tamaño máximo de cuerpo JSON y de subida de imágenes 1000000 / 12000000
LOG_LEVEL debug / info / warn / error info
NODE_ENV production oculta stack traces production
CV_MIN_BRIGHTNESS / CV_MAX_BRIGHTNESS Umbrales de brillo aceptable 25 / 245
CV_MIN_BLUR_VARIANCE Varianza mínima del Laplaciano (por debajo = borrosa) 60

Y hay más knobs del CV con valores por defecto sensatos, que se pueden ajustar contra tus cámaras reales: CV_ORB_FEATURES (1500, número de puntos ORB), CV_LOWE_RATIO (0.75, el ratio del test de Lowe), CV_MIN_INLIER_RATIO (0.3, ratio mínima de inliers en RANSAC), CV_GLOBAL_SIM_FLOOR (0.2, el suelo de SSIM global del plan B) y CV_MIN_DIMENSION / CV_MAX_DIMENSION (guardias de tamaño de imagen).

Ajustes globales (settings)

Dentro de config.json, la sección settings:

  • defaultThreshold (0.82): umbral por defecto; una herramienta con score por encima se considera ok. Cada herramienta puede sobreescribirlo.
  • uncertaintyMargin (0.05): anchura de la banda uncertain por debajo del umbral.
  • cameraConcurrency (4): cuántos bancos se inspeccionan en paralelo.
  • inspectionHistoryLimit (200): cuántas inspecciones se guardan en el historial (se podan las viejas).
  • cvTimeoutMs (30000): timeout de la llamada al CV.
  • captureRetries / captureDelayMs: reintentos globales de captura y espera entre ellos (los valores por cámara tienen prioridad).
  • metricWeights: los pesos { ssim, edgeSimilarity, templateCorrelation } de la fusión. Deben sumar 1 (se valida).

Cámaras (cameras)

Cada cámara es una entidad independiente con id único apto para URL. Sus campos:

  • type: esphome o generic-http.
  • snapshotUrl: la URL del snapshot (solo http/https).
  • authentication: none, basic, digest o bearer. Las credenciales son de solo escritura: la API nunca las devuelve.
  • request: el bloque fino de la petición:

    • timeoutMs: timeout de la petición HTTP.
    • retries: reintentos ante fallo transitorio.
    • discardInitialFrames: cuántos fotogramas descartar antes de quedarse con uno. Esto le da tiempo al sensor de la ESP32-CAM a estabilizar exposición y balance de blancos (las primeras tomas salen oscuras o con dominante de color).
    • delayBetweenFramesMs: espera entre esos fotogramas.
    • maximumResponseBytes: tope de tamaño de la respuesta (protección anti-abuso).
    • verifyTls: verificar el certificado TLS.
    • followRedirects: seguir redirecciones (desactivado por defecto; si se activa, solo al mismo host).

Bancos (workbenches) y herramientas (tools)

Cada banco referencia una cámara por cameraId, tiene una referenceImage, un bloque alignment (enabled, minimumMatches) y una lista de herramientas. Cada herramienta:

  • polygon: lista ordenada de 3 a 24 vértices normalizados 0..1 (dibujados en la UI sobre la referencia).
  • threshold: su umbral propio; si no lo tiene, usa el defaultThreshold global.
  • searchMargin (0..0.5): cuánto expandir la región de búsqueda alrededor del polígono.
  • enabled: permite desactivar una herramienta sin borrarla.

Secretos (config/auth.json)

Fichero aparte, montado solo lectura, que nunca se sube al repo:

{
  "schemaVersion": 1,
  "jwtSecret": "UNA_CADENA_LARGA_Y_ALEATORIA_DE_32+_CARACTERES",
  "triggerToken": "TOKEN_LARGO_PARA_LA_PALANCA",
  "users": [
    { "username": "admin", "passwordHash": "$2b$12$....(hash bcrypt)...." }
  ]
}

No hay registro ni gestión de usuarios: son estáticos. El passwordHash se genera con un script incluido (npm run hash-password), y tanto jwtSecret como triggerToken los saco con openssl rand -hex 32.

Notificaciones y tokens

Las notificaciones se configuran desde la UI (pestaña Notificaciones), no por variables de entorno: eliges método, URL, cabeceras y cuerpo con plantilla. En el cuerpo puedes usar tokens como {{json}} (el objeto completo del evento, recomendado), {{workbenchName}}, {{status}}, {{toolsText}} o {{imageUrl}}. Esto hace que sirva para cualquier destino: Home Assistant, n8n, un bot de Telegram... Por ejemplo, para replicar las alertas de Telegram basta apuntar el webhook a https://api.telegram.org/bot<token>/sendMessage.

Y los tokens de webhook por banco (pestaña Tokens) permiten que un servicio externo dispare la inspección de un banco concreto. Se guardan hasheados con bcrypt, tienen un alcance (un banco o all) y el secreto se muestra una sola vez al crearlo. Ideal para un rest_command de Home Assistant:

rest_command:
  inspeccionar_banco_electronica:
    url: "http://<host>:8080/api/webhooks/inspect/banco-electronica"
    method: POST
    headers:
      X-Api-Token: !secret makespace_webhook_token

Seguridad y persistencia, de pasada

No quiero alargarme, pero merecen mención un par de decisiones que me importaban:

Persistencia atómica. Un único módulo posee todas las lecturas/escrituras. Cada escritura va a un fichero temporal y luego un rename() (atómico en el mismo sistema de ficheros), protegido por un mutex para serializar peticiones concurrentes. Cada JSON lleva schemaVersion; al cargar, si es antiguo, se hace una copia de seguridad con fecha y se migra. Si la configuración es inválida, el arranque falla ruidosamente y no la sobreescribe jamás.

Protección SSRF en la captura. Como el servidor hace peticiones a URLs que configura el usuario (las cámaras), hay que blindarlo contra Server-Side Request Forgery: solo esquemas http/https, redirecciones desactivadas por defecto (y solo al mismo host si se activan), y la cabecera Authorization nunca se reenvía a otro host. Además el navegador nunca contacta con la cámara: siempre pasa por un proxy autenticado en el servidor (GET /api/cameras/:id/snapshot).

Secretos de solo escritura y redacción en logs. Las contraseñas de cámaras y los tokens nunca vuelven por la API (solo { credentialsConfigured: true, username }), y los logs redactan Authorization, contraseñas, tokens, cookies y URLs con secretos.


Limitaciones conocidas

Sería deshonesto vender esto como infalible. La visión artificial clásica tiene límites, y prefiero enumerarlos claramente:

  • Sensibilidad a la iluminación y los reflejos. Aunque CLAHE y el gating de calidad ayudan mucho, un cambio drástico de luz o un reflejo fuerte sobre una herramienta metálica puede disparar falsos uncertain/misplaced. La mitigación real es iluminación fija en el banco.
  • No identifica la herramienta, solo compara con la referencia. Si alguien pone en el hueco un objeto distinto pero de forma y tamaño parecidos, podría colar como ok. No es un detector de objetos.
  • Depende de una buena referencia y de cámara estable. Si la cámara se mueve mucho, la alineación por homografía puede no converger y el banco caerá en not_evaluable. La rótula del soporte impreso ayuda, pero hay que fijarla bien.
  • Herramientas muy pequeñas o de bajo contraste (una broca fina sobre fondo oscuro) dan pocos bordes y poca textura, así que las métricas son menos fiables. Ahí toca subir su threshold con cuidado o aceptar más uncertain.
  • Persistencia mono-proceso. El modelo de ficheros + mutex asume un solo contenedor de API escribiendo. No escala horizontalmente (ni falta que le hace para este caso, pero conviene saberlo).
  • Secretos sin cifrado en reposo. Los secretos viven en ficheros con permisos restringidos, pero no cifrados con una clave externa. Como mejora futura está documentado el uso de Docker Secrets.
  • Oclusiones. Si algo tapa parcialmente el banco (una mano, una caja apoyada), la región afectada dará mal. El sistema lo reportará como incidencia, que en el peor caso es un falso positivo revisable.

Ninguna es un impedimento para el uso real —al fin y al cabo, lo peor que puede pasar es que un humano tenga que confirmar un uncertain— pero es importante conocerlas para calibrar bien y no esperar magia.


Cierre

Este proyecto me ha permitido juntar un montón de cosas que me gustan: electrónica (ESP32-CAM, ESPHome), visión artificial clásica (y toda la teoría bonita de SSIM, homografías, RANSAC, Canny...), arquitectura de microservicios bien separada, y por supuesto resolver un problema real de la comunidad del makespace, que es lo que de verdad me motiva.

Me reafirmo en la idea de que no siempre hace falta la última red neuronal de moda: entender bien el problema te lleva muchas veces a una solución más simple, más barata, más explicable y más fácil de mantener. Y de paso aprendes muchísimo sobre los fundamentos, que es lo que de verdad queda.

Como siempre, el código está disponible para quien quiera cotillear, aprender o mejorarlo: github.com/davidpoza/workbench-organizer-cv.

Si te ha gustado y tú también eres de los míos, ya sabes que los martes hacemos puertas abiertas en el makespace. ¡Nos vamos leyendo!