5.33. Flux ImageIO

save() et to_jpeg() couvrent le cas d’E/S image isolée : une application capture une trame, l’encode et la transmet quelque part. Une autre catégorie d’applications a besoin du cas séquence : enregistrer plusieurs trames d’affilée à la cadence naturelle de capture, les stocker quelque part où elles pourront être récupérées plus tard, puis les rejouer à la bonne vitesse. Un script de collecte de données d’entraînement capture quelques centaines de trames d’exemple pour un pipeline d’apprentissage automatique ; un journal de poste d’inspection enregistre chaque pièce capturée à des fins de traçabilité ; un script de développement rejoue une séquence stockée pour tester un nouvel algorithme contre des données précédemment capturées en direct.

La classe ImageIO est l’enregistreur / lecteur du module image. Un même flux contient une séquence de trames Image – éventuellement de tailles et de formats de pixels différents – ainsi que l’intervalle inter-trames de chacune, afin que la lecture puisse recréer la cadence d’origine. Deux supports de stockage sont disponibles : un fichier sur le système de fichiers ou un tampon de taille fixe en RAM.

5.33.1. Les deux supports de stockage

Un flux fichier conserve l’enregistrement entre les cycles d’alimentation et n’est limité en taille que par le stockage qui le sous-tend. Il commence par un en-tête magique de 16 octets OMV IMG STR Vx.y suivi d’un bloc par trame ; l’écrivain actuel émet du V2.0 et le lecteur accepte encore les fichiers V1.0 et V1.1 pour des raisons de rétrocompatibilité. Le chemin du fichier est l’argument du constructeur ; le mode est le mode d’ouverture du fichier ('r' pour lire un flux existant, 'w' pour tronquer et écrire à neuf).

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

Un flux mémoire réside dans un tampon RAM alloué à la construction. Le constructeur prend un triplet (w, h, pixformat) au lieu d’un chemin, et l’argument mode devient le nombre pré-alloué d’emplacements de trames. Le tampon est dimensionné exactement pour ce nombre de trames aux dimensions fournies et n’est pas autorisé à grossir une fois alloué – écrire au-delà du dernier emplacement lève une EOFError, et écrire une trame plus grande que le tampon par emplacement lève une ValueError. Les flux mémoire sont l’outil adapté lorsque l’application doit transmettre un enregistrement à une étape en aval sans passer par le système de fichiers (un court tampon circulaire de trames récentes pour un schéma déclenchement-et-relecture, par exemple).

# 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())

Pour les formats de pixels compressés (image.JPEG, image.PNG), la taille par emplacement est estimée à 2 bits par pixel ; une trame encodée plus grande que l’estimation lève une ValueError au moment de l’écriture, de sorte qu’une application qui prévoit de stocker des JPEG de haute qualité doit soit sur-allouer le nombre d’emplacements, soit encoder d’abord à une qualité inférieure.

type() renvoie image.ImageIO.FILE_STREAM ou image.ImageIO.MEMORY_STREAM afin que le code en aval puisse s’adapter au support de stockage qui lui est fourni.

5.33.2. Enregistrement

write() ajoute une Image capturée à un flux fichier (ou la stocke à l’emplacement courant d’un flux mémoire) et avance le décalage d’une unité. Le même appel enregistre l”intervalle inter-trames écoulé depuis la dernière écriture, afin que la partie lecture puisse marquer une pause de la bonne durée entre les trames et que la cadence naturelle de l’enregistrement soit préservée.

Des trames hétérogènes sont autorisées au sein d’un même flux fichier : un enregistrement peut mélanger librement des captures RGB565, des recadrages en niveaux de gris et des vignettes encodées en JPEG, et le lecteur décodera chacune à sa taille et son format d’origine. Les flux mémoire sont homogènes (tous les emplacements partagent le (w, h, pixformat) fourni au constructeur), de sorte qu’un enregistrement mémoire est restreint à une seule configuration de trame.

write() renvoie l’objet flux afin que les appels puissent s’enchaîner. Écrire à un décalage autre que la fin d’un flux fichier tronque le reste du fichier – utile pour éditer une séquence stockée, risqué si la position de la prochaine écriture a été déplacée involontairement par un seek() antérieur.

sync() vide les écritures en attente vers le disque pour les flux fichiers (c’est une opération sans effet sur les flux mémoire) et devrait être appelée périodiquement lorsque l’enregistrement est de longue durée, pour éviter de perdre la fin de l’enregistrement si la caméra redémarre avant que le fichier ne soit fermé. Le destructeur ferme le flux automatiquement lorsque l”ImageIO sort de portée, mais l’appel explicite de close() est la bonne pratique.

5.33.3. Lecture

read() lit la trame au décalage courant, avance le décalage et renvoie la nouvelle Image. La trame reçue reste dans le tampon d’image lorsque copy_to_fb=True (la valeur par défaut), de sorte que l’image renvoyée est dessinable via l’aperçu de l’IDE ; avec copy_to_fb=False, la trame atterrit sur le tas 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

Deux mots-clés contrôlent le comportement de lecture. loop=True (la valeur par défaut pour les flux fichiers) ramène le pointeur de lecture au début lorsque la fin de l’enregistrement est atteinte, de sorte que l’appel ne renvoie jamais None ; loop=False renvoie None une fois l’enregistrement épuisé et la boucle de l’appelant se termine. pause=True (la valeur par défaut) bloque l’appel jusqu’à ce que l’intervalle inter-trames enregistré au moment de l’écriture se soit écoulé, de sorte que la cadence de lecture corresponde à la cadence de capture d’origine ; pause=False renvoie immédiatement, ce qui est utile pour les pipelines d’analyse qui veulent traiter l’enregistrement aussi vite que possible sans respecter le rythme d’origine.

Le même schéma de boucle fonctionne pour les flux mémoire, à ceci près que loop est ignoré – lire au-delà de la fin d’un flux mémoire lève une EOFError. Le schéma attendu pour un anneau mémoire consiste à effectuer un seek() explicite vers zéro lorsque le rebouclage est souhaité.

5.33.5. Enregistrements lisibles sur l’hôte

Les flux ImageIO sont l’outil adapté lorsque l’enregistrement va être rejoué sur la caméra – ils préservent chaque trame capturée dans son format de pixels natif, l’intervalle inter-trames est enregistré exactement, et un script en aval peut les parcourir, se positionner et les réanalyser sans perte. En revanche, ils ne sont pas l’outil adapté lorsque l’enregistrement doit être lisible sur un hôte – une station de travail, un téléphone, un lecteur web. Un hôte s’attend à un conteneur vidéo standard, et non au format à en-tête magique sur disque d’OpenMV.

Deux modules distincts couvrent le cas lisible sur l’hôte. Le module mjpeg enregistre du Motion JPEG : une séquence de trames compressées en JPEG empaquetées dans un unique conteneur de type AVI que VLC, QuickTime, ffmpeg et la balise vidéo web standard lisent tous directement. Le module gif enregistre un GIF animé : une séquence de trames non compressées (ou compressées par palette) avec des délais explicites par trame, lisible dans n’importe quel navigateur web ou visionneuse d’images prenant en charge les GIF animés.

Le module mjpeg est le choix naturel pour les enregistrements longs. La compression JPEG maintient la taille du fichier gérable – comparable à to_jpeg() à la qualité configurée, trame après trame – de sorte qu’une session de capture prolongée reste dans le budget de la carte SD. L’utilisation reflète de près l’enregistrement avec ImageIO :

import mjpeg

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

mjpeg.Mjpeg accepte les mêmes mots-clés positionnels et d’échelle de style dessin que les autres méthodes d’image, de sorte qu’un enregistrement peut être mis à l’échelle, recadré ou mappé sur une palette trame par trame à l’entrée. Les arguments width et height du constructeur prennent par défaut les dimensions du tampon d’image principal et fixent la résolution de sortie ; chaque trame ajoutée est mise à l’échelle (en préservant le rapport d’aspect) pour s’y ajuster. sync() vide le fichier vers le disque pendant un long enregistrement, et close() finalise le conteneur – un fichier Motion JPEG qui n’a pas été fermé proprement n’est pas lisible, donc la discipline compte.

Le module gif est le choix naturel pour les enregistrements courts partagés tels quels avec un spectateur non technique – quelques secondes d’action capturées pour une démonstration, une illustration animée pour la documentation, un extrait d’événement intégré à un message de discussion. Les trames GIF sont stockées non compressées (ou compressées par palette à une profondeur de couleur de 7 bits), ce qui rend les fichiers bien plus volumineux par seconde que le Motion JPEG et exclut le format pour des enregistrements de plus de quelques secondes, mais le résultat s’intègre directement dans n’importe quel navigateur :

import gif

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

L’argument delay de add_frame() est le temps d’affichage par trame en centisecondes (10 correspond à 100 ms par trame, soit 10 ips), ce qui constitue le contrôle de lecture GIF standard. Le mot-clé loop du constructeur définit si l’extrait résultant se rejoue automatiquement en boucle dans les visionneuses (la valeur par défaut est True, ce qui correspond à l’attente conventionnelle d’un « GIF animé »).

Les trois voies d’enregistrement couvrent ensemble les cas courants : ImageIO pour le retraitement sur la caméra, Motion JPEG pour les longs enregistrements lisibles sur l’hôte, GIF animé pour les courts extraits lisibles sur l’hôte. Le choix entre elles se résume à qui rejoue l’enregistrement. Une étape en aval s’exécutant sur la caméra elle-même lit l’ImageIO ; une station de travail hôte ou une visionneuse web lit le MJPEG ou le GIF.

5.33.6. Un schéma de déclenchement-et-relecture

Un schéma utile combine un flux mémoire avec une condition de déclenchement. La caméra enregistre en continu dans un tampon circulaire mémoire de count emplacements, en écrasant l’emplacement le plus ancien à chaque tour. Lorsqu’une condition de déclenchement se produit (un blob entre dans la trame, un événement de mouvement dépasse un seuil, un bouton est pressé), l’application capture le contenu de l’anneau – les count trames les plus récentes – et les écrit dans un flux fichier sur la carte SD. Le résultat est un enregistrement pré-déclenchement qui capture les secondes précédant l’événement effectivement remarqué par la caméra, et pas seulement les secondes suivantes, ce qui constitue la limitation classique d’un enregistreur naïf de type « capture-au-déclenchement ».

L’implémentation est simple une fois que l’on dispose des classes de flux : un flux mémoire de taille fixe sert d’anneau (avec un seek() explicite vers zéro lorsque le décalage atteint le nombre d’emplacements), la boucle principale y capture à chaque itération, et le gestionnaire de déclenchement lit le flux mémoire trame par trame et écrit chacune dans un flux fichier nommé d’après l’horodatage du déclenchement.