5.4. Odczyt i zapis pikseli

Większość operacji na obrazie ukrywa swoją pracę na poszczególnych pikselach wewnątrz pojedynczego wywołania metody, gdzie pętle dotykające każdego piksela wykonują się z natywną szybkością. Istnieją jednak przypadki, w których kod aplikacji chce dotknąć bezpośrednio jednego konkretnego piksela: aby odczytać, co znajduje się w określonej pozycji, aby zapisać do niego nową wartość, aby pobrać próbkę z pojedynczego punktu na potrzeby kroku kalibracji lub aby zdebugować wartość w znanej lokalizacji. Moduł image udostępnia ten poziom dostępu poprzez dwie formy adresowania, z których każda pasuje do innego sposobu myślenia o tym, gdzie znajduje się piksel.

5.4.1. Adresowanie przez współrzędne

Najbardziej naturalna forma to ta, dla której współrzędne wypracowały już słownictwo: nazwanie piksela przez jego kartezjańskie (x, y). get_pixel() przyjmuje (x, y) i zwraca wartość w tej pozycji; set_pixel() przyjmuje te same (x, y) wraz z wartością i ją zapisuje.

To, co te wywołania zwracają lub przyjmują, zależy od formatu obrazu. Obrazy w skali szarości, binarne i Bayer niosą jedną wartość na piksel – jasność dla skali szarości, 0 lub 1 dla obrazu binarnego, próbkę pojedynczego kanału koloru dla Bayera – więc get_pixel() zwraca pojedynczą liczbę całkowitą. RGB565 niesie trzy kanały koloru spakowane w 16 bitach, a get_pixel domyślnie rozpakowuje je do krotki (r, g, b), z każdym kanałem odwzorowanym na zakres 0255.

Domyślne zachowanie można odwrócić po obu stronach. Przekazanie rgbtuple=False do get_pixel na obrazie RGB565 powoduje powrót do surowego 16-bitowego spakowanego słowa – tej samej formy, którą zwraca indeks liniowy, i wydajnej formy, gdy aplikacja zamierza zapisać tę samą spakowaną wartość z powrotem. Przekazanie rgbtuple=True na obrazie jednokanałowym robi coś przeciwnego: przechowywana wartość jest przed zwróceniem konwertowana na krotkę RGB888, przy czym obrazy Bayer przechodzą przez krok debayeringu w locie. Argument ten istnieje po to, aby kod wywołujący mógł poprosić o piksele w jednolitej przestrzeni kolorów, niezależnie od tego, jak przechowuje je obraz bazowy.

Obrazy skompresowane – JPEG i PNG – nie są obsługiwane przez get_pixel ani set_pixel. Ich bajty nie reprezentują pikseli w znanych pozycjach, więc metody zgłaszają błąd zamiast zwracać wartość, która i tak nic by nie znaczyła.

W praktyce wzorce wyglądają następująco:

v = img.get_pixel(40, 30)            # grayscale: int 0..255
img.set_pixel(40, 30, 255)           # write white

r, g, b = img.get_pixel(40, 30)      # RGB565: defaults to (r, g, b) tuple
img.set_pixel(40, 30, (255, 0, 0))   # write red

Jeśli żądane (x, y) znajduje się poza obrazem, get_pixel zwraca None, a set_pixel nic nie robi. Jest to wyrozumiałe z założenia: wiele algorytmów porusza się blisko krawędzi obrazu i na chwilę indeksuje pozycje spoza zakresu, a ciche pominięcie operacji jest mniej uciążliwe niż wyjątek za każdym razem, gdy to się zdarza.

5.4.2. Adresowanie przez indeks liniowy

Druga forma to adresowanie pikseli przez ich pozycję w buforze bazowym. Przypomnijmy układ bufora: piksele są przechowywane wiersz po wierszu, najpierw wszystkie piksele górnego wiersza, potem wszystkie piksele następnego i tak dalej, aż do dołu. Taki układ oznacza, że każdy piksel ma pojedynczy indeks całkowity, liczony od 0 w lewym górnym rogu i zwiększany kolejno wzdłuż każdego wiersza. Piksel o współrzędnej (x, y) ma indeks liniowy y * width + x.

Siatka komórek 4 na 3. Każda komórka niesie duży indeks liniowy od 0 w lewym górnym rogu do 11 w prawym dolnym, plus pod spodem małą krotkę (x, y). Kolumny są oznaczone x równe 0, 1, 2, 3 wzdłuż góry; wiersze są oznaczone y równe 0, 1, 2 wzdłuż lewej krawędzi. Podpis pod spodem podaje zależność: indeks liniowy równa się y razy width plus x.

Piksele są adresowane zarówno przez kartezjańskie (x, y), jak i przez indeks liniowy, który przechodzi przez bufor wiersz po wierszu, od lewej do prawej.

Moduł image udostępnia ten indeks poprzez zwykłą notację indeksowania Pythona: img[i] odczytuje piksel o indeksie liniowym i, img[i] = value zapisuje go. To, co zwraca forma indeksowa, to surowa przechowywana wartość dla danego formatu, a nie rozpakowana krotka, którą domyślnie zwraca get_pixel(). To rozróżnienie ma znaczenie, ponieważ wybrany wcześniej format decyduje o tym, jak wygląda surowa wartość:

  • Piksele w skali szarości i Bayer są zwracane jako 8-bitowe liczby całkowite.

  • Piksele RGB565 i YUV422 są zwracane jako 16-bitowe liczby całkowite – spakowane słowo.

  • Piksele binarne są zwracane jako 0 lub 1.

  • Piksele JPEG i PNG są zwracane jako 8-bitowe liczby całkowite, po jednym bajcie skompresowanego strumienia na raz. Wartości te są nieprzejrzyste – są fragmentami skompresowanego kodowania, a nie pikselami w jakimkolwiek zwykłym sensie.

Forma indeksowa pasuje do kodu, który już myśli w kategoriach przesunięć w buforze: pętli przechodzącej raz przez każdy piksel, algorytmu, który musi przeskakiwać o cały wiersz na raz, lub fragmentu kodu tłumaczącego między układami bufora. Kod, który myśli w kategoriach współrzędnych x i y, jest lepiej obsługiwany przez get_pixel i set_pixel; obie formy adresują te same piksele za pomocą różnych modeli myślowych.

Image jest również iterowalny. for v in img: przechodzi przez bufor w tej samej kolejności wierszowej, zwracając surowe wartości po jednym pikselu na raz, a len(img) to liczba pikseli dla formatów nieskompresowanych lub liczba bajtów dla strumieni skompresowanych.

5.4.3. Dlaczego Python operujący na pojedynczych pikselach jest wolną ścieżką

Praktyczna uwaga, co do której warto być szczerym. Przechodzenie przez obraz piksel po pikselu z poziomu Pythona jest wolne. Obraz w skali szarości 320 × 240 zawiera 76 800 pikseli; wywoływanie get_pixel() na każdym z nich w pętli for uruchamia miliony instrukcji bajtkodu MicroPython, aby wykonać pracę, którą równoważna metoda natywna mogłaby ukończyć w kilkaset mikrosekund. To nie jest mały współczynnik. To różnica między skryptem, który przetwarza ramki w czasie rzeczywistym, a takim, który wlecze się znacznie poniżej szybkości klatek kamery.

Niemal każda metoda na powierzchni Image istnieje, ponieważ istnieje szybsza, natywna wersja powszechnego wzorca operującego na pojedynczych pikselach. Pętla dodająca dwa obrazy do siebie staje się pojedynczym natywnym wywołaniem. Pętla wygładzająca każdy piksel przez uśrednianie go z sąsiadami staje się kolejnym. Pętla klasyfikująca każdy piksel względem progu staje się trzecim. Zadaniem aplikacji jest, przez większość czasu, rozpoznanie, która metoda działająca na całym obrazie odpowiada pracy, którą wykonałaby pętla, i sięgnięcie po nią zamiast ręcznego pisania pętli.

Odczyt i zapis na poziomie pikseli wciąż są właściwym narzędziem, gdy nic innego nie pasuje – wpisywanie konkretnego pomiaru z powrotem do bufora, pobieranie próbki z jednej pozycji na potrzeby kroku kalibracji, debugowanie wartości w znanej lokalizacji. Chodzi o to, że są one wolną ścieżką, używaną, gdy metody działające na całym obrazie nie mają formy, której potrzebuje aplikacja, a nie jako domyślny sposób operowania na pikselach.