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_availableist die Verriegelung (Latch). Die Aufnahmeschleife nimmt nur dann einen neuen Schnappschuss auf, wenn sieFalseist – das heißt, der Host hat das letzte Byte des vorherigen Einzelbilds abgerufen. Der Lesevorgang des Hosts setzt sie innerhalb vonreadpwieder aufFalse, sobald der letzte Offset bedient wurde. Ohne diese Absicherung würde der nächstecsi0.snapshot()den Puffer mitten im Lesen überschreiben, und der Host würde ein aus zwei Aufnahmen zusammengesetztes Einzelbild erhalten.readpstattreadist 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 istreadpmerklich schneller alsread, das eine Zwischenkopie erzwingt.sizegibt die zwischengespeicherte JPEG-Länge zurück, ohne etwas neu zu berechnen; die Aufnahmeschleife pflegt sie, wann immer sie den Puffer aktualisiert. Der Host ruftsizezwischenpollundreadpauf, 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-ID0x01ist 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:
Das
readpder Kamera wird einmal mitoffset=0und der vollständigen 12000-Byte-Anforderung aufgerufen. Es gibt eine einzige memoryview zurück, die den gesamten Bereich abdeckt.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.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.