5.33. Stream ImageIO

save() e to_jpeg() coprono il caso di I/O a singolo frame: un’applicazione cattura un frame, lo codifica e lo invia da qualche parte. Una diversa classe di applicazioni necessita del caso a sequenza: registrare molti frame di seguito alla cadenza naturale di cattura, memorizzarli in un luogo da cui possono essere recuperati in seguito e riprodurli alla velocità corretta. Uno script di raccolta di dati di addestramento cattura qualche centinaio di frame di esempio per una pipeline di machine learning; un log di una postazione di ispezione registra ogni pezzo catturato a fini di tracciabilità; uno script di sviluppo riproduce una sequenza memorizzata per testare un nuovo algoritmo su dati precedentemente acquisiti dal vivo.

La classe ImageIO è il registratore / lettore del modulo image. Un singolo stream contiene una sequenza di frame Image – eventualmente di dimensioni e formati di pixel diversi – insieme all’intervallo inter-frame di ciascuno, in modo che la riproduzione possa ricreare il frame rate originale. Sono disponibili due archivi di supporto: un file sul filesystem o un buffer di dimensione fissa in RAM.

5.33.1. I due archivi di supporto

Uno stream su file mantiene la registrazione attraverso i cicli di accensione ed è dimensionato solo dallo storage che lo supporta. Inizia con un’intestazione magica di 16 byte OMV IMG STR Vx.y seguita da un chunk per frame; lo scrittore attuale emette V2.0 e il lettore accetta ancora i file V1.0 e V1.1 per retrocompatibilità. Il percorso del file è l’argomento del costruttore; la modalità è la modalità di apertura del file ('r' per leggere uno stream esistente, 'w' per troncare e scrivere da capo).

# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
    img = csi0.snapshot()
    stream.write(img)
stream.close()

Uno stream in memoria risiede in un buffer RAM allocato alla costruzione. Il costruttore prende una tupla a 3 elementi (w, h, pixformat) invece di un percorso, e l’argomento mode diventa il numero pre-allocato di slot per frame. Il buffer è dimensionato esattamente per quel numero di frame alle dimensioni fornite e non può crescere una volta allocato – scrivere oltre l’ultimo slot solleva EOFError, e scrivere un frame più grande del buffer per slot solleva ValueError. Gli stream in memoria sono lo strumento giusto quando l’applicazione deve passare una registrazione a uno stadio a valle senza passare per il filesystem (un breve ring buffer di frame recenti per un pattern di trigger-and-replay, ad esempio).

# Pre-allocate space for 32 QVGA RGB565 frames in RAM
stream = image.ImageIO((320, 240, image.RGB565), 32)
for _ in range(32):
    stream.write(csi0.snapshot())

Per i formati di pixel compressi (image.JPEG, image.PNG) la dimensione per slot è stimata a 2 bit per pixel; un frame codificato più grande della stima solleva ValueError al momento della scrittura, quindi un’applicazione che si aspetta di memorizzare JPEG di alta qualità deve o sovra-allocare il numero di slot o codificare prima a una qualità inferiore.

type() restituisce image.ImageIO.FILE_STREAM o image.ImageIO.MEMORY_STREAM così che il codice a valle possa adattarsi a qualunque archivio di supporto gli venga dato.

5.33.2. Registrazione

write() accoda un Image catturato a uno stream su file (o lo memorizza nello slot corrente di uno stream in memoria) e avanza l’offset di uno. La stessa chiamata registra l”intervallo inter-frame dall’ultima scrittura, così che la metà di riproduzione possa mettere in pausa per la giusta quantità di tempo tra i frame e il frame rate naturale della registrazione sia preservato.

I frame eterogenei sono ammessi all’interno di un singolo stream su file: una registrazione può mescolare liberamente catture RGB565, ritagli in scala di grigi e miniature codificate in JPEG, e il lettore decodificherà ciascuno alla sua dimensione e formato originali. Gli stream in memoria sono omogenei (tutti gli slot condividono il (w, h, pixformat) fornito al costruttore), quindi una registrazione in memoria è limitata a una sola configurazione di frame.

write() restituisce l’oggetto stream così che le chiamate possano essere concatenate. Scrivere a un offset diverso dalla fine di uno stream su file tronca il resto del file – utile per modificare una sequenza memorizzata, rischioso se la posizione della scrittura successiva è stata spostata involontariamente da una precedente seek().

sync() scarica su disco le scritture in sospeso per gli stream su file (è un no-op sugli stream in memoria) e dovrebbe essere chiamata periodicamente quando la registrazione è di lunga durata, per evitare di perdere la coda della registrazione se la cam si riavvia prima che il file sia chiuso. Il distruttore chiude lo stream automaticamente quando l’oggetto ImageIO esce dall’ambito di visibilità, ma chiamare esplicitamente close() è la disciplina corretta.

5.33.3. Riproduzione

read() legge il frame all’offset corrente, avanza l’offset e restituisce la nuova Image. Il ricevente rimane nel frame buffer quando copy_to_fb=True (il valore predefinito) così che l’immagine restituita sia disegnabile tramite l’anteprima dell’IDE; con copy_to_fb=False il frame finisce sull’heap di MicroPython.

# Loop a recorded stream at its natural frame rate
stream = image.ImageIO("/sdcard/run.bin", "r")
while True:
    img = stream.read()
    # img is now in the frame buffer; the IDE shows it
    # and the script can run any analysis it likes

Due parole chiave controllano il comportamento della riproduzione. loop=True (il valore predefinito per gli stream su file) riavvolge il puntatore di lettura all’inizio quando si raggiunge la fine della registrazione, così la chiamata non restituisce mai None; loop=False restituisce None una volta esaurita la registrazione e il ciclo del chiamante termina. pause=True (il valore predefinito) blocca la chiamata finché non è trascorso l’intervallo inter-frame registrato al momento della scrittura, così il frame rate di riproduzione corrisponde al frame rate di cattura originale; pause=False restituisce immediatamente, utile per le pipeline di analisi che vogliono scorrere la registrazione il più velocemente possibile senza rispettare la temporizzazione originale.

Lo stesso pattern di ciclo funziona per gli stream in memoria, tranne che loop viene ignorato – leggere oltre la fine di uno stream in memoria solleva EOFError. Il pattern previsto per un ring in memoria è fare seek() esplicitamente fino a zero quando si desidera l’avvolgimento.

5.33.5. Registrazioni riproducibili su host

Gli stream ImageIO sono lo strumento giusto quando la registrazione verrà riprodotta sulla cam – preservano ogni frame catturato nel suo formato di pixel nativo, l’intervallo inter-frame è registrato esattamente, e uno script a valle può scorrerli, fare seek e rianalizzare senza alcuna perdita. Non sono, tuttavia, lo strumento giusto quando la registrazione deve essere riproducibile su un host – una workstation, un telefono, un lettore web. Un host si aspetta un container video standard, non il formato OpenMV su disco con intestazione magica.

Due moduli separati coprono il caso riproducibile su host. Il modulo mjpeg registra in Motion JPEG: una sequenza di frame compressi in JPEG impacchettati in un singolo container in stile AVI che VLC, QuickTime, ffmpeg e il tag video web standard riproducono tutti direttamente. Il modulo gif registra una GIF animata: una sequenza di frame non compressi (o compressi con palette) con ritardi espliciti per frame, riproducibile in qualsiasi browser web o visualizzatore di immagini che gestisca le GIF animate.

Il modulo mjpeg è la scelta naturale per le registrazioni lunghe. La compressione JPEG mantiene gestibile la dimensione del file – paragonabile a to_jpeg() alla qualità configurata, frame dopo frame – così una sessione di cattura prolungata resta entro il budget della scheda SD. L’utilizzo rispecchia da vicino la registrazione con ImageIO:

import mjpeg

m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
    m.add_frame(csi0.snapshot(), quality=85)
m.close()

mjpeg.Mjpeg accetta le stesse parole chiave posizionali e di scala in stile disegno che prendono gli altri metodi di image, così una registrazione può essere scalata, ritagliata o mappata su palette per frame durante l’ingresso. Gli argomenti width e height del costruttore hanno come valore predefinito le dimensioni del frame buffer principale e fissano la risoluzione di output; ogni frame accodato viene scalato (preservando le proporzioni) per adattarsi. sync() scarica il file su disco durante una registrazione lunga, e close() finalizza il container – un file Motion JPEG che non è stato chiuso correttamente non è riproducibile, quindi la disciplina conta.

Il modulo gif è la scelta naturale per le registrazioni brevi condivise testualmente con uno spettatore non tecnico – qualche secondo di azione catturato per una demo, un’illustrazione animata per la documentazione, una clip di un evento incorporata in un messaggio di chat. I frame GIF sono memorizzati non compressi (o compressi con palette a una profondità di colore a 7 bit), il che rende i file molto più grandi al secondo rispetto al Motion JPEG ed esclude il formato per le registrazioni più lunghe di qualche secondo, ma il risultato si inserisce direttamente in qualsiasi browser:

import gif

g = gif.Gif("/sdcard/clip.gif")
while running:
    g.add_frame(csi0.snapshot(), delay=10)
g.close()

L’argomento delay di add_frame() è il tempo di visualizzazione per frame in centesimi di secondo (10 corrisponde a 100 ms per frame, ovvero 10 fps), che è il controllo standard della riproduzione GIF. La parola chiave loop del costruttore imposta se la clip risultante si ripete automaticamente nei visualizzatori (il valore predefinito è True, che corrisponde alla convenzionale aspettativa della «GIF animata»).

I tre percorsi di registrazione coprono insieme i casi comuni: ImageIO per la rielaborazione sulla cam, Motion JPEG per le lunghe registrazioni riproducibili su host, GIF animata per le brevi clip riproducibili su host. La scelta tra di essi si riduce a chi riproduce la registrazione. Uno stadio a valle in esecuzione sulla cam stessa legge ImageIO; una workstation host o un visualizzatore web legge MJPEG o GIF.

5.33.6. Un pattern di trigger-and-replay

Un pattern utile combina uno stream in memoria con una condizione di trigger. La cam registra continuamente in un ring buffer in memoria a count slot, sovrascrivendo lo slot più vecchio a ogni giro. Quando una condizione di trigger scatta (un blob entra nel frame, un evento di movimento supera una soglia, viene premuto un pulsante) l’applicazione cattura il contenuto del ring – i count frame più recenti – e li scrive in uno stream su file sulla scheda SD. Il risultato è una registrazione pre-trigger che cattura i secondi precedenti all’evento che la cam ha effettivamente notato, non solo i secondi successivi, che è la classica limitazione di un ingenuo registratore «cattura-quando-attivato».

L’implementazione è immediata una volta che si hanno a disposizione le classi di stream: uno stream in memoria di dimensione fissa funge da ring (con seek() esplicito a zero quando l’offset raggiunge il numero di slot), il ciclo principale cattura al suo interno a ogni iterazione, e il gestore del trigger legge lo stream in memoria frame per frame e scrive ciascuno in uno stream su file denominato in base al timestamp del trigger.