5.33. ImageIO-Streams¶
save() und to_jpeg() decken den I/O-Fall für einzelne Einzelbilder ab: Eine Anwendung erfasst ein Einzelbild, kodiert es und schiebt es irgendwohin. Eine andere Klasse von Anwendungen benötigt den Sequenz-Fall: viele Einzelbilder hintereinander mit der natürlichen Aufnahmerate aufzeichnen, sie an einem Ort speichern, von dem sie später abgerufen werden können, und sie mit der richtigen Geschwindigkeit wiedergeben. Ein Skript zur Sammlung von Trainingsdaten erfasst einige hundert Beispiel-Einzelbilder für eine Machine-Learning-Pipeline; ein Inspektionsstation-Log zeichnet jedes erfasste Teil zur Rückverfolgbarkeit auf; ein Entwicklungsskript spielt eine gespeicherte Sequenz erneut ab, um einen neuen Algorithmus gegen Daten zu testen, die zuvor live erfasst wurden.
Die Klasse ImageIO ist der Rekorder/Player des image-Moduls. Ein einzelner Stream enthält eine Sequenz von Image-Einzelbildern – möglicherweise mit unterschiedlichen Größen und Pixelformaten – zusammen mit dem Zwischenbild-Intervall jedes einzelnen, sodass die Wiedergabe die ursprüngliche Bildrate wiederherstellen kann. Es stehen zwei Backing-Stores zur Verfügung: eine Datei im Dateisystem oder ein Puffer fester Größe im RAM.
5.33.1. Die zwei Backing-Stores¶
Ein File-Stream speichert die Aufzeichnung über Stromausfälle hinweg dauerhaft und wird nur durch den ihn unterstützenden Speicher in seiner Größe begrenzt. Er beginnt mit einem 16-Byte-Magic-Header OMV IMG STR Vx.y, gefolgt von einem Chunk pro Einzelbild; der aktuelle Writer gibt V2.0 aus und der Reader akzeptiert zur Abwärtskompatibilität weiterhin V1.0- und V1.1-Dateien. Der Dateipfad ist das Konstruktor-Argument; der Modus ist der Datei-Öffnungsmodus ('r' zum Lesen eines vorhandenen Streams, 'w' zum Abschneiden und neuen Schreiben).
# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
img = csi0.snapshot()
stream.write(img)
stream.close()
Ein Memory-Stream lebt in einem RAM-Puffer, der bei der Konstruktion zugewiesen wird. Der Konstruktor nimmt ein 3-Tupel (w, h, pixformat) statt eines Pfades, und das mode-Argument wird zur vorab zugewiesenen Anzahl von Einzelbild-Slots. Der Puffer ist genau auf diese Anzahl von Einzelbildern bei den angegebenen Abmessungen dimensioniert und darf nach der Zuweisung nicht mehr wachsen – das Schreiben über den letzten Slot hinaus löst EOFError aus, und das Schreiben eines Einzelbilds, das größer als der Pro-Slot-Puffer ist, löst ValueError aus. Memory-Streams sind das richtige Werkzeug, wenn die Anwendung eine Aufzeichnung an eine nachgelagerte Stufe übergeben muss, ohne das Dateisystem zu durchlaufen (zum Beispiel ein kurzer Ringpuffer der letzten Einzelbilder für ein Trigger-and-Replay-Muster).
# Pre-allocate space for 32 QVGA RGB565 frames in RAM
stream = image.ImageIO((320, 240, image.RGB565), 32)
for _ in range(32):
stream.write(csi0.snapshot())
Für die komprimierten Pixelformate (image.JPEG, image.PNG) wird die Pro-Slot-Größe auf 2 Bit pro Pixel geschätzt; ein kodiertes Einzelbild, das größer als die Schätzung ist, löst beim Schreiben ValueError aus, sodass eine Anwendung, die hochwertige JPEGs speichern möchte, entweder die Slot-Anzahl überdimensionieren oder zuerst mit niedrigerer Qualität kodieren muss.
type() gibt image.ImageIO.FILE_STREAM oder image.ImageIO.MEMORY_STREAM zurück, sodass nachgelagerter Code sich an den jeweils bereitgestellten Backing-Store anpassen kann.
5.33.2. Aufzeichnung¶
write() hängt ein erfasstes Image an einen File-Stream an (oder speichert es im aktuellen Slot eines Memory-Streams) und rückt den Offset um eins vor. Derselbe Aufruf zeichnet das Zwischenbild-Intervall seit dem letzten Schreibvorgang auf, sodass die Wiedergabe-Hälfte für die richtige Zeitspanne zwischen den Einzelbildern pausieren kann und die natürliche Bildrate der Aufzeichnung erhalten bleibt.
Heterogene Einzelbilder sind innerhalb eines einzelnen File-Streams erlaubt: Eine Aufzeichnung kann RGB565-Aufnahmen, Graustufen-Ausschnitte und JPEG-kodierte Thumbnails frei mischen, und der Reader dekodiert jedes in seiner ursprünglichen Größe und seinem ursprünglichen Format. Memory-Streams sind homogen (alle Slots teilen sich das im Konstruktor angegebene (w, h, pixformat)), sodass eine Memory-Aufzeichnung auf eine einzige Einzelbild-Konfiguration beschränkt ist.
write() gibt das Stream-Objekt zurück, sodass Aufrufe verkettet werden können. Das Schreiben an einem Nicht-End-Offset eines File-Streams schneidet den Rest der Datei ab – nützlich zum Bearbeiten einer gespeicherten Sequenz, riskant, wenn die Position des nächsten Schreibvorgangs unbeabsichtigt durch ein früheres seek() verschoben wurde.
sync() schreibt ausstehende Schreibvorgänge bei File-Streams auf die Festplatte (bei Memory-Streams ist es eine No-Op) und sollte bei lang laufenden Aufzeichnungen regelmäßig aufgerufen werden, um den Verlust des Endes der Aufzeichnung zu vermeiden, falls die Kamera neu startet, bevor die Datei geschlossen wird. Der Destruktor schließt den Stream automatisch, wenn das ImageIO den Gültigkeitsbereich verlässt, aber ein explizites close() ist die richtige Disziplin.
5.33.3. Wiedergabe¶
read() liest das Einzelbild am aktuellen Offset, rückt den Offset vor und gibt das neue Image zurück. Der Empfänger verbleibt im Framebuffer, wenn copy_to_fb=True (die Voreinstellung), sodass das zurückgegebene Bild über die IDE-Vorschau zeichenbar ist; mit copy_to_fb=False landet das Einzelbild auf dem MicroPython-Heap.
# Loop a recorded stream at its natural frame rate
stream = image.ImageIO("/sdcard/run.bin", "r")
while True:
img = stream.read()
# img is now in the frame buffer; the IDE shows it
# and the script can run any analysis it likes
Zwei Schlüsselwörter steuern das Wiedergabeverhalten. loop=True (die Voreinstellung für File-Streams) setzt den Lesezeiger zurück an den Anfang, wenn das Ende der Aufzeichnung erreicht ist, sodass der Aufruf niemals None zurückgibt; loop=False gibt None zurück, sobald die Aufzeichnung erschöpft ist, und die Schleife des Aufrufers endet. pause=True (die Voreinstellung) blockiert den Aufruf, bis das beim Schreiben aufgezeichnete Zwischenbild-Intervall verstrichen ist, sodass die Wiedergabe-Bildrate der ursprünglichen Aufnahme-Bildrate entspricht; pause=False kehrt sofort zurück, was für Analyse-Pipelines nützlich ist, die die Aufzeichnung so schnell wie möglich durcharbeiten möchten, ohne das ursprüngliche Timing einzuhalten.
Dasselbe Schleifenmuster funktioniert auch für Memory-Streams, außer dass loop ignoriert wird – das Lesen über das Ende eines Memory-Streams hinaus löst EOFError aus. Das erwartete Muster für einen Memory-Ring ist, explizit mit seek() auf Null zurückzukehren, wenn ein Umlauf gewünscht ist.
5.33.5. Host-abspielbare Aufzeichnungen¶
ImageIO-Streams sind das richtige Werkzeug, wenn die Aufzeichnung auf der Kamera abgespielt werden soll – sie bewahren jedes erfasste Einzelbild in seinem nativen Pixelformat, das Zwischenbild-Intervall wird exakt aufgezeichnet, und ein nachgelagertes Skript kann sie durchschreiten, suchen und ohne Verlust erneut analysieren. Sie sind jedoch nicht das richtige Werkzeug, wenn die Aufzeichnung auf einem Host abspielbar sein muss – einer Workstation, einem Telefon, einem Web-Player. Ein Host erwartet einen Standard-Videocontainer, nicht das OpenMV-On-Disk-Magic-Header-Format.
Zwei separate Module decken den Host-abspielbaren Fall ab. Das mjpeg-Modul zeichnet Motion JPEG auf: eine Sequenz von JPEG-komprimierten Einzelbildern, die in einen einzigen AVI-artigen Container gepackt sind, den VLC, QuickTime, ffmpeg und das Standard-Web-Video-Tag alle direkt abspielen. Das gif-Modul zeichnet ein animiertes GIF auf: eine Sequenz von unkomprimierten (oder paletten-komprimierten) Einzelbildern mit expliziten Pro-Einzelbild-Verzögerungen, abspielbar in jedem Webbrowser oder Bildbetrachter, der animierte GIFs verarbeitet.
Das mjpeg-Modul ist die natürliche Wahl für lange Aufzeichnungen. Die JPEG-Komprimierung hält die Dateigröße überschaubar – vergleichbar mit to_jpeg() bei der konfigurierten Qualität, Einzelbild für Einzelbild – sodass eine ausgedehnte Aufnahmesitzung im Budget der SD-Karte bleibt. Die Verwendung ähnelt stark der Aufzeichnung von ImageIO:
import mjpeg
m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
m.add_frame(csi0.snapshot(), quality=85)
m.close()
mjpeg.Mjpeg akzeptiert dieselben Zeichenstil-Positions- und Skalierungs-Schlüsselwörter, die andere image-Methoden annehmen, sodass eine Aufzeichnung pro Einzelbild beim Eingang skaliert, zugeschnitten oder paletten-zugeordnet werden kann. Die Argumente width und height des Konstruktors entsprechen standardmäßig den Abmessungen des Haupt-Framebuffers und legen die Ausgabeauflösung fest; jedes angehängte Einzelbild wird (unter Beibehaltung des Seitenverhältnisses) zum Einpassen skaliert. sync() schreibt die Datei während einer langen Aufzeichnung auf die Festplatte, und close() finalisiert den Container – eine Motion-JPEG-Datei, die nicht sauber geschlossen wurde, ist nicht abspielbar, daher ist die Disziplin wichtig.
Das gif-Modul ist die natürliche Wahl für kurze Aufzeichnungen, die wortgetreu mit einem nicht-technischen Betrachter geteilt werden – ein paar Sekunden Action, die für eine Demo erfasst wurden, eine animierte Illustration für die Dokumentation, ein Ereignis-Clip, der in eine Chat-Nachricht eingebettet ist. GIF-Einzelbilder werden unkomprimiert (oder paletten-komprimiert mit 7-Bit-Farbtiefe) gespeichert, was die Dateien pro Sekunde viel größer macht als Motion JPEG und das Format für Aufzeichnungen ausschließt, die länger als ein paar Sekunden dauern, aber das Ergebnis lässt sich direkt in jeden Browser ziehen:
import gif
g = gif.Gif("/sdcard/clip.gif")
while running:
g.add_frame(csi0.snapshot(), delay=10)
g.close()
Das Argument delay bei add_frame() ist die Anzeigezeit pro Einzelbild in Hundertstelsekunden (10 entspricht 100 ms pro Einzelbild bzw. 10 fps), was die Standard-GIF-Wiedergabesteuerung ist. Das Schlüsselwort loop des Konstruktors legt fest, ob der resultierende Clip in Betrachtern automatisch in einer Schleife läuft (die Voreinstellung ist True, was der herkömmlichen Erwartung an ein „animiertes GIF“ entspricht).
Die drei Aufzeichnungspfade decken zusammen die häufigsten Fälle ab: ImageIO für die erneute Verarbeitung auf der Kamera, Motion JPEG für lange Host-abspielbare Aufzeichnungen, animiertes GIF für kurze Host-abspielbare Clips. Die Wahl zwischen ihnen läuft darauf hinaus, wer die Aufzeichnung abspielt. Eine nachgelagerte Stufe, die auf der Kamera selbst läuft, liest ImageIO; eine Host-Workstation oder ein Web-Betrachter liest MJPEG oder GIF.
5.33.6. Ein Trigger-and-Replay-Muster¶
Ein nützliches Muster kombiniert einen Memory-Stream mit einer Trigger-Bedingung. Die Kamera zeichnet kontinuierlich in einen Memory-Ringpuffer mit count Slots auf und überschreibt dabei jedes Mal den ältesten Slot. Wenn eine Trigger-Bedingung auslöst (ein Blob betritt das Einzelbild, ein Bewegungsereignis überschreitet einen Schwellenwert, eine Taste wird gedrückt), erstellt die Anwendung einen Schnappschuss des Inhalts des Rings – der letzten count Einzelbilder – und schreibt sie in einen File-Stream auf der SD-Karte. Das Ergebnis ist eine Pre-Trigger-Aufzeichnung, die die Sekunden vor dem Ereignis erfasst, das die Kamera tatsächlich bemerkt hat, nicht nur die Sekunden danach, was die klassische Einschränkung eines naiven „Erfassen-bei-Auslösung“-Rekorders ist.
Die Implementierung ist unkompliziert, sobald man die Stream-Klassen zur Hand hat: Ein Memory-Stream fester Größe dient als Ring (mit explizitem seek() auf Null, wenn der Offset die Slot-Anzahl erreicht), die Hauptschleife erfasst bei jeder Iteration in ihn hinein, und der Trigger-Handler liest den Memory-Stream Einzelbild für Einzelbild aus und schreibt jedes in einen File-Stream, der nach dem Zeitstempel des Triggers benannt ist.