Saltar al contenido

La disposición del array de píxeles

Trabajar con el Uint8ClampedArray de ImageData: la aritmética de índices, el tipo con saturación, y las vistas alternativas que aceleran el recorrido.

La aritmética de índices

JS
const i = (y * ancho + x) * 4;const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];

Y a la inversa:

JS
const pixel = Math.floor(i / 4);const x = pixel % ancho;const y = Math.floor(pixel / ancho);

El ancho que se usa es el del ImageData, no el del canvas. Cuando lees una subregión, el array tiene el ancho de esa región y los índices se calculan con él. Confundir ambos anchos produce un filtro que sale sesgado en diagonal, que es el síntoma inconfundible de este error.

Una consecuencia de la disposición: los píxeles de una misma fila están contiguos en memoria, los de una misma columna están separados por ancho * 4 bytes. Un recorrido por filas es amistoso con la caché de la CPU; uno por columnas no. En imágenes grandes la diferencia entre ambos recorridos puede ser de dos o tres veces.

JS
// Rapido: filas contiguasfor (let y = 0; y < alto; y++)  for (let x = 0; x < ancho; x++) { const i = (y * ancho + x) * 4; /* ... */ }// Lento: saltos de ancho*4 bytes en cada pasofor (let x = 0; x < ancho; x++)  for (let y = 0; y < alto; y++) { const i = (y * ancho + x) * 4; /* ... */ }

Y cuando el filtro no necesita las coordenadas, el recorrido más rápido es plano:

JS
for (let i = 0; i < data.length; i += 4) { /* ... */ }

El tipo con saturación

Uint8ClampedArray se distingue de Uint8Array en cómo trata los valores fuera de rango:

JS
const normal = new Uint8Array(1);const saturado = new Uint8ClampedArray(1);normal[0] = 300;    console.log(normal[0]);    // 44  (300 mod 256)saturado[0] = 300;  console.log(saturado[0]);  // 255 (recortado)normal[0] = -20;    console.log(normal[0]);    // 236 (envuelve)saturado[0] = -20;  console.log(saturado[0]);  // 0   (recortado)saturado[0] = 127.6; console.log(saturado[0]); // 128 (redondeo al par mas cercano)

Ese comportamiento es exactamente el que quiere el procesamiento de imagen: un cálculo de brillo que se pasa de 255 debe dar blanco, no envolver a negro. En C o en Rust esa saturación hay que escribirla a mano en cada operación; aquí es gratis.

El redondeo merece una nota: Uint8ClampedArray redondea al entero más cercano, y en los empates redondea al par. Es el mismo criterio del redondeo bancario y evita el sesgo sistemático que produce redondear siempre hacia arriba.

La consecuencia práctica es que no hace falta escribir Math.max(0, Math.min(255, valor)) en un filtro. Basta con asignar. Quitar esas dos llamadas de un bucle de millones de iteraciones se nota.

JS
// Innecesariodata[i] = Math.max(0, Math.min(255, data[i] * 1.4));// Suficiente y mas rapidodata[i] = data[i] * 1.4;

Vistas de 32 bits

Cuando un filtro trata cada píxel como una unidad —copiar, comparar, sustituir un color— es mucho más rápido operar con una vista de 32 bits sobre el mismo búfer:

JS
const datos = ctx.getImageData(0, 0, w, h);const p32 = new Uint32Array(datos.data.buffer);// Rellenar todos los pixeles de un color de un solo golpep32.fill(0xFF1E1E2E);    // ojo con el orden de bytes, ver abajo

Un solo acceso en lugar de cuatro, y las operaciones sobre enteros de 32 bits son las que mejor se optimizan. En filtros que no descomponen los canales, la mejora está entre dos y cuatro veces.

El problema es el orden de bytes. En una máquina little-endian —prácticamente todas hoy— el byte de menor peso del entero de 32 bits es el rojo, así que el valor entero se lee al revés: 0xAABBGGRR. En una máquina big-endian sería 0xRRGGBBAA. Depender de un orden concreto es un fallo de portabilidad que no se detecta hasta que alguien ejecuta el código en la arquitectura contraria.

La forma robusta es detectarlo:

JS
const esLittleEndian = (() => {  const b = new ArrayBuffer(4);  new Uint32Array(b)[0] = 0x11223344;  return new Uint8Array(b)[0] === 0x44;})();function empaquetar(r, g, b, a) {  return esLittleEndian    ? (a << 24) | (b << 16) | (g << 8) | r    : (r << 24) | (g << 16) | (b << 8) | a;}

Y el uso más frecuente de la vista de 32 bits ni siquiera necesita saber el orden: comparar píxeles enteros para saber si son iguales funciona igual en cualquier arquitectura.

JS
// Sustituir un color por otro: solo hacen falta dos valores empaquetadosconst buscar = empaquetar(255, 0, 0, 255);const poner  = empaquetar(0, 200, 100, 255);for (let i = 0; i < p32.length; i++) if (p32[i] === buscar) p32[i] = poner;

Un recorrido bien escrito

Reuniendo las reglas anteriores, así se escribe el esqueleto de cualquier filtro:

JS
function aplicarFiltro(ctx, x, y, ancho, alto, porPixel) {  const imagen = ctx.getImageData(x, y, ancho, alto);  const d = imagen.data;                 // variable local, no la propiedad  const n = d.length;  for (let i = 0; i < n; i += 4) {    porPixel(d, i);                       // sin crear objetos  }  ctx.putImageData(imagen, x, y);  return imagen;}// Uso: subir el contrasteconst factor = 1.35, centro = 128 * (1 - factor);aplicarFiltro(ctx, 0, 0, W, H, (d, i) => {  d[i]     = d[i]     * factor + centro;  d[i + 1] = d[i + 1] * factor + centro;  d[i + 2] = d[i + 2] * factor + centro;});

Ese porPixel como función pasada tiene un coste: en bucles de millones de iteraciones, una llamada por píxel puede ser significativa aunque el motor la incorpore en línea. Cuando el filtro es crítico, escribe el bucle en el sitio en lugar de abstraerlo. La abstracción es correcta mientras el tamaño sea moderado, y hay que estar dispuesto a deshacerla cuando el perfil lo pida.