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.
Los filtros que solo dependen del píxel actual —brillo, contraste, escala de grises, umbral, inversión, ajuste de curvas— son los más sencillos de escribir y los que más se escriben mal. La aritmética de la luminancia no es un promedio, el ajuste de brillo no es una suma, y la técnica que los hace rápidos de verdad es tan vieja como los gráficos por ordenador: una tabla de consulta.
- Convertir a escala de grises con la fórmula de luminancia perceptual correcta.
- Implementar umbral, inversión y posterización.
- Construir tablas de consulta para acelerar cualquier filtro por canal.
- Explicar por qué operar en el espacio codificado da resultados distintos al esperado.
Escala de grises: no es la media
La conversión ingenua promedia los tres canales:
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:
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.
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:
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:
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.
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);
}
// Gamma
const gamma = g => tablaDeCurva(v => 255 * Math.pow(v / 255, 1 / g));
// Contraste en S: sube las luces, baja las sombras
const contrasteS = tablaDeCurva(v => {
const t = v / 255;
return 255 * (t * t * (3 - 2 * t));
});
// Virado sepia por canal
aplicarTabla(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.
Aquí está el error conceptual que hace que los filtros escritos a mano se vean distintos de los del navegador, y que casi nunca se explica. Los valores de un ImageData no son cantidades de luz: son valores codificados con la función de transferencia de sRGB, que es aproximadamente una potencia de 2,2. Un valor de 128 no es la mitad de luz que 255; es alrededor del 21 %. Esa codificación existe por una buena razón: reparte los 256 niveles disponibles de forma perceptualmente uniforme, dando más resolución a las sombras, que es donde el ojo distingue mejor. La consecuencia es que cualquier operación que promedie o mezcle valores está haciendo la media en el espacio equivocado. Promediar 0 y 255 da 128, que se percibe mucho más oscuro que la mezcla física de negro y blanco, que sería 188 en valores codificados. Por eso los desenfoques hechos a mano salen más oscuros y sucios que los del navegador, por eso los degradados entre colores complementarios pasan por un gris apagado, y por eso reducir una imagen a la mitad promediando valores codificados la oscurece ligeramente. La corrección es linealizar antes y volver a codificar después, y con tablas de consulta cuesta muy poco:
const aLineal = new Float32Array(256);
for (let v = 0; v < 256; v++) {
const c = v / 255;
aLineal[v] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
const aCodificado = new Uint8ClampedArray(4096);
for (let i = 0; i < 4096; i++) {
const c = i / 4095;
aCodificado[i] = 255 * (c <= 0.0031308 ? c * 12.92
: 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
}
// Promediar dos pixeles correctamente
const medio = aCodificado[Math.round((aLineal[a] + aLineal[b]) / 2 * 4095)];Ahora la parte matizada, porque no todo filtro necesita esto. Los filtros que aplican una curva monótona por canal —brillo, contraste, gamma, sepia— están diseñados para operar en el espacio codificado y ahí funcionan bien; linealizarlos los estropea. Los que mezclan píxeles —desenfoque, reducción, media, convolución, transparencia— sí necesitan luz lineal para dar un resultado físicamente correcto. La regla es sencilla de recordar: si tu filtro suma píxeles distintos, lineariza; si solo transforma cada valor por separado, no.
Un panel de ajustes completo
<!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.