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.
Una convolución sustituye cada píxel por una combinación ponderada de sus vecinos, y con esa sola operación se consiguen el desenfoque, el enfoque, el relieve y la detección de bordes. Escribirla es fácil; escribirla bien exige decidir qué hacer en los bordes de la imagen, normalizar el núcleo y, sobre todo, aprovechar la separabilidad, que convierte un coste cuadrático en lineal.
- Implementar una convolución genérica con núcleo arbitrario.
- Elegir una estrategia de borde y explicar qué artefacto evita cada una.
- Aprovechar la separabilidad para reducir el coste del desenfoque gaussiano.
- Reconocer cuándo la convolución en JavaScript deja de ser viable.
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 solo
const enfocada = convolucionar(img, NUCLEOS.enfoque, 3);
// Bordes: suma 0, hay que dar divisor y sesgo
const 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 fotografia
function 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.
Antes de escribir una convolución para desenfocar, conviene saber que hay tres alternativas que casi siempre son mejores. La primera es ctx.filter. El contexto 2D admite la misma sintaxis de filtros que CSS, y ctx.filter = 'blur(8px)' aplica un desenfoque gaussiano implementado en código nativo y a menudo acelerado por GPU, con una calidad y una velocidad que ninguna implementación en JavaScript alcanza. Es la respuesta correcta en la inmensa mayoría de los casos, y solo hay que comprobar el soporte antes de depender de ella. La segunda es dibujar la imagen reducida y ampliarla otra vez. Dibujar en un canvas de un octavo del tamaño y volver a dibujarla al tamaño original con el suavizado activado produce un desenfoque de aspecto muy aceptable, cuesta dos drawImage y es prácticamente gratis. Repetido dos o tres veces se acerca bastante a un gaussiano. Es lo que usaban todos los efectos de desenfoque antes de que existiera ctx.filter, y sigue siendo la opción más rápida con diferencia. La tercera es el desenfoque de caja repetido, que aprovecha un resultado matemático precioso: aplicar tres veces un desenfoque de caja aproxima un gaussiano con un error inferior al tres por ciento. Y un desenfoque de caja se puede implementar con una suma acumulada móvil, de coste constante por píxel independientemente del radio. Eso significa que un desenfoque de radio 100 cuesta lo mismo que uno de radio 3, lo cual ninguna implementación gaussiana directa puede conseguir. Es el algoritmo que usan las bibliotecas de desenfoque rápido y merece la pena conocerlo. La lección de fondo: escribir una convolución a mano tiene todo el valor didáctico del mundo y casi nunca es la mejor decisión de producción. Escríbela para entenderla, y después usa ctx.filter.
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.