5.1. L’objet Image¶
Un algorithme de traitement d’image parcourt une image un pixel à la fois. À chaque position, il effectue une opération simple – lire une valeur, la comparer à un seuil, la combiner avec le pixel correspondant d’une deuxième image, réécrire un résultat. Répétées sur toute une trame, ces décisions simples par pixel sont les fondements de la détection de contours, du suivi de blobs, du décodage de QR codes et de toutes les autres techniques classiques de vision par ordinateur. Pour accomplir ce travail efficacement, l’algorithme doit savoir où chaque pixel se trouve en mémoire, ce que la valeur de chaque pixel signifie réellement, et quelle portion de l’image il doit examiner. L’objet image.Image est celui qui organise ces informations.
Vision Sensors s’est terminé au moment où csi.CSI.snapshot() retourne. Quelle que soit la machinerie côté caméra qui a produit la trame capturée, son travail est déjà fait ; l’application a l’objet Image en main et doit savoir quoi en faire.
5.1.1. Le tampon et ses propriétés¶
À l’intérieur de l’objet Image se trouvent un pointeur vers un bloc contigu d’octets en RAM et un petit en-tête portant trois éléments de métadonnées : la largeur de l’image en pixels, sa hauteur en pixels, et le format de pixel dans lequel les octets sont stockés. Les octets sont les pixels eux-mêmes, stockés dans l’ordre par lignes (row-major) – d’abord tous les pixels de la rangée du haut, puis tous ceux de la deuxième rangée, et ainsi de suite jusqu’au bas. Les propriétés décrivent comment les lire.
La largeur et la hauteur sont de simples nombres entiers. Le format de pixel est la propriété la plus intéressante, car il détermine combien d’octets occupe chaque pixel et ce que ces octets encodent. Une image en niveaux de gris porte un octet par pixel contenant une valeur de luminosité. Une image RGB565 porte deux octets par pixel contenant des champs rouge, vert et bleu compactés dans un mot de 16 bits. Une image Bayer porte un octet par pixel, mais chaque pixel est échantillonné à travers l’un des trois filtres de couleur choisi selon sa position dans la mosaïque. Vision Sensors a énuméré tout le catalogue ; ce qui importe ici, c’est qu’exactement l’un de ces formats est défini sur chaque Image, et que ce choix détermine le calcul des octets par pixel ainsi que la signification de tout octet individuel dans le tampon.
Avec un pointeur vers le tampon, la largeur, la hauteur et le format, toute autre propriété qu’un algorithme pourrait vouloir se déduit par un court calcul. L’octet qui commence le pixel (x, y) se situe au décalage (y * width + x) * bytes_per_pixel depuis le début du tampon. Le nombre total d’octets est width * height * bytes_per_pixel. L’adresse de la rangée suivante se trouve exactement width * bytes_per_pixel octets après le début de la rangée courante. L’objet Image expose les trois propriétés via de simples appels de méthode – width(), height(), format() – plus la valeur dérivée size via size(). Les méthodes ailleurs dans le module utilisent ces valeurs pour effectuer elles-mêmes le calcul des décalages ; le code applicatif n’a que rarement à le faire.
Un objet Image est une petite enveloppe Python qui pointe vers un bloc de mémoire contigu : un en-tête portant la largeur, la hauteur et le format de pixel, suivi du tampon de pixels lui-même.¶
5.1.2. D’où provient le tampon¶
Le scénario par défaut tout au long de ce chapitre est celui que Vision Sensors a déjà couvert : une trame capturée arrive depuis snapshot, les octets se trouvent dans le tampon d’image de la caméra, et l’objet Image retourné pointe vers eux. Trois autres façons d’en obtenir un reviennent régulièrement, et chacune implique quelque chose de différent quant à l’endroit où le tampon aboutit.
Charger depuis un fichier ressemble à passer un chemin au constructeur : image.Image("/sdcard/saved.jpg"). Le module lit le fichier dans un tampon fraîchement alloué sur le tas Python. Les fichiers BMP, PGM et PPM sont décodés au passage et l’objet Image résultant porte un format de pixel non compressé. Les fichiers JPEG et PNG restent compressés – l’objet Image porte le format JPEG ou PNG, et le tampon contient le flux d’octets du fichier essentiellement inchangé. Pour effectuer le moindre travail au niveau des pixels sur une image compressée, l’application la convertit d’abord via to_rgb565() ou to_grayscale(), et c’est lors de cette conversion que la décompression – et l’expansion correspondante du tas, où un JPEG de 30 Ko peut devenir 600 Ko de RGB565 – se produit réellement. Le chargement depuis un fichier est surtout utile pendant le développement, lorsqu’un algorithme doit être testé sur une trame de référence connue stockée à côté du script.
Construire une image de toutes pièces est le cas du canevas : image.Image(320, 240, image.RGB565) demande au module d’allouer ce nombre d’octets dans ce format, de mettre le contenu à zéro et de rendre l’enveloppe. Les pixels ne signifient encore rien – ils valent tous zéro – mais l’image vide est le cheval de bataille pour une poignée de motifs récurrents : trames de référence dont on soustrait une trame courante, canevas sur lesquels on compose des superpositions graphiques, tampons binaires que l’on remplit et utilise comme masques.
Construire à partir d’un ndarray fait le pont dans l’autre sens, depuis n’importe quel calcul numérique vers le module image. Passer un ulab.numpy.ndarray float32 au constructeur produit un objet Image dont les dimensions correspondent au ndarray – une forme à deux axes (h, w) devient une image en niveaux de gris, une forme à trois axes (h, w, 3) devient du RGB565 – avec les valeurs flottantes mises à l’échelle de 0.0 – 255.0 vers la plage de pixels entiers. Une carte thermique de réseau de neurones, un tableau numérique de tout type, tout ce que produit ml ou ulab devient quelque chose que les fonctions de dessin et d’inspection du module image peuvent utiliser.
Les quatre sources rendent le même type d’objet Image. Le code qui utilise l’objet retourné n’a jamais à suivre d’où il provient.
5.1.3. Deux vues sur les octets¶
La plupart du temps, le code applicatif traite un objet Image comme un objet image typé – une chose dotée de méthodes nommées. L’autre moitié de l’histoire est que le même objet apparaît aussi, de manière transparente, comme une séquence plate d’octets pour toute API MicroPython qui accepte un argument bytes. Les octets ne sont pas une copie du tampon ; ils en sont une vue directe.
Cet arrangement est ce qui permet d’envoyer une trame capturée hors de la caméra en une seule ligne. Calculer son empreinte de hachage, l’envoyer sur un port série, la transmettre à une socket réseau – aucune de ces opérations n’a besoin d’une étape distincte de « conversion de l’image en octets » :
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 vue de type octets est en lecture seule par défaut, à dessein. Les tampons d’image sont volumineux et parfois partagés entre les couches de la pile d’imagerie, donc donner à un anodin buf[0] = 0 quelque part au fond d’une pile d’appels le pouvoir d’en corrompre un silencieusement serait une arête trop tranchante à laisser exposée. Lorsque l’accès aux octets en lecture-écriture est ce dont l’application a réellement besoin – écrire une valeur d’étalonnage à un décalage connu, par exemple – bytearray() retourne une vue distincte, explicitement en lecture-écriture, sur la même mémoire, signalant l’intention sur le site d’appel.
5.1.4. Où réside le tampon¶
Les tampons de pixels sont suffisamment volumineux pour que leur emplacement en RAM ait son importance. Une trame QQVGA RGB565 fait 160 × 120 × 2 = 38 400 octets ; une trame VGA RGB565 fait 614 400 octets ; une entrée RGB565 de 224 × 224 qu’un classificateur à réseau de neurones pourrait consommer fait environ 100 Ko. Le tas Python sur les plus petites caméras peut n’être que de quelques dizaines de kilo-octets une fois le runtime démarré. Conserver plus d’une ou deux trames de données d’image sur le tas en évincerait tout le reste.
La solution est que les tampons d’image ne résident pour la plupart pas sur le tas Python. Ils résident dans la région dédiée de RAM que Vision Sensors a présentée sous le nom de frame buffer (tampon d’image) – la même mémoire dans laquelle le DMA de la caméra écrit les trames capturées et d’où l’aperçu de l’IDE lit les trames finies. La plupart des opérations sur un objet Image modifient leur source sur place : l’algorithme lit les pixels, décide, réécrit de nouvelles valeurs, et aucune image de résultat distincte n’est allouée. Les opérations qui produisent bel et bien un résultat distinct – les conversions de format et quelques autres – peuvent se voir demander de placer ce résultat dans le tampon d’image via l’argument nommé copy_to_fb. copy_to_fb=True fait deux choses à la fois : il place l’image de résultat dans le tampon d’image plutôt que sur le tas (contournant la pression sur le tas) et il fait de ce résultat la prochaine trame que l’aperçu de l’IDE affichera. Ajouter copy_to_fb=True à l’étape finale d’un pipeline, regarder le résultat apparaître à l’écran, puis itérer à partir de là est l’un des idiomes de débogage les plus utiles en traitement d’image.
Avec une enveloppe contenant un tampon étiqueté, quatre façons d’en faire exister un, deux vues sur ses octets, et un commutateur décidant où atterrissent les nouveaux, l’objet Image n’est plus un mystère. Les questions fondamentales restantes – comment une position de pixel est nommée, ce que chaque pixel contient réellement, comment restreindre une opération à une portion d’une image – s’appuient toutes sur lui.