
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
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
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: snapshotCon 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
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
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
- 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
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
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.0Esta 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
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 TrueSi 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
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
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 consideraok. Cada herramienta puede sobreescribirlo.uncertaintyMargin(0.05): anchura de la bandauncertainpor 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:esphomeogeneric-http.snapshotUrl: la URL del snapshot (solohttp/https).authentication:none,basic,digestobearer. 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 normalizados0..1(dibujados en la UI sobre la referencia).threshold: su umbral propio; si no lo tiene, usa eldefaultThresholdglobal.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_tokenSeguridad 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
thresholdcon cuidado o aceptar másuncertain. - 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!