pixel-playground

Escribir una imagen
para entender la máquina

Un taller de 3 horas donde no se aprende netpbm. Se aprende ciencias de la computación, y netpbm es el bisturí porque su kernel de convolución se escribe como texto legible, no como un blob binario aparte. Una imagen es un array de números y una promesa sobre su forma. Todo filtro es una matriz chica que se desliza sobre esos números. Todo glitch es la promesa incumplida.

Duración
3 horas
Nivel
Cero conocimiento de imágenes. Terminal básica.
Herramientas
netpbm, ffmpeg, un navegador, un editor de texto
Te llevás
La matriz 3×3 que escribiste a mano — que es la primera capa de una red convolucional
Grilla de 2 por 2 ampliada 64 veces sin interpolar: cuadrado negro arriba a la izquierda y abajo a la derecha, blanco en las otras dos celdas
Esto es mano.pbm: 15 bytes escritos con printf, sin ningún programa de dibujo, ampliados 64 veces sin suavizar para que se vean los cuatro píxeles. Es la imagen más simple que existe y ya alcanza para romper una intuición: en este formato, el 1 es negro.

01 La idea

El taller anterior de esta serie rompía video para entender un decodificador. Este va más despacio: una imagen fija no se mueve, no tiene tiempo, no tiene fotogramas P que dependan de nada. Es la unidad más chica en la que todavía se puede ver, con los ojos, qué le hace un algoritmo a un número.

Y sin embargo una imagen fija ya contiene casi todo lo que hace falta para entender visión por computadora: representación de datos, cabeceras como contratos, y una operación —la convolución— que aparece sin cambios, con otro nombre, en cada red neuronal que reconoce una cara o maneja un auto solo.

El pago final está al fondo del taller, en el módulo 7, pero conviene decirlo ahora para que cada ejercicio se sienta como un paso hacia ahí: la matriz de 3×3 que vas a escribir a mano en el módulo 2 es la primera capa de una red convolucional. No es una analogía educativa. Es la misma operación, con el mismo nombre, del mismo tamaño que usan muchas capas reales.

02 Preparación

Antes de arrancar, cada persona necesita esto resuelto. No gastes tiempo del taller instalando cosas.

# Debian / Ubuntu
sudo apt install netpbm ffmpeg

# macOS
brew install netpbm ffmpeg

Esta máquina tiene netpbm 11.5.2 (paquete Debian, compilado 2024-03-31), con 291 programas en /usr/bin, y ffmpeg 6.1.1. Todos los comandos de este taller están probados con esas versiones exactas.

El widget interactivo del módulo 2 no necesita servidor: la imagen fuente ya está incrustada como data URI en fuente-data.js, así que index.html funciona abriéndolo directo con doble clic. Esto no es un detalle menor — es la respuesta a un problema real de seguridad del navegador que el módulo 2 explica.

04 El taller

Nueve módulos, 3 horas, terminal y un navegador. Cada módulo tiene la misma estructura: un concepto de ciencias de la computación, algo que hacés con las manos, y el momento en que las dos cosas encajan.

Módulo 0

Una imagen escrita a mano

15 min

Concepto Representación mínima de datos, un formato de texto legible

printf 'P1\n2 2\n1 0\n0 1\n' > mano.pbm
pamfile mano.pbm
PBM plain, 2 by 2

mano.pbm pesa 15 bytes y es una imagen válida. pamfile la reconoce sin que le digas nada más. Convertila a algo que un navegador entienda:

pnmtopng mano.pbm > mano.png

69 bytes de PNG, cuatro píxeles. Ahora la trampa, la que todo el mundo pisa la primera vez:

El momento En P1, el 1 significa negro. Es lo opuesto de lo que asume cualquiera que venga de pensar en "1 = encendido = blanco". El formato no tiene ninguna obligación de coincidir con tu intuición; solo tiene que ser consistente con su propia especificación. Grabate esto ahora, porque el módulo 1 va a hacer la misma pregunta con la cabecera entera.

Dos formatos más de la misma familia, para tener el vocabulario completo antes de seguir:

printf 'P2\n4 2\n255\n0 85 170 255\n255 170 85 0\n' > gris.pgm
pamfile gris.pgm

printf 'P3\n2 2\n255\n255 0 0  0 255 0\n0 0 255  255 255 0\n' > color.ppm
pamfile color.ppm
gris.pgm:  PGM plain, 4 by 2  maxval 255
color.ppm: PPM plain, 2 by 2  maxval 255

P1 (bitmap, un bit), P2 (grises, un valor por píxel) y P3 (color, tres valores por píxel) son la misma idea con más números por celda. Nada de esto necesitó una cámara, un escáner ni un programa de dibujo.

Módulo 1

La cabecera es una promesa

20 min

Concepto Parsing, framing, contratos binarios

La imagen fuente del resto del taller no la escribimos a mano: la genera ffmpeg, porque necesitamos gradientes reales, no barras planas.

ffmpeg -f lavfi -i "mandelbrot=s=256x256:r=1" -frames:v 1 -pix_fmt gray fuente.pgm
Fractal de Mandelbrot en escala de grises con el borde fractal en detalle fino, sombreado que va de gris claro en el fondo a negro en el interior
256×256, escala de grises. Esta es la imagen fuente de casi todo el resto del taller.

Mirá los primeros bytes del archivo, no como imagen sino como bytes:

head -c 15 fuente.pgm | xxd
5035 0a32 3536 2032 3536 0a32 3535 0a

Convertido a ASCII eso dice P5\n256 256\n255\n: magic number, ancho y alto, maxval. Las dimensiones están escritas en texto legible dentro de un archivo binario. No necesitás ningún parser para leerlas — head alcanza.

fuente.pgm pesa 65.551 bytes: 15 de cabecera más 65.536 de datos. Y 256×256 = 65.536 exacto, ni un byte de más. La versión en color de la misma fuente pesa 196.623 bytes = 15 + 196.608, y 256×256×3 = 196.608. Cuadra siempre, porque la cabecera es una promesa sobre cuántos bytes vienen después, y nada en el formato la verifica más allá de que el archivo alcance a tener esa cantidad.

Un dato para que quede grabado: mano.pbm del módulo 0 pesa 15 bytes. La cabecera de una imagen de 65 KB también pesa 15 bytes. El tamaño de la cabecera no tiene ninguna relación con el tamaño de la imagen — es una promesa de longitud fija sobre un cuerpo de longitud variable.

El momento No existe "el archivo de imagen" como una cosa monolítica. Existen bytes y un acuerdo de texto plano sobre cómo interpretarlos. Ese acuerdo se puede leer, y —como vas a ver en el módulo 6— se puede mentir.

Módulo 2

El kernel

35 min — el módulo central

Concepto Convolución, campo receptivo, kernel como programa

pnmconvol aplica una matriz chica —el kernel— sobre cada píxel, multiplicando cada vecino por un peso y sumando. Las filas se separan con ;, los valores con ,. Ejecutalo sin argumentos:

pnmconvol
pnmconvol: You must specify either -matrix or -matrixfile at least one argument which names an old-style PGM convolution matrix file.

Esa frase, "old-style PGM convolution matrix file", no es un accidente de traducción: es un artefacto histórico real. El kernel se guardaba antes como una imagen en sí misma — una PGM chica donde cada píxel era un peso. El kernel es una imagen y la imagen es un kernel. La operación de este módulo entera cabe en esa frase.

La invocación real:

pnmconvol -matrix=0.0625,0.125,0.0625;0.125,0.25,0.125;0.0625,0.125,0.0625 fuente.pgm > salida.pgm

Esos números que acabás de escribir a mano son los mismos que usa la evidencia de esta página: la tabla siguiente sale de leer los bytes crudos de cada .pgm con numpy (2.4.4) y calcular media, desvío estándar y diferencia contra el original sobre la imagen completa.

KernelSumaMediaDesvíodifRMS
original146,741,10,00
identidad1146,741,10,00
gauss1146,739,87,47
realce1146,450,522,05
relieve1147,455,039,02
bordes08,032,5147,07
saturado9253,710,6115,32

Leé esa tabla con cuidado, porque adentro están tres lecciones:

  • La identidad da difRMS exactamente 0,00. Es el control del experimento: prueba que la matriz es la operación, no una aproximación de ella.
  • Todo kernel que suma 1 conserva la media (146,4 a 147,4, contra 146,7 del original). El que suma 9 la destruye para arriba (253,7, casi blanco). El que suma 0 la colapsa (8,0, casi negro). El módulo 3 explica por qué.
  • El desvío estándar es un proxy de "cuánto detalle queda". El desenfoque lo baja (41,1 → 39,8). El realce y el relieve lo suben (50,5 y 55,0). El efecto de un filtro es legible en un solo número.
Fractal de Mandelbrot en escala de grises con el borde fractal en detalle fino, sombreado que va de gris claro en el fondo a negro en el interior
Original Desvío 41,1.
El mismo fractal desenfocado: bordes suavizados, sin detalle fino, transición de grises más gradual
Gauss Desvío 39,8. Bajó: menos detalle.
El fractal con los bordes acentuados: mismo tono de gris de fondo pero con más contraste y contornos más marcados que el original
Realce Desvío 50,5.
El fractal con efecto de relieve: reflejos claros en los bordes superiores izquierdos y sombras oscuras en los inferiores derechos, aspecto de bajorrelieve
Relieve Desvío 55,0. El más alto de la tabla.
Fondo negro casi total con solo los contornos del fractal marcados en trazos blancos finos, sin relleno
Bordes Media 8,0, contra 146,7 del original. Suma 0: este kernel mide cambio, no nivel — el módulo 3 explica por qué eso está bien.

Una nota al margen sobre lo que hace pnmconvol por adentro: si le das un kernel de puros unos (una caja), no hace 9 multiplicaciones por píxel. Detecta la forma y lo optimiza:

pnmconvol: Convolution is a simple mean horizontally and vertically

Probalo en el navegador, corré el mismo comando en la terminal

El widget de abajo hace exactamente la misma convolución que pnmconvol, en JavaScript, sobre la misma imagen fuente (incrustada en fuente-data.js para que funcione sin servidor). Las estadísticas del widget se calculan sobre el interior de 254×254 píxeles (64.516 en total), descartando el borde de 1 píxel, para que sean directamente comparables entre el canvas y la terminal sin que la política de borde de cada implementación meta ruido. Por eso no van a coincidir dígito por dígito con la tabla de arriba, que mide la imagen completa: son dos mediciones distintas, hechas a propósito así.

Esta comparación se hizo dos veces, en serio, con el mismo kernel gauss: en el navegador vía canvas.getImageData sobre el interior, y en la terminal con pnmconvol sobre la misma región. Los dos dieron media 146,3759 y desvío 39,9821. Delta: 0,0000 en las dos estadísticas. El slider del navegador y el comando de la terminal son la misma operación.

Módulo 3

La matriz tiene que sumar 1

20 min

Concepto Ganancia DC, invariantes de un filtro

-matrix no normaliza nada por vos. El kernel "saturado" de la tabla anterior es una caja de unos, suma 9, y por eso la salida es 9 veces más brillante y satura contra el techo: la media salta de 146,7 a 253,7.

Cuadrado casi blanco uniforme, sin apenas detalle visible: el resultado de no normalizar un kernel cuyos pesos suman 9
El error que todo el mundo comete la primera vez: un kernel bien escrito, con la ganancia DC sin corregir.

-normalize existe exactamente para esto: divide la salida por la suma de los pesos. Se puede hacer a mano o dejar que lo haga el programa, y da lo mismo — verificado:

# A mano: los pesos ya vienen divididos por 16
pnmconvol -matrix=0.0625,0.125,0.0625;0.125,0.25,0.125;0.0625,0.125,0.0625 fuente.pgm > a.pgm

# Con -normalize: los mismos pesos, sin dividir, y el programa divide por vos
pnmconvol -matrix=1,2,1;2,4,2;1,2,1 -normalize fuente.pgm > b.pgm

a.pgm y b.pgm son idénticos, byte a byte. -normalize no es una aproximación de la división manual: es la misma operación.

La excepción es a propósito y está en la misma tabla: el kernel "bordes" suma 0, y su media colapsa a 8,0. No es un bug — un detector de bordes mide cambio, no nivel, así que se supone que destruye la media de una imagen sin bordes. Sumar 1 es la regla para filtros que preservan brillo; sumar 0 es la firma de los que miden diferencia.

El momento La suma de los pesos de un kernel no es un detalle aritmético: es una declaración de intención. Suma 1 dice "voy a describir la misma imagen, más suave o más dura". Suma 0 dice "no me importa el nivel, me importa el cambio". Cualquier otra suma, la mayoría de las veces, es un error — y -normalize es la red de seguridad, verificada idéntica a hacerlo a mano.

Módulo 4

Un bit por píxel

20 min

Concepto Cuantización extrema, difusión de error, profundidad de bits

Misma imagen, misma profundidad — un bit por píxel, blanco o negro, nada más— con dos algoritmos de tramado distintos:

pamditherbw -atkinson fuente.pgm > d-atkinson.pbm
pamditherbw -hilbert  fuente.pgm > d-hilbert.pbm
El fractal reducido a un bit por píxel con difusión de error Atkinson: solo negro y blanco, patrón de puntos con textura diagonal
Atkinson Como PNG: 5.149 bytes, con -randomseed=1.
El mismo fractal a un bit por píxel con difusión de error por curva de Hilbert: solo negro y blanco, patrón de puntos más denso y parejo que el de Atkinson
Hilbert Como PNG: 6.598 bytes.

Mismo bit por píxel, mismo total de píxeles, y sin embargo Hilbert pesa 1,28× más que Atkinson una vez comprimido. El patrón que a simple vista parece más prolijo y más parejo es, para un compresor, más difícil de predecir. Es la tesis del taller hermano —comprimir es apostar a que los datos son predecibles— reapareciendo en el caso más extremo de profundidad de bits que existe: uno.

También están disponibles y verificados -fs, -floyd y -threshold, cada uno con su propia firma de ruido.

El momento "Un bit por píxel" no describe cuánta información tiene una imagen — solo describe cuántos estados tiene cada casillero. Cuánto pesa de verdad depende de qué tan predecible es el patrón que llena esos casilleros, no de la profundidad. El módulo que sigue lleva esta misma idea al color completo.

Módulo 5

Menos información no es menos bytes

20 min

Concepto Entropía vs. representación, cuantización de paleta

pnmquant 2  color.ppm > q2.ppm
pnmquant 16 color.ppm > q16.ppm
ColoresPPM crudoPNG
2196.6232.335
16196.6236.913
sin cuantizar196.62365.037

El PPM crudo pesa exactamente lo mismo en las tres filas. Cuantizar a 2 colores no le saca un solo byte al archivo sin comprimir — el formato PPM reserva el mismo espacio fijo por píxel sin importar cuántos colores distintos usás en realidad. El PNG, en cambio, varía 27,9× entre los extremos.

El fractal de Mandelbrot en color: fondo magenta, banda de borde amarilla, y relleno con degradé de verde, rojo y amarillo
Sin cuantizar 65.037 bytes de PNG — la fila de referencia de la tabla.
El fractal en color reducido a 2 colores: fondo salmón coral y la forma del fractal en un gris azulado oscuro, sin degradé
2 colores 2.335 bytes de PNG.
El fractal en color reducido a 16 colores: fondo magenta y el interior en bandas visibles de verde, amarillo, naranja y rojo oscuro, el degradé original se nota escalonado
16 colores 6.913 bytes de PNG.

El momento Tirar información no se traduce en menos bytes por sí solo. Se traduce en menos bytes recién cuando algo —un codificador de entropía, como el que usa PNG— aprovecha esa información faltante para escribir menos. Información y tamaño de archivo son dos cosas distintas que solo coinciden si hay un compresor de por medio.

Módulo 6

La promesa incumplida

20 min

Concepto Bounds checking, validación de invariantes, framing

El módulo 1 mostró que la cabecera declara el ancho en texto plano. Ahora le vamos a mentir. No tocamos un solo byte de datos: solo cambiamos qué ancho dice la cabecera que tiene la imagen. 255 y 256 miden lo mismo en ASCII —tres caracteres— así que el archivo no cambia de tamaño ni un byte al mentir.

./scripts/shear.sh fuente.png 255 shear.png
Ancho declaradoDiferenciaResultado
255768 bytes de sobrase genera, corrida diagonal
2504.608 bytes de sobrase genera, corrida más fuerte
12898.304 bytes de sobrase genera, corrida extrema
257768 bytes de faltafalla, exit 1
El fractal cortado en diagonal: cada fila del dibujo empieza corrida un píxel más que la anterior, produciendo un corte que atraviesa la imagen de punta a punta
Ancho declarado 255 sobre datos de 256. Cada fila arranca un píxel antes que la real, y el desplazamiento se acumula fila tras fila: por eso el corte es diagonal, no horizontal.

Declarar 257 es la otra dirección: le estás prometiendo al lector 768 bytes que no existen, y el lector se queda sin datos a mitad de la última fila:

pnmtopng: Unexpected EOF reading row of PPM image.

La cabecera después de mentir, verificada con xxd -l 16: 5036 0a32 3535 2032 3536 0a32 3535 0a = P6\n255 256\n255\n. ./scripts/shear.sh hace este truco por vos y a propósito deja un -crudo.ppm al lado de la salida, para que puedas inspeccionar esa cabecera con xxd en lugar de confiar en lo que dice esta página.

El momento La asimetría es la lección. Pedir menos ancho del que hay degrada en silencio: la imagen se genera, se ve mal, y nada te avisa. Pedir más ancho del que hay falla fuerte e inmediato: exit 1, un mensaje claro. Si hiciste el taller de ffmpeg de esta serie, ya viste un decodificador de video alucinar en vez de fallar cuando le faltaba un fotograma clave. Acá la misma clase de mentira falla ruidosamente en una dirección y en silencio en la otra, y la diferencia completa es si el programa hace bounds checking antes de leer o confía ciegamente en la cabecera.

Módulo 7

Escribiste una red neuronal

15 min

Concepto La convolución como primitiva de visión por computadora

Volvamos a la matriz de 3×3 del módulo 2. Multiplicaste cada vecino de un píxel por un peso, sumaste, y repetiste para cada píxel de la imagen. Esa operación —una ventana chica que se desliza, multiplica y suma— tiene un nombre en aprendizaje automático: es una capa convolucional. No una versión simplificada para enseñar, ni una analogía. La misma operación, con el mismo nombre, del mismo tamaño 3×3 que usan de verdad muchas capas de redes reales.

La diferencia entre lo que hiciste a mano y una red neuronal moderna no está en la operación: está en que una red aprende los pesos de la matriz en vez de que los escribas vos, y en que apila cientos de estas capas, cada una mirando lo que produjo la anterior. Vos escribiste una, a mano, con números que entendías uno por uno. Eso es más de lo que la mayoría de la gente que usa estas redes todos los días puede decir.

Para seguir por este camino sin construir nada nuevo ahora: esta máquina tiene incluso una GPU virtual corriendo por software (ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)). La razón por la que el aprendizaje profundo corre en GPUs es exactamente lo que acabás de escribir: una función chica —el kernel— evaluada una vez por cada píxel, en paralelo. Un fragment shader es eso. Un kernel, un millón de invocaciones.

El momento No aprendiste una metáfora de la visión por computadora. Calculaste a mano, con papel mental y una calculadora de bolsillo si hizo falta, la operación exacta sobre la que está construida.

Módulo 8

Exportar y nombrar

5 min

Concepto Cierre y evaluación

Cada persona exporta el resultado que más le gustó del módulo 2 o 3:

pnmtopng miresultado.pgm > miresultado.png

Y antes de mostrarlo dice una sola frase: qué suma tiene su kernel y qué le hizo eso a la media. Esa frase es toda la evaluación del taller. Si puede decirla, entendió ganancia DC, convolución y la diferencia entre información y tamaño de archivo. Si no puede, el resultado queda igual de lindo — pero volvemos 10 minutos al módulo 3.

El momento El que aparece cuando alguien mira la matriz de otro y dice, sin que se lo pidan, "ah, por eso te quedó tan brillante, te olvidaste de normalizar". Ahí dejaron de ver un filtro y empezaron a ver una ecuación.

05 Para seguir

Ordenado por dificultad. El primero es el que más enseña.

  1. El kernel a mano en el navegador, después en la terminal

    Volvé al widget del módulo 2. Escribí una matriz que no esté en los presets, copiá el comando que te genera, y corré ese pnmconvol real sobre fuente.pgm. Compará a ojo la salida del navegador contra la de la terminal. Es el mismo ejercicio que ya hicimos con el gauss, pero con un kernel que inventaste vos.

  2. De raster a vector: el puente al próximo taller de la serie

    potrace convierte un bitmap en curvas vectoriales — el camino inverso al de todo este taller, que trabajó siempre en la grilla de píxeles. Tramá una de tus imágenes a un bit con pamditherbw del módulo 4 y pasala por potrace: es el gancho hacia la próxima parada de esta serie.

  3. El mismo kernel como fragment shader

    Un fragment shader en WebGL corre una función chica una vez por cada píxel de salida, en paralelo, en la GPU (real o virtual: esta máquina la corre en SwiftShader/ANGLE por software). Escribir el kernel del módulo 2 como shader es escribir la misma matriz una segunda vez, en un lenguaje pensado para correr un millón de invocaciones a la vez en vez de una por una. No lo construimos en este taller — es la puerta de entrada directa al motivo por el que el aprendizaje profundo necesita GPU.

  4. Convolución separable: la misma matriz en dos pasadas

    El gauss del módulo 2 —0.0625,0.125,0.0625;0.125,0.25,0.125;0.0625,0.125,0.0625— es exactamente el producto externo de [1,2,1] con sigo mismo, dividido por 16. Eso significa que se puede aplicar como dos pasadas de un kernel 1×3, una horizontal y otra vertical, en vez de una sola pasada 3×3. Por píxel, eso son 3+3=6 multiplicaciones en vez de 9: no es un detalle de implementación, es la razón por la que casi ningún desenfoque gaussiano de producción se calcula como matriz cuadrada.

06 Glosario

Estas son las traducciones que usamos, con el término original al lado para que puedan googlear.

Kernel kernel, núcleo
La matriz chica de pesos que se desliza sobre la imagen. Define la operación de un filtro.
Convolución convolution
Multiplicar cada vecino de un píxel por el peso correspondiente del kernel y sumar. La operación de todo este taller.
Ganancia DC DC gain
Cuánto cambia el nivel general (la media) de una imagen al aplicar un kernel. Un kernel que suma 1 tiene ganancia DC de 1: no cambia el nivel.
PPM / PGM / PBM
Los tres formatos de netpbm: color, escala de grises, bitmap de un bit. PAM los generaliza a todos.
netpbm
Colección de ~291 programas chicos para leer, escribir y transformar estos formatos, cada uno con una sola responsabilidad.
Cuantización quantization
Reducir cuántos valores distintos puede tener un píxel: menos colores, menos niveles de gris, menos bits.
Dithering / tramado
Simular más niveles de los que realmente hay, distribuyendo el error de redondeo como un patrón visual en vez de como una banda plana.
Difusión de error error diffusion
La familia de algoritmos de tramado (Atkinson, Floyd-Steinberg) que reparte el error de cuantizar un píxel sobre los píxeles vecinos.
Entropía entropy
Cuán predecible es un dato. Menos entropía, más se puede comprimir sin perder nada.
Profundidad de bits bit depth
Cuántos bits describen cada píxel o cada canal. Un bit por píxel son solo dos estados posibles.
Canal channel
Cada componente independiente de un píxel: rojo, verde, azul, o el único canal de gris.
Magic number
Los primeros bytes de un archivo que identifican su formato. P5 es el magic number de PGM binario.
Maxval
El valor más alto que puede tener un píxel en un archivo netpbm. Casi siempre 255.
Stride / paso de fila
Cuántos bytes hay que avanzar para pasar de un píxel a el de abajo. Se calcula, no se declara — por eso una cabecera falsa lo corre.
Campo receptivo receptive field
La región de la imagen de entrada que influye sobre un píxel de salida. Para un kernel 3×3, es exactamente esos 9 vecinos.