12.8. Frames streamen

Het meest voorkomende praktische gebruik van een aangepast kanaal is het streamen van afbeeldingsframes van de cam naar een hostprogramma met de framesnelheid van de cam. De mechaniek is subtieler dan ze lijkt: een JPEG kan oplopen tot 25 KB of meer, dus de host leest deze als meerdere fragmenten, en de capture-lus van de cam moet worden verhinderd de buffer halverwege het lezen te overschrijven. Het juiste patroon – hier getoond en gebruikt door de tools in openmv-projects/tools/vergrendelt de buffer totdat de host de laatste byte heeft opgehaald.

12.8.1. De cam-zijde

Een framekanaal dat in een enkele framebuffer vastlegt, deze vergrendelt bij de eerste leesactie van de host, en pas de volgende momentopname maakt nadat de host de volledige afbeelding heeft verbruikt:

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

Vier onderdelen doen hier het echte werk:

  • frame_available is de vergrendeling. De capture-lus maakt alleen een nieuwe momentopname wanneer deze False is – wat betekent dat de host de laatste byte van het vorige frame heeft opgehaald. De leesactie van de host zet deze terug op False van binnenuit readp zodra de laatste offset is geserveerd. Zonder deze beveiliging zou de volgende csi0.snapshot() de buffer halverwege het lezen overschrijven en zou de host een frame ontvangen dat uit twee opnamen aan elkaar is gezet.

  • readp in plaats van read is wat de backend implementeert. De protocolbibliotheek behandelt de geretourneerde buffer als gezaghebbend en leest de bytes ervan rechtstreeks in het uitgaande pakket – zonder kopie. Voor framegrote payloads is readp merkbaar sneller dan read, dat een tussenliggende kopie afdwingt.

  • size retourneert de gecachete JPEG-lengte zonder iets opnieuw te berekenen; de capture-lus onderhoudt deze telkens wanneer ze de buffer ververst. De host roept size aan tussen poll en readp om te weten hoeveel bytes opgehaald moeten worden.

  • send_event() stelt de host op de hoogte zodra een nieuw frame binnenkomt, zodat deze kan beginnen met ophalen zonder te pollen. De event-ID 0x01 is toepassingsgedefinieerd (“frame gereed” in dit geval); gebruik voor elk soort melding een ander klein geheel getal.

12.8.2. Fragmentatie

QVGA RGB565 op JPEG-kwaliteit 85 comprimeert tot ongeveer 10-25 KB, afhankelijk van het tafereel – veel groter dan de onderhandelde maximale payload op elke cam (zie de tabel per board in protocol.init()). Eén JPEG-leesactie past niet in één pakket, en dat is prima, want de protocolbibliotheek fragmenteert deze transparant.

Wanneer de host om channel_read('frame', 12000) vraagt:

  1. De readp van de cam wordt eenmaal aangeroepen met offset=0 en het volledige verzoek van 12000 bytes. Deze retourneert één memoryview die het hele bereik beslaat.

  2. De protocolbibliotheek breekt die memoryview op de draad op in fragmenten ter grootte van de maximale payload, één CHANNEL_READ-antwoordpakket per fragment, elk met een eigen header en CRC. De bytes worden rechtstreeks uit de buffer van de backend gestreamd – zonder kopie.

  3. De host ontvangt de fragmenten op volgorde, de betrouwbaarheidslaag verzendt elk stuk dat zijn CRC niet doorstaat opnieuw, en de host-SDK lijmt de stukken aan elkaar tot het resultaat van 12000 bytes dat aan de aanroeper wordt geretourneerd.

Notitie

Dit is het belangrijkste praktische verschil tussen readp en read. readp wordt eenmaal per hostverzoek aangeroepen; de protocollaag fragmenteert en verzendt vanuit de enkele geretourneerde buffer. read wordt eenmaal per fragment aangeroepen, en de bibliotheek kopieert elk geretourneerd stuk in zijn eigen pakketbuffer. Voor framegrote payloads bespaart readp zowel de Python-aanroepoverhead per fragment als de kopie.

Tip

Wil je het verschil zelf zien? Hernoem de readp-methode van de backend naar read – verder verandert er niets; de bibliotheek zal in plaats daarvan de read-mogelijkheid oppikken – en vergelijk de framesnelheidsteller van de host voor en na. Het tragere getal is de kopie per fragment en de Python-aanroepkost die je vermijdt door readp te gebruiken.

De vergrendeling in FrameChannel.readp geeft de buffer vrij wanneer offset + size == img_size – het moment waarop de host de laatste byte heeft opgehaald. Tot dan moet de buffer geldig blijven, en daarom maakt de capture-lus pas de volgende momentopname zodra frame_available terugspringt naar False.

12.8.3. De host-zijde

De host haalt frames op in een strakke lus:

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

De aanroep channel_size() fungeert tegelijk als een “is er iets gereed”-controle – nul betekent dat de cam nog niet heeft opgenomen – zodat de lus leespogingen op een lege buffer overslaat. Voor GUI-toepassingen die al op een timer pollen, is dit het natuurlijke patroon.

Pillow’s Image.open decodeert de JPEG; de cam heeft deze al JPEG-gecomprimeerd, zodat de host het dure bit-packing op RGB565 niet hoeft over te doen. Het hostscript zou de bytes net zo gemakkelijk naar schijf kunnen opslaan, aan OpenCV kunnen doorgeven, of door een webweergave kunnen sturen.

12.8.4. Nadenken over doorvoer

Drie dingen begrenzen de haalbare framesnelheid:

  • De capture-snelheid van de cam. Het protocol kan geen frames sneller leveren dan de sensor ze produceert; welke limiet het gekozen pixelformaat en de framegrootte ook op de capture leggen, dat is het plafond.

  • De onderhandelde maximale payload. Grotere payloads betekenen minder fragmenten per frame en minder framing-overhead, dus cams met grotere protocolbuffers verplaatsen bytes sneller dan kleinere.

  • CRC- en ACK-overhead. Elk pakket kost 14 bytes framing plus één ACK-retour. Voor lange fragmenten is de overhead per payload klein; voor kleine payloads domineert deze.

Voor de meeste cam-naar-laptop GUI-werk is de beperkende factor de capture- en JPEG-compressietijd van de cam, niet de protocolstack. Waar het protocol wel de bottleneck wordt – bijvoorbeeld bij het streamen van ongecomprimeerde ruwe frames met hoge framesnelheden – zijn de hefbomen het uitschakelen van ACKs (protocol.init(ack=False)), het vergroten van de protocolbuffer als de cam dit ondersteunt, of het opnemen in GRAYSCALE zodat elke gecomprimeerde JPEG één kanaal draagt in plaats van drie en het gecodeerde frame merkbaar kleiner op de draad uitkomt.

Het framekanaal is de canonieke cam-naar-host datastroom. Dezelfde backend-interface, met een write-methode toegevoegd, laat de host ook data de andere kant op sturen – wat een interactieve cam-tool nodig heeft zodra de operator iets wil veranderen in plaats van alleen toekijken.