{
    "componentChunkName": "component---src-components-tag-template-index-jsx",
    "path": "/tag/docker",
    "result": {"data":{"allMarkdownRemark":{"edges":[{"node":{"fields":{"slug":"control-de-herramientas-vision-artificial"},"frontmatter":{"title":"Control de herramientas con visión artificial en el Makespace","date":"2026-08-28","excerpt":null,"category":"python","tags":["python","computer-vision","opencv","fastapi","microservicios","docker","maker","side-project"],"coverImage":{"childrenImageSharp":[{"gatsbyImageData":{"layout":"constrained","backgroundColor":"#c8a868","images":{"fallback":{"src":"/static/240bff90985530f720576655fea115cc/8cae6/cover.png","srcSet":"/static/240bff90985530f720576655fea115cc/82a03/cover.png 150w,\n/static/240bff90985530f720576655fea115cc/f7cdc/cover.png 300w,\n/static/240bff90985530f720576655fea115cc/8cae6/cover.png 600w,\n/static/240bff90985530f720576655fea115cc/bb8ee/cover.png 1200w","sizes":"(min-width: 600px) 600px, 100vw"},"sources":[{"srcSet":"/static/240bff90985530f720576655fea115cc/6689f/cover.webp 150w,\n/static/240bff90985530f720576655fea115cc/dc425/cover.webp 300w,\n/static/240bff90985530f720576655fea115cc/381e5/cover.webp 600w,\n/static/240bff90985530f720576655fea115cc/edb28/cover.webp 1200w","type":"image/webp","sizes":"(min-width: 600px) 600px, 100vw"}]},"width":600,"height":338}}]}},"html":"<p>Hace un tiempo os hablé de <a href=\"/makespace-madrid\">Makespace Madrid</a>, 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 <strong>compartido</strong>. 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\".</p>\n<p>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: <strong>escribir código y trastear con electrónica</strong>.</p>\n<p>El resultado es <strong>Workbench Organizer CV</strong>, un sistema que, al cerrar el makespace, hace una foto de cada banco de trabajo con una cámara y <strong>comprueba mediante visión artificial si cada herramienta está en su hueco</strong>. Si algo falta o está mal colocado, genera una imagen anotada, guarda un historial y lanza una notificación.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 56.49999999999999%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsTAAALEwEAmpwYAAACb0lEQVQozy1NWU/aABzvRxm4xyWLdzQThoCocYZLymVLC7Tl3pIti9ElqFkWdUYnStG1dZRSKFAOFXRSWQWP+LKHJcuyb7PgePn/7vwBdgusU74G7ZcovEXjl5RfonGJwpvHeIP212l/jcLOjgOnDNGg8IuvmMTglzRWp3zpDRBgt0CJDch8mBFRsoocl7wfRQdRt76vWRMVtM0Tf0TstwD/ysHZsjtx4too2VJV9DIbSG8+jptp4roQWdszfUiYyrXQAe8mliZ5znOVC99lEObd2AM597fq/bSuT2H6sxVLOw79fOv4ETIBmc92iQ3cVWLxN1OZXed9IdopRfaXjMyO/boUfRCIEKSukfBN5TWbhFZ8+mYW7xz5ZArPrlp6n+VCeCM+f7gDyoXwWR7LJN2rKzPtYvSW8wUWBmpfbPdimDtcXItNX+TxK8Z/xQa4NTOQ3gSb33CJD1ZZH7Vrb+WC3zPEKec/SftaXFDmiHoKkllMFkIs6V7GdQ0ek2i/lA5k4mYgu+1oZYNtIVrPYRXeKwvhTj56I0Tvi7F2PtLORzqF2G0x1hbCJQ6lEo5zAbuhsGsG59ctwEHcVNiHyyRaSaHVQ7RMIiUSEUmkTCJiEhGTHpFEKo9pOYlUj7zd2h5c3HYllucBzctxz6INgUCX3eRymJ12I+SyLrosCAzC7gUPZDMbpyH3gtU8i8B2J2i0WedCQRRFnCbjLPDseb/G8EqlNUxMGl5odGMTGrVuRqU1qCZ1an2XjKu0Ku30uFqnmZobGhkdHBocGh4eGRntHxwA+p72PVEoFEqFQqns3Z7s4mPU9ZU9v9f5P/kHBOgzn2FGRmAAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/240bff90985530f720576655fea115cc/c7ec8/cover.webp 200w,\n/static/240bff90985530f720576655fea115cc/a55e0/cover.webp 400w,\n/static/240bff90985530f720576655fea115cc/4ff00/cover.webp 800w,\n/static/240bff90985530f720576655fea115cc/ff4be/cover.webp 1200w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/240bff90985530f720576655fea115cc/a334c/cover.png 200w,\n/static/240bff90985530f720576655fea115cc/daecc/cover.png 400w,\n/static/240bff90985530f720576655fea115cc/6d062/cover.png 800w,\n/static/240bff90985530f720576655fea115cc/3b150/cover.png 1200w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/240bff90985530f720576655fea115cc/6d062/cover.png\"\n            alt=\"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\"\n            title=\"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\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>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</p></figcaption>\n  </figure></p>\n<p>En este artículo quiero hacer un recorrido <strong>en profundidad</strong>: 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 <strong>modelo C4</strong>; y por último me detendré con calma en el microservicio de visión artificial, explicando no solo qué hace el código sino <strong>la teoría que hay detrás</strong> 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.</p>\n<p>Tenéis todo el código en el repositorio: <a href=\"https://github.com/davidpoza/workbench-organizer-cv\">github.com/davidpoza/workbench-organizer-cv</a>.</p>\n<hr>\n<h2 id=\"el-problema-y-por-qué-lo-resuelvo-así\" style=\"position:relative;\"><a href=\"#el-problema-y-por-qu%C3%A9-lo-resuelvo-as%C3%AD\" aria-label=\"el problema y por qué lo resuelvo así permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>El problema, y por qué lo resuelvo así</h2>\n<p>Antes de escribir una sola línea conviene entender bien qué estamos resolviendo, porque de ahí salen todas las decisiones técnicas.</p>\n<p>La necesidad es sencilla de enunciar: <strong>al cerrar el taller, saber si las herramientas están en su sitio</strong>. Pero tiene matices importantes que condicionan el diseño:</p>\n<ul>\n<li>Los bancos tienen <strong>paneles de sombras</strong> (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.</li>\n<li>No necesito saber <em>qué</em> herramienta es (no es un problema de clasificación de objetos), sino si <strong>la herramienta que va en este hueco está o no está</strong>. Es un problema de <strong>comparación contra una referencia</strong>, no de reconocimiento.</li>\n<li>Las condiciones son relativamente controladas: cámara fija, iluminación de taller, banco plano. No es la selva.</li>\n</ul>\n<h3 id=\"decisión-1-visión-artificial-clásica-sin-machine-learning\" style=\"position:relative;\"><a href=\"#decisi%C3%B3n-1-visi%C3%B3n-artificial-cl%C3%A1sica-sin-machine-learning\" aria-label=\"decisión 1 visión artificial clásica sin machine learning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Decisión 1: visión artificial clásica, sin machine learning</h3>\n<p>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:</p>\n<div class=\"custom-block info\"><div class=\"custom-block-body\"><p>Un modelo de <em>deep learning</em> necesitaría un <strong>dataset etiquetado</strong> 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.</p></div></div>\n<p>Como ya tengo una <strong>imagen de referencia por banco</strong> (el estado \"correcto\"), el problema se reduce a: <em>\"¿se parece lo que veo ahora, en la región de esta herramienta, a lo que había en la referencia?\"</em>. Y eso es justo lo que la visión artificial clásica —<strong>SSIM, correlación de plantillas, comparación de bordes</strong>— resuelve de maravilla, sin entrenar nada, de forma <strong>explicable</strong> (puedo decir exactamente por qué una herramienta ha dado 0.41) y corriendo en cualquier CPU modesta. Añadir una herramienta nueva es simplemente <strong>capturar una referencia y dibujar un polígono</strong>, no reentrenar un modelo.</p>\n<p>Es la misma filosofía <em>self-hosted y sin dependencias mágicas</em> que ya defendí cuando monté el <a href=\"/microservicio-de-comentarios\">clasificador bayesiano del microservicio de comentarios</a>: 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.</p>\n<h3 id=\"decisión-2-tres-microservicios\" style=\"position:relative;\"><a href=\"#decisi%C3%B3n-2-tres-microservicios\" aria-label=\"decisión 2 tres microservicios permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Decisión 2: tres microservicios</h3>\n<p>Podría haberlo hecho todo en un único proceso, pero separé responsabilidades en <strong>tres servicios</strong> con fronteras muy claras, y hay una razón de peso para cada corte:</p>\n<ul>\n<li><strong><code class=\"language-text\">cv-service</code></strong> (Python): todo el cálculo de imagen. Es <strong>sin estado</strong> (<em>stateless</em>): recibe dos imágenes y un JSON, devuelve puntuaciones. <strong>No</strong> 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 <strong>trivial de testear</strong> (le metes dos imágenes sintéticas y compruebas el resultado) y de escalar.</li>\n<li><strong><code class=\"language-text\">api-service</code></strong> (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 <strong>único</strong> que lee secretos y escribe en disco.</li>\n<li><strong><code class=\"language-text\">frontend</code></strong> (React + Vite, servido por Nginx): la interfaz en español, pensada para una <strong>tablet Android en horizontal</strong> en modo kiosco junto a la puerta.</li>\n</ul>\n<p>La frontera <em>\"solo el API ve los secretos y el disco; el CV es una calculadora pura\"</em> no es estética: es una decisión de <strong>seguridad y de testabilidad</strong>. 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.</p>\n<h3 id=\"decisión-3-persistencia-en-ficheros-json-sin-base-de-datos\" style=\"position:relative;\"><a href=\"#decisi%C3%B3n-3-persistencia-en-ficheros-json-sin-base-de-datos\" aria-label=\"decisión 3 persistencia en ficheros json sin base de datos permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Decisión 3: persistencia en ficheros JSON, sin base de datos</h3>\n<p>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 <strong>ficheros JSON</strong> con escritura atómica y un <em>mutex</em>, 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.</p>\n<h3 id=\"decisión-4-cámaras-esp32-cam-por-pull-http\" style=\"position:relative;\"><a href=\"#decisi%C3%B3n-4-c%C3%A1maras-esp32-cam-por-pull-http\" aria-label=\"decisión 4 cámaras esp32 cam por pull http permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Decisión 4: cámaras ESP32-CAM por <em>pull</em> HTTP</h3>\n<p>Nada de RTSP ni de streaming. Cada cámara expone <strong>un JPEG por HTTP</strong> y es el servidor quien lo pide (<em>pull</em>) 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.</p>\n<hr>\n<h2 id=\"el-hardware-esp32-cam--esphome\" style=\"position:relative;\"><a href=\"#el-hardware-esp32-cam--esphome\" aria-label=\"el hardware esp32 cam  esphome permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>El hardware: ESP32-CAM + ESPHome</h2>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 130.99999999999997%; position: relative; bottom: 0; left: 0; background-image: url('data:image/jpeg;base64,/9j/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wgARCAAaABQDASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAAEEAgP/xAAWAQEBAQAAAAAAAAAAAAAAAAABAAL/2gAMAwEAAhADEAAAAWpDGrjIkWlyq4QP/8QAHRAAAwACAgMAAAAAAAAAAAAAAQIDABEEEiEiMv/aAAgBAQABBQLvrkC6vkjudmIrNgMn8GpS7cumTYMtB7aGL4X/xAAVEQEBAAAAAAAAAAAAAAAAAAABIP/aAAgBAwEBPwEj/8QAFREBAQAAAAAAAAAAAAAAAAAAASD/2gAIAQIBAT8BY//EAB4QAAICAgIDAAAAAAAAAAAAAAABESECEjFhEHGh/9oACAEBAAY/Auo4MlprU8iklOKPfQilJWPwnFarzR//xAAcEAEAAgIDAQAAAAAAAAAAAAABABEhUTFBYXH/2gAIAQEAAT8hUZa5dIfkB2OEZCV9mQoV4uJpSAphyTfdi+o0cB7I/plXcLKi9w0EIAKPJ//aAAwDAQACAAMAAAAQ+wYM/8QAFxEBAQEBAAAAAAAAAAAAAAAAAREAEP/aAAgBAwEBPxACNdO//8QAGREAAwADAAAAAAAAAAAAAAAAAAERECFB/9oACAECAQE/EKuijnVj/8QAHhABAQACAgMBAQAAAAAAAAAAAREAITFBUXGBYZH/2gAIAQEAAT8Qf7So9eder9wDuvVUcT+4s1bVVwJz2ed2fmnAvAYbV5671g67Jq+LivZRiiI1OPG8BS7J6JjOfoh3c7LAsbxiCx6wBEOBDP/Z'); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/e5f4dfa9930b20a8dd421d8d62296bd8/c7ec8/esp32-cam.webp 200w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/a55e0/esp32-cam.webp 400w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/4ff00/esp32-cam.webp 800w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/ff4be/esp32-cam.webp 1200w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/63471/esp32-cam.webp 1351w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/e5f4dfa9930b20a8dd421d8d62296bd8/4571c/esp32-cam.jpg 200w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/4a4c2/esp32-cam.jpg 400w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/00b89/esp32-cam.jpg 800w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/f98a9/esp32-cam.jpg 1200w,\n/static/e5f4dfa9930b20a8dd421d8d62296bd8/01e14/esp32-cam.jpg 1351w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/jpeg\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/e5f4dfa9930b20a8dd421d8d62296bd8/00b89/esp32-cam.jpg\"\n            alt=\"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\"\n            title=\"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\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>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</p></figcaption>\n  </figure></p>\n<p>La <a href=\"https://commons.wikimedia.org/wiki/File:ESP32-CAM.jpg\">ESP32-CAM</a> es una plaquita con WiFi, una cámara OV2640 y un precio ridículo. La programo con <a href=\"https://esphome.io/\">ESPHome</a>, que me permite configurarla de forma declarativa y, lo más importante para este proyecto, exponer un <strong>endpoint de snapshot</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">esp32_camera</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> cam<span class=\"token punctuation\">-</span>banco<span class=\"token punctuation\">-</span>electronica\n  <span class=\"token comment\"># ... pines de la ESP32-CAM AI-Thinker ...</span>\n\n<span class=\"token key atrule\">esp32_camera_web_server</span><span class=\"token punctuation\">:</span>\n  <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">port</span><span class=\"token punctuation\">:</span> <span class=\"token number\">8081</span>\n    <span class=\"token key atrule\">mode</span><span class=\"token punctuation\">:</span> snapshot</code></pre></div>\n<p>Con esto, un simple <code class=\"language-text\">GET http://&lt;ip-camara>:8081/</code> devuelve un JPEG del banco. En el sistema doy de alta esa cámara como tipo <code class=\"language-text\">esphome</code> con autenticación <code class=\"language-text\">none</code>.</p>\n<p>Para montarla en el banco imprimí en 3D esta <a href=\"https://makerworld.com/es/models/1220385-esp32-cam-case-kit-snap-fit-ball-joint#profileId-1497664\">carcasa con rótula tipo snap-fit para ESP32-CAM</a>, que viene fenomenal porque la <strong>rótula</strong> permite orientar la cámara hacia el banco con precisión (algo clave, como veremos, para que la alineación funcione bien).</p>\n<div class=\"custom-block danger\"><div class=\"custom-block-body\"><p><strong>Seguridad</strong>: ESPHome <strong>no autentica</strong> el endpoint de snapshot. Cualquiera en la misma red puede ver la imagen. Por eso estas cámaras deben vivir en una <strong>VLAN / red IoT aislada</strong>, accesible únicamente desde el servidor. En mi caso ya tengo la red segmentada con pfSense, como conté en el <a href=\"/makespace-madrid\">artículo del makespace</a>.</p></div></div>\n<hr>\n<h2 id=\"la-arquitectura-con-el-modelo-c4\" style=\"position:relative;\"><a href=\"#la-arquitectura-con-el-modelo-c4\" aria-label=\"la arquitectura con el modelo c4 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>La arquitectura, con el modelo C4</h2>\n<p>Para explicar la estructura voy a usar el <a href=\"https://c4model.com/\">modelo C4</a> de Simon Brown, que describe un sistema en niveles de zoom progresivo: <strong>Contexto → Contenedores → Componentes → Código</strong>. Me parece la mejor forma de no perderse: empezamos con vista de pájaro y vamos bajando.</p>\n<h3 id=\"nivel-1--contexto\" style=\"position:relative;\"><a href=\"#nivel-1--contexto\" aria-label=\"nivel 1  contexto permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Nivel 1 — Contexto</h3>\n<p>En el nivel más alto solo interesa <strong>quién usa el sistema y con qué se relaciona</strong>:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 19.499999999999996%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAECAIAAAABPYjBAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA8UlEQVQI1yXNv07CQBwA4L6LkwPOrvpKDoY4OQjq3oSaThicHBwdHCCBiGAc7D+jUIOlPYrYu+tpr73j7mfE7wU+gzDOKwEAyb84/imKaq1LqSqpSqHWSqsNAOCcE0IozTHGQggDrXLCuFIKITQN37M0vnmY7R7f7zdHe81Rrd6/e0afKHp9mwghut3e6dm5aZonjUYQBAZmvCj/Zkrp7CMqWXY9jLYOerX6YOdosH3Yv32a59kyQYs0TTudK8u6sG271bLa7UsjWeXzJSWMA4DWGgC+vsVwgh9DMg7JeEpoIWFDShkEL67rep7nOI7v+79zDsq2B0rFdAAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/470c047fc39364916be0c86519c8f691/c7ec8/diagrama-c4-contexto.webp 200w,\n/static/470c047fc39364916be0c86519c8f691/a55e0/diagrama-c4-contexto.webp 400w,\n/static/470c047fc39364916be0c86519c8f691/4ff00/diagrama-c4-contexto.webp 800w,\n/static/470c047fc39364916be0c86519c8f691/8e10f/diagrama-c4-contexto.webp 1134w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/470c047fc39364916be0c86519c8f691/a334c/diagrama-c4-contexto.png 200w,\n/static/470c047fc39364916be0c86519c8f691/daecc/diagrama-c4-contexto.png 400w,\n/static/470c047fc39364916be0c86519c8f691/6d062/diagrama-c4-contexto.png 800w,\n/static/470c047fc39364916be0c86519c8f691/85666/diagrama-c4-contexto.png 1134w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/470c047fc39364916be0c86519c8f691/6d062/diagrama-c4-contexto.png\"\n            alt=\"Diagrama de contexto C4: el sistema, sus dos tipos de usuario y los sistemas externos con los que habla\"\n            title=\"Diagrama de contexto C4: el sistema, sus dos tipos de usuario y los sistemas externos con los que habla\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Diagrama de contexto C4: el sistema, sus dos tipos de usuario y los sistemas externos con los que habla</p></figcaption>\n  </figure></p>\n<p>Hay dos \"actores\" que disparan cosas:</p>\n<ul>\n<li>El <strong>operario</strong> del makespace, que desde la tablet consulta el panel, gestiona cámaras y bancos, y calibra los umbrales.</li>\n<li>La <strong>automatización de cierre</strong>: 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.</li>\n</ul>\n<p>Y dos sistemas externos: las <strong>cámaras</strong> (de las que el sistema tira imágenes) y un <strong>webhook de notificaciones</strong> (al que el sistema empuja las incidencias).</p>\n<h3 id=\"nivel-2--contenedores\" style=\"position:relative;\"><a href=\"#nivel-2--contenedores\" aria-label=\"nivel 2  contenedores permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Nivel 2 — Contenedores</h3>\n<p>Bajamos un nivel y abrimos la caja \"sistema\". Aparecen los tres microservicios y los almacenes de datos:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 60%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABx0lEQVQoz42Qz08TQRTHm3D2AiZe/RPgiPHiX+DBizcSTZBgSAxGE4MHY68euHAp6gXoasVL1WhAahtIoYFof4AsNdBtKWuZ3dnuzuzu7Ozs7Jh2E5IGK35PL3nvk897LybOxSEUOSSqTey6HhV9EjurwjAUQiCE6vV6vXEMjTayHaBDy7IopRfAUTjnjAWC+x6CFBuhb9sYI4wZYxfAXbdoWd6Dhb27idKt2Z07iaKOO1oe9f4BRxMKcEaf5a89Lww/3bweL5wY5L/gs+NdGliOVztWgW6I/vnL2mqbzKQOZlLyvfnvEy9/qF1z9M6w19+7Nu/0iop5eTJzdXr9ylTu0nimpLSF4JQFPOxrDl3XtRCiHoGW/bXYzFbUbOXkW6mhQSi418EJoq4dBIFPPebTHnPQDQ+Y5Xgb+6e53d/pQu3zjlI5bG1VtfWfp9mKKjcNxnxoYpt04XT6w1IyKUlSPr+5vPz+nbSYWtkeup8bnFgbGFsZmsw8Sh3dnDu48WJvJF6ekmrxj42xV79uJ6qzq2oM6hBoGgDANE0AtDbUqk34MCk/fiNPL+0/eSt/KetSQXu90ZrLqJ/KxpFGtmt46xApOvkDZNCHiOU9cyAAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/ae62c6aa197536eb1e23abec41264e8f/c7ec8/diagrama-c4-contenedores.webp 200w,\n/static/ae62c6aa197536eb1e23abec41264e8f/a55e0/diagrama-c4-contenedores.webp 400w,\n/static/ae62c6aa197536eb1e23abec41264e8f/4ff00/diagrama-c4-contenedores.webp 800w,\n/static/ae62c6aa197536eb1e23abec41264e8f/ea30c/diagrama-c4-contenedores.webp 1024w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/ae62c6aa197536eb1e23abec41264e8f/a334c/diagrama-c4-contenedores.png 200w,\n/static/ae62c6aa197536eb1e23abec41264e8f/daecc/diagrama-c4-contenedores.png 400w,\n/static/ae62c6aa197536eb1e23abec41264e8f/6d062/diagrama-c4-contenedores.png 800w,\n/static/ae62c6aa197536eb1e23abec41264e8f/7ce0b/diagrama-c4-contenedores.png 1024w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/ae62c6aa197536eb1e23abec41264e8f/6d062/diagrama-c4-contenedores.png\"\n            alt=\"Diagrama de contenedores C4: frontend, api-service, cv-service y los ficheros de datos\"\n            title=\"Diagrama de contenedores C4: frontend, api-service, cv-service y los ficheros de datos\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Diagrama de contenedores C4: frontend, api-service, cv-service y los ficheros de datos</p></figcaption>\n  </figure></p>\n<p>Fijaos en el detalle de los almacenes: <strong>solo el <code class=\"language-text\">api-service</code> monta el volumen de datos</strong> en lectura/escritura, y <code class=\"language-text\">config/auth.json</code> (que contiene los secretos) se monta como <strong>solo lectura</strong>. El <code class=\"language-text\">cv-service</code> no toca ningún fichero. Es la frontera de seguridad de la que hablaba, hecha explícita en el <code class=\"language-text\">docker-compose.yml</code>.</p>\n<h3 id=\"nivel-3--componentes-del-api-service\" style=\"position:relative;\"><a href=\"#nivel-3--componentes-del-api-service\" aria-label=\"nivel 3  componentes del api service permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Nivel 3 — Componentes del <code class=\"language-text\">api-service</code></h3>\n<p>Dentro del API, cada responsabilidad vive en su módulo:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 31.5%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAGCAIAAABM9SnKAAAACXBIWXMAAAsTAAALEwEAmpwYAAABLUlEQVQY03WOS0vDQBhF8+8F8bFSQQTBLgJiF4qoKVoorYFY0mIa+jBpbZg82kzaPG2bmW9mJN258HIXZ3XPlXRrcaZM5Xe02e6EYJyD4CDEvhWwPyAAgDDGxD5S3ynu9FgZb72UoZj6GQtScNYEJRCkMI2IjSmKmbMmNiZ2BEGyY0A5rzzSvR6eN/1rNXox45q6eBwkD73wRl3K3VVjmJ420KGyqGnxVXt58rY8esXNcSoE55xX5gHKW+O4/ZWYKFUn2EB5f7bS55nhbvR5nvyUjDFKARgDAEqp5/m2NXFwMfILqTVayapX74YdK7vVgmcjUsz1RdO9bPmyFsZFCZSUJSGElGU1hDE2jf7QzbRZLgkhPvUPZz6toNf1vi2UwLHiHjyhuh6J6qL4L79HAUOriLqWyQAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/31e436b60b58993e8d91733c549c18cb/c7ec8/diagrama-c4-componentes-api.webp 200w,\n/static/31e436b60b58993e8d91733c549c18cb/a55e0/diagrama-c4-componentes-api.webp 400w,\n/static/31e436b60b58993e8d91733c549c18cb/4ff00/diagrama-c4-componentes-api.webp 800w,\n/static/31e436b60b58993e8d91733c549c18cb/ff4be/diagrama-c4-componentes-api.webp 1200w,\n/static/31e436b60b58993e8d91733c549c18cb/f3ba0/diagrama-c4-componentes-api.webp 1426w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/31e436b60b58993e8d91733c549c18cb/a334c/diagrama-c4-componentes-api.png 200w,\n/static/31e436b60b58993e8d91733c549c18cb/daecc/diagrama-c4-componentes-api.png 400w,\n/static/31e436b60b58993e8d91733c549c18cb/6d062/diagrama-c4-componentes-api.png 800w,\n/static/31e436b60b58993e8d91733c549c18cb/3b150/diagrama-c4-componentes-api.png 1200w,\n/static/31e436b60b58993e8d91733c549c18cb/d709c/diagrama-c4-componentes-api.png 1426w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/31e436b60b58993e8d91733c549c18cb/6d062/diagrama-c4-componentes-api.png\"\n            alt=\"Diagrama de componentes C4 del api-service\"\n            title=\"Diagrama de componentes C4 del api-service\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Diagrama de componentes C4 del api-service</p></figcaption>\n  </figure></p>\n<ul>\n<li><strong>auth</strong>: login con usuarios estáticos (bcrypt), sesión por JWT en cookie <code class=\"language-text\">HttpOnly</code>, y el <em>trigger token</em> de la palanca.</li>\n<li><strong>cameras + snapshot</strong>: captura de imágenes con protección SSRF, soporte de autenticación Digest y estabilización de fotogramas.</li>\n<li><strong>workbenches / tokens / webhooks / notifications</strong>: CRUD de bancos, tokens de API por banco, y el motor de notificaciones.</li>\n<li><strong>inspections</strong>: el <strong>orquestador</strong>, que es el corazón operativo; habla con las cámaras, con el CV y con la persistencia.</li>\n<li><strong>persistence</strong> y <strong>state</strong>: escritura atómica de los JSON y estado en memoria.</li>\n</ul>\n<h3 id=\"nivel-3--componentes-del-cv-service\" style=\"position:relative;\"><a href=\"#nivel-3--componentes-del-cv-service\" aria-label=\"nivel 3  componentes del cv service permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Nivel 3 — Componentes del <code class=\"language-text\">cv-service</code></h3>\n<p>Y aquí está el protagonista de este artículo, el pipeline de visión desglosado en módulos independientes:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 23%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAFCAIAAADKYVtkAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA0UlEQVQY03XMTW+CQBSF4fn/v8Vdg4kRYkDHLyBoakxToyAzWBNLW0rpzL13ZIxtF3bhs3xzchgAGGPaG7a1Q5+LTBIRAFzDHawsy7qujTFEhIjW2uItXRWz9SGuPiu6Pv+hG7+F2f8+1OnpFIXCfXyZyPftF1b2PkYt4Bk0KTQaSC/kKNg+hLm3Pobz3N28LhU239AobDQpTQp+lngGaoHxzFke+DTrz4WbiGCS9hZFMN71vOfO6jid7fuR8BI55DsnKfxoPxil3VgO4tznqXMBUvMVWLDHvEoAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/2512af226a47833ffad6b1e1a4d3e5c7/c7ec8/diagrama-c4-componentes-cv.webp 200w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/a55e0/diagrama-c4-componentes-cv.webp 400w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/4ff00/diagrama-c4-componentes-cv.webp 800w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/ff4be/diagrama-c4-componentes-cv.webp 1200w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/0b103/diagrama-c4-componentes-cv.webp 1600w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/af3b4/diagrama-c4-componentes-cv.webp 2057w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/2512af226a47833ffad6b1e1a4d3e5c7/a334c/diagrama-c4-componentes-cv.png 200w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/daecc/diagrama-c4-componentes-cv.png 400w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/6d062/diagrama-c4-componentes-cv.png 800w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/3b150/diagrama-c4-componentes-cv.png 1200w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/5f327/diagrama-c4-componentes-cv.png 1600w,\n/static/2512af226a47833ffad6b1e1a4d3e5c7/e86b2/diagrama-c4-componentes-cv.png 2057w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/2512af226a47833ffad6b1e1a4d3e5c7/6d062/diagrama-c4-componentes-cv.png\"\n            alt=\"Diagrama de componentes C4 del cv-service: el pipeline de visión artificial\"\n            title=\"Diagrama de componentes C4 del cv-service: el pipeline de visión artificial\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Diagrama de componentes C4 del cv-service: el pipeline de visión artificial</p></figcaption>\n  </figure></p>\n<p>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.</p>\n<hr>\n<h2 id=\"anatomía-de-una-inspección\" style=\"position:relative;\"><a href=\"#anatom%C3%ADa-de-una-inspecci%C3%B3n\" aria-label=\"anatomía de una inspección permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Anatomía de una inspección</h2>\n<p>Cuando se cierra el makespace y se dispara <code class=\"language-text\">run-all</code>, esto es lo que ocurre de principio a fin:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 85.50000000000001%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAARCAIAAABSJhvpAAAACXBIWXMAAAsTAAALEwEAmpwYAAACs0lEQVQ4y21TyY4URxDtb7LEH/hofwDiG/gE45MlX/DFYG625KvBA8fBFgKEzYxnGiNG00vNVHdPV3XX2lVZuW8RaVWXNYjlHUKRmfEULyIyRgCYFg0AhhAwoAcE7P0BiAGwPw93JZEHR+s+cI+RUKYm3DgIIfz2T/7V3fHN+2/jUoQQlPW3fz3/+ofxT3+sfny6Gi+7TpiD4w0iHpzm956uRlSabdlcJRltyckk/f04fT4lVPkQgvPwar47fFf+FTUPj7ezLR+0YAiv5s3j03w0iCvLzWx+mm0mrInBldbVWu+u5b2vIoShwP9lY49gDK3qaDY7Gr95dhmfrq7eJOlZXVcdpVwIzgVlTAghpRysMdYD9JkBYLBZVhRFmWV5FMVnZ+fn55OiyK21AGCt9d67PYwxjLE+8zXZWqv22O12SqmyLJVSdV0TQkLAIWYvHL33QvAPyM45KaXWerBt29Z1vdlsiqKSUlvrnQNr+7QAwPknZKWU1ppz3nVdnmd5lq9WF+vkomm2VZVK2QpRe9De+8+TjTHW2v0E+q5SuppHf8aLF1H07DJ+nuWvhUy8x4/JQ81aa6UUAGz2uFqvmqayVlHaIroQIAT4TM2DmLZtq6oihJRluVgsJpNpHMdJkjZtyxjvOkppPzMhxAfkPR/6V8qU1tbaqqoWy+U8ipbLJedcayWEYIw556AH9mTcu845RHRGOyOdtcYYzhnZVcU2Sa4WVZ50pGGMK609AIINAUYeUCrdtoS0zSKt/o3LaUqUcQHBOT/fkEnSzbfdUVSvSzpsFwBM0268IKOK8JPpumU6hPDzi/SLb15/+f3JPOv7IY2/9eDdjTt/f/ckvvPo4uWssc6fXO4Q8ZeX6bePLvq/zYTsdxhRW6DSMeWHlUYMQnuqHOAwuJDU/MFhdL3P/wHa0bhgXw0z/QAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/954021cd842f2a95f6750932d28cbbd9/c7ec8/diagrama-secuencia-inspeccion.webp 200w,\n/static/954021cd842f2a95f6750932d28cbbd9/a55e0/diagrama-secuencia-inspeccion.webp 400w,\n/static/954021cd842f2a95f6750932d28cbbd9/4ff00/diagrama-secuencia-inspeccion.webp 800w,\n/static/954021cd842f2a95f6750932d28cbbd9/fa71b/diagrama-secuencia-inspeccion.webp 867w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/954021cd842f2a95f6750932d28cbbd9/a334c/diagrama-secuencia-inspeccion.png 200w,\n/static/954021cd842f2a95f6750932d28cbbd9/daecc/diagrama-secuencia-inspeccion.png 400w,\n/static/954021cd842f2a95f6750932d28cbbd9/6d062/diagrama-secuencia-inspeccion.png 800w,\n/static/954021cd842f2a95f6750932d28cbbd9/642d8/diagrama-secuencia-inspeccion.png 867w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/954021cd842f2a95f6750932d28cbbd9/6d062/diagrama-secuencia-inspeccion.png\"\n            alt=\"Diagrama de secuencia de una inspección completa\"\n            title=\"Diagrama de secuencia de una inspección completa\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Diagrama de secuencia de una inspección completa</p></figcaption>\n  </figure></p>\n<p>El orquestador (en <code class=\"language-text\">api-service/src/inspections/orchestrator.ts</code>) tiene tres propiedades que me importaban mucho:</p>\n<p><strong>Concurrencia acotada.</strong> Los bancos se inspeccionan <strong>en paralelo</strong> pero con un límite (<code class=\"language-text\">cameraConcurrency</code>), para no saturar la red ni el CV. Uso un pequeño <em>pool</em>:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">const</span> outcomes <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">runWithConcurrency</span><span class=\"token punctuation\">(</span>workbenches<span class=\"token punctuation\">,</span> config<span class=\"token punctuation\">.</span>settings<span class=\"token punctuation\">.</span>cameraConcurrency<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span>wb<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span>\n  <span class=\"token function\">worker</span><span class=\"token punctuation\">(</span>wb<span class=\"token punctuation\">,</span> inspectionId<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p><strong>Aislamiento de fallos parciales.</strong> Si una cámara no responde o el CV peta con un banco, ese banco se marca como <code class=\"language-text\">not_evaluable</code> pero <strong>el resto de la inspección continúa</strong>. Un fallo nunca cancela el lote. Cada <code class=\"language-text\">inspectWorkbench</code> está diseñado para <strong>no lanzar excepciones nunca</strong>: cualquier problema se convierte en un resultado.</p>\n<p><strong>Bloqueo por banco.</strong> Un banco no puede tener dos inspecciones simultáneas (imaginad la palanca y una automatización disparando a la vez). Un <code class=\"language-text\">Mutex</code> protege un <code class=\"language-text\">Set</code> de bancos bloqueados:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">const</span> locked <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">Set<span class=\"token operator\">&lt;</span><span class=\"token builtin\">string</span><span class=\"token operator\">></span></span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> lockMutex <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">Mutex</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">tryLock</span><span class=\"token punctuation\">(</span>id<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">Promise</span><span class=\"token operator\">&lt;</span><span class=\"token builtin\">boolean</span><span class=\"token operator\">></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> lockMutex<span class=\"token punctuation\">.</span><span class=\"token function\">runExclusive</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>locked<span class=\"token punctuation\">.</span><span class=\"token function\">has</span><span class=\"token punctuation\">(</span>id<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">return</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">;</span>\n    locked<span class=\"token punctuation\">.</span><span class=\"token function\">add</span><span class=\"token punctuation\">(</span>id<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token keyword\">return</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Un principio que atraviesa todo el diseño y que quiero subrayar: <strong>el sistema nunca se inventa resultados positivos</strong>. Si la imagen no tiene calidad o no se puede alinear, el banco es <code class=\"language-text\">not_evaluable</code>. Prefiero mil veces un \"no lo sé\" honesto a un \"todo correcto\" falso que te haga cerrar el taller con una herramienta perdida.</p>\n<hr>\n<h2 id=\"el-microservicio-de-visión-en-profundidad\" style=\"position:relative;\"><a href=\"#el-microservicio-de-visi%C3%B3n-en-profundidad\" aria-label=\"el microservicio de visión en profundidad permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>El microservicio de visión, en profundidad</h2>\n<p>Vamos al lío. El <code class=\"language-text\">cv-service</code> es un FastAPI minúsculo en la superficie: expone <code class=\"language-text\">POST /v1/compare</code>, que recibe <strong>dos imágenes</strong> (referencia y captura actual) más un <strong>payload JSON</strong> con la definición de las herramientas, y devuelve el veredicto.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>post</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/v1/compare\"</span><span class=\"token punctuation\">,</span> response_model<span class=\"token operator\">=</span>CompareResponse<span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">compare</span><span class=\"token punctuation\">(</span>\n    reference<span class=\"token punctuation\">:</span> UploadFile <span class=\"token operator\">=</span> File<span class=\"token punctuation\">(</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    current<span class=\"token punctuation\">:</span> UploadFile <span class=\"token operator\">=</span> File<span class=\"token punctuation\">(</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    payload<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span> <span class=\"token operator\">=</span> Form<span class=\"token punctuation\">(</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> CompareResponse<span class=\"token punctuation\">:</span>\n    payload_obj <span class=\"token operator\">=</span> ComparePayload<span class=\"token punctuation\">.</span>model_validate<span class=\"token punctuation\">(</span>json<span class=\"token punctuation\">.</span>loads<span class=\"token punctuation\">(</span>payload<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    <span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span>\n    result <span class=\"token operator\">=</span> run_compare<span class=\"token punctuation\">(</span>ref_bytes<span class=\"token punctuation\">,</span> cur_bytes<span class=\"token punctuation\">,</span> payload_obj<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Antes de tocar un solo píxel, <strong>Pydantic</strong> valida el payload (<code class=\"language-text\">app/models.py</code>). Y aquí ya hay teoría interesante. Cada herramienta se describe con un <strong>polígono</strong> de vértices normalizados, y valido su no-degeneración con la <strong>fórmula del área de Gauss</strong> (el <em>shoelace</em> o \"lazo de zapato\"):</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">_shoelace_area</span><span class=\"token punctuation\">(</span>points<span class=\"token punctuation\">:</span> List<span class=\"token punctuation\">[</span>Vertex<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token builtin\">float</span><span class=\"token punctuation\">:</span>\n    total <span class=\"token operator\">=</span> <span class=\"token number\">0.0</span>\n    <span class=\"token keyword\">for</span> i <span class=\"token keyword\">in</span> <span class=\"token builtin\">range</span><span class=\"token punctuation\">(</span><span class=\"token builtin\">len</span><span class=\"token punctuation\">(</span>points<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n        a <span class=\"token operator\">=</span> points<span class=\"token punctuation\">[</span>i<span class=\"token punctuation\">]</span>\n        b <span class=\"token operator\">=</span> points<span class=\"token punctuation\">[</span><span class=\"token punctuation\">(</span>i <span class=\"token operator\">+</span> <span class=\"token number\">1</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">%</span> <span class=\"token builtin\">len</span><span class=\"token punctuation\">(</span>points<span class=\"token punctuation\">)</span><span class=\"token punctuation\">]</span>\n        total <span class=\"token operator\">+=</span> a<span class=\"token punctuation\">.</span>x <span class=\"token operator\">*</span> b<span class=\"token punctuation\">.</span>y <span class=\"token operator\">-</span> b<span class=\"token punctuation\">.</span>x <span class=\"token operator\">*</span> a<span class=\"token punctuation\">.</span>y\n    <span class=\"token keyword\">return</span> <span class=\"token builtin\">abs</span><span class=\"token punctuation\">(</span>total<span class=\"token punctuation\">)</span> <span class=\"token operator\">/</span> <span class=\"token number\">2.0</span></code></pre></div>\n<p>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 <strong>sumen 1</strong> y que haya entre 3 y 24 vértices. Validar en la frontera, con tipos, evita que basura llegue al algoritmo.</p>\n<p>El orquestador del pipeline es <code class=\"language-text\">app/pipeline/compare.py</code>. Su flujo es este:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 566px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 215.50000000000003%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAArCAIAAAD3xz8iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFVElEQVRIx41WCVPbRhT2/+60M6EloWmaTpNAA4k5UpJAIJQzBJLWCcQGgrEtX/KBbV22JUurlVb3ter4iEtMgL7RaOZp99t36HvvbST8lmCMwzDM5wqFfGGoXpbIiO74lqCzgsYAq0UymWorD6xWR2clq+n41pVgjIMwDOtyfvLwuyfxW9PxW4/jt54ej88kxmbiYwvpcUopDrddAodd3zRXKYtnFSl9DlLFVoLkT2pyqiJlymJKd5Xhtm+7PZDAdvgDlXqLqLd2+2PYc9h3vZHgR8E+9qElykZbRHW6nas3CUlrAKMNzY5pGVeC+/6ojrRJRteLM+uF6FZ5br0YXSWmXyYfvCameciF+NpsY4wNB+mOavkaLzdFhTdchGxouBpCKAiC68AX/zNFUQzDDFXXdW+I+aJ4rmdbtuf5ruN5jmfb9s3gTOvwoLGRYN7EqmsfautxeueI28lLics8+xrcW31ffrV09vtq5o/l5MPe+9EK8eiAWr+ZnhhjXqcZtdTUqhWBOBdzTb3akIstROHwasv9NcvTnyd/nT2+/TQ+Pnd8Z/7zRPTo9vzpxF+lKdPVbmBYgP2GTFalzLmUzjePST5ZEVM1mWC1qo/9693GPWp26Px8PTsr15c0esVg1wx21TcE18NhiG8AY98EzSOJSwA2rjQTvkL4KBti23HcK0nSD8Zw1QS7m2D3Etz+x8ZOrLZ1SO3GatuH9W2oSzcnLMnFTuj9Y2rvM7N/Qu/Fa28Oq9sn1DsBtHBwyW38tQz5yLFcu8UPVdu2/w+3B4lRVAhkyfUcy7Zs17Zsa5SeFEXlepLP5wmCKOQLxTyZzeTKpYqqIKRoKkRq960qioK+iKZpgiBEeJ7Xdb2v67ouKZ08c5pjPpOtFNlKVTtZFlVopcTpVSewLtacLMsRCOEw7DAMOya7mPpl4fTui8z9+ZOJZ6d3F05/fhofX8r+xhvMxZ0Qwogsy11iBEH3Ew5NT6MUsi4XaLVUFjJVkaAVklLIBiyYvn7Rchc8tNz//nd1dTM3t1N4tp1fWM9EN4jZrez8Lrn4vr70idno990rwavZyakPP0zHxuYSE9FPtx/HxhaT99bIqZfZ+9vV2WvA3QfZoIXqDbnIokqZz1QFgkMVFpV5g9IcecDfb1oejCjheCU7uVmMviZmtsqLO5UXa+RsQUqNbOuDewlzgas3oFAwlXOI+I7WFhEvwLqiZhU1C1VC00q+xTgm9PxgNNsmLEJuX6T2APfed1Dgh66DDYVDzXeBlNC5mC0cAHpLkyuW7el6tyvIsPefMca+LboGB4QydgTXRgPvetVy1vy0mX4eb7zzv4w41/G+WIZQUSS5GRPpLZHa6dAbPJfULbMH7B6ba8cPa5tnzX/c3sQyXNQQKh2NEyQ+0hLZt+Xn0BrUag0WXqUnl88eNuB/A9VzfRzgfs2muA8LR/eW0w+JxnEEyJKAWNPWTV22EA11QInlNqIt3xhWmOPaQTBoYEHomS7yQhtAEAEA9M82DSS0zi3Ttk3Hc3zbdizLsnsiSZJhGI7TrU3HcQGQfS8AAERUVe2XlKbpQFYURVFVtV9hoigCADRNgzJULggAQFXVLvjrjt+NsKEUVnNT26W5tezMZjG6W3m2Tk7TKjlypxjtJP21Jqpt5xb2yeU32cX94lKc2Yo1VlpafeRahDG+3D1RSTyrwkwFpHKto7KUrKNsTSXOFcLw0JUTY2BWq00dfv8k8WP06Kc/U7+8Lj1YTN99QdxbSN9parUrb0ODmRy4osF1DBbYfLVZqPFkx+B4xAga6wXuDVPyYq9gaJZjm9fcAP8FAdlRkXWzhgoAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/fbfd0b8fcda056fc93fc919144a56dc0/c7ec8/diagrama-pipeline-cv.webp 200w,\n/static/fbfd0b8fcda056fc93fc919144a56dc0/a55e0/diagrama-pipeline-cv.webp 400w,\n/static/fbfd0b8fcda056fc93fc919144a56dc0/29e05/diagrama-pipeline-cv.webp 566w\"\n              sizes=\"(max-width: 566px) 100vw, 566px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/fbfd0b8fcda056fc93fc919144a56dc0/a334c/diagrama-pipeline-cv.png 200w,\n/static/fbfd0b8fcda056fc93fc919144a56dc0/daecc/diagrama-pipeline-cv.png 400w,\n/static/fbfd0b8fcda056fc93fc919144a56dc0/e4bc4/diagrama-pipeline-cv.png 566w\"\n            sizes=\"(max-width: 566px) 100vw, 566px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/fbfd0b8fcda056fc93fc919144a56dc0/e4bc4/diagrama-pipeline-cv.png\"\n            alt=\"Flujo completo del pipeline de visión, con las ramas de not_evaluable\"\n            title=\"Flujo completo del pipeline de visión, con las ramas de not_evaluable\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Flujo completo del pipeline de visión, con las ramas de not_evaluable</p></figcaption>\n  </figure></p>\n<p>Recorramos cada etapa.</p>\n<h3 id=\"1-validación-y-decodificación\" style=\"position:relative;\"><a href=\"#1-validaci%C3%B3n-y-decodificaci%C3%B3n\" aria-label=\"1 validación y decodificación permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. Validación y decodificación</h3>\n<p><code class=\"language-text\">app/pipeline/validation.py</code> decodifica los bytes a una imagen BGR. Antes de decodificar comprueba los <strong>magic bytes</strong> (los primeros bytes que identifican el formato de fichero):</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">_JPEG_MAGIC <span class=\"token operator\">=</span> <span class=\"token string\">b\"\\xff\\xd8\\xff\"</span>\n_PNG_MAGIC <span class=\"token operator\">=</span> <span class=\"token string\">b\"\\x89PNG\\r\\n\\x1a\\n\"</span>\n\n<span class=\"token keyword\">def</span> <span class=\"token function\">looks_like_image</span><span class=\"token punctuation\">(</span>data<span class=\"token punctuation\">:</span> <span class=\"token builtin\">bytes</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token builtin\">bool</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">return</span> data<span class=\"token punctuation\">.</span>startswith<span class=\"token punctuation\">(</span>_JPEG_MAGIC<span class=\"token punctuation\">)</span> <span class=\"token keyword\">or</span> data<span class=\"token punctuation\">.</span>startswith<span class=\"token punctuation\">(</span>_PNG_MAGIC<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Es una comprobación baratísima antes de gastar CPU en <code class=\"language-text\">cv2.imdecode</code>. Además valido dimensiones mínimas y máximas. La clave del módulo: <strong>nunca lanza excepciones</strong>; cualquier problema se reporta con <code class=\"language-text\">valid=False</code> y una razón, para que una imagen mala se maneje con elegancia.</p>\n<h3 id=\"2-calidad-de-imagen-brillo-y-enfoque\" style=\"position:relative;\"><a href=\"#2-calidad-de-imagen-brillo-y-enfoque\" aria-label=\"2 calidad de imagen brillo y enfoque permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Calidad de imagen: brillo y enfoque</h3>\n<p><code class=\"language-text\">app/pipeline/quality.py</code> decide si la imagen es \"de fiar\". Dos medidas:</p>\n<p><strong>Brillo</strong>: 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.</p>\n<p><strong>Enfoque / desenfoque</strong>: aquí está la joya, la <strong>varianza del Laplaciano</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">measure_blur</span><span class=\"token punctuation\">(</span>gray<span class=\"token punctuation\">:</span> np<span class=\"token punctuation\">.</span>ndarray<span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token builtin\">float</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">return</span> <span class=\"token builtin\">float</span><span class=\"token punctuation\">(</span>cv2<span class=\"token punctuation\">.</span>Laplacian<span class=\"token punctuation\">(</span>gray<span class=\"token punctuation\">,</span> cv2<span class=\"token punctuation\">.</span>CV_64F<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>var<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>El <strong>operador Laplaciano</strong> es la segunda derivada espacial de la imagen (la suma de las segundas derivadas en <code class=\"language-text\">x</code> y en <code class=\"language-text\">y</code>, <code class=\"language-text\">∂²f/∂x² + ∂²f/∂y²</code>). Responde con fuerza en las <strong>transiciones bruscas</strong> (bordes) y con cero en las zonas planas. La intuición es preciosa: una imagen <strong>nítida</strong> tiene bordes marcados, así que su Laplaciano tiene valores muy dispares → <strong>varianza alta</strong>. Una imagen <strong>borrosa</strong> difumina esos bordes, el Laplaciano se aplana → <strong>varianza baja</strong>. 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.</p>\n<p>¿Por qué me importa tanto la calidad? Porque más adelante, aunque una herramienta puntúe alto, <strong>si la imagen no es de fiar no la doy por buena</strong>: la degrado a <code class=\"language-text\">uncertain</code>. Insisto: no inventar positivos.</p>\n<h3 id=\"3-alineación-orb--ransac--homografía\" style=\"position:relative;\"><a href=\"#3-alineaci%C3%B3n-orb--ransac--homograf%C3%ADa\" aria-label=\"3 alineación orb  ransac  homografía permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. Alineación: ORB + RANSAC + homografía</h3>\n<p>Este es, con diferencia, el módulo más denso (<code class=\"language-text\">app/pipeline/alignment.py</code>), 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 <strong>alinear</strong> la imagen actual sobre el marco de la referencia.</p>\n<p>El proceso tiene cuatro fases y cada una tiene su teoría:</p>\n<p><strong>a) Detección de puntos característicos con ORB.</strong> ORB (<em>Oriented FAST and Rotated BRIEF</em>) es un detector-descriptor de <em>features</em>: encuentra puntos \"interesantes\" de la imagen (esquinas, texturas distintivas) y los describe con un vector binario invariante a rotación. Es el sustituto <strong>libre y rápido</strong> 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.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">orb <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>ORB_create<span class=\"token punctuation\">(</span>nfeatures<span class=\"token operator\">=</span>SETTINGS<span class=\"token punctuation\">.</span>orb_features<span class=\"token punctuation\">)</span>  <span class=\"token comment\"># 1500 por defecto</span>\nkp1<span class=\"token punctuation\">,</span> des1 <span class=\"token operator\">=</span> orb<span class=\"token punctuation\">.</span>detectAndCompute<span class=\"token punctuation\">(</span>ref_gray<span class=\"token punctuation\">,</span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">)</span>\nkp2<span class=\"token punctuation\">,</span> des2 <span class=\"token operator\">=</span> orb<span class=\"token punctuation\">.</span>detectAndCompute<span class=\"token punctuation\">(</span>cur_gray<span class=\"token punctuation\">,</span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p><strong>b) Emparejamiento con test de Lowe.</strong> Comparo los descriptores de ambas imágenes con un <code class=\"language-text\">BFMatcher</code> (<em>Brute Force</em>) usando distancia de <strong>Hamming</strong> (la adecuada para descriptores binarios: cuenta bits distintos). Para cada punto busco sus <strong>dos</strong> mejores candidatos y aplico el <strong>ratio test de Lowe</strong>: solo me quedo con el emparejamiento si el mejor candidato es <em>claramente</em> 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.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">raw <span class=\"token operator\">=</span> matcher<span class=\"token punctuation\">.</span>knnMatch<span class=\"token punctuation\">(</span>des1<span class=\"token punctuation\">,</span> des2<span class=\"token punctuation\">,</span> k<span class=\"token operator\">=</span><span class=\"token number\">2</span><span class=\"token punctuation\">)</span>\ngood <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span>m <span class=\"token keyword\">for</span> pair <span class=\"token keyword\">in</span> raw <span class=\"token keyword\">if</span> <span class=\"token builtin\">len</span><span class=\"token punctuation\">(</span>pair<span class=\"token punctuation\">)</span> <span class=\"token operator\">==</span> <span class=\"token number\">2</span> <span class=\"token keyword\">for</span> m<span class=\"token punctuation\">,</span> n <span class=\"token keyword\">in</span> <span class=\"token punctuation\">[</span>pair<span class=\"token punctuation\">]</span>\n        <span class=\"token keyword\">if</span> m<span class=\"token punctuation\">.</span>distance <span class=\"token operator\">&lt;</span> SETTINGS<span class=\"token punctuation\">.</span>lowe_ratio <span class=\"token operator\">*</span> n<span class=\"token punctuation\">.</span>distance<span class=\"token punctuation\">]</span></code></pre></div>\n<p><strong>c) Homografía con RANSAC.</strong> Con los buenos emparejamientos estimo una <strong>homografía</strong>: 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 (<em>outliers</em>) que arruinarían un ajuste por mínimos cuadrados. Aquí entra <strong>RANSAC</strong> (<em>Random Sample Consensus</em>): 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\" (<em>inliers</em>) reúne. Es robustísimo frente a ruido.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">h<span class=\"token punctuation\">,</span> mask <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>findHomography<span class=\"token punctuation\">(</span>dst<span class=\"token punctuation\">,</span> src<span class=\"token punctuation\">,</span> cv2<span class=\"token punctuation\">.</span>RANSAC<span class=\"token punctuation\">,</span> <span class=\"token number\">5.0</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p><strong>d) Comprobación de cordura geométrica.</strong> Que OpenCV me devuelva una matriz no significa que sea sensata. Un banco es aproximadamente <strong>plano</strong> y la cámara está <strong>fija</strong>, 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:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">_homography_is_sane</span><span class=\"token punctuation\">(</span>h<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">if</span> <span class=\"token builtin\">abs</span><span class=\"token punctuation\">(</span>h<span class=\"token punctuation\">[</span><span class=\"token number\">2</span><span class=\"token punctuation\">,</span> <span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">></span> <span class=\"token number\">1e-2</span> <span class=\"token keyword\">or</span> <span class=\"token builtin\">abs</span><span class=\"token punctuation\">(</span>h<span class=\"token punctuation\">[</span><span class=\"token number\">2</span><span class=\"token punctuation\">,</span> <span class=\"token number\">1</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">></span> <span class=\"token number\">1e-2</span><span class=\"token punctuation\">:</span>  <span class=\"token comment\"># perspectiva excesiva</span>\n        <span class=\"token keyword\">return</span> <span class=\"token boolean\">False</span>\n    det <span class=\"token operator\">=</span> <span class=\"token builtin\">float</span><span class=\"token punctuation\">(</span>np<span class=\"token punctuation\">.</span>linalg<span class=\"token punctuation\">.</span>det<span class=\"token punctuation\">(</span>h<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">:</span><span class=\"token number\">2</span><span class=\"token punctuation\">,</span> <span class=\"token number\">0</span><span class=\"token punctuation\">:</span><span class=\"token number\">2</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">if</span> det <span class=\"token operator\">&lt;=</span> <span class=\"token number\">0.1</span> <span class=\"token keyword\">or</span> det <span class=\"token operator\">>=</span> <span class=\"token number\">10.0</span><span class=\"token punctuation\">:</span>  <span class=\"token comment\"># reflexión o escala disparatada</span>\n        <span class=\"token keyword\">return</span> <span class=\"token boolean\">False</span>\n    <span class=\"token keyword\">return</span> <span class=\"token boolean\">True</span></code></pre></div>\n<p>Si la homografía es válida, deformo la imagen actual sobre el marco de la referencia con <code class=\"language-text\">cv2.warpPerspective</code>. Y aún exijo una <strong>ratio de inliers</strong> mínima: si de los emparejamientos buenos muy pocos acaban de acuerdo con la homografía, no me fío.</p>\n<p><strong>El plan B.</strong> ¿Y si no hay textura suficiente para estimar homografía (un banco muy limpio y liso da pocos <em>features</em>)? No me rindo a la primera: calculo el <strong>SSIM global</strong> entre ambas imágenes. Si son casi idénticas (desplazamiento despreciable), sigo adelante sin alinear; si son muy distintas, entonces sí marco <code class=\"language-text\">not_evaluable</code>. Otra vez la misma filosofía: seguir solo cuando puedo hacerlo con garantías.</p>\n<h3 id=\"4-regiones-del-polígono-a-los-píxeles\" style=\"position:relative;\"><a href=\"#4-regiones-del-pol%C3%ADgono-a-los-p%C3%ADxeles\" aria-label=\"4 regiones del polígono a los píxeles permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>4. Regiones: del polígono a los píxeles</h3>\n<p>Ya con las imágenes alineadas, <code class=\"language-text\">app/pipeline/regions.py</code> traduce cada polígono normalizado (coordenadas en <code class=\"language-text\">0..1</code>, independientes de la resolución) a <strong>píxeles</strong>, y prepara tres cosas por herramienta:</p>\n<ul>\n<li>El <strong>rectángulo delimitador</strong> (<em>bounding rect</em>) del polígono, para recortar.</li>\n<li>Una <strong>máscara</strong> binaria del polígono (con <code class=\"language-text\">cv2.fillPoly</code>), para que <strong>solo los píxeles dentro del contorno cuenten</strong>.</li>\n<li>Una expansión del rectángulo según el <code class=\"language-text\">searchMargin</code>, que da un pequeño margen de búsqueda alrededor.</li>\n</ul>\n<p>¿Por qué polígonos y no simples rectángulos? Porque con cámaras muy <strong>angulares</strong>, 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 <em>bounding boxes</em> rectangulares (las configuraciones antiguas se migran solas a un polígono de 4 vértices).</p>\n<h3 id=\"5-las-métricas-el-corazón-de-la-comparación\" style=\"position:relative;\"><a href=\"#5-las-m%C3%A9tricas-el-coraz%C3%B3n-de-la-comparaci%C3%B3n\" aria-label=\"5 las métricas el corazón de la comparación permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>5. Las métricas: el corazón de la comparación</h3>\n<p>Llegamos al núcleo (<code class=\"language-text\">app/pipeline/metrics.py</code>). Por cada herramienta comparo su región en la referencia contra la región en la captura actual, y calculo <strong>tres métricas complementarias</strong>. La idea de usar tres y fusionarlas es que cada una captura un aspecto distinto del parecido, y donde una falla otra compensa.</p>\n<p>Primero, <strong>normalización</strong>. Antes de comparar, ecualizo el contraste con <strong>CLAHE</strong> (<em>Contrast Limited Adaptive Histogram Equalization</em>) y aplico un ligero desenfoque gaussiano:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">normalize</span><span class=\"token punctuation\">(</span>gray<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    clahe <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>createCLAHE<span class=\"token punctuation\">(</span>clipLimit<span class=\"token operator\">=</span><span class=\"token number\">2.0</span><span class=\"token punctuation\">,</span> tileGridSize<span class=\"token operator\">=</span><span class=\"token punctuation\">(</span><span class=\"token number\">8</span><span class=\"token punctuation\">,</span> <span class=\"token number\">8</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> cv2<span class=\"token punctuation\">.</span>GaussianBlur<span class=\"token punctuation\">(</span>clahe<span class=\"token punctuation\">.</span><span class=\"token builtin\">apply</span><span class=\"token punctuation\">(</span>gray<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token number\">3</span><span class=\"token punctuation\">,</span> <span class=\"token number\">3</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>CLAHE ecualiza el histograma <strong>por regiones</strong> (a diferencia de la ecualización global), lo que la hace robusta frente a <strong>cambios de iluminación no uniformes</strong> (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.</p>\n<p><strong>Métrica 1 — SSIM (Structural Similarity Index).</strong> El SSIM no compara píxel a píxel como haría un error cuadrático medio, sino que compara <strong>luminancia, contraste y estructura</strong> en ventanas locales, imitando cómo percibe el sistema visual humano. Da un valor entre -1 y 1 (aquí lo recorto a <code class=\"language-text\">0..1</code>). Es la métrica reina para \"¿estas dos imágenes se parecen estructuralmente?\". La saco de <strong>scikit-image</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">from</span> skimage<span class=\"token punctuation\">.</span>metrics <span class=\"token keyword\">import</span> structural_similarity <span class=\"token keyword\">as</span> ssim\nvalue <span class=\"token operator\">=</span> ssim<span class=\"token punctuation\">(</span>ref_norm<span class=\"token punctuation\">,</span> cur_norm<span class=\"token punctuation\">)</span></code></pre></div>\n<p><strong>Métrica 2 — Similitud de bordes.</strong> Extraigo los bordes de ambas regiones con <strong>Canny</strong> y mido cuánto se solapan. Aquí uso el <strong>coeficiente de Dice</strong> (equivalente al F1 entre dos conjuntos de píxeles), que es dos veces la intersección dividida entre la suma de ambos conjuntos: <code class=\"language-text\">Dice = 2·|A ∩ B| / (|A| + |B|)</code>.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">edge_similarity</span><span class=\"token punctuation\">(</span>ref_edges<span class=\"token punctuation\">,</span> cur_edges<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    a <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>dilate<span class=\"token punctuation\">(</span>ref_edges<span class=\"token punctuation\">,</span> kernel<span class=\"token punctuation\">)</span> <span class=\"token operator\">></span> <span class=\"token number\">0</span>\n    b <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>dilate<span class=\"token punctuation\">(</span>cur_edges<span class=\"token punctuation\">,</span> kernel<span class=\"token punctuation\">)</span> <span class=\"token operator\">></span> <span class=\"token number\">0</span>\n    inter <span class=\"token operator\">=</span> <span class=\"token builtin\">int</span><span class=\"token punctuation\">(</span>np<span class=\"token punctuation\">.</span>logical_and<span class=\"token punctuation\">(</span>a<span class=\"token punctuation\">,</span> b<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token builtin\">sum</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> <span class=\"token builtin\">float</span><span class=\"token punctuation\">(</span><span class=\"token number\">2.0</span> <span class=\"token operator\">*</span> inter <span class=\"token operator\">/</span> <span class=\"token punctuation\">(</span>a<span class=\"token punctuation\">.</span><span class=\"token builtin\">sum</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">+</span> b<span class=\"token punctuation\">.</span><span class=\"token builtin\">sum</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>El detalle fino: <strong>dilato</strong> 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 <strong>presencia/ausencia de forma</strong>: si la herramienta no está, sus bordes característicos desaparecen y el Dice se desploma.</p>\n<p>Para visualizarlo, esto es exactamente lo que hace el <code class=\"language-text\">cv2.Canny(gray, 60, 160)</code> del código sobre una región:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 40%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAICAIAAAB2/0i6AAAACXBIWXMAAAsTAAALEwEAmpwYAAABvElEQVQY0zXNTev5cAAA8G8evva1ZaHlYU0xxkkrpWitCRt5mKc8pBYpHHaQclory03KgQsHZ2cHL8BZzl7Or/z7f97AB+i63mw2VVWt1WqKopRKJVmWc7kcz/OCIMiyHI1GU6lUOp3mOC7yQ9M0RVGhUAjs9/vRaLRYLAzDUFV1Pp/3+/1yuSwIgqqqoigCAFiWDYfDCCG73Q7+s9ls4HA4zGYzWZYvl8vtdnu9XpqmFQqFTqej63o+n0cIkSSJYVgwGGQYBsMwgiAghF6vF7zfb9M0M5nMbrf7fr+fz8eyrEajMZlM1us1y7Lox+/3x+NxjuMoiiIIAsdxn88HHo/H8XicTCbD4fD5fN7v9/P5PBgMut1uu93mOI75CQQCDMMkk8lYLIbjOIQQIQROp5NlWdPpdLVamaZ5vV63222325UkqVKplMtlmqZjsRjLshRF8TwfjUZxHEcIEQQB6vV6r9ebzWaGYfz7x+NxtVotFouaprEsCwAgSTKRSFA/Xq+XIAiPxxOJRMByuRwOh6IoJhKJVqvVbrcVRclms5IkbTYbnudtNhuE0O12u1wuh8PhdrshhE6nk6bpP3rqdGVa2LpeAAAAAElFTkSuQmCC'); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/7f42bf8993c25705132da596ab4e4143/c7ec8/canny-demo.webp 200w,\n/static/7f42bf8993c25705132da596ab4e4143/a55e0/canny-demo.webp 400w,\n/static/7f42bf8993c25705132da596ab4e4143/4ff00/canny-demo.webp 800w,\n/static/7f42bf8993c25705132da596ab4e4143/ff4be/canny-demo.webp 1200w,\n/static/7f42bf8993c25705132da596ab4e4143/a3fcc/canny-demo.webp 1286w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/7f42bf8993c25705132da596ab4e4143/a334c/canny-demo.png 200w,\n/static/7f42bf8993c25705132da596ab4e4143/daecc/canny-demo.png 400w,\n/static/7f42bf8993c25705132da596ab4e4143/6d062/canny-demo.png 800w,\n/static/7f42bf8993c25705132da596ab4e4143/3b150/canny-demo.png 1200w,\n/static/7f42bf8993c25705132da596ab4e4143/102ca/canny-demo.png 1286w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/7f42bf8993c25705132da596ab4e4143/6d062/canny-demo.png\"\n            alt=\"Extracción de bordes con Canny (umbrales 60/160, los mismos que usa el código). Imagen base de ejemplo de Wikimedia Commons\"\n            title=\"Extracción de bordes con Canny (umbrales 60/160, los mismos que usa el código). Imagen base de ejemplo de Wikimedia Commons\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Extracción de bordes con Canny (umbrales 60/160, los mismos que usa el código). Imagen base de ejemplo de Wikimedia Commons</p></figcaption>\n  </figure></p>\n<p><strong>Métrica 3 — Correlación de plantilla.</strong> Uso <code class=\"language-text\">cv2.matchTemplate</code> con el método <code class=\"language-text\">TM_CCOEFF_NORMED</code>, que desliza la región de referencia (la \"plantilla\") sobre la región de búsqueda de la captura y calcula la <strong>correlación cruzada normalizada</strong> 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 <strong>ligeramente mayor</strong> (el <code class=\"language-text\">searchMargin</code>) es que tolera que la herramienta esté un pelín desplazada dentro de su hueco:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">result <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>matchTemplate<span class=\"token punctuation\">(</span>search_region<span class=\"token punctuation\">,</span> ref_region<span class=\"token punctuation\">,</span> cv2<span class=\"token punctuation\">.</span>TM_CCOEFF_NORMED<span class=\"token punctuation\">)</span>\n_<span class=\"token punctuation\">,</span> max_val<span class=\"token punctuation\">,</span> _<span class=\"token punctuation\">,</span> max_loc <span class=\"token operator\">=</span> cv2<span class=\"token punctuation\">.</span>minMaxLoc<span class=\"token punctuation\">(</span>result<span class=\"token punctuation\">)</span></code></pre></div>\n<p><strong>El detalle que lo cambia todo: la máscara.</strong> Las tres métricas se calculan <strong>restringidas a los píxeles dentro del polígono</strong>. Para SSIM y bordes, relleno el exterior de la máscara con el <strong>valor medio interior</strong> (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.</p>\n<h3 id=\"6-fusión-y-clasificación\" style=\"position:relative;\"><a href=\"#6-fusi%C3%B3n-y-clasificaci%C3%B3n\" aria-label=\"6 fusión y clasificación permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>6. Fusión y clasificación</h3>\n<p><code class=\"language-text\">app/pipeline/scoring.py</code> es deliberadamente trivial: una <strong>combinación lineal ponderada</strong> de las tres métricas, con pesos configurables que suman 1:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">score <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>weights<span class=\"token punctuation\">[</span><span class=\"token string\">\"ssim\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">*</span> metrics<span class=\"token punctuation\">.</span>ssim\n       <span class=\"token operator\">+</span> weights<span class=\"token punctuation\">[</span><span class=\"token string\">\"edgeSimilarity\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">*</span> metrics<span class=\"token punctuation\">.</span>edge_similarity\n       <span class=\"token operator\">+</span> weights<span class=\"token punctuation\">[</span><span class=\"token string\">\"templateCorrelation\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">*</span> metrics<span class=\"token punctuation\">.</span>template_correlation<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Por defecto SSIM pesa 0.4, bordes 0.35 y plantilla 0.25. Que sea simple y transparente es una <strong>feature</strong>, no una carencia: puedo explicar exactamente de dónde sale cada score.</p>\n<p>Y <code class=\"language-text\">app/pipeline/classification.py</code> traduce ese <code class=\"language-text\">score</code> en un estado, con una <strong>banda de incertidumbre</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">classify</span><span class=\"token punctuation\">(</span>score<span class=\"token punctuation\">,</span> threshold<span class=\"token punctuation\">,</span> uncertainty_margin<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">if</span> score <span class=\"token operator\">>=</span> threshold<span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">return</span> <span class=\"token string\">\"ok\"</span>\n    <span class=\"token keyword\">if</span> score <span class=\"token operator\">>=</span> threshold <span class=\"token operator\">-</span> uncertainty_margin<span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">return</span> <span class=\"token string\">\"uncertain\"</span>\n    <span class=\"token keyword\">return</span> <span class=\"token string\">\"misplaced\"</span></code></pre></div>\n<p>Esa banda <code class=\"language-text\">uncertain</code> 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 <code class=\"language-text\">ok</code> a <code class=\"language-text\">uncertain</code>.</p>\n<h3 id=\"7-anotación\" style=\"position:relative;\"><a href=\"#7-anotaci%C3%B3n\" aria-label=\"7 anotación permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>7. Anotación</h3>\n<p>Por último, <code class=\"language-text\">app/pipeline/annotation.py</code> dibuja cada polígono sobre la imagen con su color según estado (verde <code class=\"language-text\">ok</code>, rojo <code class=\"language-text\">misplaced</code>, naranja <code class=\"language-text\">uncertain</code>, gris <code class=\"language-text\">not_evaluable</code>) 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 (<strong>nunca</strong> se persiste Base64 dentro del JSON). Es justo el estilo de la imagen de portada de este artículo.</p>\n<hr>\n<h2 id=\"configuración-todos-los-parámetros-uno-a-uno\" style=\"position:relative;\"><a href=\"#configuraci%C3%B3n-todos-los-par%C3%A1metros-uno-a-uno\" aria-label=\"configuración todos los parámetros uno a uno permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Configuración: todos los parámetros, uno a uno</h2>\n<p>Una de las cosas de las que más orgulloso estoy es de que <strong>todo es configurable sin tocar código</strong>. Hay tres niveles: variables de entorno (<code class=\"language-text\">.env</code>), la configuración del sistema (<code class=\"language-text\">data/config.json</code>) y los secretos (<code class=\"language-text\">config/auth.json</code>). Vamos con calma.</p>\n<h3 id=\"modelo-de-datos\" style=\"position:relative;\"><a href=\"#modelo-de-datos\" aria-label=\"modelo de datos permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Modelo de datos</h3>\n<p>Así se relacionan las entidades de <code class=\"language-text\">config.json</code>:</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 800px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 55.49999999999999%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsTAAALEwEAmpwYAAABf0lEQVQoz32SWW/bMBCE/f//V9GmgYoq0GHZuqzDJHXRWloUSfEohLQohCAZ7MNgsN/L7J6sdf9ml3OuhzUu7lk7iM049z//qNPHaJjmS14VDRJSuS91GpnoQXTzekMDefBx2canlGrT1lFYGJfwXB9sWZXmSittDnCYta9B+uIn8Y1gphGo+6y4UP3MC8JaKsqOlQQwbAg05dsBjvPmxY+98BrnbTsteJYdKKl0jcdvXuAnZY5oS9eqhwLPA4gDfLmh17c4uJRJiYK0CrO6QJNQmsKzaAgegVBGKKvxQCYG/Agb64x12thNG2OssVYbs67r3rMxDOaVc+ec1tpa82nb1trpqTrY+0trUnczokvHFJf6fUFKobX+9FRR1vzwz16YvU9Sddd2HEB0IMksEeUlmpqedbB7/FiPcFp9/xX8fDt7wbXAlIDCsxxARHkTFfcwa6K8DdI6TOvgWqVNf4DTCv+OUv+ce8ElKdG46J6pRWpj9y72LzT7L/71zv0B421w8FdxPGAAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <picture>\n          <source\n              srcset=\"/static/8a7cfa72c65e47a7367534aad56f1223/c7ec8/diagrama-modelo-datos.webp 200w,\n/static/8a7cfa72c65e47a7367534aad56f1223/a55e0/diagrama-modelo-datos.webp 400w,\n/static/8a7cfa72c65e47a7367534aad56f1223/4ff00/diagrama-modelo-datos.webp 800w,\n/static/8a7cfa72c65e47a7367534aad56f1223/ca150/diagrama-modelo-datos.webp 826w\"\n              sizes=\"(max-width: 800px) 100vw, 800px\"\n              type=\"image/webp\"\n            />\n          <source\n            srcset=\"/static/8a7cfa72c65e47a7367534aad56f1223/a334c/diagrama-modelo-datos.png 200w,\n/static/8a7cfa72c65e47a7367534aad56f1223/daecc/diagrama-modelo-datos.png 400w,\n/static/8a7cfa72c65e47a7367534aad56f1223/6d062/diagrama-modelo-datos.png 800w,\n/static/8a7cfa72c65e47a7367534aad56f1223/3b1ef/diagrama-modelo-datos.png 826w\"\n            sizes=\"(max-width: 800px) 100vw, 800px\"\n            type=\"image/png\"\n          />\n          <img\n            class=\"gatsby-resp-image-image\"\n            src=\"/static/8a7cfa72c65e47a7367534aad56f1223/6d062/diagrama-modelo-datos.png\"\n            alt=\"Modelo de datos de config.json: settings, cámaras, bancos y herramientas\"\n            title=\"Modelo de datos de config.json: settings, cámaras, bancos y herramientas\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n          />\n        </picture>\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\"><p>Modelo de datos de config.json: settings, cámaras, bancos y herramientas</p></figcaption>\n  </figure></p>\n<h3 id=\"variables-de-entorno-env\" style=\"position:relative;\"><a href=\"#variables-de-entorno-env\" aria-label=\"variables de entorno env permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Variables de entorno (<code class=\"language-text\">.env</code>)</h3>\n<p>Controlan el despliegue y afinan el motor de visión sin recompilar:</p>\n<table>\n<thead>\n<tr>\n<th>Variable</th>\n<th>Qué hace</th>\n<th>Por defecto</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code class=\"language-text\">TZ</code></td>\n<td>Zona horaria de todos los contenedores (sella los timestamps)</td>\n<td><code class=\"language-text\">Europe/Madrid</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">FRONTEND_PORT</code></td>\n<td>Puerto público de Nginx</td>\n<td><code class=\"language-text\">8080</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">API_PORT</code></td>\n<td>Puerto interno de Express (no se publica)</td>\n<td><code class=\"language-text\">3000</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">CV_SERVICE_URL</code></td>\n<td>URL interna del CV en la red de Compose</td>\n<td><code class=\"language-text\">http://cv:8000</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">CV_TIMEOUT_MS</code></td>\n<td>Timeout de la petición al CV</td>\n<td><code class=\"language-text\">30000</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">JWT_SECURE_COOKIE</code></td>\n<td>Marca <code class=\"language-text\">Secure</code> en la cookie de sesión (activar tras HTTPS)</td>\n<td><code class=\"language-text\">false</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">JWT_EXPIRES_SECONDS</code></td>\n<td>Duración de la sesión</td>\n<td><code class=\"language-text\">43200</code> (12 h)</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">LOGIN_RATE_LIMIT_WINDOW_MS</code> / <code class=\"language-text\">LOGIN_RATE_LIMIT_MAX</code></td>\n<td>Ventana y máximo de intentos de login</td>\n<td><code class=\"language-text\">900000</code> / <code class=\"language-text\">10</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">WEBHOOK_RATE_LIMIT_WINDOW_MS</code> / <code class=\"language-text\">WEBHOOK_RATE_LIMIT_MAX</code></td>\n<td>Rate limit de los webhooks por token</td>\n<td><code class=\"language-text\">60000</code> / <code class=\"language-text\">30</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">CORS_ALLOWED_ORIGINS</code></td>\n<td>Orígenes CORS permitidos (vacío en despliegue mismo-origen)</td>\n<td><em>(vacío)</em></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">MAX_JSON_BYTES</code> / <code class=\"language-text\">MAX_UPLOAD_BYTES</code></td>\n<td>Tamaño máximo de cuerpo JSON y de subida de imágenes</td>\n<td><code class=\"language-text\">1000000</code> / <code class=\"language-text\">12000000</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">LOG_LEVEL</code></td>\n<td><code class=\"language-text\">debug</code> / <code class=\"language-text\">info</code> / <code class=\"language-text\">warn</code> / <code class=\"language-text\">error</code></td>\n<td><code class=\"language-text\">info</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">NODE_ENV</code></td>\n<td><code class=\"language-text\">production</code> oculta <em>stack traces</em></td>\n<td><code class=\"language-text\">production</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">CV_MIN_BRIGHTNESS</code> / <code class=\"language-text\">CV_MAX_BRIGHTNESS</code></td>\n<td>Umbrales de brillo aceptable</td>\n<td><code class=\"language-text\">25</code> / <code class=\"language-text\">245</code></td>\n</tr>\n<tr>\n<td><code class=\"language-text\">CV_MIN_BLUR_VARIANCE</code></td>\n<td>Varianza mínima del Laplaciano (por debajo = borrosa)</td>\n<td><code class=\"language-text\">60</code></td>\n</tr>\n</tbody>\n</table>\n<p>Y hay más <em>knobs</em> del CV con valores por defecto sensatos, que se pueden ajustar contra tus cámaras reales: <code class=\"language-text\">CV_ORB_FEATURES</code> (1500, número de puntos ORB), <code class=\"language-text\">CV_LOWE_RATIO</code> (0.75, el ratio del test de Lowe), <code class=\"language-text\">CV_MIN_INLIER_RATIO</code> (0.3, ratio mínima de inliers en RANSAC), <code class=\"language-text\">CV_GLOBAL_SIM_FLOOR</code> (0.2, el suelo de SSIM global del plan B) y <code class=\"language-text\">CV_MIN_DIMENSION</code> / <code class=\"language-text\">CV_MAX_DIMENSION</code> (guardias de tamaño de imagen).</p>\n<h3 id=\"ajustes-globales-settings\" style=\"position:relative;\"><a href=\"#ajustes-globales-settings\" aria-label=\"ajustes globales settings permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Ajustes globales (<code class=\"language-text\">settings</code>)</h3>\n<p>Dentro de <code class=\"language-text\">config.json</code>, la sección <code class=\"language-text\">settings</code>:</p>\n<ul>\n<li><strong><code class=\"language-text\">defaultThreshold</code></strong> (<code class=\"language-text\">0.82</code>): umbral por defecto; una herramienta con score por encima se considera <code class=\"language-text\">ok</code>. Cada herramienta puede sobreescribirlo.</li>\n<li><strong><code class=\"language-text\">uncertaintyMargin</code></strong> (<code class=\"language-text\">0.05</code>): anchura de la banda <code class=\"language-text\">uncertain</code> por debajo del umbral.</li>\n<li><strong><code class=\"language-text\">cameraConcurrency</code></strong> (<code class=\"language-text\">4</code>): cuántos bancos se inspeccionan en paralelo.</li>\n<li><strong><code class=\"language-text\">inspectionHistoryLimit</code></strong> (<code class=\"language-text\">200</code>): cuántas inspecciones se guardan en el historial (se podan las viejas).</li>\n<li><strong><code class=\"language-text\">cvTimeoutMs</code></strong> (<code class=\"language-text\">30000</code>): timeout de la llamada al CV.</li>\n<li><strong><code class=\"language-text\">captureRetries</code></strong> / <strong><code class=\"language-text\">captureDelayMs</code></strong>: reintentos globales de captura y espera entre ellos (los valores por cámara tienen prioridad).</li>\n<li><strong><code class=\"language-text\">metricWeights</code></strong>: los pesos <code class=\"language-text\">{ ssim, edgeSimilarity, templateCorrelation }</code> de la fusión. <strong>Deben sumar 1</strong> (se valida).</li>\n</ul>\n<h3 id=\"cámaras-cameras\" style=\"position:relative;\"><a href=\"#c%C3%A1maras-cameras\" aria-label=\"cámaras cameras permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Cámaras (<code class=\"language-text\">cameras</code>)</h3>\n<p>Cada cámara es una entidad independiente con <code class=\"language-text\">id</code> único apto para URL. Sus campos:</p>\n<ul>\n<li><strong><code class=\"language-text\">type</code></strong>: <code class=\"language-text\">esphome</code> o <code class=\"language-text\">generic-http</code>.</li>\n<li><strong><code class=\"language-text\">snapshotUrl</code></strong>: la URL del snapshot (solo <code class=\"language-text\">http</code>/<code class=\"language-text\">https</code>).</li>\n<li><strong><code class=\"language-text\">authentication</code></strong>: <code class=\"language-text\">none</code>, <code class=\"language-text\">basic</code>, <code class=\"language-text\">digest</code> o <code class=\"language-text\">bearer</code>. Las credenciales son <strong>de solo escritura</strong>: la API nunca las devuelve.</li>\n<li>\n<p><strong><code class=\"language-text\">request</code></strong>: el bloque fino de la petición:</p>\n<ul>\n<li><code class=\"language-text\">timeoutMs</code>: timeout de la petición HTTP.</li>\n<li><code class=\"language-text\">retries</code>: reintentos ante fallo transitorio.</li>\n<li><code class=\"language-text\">discardInitialFrames</code>: <strong>cuántos fotogramas descartar</strong> 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).</li>\n<li><code class=\"language-text\">delayBetweenFramesMs</code>: espera entre esos fotogramas.</li>\n<li><code class=\"language-text\">maximumResponseBytes</code>: tope de tamaño de la respuesta (protección anti-abuso).</li>\n<li><code class=\"language-text\">verifyTls</code>: verificar el certificado TLS.</li>\n<li><code class=\"language-text\">followRedirects</code>: seguir redirecciones (desactivado por defecto; si se activa, solo al mismo host).</li>\n</ul>\n</li>\n</ul>\n<h3 id=\"bancos-workbenches-y-herramientas-tools\" style=\"position:relative;\"><a href=\"#bancos-workbenches-y-herramientas-tools\" aria-label=\"bancos workbenches y herramientas tools permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Bancos (<code class=\"language-text\">workbenches</code>) y herramientas (<code class=\"language-text\">tools</code>)</h3>\n<p>Cada banco referencia <strong>una cámara</strong> por <code class=\"language-text\">cameraId</code>, tiene una <code class=\"language-text\">referenceImage</code>, un bloque <code class=\"language-text\">alignment</code> (<code class=\"language-text\">enabled</code>, <code class=\"language-text\">minimumMatches</code>) y una lista de herramientas. Cada herramienta:</p>\n<ul>\n<li><strong><code class=\"language-text\">polygon</code></strong>: lista ordenada de 3 a 24 vértices normalizados <code class=\"language-text\">0..1</code> (dibujados en la UI sobre la referencia).</li>\n<li><strong><code class=\"language-text\">threshold</code></strong>: su umbral propio; si no lo tiene, usa el <code class=\"language-text\">defaultThreshold</code> global.</li>\n<li><strong><code class=\"language-text\">searchMargin</code></strong> (<code class=\"language-text\">0..0.5</code>): cuánto expandir la región de búsqueda alrededor del polígono.</li>\n<li><strong><code class=\"language-text\">enabled</code></strong>: permite desactivar una herramienta sin borrarla.</li>\n</ul>\n<h3 id=\"secretos-configauthjson\" style=\"position:relative;\"><a href=\"#secretos-configauthjson\" aria-label=\"secretos configauthjson permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Secretos (<code class=\"language-text\">config/auth.json</code>)</h3>\n<p>Fichero aparte, montado <strong>solo lectura</strong>, que nunca se sube al repo:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"schemaVersion\"</span><span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"jwtSecret\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"UNA_CADENA_LARGA_Y_ALEATORIA_DE_32+_CARACTERES\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"triggerToken\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"TOKEN_LARGO_PARA_LA_PALANCA\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"users\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"username\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"admin\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"passwordHash\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"$2b$12$....(hash bcrypt)....\"</span> <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">]</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>No hay registro ni gestión de usuarios: son <strong>estáticos</strong>. El <code class=\"language-text\">passwordHash</code> se genera con un script incluido (<code class=\"language-text\">npm run hash-password</code>), y tanto <code class=\"language-text\">jwtSecret</code> como <code class=\"language-text\">triggerToken</code> los saco con <code class=\"language-text\">openssl rand -hex 32</code>.</p>\n<h3 id=\"notificaciones-y-tokens\" style=\"position:relative;\"><a href=\"#notificaciones-y-tokens\" aria-label=\"notificaciones y tokens permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Notificaciones y tokens</h3>\n<p>Las <strong>notificaciones</strong> se configuran desde la UI (pestaña <em>Notificaciones</em>), no por variables de entorno: eliges método, URL, cabeceras y <strong>cuerpo con plantilla</strong>. En el cuerpo puedes usar tokens como <code class=\"language-text\">{{json}}</code> (el objeto completo del evento, recomendado), <code class=\"language-text\">{{workbenchName}}</code>, <code class=\"language-text\">{{status}}</code>, <code class=\"language-text\">{{toolsText}}</code> o <code class=\"language-text\">{{imageUrl}}</code>. 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 <code class=\"language-text\">https://api.telegram.org/bot&lt;token>/sendMessage</code>.</p>\n<p>Y los <strong>tokens de webhook por banco</strong> (pestaña <em>Tokens</em>) permiten que un servicio externo dispare la inspección de <strong>un banco concreto</strong>. Se guardan <strong>hasheados</strong> con bcrypt, tienen un <strong>alcance</strong> (un banco o <code class=\"language-text\">all</code>) y el secreto se muestra <strong>una sola vez</strong> al crearlo. Ideal para un <code class=\"language-text\">rest_command</code> de Home Assistant:</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">rest_command</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">inspeccionar_banco_electronica</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">url</span><span class=\"token punctuation\">:</span> <span class=\"token string\">\"http://&lt;host>:8080/api/webhooks/inspect/banco-electronica\"</span>\n    <span class=\"token key atrule\">method</span><span class=\"token punctuation\">:</span> POST\n    <span class=\"token key atrule\">headers</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">X-Api-Token</span><span class=\"token punctuation\">:</span> <span class=\"token tag\">!secret</span> makespace_webhook_token</code></pre></div>\n<hr>\n<h2 id=\"seguridad-y-persistencia-de-pasada\" style=\"position:relative;\"><a href=\"#seguridad-y-persistencia-de-pasada\" aria-label=\"seguridad y persistencia de pasada permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Seguridad y persistencia, de pasada</h2>\n<p>No quiero alargarme, pero merecen mención un par de decisiones que me importaban:</p>\n<p><strong>Persistencia atómica.</strong> Un único módulo posee todas las lecturas/escrituras. Cada escritura va a un fichero temporal y luego un <code class=\"language-text\">rename()</code> (atómico en el mismo sistema de ficheros), protegido por un <em>mutex</em> para serializar peticiones concurrentes. Cada JSON lleva <code class=\"language-text\">schemaVersion</code>; al cargar, si es antiguo, se hace una <strong>copia de seguridad con fecha</strong> y se migra. Si la configuración es inválida, el arranque <strong>falla ruidosamente</strong> y no la sobreescribe jamás.</p>\n<p><strong>Protección SSRF en la captura.</strong> Como el servidor hace peticiones a URLs que configura el usuario (las cámaras), hay que blindarlo contra <em>Server-Side Request Forgery</em>: solo esquemas <code class=\"language-text\">http</code>/<code class=\"language-text\">https</code>, redirecciones desactivadas por defecto (y solo al mismo host si se activan), y la cabecera <code class=\"language-text\">Authorization</code> <strong>nunca</strong> se reenvía a otro host. Además el navegador nunca contacta con la cámara: siempre pasa por un <strong>proxy autenticado</strong> en el servidor (<code class=\"language-text\">GET /api/cameras/:id/snapshot</code>).</p>\n<p><strong>Secretos de solo escritura y redacción en logs.</strong> Las contraseñas de cámaras y los tokens nunca vuelven por la API (solo <code class=\"language-text\">{ credentialsConfigured: true, username }</code>), y los logs redactan <code class=\"language-text\">Authorization</code>, contraseñas, tokens, cookies y URLs con secretos.</p>\n<hr>\n<h2 id=\"limitaciones-conocidas\" style=\"position:relative;\"><a href=\"#limitaciones-conocidas\" aria-label=\"limitaciones conocidas permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Limitaciones conocidas</h2>\n<p>Sería deshonesto vender esto como infalible. La visión artificial clásica tiene límites, y prefiero enumerarlos claramente:</p>\n<ul>\n<li><strong>Sensibilidad a la iluminación y los reflejos.</strong> Aunque CLAHE y el <em>gating</em> de calidad ayudan mucho, un cambio drástico de luz o un reflejo fuerte sobre una herramienta metálica puede disparar falsos <code class=\"language-text\">uncertain</code>/<code class=\"language-text\">misplaced</code>. La mitigación real es <strong>iluminación fija</strong> en el banco.</li>\n<li><strong>No identifica la herramienta, solo compara con la referencia.</strong> Si alguien pone en el hueco un objeto <em>distinto</em> pero de forma y tamaño parecidos, podría colar como <code class=\"language-text\">ok</code>. No es un detector de objetos.</li>\n<li><strong>Depende de una buena referencia y de cámara estable.</strong> Si la cámara se mueve mucho, la alineación por homografía puede no converger y el banco caerá en <code class=\"language-text\">not_evaluable</code>. La rótula del soporte impreso ayuda, pero hay que fijarla bien.</li>\n<li><strong>Herramientas muy pequeñas o de bajo contraste</strong> (una broca fina sobre fondo oscuro) dan pocos bordes y poca textura, así que las métricas son menos fiables. Ahí toca subir su <code class=\"language-text\">threshold</code> con cuidado o aceptar más <code class=\"language-text\">uncertain</code>.</li>\n<li><strong>Persistencia mono-proceso.</strong> El modelo de ficheros + mutex asume <strong>un solo</strong> contenedor de API escribiendo. No escala horizontalmente (ni falta que le hace para este caso, pero conviene saberlo).</li>\n<li><strong>Secretos sin cifrado en reposo.</strong> 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.</li>\n<li><strong>Oclusiones.</strong> 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.</li>\n</ul>\n<p>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 <code class=\"language-text\">uncertain</code>— pero es importante conocerlas para calibrar bien y no esperar magia.</p>\n<hr>\n<h2 id=\"cierre\" style=\"position:relative;\"><a href=\"#cierre\" aria-label=\"cierre permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Cierre</h2>\n<p>Este proyecto me ha permitido juntar un montón de cosas que me gustan: <strong>electrónica</strong> (ESP32-CAM, ESPHome), <strong>visión artificial clásica</strong> (y toda la teoría bonita de SSIM, homografías, RANSAC, Canny...), <strong>arquitectura de microservicios</strong> bien separada, y por supuesto <strong>resolver un problema real</strong> de la comunidad del makespace, que es lo que de verdad me motiva.</p>\n<p>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 <strong>más simple, más barata, más explicable y más fácil de mantener</strong>. Y de paso aprendes muchísimo sobre los fundamentos, que es lo que de verdad queda.</p>\n<p>Como siempre, el código está disponible para quien quiera cotillear, aprender o mejorarlo: <a href=\"https://github.com/davidpoza/workbench-organizer-cv\">github.com/davidpoza/workbench-organizer-cv</a>.</p>\n<p>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!</p>","excerpt":"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…"}}]}},"pageContext":{"tag":"docker"}},
    "staticQueryHashes": ["1145805751","2130837617","345446602","79378074"]}