Convolución: filtros que miran a los vecinos
Implementar el filtro de vecindad que está detrás del desenfoque, el enfoque y la detección de bordes, con el tratamiento correcto de los bordes y la separabilidad.
El algoritmo
Para cada píxel, se centra el núcleo sobre él, se multiplica cada peso por el valor del píxel correspondiente y se suman los resultados.
function convolucionar(imagen, nucleo, lado, divisor, sesgo = 0) { const { width: w, height: h, data: src } = imagen; const salida = new Uint8ClampedArray(src.length); const medio = Math.floor(lado / 2); const div = divisor || nucleo.reduce((a, b) => a + b, 0) || 1; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let r = 0, g = 0, b = 0; for (let ky = 0; ky < lado; ky++) { for (let kx = 0; kx < lado; kx++) { // Borde por repeticion: se fija a los limites const sy = Math.min(h - 1, Math.max(0, y + ky - medio)); const sx = Math.min(w - 1, Math.max(0, x + kx - medio)); const i = (sy * w + sx) * 4; const peso = nucleo[ky * lado + kx]; r += src[i] * peso; g += src[i + 1] * peso; b += src[i + 2] * peso; } } const o = (y * w + x) * 4; salida[o] = r / div + sesgo; salida[o + 1] = g / div + sesgo; salida[o + 2] = b / div + sesgo; salida[o + 3] = src[o + 3]; // el alfa se conserva } } return new ImageData(salida, w, h);}Tres decisiones de esa implementación. Se escribe en un array de salida separado, porque escribir sobre el mismo array haría que los píxeles ya procesados contaminaran a los siguientes. El divisor normaliza el núcleo para que la suma de pesos valga uno y el brillo global no cambie; si el núcleo suma cero, como los de detección de bordes, hay que pasar un divisor de uno y un sesgo de 128 para centrar el resultado. Y el alfa se copia sin tocar, porque desenfocar el alfa produce halos.
Núcleos clásicos
const NUCLEOS = { identidad: [0, 0, 0, 0, 1, 0, 0, 0, 0], caja: [1, 1, 1, 1, 1, 1, 1, 1, 1], gaussiano: [1, 2, 1, 2, 4, 2, 1, 2, 1], enfoque: [0, -1, 0, -1, 5, -1, 0, -1, 0], relieve: [-2, -1, 0, -1, 1, 1, 0, 1, 2], bordes: [-1, -1, -1, -1, 8, -1, -1, -1, -1], sobelX: [-1, 0, 1, -2, 0, 2, -1, 0, 1], sobelY: [-1, -2, -1, 0, 0, 0, 1, 2, 1],};// Enfoque: suma 1, se normaliza soloconst enfocada = convolucionar(img, NUCLEOS.enfoque, 3);// Bordes: suma 0, hay que dar divisor y sesgoconst conBordes = convolucionar(img, NUCLEOS.bordes, 3, 1, 128);El detector de bordes de Sobel se usa en dos pasadas, una por eje, y se combina la magnitud del gradiente:
function sobel(imagen) { const gx = convolucionar(imagen, NUCLEOS.sobelX, 3, 1, 0); const gy = convolucionar(imagen, NUCLEOS.sobelY, 3, 1, 0); const out = new Uint8ClampedArray(imagen.data.length); for (let i = 0; i < out.length; i += 4) { const m = Math.hypot(gx.data[i] - 0, gy.data[i] - 0); out[i] = out[i + 1] = out[i + 2] = m; out[i + 3] = 255; } return new ImageData(out, imagen.width, imagen.height);}El tratamiento del borde
En los píxeles del contorno, parte del núcleo cae fuera de la imagen. Hay cuatro estrategias y cada una produce un artefacto distinto.
Fijar a los límites (lo que hace el código anterior): se repite el píxel del borde. Es la opción por defecto razonable y produce un ligero refuerzo del borde.
Envolver: se toma el píxel del lado opuesto. Correcto solo para texturas que se repiten.
Reflejar: se refleja la imagen en el borde. Es la que menos artefactos produce en fotografías, porque conserva la continuidad de los gradientes.
Recortar: no se procesan los píxeles del borde y se dejan como estaban. Produce un marco visible con un filtro fuerte.
// Reflejar: la mejor opcion general para fotografiafunction reflejar(v, max) { if (v < 0) return -v - 1; if (v >= max) return 2 * max - v - 1; return v;}Ignorar el borde por completo, dejando los píxeles a cero, produce un marco negro que es el artefacto delator de una convolución mal escrita.
Separabilidad: el ahorro que cambia el juego
Un núcleo gaussiano de lado n es separable: aplicar una convolución 2D con él da exactamente el mismo resultado que aplicar dos convoluciones 1D consecutivas, una horizontal y otra vertical.
El ahorro es enorme. Una convolución 2D con lado n hace n² multiplicaciones por píxel; dos 1D hacen 2n. Con un desenfoque de radio 10, el lado es 21, y la diferencia es de 441 operaciones frente a 42: diez veces menos. Con radio 25 la diferencia es de veinticinco veces.
function gaussiano1D(radio) { const sigma = radio / 3; const lado = radio * 2 + 1; const k = new Float32Array(lado); let suma = 0; for (let i = 0; i < lado; i++) { const x = i - radio; k[i] = Math.exp(-(x * x) / (2 * sigma * sigma)); suma += k[i]; } for (let i = 0; i < lado; i++) k[i] /= suma; return k;}function desenfoqueSeparable(imagen, radio) { const k = gaussiano1D(radio); const { width: w, height: h } = imagen; const paso1 = new Uint8ClampedArray(imagen.data.length); const paso2 = new Uint8ClampedArray(imagen.data.length); const src = imagen.data; // Pasada horizontal for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let r = 0, g = 0, b = 0; for (let i = 0; i < k.length; i++) { const sx = Math.min(w - 1, Math.max(0, x + i - radio)); const p = (y * w + sx) * 4; r += src[p] * k[i]; g += src[p + 1] * k[i]; b += src[p + 2] * k[i]; } const o = (y * w + x) * 4; paso1[o] = r; paso1[o + 1] = g; paso1[o + 2] = b; paso1[o + 3] = src[o + 3]; } } // Pasada vertical for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let r = 0, g = 0, b = 0; for (let i = 0; i < k.length; i++) { const sy = Math.min(h - 1, Math.max(0, y + i - radio)); const p = (sy * w + x) * 4; r += paso1[p] * k[i]; g += paso1[p + 1] * k[i]; b += paso1[p + 2] * k[i]; } const o = (y * w + x) * 4; paso2[o] = r; paso2[o + 1] = g; paso2[o + 2] = b; paso2[o + 3] = paso1[o + 3]; } } return new ImageData(paso2, w, h);}Ese algoritmo es el que hay que usar siempre que el núcleo sea gaussiano o de caja. Los núcleos de enfoque, relieve y detección de bordes no son separables y hay que aplicarlos en 2D, pero como sus lados son de 3, el coste es asumible.
Cuándo dejar JavaScript
La convolución es el punto donde el procesamiento por píxel en el hilo principal deja de ser viable, y conviene tener los números.
Una imagen de 1920 por 1080 tiene dos millones de píxeles. Con un núcleo de 3 por 3, son 18 millones de multiplicaciones por canal, unos 55 millones en total. En JavaScript eso son del orden de 100 a 300 milisegundos: entre seis y dieciocho fotogramas perdidos. Con un núcleo de 9 por 9 y sin separabilidad, se va a varios segundos.
Los tres caminos de salida, en orden de esfuerzo:
ctx.filter cuando el efecto que quieres está entre los que CSS ofrece: desenfoque, brillo, contraste, saturación, tono, sepia, sombra.
Un worker con OffscreenCanvas cuando el filtro es propio y puedes tolerar la latencia. Saca el trabajo del hilo principal, aunque no lo hace más rápido.
Un sombreador de fragmentos en WebGL o WebGPU cuando el filtro tiene que ir en tiempo real sobre vídeo o a resolución alta. Ahí la convolución se ejecuta en paralelo sobre miles de píxeles simultáneamente y el coste cae dos órdenes de magnitud.