12.8. Diffusion de trames¶
L’utilisation concrète la plus courante d’un canal personnalisé consiste à diffuser des trames d’image de la caméra vers un programme hôte, à la cadence de la caméra. La mécanique est plus subtile qu’il n’y paraît : un JPEG peut atteindre 25 Ko ou plus, l’hôte le lit donc sous forme de plusieurs fragments, et il faut empêcher la boucle de capture de la caméra d’écraser le tampon en cours de lecture. Le bon modèle – montré ici et utilisé par les outils du répertoire openmv-projects/tools/ – verrouille le tampon jusqu’à ce que l’hôte ait récupéré le dernier octet.
12.8.1. Côté caméra¶
Un canal de trames qui capture dans un unique tampon d’image, le verrouille à la première lecture de l’hôte et ne prend la capture suivante qu’une fois que l’hôte a consommé l’image complète
import csi
import protocol
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QVGA)
csi0.framebuffers(1)
img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True
class FrameChannel:
def poll(self):
return frame_available
def size(self):
return img_size
def readp(self, offset, size):
global frame_available
end = offset + size
mv = img_mv[offset:end]
if end == img_size:
# Host has just read the last byte of this frame --
# release the buffer so the capture loop can refresh.
frame_available = False
return mv
ch = protocol.register(name='frame', backend=FrameChannel())
while True:
if not frame_available:
img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True
ch.send_event(0x01) # notify host that a new frame is ready
Quatre éléments font ici le vrai travail :
frame_availableest le verrou. La boucle de capture ne prend une nouvelle capture que lorsqu’il vautFalse– ce qui signifie que l’hôte a récupéré le dernier octet de la trame précédente. La lecture de l’hôte le remet àFalsedepuis l’intérieur dereadpune fois le décalage final servi. Sans cette protection, le prochaincsi0.snapshot()écraserait le tampon en cours de lecture et l’hôte recevrait une trame assemblée à partir de deux captures.C’est
readpplutôt quereadque le backend implémente. La bibliothèque de protocole considère le tampon renvoyé comme faisant autorité et lit ses octets directement dans le paquet sortant – sans copie. Pour des charges utiles de la taille d’une trame,readpest nettement plus rapide queread, qui force une copie intermédiaire.sizerenvoie la longueur JPEG mise en cache sans rien recalculer ; la boucle de capture la maintient chaque fois qu’elle rafraîchit le tampon. L’hôte appellesizeentrepolletreadppour savoir combien d’octets récupérer.send_event()notifie l’hôte à l’instant même où une nouvelle trame arrive, afin qu’il puisse commencer à la récupérer sans interrogation. L’identifiant d’événement0x01est défini par l’application (« trame prête » dans ce cas) ; utilisez un petit entier différent pour chaque type de notification.
12.8.2. Fragmentation¶
Une image QVGA RGB565 à une qualité JPEG de 85 se compresse à environ 10-25 Ko, selon la scène – bien plus que la charge utile maximale négociée sur n’importe quelle caméra (voir le tableau par carte dans protocol.init()). Une lecture JPEG ne tient pas dans un seul paquet, et ce n’est pas un problème, car la bibliothèque de protocole la fragmente de façon transparente.
Lorsque l’hôte demande channel_read('frame', 12000) :
Le
readpde la caméra est appelé une seule fois avecoffset=0et la requête complète de 12000 octets. Il renvoie une seule memoryview couvrant toute la plage.La bibliothèque de protocole découpe cette memoryview en fragments de la taille de la charge utile maximale sur le fil, un paquet de réponse
CHANNEL_READpar fragment, chacun avec son propre en-tête et son CRC. Les octets sont diffusés directement à partir du tampon du backend – sans copie.L’hôte reçoit les fragments dans l’ordre, la couche de fiabilité retransmet tout fragment qui échoue à son CRC, et le SDK hôte recolle les fragments en un résultat de 12000 octets renvoyé à l’appelant.
Note
C’est là la différence pratique essentielle entre readp et read. readp est appelé une seule fois par requête de l’hôte ; la couche de protocole fragmente et transmet à partir de l’unique tampon renvoyé. read est appelé une fois par fragment, et la bibliothèque copie chaque fragment renvoyé dans son propre tampon de paquet. Pour des charges utiles de la taille d’une trame, readp économise à la fois le coût de l’appel Python par fragment et la copie.
Astuce
Vous voulez constater l’écart par vous-même ? Renommez la méthode readp du backend en read – rien d’autre ne change ; la bibliothèque détectera la capacité read à la place – et comparez le compteur de cadence d’images de l’hôte avant et après. Le chiffre le plus lent correspond au coût de copie par fragment et d’appel Python que vous évitez en utilisant readp.
Le verrou dans FrameChannel.readp libère le tampon lorsque offset + size == img_size – au moment où l’hôte a récupéré le dernier octet. Jusque-là, le tampon doit rester valide, ce qui explique pourquoi la boucle de capture ne prend la capture suivante qu’une fois que frame_available repasse à False.
12.8.3. Côté hôte¶
L’hôte récupère les trames dans une boucle serrée
import io
from PIL import Image
from openmv.camera import Camera
with Camera('/dev/ttyACM0', baudrate=921600) as cam:
cam.update_channels()
while True:
size = cam.channel_size('frame')
if not size:
continue
data = cam.channel_read('frame', size)
img = Image.open(io.BytesIO(data))
img.show() # or feed to a GUI
L’appel channel_size() fait aussi office de vérification « y a-t-il quelque chose de prêt » – zéro signifie que la caméra n’a pas encore capturé – de sorte que la boucle évite les tentatives de lecture sur un tampon vide. Pour les applications graphiques qui interrogent déjà sur un minuteur, c’est le modèle naturel.
Le Image.open de Pillow décode le JPEG ; la caméra l’a déjà compressé en JPEG, donc l’hôte n’a pas à refaire le coûteux empaquetage de bits sur du RGB565. Le script hôte pourrait tout aussi bien enregistrer les octets sur le disque, les transmettre à OpenCV ou les pousser dans une vue web.
12.8.4. Réflexion sur le débit¶
Trois éléments limitent la cadence d’images atteignable :
La cadence de capture de la caméra. Le protocole ne peut pas livrer les trames plus vite que le capteur ne les produit ; quelle que soit la limite que le format de pixel et la taille de trame choisis imposent à la capture, c’est le plafond.
La charge utile maximale négociée. Des charges utiles plus grandes signifient moins de fragments par trame et moins de surcoût d’encadrement, de sorte que les caméras dotées de tampons de protocole plus grands transfèrent les octets plus vite que les plus petites.
Le surcoût du CRC et de l’ACK. Chaque paquet coûte 14 octets d’encadrement plus un aller-retour d’ACK. Pour les longs fragments, le surcoût par charge utile est faible ; pour les charges utiles minuscules, il domine.
Pour la plupart des travaux d’interface graphique caméra-vers-ordinateur portable, le facteur limitant est le temps de capture et de compression JPEG de la caméra, et non la pile de protocole. Là où le protocole devient effectivement le goulot d’étranglement – la diffusion de trames brutes non compressées à des cadences élevées, par exemple – les leviers sont la désactivation des ACK (protocol.init(ack=False)), l’augmentation du tampon de protocole si la caméra le permet, ou la capture en GRAYSCALE pour que chaque JPEG compressé porte un canal au lieu de trois et que la trame encodée finisse nettement plus petite sur le fil.
Le canal de trames est le flux de données canonique de la caméra vers l’hôte. La même interface de backend, avec une méthode write ajoutée, permet à l’hôte de pousser des données dans l’autre sens également – ce dont un outil interactif de caméra a besoin dès que l’opérateur veut modifier quelque chose plutôt que de simplement observer.