Saltar al contenido

Filtros a mano: escala de grises, umbral y curvas

Escribir los filtros por píxel que no necesitan vecindad, con la aritmética correcta de luminancia y el uso de tablas de consulta para acelerarlos.

Escala de grises: no es la media

La conversión ingenua promedia los tres canales:

JS
const gris = (r + g + b) / 3;      // INCORRECTO desde el punto de vista perceptual

El ojo humano no percibe los tres primarios con la misma intensidad: es mucho más sensible al verde que al rojo, y mucho menos al azul. La fórmula que refleja esa sensibilidad es la de luminancia, con los coeficientes de la recomendación BT.709, que es el espacio de la web:

JS
const luz = 0.2126 * r + 0.7152 * g + 0.0722 * b;

La diferencia es evidente en cualquier imagen con colores saturados: con el promedio, un rojo puro y un verde puro dan el mismo gris, lo cual es visualmente absurdo porque el verde se percibe muchísimo más claro.

JS
function escalaDeGrises(ctx, x, y, w, h) {  const img = ctx.getImageData(x, y, w, h);  const d = img.data;  for (let i = 0; i < d.length; i += 4) {    const l = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];    d[i] = d[i + 1] = d[i + 2] = l;  }  ctx.putImageData(img, x, y);}

Existen otros conjuntos de coeficientes —los de BT.601, con 0,299, 0,587 y 0,114— que se usan en vídeo estándar. Para contenido web, los de BT.709 son los correctos.

Umbral y posterización

El umbral convierte cada píxel en blanco o negro según supere un valor:

JS
function umbral(ctx, x, y, w, h, nivel = 128) {  const img = ctx.getImageData(x, y, w, h);  const d = img.data;  for (let i = 0; i < d.length; i += 4) {    const l = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];    const v = l >= nivel ? 255 : 0;    d[i] = d[i + 1] = d[i + 2] = v;  }  ctx.putImageData(img, x, y);}

El umbral duro pierde muchísima información. La alternativa clásica es el umbral con difusión de error, que reparte el error de redondeo entre los píxeles vecinos y consigue reproducir tonos intermedios con solo dos colores:

JS
function floydSteinberg(ctx, x, y, w, h, niveles = 2) {  const img = ctx.getImageData(x, y, w, h);  const d = img.data;  const paso = 255 / (niveles - 1);  // Trabajamos sobre luminancia en un array de floats para acumular el error  const luz = new Float32Array(w * h);  for (let i = 0, p = 0; i < d.length; i += 4, p++) {    luz[p] = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];  }  for (let fy = 0; fy < h; fy++) {    for (let fx = 0; fx < w; fx++) {      const p = fy * w + fx;      const viejo = luz[p];      const nuevo = Math.round(viejo / paso) * paso;      luz[p] = nuevo;      const error = viejo - nuevo;      // Repartir el error segun el nucleo de Floyd-Steinberg      if (fx + 1 < w)              luz[p + 1]         += error * 7 / 16;      if (fy + 1 < h) {        if (fx > 0)                luz[p + w - 1]     += error * 3 / 16;                                   luz[p + w]         += error * 5 / 16;        if (fx + 1 < w)            luz[p + w + 1]     += error * 1 / 16;      }    }  }  for (let i = 0, p = 0; i < d.length; i += 4, p++) {    d[i] = d[i + 1] = d[i + 2] = luz[p];  }  ctx.putImageData(img, x, y);}

Ese algoritmo es de 1976 y sigue siendo el que se usa para convertir imágenes a paletas reducidas. El array de flotantes intermedio es imprescindible: si acumularas el error sobre el array de ocho bits, la saturación lo destruiría.

Tablas de consulta

Cualquier filtro que transforme un canal de forma independiente —brillo, contraste, gamma, curvas, inversión, posterización— se puede precalcular en una tabla de 256 entradas y aplicarse con un solo acceso por canal.

JS
function tablaDeCurva(fn) {  const t = new Uint8ClampedArray(256);  for (let v = 0; v < 256; v++) t[v] = fn(v);  return t;}function aplicarTabla(ctx, x, y, w, h, tR, tG = tR, tB = tR) {  const img = ctx.getImageData(x, y, w, h);  const d = img.data;  for (let i = 0; i < d.length; i += 4) {    d[i]     = tR[d[i]];    d[i + 1] = tG[d[i + 1]];    d[i + 2] = tB[d[i + 2]];  }  ctx.putImageData(img, x, y);}// Gammaconst gamma = g => tablaDeCurva(v => 255 * Math.pow(v / 255, 1 / g));// Contraste en S: sube las luces, baja las sombrasconst contrasteS = tablaDeCurva(v => {  const t = v / 255;  return 255 * (t * t * (3 - 2 * t));});// Virado sepia por canalaplicarTabla(ctx, 0, 0, W, H,  tablaDeCurva(v => v * 1.07 + 12),  tablaDeCurva(v => v * 0.98),  tablaDeCurva(v => v * 0.82 - 6));

La ganancia es doble. Se sustituyen operaciones caras —potencias, raíces, funciones trigonométricas— por un acceso a un array de 256 bytes que cabe entero en la caché de primer nivel. Y la tabla se calcula una vez aunque el filtro se aplique a millones de píxeles o en cada fotograma de un vídeo.

Es la misma técnica que usan los ajustes de un programa de edición fotográfica: la curva que el usuario dibuja se convierte en una tabla, y aplicar la curva a una imagen de treinta megapíxeles es tan barato como aplicar una inversión.

Un panel de ajustes completo

HTML
<!doctype html><meta charset="utf-8"><style>  body { background:#1e1e2e; color:#cdd6f4; font:14px system-ui; }  label { display:block; margin:.4rem 0; }</style><canvas id="c" width="480" height="320"></canvas><label>Brillo <input id="brillo" type="range" min="-100" max="100" value="0"></label><label>Contraste <input id="contraste" type="range" min="0" max="300" value="100"></label><label>Saturación <input id="sat" type="range" min="0" max="200" value="100"></label><script>  const ctx = document.getElementById('c').getContext('2d', { willReadFrequently: true });  let original = null;  // Imagen de prueba generada por codigo, para que el ejemplo sea autonomo  (function generar() {    const g = ctx.createLinearGradient(0, 0, 480, 320);    g.addColorStop(0, '#f38ba8'); g.addColorStop(0.5, '#a6e3a1');    g.addColorStop(1, '#89b4fa');    ctx.fillStyle = g; ctx.fillRect(0, 0, 480, 320);    for (let i = 0; i < 40; i++) {      ctx.fillStyle = `hsl(${i * 9} 70% ${20 + i}%)`;      ctx.beginPath();      ctx.arc(Math.random() * 480, Math.random() * 320, 6 + Math.random() * 30, 0, 7);      ctx.fill();    }    original = ctx.getImageData(0, 0, 480, 320);  })();  function aplicar() {    const b = +brillo.value;    const c = +contraste.value / 100;    const s = +sat.value / 100;    const tabla = new Uint8ClampedArray(256);    for (let v = 0; v < 256; v++) tabla[v] = (v - 128) * c + 128 + b;    const img = new ImageData(new Uint8ClampedArray(original.data), 480, 320);    const d = img.data;    for (let i = 0; i < d.length; i += 4) {      let r = tabla[d[i]], g2 = tabla[d[i + 1]], b2 = tabla[d[i + 2]];      const luz = 0.2126 * r + 0.7152 * g2 + 0.0722 * b2;      d[i]     = luz + (r  - luz) * s;      d[i + 1] = luz + (g2 - luz) * s;      d[i + 2] = luz + (b2 - luz) * s;    }    ctx.putImageData(img, 0, 0);  }  for (const el of [brillo, contraste, sat]) el.addEventListener('input', aplicar);</script>

Fíjate en la saturación: es una interpolación entre el color y su luminancia. Con factor 0 da escala de grises, con 1 deja el color intacto y con valores mayores que 1 lo exagera. Es la fórmula estándar y cabe en tres líneas.