Przetwarzanie obrazów

Przetwarzanie obrazów

Autor: Maciej Zelwak

Opublikowano: 1/2/2008, 12:00 AM

Liczba odsłon: 104099

Przetwarzanie obrazów jest jedną z najbardziej rozwijanych technik komputerowych. W tym artykule skupimy się wyłącznie na tzw. przekształceniach punktowych. Zaimplementujemy w programie cztery operacje jednopunktowe wykorzystywane w korekcji zdjęć. Co więcej, poznamy najlepszy i najszybszy sposób obsługi bitmap w C#.

Image Processing

Cechą charakterystyczną przekształceń punktowych jest to, iż kolejne punkty obrazu są modyfikowane niezależnie od otaczających go sąsiadów. Dzięki temu mogą być wykonywane szybko i sprawnie nawet na dużych zdjęciach. Operacje punktowe (zwane anamorficznymi) w najprostszej wersji wykorzystują gotowe tablice korekcji. Głównym ich zadaniem jest uwidocznienie pewnych cech obrazu, bez wprowadzania żadnych dodatkowych informacji i bez ingerencji w relacje geometryczne.

Operacje na bitmapach

Szybkość jest głównym kryterium w doborze sposobu przetwarzania obrazów. Jeden z prostszych sposobów polega na wykorzystaniu metod GetPixel i SetPixel. Klasa Bitmap dostarcza jednak bardziej efektywne metody: LockBits i odpowiednio UnlockBits. Służą one do zablokowania ustalonej części bitmapy w pamięci. Do tak utworzonej tablicy pikseli możemy bezpośrednio odwołać się i ją modyfikować. LockBits zwraca obiekt klasy BitmapData, która opisuje rozkład i pozycję danych w zablokowanej tablicy. Klasa ta zawiera wiele ważnych właściwości:

  • Scan0 – adres tablicy danych w pamięci
  • Stride – szerokość pojedynczego wiersza danych w bajtach
  • PixelFormat – format bitmapy
  • Width – szerokość aktualnego obrazka
  • Height – wysokość aktualnego obrazka

Własność Stride przechowuje długość jednego wiersza danych w bajtach. Musi to być wielokrotność czterech, więc w razie potrzeby dane obrazka rozszerzane są o dodatkowe bajty. Na przykład dla 24 bitowego obrazka o szerokości 18 pikseli wartość Stride wynosi 56. Użyteczne dane zajmują 18*3 = 54 bajty (mnożymy przez trzy, ponieważ każdy piksel składa się z trzech kolorów). Pozostałe dwa bajty poszerzają wiersz do 56 bajtów, czyli do 4*14 bajtów.

Rozłożenie kolorów wewnątrz wiersza danych zależy od formatu pliku. 24 bitowy obraz co 3 bajty przechowuje informacje o nowym pikselu. 32 bitowy obraz dodatkowo opisuje kanał alpha, stąd jeden piksel zajmuje 4 bajty. Należy zachować szczególną uwagę przy przetwarzaniu obrazów, które w jednym bajcie przechowują informacje o sąsiadujących punktach (m. in. 4 lub 1 bitowe obrazy).

Poszukiwanie właściwych bajtów w wierszu należy rozpocząć od sprawdzenia z jakim formatem mamy do czynienia. Spróbujmy uzyskać adres wybranego elementu w Format32BppArgb: Scan0+(y*stride)+(x*4) Podobnie dla Format24BppRgb napiszemy: Scan0+(y*Stride)+(x*3) Reprezentacją czarno-białych bitmap jest Format8BppIndexed. Formaty nieindeksowane wskazują na odpowiedni kolor, a indeksowane mają inną strukturą i wskazują na indeks w tablicy odcieni danego koloru.

Używanie wskaźników

Środowisko CLR na platformie .NET odpowiedzialne jest za technologię kodu nadzorowanego (managed code). Technologia ta jest odpowiedzialna za odpowiednie wykorzystanie zasobów systemowych przez program, optymalizację i automatyczne odzyskiwanie pamięci. Oznacza to, że nie wiadomo gdzie fizycznie w pamięci znajduje się którykolwiek z zadeklarowanych elementów. Stosowanie wskaźników jest więc utrudnione i aby móc ich używać trzeba wydzielić potencjalnie niebezpieczny kod (w związku z operacjami na adresach), żeby CLR pomijał go w operacjach nad zasobami systemu. Służy do tego słowo kluczowe unsafe, które może być użyte praktycznie wobec dowolnego fragmentu kodu: klasy, metody, bloku kodu.

unsafe public void funkcja()
{
// operacje na wskaźnikach
}

Środowisko zapobiega realokacji obiektów adresowanych przez wskaźniki. Obiekty te można również określić korzystając z polecenia fixed. Dodatkowo trzeba ustawić opcję kompilatora Allow unsafe code blocks na true.

Piszemy program

Tworzymy nowy projekt typu Window Aplication. Rozmiar formy powinien być niezmienny, dlatego właściwość FormBorderStyle ustawiamy na FixedSingle. Układamy potrzebne kontrolki, będą to: PictureBox, Button, radioButton i trackBar. Elementy rozkładamy dowolnie, ale tak aby zachować funkcjonalność programu. Potrzebnych jest pięć przycisków. Zmieniamy właściwość text każdego z nich, wpisujemy kolejno: Skala szarości, Negatyw, Zapisz, Otwórz i Cofnij. Pozostałe transformacje obsłużymy za pomocą dwóch kontrolek radioButton i suwaka (trackbar). Dla przejrzystości kodu wprowadzamy również nowe nazwy (bCofnij, bZapisz, bOtworz, … ). Sposób położenia i rozmiaru obrazka w kontrolce PictureBox zależy od własności SizeMode. Istnieje kilka możliwości, z których wybieramy StretchImage. Niezależnie od rozmiaru zdjęcia, zostanie ono dopasowane do wielkości kontrolki.

Utworzymy klasę TransformImage odpowiedzialną za transformacje zablokowanych w pamięci tablic. Zaczynamy od dodania potrzebnych przestrzeni nazw.

using System.Drawing;
using System.Drawing.Imaging;

Dodajemy do klasy dwie bitmapy. Na pierwszej z nich wykonywane będą przekształcenia, druga posłuży do cofnięcia wykonanych operacji i powrotu do oryginalnego zdjęcia. Obie zainicjujemy w konstruktorze.

private Bitmap obrazek;
private Bitmap obrazekKopia;

Do obsługi tych butmap poza klasą wykorzystamy właściwości. Mechanizm ten służy do zmiany wartości pól klasy. Pamiętajmy również, że definiując właściwości możemy zdefiniować rodzaj dostępu.

public Bitmap Obrazek
{
get
{
return obrazek;
}
set
{
obrazek = value;
}
}

public Bitmap ObrazekKopia
{
get
{
return obrazekKopia;
}
}

Zajmijmy się teraz konstruktorem. W czasie tworzenie nowego obiektu klasy, zainicjujemy pole bitmapą przekazaną przez argument.

public TransformImage(Bitmap img)
{
this.obrazekKopia = this.obrazek = img;
}

Wzbogaćmy program o typ wyliczeniowy trans, pozwalający określić jaką transformację wykonujemy na obrazie.

public enum trans
{
JASNOSC, BINARYZACJA, KSZAROSCI, NEGATYW
}

Możemy przystąpić do oprogramowania metody odpowiedzialnej za transformację zdjęć. Przyjmuje ona dwa argumenty, pierwszy określa wartość do której przekształcamy bitmapę, drugi to typ wyliczeniowy filtru. Efektem działania metody jest nowy obraz.

public Bitmap Transformacja(int prog, trans RodzTransformacji)
{

}

Dokonujemy rozróżnienia między dwoma najpopularniejszymi rodzajami. Jeżeli obraz ma inną strukturę przekształcamy go do formatu Format24bppRgb, zabezpieczając algorytm przed ewentualnymi błędami transformacji. Oczywiście użytkownik nie zauważy różnicy w jakości wyświetlanego obrazu. Wynik działania instrukcji wykorzystamy do utworzenia tymczasowej zmiennej typu PixelFormat.

if (obrazekKopia.PixelFormat != PixelFormat.Format8bppIndexed && obrazekKopia.PixelFormat != PixelFormat.Format24bppRgb)
{
Bitmap bmp = new Bitmap(obrazekKopia.Width, obrazekKopia.Height, PixelFormat.Format24bppRgb);
Graphics g = Graphics.FromImage(bmp);
g.DrawImage(obrazekKopia, 0, 0, obrazekKopia.Width, obrazekKopia.Height);
g.Dispose();
obrazekKopia = bmp;
obrazek = bmp;
}

PixelFormat formatObrazka = (obrazek.PixelFormat == PixelFormat.Format8bppIndexed) ? PixelFormat.Format8bppIndexed : PixelFormat.Format24bppRgb;

Blokujemy bitmapy w pamięci. Jako pierwszy argument metody LockBits podajemy obszar z współrzędnymi początku i końca obrazka. Następnie określamy sposób dostępu i format. Do tak utworzonej tablicy pikseli możemy się bezpośrednio odwołać i ją modyfikować.

BitmapData daneWyjsciowe = nowyObrazek.LockBits(new Rectangle(0, 0, obrazek.Width, obrazek.Height), ImageLockMode.ReadWrite, formatObrazka);

BitmapData daneWejsciowe = obrazek.LockBits(new Rectangle(0, 0, obrazek.Width, obrazek.Height), ImageLockMode.ReadOnly, formatObrazka);

Następnie poinformujemy kompilator, że dalsza część kodu będzie używać wskaźników. Dodajemy do programu blok unsafe.

unsafe
{

}

Zadeklarujemy wskaźniki na początek tablic danych w pamięci (Scan0). Zmienna Offset przechowuje rzeczywisty rozmiar pojedynczego wiersza danych.

byte* wskWyjsciowy = (byte*)daneWyjsciowe.Scan0;
byte* wskWejsciowy = (byte*)daneWejsciowe.Scan0;

int nOffset = daneWejsciowe.Stride - obrazek.Width * 3;

Wszystkie operacje filtracji umieścimy w instrukcji switch, w której wyrażenie sterujące określa używany algorytm. Operacja zmiany jasności polega na dodaniu lub odjęciu danej liczby do każdego elementu zablokowanej tablicy. Ponadto musimy sprawdzić, czy nowa wartość nie wykracza poza zakres 0-255.

switch (RodzTransformacji)
{
case trans.JASNOSC:
int nowaWartosc = 0;
for (int y = 0; y < obrazek.Height; y++)
{
for (int x = 0; x < obrazek.Width*3; x++)
{
nowaWartosc = (int)(wskWejsciowy[0] + prog);
if (nowaWartosc < 0) nowaWartosc = 0;
if (nowaWartosc > 255) nowaWartosc = 255;

wskWyjsciowy[0] = (byte)nowaWartosc;

wskWyjsciowy++; wskWejsciowy++;
}
wskWyjsciowy += nOffset; wskWejsciowy += nOffset;
}
break;
}

Dopisujemy kolejną sekcję instrukcji. Zmianę obrazka kolorowego w obrazek w kolorach szarości można m. in. przeprowadzić obliczając średnią arytmetyczną trzech kolorów w pikselu (RGB) i zastępując wartości tych kolory wynikiem obliczeń.

case trans.KSZAROSCI:

for (int y = 0; y < obrazekKopia.Height; y++)
{
for (int x = 0; x < obrazekKopia.Width; x++)
{
wskWyjsciowy[0] = wskWyjsciowy[1] = wskWyjsciowy[2] = (byte)((wskWejsciowy[0] + wskWejsciowy[1] + wskWejsciowy[2]) / 3);

wskWejsciowy += 3; wskWyjsciowy += 3;
}
wskWejsciowy += nOffset; wskWyjsciowy += nOffset;
}
break;

Przekształcenie obrazu w negatyw również jest prostą operacją. Odejmujemy wartości kolorów w kolejnych pikselach od górnej granicy zakresu, czyli 255.

case trans.NEGATYW:

for (int y = 0; y < obrazekKopia.Height; y++)
{
for (int x = 0; x < obrazekKopia.Width; x++)
{
wskWyjsciowy[0] = (byte)(255 - wskWejsciowy[0]);
wskWyjsciowy[1] = (byte)(255 - wskWejsciowy[1]);
wskWyjsciowy[2] = (byte)(255 - wskWejsciowy[2]);

wskWejsciowy += 3; wskWyjsciowy += 3;
}
wskWejsciowy += nOffset; wskWyjsciowy += nOffset;
}
break;

Dzięki binaryzacji możemy uzyskać bardzo ciekawy rezultat. W operacji tej możemy wyróżnić dwa kroki. W pierwszym przekształcamy każdy kolor piksela w odcień szarości. Następnie porównujemy wyniki z wcześniej założonym progiem. Jeśli wartość jest mniejsza to do kolejnych kolorów w pikselu wpisujemy 0 (kolor czarny), w przeciwnym wypadku 255 (kolor biały).

case trans.BINARYZACJA:

for (int y = 0; y < obrazekKopia.Height; y++)
{
for (int x = 0; x < obrazekKopia.Width; x++)
{
if ( (wskWejsciowy[0] + wskWejsciowy[1] + wskWejsciowy[2]) /3 < prog)
{
wskWyjsciowy[0] = wskWyjsciowy[1] = wskWyjsciowy[2] = 0;
}
else
{
wskWyjsciowy[0] = wskWyjsciowy[1] = wskWyjsciowy[2] = 255;
}
wskWejsciowy += 3; wskWyjsciowy += 3;
}
wskWejsciowy += nOffset; wskWyjsciowy += nOffset;
}
break;

Działanie metody kończymy zwolnieniem zasobów i zwróceniem przekształconego obrazka. Wykorzystujemy metodę UnlockBits i wcześniej zadeklarowane zmienne typu BitmapData.

obrazek.UnlockBits(daneWejsciowe);
nowyObrazek.UnlockBits(daneWyjsciowe);

return nowyObrazek;

Pozostało jeszcze oprogramować elementy interfejsu użytkownika. Wracamy do klasy MainForm i dodajemy na początku definicję klasy TransformImage.

TransformImage tI;

Wykorzystując obiekt klasy OpenFileDialog, wprowadzamy możliwość otwierania zdjęć. Definiujemy filtr tak, aby można było wybrać pliki graficzne. Za pomocą metody ShowDialog wyświetlamy okienko wyboru. Jeśli użytkownik wskaże właściwy plik, zostanie on wczytany do kontrolki PictureBox i utworzony nowy obiekt klasy TransformImage. Klikamy dwa razy przycisk Otwórz i dodajemy kod.

String filter = "JPEG files (*.jpg)|*.jpg| Bitmaps (*.bmp)|*.bmp";

OpenFileDialog dlg = new OpenFileDialog();
dlg.Multiselect = false;
if (filter.Length > 0) { dlg.Filter = filter; }

if (dlg.ShowDialog(this) != DialogResult.Cancel)
{
if (dlg.FileName != null)
{
pictureBox.Image = new Bitmap(dlg.FileName);
tI = new TransformImage((Bitmap)pictureBox.Image);
}
}

Postąpimy podobnie przy dodawania kodu do przycisku Zapisz. Skorygowane i przefiltrowane zdjęcie zapiszemy wykorzystując obiekt klasy SaveFileDialog. Metoda Save wymaga podania nazwy i formatu pliku.

SaveFileDialog dlg = new SaveFileDialog();
dlg.Filter = "JPEG files (*.jpg)|*.jpg";
if (dlg.ShowDialog() == DialogResult.OK)
{
System.Drawing.Imaging.ImageFormat format = System.Drawing.Imaging.ImageFormat.Jpeg;
System.Drawing.Image img = new Bitmap(pictureBox.Image);
img.Save(dlg.FileName, format);
}

Cofnięcie wszystkich operacji sprowadza się do wczytania kopii bitmapy, którą zainicjowaliśmy tworząc obiekt klasy TransformImage.

pictureBox.Image = tI.Obrazek = tI.ObrazekKopia;

Zmiana obrazka na odcienie szarości i negatyw przebiega podobnie. Transformujemy obrazek, przy czym wybór progu (pierwszego argumentu) jest nieistotny. Klikamy przycisk Skala szarości i dopisujemy następującą linię kodu.

tI.Obrazek = (Bitmap)pictureBox.Image;
pictureBox.Image = tI.Obrazek = tI.Transformacja(0, TransformImage.trans.KSZAROSCI);

W przypadku przycisku Negatyw zmieni się tylko wywołanie metody Transformacja.

tI.Obrazek = (Bitmap)pictureBox.Image;
pictureBox.Image = tI.Obrazek = tI.Transformacja(0, TransformImage.trans.NEGATYW);

Wizualizujemy efekty zachodzące po zmianie położenia suwaka. W tym celu, przechodzimy do widoku formy i dodajemy dla niego zdarzenie MouseUp. Nie modyfikujemy jednak zawartości zmiennej obrazek, ponieważ musimy korygować to same zdjęcie.

if (rJasnosc.Checked)
{
pictureBox.Image = tI.Transformacja(trackBar.Value, TransformImage.trans.JASNOSC);
}
if (rBinaryzacja.Checked)
{
pictureBox.Image = tI.Transformacja(trackBar.Value, TransformImage.trans.BINARYZACJA);
}

Kiedy uznamy, że otrzymaliśmy pożądany rezultat i chcielibyśmy wybrać następny filtr, musimy ustawić odpowiednie parametry suwaka. Jeśli odznaczyliśmy filtr, aktualizujemy zmienną obrazek.Wracamy do widoku formy i klikamy kontrolkę Binaryzacja.

if (rBinaryzacja.Checked)
{
trackBar.Minimum = 0;
trackBar.Maximum = 255;
trackBar.Value = 0;
}
else
{
tI.Obrazek = (Bitmap)pictureBox.Image;
}

Jednakowo rozwiniemy zdarzenie CheckedChanged dla kontrolki Jasność.

if (rJasnosc.Checked)
{
trackBar.Minimum = -200;
trackBar.Maximum = 200;
trackBar.Value = 0;
}
else
{
tI.Obrazek = (Bitmap)pictureBox.Image;
}

Jeśli teraz spróbujemy skompilować program i wygeneruje on błąd Unsafe code may only appear if compiling with /unsafe , oznacza to że nie zmieniliśmy jeszcze ustawień zezwalających na działanie nie nadzorowanego kodu. Klikamy prawym przyciskiem nazwę projektu w oknie Solution Explorer. Wybieramy opcję Properties. W otwartym oknie przechodzimy do zakładki Build i zaznaczamy opcję Allow unsafe code.

Podsumowanie

Zakończyliśmy pisanie programu. Nauczyliśmy się szybkich operacji na bitmapach z wykorzystaniem wskaźników. Wdrożyliśmy podstawowe przekształcenia punktowe, które wykorzystuje się w zaawansowanych programach graficznych. Zdobyte informacje możemy potraktować jako wstęp do ciągle rozwijanych dziedzin przetwarzania obrazów, m. in. filtracji kontekstowej i przekształceń geometrycznych.

Jak wykorzystać Copilot w codziennej pracy? Kurs w przedsprzedaży
Jak wykorzystać Copilot w codziennej pracy? Kurs w przedsprzedaży

Wydarzenia