Una imagen digital puede verse como una matriz de intensidades o un tensor de color. Filtros, ajustes y transformaciones geométricas se expresan mediante operaciones con vectores y matrices.
Procesar una imagen significa transformar sus valores para mejorarla, analizarla, comprimirla o producir un efecto visual.
El álgebra lineal proporciona modelos para los píxeles, los canales de color, los vecindarios y los cambios de coordenadas.
Una imagen de alto h y ancho w puede representarse mediante una matriz h × w. Cada elemento indica la intensidad de un píxel.
const imagen = [
[0, 64, 128],
[32, 128, 220],
[80, 180, 255]
];
console.log(imagen.length); // alto: 3
console.log(imagen[0].length); // ancho: 3
console.log(imagen[1][2]); // intensidad: 220
En imágenes de 8 bits, 0 suele representar negro y 255 blanco.
Un píxel RGB es un vector (r, g, b). Una imagen en color reúne alto, ancho y tres canales, por lo que puede considerarse un tensor h × w × 3.
| Representación | Forma |
|---|---|
| Escala de grises | h × w |
| RGB | h × w × 3 |
| RGBA | h × w × 4 |
| Lote de n imágenes RGB | n × h × w × 3 |
const pixel = [120, 200, 80];
const [rojo, verde, azul] = pixel;
console.log({ rojo, verde, azul });
Las API suelen almacenar píxeles consecutivos en un arreglo plano. Para una imagen RGBA, el índice del canal inicial del píxel (x, y) es:
function leerPixel(datos, ancho, x, y) {
const indice = 4 * (y * ancho + x);
return Array.from(datos.slice(indice, indice + 4));
}
const datos = new Uint8ClampedArray([
255, 0, 0, 255, 0, 255, 0, 255
]);
console.log(leerPixel(datos, 2, 1, 0)); // [0, 255, 0, 255]
Aumentar el brillo suma una constante b a cada canal y limita el resultado al intervalo permitido:
Si b ≠ 0, la operación es afín y no lineal, porque el píxel cero no se transforma en cero.
const limitarCanal = valor => Math.max(0, Math.min(255, Math.round(valor)));
const ajustarBrillo = (pixel, cambio) =>
pixel.map(canal => limitarCanal(canal + cambio));
console.log(ajustarBrillo([100, 220, 250], 30)); // [130, 250, 255]
El contraste puede modificarse alejando o acercando cada valor a un punto medio m:
function ajustarContraste(pixel, factor, medio = 128) {
return pixel.map(canal =>
Math.max(0, Math.min(255, Math.round(medio + factor * (canal - medio))))
);
}
console.log(ajustarContraste([80, 128, 180], 1.5)); // [56, 128, 206]
Un factor mayor que 1 aumenta el contraste; uno entre 0 y 1 lo reduce.
Una luminancia aproximada se obtiene mediante un producto escalar entre el color y pesos perceptuales:
function luminancia([r, g, b]) {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
console.log(luminancia([255, 0, 0])); // 54.213
console.log(luminancia([0, 255, 0])); // 182.376
console.log(luminancia([0, 0, 255])); // 18.411
En flujos con gestión precisa de color también debe considerarse si los canales están codificados o en espacio lineal.
Una matriz 3 × 3 mezcla los canales RGB:
Esta técnica permite intercambiar canales, ajustar saturación o cambiar espacios de color.
function transformarColor(M, color) {
return M.map(fila =>
fila.reduce((suma, valor, i) => suma + valor * color[i], 0)
);
}
const intercambiarRojoAzul = [[0, 0, 1], [0, 1, 0], [1, 0, 0]];
console.log(transformarColor(intercambiarRojoAzul, [200, 100, 50]));
// [50, 100, 200]
Para colocar un color frontal F con opacidad α sobre un fondo opaco B:
function mezclar(frente, fondo, alfa) {
const a = Math.max(0, Math.min(1, alfa));
return frente.map((canal, i) =>
Math.round(a * canal + (1 - a) * fondo[i])
);
}
console.log(mezclar([255, 0, 0], [0, 0, 255], 0.25));
// [64, 0, 191]
La composición entre capas semitransparentes requiere además combinar correctamente sus valores alfa.
Un núcleo es una matriz pequeña que se desplaza sobre la imagen. En cada posición se calcula un producto escalar entre sus pesos y el vecindario.
En la convolución matemática el núcleo se invierte. Muchas bibliotecas llaman “convolución” a la correlación cruzada, que aplica el núcleo sin invertir. Para núcleos simétricos ambas coinciden.
En los límites de la imagen faltan vecinos. Debe elegirse una política:
| Política | Idea |
|---|---|
| Ceros | Considerar negro fuera de la imagen |
| Extensión | Repetir el píxel más cercano del borde |
| Reflexión | Reflejar la imagen en el límite |
| Envoltura | Continuar desde el borde opuesto |
| Solo región válida | Producir una salida más pequeña |
function indiceExtendido(indice, longitud) {
return Math.max(0, Math.min(longitud - 1, indice));
}
console.log(indiceExtendido(-1, 5)); // 0
console.log(indiceExtendido(7, 5)); // 4
Un desenfoque de caja 3 × 3 reemplaza cada píxel por el promedio de su vecindario:
La suma de los pesos es 1, por lo que las regiones constantes conservan su brillo.
const nucleoDesenfoque = Array.from({ length: 3 }, () =>
Array(3).fill(1 / 9)
);
const sumaPesos = nucleoDesenfoque.flat()
.reduce((suma, valor) => suma + valor, 0);
console.log(sumaPesos); // aproximadamente 1
Un núcleo de enfoque aumenta la diferencia entre un píxel y sus vecinos:
Su suma también es 1. Los resultados pueden salir del intervalo [0, 255] y deben limitarse.
const nucleoEnfoque = [[0, -1, 0], [-1, 5, -1], [0, -1, 0]];
const vecindarioPlano = Array(9).fill(100);
const salida = nucleoEnfoque.flat().reduce(
(suma, peso, i) => suma + peso * vecindarioPlano[i],
0
);
console.log(salida); // 100
Los núcleos de Sobel aproximan cambios horizontales y verticales:
const gx = 30;
const gy = 40;
const magnitudBorde = Math.hypot(gx, gy);
const orientacion = Math.atan2(gy, gx) * 180 / Math.PI;
console.log(magnitudBorde); // 50
console.log(orientacion); // aproximadamente 53.13°
Esta implementación aplica un núcleo impar sin invertir y extiende los píxeles del borde.
function filtrar(imagen, nucleo) {
const alto = imagen.length;
const ancho = imagen[0].length;
const radioY = Math.floor(nucleo.length / 2);
const radioX = Math.floor(nucleo[0].length / 2);
return imagen.map((fila, y) => fila.map((_, x) => {
let suma = 0;
for (let ky = 0; ky < nucleo.length; ky++) {
for (let kx = 0; kx < nucleo[0].length; kx++) {
const iy = Math.max(0, Math.min(alto - 1, y + ky - radioY));
const ix = Math.max(0, Math.min(ancho - 1, x + kx - radioX));
suma += imagen[iy][ix] * nucleo[ky][kx];
}
}
return suma;
}));
}
const imagen = [[0, 0, 0], [0, 255, 0], [0, 0, 0]];
const promedio = Array.from({ length: 3 }, () => Array(3).fill(1 / 9));
console.log(filtrar(imagen, promedio));
Rotar, escalar o deformar una imagen equivale a cambiar las coordenadas de muestreo. Para cada píxel de salida se aplica la transformación inversa y se busca su origen.
El mapeo inverso evita huecos que aparecerían al enviar cada píxel original hacia su destino.
function aplicarHomografia(H, [x, y]) {
const [a, b, c] = H.map(fila => fila[0] * x + fila[1] * y + fila[2]);
return c === 0 ? null : [a / c, b / c];
}
const inversaTraslacion = [[1, 0, -5], [0, 1, 2], [0, 0, 1]];
console.log(aplicarHomografia(inversaTraslacion, [10, 10])); // [5, 12]
Una coordenada de origen rara vez cae exactamente sobre un píxel. La interpolación bilineal combina los cuatro vecinos según sus distancias.
function bilineal(q00, q10, q01, q11, tx, ty) {
const arriba = q00 * (1 - tx) + q10 * tx;
const abajo = q01 * (1 - tx) + q11 * tx;
return arriba * (1 - ty) + abajo * ty;
}
console.log(bilineal(0, 100, 200, 255, 0.5, 0.5)); // 138.75
El vecino más cercano es más rápido pero produce bordes escalonados; la interpolación bilineal genera transiciones más suaves.
La descomposición en valores singulares permite aproximar una matriz de imagen conservando solo los componentes más importantes. Una aproximación de rango k puede almacenar menos datos y eliminar parte del ruido.
const columna = [1, 2, 3];
const fila = [10, 20, 30, 40];
const imagenRangoUno = columna.map(a => fila.map(b => a * b));
console.log(imagenRangoUno);
// Toda la matriz se reconstruye a partir de dos vectores.
Para rendimiento se usan arreglos tipados, procesamiento por bloques, GPU y filtros separables cuando existen.
Errores frecuentes son intercambiar x e y, olvidar limitar canales, modificar alfa sin intención, mezclar espacios de color, usar una política de borde implícita, confundir convolución con correlación y mapear píxeles hacia adelante dejando huecos.
Representar imágenes como matrices convierte filtros, cambios de color y deformaciones en operaciones algebraicas. La misma perspectiva permite optimizar algoritmos y razonar sobre sus resultados.
En el próximo tema estudiaremos cómo la visión por computadora utiliza estas operaciones para extraer información del mundo visual.