5.1. El objeto Image

Un algoritmo de procesamiento de imágenes recorre una imagen un píxel a la vez. En cada posición hace algo simple: leer un valor, compararlo con un umbral, combinarlo con el píxel correspondiente de una segunda imagen, escribir un resultado de vuelta. Repetidas a lo largo de un fotograma entero, esas sencillas decisiones por píxel son aquello con lo que se construyen la detección de bordes, el seguimiento de manchas (blobs), la decodificación de códigos QR y todas las demás técnicas clásicas de visión por computadora. Para hacer ese trabajo de forma eficiente, el algoritmo tiene que saber dónde se encuentra cada píxel en memoria, qué significa realmente el valor de cada píxel y qué porción de la imagen debería estar examinando. La clase image.Image es el objeto que organiza esa información.

Los sensores de visión terminaron en el momento en que csi.CSI.snapshot() retorna. Lo que sea que hiciera la maquinaria del lado de la cámara para producir el fotograma capturado ya está hecho; la aplicación tiene la Image en la mano y necesita saber qué hacer con ella.

5.1.1. El búfer y sus propiedades

Dentro de la Image hay un puntero a un bloque contiguo de bytes en RAM y una pequeña cabecera que lleva tres piezas de metadatos: el ancho de la imagen en píxeles, su alto en píxeles y el formato de píxel en el que están los bytes. Los bytes son los píxeles mismos, almacenados en orden por filas (row-major): primero todos los píxeles de la fila superior, luego todos los de la segunda fila, y así sucesivamente hasta la inferior. Las propiedades describen cómo leerlos.

El ancho y el alto son simples conteos enteros. El formato de píxel es la propiedad más interesante, porque determina cuántos bytes ocupa cada píxel y qué codifican esos bytes. Una imagen en escala de grises lleva un byte por píxel que contiene un valor de brillo. Una imagen RGB565 lleva dos bytes por píxel que contienen los campos de rojo, verde y azul empaquetados en una palabra de 16 bits. Una imagen Bayer lleva un byte por píxel, pero cada píxel se muestrea a través de uno de tres filtros de color elegido por su posición en el mosaico. Los sensores de visión enumeraron el catálogo completo; lo que importa aquí es que exactamente uno de esos formatos está establecido en cada Image, y la elección determina la aritmética de bytes por píxel y el significado de cualquier byte individual del búfer.

Con un puntero al búfer, el ancho, el alto y el formato, cualquier otra propiedad que un algoritmo pudiera querer surge como un breve cálculo. El byte que comienza el píxel (x, y) se sitúa en el desplazamiento (y * width + x) * bytes_per_pixel desde el inicio del búfer. El conteo total de bytes es width * height * bytes_per_pixel. La dirección de la siguiente fila hacia abajo está exactamente width * bytes_per_pixel bytes después del inicio de la actual. La clase Image expone las tres propiedades mediante simples llamadas a métodos – width(), height(), format() – más el size derivado a través de size(). Otros métodos del módulo usan esos valores para hacer ellos mismos la aritmética de desplazamientos; el código de la aplicación rara vez tiene que hacerlo.

Una caja etiquetada image.Image -- envoltorio de Python en la parte superior, con una flecha apuntando hacia abajo etiquetada "referencia" hacia dos cajas apiladas: una caja de cabecera delgada que contiene el ancho, el alto y el formato de píxel, y una caja de búfer de píxeles más gruesa con una fila de pequeñas celdas que representan píxeles individuales. Una leyenda debajo indica que el búfer reside en el montón (heap) por defecto y en el búfer de fotogramas (frame buffer) cuando copy_to_fb es verdadero.

Una Image es un pequeño envoltorio de Python que apunta a un bloque contiguo de memoria: una cabecera que lleva el ancho, el alto y el formato de píxel, seguida del propio búfer de píxeles.

5.1.2. De dónde viene el búfer

La historia por defecto a lo largo de este capítulo es la que los sensores de visión ya cubrieron: un fotograma capturado llega de snapshot, los bytes están en el búfer de fotogramas (frame buffer) de la cámara, y la Image retornada apunta a ellos. Otras tres formas de obtener una surgen con regularidad, y cada una implica algo distinto sobre dónde acaba el búfer.

Cargar desde un archivo se ve como pasar una ruta al constructor: image.Image("/sdcard/saved.jpg"). El módulo lee el archivo en un búfer recién asignado en el montón (heap) de Python. Los archivos BMP, PGM y PPM se decodifican al entrar y la Image resultante lleva un formato de píxel sin comprimir. Los archivos JPEG y PNG permanecen comprimidos: la Image lleva el formato JPEG o PNG, y el búfer contiene el flujo de bytes del archivo esencialmente sin cambios. Para hacer cualquier trabajo a nivel de píxel sobre una imagen comprimida, la aplicación la convierte primero a través de to_rgb565() o to_grayscale(), y esa conversión es donde la descompresión – y el correspondiente inflado del montón, donde un JPEG de 30 KB puede convertirse en 600 KB de RGB565 – realmente ocurre. Cargar desde archivo es más útil durante el desarrollo, cuando un algoritmo necesita probarse contra un fotograma de referencia conocido almacenado junto al script.

Construir una desde cero es el caso del lienzo: image.Image(320, 240, image.RGB565) le pide al módulo que asigne esa cantidad de bytes en ese formato, ponga el contenido a cero y devuelva el envoltorio. Los píxeles aún no significan nada – son todos ceros – pero la imagen vacía es la fuerza de trabajo para un puñado de patrones recurrentes: fotogramas de referencia contra los cuales se resta un fotograma actual, lienzos sobre los que se componen superposiciones gráficas, búferes binarios que se rellenan y se usan como máscaras.

Construir a partir de un ndarray tiende un puente en la otra dirección, desde cualquier cálculo numérico de vuelta al módulo de imágenes. Pasar un ulab.numpy.ndarray float32 al constructor produce una Image cuyas dimensiones coinciden con el ndarray – una forma de dos ejes (h, w) se convierte en una imagen en escala de grises, una forma de tres ejes (h, w, 3) se convierte en RGB565 – con los valores flotantes escalados desde 0.0255.0 al rango de píxeles enteros. Un mapa de calor de una red neuronal, un arreglo numérico de cualquier tipo, cualquier cosa producida por ml o ulab se convierte en algo que el lado de dibujo e inspección del módulo de imágenes puede usar.

Las cuatro fuentes devuelven el mismo tipo de Image. El código que usa el objeto retornado nunca tiene que rastrear de dónde provino.

5.1.3. Dos vistas sobre los bytes

La mayor parte del tiempo el código de la aplicación trata una Image como un objeto de imagen tipado – una cosa con métodos con nombre. La otra mitad de la historia es que el mismo objeto también aparece, de forma transparente, como una secuencia plana de bytes ante cualquier API de MicroPython que tome un argumento bytes. Los bytes no son una copia del búfer; son una vista directa de él.

Esa disposición es lo que hace que enviar un fotograma capturado fuera de la cámara sea cosa de una sola línea. Calcular su hash, enviarlo por un puerto serie, reenviarlo a un socket de red – ninguna de esas operaciones necesita un paso separado de «convertir la imagen a bytes»:

import csi
import hashlib

csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)

img = csi0.snapshot()
uart.write(img)              # transmits the raw pixel bytes
hashlib.sha256(img)          # hashes the same bytes
sock.send(img)               # sends them over a socket

La vista tipo bytes es de solo lectura por defecto, a propósito. Los búferes de imagen son grandes y a veces se comparten entre capas de la pila de imagen, así que darle a un casual buf[0] = 0 en algún lugar profundo de una pila de llamadas el poder de corromper uno silenciosamente es un borde demasiado afilado para dejarlo expuesto. Cuando lo que la aplicación realmente necesita es acceso de lectura y escritura a nivel de byte – escribir un valor de calibración en un desplazamiento conocido, por ejemplo – bytearray() retorna una vista separada y explícitamente de lectura y escritura sobre la misma memoria, señalando la intención en el punto de la llamada.

5.1.4. Dónde reside el búfer

Los búferes de píxeles son lo bastante grandes como para que importe dónde residen en la RAM. Un fotograma QQVGA RGB565 es 160 × 120 × 2 = 38.400 bytes; un fotograma VGA RGB565 es 614.400 bytes; una entrada RGB565 de 224 × 224 que un clasificador de red neuronal podría consumir son unos 100 KB. El montón (heap) de Python en las cámaras más pequeñas puede ser de solo unas pocas decenas de kilobytes una vez que el entorno de ejecución ha arrancado. Mantener más de uno o dos fotogramas de datos de imagen en el montón desplazaría todo lo demás fuera de él.

La salida es que los búferes de imagen en su mayoría no residen en el montón (heap) de Python. Residen en la región dedicada de RAM que los sensores de visión introdujeron como el búfer de fotogramas (frame buffer) – la misma memoria en la que el DMA de la cámara escribe los fotogramas capturados y de la que la vista previa del IDE lee los fotogramas terminados. La mayoría de las operaciones sobre una Image modifican su origen in situ: el algoritmo lee píxeles, decide, escribe nuevos valores de vuelta, y no se asigna ninguna imagen de resultado separada. Las operaciones que producen un resultado separado – conversiones de formato y un puñado de otras – pueden recibir la instrucción de colocar ese resultado en el búfer de fotogramas mediante el argumento de palabra clave copy_to_fb. copy_to_fb=True hace dos cosas a la vez: pone la imagen de resultado en el búfer de fotogramas en lugar de en el montón (esquivando la presión sobre el montón) y hace que el resultado sea el siguiente fotograma que mostrará la vista previa del IDE. Agregar copy_to_fb=True al paso final de una tubería, observar el resultado aparecer en pantalla e iterar a partir de ahí es uno de los modismos de depuración más útiles en el procesamiento de imágenes.

Con un envoltorio que sostiene un búfer etiquetado, cuatro formas de hacer que uno llegue a existir, dos vistas sobre sus bytes y un interruptor que decide dónde aterrizan los nuevos, la Image ya no es un misterio. Las preguntas fundamentales restantes – cómo se nombra una posición de píxel, qué contiene realmente cada píxel, cómo acotar una operación a una porción de uno – se construyen sobre ella.