Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Szybkie operacje pikselowe w .NET (z i bez unsafe)

Klasa Bitmap posiada metody GetPixel i SetPixel, które pozwalają na pobranie i zmianę koloru piksela. Metody te są bardzo proste w użyciu, niestety są też niezwykle powolne. Mój poprzedni post szczegółowo opisuje ten temat, kliknij tutaj jeśli chcesz wiedzieć więcej.

Na szczęście nie trzeba używać zewnętrznych bibliotek (lub całkowicie rezygnować z .NET) by efektywnie przetwarzać obraz. Framework ma wbudowaną klasę ColorMatrix pozwalającą na wykonanie zmian na mapie bitowej w krótkim czasie. Właściwości takie jak kontrast lub nasycenie mogą być modyfikowane w ten sposób. Co jednak z manipulowaniem pojedynczymi pikselami? Da się to zrobić za pomocą metody Bitmap.LockBits i klasy BitmapData

Dobrym testem szybkości operacji pikselowych jest badanie różnicy kolorów. Zadanie polega na znalezieniu fragmentów obrazu, które mają kolor podobny do zadanego koloru. Jak sprawdzić czy kolory są podobne? Pomyśl o kolorze jako punkcie w trójwymiarowej przestrzeni, gdzie osiami są: czerwony, zielony i niebieski. Dwa kolory to dwa punkty. Różnica między kolorami jest więc opisana przez dystans dzielący te dwa punkty w przestrzeni RGB.

Kolory jako punkty w przestrzeni 3D

diff = sqrt((C1R-C2R)2+(C1G-C2G)2+(C1B-C2B)2)

Ta technika jest bardzo prosta w realizacji i daje przyzwoite rezultaty. Porównywanie kolorów jest jednak zagadnieniem bardzo złożonym. Inne przestrzenie koloru niż RGB nadają się lepiej do tego zadania. Pod uwagę należy również brać sposób percepcji koloru przez człowieka (np. nasze oczy są bardziej wyczulone na różnice w odcieniach zieleni niż koloru niebieskiego). Trzymajmy się jednak prostego rozwiązania…

Naszym testowym obrazem będzie to zdjęcie Ultra HD 8K: 7680x4320, 33.1Mpx (na potrzeby bloga je oczywiście zmniejszyłem by nie marnować transferu):

Obraz wejściowy dla badania różnicy koloru (zmniejszony dla bloga)

Oto metoda, która może np. wyszukać piksele R=255 G=161 B=71 (numer auta "36") i ustawić je na kolor biały (reszta stanie się czarna):

static void DetectColorWithGetSetPixel(Bitmap image, byte searchedR, byte searchedG, int searchedB, int tolerance)
{
    int toleranceSquared = tolerance * tolerance;

    for (int x = 0; x < image.Width; x++)
    {
        for (int y = 0; y < image.Height; y++)
        {
            Color pixel = image.GetPixel(x, y);

            int diffR = pixel.R - searchedR;
            int diffG = pixel.G - searchedG;
            int diffB = pixel.B - searchedB;

            int distance = diffR * diffR + diffG * diffG + diffB * diffB;

            image.SetPixel(x, y, distance > toleranceSquared ? Color.Black : Color.White);
        }
    }
}

Powyższy kod to nasza okropnie wolna baza porównawcza Get/SetPixel. Przy wywołaniu w taki sposób (parametry nazwana dla czytelności):

DetectColorWithGetSetPixel(image, searchedR: 255, searchedG: 161, searchedB: 71, tolerance: 60);

otrzymamy taki oto wynik:

Obraz wyjściowy dla badania różnicy koloru (zmniejszony dla bloga)

Rezultat jest całkiem ok ale czekanie na niego ponad 84300ms* to całkowita porażka.

Teraz rzuć okiem na tą metodę:

static unsafe void DetectColorWithUnsafe(Bitmap image, byte searchedR, byte searchedG, int searchedB, int tolerance)
{
    BitmapData imageData = image.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
    int bytesPerPixel = 3;

    byte* scan0 = (byte*)imageData.Scan0.ToPointer();
    int stride = imageData.Stride;

    byte unmatchingValue = 0;
    byte matchingValue = 255;
    int toleranceSquared = tolerance * tolerance;

    for (int y = 0; y < imageData.Height; y++)
    {
        byte* row = scan0 + (y * stride);

        for (int x = 0; x < imageData.Width; x++)
        {
            // Uwaga na rzeczywistą kolejność (BGR)!
            int bIndex = x * bytesPerPixel;
            int gIndex = bIndex + 1;
            int rIndex = bIndex + 2;

            byte pixelR = row[rIndex];
            byte pixelG = row[gIndex];
            byte pixelB = row[bIndex];

            int diffR = pixelR - searchedR;
            int diffG = pixelG - searchedG;
            int diffB = pixelB - searchedB;

            int distance = diffR * diffR + diffG * diffG + diffB * diffB;

            row[rIndex] = row[bIndex] = row[gIndex] = distance > toleranceSquared ? unmatchingValue : matchingValue;
        }
    }

    image.UnlockBits(imageData);
}

Robi dokładnie to samo ale wykonuje się tylko 230ms ponad 360 razy szybciej!

Kod ten używa metody Bitmap.LockBits, która jest oprawką na natywną funkcję GdipBitmapLockBits (GDI+, gdiplus.dll). LockBits tworzy tymczasowy bufor przechowujący informacje o pikselach w żądanym formacie (w naszym przypadku RGB, po 8 bitów na składową koloru). Wszelkie zmiany w tym buforze są kopiowane do bitmapy przy wywołaniu UnlockBits (dlatego zawsze należy używać LockBits i UnlockBits jako pary). Bitmap.LockBits zwraca obiekt BitmapData (namespace System.Drawing.Imaging), który posiada dwie interesujące właściwości: Scan0 i Stride. Scan0 zwraca adres danych pierwszego piksela. Stride to szerokość jednego rzędu pikseli (tzw. scan line) w bajtach (z opcjonalnym wypełnieniem tak by wartość była podzielna przez 4). 

Układ BitmapData

Zauważ, że nie używam odwołań do Math.Pow i Math.Sqrt by obliczyć dystans między kolorami. Pisanie takiego kodu:

 

double distance = Math.Sqrt(Math.Pow(pixelR - searchedR, 2) + Math.Pow(pixelG - searchedG, 2) + Math.Pow(pixelB - searchedB, 2));

do przetwarzania milionów pikseli do straszny pomysł. Taka linia uczyniła by naszą zoptymalizowaną metodę około 25 razy wolniejszą. Używanie Math.Pow z parametrami całkowitymi to czyste marnotrawstwo, nie trzeba też wyliczać pierwiastka kwadratowego by określić czy odległość mieści się w pewnej tolerancji.

Pokazana wcześniej metoda jest oznaczona słowem kluczowym unsafe. Pozwala ono programowi C# na użycie operacji wskaźnikowych. Niestety tryb unsafe ma istotne ograniczenia. Kod, który go używa musi być skompilowany z opcją \unsafe oraz musi być wykonywany w pełni zaufanym assembly.

Na szczęście istnieje metoda Marshal.Copy (z przestrzeni nazw System.Runtime.InteropServices), dzięki której można przenosić dane między zarządzaną a niezarządzana pamięcią. Możemy jej użyć do skopiowania danych obrazu do tablicy bajtów i manipulowania pikselami w bardzo efektywny sposób. Przeanalizuj tą metodę:

static void DetectColorWithMarshal(Bitmap image, byte searchedR, byte searchedG, int searchedB, int tolerance)
{        
    BitmapData imageData = image.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);

    byte[] imageBytes = new byte[Math.Abs(imageData.Stride) * image.Height];
    IntPtr scan0 = imageData.Scan0;

    Marshal.Copy(scan0, imageBytes, 0, imageBytes.Length);
  
    byte unmatchingValue = 0;
    byte matchingValue = 255;
    int toleranceSquared = tolerance * tolerance;

    for (int i = 0; i < imageBytes.Length; i += 3)
    {
        byte pixelB = imageBytes[i];
        byte pixelR = imageBytes[i + 2];
        byte pixelG = imageBytes[i + 1];

        int diffR = pixelR - searchedR;
        int diffG = pixelG - searchedG;
        int diffB = pixelB - searchedB;

        int distance = diffR * diffR + diffG * diffG + diffB * diffB;

        imageBytes[i] = imageBytes[i + 1] = imageBytes[i + 2] = distance > toleranceSquared ? unmatchingValue : matchingValue;
    }

    Marshal.Copy(imageBytes, 0, scan0, imageBytes.Length);

    image.UnlockBits(imageData);
}

Wykonuje się ona tylko 280ms, jest wiec jedynie odrobinę wolniejsza od wersji unsafe. Pod względem CPU kod jest oszczędny jednak zużywa więcej pamięci niż poprzednia metoda – prawie 100 megabajtów dla naszego testowego obrazu Ultra HD 8K w formacie RGB 24.

Jeśli chcesz uzyskać jeszcze większą prędkość obróbki obrazu możesz przetwarzać różne części obrazu równolegle. Musisz jednak wykonać nieco testów ponieważ dla małych obrazów koszt użycia wielu wątków może okazać się większy niż zysk z wykonywania równoległego. Poniższa metoda to przykład kodu, który używa 4 wątków do jednoczesnego procesowania 4 fragmentów obrazu. Na mojej maszynie daje to ok 30% wzrostu wydajności. Traktuj ten kod jako szybką wskazówkę, ten post jest już i tak zbyt długi…

static unsafe void DetectColorWithUnsafeParallel(Bitmap image, byte searchedR, byte searchedG, int searchedB, int tolerance)
{
    BitmapData imageData = image.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
    int bytesPerPixel = 3;

    byte* scan0 = (byte*)imageData.Scan0.ToPointer();
    int stride = imageData.Stride;

    byte unmatchingValue = 0;
    byte matchingValue = 255;
    int toleranceSquared = tolerance * tolerance;

    Task[] tasks = new Task[4];
    for (int i = 0; i < tasks.Length; i++)
    {
        int ii = i;
        tasks[i] = Task.Factory.StartNew(() =>
            {
                int minY = ii < 2 ? 0 : imageData.Height / 2;
                int maxY = ii < 2 ? imageData.Height / 2 : imageData.Height;

                int minX = ii % 2 == 0 ? 0 : imageData.Width / 2;
                int maxX = ii % 2 == 0 ? imageData.Width / 2 : imageData.Width;                        
                
                for (int y = minY; y < maxY; y++)
                {
                    byte* row = scan0 + (y * stride);

                    for (int x = minX; x < maxX; x++)
                    {
                        int bIndex = x * bytesPerPixel;
                        int gIndex = bIndex + 1;
                        int rIndex = bIndex + 2;

                        byte pixelR = row[rIndex];
                        byte pixelG = row[gIndex];
                        byte pixelB = row[bIndex];

                        int diffR = pixelR - searchedR;
                        int diffG = pixelG - searchedG;
                        int diffB = pixelB - searchedB;

                        int distance = diffR * diffR + diffG * diffG + diffB * diffB;

                        row[rIndex] = row[bIndex] = row[gIndex] = distance > toleranceSquared ? unmatchingValue : matchingValue;
                    }
                }
            });
    }

    Task.WaitAll(tasks);

    image.UnlockBits(imageData);
}

Aktualizacja 2018-01-08: Jeśli zależy Ci na naprawdę wydajnym przetwarzaniu obrazu powinieneś sięgnąć po wyspecjalizowaną bibliotekę, taką jak OpenCV. Kilka miesięcy temu napisałem serie postów "Detecting a Drone - OpenCV in .NET for Beginners (Emgu CV 3.2, Visual Studio 2017)", które pomogą Ci zacząć...

* Aplikacja konsolowa .NET 4 uruchamiana na laptopie MSI GE620 DX: Intel Core i5-2430M 2.40GHz (2 rdzenie, 4 wątki), 4GB DDR3 RAM, NVIDIA GT 555M 2GB DDR3, HDD 500GB 7200RPM, Windows 7 Home Premium x64.

Komentarze (3) -

  • Kuba P.

    2019-01-24 13:05:17 | Odpowiedź

    Rewelacyjny artykuł. Pozdrawiam.

  • Spoko

    2019-03-06 00:04:11 | Odpowiedź

    Jak Span<T> wpłynie na zabawy na Bitmapach?

  • Test

    2019-03-06 00:06:58 | Odpowiedź

    Znikł mój komentarz? Tong

    Jak wpłynie Span<T> na operacje na .bmp?

    Internal Server Error

    Server Error in '/' Application.
    Runtime Error
    Description: An exception occurred while processing your request. Additionally, another exception occurred while executing the custom error page for the first exception. The request has been terminated.

Dodaj komentarz

Loading