wandres.dev
RASTER Y VECTOR · Los dos modelos de imagen

Qué es de verdad una imagen raster

Entender la rejilla de píxeles por dentro: canales, profundidad de bits, alfa premultiplicado y espacio de color, y por qué cada uno de esos detalles acaba apareciendo en el canvas.

⏱ 18 min

Un raster es una rejilla de muestras de color, y esa frase de nueve palabras contiene cuatro decisiones de diseño que casi nunca se explican: cuántas muestras, cuántos canales, cuántos bits por canal y en qué espacio de color están esos números. El canvas 2D toma una postura concreta en las cuatro, y conocerla explica desde por qué un degradado tiene bandas hasta por qué leer y escribir píxeles no siempre devuelve exactamente lo que escribiste.

🎯 Al terminar esta lección sabrás
  • Describir la estructura de una imagen raster en términos de rejilla, canales y profundidad.
  • Explicar qué es el alfa premultiplicado y dónde aparece dentro del canvas.
  • Calcular la memoria que ocupa un búfer de píxeles a partir de sus dimensiones.
  • Distinguir el espacio de color del búfer del espacio de color de los valores que escribes.

La rejilla y sus muestras

Una imagen raster es una función discreta: para cada par de coordenadas enteras devuelve un color. No hay nada entre el píxel (3, 7) y el (4, 7), del mismo modo que no hay nada entre dos fotogramas de una película. Cuando el sistema necesita un valor en una posición intermedia —porque escalas, rotas o transformas— tiene que inventarlo interpolando los vecinos, y de ahí sale toda la fenomenología de la borrosidad y los artefactos.

Esa discretización tiene una consecuencia que conviene interiorizar pronto: el píxel no es un cuadradito, es una muestra. La representación como cuadrados de color es una convención de visualización cómoda, pero el modelo matemático es el de un punto de muestreo con un filtro de reconstrucción asociado. Por eso las reglas de alineación en el canvas hablan de coordenadas que caen entre muestras, no de cuadrados que se solapan, y por eso una línea de un píxel de grosor dibujada en una coordenada entera se reparte entre dos columnas.

El canvas 2D usa una convención concreta que hay que memorizar: las coordenadas enteras caen en las esquinas de los píxeles, no en sus centros. El centro del píxel superior izquierdo está en (0.5, 0.5). Esta convención es la misma que usan la mayoría de las APIs gráficas y es coherente con el modelo de muestreo, pero choca con la intuición de quien viene de manipular arrays de imágenes, donde el índice [0][0] se piensa como “el primer píxel”.

Canales, profundidad y alfa

Cada muestra del canvas tiene cuatro canales: rojo, verde, azul y alfa, en ese orden. Y ocho bits por canal, es decir, 256 niveles por componente. Eso significa que un canvas 2D estándar no puede representar más de dieciséis millones y pico de colores, ni transparencias más finas que un 0,4 %.

Las bandas visibles en un degradado suave y largo son consecuencia directa de esa cuantización: si repartes 256 niveles de gris a lo largo de mil píxeles, cada nivel ocupa casi cuatro columnas y el ojo detecta el escalón. La solución clásica es añadir ruido de muy baja amplitud —un dither— para romper el borde de cada banda.

// Ruido sutil sobre un degradado para romper las bandas
const g = ctx.createLinearGradient(0, 0, 800, 0);
g.addColorStop(0, '#11111b');
g.addColorStop(1, '#585b70');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 800, 200);

ctx.globalAlpha = 0.025;
for (let i = 0; i < 20000; i++) {
  ctx.fillStyle = Math.random() < 0.5 ? '#000' : '#fff';
  ctx.fillRect(Math.random() * 800 | 0, Math.random() * 200 | 0, 1, 1);
}
ctx.globalAlpha = 1;

El alfa merece atención aparte porque tiene dos representaciones y el canvas usa las dos en sitios distintos. En la representación directa, cada canal de color guarda su valor tal cual y el alfa va aparte: un rojo puro al 50 % es (255, 0, 0, 128). En la representación premultiplicada, los canales de color ya vienen multiplicados por el alfa: el mismo rojo sería (128, 0, 0, 128).

La premultiplicada es la que usan internamente casi todos los motores de composición, porque hace que la fórmula de mezcla sea una simple combinación lineal sin multiplicaciones repetidas y evita los halos oscuros en los bordes al interpolar. El búfer del canvas está premultiplicado. Pero ImageData —el objeto que obtienes al leer píxeles— no lo está: la especificación dice que sus valores son alfa no premultiplicado.

El viaje de ida y vuelta por ImageData no es idempotente

De esa asimetría sale un comportamiento que desconcierta a todo el que escribe filtros: leer píxeles con getImageData y volver a escribirlos con putImageData sin tocar nada puede cambiar la imagen. La razón es que el búfer guarda valores premultiplicados en ocho bits, la lectura los des-premultiplica dividiendo por el alfa, y la escritura los vuelve a multiplicar. Con alfa igual a 255 la operación es exacta, pero con alfa bajo se pierde precisión de forma brutal: un píxel con alfa 1 de 255 solo puede distinguir 256 valores de color divididos por 255, así que el color de vuelta se redondea a un múltiplo grosero. Un píxel casi transparente pierde prácticamente toda su información de color en cada viaje. La consecuencia práctica es doble. Primera: no uses getImageData/putImageData como si fuera una copia sin pérdidas; para copiar píxeles tal cual está drawImage, que se queda en el espacio premultiplicado y no pierde nada. Segunda: si escribes un filtro que se aplica repetidamente sobre zonas semitransparentes, la degradación se acumula fotograma a fotograma. Cuando veas que las zonas suaves de tu efecto se van ensuciando con el tiempo, ya sabes de dónde viene.

La memoria, sin misterio

El coste en memoria de un búfer raster es trivial de calcular y sorprendentemente fácil de subestimar:

bytes = ancho × alto × 4

Cuatro bytes por píxel, sin compresión, siempre. Un canvas de 1920 por 1080 ocupa 8,3 MB. El mismo canvas en una pantalla con densidad doble, correctamente dimensionado, tiene un búfer de 3840 por 2160 y ocupa 33 MB. Cuatro veces más, porque la memoria crece con el cuadrado de la escala lineal.

Tamaño del búfer Megapíxeles Memoria
800 × 600 0,48 1,9 MB
1920 × 1080 2,07 8,3 MB
3840 × 2160 8,29 33,2 MB
8000 × 8000 64 256 MB

Esa tabla explica varias cosas a la vez. Explica por qué los navegadores imponen un límite de área máxima al canvas. Explica por qué tener veinte canvas de caché vivos puede tirar una pestaña en un móvil. Y explica por qué la estrategia de “hago el canvas enorme y luego lo escalo con CSS” es una de las peores ideas posibles: el coste está en el búfer, no en lo que se ve.

Conviene añadir que ese cálculo es el mínimo teórico. Un canvas acelerado vive en memoria de vídeo y puede tener copias adicionales durante la composición, y el navegador puede mantener búferes intermedios para ciertas operaciones. La cifra real está por encima, nunca por debajo.

El espacio de color, que también está ahí

Durante mucho tiempo el canvas fue implícitamente sRGB y nadie tenía que pensarlo. Hoy el contexto acepta una opción de espacio de color y ImageData también, con dos valores posibles: "srgb" y "display-p3".

const ctx = canvas.getContext('2d', { colorSpace: 'display-p3' });
ctx.fillStyle = 'color(display-p3 1 0 0)';
ctx.fillRect(0, 0, 100, 100);

La distinción importante es entre el espacio del búfer y el espacio de los valores que escribes. El búfer tiene un espacio fijo, decidido al crear el contexto. Los colores que le das a fillStyle se especifican con la sintaxis de color de CSS, que admite espacios distintos, y se convierten al espacio del búfer al pintar. Si el búfer es sRGB y le das un rojo de P3, ese rojo se recorta al gamut de sRGB y pierde saturación irreversiblemente.

Hay una segunda asimetría: los valores de ImageData son números de ocho bits en el espacio del búfer, sin ninguna etiqueta de color más allá de la propiedad colorSpace del objeto. Cuando escribes un filtro que opera sobre esos números, estás operando sobre valores codificados con la curva de transferencia de sRGB, que no es lineal. Promediar dos píxeles sumando sus valores y dividiendo por dos no da la media física de luz, da algo más oscuro. Es la razón de que los desenfoques hechos a mano en el espacio codificado se vean más apagados que los del navegador, que trabaja en luz lineal. Si el resultado importa, hay que linealizar antes de promediar y volver a codificar después.