12.8. Streaming von Einzelbildern

Die häufigste reale Anwendung eines benutzerdefinierten Kanals ist das Streaming von Bildeinzelbildern von der Kamera zu einem Host-Programm mit der Bildrate der Kamera. Die Mechanik ist subtiler, als sie aussieht: Ein JPEG kann 25 KB oder mehr umfassen, sodass der Host es in mehreren Fragmenten liest, und die Aufnahmeschleife der Kamera muss daran gehindert werden, den Puffer mitten im Lesen zu überschreiben. Das richtige Muster – hier gezeigt und von den Tools in openmv-projects/tools/ verwendet – verriegelt (latch) den Puffer, bis der Host das letzte Byte abgerufen hat.

12.8.1. Die Kameraseite

Ein Einzelbildkanal, der in einen einzigen Framebuffer aufnimmt, ihn beim ersten Lesevorgang des Hosts verriegelt und erst dann den nächsten Schnappschuss aufnimmt, wenn der Host das vollständige Bild verbraucht hat:

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 Komponenten leisten hier echte Arbeit:

  • frame_available ist die Verriegelung (Latch). Die Aufnahmeschleife nimmt nur dann einen neuen Schnappschuss auf, wenn sie False ist – das heißt, der Host hat das letzte Byte des vorherigen Einzelbilds abgerufen. Der Lesevorgang des Hosts setzt sie innerhalb von readp wieder auf False, sobald der letzte Offset bedient wurde. Ohne diese Absicherung würde der nächste csi0.snapshot() den Puffer mitten im Lesen überschreiben, und der Host würde ein aus zwei Aufnahmen zusammengesetztes Einzelbild erhalten.

  • readp statt read ist das, was das Backend implementiert. Die Protokollbibliothek behandelt den zurückgegebenen Puffer als maßgeblich und liest seine Bytes direkt in das ausgehende Paket – ohne Kopie. Für einzelbildgroße Nutzdaten ist readp merklich schneller als read, das eine Zwischenkopie erzwingt.

  • size gibt die zwischengespeicherte JPEG-Länge zurück, ohne etwas neu zu berechnen; die Aufnahmeschleife pflegt sie, wann immer sie den Puffer aktualisiert. Der Host ruft size zwischen poll und readp auf, um zu wissen, wie viele Bytes er abrufen muss.

  • send_event() benachrichtigt den Host in dem Moment, in dem ein neues Einzelbild eintrifft, sodass er mit dem Abrufen beginnen kann, ohne zu pollen. Die Event-ID 0x01 ist anwendungsdefiniert („frame ready“ in diesem Fall); verwenden Sie für jede Art von Benachrichtigung eine andere kleine Ganzzahl.

12.8.2. Fragmentierung

QVGA RGB565 bei JPEG-Qualität 85 komprimiert sich je nach Szene auf etwa 10-25 KB – deutlich größer als die ausgehandelte maximale Nutzlast auf jeder Kamera (siehe die boardspezifische Tabelle in protocol.init()). Ein JPEG-Lesevorgang passt nicht in ein einzelnes Paket, und das ist in Ordnung, denn die Protokollbibliothek fragmentiert es transparent.

Wenn der Host channel_read('frame', 12000) anfordert:

  1. Das readp der Kamera wird einmal mit offset=0 und der vollständigen 12000-Byte-Anforderung aufgerufen. Es gibt eine einzige memoryview zurück, die den gesamten Bereich abdeckt.

  2. Die Protokollbibliothek zerlegt diese memoryview auf der Leitung in Fragmente in Größe der maximalen Nutzlast, ein CHANNEL_READ-Antwortpaket pro Fragment, jeweils mit eigenem Header und CRC. Die Bytes werden direkt aus dem Puffer des Backends herausgestreamt – ohne Kopie.

  3. Der Host empfängt die Fragmente in der richtigen Reihenfolge, die Zuverlässigkeitsschicht überträgt jeden einzelnen Chunk erneut, der seinen CRC nicht besteht, und das Host-SDK fügt die Chunks zu dem 12000-Byte-Ergebnis zusammen, das an den Aufrufer zurückgegeben wird.

Bemerkung

Dies ist der entscheidende praktische Unterschied zwischen readp und read. readp wird einmal pro Host-Anforderung aufgerufen; die Protokollschicht fragmentiert und überträgt aus dem einen zurückgegebenen Puffer. read wird einmal pro Fragment aufgerufen, und die Bibliothek kopiert jeden zurückgegebenen Chunk in seinen eigenen Paketpuffer. Für einzelbildgroße Nutzdaten spart readp sowohl den Aufruf-Overhead auf Python-Ebene pro Fragment als auch die Kopie.

Tipp

Möchten Sie den Unterschied selbst sehen? Benennen Sie die Methode readp des Backends in read um – nichts anderes ändert sich; die Bibliothek nimmt stattdessen die read-Fähigkeit auf – und vergleichen Sie den Bildraten-Zähler des Hosts vorher und nachher. Die langsamere Zahl ist der Kopier- und Python-Aufruf-Aufwand pro Fragment, den Sie durch die Verwendung von readp vermeiden.

Die Verriegelung in FrameChannel.readp gibt den Puffer frei, wenn offset + size == img_size ist – in dem Moment, in dem der Host das letzte Byte abgerufen hat. Bis dahin muss der Puffer gültig bleiben, weshalb die Aufnahmeschleife den nächsten Schnappschuss erst aufnimmt, wenn frame_available wieder auf False umspringt.

12.8.3. Die Hostseite

Der Host ruft Einzelbilder in einer engen Schleife ab:

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

Der Aufruf von channel_size() dient zugleich als Prüfung „ist etwas bereit“ – null bedeutet, dass die Kamera noch nichts aufgenommen hat – sodass die Schleife Leseversuche auf einem leeren Puffer überspringt. Für GUI-Anwendungen, die ohnehin per Timer pollen, ist dies das natürliche Muster.

Pillows Image.open dekodiert das JPEG; die Kamera hat es bereits JPEG-komprimiert, sodass der Host das aufwendige Bit-Packing auf RGB565 nicht wiederholen muss. Das Host-Skript könnte die Bytes ebenso gut auf die Festplatte speichern, an OpenCV übergeben oder durch eine Webansicht schieben.

12.8.4. Überlegungen zum Durchsatz

Drei Dinge begrenzen die erreichbare Bildrate:

  • Die Aufnahmerate der Kamera. Das Protokoll kann Einzelbilder nicht schneller liefern, als der Sensor sie erzeugt; welches Limit auch immer das gewählte Pixelformat und die Einzelbildgröße der Aufnahme auferlegen, ist die Obergrenze.

  • Die ausgehandelte maximale Nutzlast. Größere Nutzdaten bedeuten weniger Fragmente pro Einzelbild und weniger Framing-Overhead, sodass Kameras mit größeren Protokollpuffern Bytes schneller bewegen als kleinere.

  • CRC- und ACK-Overhead. Jedes Paket kostet 14 Bytes Framing plus einen ACK-Roundtrip. Bei langen Fragmenten ist der Overhead pro Nutzlast gering; bei winzigen Nutzdaten dominiert er.

Bei den meisten Kamera-zu-Laptop-GUI-Arbeiten ist der begrenzende Faktor die Aufnahme- und JPEG-Kompressionszeit der Kamera, nicht der Protokollstack. Wo das Protokoll doch zum Engpass wird – etwa beim Streaming unkomprimierter Roh-Einzelbilder mit hohen Bildraten – sind die Stellschrauben das Abschalten von ACKs (protocol.init(ack=False)), das Vergrößern des Protokollpuffers, falls die Kamera dies unterstützt, oder die Aufnahme in GRAYSCALE, sodass jedes komprimierte JPEG einen Kanal statt drei trägt und das kodierte Einzelbild auf der Leitung merklich kleiner ausfällt.

Der Einzelbildkanal ist der kanonische Datenfluss von der Kamera zum Host. Dieselbe Backend-Schnittstelle, ergänzt um eine write-Methode, ermöglicht es dem Host, Daten auch in die andere Richtung zu schieben – genau das, was ein interaktives Kameratool benötigt, sobald der Bediener etwas ändern möchte, statt nur zuzuschauen.