Skala szarości i sepia

Na początku miałem zamiar zrobić z tego tematu krótki tekst, który w sam raz nadawałby się na Short Tip. Jednak testowy projekt rozrósł się na tyle (a przy okazji doszedłem do paru ciekawych wniosków), że grzechem byłoby niepoświęcenie temu „śledztwu” kilku minut i słów więcej.

Chodzi o banalny na pozór temat: przetwarzanie obrazów i dwa bodajże najbardziej popularne filtry: skalę szarości oraz sepię. Przy okazji pisania projektu w CUDA chciałem obejrzeć te efekty, zaimplementowałem więc prosty postprocess na obrazie, który generowałem. Wszystkich podekscytowanych uprzedzam – nie o CUDA ta notka, chociaż nie wykluczam pojawienia się tego tematu w niedalekiej przyszłości. Dość zatem lania wody, czas na przydatne informacje.

Krótkie wprowadzenie: obraz składa się z pikseli, piksel ma cztery składowe (czerwoną, zieloną, niebieską oraz kanał alfa, którym nie będziemy się zajmować). Projekt przykładowy korzysta z klasy Bitmap, a rolę modelki po raz kolejny zgodziła się odegrać nieśmiertelna Lena:

lena

Skala szarości

Aby zastosować ten filtr dla obrazu należy dla każdego piksela input, przypisać składowym wartość

output.R = output.G = output.B = (input.R + input.G + input.B) / 3;

To najprostszy sposób. Głębsze sięgnięcie do literatury pozwoli znaleźć nieco lepsze mnożniki dla odpowiednich składowych koloru. Wynikowa wartość dla składowych piksela będzie więc liczona w następujący sposób:

output.R = output.G = output.B = input.R * 0.3 + input.G * 0.59 + input.B * 0.11;

lenaGrayscale

Sepia

O samej technice w fotografii analogowej i cyfrowej można więcej dowiedzieć się gdzie indziej (http://en.wikipedia.org/wiki/Photographic_print_toning). Aby uzyskać obraz właśnie w takich właśnie odcieniach można pójść dwiema drogami.

  1. wersja z mnożnikami
    Tak jak w przypadku skali szarości, można posłużyć się odpowiednimi mnożnikami, lecz tym razem wynikowy kolor będzie miał różniące się między sobą wartości poszczególnych składowych:

    output.R = input.R * .393 + input.G * .769 + input.B * .189;
    output.G = input.R * .349 + input.G * .686 + input.B * .168;
    output.B = input.R * .272 + input.G * .534 + input.B * .131;
    

    lenaSepia

  2. wersja z Depth i Intensity
    Druga wersja składa się z dwóch kroków, pierwszy to przekonwertowanie koloru piksela do skali szarości, drugi – zwiększenie wartości składowych czerwonej (najbardziej) oraz zielonej (mniej) oraz zmniejszenie wartości składowej niebieskiej. Podczas poszukiwań natknąłem się na nazwy sepiaDepth oraz sepiaIntensity, z odpowiednimi wartościami 20 i 30 dającymi najlepsze efekty. Warto zauważyć, że sepiaDepth ustawione na 0 daje obraz czarno-biały.

    const int sepiaDepth = 20;
    const int sepiaIntensity = 30;
    // to grayscale
    output.R = output.G = output.B = input.R * 0.3 + input.G * 0.59 + input.B * 0.11;
    // increase R and G
    output.R += 2 * sepiaDepth;
    output.G += sepiaDepth;
    // decrease B
    output.B -= sepiaIntensity;
    

    lenaSepiaDI

Tyle jeżeli chodzi o teorię dotyczącą teorię dotyczącą modyfikacji kolorów obrazu aby uzyskać pożądany filtr. Przejdźmy do metod stosowania jej do obrazów. Do testów wyodrębniłem trzy podejścia, nazwałem je enigmatycznie „quick & painless”, „slow & horrible” i „unsafe”. Zaraz okaże się dlaczego.

„Slow & horrible”

Podejście naiwne, czyli pobranie wartości każdego piksela z bitmapy metodą GetPixel(), zmodyfikowanie wartości jego składowych i zapisanie wynikowego piksela metodą SetPixel. Kod (przekształcający obraz do skali szarości) poniżej:

public Bitmap ToGrayscale(Bitmap bmp)
{
    const float rMod = 0.30f;
    const float gMod = 0.59f;
    const float bMod = 0.11f;
    Bitmap res = new Bitmap(bmp.Width, bmp.Height);
    for (int y = 0; y < bmp.Height; ++y)
        for (int x = 0; x  < bmp.Width; ++x)
        {
            Color c = bmp.GetPixel(x, y);
            int grayscaleColor = (int)(c.R * rMod + c.G * gMod + c.B * bMod);
            res.SetPixel(x, y, Color.FromArgb(grayscaleColor, grayscaleColor, grayscaleColor));
        }
    return res;
}

Wady:

  • dla obrazu wykonywanych jest bmp.Width * bmp.Height operacji GetPixel()/SetPixel(), więc: horribly slow (stąd nazwa)

Zalety:

  • hmmm, łatwość implementacji?

„Unsafe”

Metoda samoopisująca się – używamy kodu niezarządzanego, lock’ujemy bitmapę, pobieramy wskaźnik do pierwszego elementu i rysujemy bezpośrednio po pamięci (kod tym razem odpowiedzialny za sepię metodą z mnożnikami):

public Bitmap ToSepia(Bitmap bmp)
{
    Bitmap res = new Bitmap(bmp.Width, bmp.Height);
    unsafe
    {
        // lock bitmaps
        BitmapData srcData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
        BitmapData dstData = res.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), 	  ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
        const int bytesPerPixel = 3;
        for (int y = 0; y < bmp.Height; y++)
        {
            // get address of current row in source and destination bitmap
            byte* srcRow = (byte*)srcData.Scan0 + (y * srcData.Stride);
            byte* dstRow = (byte*)dstData.Scan0 + (y * dstData.Stride);
            for (int x = 0; x < bmp.Width; x++)
            {
                // get color
                byte b = srcRow[x * bytesPerPixel];
                byte g = srcRow[x * bytesPerPixel + 1];
                byte r = srcRow[x * bytesPerPixel + 2];

                int rr = (int)(r * .393f + g * .769f + b * .189f);
                int gg = (int)(r * .349f + g * .686f + b * .168f);
                int bb = (int)(r * .272f + g * .534f + b * .131f);

                // B
                dstRow[x * bytesPerPixel] = (bb > 255) ? (byte)255 : (byte)bb;
                // G
                dstRow[x * bytesPerPixel + 1] = (gg > 255) ? (byte)255 : (byte)gg;
                // R
                dstRow[x * bytesPerPixel + 2] = (rr > 255) ? (byte)255 : (byte)rr;
            }
        }
        // unlock bitmaps
        res.UnlockBits(dstData);
        bmp.UnlockBits(srcData);
    }
    return res;

Zalety:

  • zdecydowanie szybsza od „slow & horrible”
  • implementacja pętli w miarę intuicyjna

Wady:

  • użycie kodu niezarządzanego (nie każdy lubi i radzi sobie ze wskaźnikami [sic!])
  • w srcRow i dstRow składowe koloru są ułożone w kolejności BGR a nie RGB – łatwa pułapka

„Quick & painless”

Na fragment kodu źródłowego używającego tego podejścia natknąłem się przypadkiem, na MDSN musiałem zerknąć głębiej w dokumentację klas. Chodzi o użycie obiektów: ColorMatrix, ImageAttributes oraz metody DrawImage obiektu Graphics. Jak zwykle, fragment kodu mówi więcej, niż tysiąc słów:

public Bitmap ToGrayscale(Bitmap bmp)
{
    const float rMod = 0.30f;
    const float gMod = 0.59f;
    const float bMod = 0.11f;
    Bitmap res = new Bitmap(bmp.Width, bmp.Height);
    Graphics g = Graphics.FromImage(res);

    ColorMatrix colorMatrix = new olorMatrix(new[]
    {
        new[] {rMod, rMod, rMod, 0, 0},
        new[] {gMod, gMod, gMod, 0, 0},
        new[] {bMod, bMod, bMod, 0, 0},
        new[] {0.0f, 0.0f, 0.0f, 1, 0},
        new[] {0.0f, 0.0f, 0.0f, 0, 1}
    });

    ImageAttributes attributes = new ImageAttributes();
    attributes.SetColorMatrix(colorMatrix);
    g.DrawImage(bmp,
                      new Rectangle(0, 0, bmp.Width, bmp.Height),
                      0, 0, bmp.Width, bmp.Height,
                      GraphicsUnit.Pixel,
                      attributes
                     );
    g.Dispose();
    return res;
}

Powyższy kod jest odpowiedzialny za przekształcenie obrazu do skali szarości. Zacznijmy analizę od końca. W linii 20 jest wywoływana metoda DrawImage(), która rysuje całą bitmapę bmp (parametr pierwszy i drugi), do bitmapy res (obiekt g jest utworzony na podstawie obiektu res) o podanych rozmiarach (parametry 3-6) używając obiektu attributes do modyfikacji kolorów (parametr 7).

Obiekt attributes zasililiśmy macierzą transformacji koloru, colorMatrix. Jest to bezpośrednia analogia do transformacji wektorów w przestrzeni za pomocą macierzy. Można to sobie wyobrazić jako pomnożenie każdego piksela przez macierz transformacji przez zapisaniem go do nowej bitmapy.

Kolor jest definiowany przez 4 atrybuty: składowe R, G, B oraz kanał Alfa (będę go oznaczał jako A). Każdy kolor można przedstawić jako wektor [R, G, B, A] (wektory powinny być przedstawiane kolumnowo, ale w produktach Microsoftu (m.in. DirectX) jest odwrotnie – wektory są wierszowe, więc mnożenie przez macierz wygląda tak: vOut = vIn * Matrix). Aby mieć możliwość skalowania składowych, „obracania” ich oraz translacji za pomocą jednej operacji mnożenia macierzy, należy rozszerzyć wektor koloru o pojedynczą jedynkę na końcu. Wektor koloru będzie więc wyglądał następująco: [R, G, B, A, 1].

Po przemnożeniu koloru c = [ c.R, c.G, c.B, c.A, 1] przez obiekt klasy ColorMatrix (dla ustalenia nazwijmy go CM) wynikowy kolor będzie miał następujące wartości składowych:

out.R = c.R * CM[0][0] + c.G * CM[1][0] + c.B * CM[2][0] + c.A * CM[3][0] + 1 * CM[4][0];
out.G = c.R * CM[0][1] + c.G * CM[1][1] + c.B * CM[2][1] + c.A * CM[3][1] + 1 * CM[4][1];
out.B = c.R * CM[0][2] + c.G * CM[1][2] + c.B * CM[2][2] + c.A * CM[3][2] + 1 * CM[4][2];
out.A = c.R * CM[0][3] + c.G * CM[1][3] + c.B * CM[2][3] + c.A * CM[3][3] + 1 * CM[4][3];

Macierz mnożników dla sepii będzie więc wyglądała następująco:

ColorMatrix colorMatrix = new ColorMatrix(new[]
{
    new[] {0.393f, 0.349f, 0.272f, 0.0f, 0.0f},
    new[] {0.769f, 0.686f, 0.534f, 0.0f, 0.0f},
    new[] {0.189f, 0.168f, 0.131f, 0.0f, 0.0f},
    new[] {  0.0f,   0.0f,   0.0f, 1.0f, 0.0f},
    new[] {  0.0f,   0.0f,   0.0f, 0.0f, 1.0f}
});

Jak najbardziej można też stworzyć wersję używając parametrów sepiaDepth oraz sepiaIntensity, ich wartości należy jednak sprowadzić do przedziału [0; 1].

const float sepiaDepth = 20.0f / 255.0f;
const float sepiaIntensity = 30.0f / 255.0f;
const float rMod = 0.30f;
const float gMod = 0.59f;
const float bMod = 0.11f;
ColorMatrix colorMatrix = new ColorMatrix(new[]
{
    new[] { rMod, rMod, rMod, 0.0f, 0.0f},
    new[] { gMod, gMod, gMod, 0.0f, 0.0f},
    new[] { bMod, bMod, bMod, 0.0f, 0.0f},
    new[] { 0.0f, 0.0f, 0.0f, 1.0f, 0.0f},
    new[] {sepiaDepth * 2.0f, sepiaDepth, -sepiaIntensity, 0.0f, 1.0f}
});

Zalety:

  • szybka!
  • modyfikacja tylko macierzy koloru

Wady:

  • początkowo użycie macierzy może się wydawać trudne (chociaż w rzeczywistości jest zupełnie inaczej)

Polecam eksperymenty z wartościami macierzy ColorMatrix – można uzyskać naprawdę ciekawe efekty.

Cała ta pisanina oraz implementacja byłaby bez sensu bez porównania czasów działania wszystkich trzech podejść. Test był w miarę prosty:

  • jako input: obraz „lena.jpg„, rozmiar 512 x 512 pikseli
  • każda z metod uruchamiana 30 razy na oryginalnym obrazie
  • w tabeli poniżej jest czas średni z 30 iteracji
  • parametry komputera: Core i7 (4 x 1.6 GHz), 4 GB RAM, nVidia GTX 260M

TestResults

Jak widać, w stosunku do podejścia naiwnego (GetPixel/SetPixel) metoda używająca macierzy transformacji koloru jest prawie 25 razy szybsza. Wniosek z tego jeden: nie należy wynajdywać koła na nowo – bo to, co chcemy uzyskać najprawdopodobniej już zostało zrobione. Wystarczy zgrabnie zastosować komponenty.

Dla zainteresowanych: projekt Visual Studio do pobrania (.rar, 144 kB)

Be Sociable, Share!
czoper opublikowano dnia 2010-4-17 Kategoria: Programowanie | Tagi:, , ,

Odpowiedzi: 4 Zostaw komentarz

  1. #4K @ 2010-4-29 13:25

    Fajnie byłoby porównać też wydajność w porównaniu z funkcjami z Windows API [poprzez InteropServices/DllImport] SetPixel, GetPixel w wersji na pisanej na szybko(tak apropo istnieją funkcje SetPixelV,GetPixelV które działają szybciej, ale kiedyś spotkałem się problemami w ich działaniem na Windows Vista). Wersja z dużą wydajnością powinna korzystać z GetDIBits.

  2. #3runge @ 2010-4-19 19:16

    Lena jest wieczna. Btw 0.11*B to ostra dyskryminacja

  3. #2Xion @ 2010-4-17 18:02

    @#1: Chwilkę dosłownie. Takie manipulacje na pikselach to pikuś 🙂

  4. #1Michniewicz @ 2010-4-17 17:10

    Ciekawe jak „długo” by to liczył prosty fragment shader 😉

Zostaw odpowiedź

(Ctrl + Enter)