Reconocimiento de Gestos 2D en Tiempo Real con JavaScript y Algoritmo de Normalización Vectorial ($1 Recognizer)

JAVASCRIPT 23 de junio de 2026 140 lecturas
Motor determinista de reconocimiento de gestos 2D en JavaScript puro sin dependencias externas, optimizado para alto rendimiento y baja latencia mediante normalización vectorial e invariancia espacial.

1. El Paradigma de Interacción Táctil y Vectorial: Geometría vs. Machine Learning

En las arquitecturas web modernas, la implementación de interfaces gestuales interactivas mediante canvas o SVG suele derivar apresuradamente en la integración de redes neuronales convolucionales o modelos de visión por computador ejecutados en el navegador. La adopción indiscriminada de bibliotecas pesadas de IA para reconocer un conjunto finito de trazos geométricos simples (como círculos, flechas, 'checkmarks' o letras individuales) introduce un coste operativo injustificado. Cargar decodificadores de decenas de megabytes, inicializar contextos WebGL o WebGPU complejos y saturar la CPU con inferencias continuas degrada severamente métricas vitales del rendimiento Web Vitals, tales como el Interaction to Next Paint (INP) y el First Contentful Paint (FCP).

Frente al enfoque probabilístico e indeterminado del aprendizaje profundo, el paradigma determinista basado en el algoritmo $1 Unistroke Recognizer (introducido originalmente por Wobbrock, Wilson y Li) ofrece una precisión superior al 97% con un consumo de recursos insignificante. Mediante transformaciones algebraicas vectoriales de bajo nivel, este método convierte trazos arbitrarios ejecutados a mano alzada en representaciones canónicas invariantes. La ejecución de un reconocedor geométrico determinista en JavaScript puro toma menos de 0.5 milisegundos por trazo, no genera consumo de red ni costes de carga inicial, y opera con un footprint de memoria estrictamente limitado a unos pocos kilobytes.

2. Fundamentos Matemáticos: Pipelines de Normalización de Trayectorias

Cuando un usuario dibuja sobre una pantalla táctil, el navegador emite eventos de puntero que contienen pares de coordenadas cartesianas $(x, y)$. Sin embargo, la trayectoria resultante presenta una variabilidad estocástica extrema condicionada por factores de hardware y comportamiento humano. Dos trazos pertenecientes al mismo gesto geométrico difieren inevitablemente en tres dimensiones críticas: la densidad temporal de los puntos (frecuencia de muestreo del hardware), la dimensión escalar (tamaño del dibujo) y la traslación espacial (posición dentro del viewport).

Para construir un clasificador capaz de comparar dos trazos cuantitativamente, es imperativo someter la lista de coordenadas crudas a una pipeline rigurosa de normalización geométrica estructurada en las siguientes fases:


3. Optimización para Mapeo de Alto Rendimiento a 120Hz y Recolección de Basura

El procesamiento de eventos en tiempo real dentro del hilo principal de JavaScript exige una gestión meticulosa de la memoria y la renderización gráfica. El principal cuello de botella en implementaciones ingenuas radica en la creación y destrucción masiva de objetos intermedios durante el filtrado de eventos y la interpolación de puntos. Si se instancian decenas de arrays intermedios en cada frame durante un trazo continuo, el motor V8 se ve forzado a ejecutar pausas periódicas de Garbage Collection (GC), provocando caídas notables de frames (jank) en pantallas de 90Hz o 120Hz.

Para mitigar este problema, la arquitectura debe utilizar bucles inmutables de paso único sin concatenación de sub-arrays en runtime (reemplazando técnicas costosas como points.slice()). Asimismo, la captura de la interacción táctil debe ser gestionada estrictamente mediante la API de PointerEvents, invocando setPointerCapture para evitar pérdidas de foco fuera del lienzo y desacoplando la renderización visual mediante un bucle requestAnimationFrame eficiente.

4. Manejo Avanzado de Pantallas High-DPI y Experiencia de Usuario Responsiva

Las pantallas modernas de alta densidad de píxeles (Retina, AMOLED 4K) requieren que las dimensiones lógicas del lienzo en el DOM se multipliquen por la relación de píxeles del dispositivo (window.devicePixelRatio). Sin este ajuste en el buffer gráfico de RenderContext 2D, las líneas renderizadas parecerán borrosas o pixeladas. A continuación, se presenta una solución completa, robusta, altamente optimizada y libre de dependencias externas para el reconocimiento de gestos vectoriales en tiempo real.

/**
 * Motor determinista de reconocimiento de gestos 2D basado en normalización espacial ($1 Recognizer).
 */
class GeometricGestureRecognizer {
    /**
     * @param {Object} [options={}]
     * @param {number} [options.samplePointsCount=32] - Puntos fijos para re-muestreo.
     * @param {number} [options.similarityThreshold=0.75] - Umbral de coincidencia (0 a 1).
     * @param {number} [options.boundingSize=100] - Tamaño de la caja de normalización.
     */
    constructor(options = {}) {
        this.samplePointsCount = options.samplePointsCount || 32;
        this.similarityThreshold = options.similarityThreshold || 0.75;
        this.boundingSize = options.boundingSize || 100;
        this.templates = new Map();

        this.initDefaultTemplates();
    }

    /**
     * Re-muestrea una ruta de puntos en exactamente N puntos equidistantes.
     * Algoritmo lineal optimizado de alto rendimiento sin asignaciones masivas en memoria.
     * @param {Array<{x: number, y: number}>} points 
     * @param {number} n 
     * @returns {Array<{x: number, y: number}>}
     */
    resample(points, n) {
        if (!points || points.length === 0) return [];
        if (points.length === 1) {
            return Array.from({ length: n }, () => ({ x: points[0].x, y: points[0].y }));
        }

        const totalLen = this.pathLength(points);
        if (totalLen === 0) {
            return Array.from({ length: n }, () => ({ x: points[0].x, y: points[0].y }));
        }

        const interval = totalLen / (n - 1);
        const resampled = [{ x: points[0].x, y: points[0].y }];
        
        let accumulatedDistance = 0;
        let currIndex = 1;
        let lastPoint = { x: points[0].x, y: points[0].y };

        while (currIndex < points.length && resampled.length < n) {
            const nextPoint = points[currIndex];
            const dist = Math.hypot(nextPoint.x - lastPoint.x, nextPoint.y - lastPoint.y);

            if (accumulatedDistance + dist >= interval) {
                const delta = (interval - accumulatedDistance) / dist;
                const newX = lastPoint.x + delta * (nextPoint.x - lastPoint.x);
                const newY = lastPoint.y + delta * (nextPoint.y - lastPoint.y);
                
                const interpolatedPoint = { x: newX, y: newY };
                resampled.push(interpolatedPoint);
                lastPoint = interpolatedPoint;
                accumulatedDistance = 0;
            } else {
                accumulatedDistance += dist;
                lastPoint = nextPoint;
                currIndex++;
            }
        }

        // Relleno de seguridad si faltan puntos por imprecisión flotante
        while (resampled.length < n) {
            const last = points[points.length - 1];
            resampled.push({ x: last.x, y: last.y });
        }

        return resampled;
    }

    /**
     * Calcula la longitud total acumulada de una trayectoria.
     */
    pathLength(points) {
        let length = 0;
        for (let i = 1; i < points.length; i++) {
            length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
        }
        return length;
    }

    /**
     * Calcula el centroide (centro de masa) de una trayectoria.
     */
    getCentroid(points) {
        let sumX = 0;
        let sumY = 0;
        for (let i = 0; i < points.length; i++) {
            sumX += points[i].x;
            sumY += points[i].y;
        }
        return { x: sumX / points.length, y: sumY / points.length };
    }

    /**
     * Escala y traslada los puntos al centroide (0,0) dentro de la caja de tamaño objetivo.
     */
    scaleAndTranslate(points) {
        let minX = Infinity, maxX = -Infinity;
        let minY = Infinity, maxY = -Infinity;

        for (let i = 0; i < points.length; i++) {
            const p = points[i];
            minX = Math.min(minX, p.x);
            maxX = Math.max(maxX, p.x);
            minY = Math.min(minY, p.y);
            maxY = Math.max(maxY, p.y);
        }

        const width = maxX - minX || 1;
        const height = maxY - minY || 1;
        const scale = Math.max(width, height);
        const centroid = this.getCentroid(points);

        const normalized = new Array(points.length);
        for (let i = 0; i < points.length; i++) {
            normalized[i] = {
                x: ((points[i].x - centroid.x) / scale) * this.boundingSize,
                y: ((points[i].y - centroid.y) / scale) * this.boundingSize
            };
        }

        return normalized;
    }

    /**
     * Pipeline completa de procesamiento de un trazo.
     */
    processTrace(rawPoints) {
        if (!rawPoints || rawPoints.length < 3) return null;
        const resampled = this.resample(rawPoints, this.samplePointsCount);
        return this.scaleAndTranslate(resampled);
    }

    /**
     * Registra una nueva plantilla en el reconocido.
     */
    addTemplate(name, rawPoints) {
        const processed = this.processTrace(rawPoints);
        if (processed) {
            this.templates.set(name, processed);
        }
    }

    /**
     * Clasifica un trazo comparándolo contra las plantillas cargadas.
     */
    classify(rawPoints) {
        const processed = this.processTrace(rawPoints);
        if (!processed) return { gesture: 'Desconocido', score: 0 };

        let bestGesture = 'Desconocido';
        let minDistance = Infinity;

        for (const [name, templatePoints] of this.templates.entries()) {
            const dist = this.pathDistance(processed, templatePoints);
            if (dist < minDistance) {
                minDistance = dist;
                bestGesture = name;
            }
        }

        const avgDistance = minDistance / this.samplePointsCount;
        // Puntuación inversa normalizada entre 0.0 y 1.0
        const score = Math.max(0, 1 - (avgDistance / (this.boundingSize / 2)));

        if (score >= this.similarityThreshold) {
            return { gesture: bestGesture, score };
        }

        return { gesture: 'Desconocido', score };
    }

    /**
     * Distancia euclidiana acumulada entre dos trazos de igual longitud.
     */
    pathDistance(pathA, pathB) {
        let distance = 0;
        for (let i = 0; i < pathA.length; i++) {
            distance += Math.hypot(pathA[i].x - pathB[i].x, pathA[i].y - pathB[i].y);
        }
        return distance;
    }

    /**
     * Carga de plantillas base sintetizadas geométricamente.
     */
    initDefaultTemplates() {
        const makeLine = (dx, dy) => {
            const pts = [];
            for (let i = 0; i < 20; i++) pts.push({ x: i * dx, y: i * dy });
            return pts;
        };

        const makeL = () => {
            const pts = [];
            for (let i = 0; i < 10; i++) pts.push({ x: 0, y: i * 10 });
            for (let i = 0; i < 10; i++) pts.push({ x: i * 10, y: 90 });
            return pts;
        };

        const makeCaret = () => {
            const pts = [];
            for (let i = 0; i < 10; i++) pts.push({ x: i * 5, y: 50 - i * 5 });
            for (let i = 0; i < 10; i++) pts.push({ x: 50 + i * 5, y: i * 5 });
            return pts;
        };

        this.addTemplate('Horizontal_Derecha', makeLine(10, 0));
        this.addTemplate('Vertical_Abajo', makeLine(0, 10));
        this.addTemplate('Gesto_L', makeL());
        this.addTemplate('Gesto_Carete', makeCaret());
    }
}

/**
 * Inicializador del Lienzo Interactivo con soporte para Pointer Events y High-DPI.
 */
function inicializarLienzoGestos(canvasId, outputElementId, recognizerOptions = {}) {
    const canvas = document.getElementById(canvasId);
    const output = document.getElementById(outputElementId);

    if (!canvas || !output) return null;

    const ctx = canvas.getContext('2d');
    const recognizer = new GeometricGestureRecognizer(recognizerOptions);

    let rawPoints = [];
    let isDrawing = false;
    let animationFrameId = null;

    canvas.style.touchAction = 'none';

    const resizeCanvas = () => {
        const rect = canvas.getBoundingClientRect();
        const dpr = window.devicePixelRatio || 1;

        canvas.width = rect.width * dpr;
        canvas.height = (rect.height || 300) * dpr;

        ctx.scale(dpr, dpr);
    };

    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    const configureCanvasStyle = () => {
        ctx.lineWidth = 4;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.strokeStyle = '#06b6d4';
        ctx.shadowBlur = 6;
        ctx.shadowColor = 'rgba(6, 182, 212, 0.4)';
    };

    const render = () => {
        if (!isDrawing) return;

        const rect = canvas.getBoundingClientRect();
        ctx.clearRect(0, 0, rect.width, rect.height);
        configureCanvasStyle();

        if (rawPoints.length > 1) {
            ctx.beginPath();
            ctx.moveTo(rawPoints[0].x, rawPoints[0].y);
            for (let i = 1; i < rawPoints.length; i++) {
                ctx.lineTo(rawPoints[i].x, rawPoints[i].y);
            }
            ctx.stroke();
        }

        animationFrameId = requestAnimationFrame(render);
    };

    const getCanvasCoordinates = (e) => {
        const rect = canvas.getBoundingClientRect();
        return {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top
        };
    };

    canvas.addEventListener('pointerdown', (e) => {
        canvas.setPointerCapture(e.pointerId);
        isDrawing = true;

        rawPoints = [getCanvasCoordinates(e)];

        if (animationFrameId) cancelAnimationFrame(animationFrameId);
        animationFrameId = requestAnimationFrame(render);
    });

    canvas.addEventListener('pointermove', (e) => {
        if (!isDrawing) return;

        const currentPoint = getCanvasCoordinates(e);
        const lastPoint = rawPoints[rawPoints.length - 1];

        // Umbral anti-ruido (distancia mínima de 2px)
        if (Math.hypot(currentPoint.x - lastPoint.x, currentPoint.y - lastPoint.y) > 2) {
            rawPoints.push(currentPoint);
        }
    });

    const finishDrawing = (e) => {
        if (!isDrawing) return;
        isDrawing = false;
        
        if (animationFrameId) cancelAnimationFrame(animationFrameId);

        if (canvas.hasPointerCapture(e.pointerId)) {
            canvas.releasePointerCapture(e.pointerId);
        }

        const result = recognizer.classify(rawPoints);

        if (result.gesture !== 'Desconocido') {
            output.textContent = `🎯 Gesto: ${result.gesture} (${(result.score * 100).toFixed(1)}% similitud)`;
            output.style.color = '#10b981';
        } else {
            output.textContent = '❌ Gesto no reconocido. Inténtalo de nuevo.';
            output.style.color = '#ef4444';
        }
    };

    canvas.addEventListener('pointerup', finishDrawing);
    canvas.addEventListener('pointercancel', finishDrawing);

    return recognizer;
}
¿Qué te pareció?
🔥 Brillante 0
💡 Me sirvió 0
🚀 A otro nivel 0

¿Te resultó útil este snippet? Explora más código y soluciones en AndresSY.dev.

Volver a Snippets