5.1. Obiekt Image¶
Algorytm przetwarzania obrazu przechodzi przez obraz piksel po pikselu. W każdej pozycji wykonuje coś prostego – odczytuje wartość, porównuje ją z progiem, łączy ją z odpowiadającym pikselem drugiego obrazu, zapisuje wynik z powrotem. Powtarzane na całej ramce, te proste decyzje podejmowane dla poszczególnych pikseli są tym, z czego zbudowane są wykrywanie krawędzi, śledzenie plam (blob), dekodowanie kodów QR oraz każda inna klasyczna technika wizji komputerowej. Aby wykonać tę pracę wydajnie, algorytm musi wiedzieć, gdzie każdy piksel leży w pamięci, co tak naprawdę oznacza wartość każdego piksela oraz na którą część obrazu powinien patrzeć. image.Image jest obiektem, który porządkuje te informacje.
Sensory wizyjne kończyły się w momencie, w którym csi.CSI.snapshot() zwraca wynik. Cokolwiek mechanizmy po stronie kamery zrobiły, aby wytworzyć przechwyconą ramkę, jest już wykonane; aplikacja ma w ręku obiekt Image i musi wiedzieć, co z nim zrobić.
5.1.1. Bufor i jego właściwości¶
Wewnątrz Image znajduje się wskaźnik do ciągłego bloku bajtów w pamięci RAM oraz mały nagłówek niosący trzy elementy metadanych: szerokość obrazu w pikselach, jego wysokość w pikselach oraz format pikseli, w jakim zapisane są bajty. Bajty to same piksele, przechowywane w porządku wierszowym (row-major) – najpierw wszystkie piksele górnego wiersza, potem wszystkie piksele drugiego wiersza i tak dalej, aż na sam dół. Właściwości opisują, jak je odczytywać.
Szerokość i wysokość to zwykłe liczby całkowite. Format pikseli jest bardziej interesującą właściwością, ponieważ ustala, ile bajtów zajmuje każdy piksel i co te bajty kodują. Obraz w skali szarości niesie jeden bajt na piksel, przechowujący wartość jasności. Obraz RGB565 niesie dwa bajty na piksel, przechowujące pola czerwieni, zieleni i błękitu upakowane w 16-bitowym słowie. Obraz Bayer niesie jeden bajt na piksel, ale każdy piksel jest próbkowany przez jeden z trzech filtrów kolorów wybranych na podstawie jego pozycji w mozaice. Sensory wizyjne wyliczyły cały katalog; tutaj liczy się to, że na każdym Image ustawiony jest dokładnie jeden z tych formatów, a wybór ten napędza arytmetykę bajtów na piksel oraz znaczenie dowolnego pojedynczego bajtu w buforze.
Mając wskaźnik do bufora, szerokość, wysokość i format, każda inna właściwość, której algorytm mógłby potrzebować, wynika z krótkiego obliczenia. Bajt rozpoczynający piksel (x, y) leży pod przesunięciem (y * width + x) * bytes_per_pixel od początku bufora. Całkowita liczba bajtów to width * height * bytes_per_pixel. Adres następnego wiersza poniżej znajduje się dokładnie width * bytes_per_pixel bajtów za początkiem bieżącego. Image udostępnia te trzy właściwości poprzez zwykłe wywołania metod – width(), height(), format() – oraz wyprowadzony size poprzez size(). Metody w innych miejscach modułu używają tych wartości, aby samodzielnie wykonać arytmetykę przesunięć; kod aplikacji rzadko musi to robić.
Image to małe opakowanie Pythona, które wskazuje na ciągły blok pamięci: nagłówek niosący szerokość, wysokość i format pikseli, a po nim sam bufor pikseli.¶
5.1.2. Skąd pochodzi bufor¶
Domyślnym scenariuszem w całym tym rozdziale jest ten, który Sensory wizyjne już omówiły: przechwycona ramka przybywa z snapshot, bajty leżą w buforze ramki kamery, a zwrócony Image wskazuje na nie. Trzy inne sposoby pozyskania go pojawiają się regularnie, a każdy z nich oznacza coś innego co do tego, gdzie ostatecznie ląduje bufor.
Wczytanie z pliku wygląda jak przekazanie ścieżki do konstruktora: image.Image("/sdcard/saved.jpg"). Moduł wczytuje plik do świeżo zaalokowanego bufora na stercie Pythona. Pliki BMP, PGM i PPM są dekodowane przy wczytywaniu, a wynikowy Image niesie nieskompresowany format pikseli. Pliki JPEG i PNG pozostają skompresowane – Image niesie format JPEG lub PNG, a bufor przechowuje strumień bajtów pliku zasadniczo bez zmian. Aby wykonać jakąkolwiek pracę na poziomie pikseli na skompresowanym obrazie, aplikacja najpierw konwertuje go za pomocą to_rgb565() lub to_grayscale(), i to właśnie podczas tej konwersji następuje dekompresja – oraz odpowiadające jej rozdęcie sterty, gdzie 30 KB JPEG może stać się 600 KB RGB565. Wczytywanie z pliku jest najbardziej przydatne podczas tworzenia oprogramowania, gdy algorytm trzeba przetestować względem znanej ramki referencyjnej przechowywanej obok skryptu.
Zbudowanie obrazu od zera to przypadek płótna: image.Image(320, 240, image.RGB565) prosi moduł o zaalokowanie tylu bajtów w tym formacie, wyzerowanie zawartości i zwrócenie opakowania. Piksele nic jeszcze nie znaczą – wszystkie są zerowe – ale pusty obraz jest koniem roboczym dla kilku powracających wzorców: ramek referencyjnych, od których odejmowana jest bieżąca ramka, płócien, na których komponowane są nakładki graficzne, buforów binarnych, które są wypełniane i używane jako maski.
Konstruowanie z ndarray łączy w drugim kierunku, od dowolnego obliczenia numerycznego z powrotem do modułu obrazu. Przekazanie float32 ulab.numpy.ndarray do konstruktora wytwarza Image, którego wymiary pasują do ndarray – dwuosiowy kształt (h, w) staje się obrazem w skali szarości, trójosiowy kształt (h, w, 3) staje się RGB565 – przy czym wartości zmiennoprzecinkowe są skalowane z zakresu 0.0 – 255.0 do całkowitoliczbowego zakresu pikseli. Mapa cieplna sieci neuronowej, tablica numeryczna dowolnego rodzaju, cokolwiek wytworzone przez ml lub ulab staje się czymś, czego strona rysowania i inspekcji modułu obrazu może użyć.
Wszystkie cztery źródła zwracają ten sam rodzaj Image. Kod używający zwróconego obiektu nigdy nie musi śledzić, skąd on pochodzi.
5.1.3. Dwa widoki na bajty¶
Przez większość czasu kod aplikacji traktuje Image jako typowany obiekt obrazu – coś z nazwanymi metodami. Druga połowa historii jest taka, że ten sam obiekt pojawia się także, w sposób przezroczysty, jako płaska sekwencja bajtów dla dowolnego API MicroPython, które przyjmuje argument bytes. Te bajty nie są kopią bufora; są jego bezpośrednim widokiem.
Taki układ sprawia, że wypchnięcie przechwyconej ramki z kamery to jednolinijkowiec. Obliczenie jej skrótu, wysłanie jej przez port szeregowy, przekazanie jej do gniazda sieciowego – żadna z tych operacji nie potrzebuje osobnego kroku „konwersji obrazu na bajty”:
import csi
import hashlib
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)
img = csi0.snapshot()
uart.write(img) # transmits the raw pixel bytes
hashlib.sha256(img) # hashes the same bytes
sock.send(img) # sends them over a socket
Widok bajtopodobny jest domyślnie tylko do odczytu, celowo. Bufory obrazów są duże i czasami współdzielone między warstwami stosu obrazowania, więc danie przypadkowemu buf[0] = 0 gdzieś głęboko w stosie wywołań mocy do cichego uszkodzenia jednego z nich to zbyt ostra krawędź, by zostawić ją odsłoniętą. Gdy to, czego aplikacja naprawdę potrzebuje, to dostęp na poziomie bajtów z możliwością odczytu i zapisu – na przykład zapisanie wartości kalibracyjnej do znanego przesunięcia – bytearray() zwraca osobny, jawnie odczytywalno-zapisywalny widok na tę samą pamięć, sygnalizując intencję w miejscu wywołania.
5.1.4. Gdzie żyje bufor¶
Bufory pikseli są wystarczająco duże, by miało znaczenie, gdzie leżą w pamięci RAM. Ramka QQVGA RGB565 ma 160 × 120 × 2 = 38 400 bajtów; ramka VGA RGB565 ma 614 400 bajtów; wejście 224 × 224 RGB565, które klasyfikator oparty na sieci neuronowej mógłby przyjmować, to około 100 KB. Sterta Pythona na najmniejszych kamerach może mieć zaledwie kilkadziesiąt kilobajtów, gdy środowisko uruchomieniowe już wystartuje. Trzymanie więcej niż jednej lub dwóch ramek danych obrazu na stercie wypchnęłoby z niej wszystko inne.
Wyjściem jest to, że bufory obrazów w większości nie żyją na stercie Pythona. Żyją w dedykowanym obszarze pamięci RAM, który Sensory wizyjne Sensory wizyjne wprowadziły jako bufor ramki – tę samą pamięć, do której DMA kamery zapisuje przechwycone ramki i z której podgląd IDE odczytuje gotowe ramki. Większość operacji na Image modyfikuje swoje źródło w miejscu: algorytm odczytuje piksele, podejmuje decyzję, zapisuje nowe wartości z powrotem i nie jest alokowany żaden osobny obraz wynikowy. Operacje, które jednak wytwarzają osobny wynik – konwersje formatu oraz garść innych – mogą zostać poproszone o umieszczenie tego wyniku w buforze ramki poprzez argument kluczowy copy_to_fb. copy_to_fb=True robi dwie rzeczy naraz: umieszcza obraz wynikowy w buforze ramki zamiast na stercie (omijając presję na stertę) oraz sprawia, że wynik staje się następną ramką, którą wyświetli podgląd IDE. Dodanie copy_to_fb=True do ostatniego kroku potoku, obserwowanie, jak wynik pojawia się na ekranie, i iterowanie od tego miejsca to jeden z najbardziej przydatnych idiomów debugowania w przetwarzaniu obrazu.
Mając opakowanie przechowujące oznaczony bufor, cztery sposoby powołania go do istnienia, dwa widoki na jego bajty oraz przełącznik decydujący, gdzie lądują nowe, Image nie jest już tajemnicą. Pozostałe podstawowe pytania – jak nazywana jest pozycja piksela, co tak naprawdę przechowuje każdy piksel, jak ograniczyć operację do części jednego z nich – są zbudowane na jego bazie.