12  Sieci splotowe

W tym rozdziale przedstawimy konwolucyjne (lub splotowe) sieci neuronowe (ang. convolutional neural networks), znane również jako CovNets, rodzaj modelu głębokiego uczenia się, który jest niemal zawsze stosowany w aplikacjach widzenia komputerowego.

Wkrótce zagłębimy się w teorię tego, czym są sieci splotowe i dlaczego odniosły taki sukces w zadaniach związanych z widzeniem komputerowym. Ale najpierw przyjrzyjmy się w praktyce prostemu przykładowi sieci splotowej. Wykorzystuje on sieć kowolucyjną do klasyfikacji cyfr MNIST, czyli zadania, które wykonaliśmy wcześniej przy użyciu sieci gęsto połączonej (nasza dokładność testu wyniosła wtedy 97,8%).

Poniższe linie kodu pokazują, jak wygląda podstawowa sieć CovNet. Jest to stos warstw layer_conv_2d i layer_max_pooling_2d, o których zasadzie działania będzie jeszcze więcej za chwilę.

Kod
library(keras)
model <- keras_model_sequential() %>%
  layer_conv_2d(filters = 32, kernel_size = c(3, 3), activation = "relu",
  input_shape = c(28, 28, 1)) %>%
  layer_max_pooling_2d(pool_size = c(2, 2)) %>%
  layer_conv_2d(filters = 64, kernel_size = c(3, 3), activation = "relu") %>%
  layer_max_pooling_2d(pool_size = c(2, 2)) %>%
  layer_conv_2d(filters = 64, kernel_size = c(3, 3), activation = "relu")

Co ważne, CovNet przyjmuje jako dane wejściowe tensory o kształcie (image_height, image_width, image_channels) (nie licząc wymiaru partii). W tym przypadku skonfigurujemy sieć do przetwarzania danych wejściowych o rozmiarze (28, 28, 1), czyli w formacie obrazów MNIST. Zrobimy to przekazując argument input_shape = c(28, 28, 1) do pierwszej warstwy.

Kod
model
Model: "sequential"
________________________________________________________________________________
 Layer (type)                       Output Shape                    Param #     
================================================================================
 conv2d_2 (Conv2D)                  (None, 26, 26, 32)              320         
 max_pooling2d_1 (MaxPooling2D)     (None, 13, 13, 32)              0           
 conv2d_1 (Conv2D)                  (None, 11, 11, 64)              18496       
 max_pooling2d (MaxPooling2D)       (None, 5, 5, 64)                0           
 conv2d (Conv2D)                    (None, 3, 3, 64)                36928       
================================================================================
Total params: 55744 (217.75 KB)
Trainable params: 55744 (217.75 KB)
Non-trainable params: 0 (0.00 Byte)
________________________________________________________________________________

Zauważmy, że wyjścia z warstw layer_conv_2d i layer_max_pooling_2d są tensorami 3D kształtu (wysokość, szerokość, filtry)1. Wymiary szerokości i wysokości mają tendencję do kurczenia się, gdy wchodzimy głębiej w sieć. Liczba filtrów jest kontrolowana przez pierwszy argument przekazany do layer_conv_2d (32 lub 64).

1 w niektórych publikacjach ostatni parametr jest nazywany kanałami, ale aby nie wprowadzać zamieszania, ponieważ nazwa kanał jest zarezerwowana do obrazów, to zostanę przy nazwie filtry

Następnym krokiem jest wprowadzenie ostatniego tensora wyjściowego (o kształcie (3, 3, 64)) do gęsto połączonej sieci klasyfikatorów, takich jak te, które już znamy - stosu gęstych warstw. Te klasyfikatory przetwarzają wektory 1D, podczas gdy bieżące wyjście jest tensorem 3D. Najpierw musimy spłaszczyć wyjścia 3D do 1D, a następnie dodać kilka gęstych warstw na wierzchu.

Kod
model <- model %>%
  layer_flatten() %>%
  layer_dense(units = 64, activation = "relu") %>%
  layer_dense(units = 10, activation = "softmax")

Zrobimy klasyfikację 10-kierunkową, używając warstwy końcowej z 10 wyjściami i aktywacją softmax. Oto jak wygląda teraz sieć:

Kod
model
Model: "sequential"
________________________________________________________________________________
 Layer (type)                       Output Shape                    Param #     
================================================================================
 conv2d_2 (Conv2D)                  (None, 26, 26, 32)              320         
 max_pooling2d_1 (MaxPooling2D)     (None, 13, 13, 32)              0           
 conv2d_1 (Conv2D)                  (None, 11, 11, 64)              18496       
 max_pooling2d (MaxPooling2D)       (None, 5, 5, 64)                0           
 conv2d (Conv2D)                    (None, 3, 3, 64)                36928       
 flatten (Flatten)                  (None, 576)                     0           
 dense_1 (Dense)                    (None, 64)                      36928       
 dense (Dense)                      (None, 10)                      650         
================================================================================
Total params: 93322 (364.54 KB)
Trainable params: 93322 (364.54 KB)
Non-trainable params: 0 (0.00 Byte)
________________________________________________________________________________

Jak widać, wyjścia (3, 3, 64) są spłaszczane do wektorów o kształcie (576)2 przed przejściem przez dwie warstwy gęste.

2 ponieważ 3364=576

Kod
mnist <- dataset_mnist()

c(c(train_images, train_labels), c(test_images, test_labels)) %<-% mnist
train_images <- array_reshape(train_images, c(60000, 28, 28, 1))
train_images <- train_images / 255
test_images <- array_reshape(test_images, c(10000, 28, 28, 1))
test_images <- test_images / 255
train_labels <- to_categorical(train_labels)
test_labels <- to_categorical(test_labels)
Kod
model %>% compile(
  optimizer = "rmsprop",
  loss = "categorical_crossentropy",
  metrics = c("accuracy")
)

history <- model %>% fit(
  train_images, 
  train_labels,
  epochs = 5, 
  batch_size=64,
  validation_split = 0.2
)

Jak widać z powyższego wykresu model bardzo dobrze dopasował się do danych. Sprawdźmy zatem jak sobie radzi na zbiorze testowym.

Kod
# cache: true
results <- model %>% evaluate(test_images, test_labels)
313/313 - 2s - loss: 0.0446 - accuracy: 0.9883 - 2s/epoch - 6ms/step
Kod
results
    loss accuracy 
0.044566 0.988300 

W rezultacie otrzymaliśmy sieć o dokładności 99,08%. Choć może to w stosunku do 97,79%, czyli dokładności dla sieci gęstej z pierwszego przykładu MNIST, to jednak względny błąd predykcji dla sieci splotowej zmalał o ponad 58% 🤩. Wynik imponujący, zatem pojawia się pytanie skąd taka dokładność? Na czym polega magia sieci splotowych? W poniższych listingach postaramy się to wyjaśnić.

12.1 Działanie sieci splotowej

Podstawową różnicą pomiędzy warstwą gęstych połączeń a siecią splotową jest to, że warstwy dense uczą się cech parametrów globalnych w swoich wejściowych przestrzeniach (w przypadku cyfr MNIST są to wzorce związane ze wszystkimi pikselami), a warstwy konwolucyjne uczą się lokalnych wzorców (patrz Rysunek 12.1) - w przypadku obrazów wzorce są znajdowane w małych dwuwymiarowych oknach danych wejściowych. W zaprezentowanym przykładzie wszystkie te okna charakteryzowały się wymiarami 3x3.

Rysunek 12.1: Rozbicie obrazu na lokalne wzorce

Dzięki tej kluczowej charakterystyce sieci konwolucyjne mają dwie ciekawe własności:

  • wzorce rozpoznawane przez sieć są niezależne od przesunięcia. Sieć konwolucyjna po rozpoznaniu określonego wzoru w prawym dolnym rogu obrazu może rozpoznać go np. w lewym górnym rogu obrazu. Sieć gęsta w celu rozpoznania wzorca znajdującego się w innym miejscu musi nauczyć się go na nowo. W związku z tym sieci konwolucyjne charakteryzują się dużą wydajnością podczas przetwarzania obrazów. Sieci splotowe mogą skutecznie tworzyć uogólnienia po przetworzeniu mniejszego zbioru testowego.
  • sieci splotowe mogą uczyć się przestrzennej hierarchii wzorców (patrz Rysunek 12.8). Pierwsza warstwa uczy się rozpoznania położenia kluczowych obiektów przez zmianę konturów i kontrastu. Druga warstwa (pooling) redukuje najważniejsze informacje do prostszej postaci (zmniejszenie rozdzielczości). Kolejna warstwa stara się wyciągnąć kluczowe elementy (wzorce) występujące w obiekcie, jak linie proste, ukośne, okręgi, łuki, itp. Kolejne dwie warstwy ponownie redukują rozdzielczość wyciągając kluczowe elementy obrazu. Ostatecznie wartości wyjściowe z ostatniej warstwy konwolucyjnej przekazują kluczowe informacje do sieci gęstej, a ta ostatecznie zamienia je za pomocą funkcji softmax na przewidywane cyfry.

Sieci konwolucyjne działają na trójwymiarowych tensorach określanych mianem map cech, zawierających dwie przestrzenne osie definiujące wysokość i szerokość. Trzecią osią jest oś głębi, nazywana również osią kanałów. W przypadku obrazu RGB oś głębi ma trzy wymiary (po jednym dla każdego koloru). Obrazy monochromatyczne (takie jak MNIST), mają jeden wymiar głębi (kolor opisuje tylko skalę nasycenia szarości). Operacja konwolucji wyodrębnia fragmenty z wejściowej mapy cech i stosuje to samo przekształcenie do wszystkich tych fragmentów, dając wyjściową mapę cech. Ta wyjściowa mapa cech jest nadal tensorem 3D: ma szerokość i wysokość. Jej głębokość może być dowolna, ponieważ głębokość wyjściowa jest parametrem warstwy, a różne kanały w tej osi głębokości nie oznaczają już konkretnych kolorów, jak w przypadku wejścia RGB; oznaczają one raczej filtry. Filtry kodują specyficzne aspekty danych wejściowych: na wysokim poziomie pojedynczy filtr może kodować na przykład pojęcie “obecności twarzy na wejściu”.

Rysunek 12.2: Procedura filtrowania obrazu

W przykładzie MNIST, pierwsza warstwa konwolucji pobiera mapę cech o rozmiarze (28, 28, 1) i wyprowadza mapę cech o rozmiarze (26, 26, 32): oblicza 32 filtry na danych wejściowych. Każdy z tych 32 filtrów wyjściowych zawiera siatkę wartości 26 × 26, która jest mapą odpowiedzi filtra, wskazującą odpowiedź tego filtra w różnych miejscach wejścia (patrz Rysunek 12.2). To właśnie oznacza termin mapa cech: każdy wymiar na osi głębokości jest cechą (lub filtrem), a tensor 2D output[:, :, n] jest przestrzenną mapą 2D odpowiedzi tego filtra na wejście.

Konwolucje są definiowane przez dwa kluczowe parametry:

  • Rozmiar filtrów wyodrębnionych z wejść - są to zwykle 3 × 3 lub 5 × 5. W przykładzie były to 3 × 3, co jest częstym wyborem.
  • Głębokość wyjściowej mapy cech - czyli liczba filtrów obliczonych przez konwolucję. Przykład rozpoczął się z głębokością 32, a zakończył z głębokością 64.

W keras parametry te są pierwszymi argumentami przekazywanymi do warstwy: layer_conv_2d(output_depth, c(window_height, window_width)).

Konwolucja działa poprzez przesuwanie tych okien o rozmiarze 3 × 3 lub 5 × 5 po wejściowej mapie cech 3D, zatrzymując się w każdym możliwym miejscu, i wyodrębniając trójwymiarową łatę otaczających cech (kształt (window_height, window_width, input_depth)). Każda taka paczka 3D jest następnie przekształcana (poprzez iloczyn tensorowy z tą samą uczoną macierzą wag, zwaną jądrem konwolucji) w 1D wektor kształtu (output_depth). Wszystkie te wektory są następnie przestrzennie składane w trójwymiarową wyjściową mapę kształtu (wysokość, szerokość, głębokość wyjściowa). Każde miejsce w wyjściowej mapie cech odpowiada temu samemu miejscu w wejściowej mapie cech (na przykład prawy dolny róg wyjścia zawiera informacje o prawym dolnym rogu wejścia). Na przykład, przy oknach 3 × 3, wektor output[i, j, ] pochodzi z wejściowej mapy 3D input[i-1:i+1, j-1:j+1, ]. Pełny proces został szczegółowo przedstawiony na Rysunek 12.3.

Rysunek 12.3: Zasada działania filtrów w sieci splotowej

Zauważmy, że szerokość i wysokość wyjściowa może się różnić od szerokości i wysokości wejściowej. Mogą się one różnić z dwóch powodów:

  • Efekty brzegowe, którym można przeciwdziałać poprzez padding wejściowej mapy funkcji
  • Użycie pasków (ang. strides), które zdefiniujemy za chwilę.

12.1.1 Efekty brzegowe - padding

Rozważmy mapę cech 5 × 5 (łącznie 25 kwadratów). Jest tylko 9 kwadratów, wokół których można wyśrodkować okno 3 × 3, tworząc siatkę 3 × 3. Dlatego też wyjściowa mapa cech zmniejsza się nieco: w tym przypadku dokładnie o dwa kwadraty wzdłuż każdego wymiaru. Ten efekt brzegowy można zobaczyć w działaniu we wcześniejszym przykładzie: zaczynamy z 28 × 28 na danych wejściowych, które po pierwszej warstwie konwolucji stają się 26 × 26.

Jeśli chcemy uzyskać wyjściową mapę cech o takich samych wymiarach przestrzennych jak wejściowa, możemy użyć paddingu. Padding polega na dodaniu odpowiedniej liczby wierszy i kolumn po każdej stronie wejściowej mapy cech, tak aby umożliwić dopasowanie środkowych okien konwolucji wokół każdego kafelka wejściowego. Dla okna 3 × 3 dodamy jedną kolumnę po prawej, jedną kolumnę po lewej, jeden rząd na górze i jeden rząd na dole. Dla okna 5 × 5 dodalibyśmy dwa rzędy (patrz Rysunek 12.3).

W warstwach layer_conv_2d padding jest konfigurowalny poprzez argument padding, który przyjmuje dwie wartości: valid, co oznacza brak paddingu (zostaną użyte tylko poprawne lokalizacje okien); oraz same, co oznacza “rozszerz wejście w taki sposób, aby mieć wyjście o takiej samej szerokości i wysokości jak wejście”. Argument padding domyślnie przyjmuje wartość valid.

Rysunek 12.4: Przykłady paddingu i konwolucji kroczącej

12.1.2 Efekty brzegowe - stirdes

Innym czynnikiem, który może wpływać na wielkość wyjścia jest pojęcie pasków (ang. strides). Dotychczasowy opis konwolucji zakładał, że wszystkie środkowe kwadraty okien konwolucji są przylegające. Jednak odległość między dwoma kolejnymi oknami jest parametrem konwolucji, zwanym jej paskiem, który domyślnie wynosi 1. Możliwe jest istnienie konwolucji paskowych: konwolucji o pasku większym niż jeden. Na Rysunek 12.4 widać części wyekstrahowane przez konwolucję 3 x 3 z paskiem 2 na wejściu 5 × 5 (bez wypełnienia). Użycie paska o szerokości 2 oznacza, że szerokość i wysokość mapy cech są pomniejszane o współczynnik 2 (oprócz zmian wywołanych przez efekty brzegowe). Konwolucje z paskami są rzadko używane w praktyce, choć mogą być przydatne w niektórych typach modeli; dobrze jest zapoznać się z tą koncepcją. Do downsamplingu map cech, zamiast kroków, używamy zwykle operacji max pooling, którą zastosowaliśmy w sieci do przykładu MNIST. Przyjrzyjmy się jej bardziej szczegółowo.

12.2 Max pooling

W przykładzie MNIST mogliśmy zauważyć, że rozmiar map cech jest zmniejszany o połowę po każdej operacji layer_max_pooling_2d. Na przykład przed pierwszą layer_max_pooling_2d mapa cech ma rozmiar 26 × 26, ale operacja max poolingu zmniejsza ją o połowę do 13 × 13. Taka jest właśnie rola max poolingu: agresywne zmniejszanie próbkowania map cech, podobnie jak w przypadku konwolucji krokowych.

Operacja max pooling polega na wyodrębnieniu okien z wejściowych map cech i wyprowadzeniu maksymalnej wartości każdego filtra. Koncepcyjnie jest to podobne do konwolucji, z tą różnicą, że zamiast przekształcać lokalne łaty poprzez wyuczone przekształcenie liniowe (jądro konwolucji), są one przekształcane poprzez zakodowaną operację max tensora. Dużą różnicą w stosunku do konwolucji jest to, że max pooling jest zwykle wykonywany z oknami 2 × 2 i krokiem 2, w celu zmniejszenia próbkowania map cech o współczynnik 2. Z drugiej strony, konwolucja jest zwykle wykonywana z oknami 3 × 3 i bez kroku (stride 1).

Dlaczego obniżamy rozmiar mapy cech w ten sposób? Dlaczego nie usunąć warstw max pooling i zachować dość duże mapy funkcji przez całą sieć? Przyjrzyjmy się tej opcji. Konwolucyjna baza modelu wyglądałaby wtedy tak:

Kod
model_no_max_pool <- keras_model_sequential() %>%
  layer_conv_2d(filters = 32, kernel_size = c(3, 3), activation = "relu",
                input_shape = c(28, 28, 1)) %>%
  layer_conv_2d(filters = 64, kernel_size = c(3, 3), activation = "relu") %>%
  layer_conv_2d(filters = 64, kernel_size = c(3, 3), activation = "relu")

model_no_max_pool
Model: "sequential_1"
________________________________________________________________________________
 Layer (type)                       Output Shape                    Param #     
================================================================================
 conv2d_5 (Conv2D)                  (None, 26, 26, 32)              320         
 conv2d_4 (Conv2D)                  (None, 24, 24, 64)              18496       
 conv2d_3 (Conv2D)                  (None, 22, 22, 64)              36928       
================================================================================
Total params: 55744 (217.75 KB)
Trainable params: 55744 (217.75 KB)
Non-trainable params: 0 (0.00 Byte)
________________________________________________________________________________

Co jest nie tak z tą architekturą? Dwie rzeczy:

  • Nie sprzyja uczeniu się przestrzennej hierarchii cech. Okna 3 × 3 w trzecich warstwach będą zawierały jedynie informacje pochodzące z okien 7 × 7 wejścia początkowego. Wzorce wysokopoziomowe wyuczone przez sieć splotową będą nadal bardzo małe w stosunku do początkowego wejścia, co może nie wystarczyć do klasyfikacji cyfr (spróbujmy rozpoznać cyfrę, patrząc na nią tylko przez okna o wymiarach 7 × 7 pikseli!). Potrzebujemy, aby cechy z ostatniej warstwy konwolucji zawierały informacje o całości danych wejściowych.
  • Ostateczna mapa cech ma 22 * 22 * 64 = 30976 wszystkich współczynników na próbkę. Jest to ogromna ilość. Jeśli mielibyśmy ją spłaszczyć, aby dołączyć gęstą warstwę o rozmiarze 512, ta warstwa miałaby 15,8 miliona parametrów. Jest to zdecydowanie zbyt dużo dla tak małego modelu i spowodowałoby przeuczenie.

W skrócie, powodem użycia redukcji wymiaru jest zmniejszenie liczby współczynników mapy cech do przetworzenia, jak również wywołanie hierarchii filtrów przestrzennych poprzez sprawienie, że kolejne warstwy konwolucji będą patrzyły na coraz większe okna (w sensie ułamka oryginalnego wejścia, które obejmują).

Zauważmy, że max pooling nie jest jedynym sposobem, w jaki możemy osiągnąć taką redukcję wymiaru. Możemy też użyć average pooling zamiast max pooling, gdzie każdy lokalny fragment wejściowy jest przekształcany przez użycie średniej wartości każdego filtra w tym fragmencie, a nie maksimum. Mimo to, max pooling jest preferowanym rozwiązaniem ponieważ często daje lepsze rezultaty. W skrócie, powodem jest to, że cechy mają tendencję do kodowania przestrzennej obecności jakiegoś wzoru lub koncepcji w różnych kaflach mapy cech (stąd termin mapa cech), a bardziej informatywne jest spojrzenie na maksymalną obecność różnych cech niż na ich średnią obecność. Tak więc najrozsądniejszą strategią redukcji wymiaru jest najpierw wytworzenie map cech, a następnie spojrzenie na maksymalną aktywację cech w małych fragmentach, a nie patrzenie na rzadsze okna wejść lub uśrednianie fragmentów wejściowych, co może spowodować przegapienie lub rozmycie informacji o obecności cech.

Poniżej zaprezentowane są wyniki działania poszczególnych warstw sieci konwolucyjnej.

img <- train_images[44,,,] |> as.raster()
plot(img, interp=F)

img_tensor <- train_images[44,,,]
dim(img_tensor)

img_tensor <- array_reshape(img_tensor, c(1, 28, 28, 1))
dim(img_tensor)

layer_outputs <- lapply(model$layers[1:5], function(layer) layer$output)
layer_outputs

activation_model <- keras_model(inputs = model$input, outputs = layer_outputs)
activations <- activation_model %>% predict(img_tensor)

first_layer_activation <- activations[[1]]
dim(first_layer_activation)

plot_channel <- function(channel) {
  rotate <- function(x) t(apply(x, 2, rev))
  image(rotate(channel), axes = FALSE, asp = 1, 
        col = gray.colors(20))
}

plot_channel(first_layer_activation[1,,,4])
plot_channel(first_layer_activation[1,,,10])
Kod
1
wybierz obraz
2
zamień go na tensor
3
dostosuj rozmiar tensora do wejścia do sieci
4
wylistuj wszystkie wyjścia z sieci splotowych
5
stwórz model pomocniczy składający się z wejścia i warstw splotowych
6
wybierz warstwę do wizualizacji
7
napisz funkcję do wyświetlania obrazów
8
rysuj obrazy
[1] 28 28
[1]  1 28 28  1
[[1]]
KerasTensor(type_spec=TensorSpec(shape=(None, 26, 26, 32), dtype=tf.float32, name=None), name='conv2d_14/Relu:0', description="created by layer 'conv2d_14'")

[[2]]
KerasTensor(type_spec=TensorSpec(shape=(None, 13, 13, 32), dtype=tf.float32, name=None), name='max_pooling2d_9/MaxPool:0', description="created by layer 'max_pooling2d_9'")

[[3]]
KerasTensor(type_spec=TensorSpec(shape=(None, 11, 11, 64), dtype=tf.float32, name=None), name='conv2d_13/Relu:0', description="created by layer 'conv2d_13'")

[[4]]
KerasTensor(type_spec=TensorSpec(shape=(None, 5, 5, 64), dtype=tf.float32, name=None), name='max_pooling2d_8/MaxPool:0', description="created by layer 'max_pooling2d_8'")

[[5]]
KerasTensor(type_spec=TensorSpec(shape=(None, 3, 3, 64), dtype=tf.float32, name=None), name='conv2d_12/Relu:0', description="created by layer 'conv2d_12'")

1/1 - 0s - 47ms/epoch - 47ms/step
[1]  1 26 26 32
Rysunek 12.5: Wyniki filtracji pierwszą warstwą splotową (wybrano filtry 4 i 10)
Rysunek 12.6: Wyniki filtracji pierwszą warstwą splotową (wybrano filtry 4 i 10)
Rysunek 12.7: Wyniki filtracji pierwszą warstwą splotową (wybrano filtry 4 i 10)

Tak wygląda wynik pierwszej warstwy splotowej dla wybranych dwóch filtrów (kanałów), a jakby to wyglądało gdyby wyświetlić wyniki wszystkich warstw i kanałów.

Kod
dir.create("nine_activations")
image_size <- 58
images_per_row <- 16
for (i in 1:5) {
  
  layer_activation <- activations[[i]]
  layer_name <- model$layers[[i]]$name $

  n_features <- dim(layer_activation)[[4]]
  n_cols <- n_features %/% images_per_row
 
  png(paste0("nine_activations/", i, "_", layer_name, ".png"),
      width = image_size * images_per_row, 
      height = image_size * n_cols)
  op <- par(mfrow = c(n_cols, images_per_row), mai = rep_len(0.02, 4))
  
  for (col in 0:(n_cols-1)) {
    for (row in 0:(images_per_row-1)) {
      channel_image <- layer_activation[1,,,(col*images_per_row) + row + 1]
      plot_channel(channel_image)
    }
  }
  
  par(op)
  dev.off()
}
1
stwórz katalog na obrazy
2
wybierz wielkość obrazu w px
3
wybierz ile obrazów ma się mieścić w wierszu
4
rozpocznij pętlę po wszystkich nr warstw splotowych
5
przypisz i-tą warstwę
6
zapisz nazwę warstwy
7
wyciągnij liczbę filtrów
8
oblicz liczbę obrazów na wiersz
9
stwórz plik png o wymiarach zgodnych z liczbą obrazów w wierszu i liczbą wierszy
10
określ parametry obrazu (zmiana layout i marginesów)
11
rozpocznij pętlę po kolumnach
12
rozpocznij pętlę po wierszach
13
wybierz filtr do obrazowania
14
narysuj obraz filtra
15
zapisz plik

Rysunek 12.8: Wyniki wszystkich warstw splotowych i wszystkich filtrów