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_availableto 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 naFalsez wnętrzareadp, gdy obsłużone zostanie końcowe przesunięcie. Bez tego zabezpieczenia następne wywołaniecsi0.snapshot()nadpisałoby bufor w trakcie odczytu, a host otrzymałby ramkę zszytą z dwóch przechwyceń.To
readp, a nieread, 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 ramkireadpjest zauważalnie szybsze niżread, które wymusza pośrednią kopię.sizezwraca buforowaną długość JPEG bez ponownego przeliczania czegokolwiek; pętla przechwytywania utrzymuje ją za każdym razem, gdy odświeża bufor. Host wywołujesizepomiędzypollareadp, 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 zdarzenia0x01jest 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):
readpkamery jest wywoływane raz zoffset=0i pełnym żądaniem o rozmiarze 12000 bajtów. Zwraca jeden memoryview obejmujący cały zakres.Biblioteka protokołu dzieli ten memoryview na fragmenty o rozmiarze maksymalnego ładunku w transmisji, po jednym pakiecie odpowiedzi
CHANNEL_READna fragment, każdy z własnym nagłówkiem i CRC. Bajty są strumieniowane bezpośrednio z bufora backendu – bez kopiowania.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ć.