5.33. Strumienie ImageIO

save() i to_jpeg() obsługują przypadek wejścia/wyjścia dla pojedynczej ramki: aplikacja przechwytuje ramkę, koduje ją i przesyła gdzieś dalej. Inna klasa aplikacji potrzebuje obsługi sekwencji: nagrania wielu ramek z rzędu z naturalną szybkością przechwytywania, zapisania ich w miejscu, z którego można je później odczytać, oraz odtworzenia z właściwą prędkością. Skrypt zbierający dane treningowe przechwytuje kilkaset przykładowych ramek dla potoku uczenia maszynowego; dziennik stacji kontroli rejestruje każdą przechwyconą część w celu zapewnienia identyfikowalności; skrypt deweloperski odtwarza zapisaną sekwencję, aby przetestować nowy algorytm na danych przechwyconych wcześniej na żywo.

Klasa ImageIO jest rejestratorem/odtwarzaczem modułu image. Pojedynczy strumień przechowuje sekwencję ramek Image – być może o różnych rozmiarach i formatach pikseli – wraz z odstępem międzyramkowym każdej z nich, dzięki czemu odtwarzanie może odtworzyć oryginalną liczbę klatek. Dostępne są dwa magazyny danych: plik w systemie plików lub bufor o stałym rozmiarze w pamięci RAM.

5.33.1. Dwa magazyny danych

Strumień plikowy przechowuje nagranie pomiędzy cyklami zasilania, a jego rozmiar jest ograniczony jedynie przez magazyn, który go przechowuje. Zaczyna się od 16-bajtowego nagłówka magicznego OMV IMG STR Vx.y po którym następuje jeden fragment na ramkę; bieżący zapisujący generuje wersję V2.0, a odczytujący nadal akceptuje pliki V1.0 i V1.1 w celu zachowania wstecznej kompatybilności. Ścieżka pliku jest argumentem konstruktora; tryb to tryb otwarcia pliku ('r' dla odczytu istniejącego strumienia, 'w' dla obcięcia i zapisu od nowa).

# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
    img = csi0.snapshot()
    stream.write(img)
stream.close()

Strumień pamięciowy znajduje się w buforze RAM przydzielonym podczas konstrukcji. Konstruktor przyjmuje 3-elementową krotkę (w, h, pixformat) zamiast ścieżki, a argument mode staje się wstępnie przydzieloną liczbą gniazd ramek. Bufor ma rozmiar dokładnie dopasowany do tej liczby ramek o podanych wymiarach i nie może rosnąć po przydzieleniu – zapis poza ostatnie gniazdo zgłasza EOFError, a zapis ramki większej niż bufor pojedynczego gniazda zgłasza ValueError. Strumienie pamięciowe są właściwym narzędziem, gdy aplikacja musi przekazać nagranie do kolejnego etapu bez przechodzenia przez system plików (na przykład krótki bufor cykliczny ostatnich ramek dla wzorca wyzwalania i odtwarzania).

# 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())

W przypadku skompresowanych formatów pikseli (image.JPEG, image.PNG) rozmiar pojedynczego gniazda jest szacowany na 2 bity na piksel; zakodowana ramka większa niż oszacowanie zgłasza ValueError podczas zapisu, więc aplikacja, która zamierza przechowywać wysokiej jakości pliki JPEG, musi albo nadmiarowo przydzielić liczbę gniazd, albo najpierw zakodować przy niższej jakości.

type() zwraca image.ImageIO.FILE_STREAM lub image.ImageIO.MEMORY_STREAM, dzięki czemu kod kolejnego etapu może dostosować się do tego, jaki magazyn danych otrzymał.

5.33.2. Nagrywanie

write() dołącza przechwycony obraz Image do strumienia plikowego (lub zapisuje go w bieżącym gnieździe strumienia pamięciowego) i przesuwa offset o jeden. To samo wywołanie rejestruje odstęp międzyramkowy od ostatniego zapisu, dzięki czemu strona odtwarzająca może wstrzymać się na odpowiedni czas pomiędzy ramkami i zachowana zostaje naturalna liczba klatek nagrania.

W ramach pojedynczego strumienia plikowego dozwolone są niejednorodne ramki: nagranie może swobodnie mieszać przechwyty RGB565, przycięcia w skali szarości i miniatury zakodowane w JPEG, a odczytujący zdekoduje każdą z nich w jej oryginalnym rozmiarze i formacie. Strumienie pamięciowe są jednorodne (wszystkie gniazda współdzielą podane w konstruktorze (w, h, pixformat)), więc nagranie pamięciowe jest ograniczone do jednej konfiguracji ramki.

write() zwraca obiekt strumienia, dzięki czemu wywołania można łączyć w łańcuch. Zapis w pozycji innej niż koniec strumienia plikowego obcina resztę pliku – przydatne przy edycji zapisanej sekwencji, ryzykowne, jeśli pozycja następnego zapisu została niezamierzenie przesunięta przez wcześniejsze seek().

sync() opróżnia oczekujące zapisy na dysk w przypadku strumieni plikowych (jest operacją pustą dla strumieni pamięciowych) i powinno być wywoływane okresowo, gdy nagranie trwa długo, aby uniknąć utraty końcówki nagrania, jeśli kamera zrestartuje się przed zamknięciem pliku. Destruktor automatycznie zamyka strumień, gdy ImageIO wychodzi poza zakres, ale właściwą praktyką jest jawne close().

5.33.3. Odtwarzanie

read() odczytuje ramkę w bieżącym offsecie, przesuwa offset i zwraca nowy obraz Image. Odbierany obiekt pozostaje w buforze ramki, gdy copy_to_fb=True (wartość domyślna), dzięki czemu zwrócony obraz można rysować poprzez podgląd w IDE; przy copy_to_fb=False ramka trafia na stertę MicroPython.

# 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

Dwa słowa kluczowe kontrolują zachowanie odtwarzania. loop=True (wartość domyślna dla strumieni plikowych) zawija wskaźnik odczytu z powrotem na początek po osiągnięciu końca nagrania, więc wywołanie nigdy nie zwraca None; loop=False zwraca None po wyczerpaniu nagrania i pętla wywołującego się kończy. pause=True (wartość domyślna) blokuje wywołanie do czasu upłynięcia odstępu międzyramkowego zarejestrowanego podczas zapisu, dzięki czemu liczba klatek odtwarzania odpowiada oryginalnej liczbie klatek przechwytywania; pause=False zwraca natychmiast, co jest przydatne dla potoków analitycznych, które chcą przetworzyć nagranie tak szybko, jak to możliwe, bez uwzględniania oryginalnego taktowania.

Ten sam wzorzec pętli działa dla strumieni pamięciowych, z tym że loop jest ignorowane – odczyt poza koniec strumienia pamięciowego zgłasza EOFError. Oczekiwanym wzorcem dla pierścienia pamięciowego jest jawne seek() z powrotem do zera, gdy pożądane jest zawijanie.

5.33.5. Nagrania odtwarzalne na hoście

Strumienie ImageIO są właściwym narzędziem, gdy nagranie ma być odtwarzane na kamerze – zachowują każdą przechwyconą ramkę w jej natywnym formacie pikseli, odstęp międzyramkowy jest rejestrowany dokładnie, a kolejny skrypt może przez nie przechodzić, przeszukiwać i ponownie analizować bez strat. Nie są jednak właściwym narzędziem, gdy nagranie musi być odtwarzalne na hoście – stacji roboczej, telefonie, odtwarzaczu internetowym. Host oczekuje standardowego kontenera wideo, a nie formatu OpenMV z magicznym nagłówkiem zapisywanego na dysku.

Dwa osobne moduły obsługują przypadek odtwarzania na hoście. Moduł mjpeg nagrywa Motion JPEG: sekwencję ramek skompresowanych w JPEG upakowanych w pojedynczy kontener w stylu AVI, który VLC, QuickTime, ffmpeg i standardowy znacznik wideo w przeglądarce odtwarzają bezpośrednio. Moduł gif nagrywa animowany GIF: sekwencję nieskompresowanych (lub skompresowanych paletą) ramek z jawnymi opóźnieniami dla poszczególnych ramek, odtwarzalną w dowolnej przeglądarce internetowej lub przeglądarce obrazów obsługującej animowane GIF-y.

Moduł mjpeg jest naturalnym wyborem dla długich nagrań. Kompresja JPEG utrzymuje rozmiar pliku na rozsądnym poziomie – porównywalnym do to_jpeg() przy skonfigurowanej jakości, ramka po ramce – więc wydłużona sesja przechwytywania mieści się w budżecie karty SD. Sposób użycia ściśle odzwierciedla nagrywanie ImageIO:

import mjpeg

m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
    m.add_frame(csi0.snapshot(), quality=85)
m.close()

mjpeg.Mjpeg akceptuje te same pozycyjne i skalujące słowa kluczowe w stylu rysowania, które przyjmują inne metody obrazu, więc nagranie może być skalowane, przycinane lub mapowane paletą dla każdej ramki na wejściu. Argumenty width i height konstruktora domyślnie przyjmują wymiary głównego bufora ramki i ustalają rozdzielczość wyjściową; każda dołączana ramka jest skalowana (z zachowaniem proporcji), aby się zmieścić. sync() opróżnia plik na dysk podczas długiego nagrania, a close() finalizuje kontener – plik Motion JPEG, który nie został poprawnie zamknięty, nie jest odtwarzalny, więc dyscyplina ma znaczenie.

Moduł gif jest naturalnym wyborem dla krótkich nagrań udostępnianych w niezmienionej formie nietechnicznemu odbiorcy – kilka sekund akcji przechwyconych na potrzeby demonstracji, animowana ilustracja do dokumentacji, klip ze zdarzeniem osadzony w wiadomości czatu. Ramki GIF są przechowywane nieskompresowane (lub skompresowane paletą przy 7-bitowej głębi koloru), co sprawia, że pliki są znacznie większe na sekundę niż Motion JPEG i wyklucza ten format dla nagrań dłuższych niż kilka sekund, ale wynik trafia bezpośrednio do dowolnej przeglądarki:

import gif

g = gif.Gif("/sdcard/clip.gif")
while running:
    g.add_frame(csi0.snapshot(), delay=10)
g.close()

Argument delay w add_frame() to czas wyświetlania pojedynczej ramki w centysekundach (10 to 100 ms na ramkę, czyli 10 fps), co jest standardowym sposobem kontroli odtwarzania GIF. Słowo kluczowe loop konstruktora ustala, czy powstały klip automatycznie zapętla się w przeglądarkach (wartością domyślną jest True, co odpowiada konwencjonalnemu oczekiwaniu wobec „animowanego GIF-a”).

Trzy ścieżki nagrywania razem obejmują typowe przypadki: ImageIO do ponownego przetwarzania na kamerze, Motion JPEG do długich nagrań odtwarzalnych na hoście, animowany GIF do krótkich klipów odtwarzalnych na hoście. Wybór między nimi sprowadza się do tego, kto odtwarza nagranie. Kolejny etap działający na samej kamerze odczytuje ImageIO; hostowa stacja robocza lub przeglądarka internetowa odczytuje MJPEG lub GIF.

5.33.6. Wzorzec wyzwalania i odtwarzania

Przydatny wzorzec łączy strumień pamięciowy z warunkiem wyzwalającym. Kamera nagrywa w sposób ciągły do bufora cyklicznego pamięci o count gniazdach, za każdym obiegiem nadpisując najstarsze gniazdo. Gdy warunek wyzwalający zostaje spełniony (plama (blob) wchodzi w kadr, zdarzenie ruchu przekracza próg, naciśnięty zostaje przycisk), aplikacja wykonuje zrzut zawartości pierścienia – najnowszych count ramek – i zapisuje je do strumienia plikowego na karcie SD. Wynikiem jest nagranie sprzed wyzwolenia, które przechwytuje sekundy przed zdarzeniem, które kamera faktycznie zauważyła, a nie tylko sekundy po nim, co jest klasycznym ograniczeniem naiwnego rejestratora „przechwytuj-przy-wyzwoleniu”.

Implementacja jest prosta, gdy ma się do dyspozycji klasy strumieni: strumień pamięciowy o stałym rozmiarze służy jako pierścień (z jawnym seek() do zera, gdy offset osiąga liczbę gniazd), główna pętla przechwytuje do niego w każdej iteracji, a obsługa wyzwalacza odczytuje strumień pamięciowy ramka po ramce i zapisuje każdą do strumienia plikowego nazwanego według znacznika czasu wyzwolenia.