5.1. L’oggetto Image

Un algoritmo di elaborazione delle immagini scorre l’immagine un pixel alla volta. In ogni posizione esegue qualcosa di semplice – legge un valore, lo confronta con una soglia, lo combina con il pixel corrispondente di una seconda immagine, riscrive un risultato. Ripetute su un intero frame, queste semplici decisioni per singolo pixel sono ciò di cui sono fatte il rilevamento dei bordi, il tracciamento dei blob, la decodifica dei codici QR e ogni altra tecnica classica di visione artificiale. Per svolgere questo lavoro in modo efficiente, l’algoritmo deve sapere dove si trova ogni pixel in memoria, cosa significa realmente il valore di ogni pixel e quale porzione dell’immagine deve esaminare. La classe image.Image è l’oggetto che organizza queste informazioni.

I sensori di visione si concludevano nel momento in cui csi.CSI.snapshot() restituisce il risultato. Tutto ciò che la macchina lato camera ha fatto per produrre il frame catturato è già stato compiuto; l’applicazione ha in mano l’oggetto Image e deve sapere cosa farne.

5.1.1. Il buffer e le sue proprietà

All’interno dell’oggetto Image c’è un puntatore a un blocco contiguo di byte nella RAM e una piccola intestazione che trasporta tre informazioni di metadati: la larghezza dell’immagine in pixel, la sua altezza in pixel e il formato dei pixel in cui sono espressi i byte. I byte sono i pixel stessi, memorizzati in ordine per righe (row-major) – prima tutti i pixel della riga superiore, poi tutti quelli della seconda riga e così via fino in fondo. Le proprietà descrivono come leggerli.

Larghezza e altezza sono semplici conteggi interi. Il formato dei pixel è la proprietà più interessante, perché stabilisce quanti byte occupa ciascun pixel e cosa codificano quei byte. Un’immagine in scala di grigi trasporta un byte per pixel che contiene un valore di luminosità. Un’immagine RGB565 trasporta due byte per pixel che contengono i campi rosso, verde e blu impacchettati in una parola a 16 bit. Un’immagine Bayer trasporta un byte per pixel, ma ciascun pixel è campionato attraverso uno dei tre filtri di colore scelti in base alla sua posizione nel mosaico. I sensori di visione enumeravano l’intero catalogo; ciò che conta qui è che esattamente uno di quei formati è impostato su ogni Image, e questa scelta determina l’aritmetica dei byte per pixel e il significato di qualsiasi singolo byte nel buffer.

Con un puntatore al buffer, la larghezza, l’altezza e il formato, ogni altra proprietà che un algoritmo potrebbe desiderare si ricava con un breve calcolo. Il byte che inizia il pixel (x, y) si trova all’offset (y * width + x) * bytes_per_pixel dall’inizio del buffer. Il conteggio totale dei byte è width * height * bytes_per_pixel. L’indirizzo della riga successiva è esattamente width * bytes_per_pixel byte dopo l’inizio di quella corrente. La classe Image espone le tre proprietà tramite semplici chiamate di metodo – width(), height(), format() – più la size derivata tramite size(). I metodi altrove nel modulo usano questi valori per eseguire da soli l’aritmetica degli offset; il codice dell’applicazione raramente deve farlo.

Un riquadro etichettato image.Image -- wrapper Python in alto, con una freccia che punta verso il basso etichettata "riferimenti" verso due riquadri impilati -- un sottile riquadro di intestazione che contiene larghezza, altezza e formato dei pixel, e un riquadro più spesso del buffer dei pixel con una fila di piccole celle che rappresentano i singoli pixel. Una didascalia in basso annota che il buffer risiede nell'heap per impostazione predefinita e nel frame buffer quando copy_to_fb è true.

Un Image è un piccolo wrapper Python che punta a un blocco contiguo di memoria: un’intestazione che trasporta la larghezza, l’altezza e il formato dei pixel, seguita dal buffer dei pixel stesso.

5.1.2. Da dove proviene il buffer

Lo scenario predefinito in tutto questo capitolo è quello che i sensori di visione hanno già trattato: un frame catturato arriva da snapshot, i byte risiedono nel frame buffer della camera e l’oggetto Image restituito punta a essi. Altri tre modi per ottenerne uno si presentano regolarmente, e ciascuno implica qualcosa di diverso riguardo a dove finisce il buffer.

Caricare da un file equivale a passare un percorso al costruttore: image.Image("/sdcard/saved.jpg"). Il modulo legge il file in un buffer appena allocato sull’heap Python. I file BMP, PGM e PPM vengono decodificati durante il caricamento e l’oggetto Image risultante trasporta un formato di pixel non compresso. I file JPEG e PNG rimangono compressi – l’oggetto Image trasporta il formato JPEG o PNG, e il buffer contiene il flusso di byte del file sostanzialmente invariato. Per eseguire qualsiasi lavoro a livello di pixel su un’immagine compressa, l’applicazione la converte prima tramite to_rgb565() o to_grayscale(), ed è in quella conversione che avviene effettivamente la decompressione – e il corrispondente aumento esplosivo dell’heap, dove un JPEG da 30 KB può diventare 600 KB di RGB565. Il caricamento da file è più utile durante lo sviluppo, quando un algoritmo deve essere testato rispetto a un frame di riferimento noto memorizzato insieme allo script.

Costruirne uno da zero è il caso della tela vuota (canvas): image.Image(320, 240, image.RGB565) chiede al modulo di allocare quel numero di byte in quel formato, azzerare il contenuto e restituire il wrapper. I pixel non significano ancora nulla – sono tutti zero – ma l’immagine vuota è il cavallo di battaglia per una manciata di schemi ricorrenti: frame di riferimento da cui viene sottratto un frame corrente, tele su cui vengono composti gli overlay grafici, buffer binari che vengono riempiti e usati come maschere.

Costruire da un ndarray crea un ponte nella direzione opposta, da qualsiasi calcolo numerico verso il modulo image. Passare un ulab.numpy.ndarray float32 al costruttore produce un Image le cui dimensioni corrispondono all’ndarray – una forma a due assi (h, w) diventa un’immagine in scala di grigi, una forma a tre assi (h, w, 3) diventa RGB565 – con i valori float scalati da 0.0255.0 nell’intervallo intero dei pixel. Una heatmap di una rete neurale, un array numerico di qualsiasi tipo, qualsiasi cosa prodotta da ml o ulab diventa qualcosa che il lato di disegno e ispezione del modulo image può utilizzare.

Tutte e quattro le sorgenti restituiscono lo stesso tipo di Image. Il codice che usa l’oggetto restituito non deve mai tenere traccia di da dove provenga.

5.1.3. Due viste sui byte

La maggior parte delle volte il codice dell’applicazione tratta un Image come un oggetto immagine tipizzato – una cosa con metodi denominati. L’altra metà della storia è che lo stesso oggetto appare anche, in modo trasparente, come una sequenza piatta di byte per qualsiasi API di MicroPython che accetti un argomento bytes. I byte non sono una copia del buffer; ne sono una vista diretta.

Questa disposizione è ciò che rende l’invio di un frame catturato fuori dalla cam un’operazione su una sola riga. Calcolarne l’hash, inviarlo su una porta seriale, inoltrarlo a un socket di rete – nessuna di queste operazioni necessita di un passaggio separato di «conversione dell’immagine in byte»:

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 in stile byte è di sola lettura per impostazione predefinita, intenzionalmente. I buffer delle immagini sono grandi e talvolta condivisi tra i livelli dello stack di imaging, quindi dare a un disinvolto buf[0] = 0 in qualche punto profondo di uno stack di chiamate il potere di corromperne silenziosamente uno è un bordo troppo affilato da lasciare esposto. Quando ciò di cui l’applicazione ha realmente bisogno è un accesso in lettura-scrittura a livello di byte – ad esempio scrivere un valore di calibrazione in un offset noto – bytearray() restituisce una vista separata, esplicitamente in lettura-scrittura, sulla stessa memoria, segnalando l’intento nel punto della chiamata.

5.1.4. Dove risiede il buffer

I buffer dei pixel sono abbastanza grandi da far sì che la loro collocazione nella RAM sia importante. Un frame QQVGA RGB565 è 160 × 120 × 2 = 38.400 byte; un frame VGA RGB565 è 614.400 byte; un input RGB565 224 × 224 che un classificatore basato su rete neurale potrebbe consumare è di circa 100 KB. L’heap Python sulle cam più piccole può essere di soli poche decine di kilobyte una volta avviato il runtime. Mantenere più di uno o due frame di dati immagine nell’heap escluderebbe tutto il resto da esso.

La soluzione è che i buffer delle immagini per lo più non risiedono nell’heap Python. Risiedono nella regione dedicata della RAM che I sensori di visione hanno introdotto come frame buffer – la stessa memoria in cui il DMA della camera scrive i frame catturati e da cui l’anteprima dell’IDE legge i frame finiti. La maggior parte delle operazioni su un Image modifica la propria sorgente in loco: l’algoritmo legge i pixel, decide, riscrive nuovi valori, e non viene allocata alcuna immagine di risultato separata. Le operazioni che invece producono un risultato separato – le conversioni di formato e poche altre – possono essere indotte a collocare quel risultato nel frame buffer tramite l’argomento keyword copy_to_fb. copy_to_fb=True fa due cose contemporaneamente: mette l’immagine di risultato nel frame buffer anziché nell’heap (eludendo la pressione sull’heap) e rende il risultato il frame successivo che l’anteprima dell’IDE visualizzerà. Aggiungere copy_to_fb=True all’ultimo passaggio di una pipeline, osservare il risultato comparire sullo schermo e iterare da lì è uno degli idiomi di debug più utili nell’elaborazione delle immagini.

Con un wrapper che contiene un buffer etichettato, quattro modi per portarne uno all’esistenza, due viste sui suoi byte e un interruttore che decide dove finiscono quelli nuovi, l’oggetto Image non è più un mistero. Le restanti domande fondamentali – come viene nominata una posizione di pixel, cosa contiene realmente ciascun pixel, come delimitare un’operazione a una porzione di esso – sono costruite su di esso.