dikamilo.net

Kolejny blog w sieci...

Filtry splotowe w C#

Kolejnym zagadnieniem grafiki komputerowej które chciałbym omówić są tak zwane filtry splotowe. Są one stosowane w przetwarzaniu obrazów do uzyskiwania różnych informacji o obrazie, do nakładania efektów takich jak rozmycie, wyostrzenie czy odnajdowanie krawędzi. Działanie filtrów splotowych opiera się na wyliczeniu nowej wartości piksela biorąc pod uwagę sąsiednie piksele.

Generalnie filtr składa się z maski wag każdego piksela. Typowy rozmiar maski to 3x3, 5x5 czy 7x7. Oczywiście maski mogą być dużo większe i nie koniecznie muszą być kwadratowe jednak wysokość i szerokość takiej maski musi być nieparzysta. Im większa maska tym efekt jest silniejszy i bardziej dokładny a co za tym idzie obliczenia trwają dłużej. Środkowa wartość maski jest przypisana do piksela na którym wykonywane są przekształcenia. Dodatkowo stosuje się tak zwaną wartość normalizacyjną która zazwyczaj jest sumą wszystkich wag maski (w przypadku gdy suma wag wynosi 0, współczynnik przyjmuje wartość 1), lub może być przypisana ręcznie w zależności co chcieli byśmy uzyskać.

Najprostszym filtrem jest filtr 3x3 w którym środkowa wartość wynosi 1 a pozostałe 0. Taki filtr nic nie zmienia w przetwarzanym obrazie.

Obliczenie wartości piksela polega na wymnożeniu wartości pikseli przez ich wagę z maski oraz zsumowaniu ich a następnie podzielenie wartości przez współczynnik normalizacyjny. Jako iż filtry splotowe uwzględniają sąsiednie piksele, aby dokonać obliczeń będziemy potrzebowali dwóch kopi bitmapy, z jednej będziemy odczytywać wartości pikseli a na drugiej będziemy zapisywać obliczone wartości.

Problemem przy filtrach splotowych jest obliczanie wartości pikseli znajdujących się obrzeżach bitmapy. Stosując maskę 3x3 dla piksela w górnym lewym narożniku nie możemy wyliczyć wartości dla pikseli sąsiadujących z góry oraz lewej strony.

Filtr splotowy 3x3

Obrazek przedstawia próbę nałożenia maski 3x3 na brzegowy piksel. Zielone piksele przedstawiają bitmapę natomiast niebieskie maskę, kolorem szarym zaznaczony jest piksel na którym wykonywane są operację.

Powyższy problem można obejść na kilka sposobów. Jednym z nich jest obcięcie bitmapy i zmniejszenie jej wymiarów o ramkę co nie jest zbyt eleganckim rozwiązaniem. Innym rozwiązaniem jest pobranie pikseli z przeciwnej strony bitmapy, dla lewej strony - piksele z prawej, dla górnej strony - piksele z dolnej. Rozwiązanie to nie jest zbyt dobre ponieważ przy stosowaniu filtrów można zobaczyć "artefakty". Najlepszym rozwiązaniem zdaje się być pobranie pikseli będących na brzegu bitmapy. Dla obliczenia piksela -1,-1 pobieramy piksel 0,0, dla piksela -1,2 pobieramy 0,2 i tak dalej.

Obliczanie wartości dla pikseli na krawędzi

W mojej implementacji wykorzystałem właśnie to rozwiązanie ponieważ wydaje się najbardziej odpowiednie i nie ma zbyt dużego wpływu na jakość filtrowania.

Poniżej znajduje się implementacja:

// bitmapa na ktora bedziemy zapisywac
Bitmap image = (Bitmap)img.Clone();

// bitmapa z ktorej odczytujemy piksele
Bitmap tempBitmap = (Bitmap)image.Clone();

// zablokowanie bitmap
BitmapData imageData = image.LockBits(
    new Rectangle(0, 0, image.Width, image.Height),
    ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);

BitmapData tempData = tempBitmap.LockBits(
    new Rectangle(0, 0, tempBitmap.Width, tempBitmap.Height),
    ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);

// obliczenie zmiennych pomocniczych
int pixelPerSide = matrixWidth / 2;
int imageWidthWithoutPixelPerSide = image.Width - pixelPerSide;
int imageHeightWithoutPixelPerSide = image.Height - pixelPerSide;

// wartosci poszczegolnych kolorow piksela
double redPixel = 0;
double bluePixel = 0;
double greenPixel = 0;

// wspolzedne piksela
int imageX = 0;
int imageY = 0;

unsafe
{
    // wskazniki na bitmape
    byte* imagePtr = (byte*)imageData.Scan0;
    byte* tempPtr = (byte*)tempData.Scan0;

    // petla po wszystkich pikselach bitmapy
    for (int y = 0; y < image.Height; y++)
    {
        for (int x = 0; x < image.Width; x++)
        {
            // wyzerowanie wartosci pikseli
            redPixel = bluePixel = greenPixel = 0;

            int xWithoutPixelPerSide = x - pixelPerSide;
            int yWithoutPixelPerSide = y - pixelPerSide;

            // petla po masce filtra
            for (int i = 0; i < matrixWidth; ++i)
            {
                for (int j = 0; j < matrixWidth; ++j)
                {
                    imageX = (xWithoutPixelPerSide + i + image.Width) 
                        % image.Width;

                    imageY = (yWithoutPixelPerSide + j + image.Height) 
                        % image.Height;

                    if ((imageX >= imageWidthWithoutPixelPerSide) 
                        && x <= pixelPerSide)
                    {
                        imageX = 0;
                    }

                    if ((imageX <= pixelPerSide) 
                        && x >= (imageWidthWithoutPixelPerSide ))
                    {
                        imageX = image.Width - 1;
                    }

                    if ((imageY >= imageHeightWithoutPixelPerSide) 
                        && y <= pixelPerSide)
                    {
                        imageY = 0;
                    }

                    if ((imageY <= pixelPerSide) 
                        && y >= (imageHeightWithoutPixelPerSide))
                    {
                        imageY = image.Height - 1;
                    }

                    // ustawienie wskaznika na pozadanym pikselu
                    tempPtr = (byte*)(tempData.Scan0) + 
                        (imageY * tempData.Stride) + (imageX * 3);

                    // obliczenie wartosci
                    redPixel += matrix[i * matrixWidth + j] * 
                        (double)tempPtr[2];

                    greenPixel += matrix[i * matrixWidth + j] * 
                        (double)tempPtr[1];

                    bluePixel += matrix[i * matrixWidth + j] * 
                        (double)tempPtr[0];
                }
            }
            // ustawienie wskaznika na pozadanym pikselu
            imagePtr = (byte*)(imageData.Scan0) + 
                (y * imageData.Stride) + (x * 3);

            // zapisanie wyliczonych wartosci
            imagePtr[2] = (byte)correct(redPixel / factor);
            imagePtr[1] = (byte)correct(greenPixel / factor);
            imagePtr[0] = (byte)correct(bluePixel / factor);
        }
    }
}
image.UnlockBits(imageData);
tempBitmap.UnlockBits(tempData);

Moja implementacja uwzględnia tylko filtry kwadratowe jednak ich wielkość nie ma znaczenia. Zasada działania jest następująca:

  • Iteracja po wszystkich pikselach bitmapy.
  • Wyzerowanie wartości do obliczeń oraz policzenie wysokości i szerokości bitmapy bez "ramki".
  • Iteracja po masce filtra.
  • Zamiana współrzędnych piksela na brzegowe jeżeli jest to wymagane.
  • Pobranie piksela oraz doliczenie wagi piksela do sumy.
  • Pobranie współrzędnych piksela docelowego i zapisanie nowej wartości dzieląc ją przez wartość normalizacyjną (factor).

Funkcja correct zamienia wartość piksela na 0 gdy jest mniejsza od 0 oraz na 255 gdy jest większa od 255.

Algorytm działa poprawnie jednak dzielenie modulo znacznie spowalnia obliczenia co można zauważyć licząc filtr na dużych bitmapach. Dodatkowo zrobiłem osobne okienko w którym można wprowadzić dowolny filtr 7x7:

Okno do wprowadzania własnych filtrów

Kontrolki w okienku ustawione są na sztywno tylko dla tego że nie wiedziałem jak rozwiązać problem ze zwiększaniem i zmniejszaniem wielkości filtra. Dopiero jakiś czas potem znalazłem wygodny sposób aby uzyskać taki efekt.

Poniżej przedstawiam kilka wybranych filtrów których używałem do testowania. Filtry zapisane są w mojej klasie jako statyczne tablice aby mógł wygodnie się do nich odwoływać z GUI aplikacji.

public static double[] boxFilter = {
    1, 1, 1, 
    1, 1, 1, 
    1, 1, 1 };
public static double[] gaussianBlur = { 
    1, 2, 1, 
    2, 4, 2, 
    1, 2, 1 };
public static double[] sharpenFilter = { 
    -1, -2, -1, 
    -2, 16, -2, 
    -1, -2, -1 };
public static double[] laplacianFilter = { 
    0, -1, 0, 
    -1, 4, -1, 
    0, -1, 0 };
public static double[] embossFilter = { 
    2, 0, -0, 
    0, -1, 0, 
    0, 0, -1 };
public static double[] sobelHorizontalFilter = { 
    -1, 0, 1, 
    -2, 0, 2, 
    -1, 0, 1 };
public static double[] sobelVerticalFilter = { 
    -1, -2, -1, 
    0, 0, 0, 
    1, 2, 1 };
public static double[] motionBlurFilter = {
    1, 0, 0, 0, 0,
    0, 1, 0, 0, 0,
    0, 0, 1, 0, 0,
    0, 0, 0, 1, 0,
    0, 0, 0, 0, 1 };
public static double[] findHorizontalEdges = { 
    0,  0,  0,  0,  0,
    0,  0,  0,  0,  0,
    -1, -1,  2,  0,  0,
    0,  0,  0,  0,  0,
    0,  0,  0,  0,  0 };
public static double[] findVerticalEdges = {
    0,  0, -1,  0,  0,
    0,  0, -1,  0,  0,
    0,  0,  4,  0,  0,
    0,  0, -1,  0,  0,
    0,  0, -1,  0,  0 };
public static double[] highPassFilter = {
    0, -1, -1, -1, 0,
    -1, 2, -4, 2, -1,
    -1, -4, 13, -4, -1,
    -1, 2, -4, 2, -1,
    0, -1, -1, -1, 0 };

Tagi