12.8. Strumieniowanie ramek

Najczęstszym rzeczywistym zastosowaniem niestandardowego kanału jest strumieniowanie ramek obrazu z kamery do programu hosta z szybkością klatek kamery. Mechanika jest subtelniejsza, niż się wydaje: plik JPEG może osiągać 25 KB lub więcej, więc host odczytuje go jako kilka fragmentów, a pętla przechwytywania kamery musi mieć uniemożliwione nadpisanie bufora w trakcie odczytu. Właściwy wzorzec – pokazany tutaj i używany przez narzędzia w openmv-projects/tools/blokuje (latch) bufor, dopóki host nie pobierze ostatniego bajtu.

12.8.1. Strona kamery

Kanał ramek, który przechwytuje do pojedynczego framebuffera, blokuje go przy pierwszym odczycie hosta i wykonuje kolejny zrzut obrazu dopiero wtedy, gdy host pobierze cały obraz:

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

Cztery elementy wykonują tutaj rzeczywistą pracę:

  • frame_available to blokada (latch). Pętla przechwytywania wykonuje nowy zrzut obrazu tylko wtedy, gdy ma wartość False – co oznacza, że host pobrał ostatni bajt poprzedniej ramki. Odczyt hosta ustawia ją z powrotem na False z wnętrza readp, gdy obsłużone zostanie końcowe przesunięcie. Bez tego zabezpieczenia następne wywołanie csi0.snapshot() nadpisałoby bufor w trakcie odczytu, a host otrzymałby ramkę zszytą z dwóch przechwyceń.

  • To readp, a nie read, jest tym, co implementuje backend. Biblioteka protokołu traktuje zwrócony bufor jako miarodajny i odczytuje jego bajty bezpośrednio do wychodzącego pakietu – bez kopiowania. Dla ładunków wielkości ramki readp jest zauważalnie szybsze niż read, które wymusza pośrednią kopię.

  • size zwraca buforowaną długość JPEG bez ponownego przeliczania czegokolwiek; pętla przechwytywania utrzymuje ją za każdym razem, gdy odświeża bufor. Host wywołuje size pomiędzy poll a readp, aby wiedzieć, ile bajtów pobrać.

  • send_event() powiadamia host w momencie pojawienia się nowej ramki, dzięki czemu może on rozpocząć pobieranie bez odpytywania. Identyfikator zdarzenia 0x01 jest definiowany przez aplikację („ramka gotowa” w tym przypadku); dla każdego rodzaju powiadomienia użyj innej małej liczby całkowitej.

12.8.2. Fragmentacja

QVGA RGB565 przy jakości JPEG 85 kompresuje się do mniej więcej 10-25 KB, w zależności od sceny – znacznie więcej niż wynegocjowany maksymalny ładunek na dowolnej kamerze (zobacz tabelę dla poszczególnych płytek w protocol.init()). Jeden odczyt JPEG nie zmieści się w jednym pakiecie, i to jest w porządku, ponieważ biblioteka protokołu fragmentuje go w sposób przezroczysty.

Gdy host żąda channel_read('frame', 12000):

  1. readp kamery jest wywoływane raz z offset=0 i pełnym żądaniem o rozmiarze 12000 bajtów. Zwraca jeden memoryview obejmujący cały zakres.

  2. Biblioteka protokołu dzieli ten memoryview na fragmenty o rozmiarze maksymalnego ładunku w transmisji, po jednym pakiecie odpowiedzi CHANNEL_READ na fragment, każdy z własnym nagłówkiem i CRC. Bajty są strumieniowane bezpośrednio z bufora backendu – bez kopiowania.

  3. Host odbiera fragmenty w kolejności, warstwa niezawodności retransmituje każdy fragment, który nie przejdzie weryfikacji CRC, a SDK hosta skleja fragmenty w wynik o rozmiarze 12000 bajtów zwracany do wywołującego.

Informacja

To kluczowa praktyczna różnica między readp a read. readp jest wywoływane raz na żądanie hosta; warstwa protokołu fragmentuje i transmituje z pojedynczego zwróconego bufora. read jest wywoływane raz na fragment, a biblioteka kopiuje każdy zwrócony fragment do własnego bufora pakietu. Dla ładunków wielkości ramki readp oszczędza zarówno narzut wywołań na poziomie Pythona przypadający na fragment, jak i kopiowanie.

Wskazówka

Chcesz zobaczyć tę różnicę na własne oczy? Zmień nazwę metody readp backendu na read – nic więcej się nie zmienia; biblioteka wykryje wtedy zdolność read – i porównaj licznik szybkości klatek hosta przed i po. Wolniejsza wartość to koszt kopiowania na fragment i wywołań Pythona, którego unikasz, używając readp.

Blokada w FrameChannel.readp zwalnia bufor, gdy offset + size == img_size – w momencie, gdy host pobrał ostatni bajt. Do tego czasu bufor musi pozostać prawidłowy, dlatego pętla przechwytywania wykonuje następny zrzut obrazu dopiero wtedy, gdy frame_available wróci do False.

12.8.3. Strona hosta

Host pobiera ramki w ciasnej pętli:

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

Wywołanie channel_size() pełni jednocześnie funkcję sprawdzenia „czy cokolwiek jest gotowe” – zero oznacza, że kamera jeszcze niczego nie przechwyciła – więc pętla pomija próby odczytu na pustym buforze. Dla aplikacji GUI, które już odpytują za pomocą licznika czasu (timera), jest to naturalny wzorzec.

Image.open z biblioteki Pillow dekoduje JPEG; kamera już skompresowała go do JPEG, więc host nie musi ponownie wykonywać kosztownego upakowywania bitów na RGB565. Skrypt hosta mógłby równie łatwo zapisać bajty na dysk, przekazać je do OpenCV lub wypchnąć przez widok internetowy.

12.8.4. Myślenie o przepustowości

Trzy rzeczy ograniczają osiągalną szybkość klatek:

  • Szybkość przechwytywania kamery. Protokół nie może dostarczać ramek szybciej, niż produkuje je sensor; jakikolwiek limit narzucony na przechwytywanie przez wybrany format pikseli i rozmiar ramki stanowi pułap.

  • Wynegocjowany maksymalny ładunek. Większe ładunki oznaczają mniej fragmentów na ramkę i mniejszy narzut ramkowania, więc kamery z większymi buforami protokołu przesyłają bajty szybciej niż te z mniejszymi.

  • Narzut CRC i ACK. Każdy pakiet kosztuje 14 bajtów ramkowania plus jedną rundę ACK. Dla długich fragmentów narzut przypadający na ładunek jest mały; dla maleńkich ładunków dominuje.

W większości prac GUI typu kamera-laptop czynnikiem ograniczającym jest czas przechwytywania i kompresji JPEG kamery, a nie stos protokołu. Tam, gdzie protokół faktycznie staje się wąskim gardłem – na przykład przy strumieniowaniu nieskompresowanych surowych ramek z wysoką szybkością klatek – dźwigniami są wyłączenie ACK (protocol.init(ack=False)), zwiększenie bufora protokołu, jeśli kamera to obsługuje, lub przechwytywanie w trybie GRAYSCALE, tak aby każdy skompresowany JPEG niósł jeden kanał zamiast trzech, a zakodowana ramka okazała się zauważalnie mniejsza w transmisji.

Kanał ramek to kanoniczny przepływ danych z kamery do hosta. Ten sam interfejs backendu, z dodaną metodą write, pozwala hostowi wypychać dane także w drugą stronę – co jest tym, czego potrzebuje interaktywne narzędzie kamery w momencie, gdy operator chce coś zmienić, a nie tylko obserwować.