5.16. Niestandardowe jądra splotu

Filtry sąsiedztwa omówione do tej pory miały każdy wbudowaną statystykę, którą filtr stosował do okna w każdej pozycji – średnią, średnią ważoną gaussowsko, medianę. morph() to jedyny filtr, który pozwala aplikacji samodzielnie dostarczyć statystykę w postaci jądra: małej macierzy wag opisującej, jak filtr powinien połączyć piksele sąsiedztwa w pojedynczą wartość wyjściową.

Mechanizm ten to klasyczna operacja splotu. W każdej pozycji wyjściowej każdy piksel sąsiedztwa jest mnożony przez odpowiadającą mu wagę w jądrze, iloczyny są sumowane, wynik jest opcjonalnie skalowany i przesuwany, a wartość zapisywana jest do piksela wyjściowego. Różne jądra dają różne wyniki z tego samego wejścia. Jądro z jednakowymi dodatnimi wagami odtwarza filtr mean(); jądro w kształcie dzwonu odtwarza gaussian(). Wzorce wykraczające poza te dają odpowiedzi krawędziowe, efekty wytłoczenia, gradienty, wyostrzanie, rozmycie ruchu oraz długi katalog innych efektów – wszystko, co klasyczne przetwarzanie obrazu kiedykolwiek chciało osiągnąć w jednym liniowym przebiegu.

5.16.1. Metoda morph

Sygnatura wygląda jak w pozostałych filtrach sąsiedztwa, z jednym dodatkowym argumentem:

img.morph(size, kernel, mul=1.0, add=0.0)

size to promień, tak samo jak wszędzie indziej, więc jądro musi mieć dokładnie (2 * size + 1) wierszy na (2 * size + 1) kolumn. Samo jądro to płaska lista Pythona zawierająca tyle liczb, w kolejności wierszowej (row-major) – pierwsze (2 * size + 1) wpisów to górny wiersz, kolejne (2 * size + 1) to drugi wiersz i tak dalej, aż do dolnego wiersza. mul skaluje sumę iloczynów, zanim zostanie ona zapisana do piksela wyjściowego, a add dodaje stałą. Domyślne mul=1.0 i add=0.0 pozostawiają wynik splotu bez zmian.

Jeden szczegół warty wyraźnego wyjaśnienia: metoda automatycznie dzieli sumę iloczynów przez sumę wpisów jądra przed zapisaniem wyniku. To automatyczne dzielenie oznacza, że jądro uśredniające, którego wpisy sumują się do dziewięciu – na przykład rozmycie pudełkowe 3 na 3 – wychodzi w skali jednej dziewiątej bez żadnego dodatkowego wysiłku, a jądro przybliżające rozkład Gaussa, którego suma wynosi szesnaście, wychodzi w skali jednej szesnastej, oba bez konieczności samodzielnego obliczania dzielenia przez aplikację. Aplikacja ustawia mul tylko wtedy, gdy chce dodatkowo przeskalować wynik ponad automatyczną normalizację – lub, częściej, gdy jądro sumuje się do zera (jądro odpowiedzi krawędziowej) i automatyczne dzielenie byłoby dzieleniem przez nic. Framework traktuje w takim przypadku sumę jako jeden, a mul staje się jedynym pokrętłem utrzymującym nieskalowaną sumę iloczynów w zakresie.

Para threshold=True / offset=N z sekcji o progowaniu adaptacyjnym działa również w morph(), więc ten sam framework niestandardowych jąder może wytworzyć próg binarny, którego punkt odcięcia jest obliczany przez niestandardową statystykę.

5.16.2. Układ jądra

Jądro 3 na 3 (size=1) to płaska lista dziewięciu liczb ułożonych od lewej do prawej, od góry do dołu. Konwencja czyta się naturalnie, jeśli lista zostanie rozbita na trzy linie Pythona:

sobel_x = [-1,  0,  1,
           -2,  0,  2,
           -1,  0,  1]

To operator gradientu Sobela-x – pierwsze standardowe jądro, którego zechce każda aplikacja, i przydatne, by prześledzić je od początku do końca. Wzorzec jest prosty: ujemne wagi w lewej kolumnie, dodatnie wagi w prawej kolumnie, z zerową kolumną środkową. Wagi wierszy -1, -2, -1 (lub 1, 2, 1 po prawej) są wyższe w środku niż w narożnikach, co daje środkowemu wierszowi większy wpływ na wynik niż wierszom narożnym.

Gdy jądro przesuwa się po pionowej krawędzi – kolumnie pikseli przechodzącej od ciemnej po lewej do jasnej po prawej – ujemne wagi wychwytują ciemną stronę, a dodatnie wagi jasną stronę. Suma iloczynów jest dużą liczbą dodatnią, którą filtr zapisuje jako jasny piksel wyjściowy. Poziomy obszar jednorodnej jasności daje zero, ponieważ każda dodatnia waga jest równoważona ujemną wagą o tej samej wielkości na pikselu o tej samej wartości.

Uruchomienie jądra:

img.morph(1, sobel_x, mul=0.25)

Jądro Sobela sumuje się do zera – każda ujemna waga po lewej stronie jest równoważona równą dodatnią wagą po prawej – więc automatyczne dzielenie nie dzieli przez nic, a mul jest jedynym skalowaniem sumy iloczynów. mul=0.25 utrzymuje odpowiedź w zakresie: największa bezwzględna suma, jaką Sobel-x może wytworzyć z obszaru 3 na 3, wynosi mniej więcej 4 * 255 = 1020 (osiem jasnych pikseli ważonych aż do 2), a podzielenie tego przez cztery sprowadza skrajne przypadki do 255, gdzie format obcina je czysto.

Odpowiadające mu jądro Sobela-y wykrywa krawędzie poziome poprzez obrócenie tego samego wzorca wag o 90 stopni:

sobel_y = [-1, -2, -1,
            0,  0,  0,
            1,  2,  1]

Aplikacje, które chcą wykrywać dowolną krawędź, niezależnie od kierunku, zazwyczaj uruchamiają oba operatory Sobela i łączą odpowiedzi.

5.16.3. Przesuwanie wyniku

add to druga połowa historii o skalowaniu. Odpowiedź jądra o sumie zerowej jest ze znakiem – dodatnia po jednej stronie krawędzi, ujemna po drugiej – a ujemna połowa zostaje obcięta do zera przy zapisie do piksela bez znaku. add=128 przesuwa odpowiedź tak, by była wyśrodkowana na średniej szarości, więc odpowiedzi ujemne przetrwają jako wartości poniżej 128, a dodatnie wylądują powyżej: odpowiedź krawędziowa lub efekt wytłoczenia staje się widoczny w obu kierunkach, kosztem połowy zakresu w każdym z nich.

To, jakiej kombinacji mul i add oczekuje dane jądro, jest częścią jego projektu; katalog standardowych jąder wymienia właściwe ustawienia dla każdego typowego jądra.

5.16.4. Większe jądra

Wszystko na tej stronie zostało opisane na jądrach 3 na 3 (size=1), ponieważ jest to rozmiar używany przez standardowy katalog i ponieważ układ wierszowy łatwo wypisać ręcznie przy tym rozmiarze. Nic w mechanizmie nie ogranicza jednak jądra do 3 na 3. size=2 uruchamia jądro 5 na 5, z dwudziestoma pięcioma wpisami na płaskiej liście; size=3 uruchamia jądro 7 na 7 z czterdziestoma dziewięcioma; i tak dalej, aż do dowolnego promienia, za jaki aplikacja jest skłonna zapłacić. Framework obsługuje zarówno układ płaskiej listy, jak i zagnieżdżonych wierszy przy dowolnym nieparzystym rozmiarze.

Powód, by sięgnąć po większe jądro, jest taki sam jak powód, by sięgnąć po większe sąsiedztwo w którymkolwiek z wbudowanych filtrów: więcej uśredniania, szersze wykrywanie cech, mniejsza wrażliwość na szum pojedynczych pikseli. Koszt rośnie jak kwadrat promienia – jądro 5 na 5 wykonuje mniej więcej 2,8 raza więcej pracy na piksel niż jądro 3 na 3, a 7 na 7 około 5,4 raza – i ten mnożnik bierze się wprost z liczby klatek na sekundę.

Praktyczny wzorzec to pozostanie przy size=1 dla standardowego katalogu i sięganie po większe rozmiary tylko wtedy, gdy algorytm potrzebuje większego sąsiedztwa. Detektory krawędzi rzadko zyskują powyżej 3 na 3; filtry wygładzające czasem tak; właściwy rozmiar zależy od skali cech, które aplikacja stara się uwydatnić lub stłumić.

5.16.5. Kiedy sięgnąć po morph

Do codziennego wygładzania mean(), gaussian() oraz bilateral() są szybsze i czystsze. Do wykrywania krawędzi laplacian() i find_edges() są stworzone specjalnie w tym celu. Sięgnięcie bezpośrednio po morph() ma sens wtedy, gdy aplikacja potrzebuje konkretnego splotu, którego wbudowane filtry nie udostępniają – kierunkowego Sobela, niestandardowego szablonu krawędzi, jądra dostrojonego do określonej tekstury, której reszta potoku będzie szukać, lub któregokolwiek ze standardowego katalogu użytecznych jąder, jaki klasyczne przetwarzanie obrazu zgromadziło przez dziesięciolecia. Dostępna jest pełna elastyczność dowolnych jąder; ceną jest to, że aplikacja sama odpowiada za dobór wartości jądra, które dają pożądany wynik.