wandres.dev
PÍXELES · ImageData y el acceso directo

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.

⏱ 16 min

El array de un ImageData es plano, de ocho bits por canal y en orden rojo, verde, azul, alfa, fila a fila y de arriba abajo. Esa disposición determina la aritmética de índices que hay que escribir en cada filtro, y el tipo del array —con saturación en lugar de desbordamiento— evita una categoría entera de errores que en otros lenguajes hay que gestionar a mano.

🎯 Al terminar esta lección sabrás
  • Calcular el índice de un píxel a partir de sus coordenadas y viceversa.
  • Explicar qué hace Uint8ClampedArray con los valores fuera de rango.
  • Usar vistas de 32 bits sobre el mismo búfer y sus implicaciones de orden de bytes.
  • Recorrer el array de forma eficiente y evitar los patrones lentos.

La aritmética de índices

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:

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.

// Rapido: filas contiguas
for (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 paso
for (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:

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:

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.

// Innecesario
data[i] = Math.max(0, Math.min(255, data[i] * 1.4));
// Suficiente y mas rapido
data[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:

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 golpe
p32.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:

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.

// Sustituir un color por otro: solo hacen falta dos valores empaquetados
const 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;
El cuello de botella de un filtro casi nunca es la aritmética: son los accesos

Cuando un filtro va lento, la reacción instintiva es simplificar las operaciones matemáticas. Casi siempre es el sitio equivocado. Un canvas de 1920 por 1080 tiene dos millones de píxeles y ocho millones de bytes; el bucle hace del orden de decenas de millones de accesos a memoria, y las multiplicaciones que hay en medio son prácticamente gratis comparadas con eso. Lo que de verdad cuesta se reparte en cuatro sitios y conviene atacarlos en este orden. Uno, la propiedad data. imageData.data es un getter, y leerlo dentro del bucle puede impedir optimizaciones del motor. Guárdalo en una variable local antes de empezar: es la optimización de una línea que más veces da un veinte por ciento. Dos, el recorrido por columnas. Cambiar el orden de los dos bucles anidados para recorrer filas contiguas mejora la localidad de caché de forma medible. Tres, la creación de objetos dentro del bucle. Un objeto {r, g, b} por píxel son dos millones de asignaciones que el recolector tendrá que limpiar; trabaja con números sueltos. Cuatro, y solo entonces, la aritmética: precalcula lo que no dependa del píxel, sustituye divisiones por multiplicaciones por el inverso, y usa desplazamientos de bits para las divisiones por potencias de dos. Y hay una quinta consideración que supera a todas: si el filtro es caro de verdad, el sitio correcto no es JavaScript. Un desenfoque grande, una convolución con núcleo amplio o una mezcla de varias capas se hacen en un sombreador de fragmentos, donde el paralelismo es de miles de píxeles a la vez. La regla que resume el nivel: JavaScript para filtros por píxel independientes y sencillos, GPU para todo lo demás.

Un recorrido bien escrito

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

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 contraste
const 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.