5.17. Katalog standardowych jąder¶
Klasyczne przetwarzanie obrazu zgromadziło spory katalog wzorców wag jąder, które pojawiają się raz za razem – detektory krawędzi, wyostrzacze, efekty wytłoczenia, wygładzacze, rozmycia ruchu – i każdy z nich działa poprzez morph(). Każde jest krótkie, każde robi jedną rzecz i większość jest łatwa do odczytania, gdy zrozumie się podstawową logikę wag.
Wszystkie poniższe jądra mają rozmiar 3 na 3, o ile nie zaznaczono inaczej, więc wszystkie używają size=1 w wywołaniu. Struktura wag każdego jądra jest opisana obok niego, ponieważ to odczytywanie wag buduje intuicję, dlaczego jedno jądro tworzy wytłoczenie, a inne wyostrza.
5.17.1. Jądro tożsamościowe¶
Najprostszym możliwym jądrem jest tożsamość – jedynka pośrodku, zera wszędzie indziej:
identity = [0, 0, 0,
0, 1, 0,
0, 0, 0]
img.morph(1, identity)
Każdy piksel wyjściowy przyjmuje wartość ze środka sąsiedztwa, czyli z piksela wejściowego w tej samej pozycji. Obraz przechodzi niezmieniony. Tożsamość nie ma praktycznego zastosowania jako filtr, ale jest użytecznym punktem odniesienia do zrozumienia każdego innego jądra: każde jądro inne niż tożsamościowe to tożsamość plus pewna modyfikacja.
Jądro o dużej wadze środkowej z małymi ujemnymi wagami wokół niej odejmuje otoczenie od środka. Jądro z zerową wagą środkową ignoruje sam piksel i reaguje wyłącznie na różnice między jego sąsiadami. Odczytywanie jądra w ten sposób – co waga środkowa robi z pikselem, co otaczające wagi dodają lub odejmują – to najszybszy sposób przewidzenia jego efektu.
5.17.2. Wykrywanie krawędzi¶
Jądra wykrywające krawędzie silnie reagują w miejscach, gdzie jasność zmienia się gwałtownie w określonym kierunku, a w miejscach o jednolitej jasności dają wyjście bliskie zeru. To rodzina, której wagi sumują się do zera: jednolity fragment (każdy piksel o tej samej wartości) daje zerowe wyjście, ponieważ każda dodatnia waga jest dokładnie znoszona przez ujemną wagę o równej wielkości.
Sobel-x to kanoniczny przykład. Wykrywa pionowe krawędzie (przejścia jasności lewo/prawo):
sobel_x = [-1, 0, 1,
-2, 0, 2,
-1, 0, 1]
img.morph(1, sobel_x, mul=0.25, add=128)
Odpowiadający mu Sobel-y to ten sam wzorzec obrócony o 90 stopni; wykrywa krawędzie poziome (przejścia jasności góra/dół):
sobel_y = [-1, -2, -1,
0, 0, 0,
1, 2, 1]
Środkowy wiersz Sobela-x ma wagi -2 i 2, a nie -1 i 1. Dodatkowa waga w środkowym wierszu nadaje jądru niewielkie wbudowane wygładzanie w kierunku wzdłuż krawędzi, co czyni je bardziej odpornym na szum niż prostszy operator Prewitt, który rezygnuje z tych dodatkowych wielkości:
prewitt_x = [-1, 0, 1,
-1, 0, 1,
-1, 0, 1]
prewitt_y = [-1, -1, -1,
0, 0, 0,
1, 1, 1]
Prewitt waży każdy wiersz jednakowo, więc jego odpowiedź jest odrobinę ostrzejsza niż Sobela, kosztem większej wrażliwości na szum pojedynczych pikseli (koszt uruchomienia jądra jest identyczny – splot wykonuje tę samą pracę niezależnie od wag). Na czystym obrazie z silnymi krawędziami jest doskonałym zamiennikiem Sobela.
Scharr idzie w drugą stronę. Jego wagi są większe i dostrojone do dokładnego wykrywania kierunku krawędzi przy drobniejszych kątach:
scharr_x = [-3, 0, 3,
-10, 0, 10,
-3, 0, 3]
img.morph(1, scharr_x, mul=0.0625, add=128)
Dzielnik mul=0.0625 (1/16) sprowadza wyjście z powrotem do zakresu 0 – 255 po większej sumie iloczynów. Scharr jest właściwym wyborem, gdy aplikacja potrzebuje najwierniejszej geometrycznie odpowiedzi gradientowej i jest gotowa zapłacić za to nieco większą liczbą operacji arytmetycznych.
5.17.3. Laplasjan¶
Jądro Laplasjanu reaguje na krawędzie w dowolnym kierunku jednocześnie. Podczas gdy poszczególne operatory Sobela wykrywają zmiany jasności wzdłuż jednej osi, symetryczny wzorzec wag Laplasjanu reaguje tak samo niezależnie od kierunku, w którym biegnie krawędź:
laplacian_4 = [ 0, -1, 0,
-1, 4, -1,
0, -1, 0]
img.morph(1, laplacian_4, add=128)
Struktura: waga środkowa 4, czterej sąsiedzi poziomi/pionowi z wagą -1, czterej sąsiedzi po przekątnej z wagą zero. Jądro sumuje się do zera, więc jednolite fragmenty dają zerowe wyjście. Tam, gdzie jasność się zmienia, wartość środkowa różni się od średniej czterech sąsiadów kardynalnych, a wyjście odpowiada wielkości tej różnicy.
Wariant 8-spójny obejmuje sąsiadów po przekątnej:
laplacian_8 = [-1, -1, -1,
-1, 8, -1,
-1, -1, -1]
Każde jądro wykrywa nieco inne rzeczy. Wersja 4-spójna daje czystsze wyjście na krawędziach poziomych i pionowych; wersja 8-spójna jest bardziej izotropowa – reaguje równie dobrze w każdym kierunku – ale daje nieco bardziej zaszumione wyjście. Jądro 8-spójne krąży też pod nazwą outline, ze względu na zastosowanie do wizualizacji krawędzi.
5.17.5. Wytłoczenie¶
Jądro wytłoczenia (emboss) tworzy efekt oświetlenia z boku znany z klasycznych edytorów obrazu. Wyjście wygląda, jakby obraz został wytłoczony w płaskorzeźbę, a następnie oświetlony z jednego rogu:
emboss = [-2, -1, 0,
-1, 1, 1,
0, 1, 2]
img.morph(1, emboss, add=128)
Sztuczka tkwi w asymetrii względem przekątnej. Lewy górny róg ma najbardziej ujemną wagę, prawy dolny ma najbardziej dodatnią wagę, a przekątna od rogu do rogu przebiega od wartości ujemnej przez jedynkę do dodatniej. Przy każdym pikselu jądro zasadniczo oblicza „jasność po mojej prawej dolnej stronie minus jasność po mojej lewej górnej stronie”, co jest dodatnie tam, gdzie obraz staje się jaśniejszy w tym kierunku, i ujemne tam, gdzie ciemnieje. Dodanie 128 przesuwa znakowe wyjście do średniej szarości, dzięki czemu efekt jest widoczny.
Obrócenie asymetrii względem drugiej przekątnej tworzy wytłoczenie z przeciwnego kierunku:
emboss_alt = [ 0, 1, 2,
-1, 1, 1,
-2, -1, 0]
img.morph(1, emboss_alt, add=128)
Oba kierunki wytłoczenia są przydatne w połączeniu – przez odejmowanie jednego od drugiego lub uruchomienie każdego na tym samym obrazie i porównanie odpowiedzi – gdy aplikacja musi wykrywać orientację.
5.17.6. Wygładzanie¶
Jądra wygładzające to rodzina, której wagi sumują się do jedynki (i wszystkie są nieujemne). Jednolity fragment przepuszczony przez takie jądro daje tę samą jednolitą jasność, ponieważ jądro uśrednia wartości pikseli zamiast wzmacniać ich różnice.
Najprostszym jest box blur (rozmycie pudełkowe), które jest dokładnie tym, co oblicza mean():
box_blur = [1, 1, 1,
1, 1, 1,
1, 1, 1]
img.morph(1, box_blur)
Jądro sumuje się do 9, więc automatyczne dzielenie przez sumę jądra zamienia sumę iloczynów w prawdziwą średnią po dziewięciu pikselach sąsiedztwa. W praktyce mean() jest lepszym sposobem uruchomienia tego jądra – daje to samo wyjście szybciej, poprzez ścieżkę zoptymalizowaną wyłącznie pod obliczanie średniej, podczas gdy morph uruchamia ogólny mechanizm splotu. Box blur znajduje się w katalogu, ponieważ jest właściwym punktem odniesienia do zrozumienia każdego innego jądra wygładzającego.
Przybliżenie 3 na 3 jądra Gaussa waży środek i sąsiadów kardynalnych bardziej niż rogi:
gaussian = [1, 2, 1,
2, 4, 2,
1, 2, 1]
img.morph(1, gaussian)
Wagi to wiersz trójkąta Pascala 1, 2, 1 pomnożony zewnętrznie przez samego siebie. Waga środkowa 4 jest największa, ponieważ piksel środkowy wnosi najwięcej do własnego wyjścia; rogi mają 1, ponieważ są najdalej od środka. Jądro sumuje się do 16, a automatyczne dzielenie przez sumę jądra obsługuje normalizację – nie jest potrzebny argument mul. Forma 3 na 3 to zgrubne przybliżenie prawdziwego rozkładu Gaussa, nieodróżnialne od gaussian() przy size=1; forma morph jest najbardziej przydatna, gdy aplikacja chce złożyć wygładzanie z inną operacją w tym samym przebiegu.
5.17.7. Rozmycie ruchu¶
Jądro rozmycia ruchu (motion-blur) uśrednia piksele wzdłuż jednego kierunku, pozostawiając kierunek prostopadły nierozmyty. Najprostszym przypadkiem jest poziom:
motion_h = [0, 0, 0,
1, 1, 1,
0, 0, 0]
img.morph(1, motion_h)
Środkowy wiersz uśrednia trzy piksele wzdłuż osi poziomej; górny i dolny wiersz są zerowe. Jądro sumuje się do 3, więc automatyczne dzielenie przez sumę jądra daje prawdziwą średnią z trzech pikseli bez potrzeby użycia mul. Wyjściem jest poziomo rozmazana kopia wejścia – efekt, jaki kamera rejestruje, gdy obiekt porusza się na boki podczas ekspozycji. Pionowe rozmycie ruchu to ten sam wzorzec obrócony:
motion_v = [0, 1, 0,
0, 1, 0,
0, 1, 0]
Ukośne rozmycie ruchu wykorzystuje główną przekątną:
motion_diag = [1, 0, 0,
0, 1, 0,
0, 0, 1]
img.morph(1, motion_diag)
Jądra rozmycia ruchu są przydatne zarówno jako efekt (celowe rozmycie ramki w celach wizualnych), jak i jako wzorzec testowy dla algorytmów, które muszą być odporne na artefakty ruchu (uruchom algorytm na wejściu z rozmyciem ruchu i sprawdź, czy nadal daje właściwą odpowiedź).
5.17.8. Odczytywanie jąder na pierwszy rzut oka¶
Kilka praktycznych reguł ułatwia odczytanie nowych jąder na pierwszy rzut oka:
Suma do jedynki z nieujemnymi wagami ⇒ wygładzanie (zachowuje średnią jasność).
Suma do zera z wagami zarówno dodatnimi, jak i ujemnymi ⇒ odpowiedź na krawędzie (zero na jednolitych fragmentach).
Suma do jedynki z dużym dodatnim środkiem i małym ujemnym otoczeniem ⇒ wyostrzanie (tożsamość plus odpowiedź na krawędzie).
Asymetria względem przekątnej z sumą równą jedynce ⇒ wytłoczenie (uwydatnia jedną stronę każdego przejścia jasności).
Skupienie wzdłuż jednej osi z sumą równą jedynce ⇒ rozmycie kierunkowe.
Pierwsza z tych reguł, którą jądro spełnia, jest zwykle właściwym przypuszczeniem co do tego, co robi. Większość przydatnych jąder można rozpoznać już po samym układzie ich wzorca wag.
Gdy żadne ze standardowych jąder nie robi tego, czego oczekuje aplikacja, kolejnym krokiem jest ręczne dostrojenie własnego. Połączenie powyższych reguł oraz kontroli mul / add obejmuje niemal każdy liniowy przebieg, jakiego kiedykolwiek potrzebował klasyczny potok wizji maszynowej; stamtąd pozostaje już tylko próbowanie wag, oglądanie wyjścia i iterowanie.