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:
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;
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.
- 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;
- 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 nazwysepiaDepthorazsepiaIntensity, z odpowiednimi wartościami 20 i 30 dającymi najlepsze efekty. Warto zauważyć, żesepiaDepthustawione 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;
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.HeightoperacjiGetPixel()/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
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)





Odpowiedzi: 4 Zostaw komentarz
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.
Lena jest wieczna. Btw 0.11*B to ostra dyskryminacja
@#1: Chwilkę dosłownie. Takie manipulacje na pikselach to pikuś
Ciekawe jak „długo” by to liczył prosty fragment shader