Rozdział 15.
Techniki multimedialne
Damon Chandler
Siu-Fan Wu
Rob Allen
Interfejs GDI
Wyświetlanie grafiki rastrowej
Przetwarzanie obrazu
Odtwarzanie zapisów audio, wideo i płyt CD
Przekaz multimedialny jest coraz powszechniejszym elementem nowoczesnych aplikacji. Wykorzystanie multimediów może sprowadzać się do zwykłego „ubarwienia” interfejsu programu, jednak w wielu przypadkach stanowi podstawowe narzędzie komunikacji z użytkownikiem. Obszerny wachlarz aplikacji przeznaczonych do tworzenia, odtwarzania i zarządzania zapisami multimedialnymi poszerza się z dnia na --> dzień[Author:ts] .
W rozdziale tym omówimy kilka wybranych technik multimedialnych stosowanych w aplikacjach. Zakres realizowanych operacji zależy li tylko od zapotrzebowania użytkowników programu - jak się wkrótce przekonamy, system operacyjny dostarcza standardowe mechanizmy, umożliwiające aplikacji wymianę danych ze sterownikami odpowiednich urządzeń odtwarzających.
Wyświetlanie „tradycyjnej” grafiki (nieruchomych obrazów dwuwymiarowych) jest domeną podsystemu GDI, czyli systemowego interfejsu urządzeń graficznych (Graphics Device Interface). Funkcje GDI tworzą warstwę pośrednią, położoną pomiędzy wywołującą aplikacją a sterownikami urządzeń graficznych. Interfejs GDI oraz udostępniające go klasy VCL (TCanvas, TBrush, TPen i TFont) omówimy w pierwszej części rozdziału, w której przedstawimy sposób wykorzystania klas VCL do wyświetlania obiektów graficznych na ekranie --> monitora[Author:ts] . W następnej kolejności zajmiemy się klasami TBitmap i TJPEGImage, obsługującymi grafikę zapisaną w formacie zwykłych map bitowych oraz skompresowanym formacie JPEG. Wspomnimy także o innych formatach zapisu obrazów i metodach ich obsługi w programach bazujących na bibliotece VCL.
W drugiej części rozdziału przedstawimy wprowadzenie do przetwarzania danych obrazowych i zaprezentujemy kilka podstawowych metod ich obróbki, jak: odczytywanie i modyfikację pojedynczych punktów, dyskryminację, konwersję barw, korygowanie kontrastu i wyrównywanie histogramów, transformacje związane z powiększaniem fragmentów obrazu oraz filtry realizujące wygładzanie i detekcję krawędzi.
Odczytywanie i zapisywanie danych dźwiękowych i wizyjnych obsługuje w systemie Windows interfejs sterowania mediami (Media Control Interface, MCI). Udostępnia on aplikacjom zestaw funkcji, pozwalających pośrednio komunikować się z urządzeniami odtwarzającymi i zapisującymi obraz oraz dźwięk. Oprócz „ogólnego” interfejsu MCI Windows oferuje też inne, bardziej wyspecjalizowane mechanizmy związane z konkretnymi formatami zapisu danych multimedialnych. W ramach tego rozdziału zaprezentujemy wykorzystanie funkcji MCI do odtwarzania dźwięku i obrazu oraz użycie interfejsów systemowych do odtwarzania spróbkowanych danych dźwiękowych zapisanych w formacie WAV (Waveform Audio).
Interfejs GDI
Większość wyświetlanych na ekranie elementów interfejsu użytkownika tworzona jest w C++Builderze za pomocą formularzy i komponentów. Czasami okazuje się jednak konieczne wyświetlenie rysunku lub obrazu (np. mapy bitowej) - w takich sytuacjach do głosu dochodzą funkcje systemowego interfejsu urządzeń graficznych, w skrócie GDI. Podsystem GDI, będący jednym z filarów systemu Windows, zawarty jest w bibliotece dynamicznej GDI32. --> DLL[Author:ts] , zawierającej setki funkcji (w testowanej przez autorów wersji pochodzącej z systemu Windows NT zaimplementowano 401 wywołań). Wszelkie operacje rysowania czegokolwiek na ekranie (w tym oczywiście systemowe mechanizmy graficzne) wykorzystują funkcje GDI.
Podstawową zaletą zamknięcia obsługi grafiki w podsystemie GDI jest uniezależnienie operacji graficznych od fizycznych urządzeń wyjściowych. Wywołując funkcje GDI, nie trzeba zastanawiać się nad szczegółami programowania kart graficznych i drukarek, nie trzeba też martwić się o ewentualną niezgodność z przyszłymi modelami tych urządzeń. Innymi słowy, GDI zapewnia abstrakcyjną reprezentację urządzeń wyjściowych, izolując je od aplikacji.
Omówienie wszystkich funkcji GDI zdecydowanie przekracza skromne ramy tego rozdziału; zainteresowanych odsyłamy do dokumentacji biblioteki VCL (najlepiej rozpocząć od opisu klasy TCanvas) oraz elektronicznej dokumentacji Windows SDK.
Interfejs programowy Windows i konteksty urządzeń
Podobnie jak w przypadku innych funkcji udostępnianych przez interfejs programowy systemu Windows, komunikacja z obiektami GDI wykorzystuje specyficzny rodzaj uchwytu, zwany kontekstem urządzenia (ang. device context, DC). Udostępnia go systemowa funkcja GetDC():
HDC hDC = GetDC(hWindow);
Uzyskany w ten sposób kontekst urządzenia jest związany z danym oknem, co oznacza, że rysowanie poza obszarem okna jest niemożliwe (aby uzyskać dostęp do dowolnego miejsca pulpitu, należy wywołać funkcję GetDC() z parametrem hWindow równym NULL). Jak można się domyślać, uzyskana w ten sposób wartość kontekstu jest obowiązkowym parametrem każdego wywołania funkcji GDI. Po zakończeniu rysowania należy zwolnić kontekst urządzenia, do czego służy funkcja ReleaseDC():
ReleaseDC( --> hDC[Author:ts] );
Kontekst urządzenia a VCL, czyli klasa TCanvas
Jak wiadomo, programując w systemie C++Builder, eliminuje się wywołania „czystych” funkcji API na korzyść użycia komponentów, co pozwala skrócić cykl rozwojowy oprogramowania. Rysowanie obiektów graficznych i manipulowanie nimi umożliwia klasa VCL TCanvas, „opakowująca” funkcje GDI i dostępna jako jedna z właściwości klas TForm i TPrinter, a także większości komponentów reprezentujących elementy interfejsu użytkownika. Odwołanie do właściwości Canvas jest bardzo proste - oto przykład wykreślenia na formularzu prostokąta:
Canvas->Rectangle(10, 10, 100, 100);
Co dzieje się w środku?
Klasa TCanvas jest obiektową reprezentacją pewnego mechanizmu udostępnianego przez system operacyjny. Jej największą zaletą jest pełna automatyzacja zarządzania zasobami GDI, co w przypadku „ręcznego” wywoływania funkcji API jest zadaniem dość skomplikowanym (i podatnym na błędy - przyp. tłum.). Klasa TCanvas pozwala także na udostępnianie parametrów „stowarzyszonego” kontekstu urządzenia poprzez pojedynczą właściwość odpowiedniego komponentu, co ułatwia programowanie i poprawia czytelność kodu.
Elementy klasy TCanvas
Klasa TCanvas udostępnia kilka właściwości, o których należy tu wspomnieć. Są to:
TPen Pen - obiekt reprezentujący bieżące pióro (służące do kreślenia);
TBrush Brush - obiekt reprezentujący bieżący pędzel (służący do wypełniania konturów);
TFont Font - obiekt reprezentujący bieżącą czcionkę;
TPoint PenPos - obiekt reprezentujący bieżące położenie pióra.
Podkreślmy tutaj, że do rysowania obiektów na płótnie używane są zawsze bieżące (wybrane, ang. selected) pióro, pędzel, czcionka i pozycja pióra. Oznacza to, że w razie potrzeby należy zmienić ich ustawienia przed rozpoczęciem rysowania.
Czego brakuje?
Definicje TCanvas i związanych z nią klas zawarto w pliku graphics.pas. Jakkolwiek obszerne, nie obejmują one jednak wszystkich możliwości interfejsu --> GDI [Author:ts] - projektanci firmy Borland zdecydowali się na zaimplementowanie tylko najczęściej używanych funkcji. Również niektóre klasy pomocnicze, jak np. TPoint i TRect, nie dodają wiele do reprezentowanych przez siebie obiektów API i mogłyby być nieco bardziej uniwersalne. Nie oznacza to bynajmniej, że programista stoi na straconej pozycji - wszak zawsze można odwołać się bezpośrednio do systemu. Klasa TCanvas udostępnia właściwość Handle, będącą niczym innym, jak tylko uchwytem kontekstu urządzenia wykorzystywanym w wywołaniach GDI. Tak więc alternatywna metoda wykreślenia prostokąta (zobacz wyżej) z użyciem funkcji GDI Rectangle() miałaby postać:
Rectangle(Canvas->Handle, 10, 10, 100, 100);
Uwaga
Trzeba uczciwie przyznać, że niektóre z VCL-owych odpowiedników obiektów GDI mogłyby nieść w sobie znacznie więcej „wartości dodanej”, aniżeli ma to miejsce obecnie. Przykładami mogą tu być klasy TRect, TPoint i TSize, tworzące zaledwie cienką otoczkę dla odpowiednich struktur GDI. Klasa TRect, reprezentująca prostokąt, zawiera np. tylko cztery użyteczne metody - operatory == i != oraz funkcje Width() i Height() - chociaż wystarczyłoby nieco inwencji, aby uczynić ją naprawdę przydatnym narzędziem. Można by np. pomyśleć o funkcji sprawdzającej, czy dany punkt znajduje się wewnątrz prostokąta, metodach jego przesuwania, skalowania itd. Co prawda można się przyłożyć i zaprogramować odpowiednie operacje samemu, jednak możliwość użycia np. konstrukcji
--> if[Author:ts] (myRect->Contains(myPoint){ // wykonaj operację... }
byłaby bardzo pożądana. Można mieć nadzieję, że projektanci firmy Borland postarają się wkrótce wyeliminować te niedociągnięcia.
Użycie klasy TCanvas
Procedury rysowania na płótnie umieszcza się najczęściej w funkcji obsługi zdarzenia OnPaint() klasy TForm, co pozwala na bieżąco aktualizować zawartość formularza. Powszechną praktyką jest również rysowanie „na drugim planie”, z wykorzystaniem oddzielnego, ukrytego obiektu klasy TCanvas, będącego kopią właściwego płótna. Po zakończeniu rysowania zawartość takiego obiektu kopiuje się w funkcji OnPaint() do obiektu Canvas, reprezentującego formularz ekranowy, co pozwala przyspieszyć wyświetlanie.
Jako przykład rozważmy wyświetlanie tarczy zegara analogowego. Jeśli zrezygnujemy z sekundnika, będzie można aktualizować tarczę co minutę. Ponieważ jednak wykreślenie obu wskazówek wymaga obliczenia kątów, czyli zabiera trochę czasu, nie ma sensu wykonywać go w funkcji OnPaint(), która wywoływana jest każdorazowo w chwili przesunięcia okna, zmiany jego rozmiarów, zasłonięcia itd. Użycie „ukrytego” obiektu klasy TCanvas pozwala w takiej sytuacji zoptymalizować wyświetlanie grafiki. Oto stosowny kod:
void __fastcall TForm1::FormPaint(TObject *Sender)
{
// Skopiuj obraz z mapy drugoplanowej na ekran.
Canvas->CopyRect(ClientRect, HiddenImage->Canvas,
HiddenImage->ClientRect);
}
Więcej na ten temat powiemy w dalszej części rozdziału.
Kreślenie linii
Rysowanie odcinków i krzywych z użyciem klasy TCanvas jest proste - wystarczy zdefiniować ustawienia pióra i wywołać odpowiednią funkcję. Zestaw funkcji służących do kreślenia linii opisano szczegółowo w dokumentacji C++Buildera; w charakterze przykładu przedstawimy dwie instrukcje kreślące odcinek:
Canvas->PenPos = TPoint(1, 1);
Canvas->LineTo(9, 1);
Warto zauważyć, że linie kreślone przez wszystkie funkcje z grupy XxxTo() rozpoczynają się zawsze od bieżącej pozycji pióra i nie zawierają punktu końcowego. Widać to na rysunku 15.1, przedstawiającym wynik wykonania powyższego przykładu - odcinek rozpoczyna się w punkcie (1, 1), ale kończy w punkcie o współrzędnych (9, 1). To samo dotyczy innych figur, np. prostokątów (funkcja Rectangle(1, 1, 10, 10) utworzy prostokąt nie obejmujący punktu o współrzędnych (10, 10)) czy elips (opisywanych poprzez podanie współrzędnych prostokąta ograniczającego).
Rysunek 15.1. Odcinek kreślony przez funkcję LineTo(1, 9) nie zawiera ostatniego piksela
Kreślenie figur geometrycznych
Do kreślenia figur geometrycznych i wypełniania ich konturów używa się dwóch narzędzi - pióra oraz pędzla. Pierwsze z nich wykreśla sam kontur, drugie zaś pozwala wypełnić jego wnętrze. Oto prosty przykład wykreślenia prostokąta:
TRect myRect(10, 10, 100, 00);
Canvas-> --> Rectangle[Author:ts] (myRect)
W przypadku kreślenia elipsy współrzędne podaje się poprzez zdefiniowanie opisanego na niej prostokąta (tzw. prostokąta ograniczającego, ang. bounding rectangle). Funkcja Ellipse() kreśli elipsę „stykającą się” z bokami takiego prostokąta.
Wyprowadzanie tekstu
Do wyprowadzania tekstu można użyć kilku metod. Najprostszą jest wywołanie funkcji TextOut(), rysującej zadany tekst począwszy od punktu o określonych współrzędnych (określa on położenie lewego górnego wierzchołka prostokąta ograniczającego tekst - przyp. tłum.). Po zakończeniu rysowania pióro przenoszone jest do punktu położonego w prawym górnym rogu prostokąta ograniczającego, co ułatwia wyprowadzanie kolejnych napisów. Alternatywą jest użycie funkcji TextRect(), wyprowadzającej tekst zamknięty w zadanym prostokącie (i obcinającej nie mieszczące się w nim fragmenty).
Rozmiar wyprowadzanego tekstu zależy od kroju i wielkości użytej czcionki. Dla czcionek o stałej szerokości (ang. fixed-pitch), jak np. Courier, każda litera zajmuje dokładnie tyle samo miejsca w poziomie (czyli „m” jest tak samo „szerokie”, jak „i”). W przypadku czcionek proporcjonalnych (ang. variable-pitch) szerokość poszczególnych znaków jest zmienna. Do ustalenia całkowitej szerokości łańcucha można wykorzystać metodę TextExtent() klasy TCanvas. Zwraca ona obiekt klasy TSize, zawierający szerokość i wysokość napisu reprezentującego na płótnie zadany tekst, co pozwala na odpowiednie dobranie współrzędnych.
We wspomnianym już przykładzie zegara analogowego tekst wyprowadzany jest za pomocą instrukcji
AnsiString text = "Prawy przycisk -> menu";
TSize textSize = HiddenImage->Canvas->TextExtent(text);
int x = (HiddenImage->Width - textSize.cx) / 2;
int y = HiddenImage->Height - textSize.cy - 2;
HiddenImage->Canvas->TextOut(x, y, text);
Powoduje to wyświetlenie napisu pośrodku dolnej części płótna obiektu HiddenImage.
Zmiana ustawień rysowania
Rysunek zmontowany z kresek i tekstu jest raczej mało atrakcyjny, toteż warto powiedzieć nieco na temat możliwości zmiany parametrów rysowania. Najbardziej oczywiste wydają się ustawienia grubości i koloru linii oraz parametry czcionki używanej do wyprowadzania tekstu. C++Builder dostarcza cały zestaw klas pomocniczych, pozwalających „nadać szlif” tworzonym rysunkom.
Uwaga
Zmiany parametrów kreślenia (np. koloru linii) należy dokonać przed wywołaniem funkcji, której zmiana ta ma dotyczyć.
Klasa TColor
Manipulowanie kolorem obiektów graficznych umożliwia klasa VCL TColor, będąca odpowiednikiem znanego z Windows API typu COLORREF. Ten ostatni oznacza wartość 32-bitową, w której poszczególne bajty odpowiadają barwom składowym (najbardziej znaczący bajt jest równy zeru - przyp. tłum.). W modelu RGB, z którym mamy tu do czynienia, każdy kolor reprezentowany jest przez trzy barwy składowe: czerwoną (R), zieloną (G) i niebieską (B). Czystej czerwieni odpowiada np. kombinacja składowych RGB równa (255, 0, 0), zaś bieli - trójka wartości (255, 255, 255). Model RGB wykorzystywany jest m.in. do reprezentacji barw w monitorach komputerowych: jeśli przyjrzeć się monitorowi przez szkło powiększające, można zauważyć, że poszczególne piksele składają się z punktów (lub pionowych kresek) o wspomnianych wyżej barwach składowych. Przy oglądaniu z większej odległości poszczególne punkty zlewają się w całość.
Jak nietrudno policzyć, liczba możliwych kombinacji wszystkich składowych wynosi w opisywanym systemie 256×256×256 = 16 777 --> 216[Author:ts] , czyli mniej więcej 16 milionów wartości. Sprzętowe ograniczenia niektórych kart graficznych powodują, że w określonych rozdzielczościach liczba wyświetlanych kolorów może być mniejsza i wynosić np. 16, 256 lub 65 536. Liczba dostępnych kolorów bywa też określana mianem głębi koloru (ang. color depth). Sposób reprezentacji kolorów przy różnych wartościach głębi koloru przedstawiono w tabeli 15.1.
Tabela 15.1. Reprezentacja barw dla różnych wartości głębi koloru
Liczba kolorów |
Objaśnienie |
16 |
Kolory dostępne w trybie 16-kolorowym są sztywno ustalone (zobacz opis klasy TColor) |
256 |
Sterownik graficzny wykorzystuje paletę barw. Jednocześnie można wyświetlić co najwyżej 256 kolorów, jednak zawartość palety może być zmieniana |
65 536 |
Wartości składowych czerwonej, zielonej i niebieskiej są zapisane w 16-bitowym słowie (oddzielnie dla każdego piksela); ze względu na niewystarczającą liczbę bitów (16 zamiast 24) niektóre kolory mogą być reprezentowane niedokładnie |
16 milionów |
Grafika wyświetlana jest z użyciem wszystkich możliwych barw |
Zarządzanie paletami jest zagadnieniem dość obszernym i nie będziemy go tu rozwijać; zainteresowanym Czytelnikom polecamy sięgnięcie do literatury poświęconej programowaniu w Windows, jak np. Programming Windows Charlesa Petzolda (Microsoft Press). Określenia liczby dostępnych w danej konfiguracji kolorów można dokonać za pomocą funkcji Windows API GetDeviceCaps(), zwracającej m.in. liczbę tzw. płatów (ang. color plane) oraz bitów opisujących kolor pojedynczego piksela. Sposób ustalenia tych wartości przedstawiono poniżej.
int BitsPerPixel = GetDeviceCaps(Canvas->Handle, BITSPIXEL);
int NumberOfPlanes = GetDeviceCaps(Canvas->Handle, PLANES);
int NumberOfColors = 1<<(NumberOfPlanes * BitsPerPixel);
Klasa TPen
Wygląd linii używanej do wykreślania obiektów (np. kół czy prostokątów) określany jest przez bieżące ustawienia pióra (ang. pen) - kolor, grubość i styl linii. Ten ostatni parametr determinuje wygląd linii (ciągła, przerywana, kropkowa itd.). Warto pamiętać, że użycie szerokości większej niż 1 automatycznie wymusza styl linii ciągłej, toteż aby narysować grubą linię kropkową, trzeba wykreślić obok siebie kilka takich linii o grubości 1.
Klasa TPen pozwala także określić tryb kreślenia, tj. sposób, w jaki wartości pikseli kreślonej linii będą składane z pikselami tła. Na uwagę zasługuje tu stała pmNotXor, nakazująca złożenie typu „nie-albo” (zanegowana alternatywa wyłączająca XOR). Ułatwia to kreślenie linii „tymczasowych”, które mają zostać za chwilę usunięte - linię taką wystarczy wykreślić ponownie, by jej piksele zostały w wyniku złożenia usunięte bez wpływu na zawartość tła. Technikę tę można wykorzystać np. do rysowania prostokąta wyznaczającego obszar powiększenia (przesuwanie prostokąta myszą wymaga jego szybkiego przerysowywania bez wpływu na zawartość rysunku znajdującą się „pod spodem”).
Klasa TBrush
Bieżące ustawienia pędzla (ang. brush) określają sposób wypełniania konturów podczas rysowania. Dwie podstawowe właściwości klasy TBrush to kolor (Color) i styl wypełnienia (Style). Ten ostatni może przyjmować wartości bsSolid (wypełnienie jednolite) lub: bsCross, bsClear, bsDiagCross, bsBDiagonal, bsHorizontal, bsFDiagonal i bsVertical. Ich szczegółowy opis można znaleźć w plikach pomocy, zaś efekty użycia zaprezentowano w przykładowym programie wyświetlającym zegar analogowy.
Klasa TFont
Funkcje służące do wyprowadzania napisów posługują się definicją bieżącej czcionki, zawierającą, jak można się spodziewać, wszystkie podstawowe parametry (nazwa kroju, kolor, wysokość, waga itd.). Rozmiar czcionki można ustalić na dwa sposoby - podając jej wysokość w pikselach lub wielkość w punktach (tzw. rozmiar fizyczny i logiczny - przyp. tłum.), przy czym obie wartości są ze sobą jednoznacznie powiązane. Ustalanie rozmiaru czcionki w punktach jest metodą preferowaną przez większość użytkowników.
Przykład - zegar analogowy
Praktyczną demonstrację opisanych wyżej zagadnień zawiera przykładowy program wyświetlający zegar analogowy. Odpowiedni plik projektu, clock.bpr, znajduje się w katalogu GDI na dołączonej do książki płycie CD. Kod przykładu oparto na starym (opracowanym jeszcze w czasach biblioteki OWL) programie aclock. Efektem uruchomienia programu powinno być wyświetlenie zegara wskazówkowego podobnego do pokazanego na rysunku 15.2.
Rysunek 15.2. Zegar analogowy
Uwaga
Zadaniem programu clock jest demonstracja użycia klasy TCanvas do kreślenia obiektów graficznych. Kluczowymi jego funkcjami są metody formularza głównego: InitialiseImage(), DrawClockToHiddenImage() i FormPaint(). Dla uproszczenia pominięto obsługę błędów, a kod sam w sobie nie jest wzorcowym przykładem dobrego programowania, ale… to w końcu tylko ćwiczenia.
Wszystkie operacje rysowania obiektów wykonywane są na „drugoplanowym” obiekcie typu TImage, którego zawartość jest kopiowana na płótno formularza głównego w funkcji OnPaint() tego ostatniego. W przykładzie zademonstrowano wykorzystanie właściwości Font, Brush i Pen obiektu Canvas, a także metody kreślenia linii (wskazówki) i elips (tarcza zegara). Menu kontekstowe umożliwia zmianę stylu pędzla, czyli sposobu wypełnienia --> tarczy[Author:ts] .
Wyświetlanie grafiki rastrowej
Podsystem GDI zapewnia obsługę map bitowych w formacie BMP, udostępniając liczne funkcje przeznaczone do manipulowania ich zawartością. Wyświetlenie zawartości pliku graficznego w formacie innym niż BMP wymaga skonstruowania dodatkowych procedur, tłumaczących zawartość pliku na rodzimą mapę bitową Windows, toteż programiści zainteresowani wykorzystaniem innych formatów muszą sami stworzyć odpowiednie narzędzia (lub skorzystać z oprogramowania firm trzecich).
Omawianie budowy poszczególnych formatów rastrowych dalece wykracza poza ramy tego rozdziału i nie będziemy podejmować się tego zadania. Przedstawimy jedynie ogólne informacje o najpopularniejszych rozwiązaniach. Na początek zajmiemy się mapami bitowymi w formacie BMP, obsługującymi je funkcjami GDI oraz dostępną w bibliotece VCL klasą TBitmap. Pozostałe formaty omówimy w dalszych punktach, podając jednocześnie źródła dodatkowych informacji na ich temat. Wspomnimy także o dostarczanych przez firmy trzecie bibliotekach oprogramowania, umożliwiających konwersję innych formatów rastrowych na format BMP i odwrotnie.
Mapy bitowe w Windows
W ramach podsystemu GDI zdefiniowano dwa rodzaje map bitowych: zależne od urządzenia (Device Dependent Bitmap, DDB) oraz niezależne od urządzenia (Device Independent Bitmap, DIB). Mapa bitowa DDB definiowana jest za pomocą struktury typu BITMAP, a sposób jej wykorzystania nie różni się szczególnie od innych obiektów graficznych - po wybraniu mapy DDB w kontekście urządzenia wywołujemy odpowiednie funkcje GDI, rysujące jej zawartość. Niestety, podsystem GDI nie pozwala bezpośrednio odwoływać się do poszczególnych punktów (bitów) mapy DDB. Mapa bitowa typu DIB (niezależna od urządzenia) opisywana jest nieco inaczej - jej definicja składa się ze struktury typu BITMAPINFO oraz tablicy pikseli, co pozwala bezpośrednio manipulować jej zawartością. Ceną za to jest pogorszenie wydajności, bowiem mapy typu DIB muszą być w chwili wyświetlania każdorazowo przekształcane do formatu zgodnego z urządzeniem wyjściowym.
Aby wyeliminować problemy wynikające z istnienia dwóch różnych rodzajów map bitowych, projektanci systemu Windows opracowali rozwiązanie hybrydowe, łączące ich cechy. Mowa tu o strukturze typu DIBSECTION, zdefiniowanej następująco:
typedef struct tagDIBSECTION {
BITMAP dsBm;
BITMAPINFOHEADER dsBmih;
DWORD dsBitfields[3];
HANDLE dshSection;
DWORD dsOffset;
} DIBSECTION;
W odróżnieniu od „prawdziwej” mapy bitowej typu DDB, struktura ta umożliwia bezpośredni dostęp do poszczególnych pikseli; możliwe jest także wybranie jej w wirtualnym (pamięciowym) kontekście urządzenia, co z kolei odróżnia ją od mapy typu DIB. Obie te cechy nabierają znaczenia w zastosowaniach wymagających zarówno szybkiego wyświetlania, jak i dostępu do pojedynczych punktów. Struktura typu DIBSECTION jest podstawą definicji klasy TBitmap.
Klasa TBitmap
Zdefiniowana w bibliotece VCL klasa TBitmap jest „otoczką” dla rodzimych map bitowych Windows. Wywodzi się ona z abstrakcyjnej klasy TGraphic i umożliwia „inteligentny” wybór pomiędzy reprezentacją w postaci mapy bitowej typu DDB lub DIB. Punktem centralnym tego mechanizmu jest wewnętrzna funkcja biblioteki VCL CopyBitmap(), tworząca, w zależności od zapotrzebowania, mapę bitową DDB (wywołaniem funkcji GDI CreateBitmap() dla map monochromatycznych lub CreateCompatibleBitmap() dla kolorowych) lub hybrydową mapę DIB (wywołaniem funkcji CreateDIBSection()). Szczegółowe omawianie funkcji CopyBitmap() wykracza poza ramy rozdziału, skupimy się zatem na metodach i właściwościach klasy TBitmap, umożliwiających obsługę map bitowych w formatach innych niż BMP.
Klasa TBitmap intensywnie wykorzystuje dwie inne klasy - TBitmapCanvas i TBitmapImage. Pierwsza z nich dziedziczy z TCanvas, uzupełniając ją o obsługę wirtualnych kontekstów urządzeń GDI. Druga, będąca pochodną klasy TSharedImage, obsługuje kontrolę zasobów i zwalnianie struktur danych, obsługujących mapy DDB i DIB. To ostatnie zadanie realizowane jest za pomocą funkcji GDI DeleteObject().
Właściwe rysowanie map bitowych obsługują metody: Draw(), StretchDraw() i CopyRect(), dziedziczone przez TBitmapCanvas od jej klasy bazowej. Funkcja TBitmapCanvas::CreateHandle() umożliwia wybranie mapy (i w razie potrzeby także jej palety) w odpowiednim wirtualnym kontekście urządzenia. Pozwala to klasie TCanvas posługiwać się w metodach Draw(), StretchDraw() i CopyRect()bezpośrednio funkcją GDI --> StretchBlt[Author:ts] ().
Poza opisanymi wyżej funkcjami rysującymi, pochodzącymi z klasy TCanvas, TBitmap udostępnia także właściwość ScanLine. Wykorzystuje ona wewnętrzną funkcję TBitmap::GetScanLine(), zwracającą wskaźnik do obszaru pamięci, zawierającego żądaną linię pikseli. Ułatwia to znacząco operacje konwersji formatów rastrowych, wymagające bezpośredniego dostępu do zawartości mapy bitowej.
Mapy bitowe w formacie JPEG
Skrót JPEG (wymawiany „jotpeg” lub „dżejpeg”) nie oznacza de facto formatu graficznego, a algorytm kompresji obrazów rastrowych opracowany przez zespół roboczy ekspertów ds. fotografii (Joint Photographic Experts Group, JPEG). Algorytm JPEG należy do grupy metod kompresji stratnej, co oznacza, że część informacji zawartej w oryginalnym obrazie jest w trakcie obróbki tracona. Mimo iż odtworzony po kompresji obraz nie jest identyczny z oryginałem, specyfika percepcji wzrokowej powoduje, że w większości przypadków odczuwalna utrata wierności jest stosunkowo niewielka. Ponadto współczynnik kompresji (a co za tym idzie stopień utraty jakości) można dobierać stosownie do potrzeb.
Proces kompresji JPEG obejmuje trzy etapy: redukcję nadmiarowości pikseli z wykorzystaniem tzw. dyskretnej transformaty kosinusowej (DCT), kwantowanie uzyskanych danych (współczynników DCT) i eliminację nadmiarowości wyników. Utrata informacji następuje w drugim etapie algorytmu (kwantowanie), wykorzystującym zwykle właściwości (a ściślej - ułomności) ludzkiego wzroku (aczkolwiek specyfikacja JPEG nie precyzuje dokładnie sposobu przeprowadzania kwantowania). Szczegółowe informacje na temat algorytmu kompresji JPEG można znaleźć pod adresem http://www.jpeg.org.
Obsługę map bitowych w formacie JPEG realizuje zdefiniowana w bibliotece VCL klasa TJPEGImage. Procedury kompresji i dekompresji bazują na bibliotece opracowanej przez zespół Independent JPEG Group (IJG). Podobnie jak TBitmap, klasa TJPEGImage dziedziczy z klasy TGraphic.
Zawarte w klasie TJPEGImage metody TPersistent::Assign() i AssignTo() umożliwiają łatwą konwersję pomiędzy obiektami typu TJPEGImage i TBitmap. Warto też zauważyć, że wewnętrzna reprezentacja obrazu w klasie TJPEGImage wykorzystuje „zwykłą” mapę bitową Windows, co pozwala na wykorzystanie funkcji GDI do rysowania zawartości obrazu. Skompresowane dane JPEG reprezentowane są przez obiekt klasy TJPEGData.
Klasa TJPEGImage posiada szereg właściwości, umożliwiających sterowanie procesem kompresji i dekompresji danych. Właściwość CompressionQuality pozwala na określenie współczynnika kompresji (a tym samym docelowej jakości obrazu); właściwość Performance pozwala kontrolować czas dekompresji, a Scale określa rozmiary odtwarzanego obrazu. Właściwości ProgressiveDisplay i ProgressiveEncoding pozwalają korzystać z mechanizmu tzw. dekompresji progresywnej, umożliwiającego wyświetlanie obrazu ze stopniowym „wyostrzaniem”. Wraz z właściwością Smoothing przydają się one w sytuacjach, gdy obraz musi być wyświetlany stopniowo (np. podczas transmisji danych przez Internet).
Obsługę zapisu i odczytu danych JPEG z i do plików, strumieni i schowka zapewniają metody: LoadFromClipboardFormat(), LoadFromStream(), LoadFromFile(), SaveToClipboardFormat(), SaveToStream() i SaveToFile(), dziedziczone z klasy TGraphic. Ich wykorzystanie nie wymaga w zasadzie komentarza, warto jedynie zauważyć, że w wymianie danych ze schowkiem klasa TJPEGImage wykorzystuje rodzimy format mapy bitowej Windows.
Mapy bitowe w formacie GIF
Format GIF (Graphics Interchange Format; czyta się „gif”) został opracowany w roku 1987 przez firmę Compuserve. W odróżnieniu od formatu JPEG, kompresja danych obrazowych w formacie GIF jest bezstratna (tj. odtworzona ze skompresowanej postaci kopia jest identyczna z oryginałem). Najnowsza wersja formatu (GIF89a) umożliwia także dekodowanie progresywne (z tzw. przeplotem), zapis sekwencji obrazów (animacje) i użycie przezroczystego tła. Więcej informacji na ten temat można znaleźć na licznych stronach internetowych, np. pod adresem http://www.geocities.co.jp/SiliconValley/3453/gif_info/.
Problemy związane z licencjami i prawami autorskimi zniechęcają wielu projektantów do wyposażania aplikacji w funkcje obsługi formatu GIF. Problem nie dotyczy zresztą samego formatu, a wykorzystywanej przezeń odmiany znanego algorytmu kompresji LZW (Lempel-Ziv-Welch) o nazwie LZ78, opatentowanej przez firmę Unisys Corporation. Co prawda licencja udzielana przez Compuserve zwalnia projektantów od wnoszenia opłat z tytułu tantiem, jednak Unisys, jako właściciel „części” standardu GIF, domaga się zakupu licencji.
Biblioteka VCL nie zapewnia bezpośredniej obsługi formatu GIF. Wyświetlenie zawartości pliku GIF wymaga przetłumaczenia rozpakowanych danych obrazowych na format BMP, co można zrealizować „ręcznie” poprzez odwołanie do właściwości ScanLine. Problemem pozostaje niestety sama kompresja i dekompresja danych (oraz ich odczytywanie i zapisywanie do pliku). Zadanie to można wykonać z użyciem komponentów i bibliotek pochodzących od niezależnych producentów; na uwagę zasługuje tu dostępny bez opłat komponent VCL TGIFImage autorstwa Andersa Melandera (http://www.melander.dk/delphi/gifimage/).
Mapy bitowe w formacie PNG
Format Portable Network Graphics, czyli PNG (czyta się „pe-en-gie” lub „ping”), opracowano celem rozszerzenia formatu GIF i rozluźnienia związanych z nim restrykcji patentowych. Podobnie jak GIF, również i format PNG wykorzystuje kompresję bezstratną, jednak zamiast opatentowanego algorytmu LZ78, wykorzystuje zmodyfikowaną wersję LZ77 (Lempel-Ziv 77), wykorzystywaną w popularnych programach do kompresji danych. Analogicznie do swojego poprzednika, format PNG pozwala na określanie wybranych pikseli jako przezroczystych, rozszerzając ten mechanizm o możliwość ustalania stopnia przezroczystości (ang. alpha blending), co pozwala na „wtapianie” obrazów w tło. Mapy bitowe PNG są także wolne od znanego ze standardu GIF ograniczenia do 256 kolorów, mogą zawierać informacje o korekcji gamma (co pozwala skompensować różnice w działaniu monitorów) i udostępniają specjalny dwuwymiarowy algorytm dekodowania progresywnego. Nie umożliwiają natomiast umieszczania sekwencji obrazów w jednym pliku (animacji).
Wyświetlanie map bitowych PNG w programach tworzonych w C++Builderze wymaga konwersji danych obrazowych do formatu DIB. Można tego dokonać „ręcznie”, podpierając się specyfikacją PNG (http://www.libpng.org/pub/png/spec/PNG-Contents.html) lub z użyciem komponentów i bibliotek opracowanych przez niezależnych programistów i firmy. Przykładem takiego narzędzia jest dostępna bezpłatnie biblioteka PNGDIB autorstwa Jasona Summersa (http://home.mieweb.com/jason/imaging/pngdib/). Zawarte w niej funkcje read_png_to_dib() i write_dib_to_png() realizują odpowiednio odczytanie pliku PNG i konwersję jego zawartości na mapę bitową DIB (paleta i właściwa mapa) oraz konwersję mapy DIB na format PNG i zapisanie tej ostatniej w pliku.
Przykład zastosowania wspomnianej biblioteki przedstawiono poniżej. W pierwszym etapie inicjalizujemy obiekt klasy TBitmap, przekształcając zawartość pliku PNG na format DIB za pomocą funkcji read_png_to_dib() i wypełniając mapę bitową poprzez odwołanie do właściwości ScanLine lub wywołanie funkcji GDI SetDIBits(). Pokazano to na wydruku 15.1.
Wydruk 15.1. Konwersja mapy bitowej PNG na obiekt TBitmap
if (OpenDialog1->Execute())
{
TCHAR filename[MAX_PATH];
lstrcpyn(filename, OpenDialog1->FileName.c_str(), MAX_PATH);
// Deklaracja i wyzerowanie struktury PNGD_P2DINFO
PNGD_P2DINFO png2dib;
memset(&png2dib, 0, sizeof(PNGD_P2DINFO));
// Inicjalizacja rozmiaru struktury i nazwy pliku
png2dib.structsize = sizeof(PNGD_P2DINFO);
png2dib.pngfn = filename;
// Konwersja z formatu PNG na DIB
if (read_png_to_dib(&png2dib) == PNGD_E_SUCCESS)
{
Graphics::TBitmap* Bitmap = Image1->Picture->Bitmap;
Bitmap->Width = png2dib.lpdib->biWidth;
Bitmap->Height = png2dib.lpdib->biHeight;
HBITMAP hBmp = Bitmap->ReleaseHandle();
HDC hDC = Canvas->Handle;
try
{
// do uzupełnienia: obsługa palety
// Konwersja z formatu DIB na TBitmap
SetDIBits(
hDC, hBmp, 0,
png2dib.lpdib->biHeight, png2dib.bits,
reinterpret_cast<LPBITMAPINFO>(png2dib.lpdib),
DIB_RGB_COLORS
);
}
catch (...)
{
Bitmap->Handle = hBmp;
GlobalFree(png2dib.lpdib);
}
Bitmap->Handle = hBmp;
GlobalFree(png2dib.lpdib);
}
}
Utworzenie pliku PNG na podstawie zawartości obiektu klasy TBitmap przebiega podobnie - najpierw należy wyekstrahować z obiektu mapę bitową DIB (za pomocą funkcji VCL GetDIBSizes() i GetDIB()), a następnie wywołać funkcję write_dib_to_png(), zapisującą map DIB w pliku PNG. Ilustruje to wydruk 15.2.
Wydruk 15.2. Konwersja obiektu TBitmap na mapę bitową PNG
if (SaveDialog1->Execute())
{
TCHAR filename[MAX_PATH];
lstrcpyn(filename, SaveDialog1->FileName.c_str(), MAX_PATH);
BITMAPINFO bmi;
Graphics::TBitmap* Bitmap = Image1->Picture->Bitmap;
//
// Ustal rozmiar nagłówka mapy DIB
// (BITMAPINFOHEADER + tablica kolorów)
// oraz rozmiar samej mapy (tablicy pikseli).
//
unsigned int info_size = 0, bits_size = 0;
GetDIBSizes(Bitmap->Handle, info_size, bits_size);
// Przydziel pamięć na tablicę pikseli.
unsigned char *bits = new unsigned char[bits_size];
try
{
// Odczytaj nagłówek BITMAPINFOHEADER, tablicę kolorów
// i tablicę pikseli.
if (GetDIB(Bitmap->Handle, Bitmap->Palette, &bmi, bits))
{
// deklaracja i wyzerowanie struktury PNGD_D2PINFO
PNGD_D2PINFO dib2png;
memset(&dib2png, 0, sizeof(PNGD_D2PINFO));
// Inicjalizuj strukturę.
dib2png.structsize = sizeof(PNGD_D2PINFO);
dib2png.flags = PNGD_INTERLACED;
dib2png.pngfn = filename;
dib2png.lpdib = &bmi.bmiHeader;
dib2png.lpbits = bits;
// Przetłumacz mapę DIB na PNG i zapisz ją w pliku.
if (write_dib_to_png(&dib2png) != PNGD_E_SUCCESS)
{
throw EInvalidGraphic("Błąd zapisu pliku PNG!");
}
}
}
catch (...)
{
delete [] bits;
}
delete [] bits;
}
Pełną demonstrację użycia opisanej biblioteki zawiera projekt PNGDIB_Demo, zawarty w katalogu PNGDemo na dołączonej do książki płycie CD-ROM.
Przetwarzanie obrazu
Potoczne określenie „przetwarzanie obrazu” odnosi się do komputerowego przetwarzania i analizy danych obrazowych na drodze cyfrowej. Systemy do przetwarzania obrazu można znaleźć praktycznie wszędzie, począwszy od amatorskich kamer cyfrowych, a skończywszy na zaawansowanych (i supertajnych) systemach wojskowych.
Dane obrazowe przechowywane są w komputerach w postaci dwuwymiarowych tablic (macierzy). Pojedynczy element obrazu (punkt) zwany jest potocznie pikselem (ang. pixel, od picture element), zaś liczba pikseli przypadająca na szerokość i wysokość obrazu określa tzw. rozdzielczość (ang. resolution). Im więcej pikseli zawiera dany obraz, tym większa jego rozdzielczość. Przykładowe obrazy, których przetwarzaniem zajmiemy się w tym rozdziale, mają rozdzielczość 256×256 lub 512×512 pikseli.
Liczba bajtów przypadających na pojedynczy piksel zależy od liczby kolorów (poziomów szarości) obrazu. W przypadku obrazów monochromatycznych do reprezentacji pojedynczego piksela wystarczy jeden bit (czerń lub biel). Obrazy wyświetlane w skali szarości zawierają na ogół do 256 kolorów (wartość ta wynika z ograniczenia rozdzielczości barwnej ludzkiego oka oraz sposobów przechowywania danych w komputerze), co pozwala reprezentować pojedynczy piksel w postaci jednego bajta. W przypadku obrazu wykorzystującego pełny kolor (true color) każdy piksel opisany jest trzema wartościami odpowiadającymi składowym: czerwonej, zielonej i niebieskiej (model RGB), zapisanymi w trzech oddzielnych bajtach (łącznie 24 bity). Jak łatwo obliczyć, kolorowy obraz o rozdzielczości 512×512 pikseli wymaga 768 kilobajtów pamięci lub przestrzeni dysku. Ogromne zapotrzebowanie na zasoby (głównie pamięć) było przyczyną, dla której jeszcze do niedawna przechowywanie i przetwarzanie danych obrazowych pozostawało domeną dużych komputerów. Być może zresztą niektórzy Czytelnicy pamiętają jeszcze komputerki typu Apple II czy Sinclair Spectrum, dysponujące zawrotną ilością 48 kB pamięci operacyjnej.
Dane obrazowe nie są na ogół przechowywane w pierwotnej, „surowej” postaci - wykorzystuje się w tym celu liczne formaty zapisu, często wyspecjalizowane pod kątem konkretnych zastosowań. W systemie Windows do przechowywania obrazów stosowany jest format BMP (Windows Bitmap), którego używamy w większości przedstawionych tu przykładów. Innymi popularnymi formatami rastrowymi są np. TIFF i PCX.
Dokładna prezentacja metod i algorytmów przetwarzania obrazu wykracza oczywiście poza ramy tego rozdziału, spróbujemy jednak omówić w nim kilka podstawowych technik, popierając teorię przykładami napisanymi w C++Builderze. Prezentowane tu fragmenty pochodzą z kodu źródłowego projektu IPro.bpr, znajdującego się w katalogu ImageProcessing na dołączonej do książki płycie CD-ROM. Na rysunku 15.3 przedstawiono okno programu, prezentujące zawartość przykładowego pliku peppers.bmp (również umieszczonego na płycie CD).
Rysunek 15.3. Prosty program do przetwarzania obrazu
Odczytywanie i wyświetlanie parametrów obrazu
Pierwszym etapem w procesie przetwarzania i wyświetlania obrazu jest ustalenie (odczytanie z pliku) jego parametrów, jak np. rozdzielczość czy głębia koloru. Najprościej użyć w tym celu komponentu TImage, zlokalizowanego na karcie Additional palety komponentów. Po umieszczeniu go na formularzu warto ustawić właściwość AutoSize na true, włączając w ten sposób automatyczną zmianę rozmiarów komponentu stosownie do wielkości wyświetlanego obrazu. Na wydruku 15.3 przedstawiono sposób odczytania zawartości wybranego pliku i wyświetlenia jej za pomocą komponentu TImage. Po załadowaniu obrazu program wyświetla jego parametry w odpowiednich polach edycyjnych. Nazwy komponentów użytych w projekcie pozostawiono niezmienione.
Wydruk 15.3. Odczytanie obrazu z pliku i wyświetlenie jego parametrów
void __fastcall TForm1::Button1Click(TObject *Sender)
{
OpenDialog1->Filter = "Pliki BMP (*.bmp)|*.BMP";
if (OpenDialog1->Execute())
{
Image1->Picture->Bitmap->LoadFromFile(OpenDialog1->FileName);
Edit1->Text = OpenDialog1->FileName;
Edit2->Text = Image1->Width;
Edit3->Text = Image1->Height;
int pixelFormats[9] = {0, 1, 4, 8, 15, 16, 24, 32, 0};
Edit4->Text = pixelFormats[Image1->Picture->Bitmap->PixelFormat];
}
}
Pierwsza instrukcja wyświetla standardowe okno otwarcia pliku, reprezentowane przez komponent OpenDialog1 typu TOpenDialog. Maskę nazwy pliku ustawiono na BMP (pliki w formacie BMP). Po zwróceniu przez metodę Execute() wartości true (co oznacza, że użytkownik zaznaczył plik i kliknął przycisk OK), wybrany plik jest otwierany, a jego zawartość ładowana do mapy bitowej Image1. Teraz pozostaje już tylko odczytać parametry obrazu z odpowiednich właściwości obiektu. Na uwagę zasługuje tu właściwość PixelFormat, określająca głębię koloru (liczbę bitów przypadających na piksel) i zdefiniowana typem wyliczeniowym TPixelFormat w pliku graphics.hpp:
enum TPixelFormat { pfDevice, pf1bit, pf4bit, pf8bit, pf15bit,
pf16bit, pf24bit, pf32bit, pfCustom };
Jak stąd wynika, dla obrazu wykorzystującego pełną 24-bitową paletę barw (true color) właściwość PixelFormat powinna zawierać wartość 6, zaś dla 256-kolorowej mapy bitowej uzyskamy wartość 3. Warto też zauważyć, że chociaż wykorzystana tu funkcja Load() radzi sobie z mapami bitowymi o dowolnych rozmiarach i głębi koloru, dla większości operacji opisywanych w dalszej części rozdziału przyjęto upraszczające założenie, iż mamy do czynienia z 256-kolorową (8 bitów na piksel) mapą bitową o rozdzielczości 256×256 pikseli.
Jak widać na rysunku 15.3, informacje o parametrach obrazu wyświetlane są w polach edycyjnych w prawej górnej części okna programu.
Dostęp do pikseli poprzez właściwość TCanvas->Pixels
Zanim zajmiemy się przetwarzaniem obrazu, musimy określić sposób odczytywania i zapisywania wartości jego poszczególnych pikseli. Najprostsza metoda polega na wykorzystaniu właściwości Pixels klasy TCanvas, reprezentującej dwuwymiarową tablicę pikseli indeksowaną, począwszy od lewego górnego wierzchołka obrazu. Sposób odczytu i wyświetlania współrzędnych kursora myszy i wartości wskazanego nią piksela pokazano na wydruku 15.4. Odpowiednie dane wyświetlane są w trzech dolnych polach edycyjnych widocznych na rysunku 15.3.
Wydruk 15.3. Odczyt wartości piksela wskazanego myszą
void __fastcall TForm1::Image1MouseMove(TObject *Sender,
TShiftState Shift, int X, int Y)
{
Edit5->Text = X;
Edit6->Text = Y;
Edit7->Text = Image1->Picture->Bitmap->Canvas->Pixels[X][Y];
}
Bieżące współrzędne kursora myszy przekazywane są do funkcji obsługi w parametrach X i Y. Wartość piksela, zawarta w obiekcie klasy TColor, interpretowana jest zgodnie z ustawieniem właściwości PixelFormat. Dla obrazów wyświetlanych w skali szarości jasność danego punktu opisana jest wartością najmniej znaczącego bajta 32-bitowego słowa opisującego piksel. Dla obrazów wykorzystujących paletę 24-bitową używane są trzy mniej znaczące bajty, reprezentujące odpowiednio składową niebieską, zieloną i czerwoną. Na przykład wartość --> 0x00FF0000 [Author:ts] oznacza czysty kolor niebieski, 0x0000FF00 to czysta zieleń, a 0x000000FF - czerwień.
Właściwość TCanvas->Pixels pozwala także na zmianę wartości poszczególnych pikseli. Na przykład uzupełnienie funkcji Image1MouseMove() o poniższy wiersz spowoduje wykreślenie białej linii, wyznaczającej ślad kursora myszy:
Image1->Canvas->Pixels[X][Y] = clWhite;
Odwoływanie się do poszczególnych pikseli poprzez wywołanie Canvas->Pixels[X][Y] jest rozwiązaniem tyleż prostym, co beznadziejnie nieefektywnym, dlatego też wykorzystanie tej metody najlepiej ograniczyć do prostych i mało wymagających programów. Znacznie lepsze osiągi zapewnia użycie właściwości ScanLine, którą omówimy dalej, w punkcie „Dostęp do pikseli poprzez właściwość ScanLine”.
Tworzenie obrazów
Jednym z zadań częściej spotykanych w praktyce przetwarzania obrazów jest generowanie obrazów testowych, posiadających określone parametry. W kolejnym przykładzie spróbujemy zatem utworzyć mapę bitową, pozwalającą zbadać pewną właściwość ludzkiego wzroku. Jasność (w skali szarości) poszczególnych pikseli będzie zmieniała się wzdłuż osi poziomej zgodnie z przebiegiem sinusoidalnym; częstotliwość sinusoidy (rozdzielczość przestrzenna) będzie liniowo rosła od lewej do prawej, zaś amplituda będzie liniowo malała od góry do dołu obrazu. Kod źródłowy przykładu pokazano na wydruku 15.5. Warto zauważyć, iż wykorzystuje on funkcje arytmetyczne exp(), sin() i --> sqrt[Author:ts] (), co wymaga włączenia pliku nagłówkowego math.h. Zawartość wydruku pochodzi ze wspomnianego wcześniej projektu IPro.bpr.
Wydruk 15.5. Graficzna reprezentacja sinusoidy o zmiennej częstotliwości i amplitudzie
void __fastcall TForm1::Button2Click(TObject *Sender)
{
int x, y;
int Amplitude, Gray;
float Period;
TColor RGB;
Image1->Picture->Bitmap = new Graphics::TBitmap;
Image1->Picture->Bitmap->PixelFormat = pf24bit;
Image1->Picture->Bitmap->Width = 256;
Image1->Picture->Bitmap->Height = 256;
for (y=0; y<=255; y++)
for (x=0; x<=255; x++)
{
Amplitude = 64*(255-y)/255;
Period = 100*sqrt(1/(1+(exp(0.013*x)*exp(0.027*x)/400)));
Gray = Amplitude*sin(2*M_PI/Period*x)+128;
RGB = (Graphics::TColor)((Gray << 16) | (Gray << 8) | Gray);
Image1->Canvas->Pixels[x][y] = RGB;
}
}
W pierwszym kroku tworzymy obiekt TBitmap, reprezentujący mapę bitową wykorzystywaną do rysowania, a następnie ustalamy właściwości: PixelFormat, Width i Height. Wartości poszczególnych pikseli obliczane są w dwóch zagnieżdżonych pętlach for (dyskusję na temat użytych wzorów pozwolimy sobie tu pominąć), zaś do wyświetlenia samych pikseli używamy konstrukcji Image1->Canvas->Pixels[][]. Wygenerowany w ten sposób obraz pokazano na rysunku 15.4.
Rysunek 15.4. Mapa bitowa o sinusoidalnie zmiennych wartościach pikseli
Uwaga
Spoglądając na obraz testowy, łatwo stwierdzić, że wrażliwość oka na kontrast zależy od rozdzielczości przestrzennej, osiągając maksimum dla pośrednich wartości częstotliwości i spadając wraz z jej wzrostem. Uzyskane w ten sposób informacje przydają się podczas projektowania rzeczywistych systemów przesyłania i kompresji danych wizyjnych, umożliwiając określenie ilości miejsca lub szerokości pasma niezbędnej do zachowania odpowiedniej wierności obrazu. Jak nietrudno się domyślić, informację mniej istotną (tj. szczegóły słabo widoczne lub niewidoczne) można częściowo lub nawet całkowicie pominąć bez wyraźnego pogorszenia jakości.
Pozostaje jeszcze kwestia zapisania naszego obrazu do przyszłego wykorzystania. Umożliwia to metoda SaveToFile(), wywoływana w funkcji obsługi przycisku kliknięcia Button5Click():
void __fastcall TForm1::Button5Click(TObject *Sender)
{
Image1->Picture->Bitmap->SaveToFile("test.bmp");
}
Użycie komponentu TSaveDialog pozwoli użytkownikowi na wybranie nazwy pliku.
Dostęp do pikseli poprzez właściwość ScanLine
Nawet na komputerze wyposażonym w stosunkowo szybki procesor, jak np. Pentium III 500 MHz, wygenerowanie obrazu testowego poprzednią metodą zabiera sekundę do dwóch. Przyczyną tego jest ogromny narzut obliczeniowy związany z odwołaniami do właściwości Pixels klasy TCanvas. Znacznie bardziej wydajną metodą jest utworzenie poszczególnych punktów w pamięci (w postaci tablicy dwuwymiarowej), a następnie skopiowanie ich do obiektu TBitmap z wykorzystaniem właściwości ScanLine. Praktyczną realizację tego sposobu pokazano na wydruku 15.6.
Wydruk 15.6. Utworzenie obrazu testowego z użyciem właściwości ScanLine
void __fastcall TForm1::Button3Click(TObject *Sender)
{
int x, y;
int Amplitude;
float Period;
BYTE ImageData[256][256], *LinePtr;
Image1->Picture->Bitmap = new Graphics::TBitmap;
Image1->Picture->Bitmap->PixelFormat = pf24bit;
Image1->Picture->Bitmap->Width = 256;
Image1->Picture->Bitmap->Height = 256;
for (y=0; y<=255; y++)
for (x=0; x<=255; x++)
{
Amplitude = 64*(255-y)/255;
Period = 100*sqrt(1/(1+(exp(0.013*x)*exp(0.027*x)/400)));
ImageData[x][y] = Amplitude*sin(2*M_PI/Period*x)+128;
}
// Kopiuj obraz na ekran.
for (y=0; y<=255; y++)
{
LinePtr = (BYTE *) Image1->Picture->Bitmap->ScanLine[y];
for (x=0; x<=255; x++)
{
LinePtr[x*3] = ImageData[x][y]; // czerwony
LinePtr[x*3+1] = ImageData[x][y]; // zielony
LinePtr[x*3+2] = ImageData[x][y]; // niebieski
}
}
Image1->Refresh();
}
Obliczone wartości pikseli trafiają do dwuwymiarowej tablicy ImageData, skąd po zakończeniu obliczeń są wiersz po wierszu kopiowane do właściwej mapy bitowej. Położenie w pamięci początków kolejnych linii docelowej mapy bitowej określa wskaźnik LinePtr, którego wartość ustalana jest poprzez odwołanie do właściwości ScanLine płótna. Dla każdego piksela wymagane są trzy operacje kopiowania, odpowiednio dla składowej czerwonej, zielonej i niebieskiej. Jak można sprawdzić doświadczalnie, nowa wersja programu generuje obraz praktycznie natychmiast.
Przekształcenia punktowe - dyskryminacja i konwersja koloru na odcienie szarości
Operacje modyfikujące wartości koloru (lub poziomu szarości) przypisane indywidualnym pikselom określa się niekiedy mianem przekształceń punktowych (ang. point operation). Wynik takiego przekształcenia zależy tylko od położenia lub wartości danego piksela; w tym ostatnim przypadku mamy do czynienia z konwersją poziomów szarości. Modyfikacje na podstawie współrzędnych elementu obrazu wykorzystywane są zwykle do korygowania wad urządzeń obrazujących (np. kamer) lub nierównomiernego oświetlenia; konwersja poziomów szarości umożliwia też poprawę kontrastu i jaskrawości obrazu. Do demonstracji przekształceń punktowych wykorzystamy przykładowy obraz zawarty w pliku splash.bmp (można go znaleźć w katalogu ImageProcessing na płycie CD dołączonej do książki).
Rysunek 15.5. Wyjściowa postać obrazu przed przekształceniami
Najprostszą odmianą konwersji poziomów szarości jest dyskryminacja (ang. thresholding). Przekształcenie to polega na zmianie wartości piksela zgodnie ze
-->
wzorem[Author:ts]
:
gdzie p0 i p1 oznaczają odpowiednio wartość piksela przed i po przekształceniu, zaś T jest wartością progu. Jak widać, wartość piksela po przeprowadzeniu operacji może wynosić 0 (czerń) w przypadku, gdy pierwotna wartość była mniejsza od progowej, lub 255 (biel), gdy pierwotna wartość była równa lub większa od progowej. Innymi słowy, w wyniku operacji otrzymujemy obraz monochromatyczny bez gradacji szarości.
Wartość progu może być sztywno ustalona lub obliczana dynamicznie. Typowym zastosowaniem dyskryminacji jest tzw. segmentacja, czyli przekształcanie obrazów z gradacją szarości na obrazy monochromatyczne.
W przykładzie pokazanym na wydruku 15.7 przeprowadzana jest dyskryminacja z użyciem progu o wartości 127. Jeśli wartość danego piksela jest równa lub większa od progu, jest on „podciągany” do poziomu bieli; w przeciwnym przypadku jest „wygaszany” do poziomu czerni. Wynik przekształcenia obrazu oryginalnego (rysunek 15.5) pokazano na rysunku 15.6.
Wydruk 15.7. Przekształcenie obrazu przez dyskryminację
void __fastcall TForm1::Button11Click(TObject *Sender)
{
int x, y;
BYTE* LinePtr;
// dyskryminacja
for (y=0; y<=255; y++)
{
LinePtr=(BYTE*)Image1->Picture->Bitmap->ScanLine[y];
for (x=0; x<=255; x++)
{
if (LinePtr[x] > 128)
LinePtr[x] = 255;
else
LinePtr[x] = 0;
}
}
Image1->Refresh();
}
Rysunek 15.6. Wynik dyskryminacji dla obrazu z rysunku 15.5
Określenie wartości progu może okazać się dość kłopotliwe i często wymaga wcześniejszej wiedzy na temat obrazu lub poczynienia pewnych założeń. Można także ustalać próg na podstawie określonych statystyk obrazu, jak np. średnia wartość piksela, odchylenie standardowe itp., jednak omawianie tych metod wykracza poza zakres naszej dyskusji.
Innym przykładem przekształcenia punktowego jest inwersja obrazu, dająca efekt „negatywu”. Sprowadza się ona do prostego odejmowania, co pokazano na wydruku 15.8.
Wydruk 15.8. Inwersja obrazu
void __fastcall TForm1::Button4Click(TObject *Sender)
{
int x, y;
BYTE* LinePtr;
// inwersja
for (y=0; y<=255; y++)
{
LinePtr=(BYTE*)Image1->Picture->Bitmap->ScanLine[y];
for (x=0; x<=255; x++)
LinePtr[x] = 255 - LinePtr[x];
}
Image1->Refresh();
}
Wynik inwersji przedstawiono na rysunku 15.7. Jak widać, otrzymaliśmy coś w rodzaju czarno-białego negatywu.
Rysunek 15.7. Obraz z rysunku 15.5 po inwersji
Przekształcenia globalne - wyrównanie histogramu
Pojęcie „przekształcenie globalne” (ang. global operation) oznacza, że dane przekształcenie dotyczy globalnych właściwości obrazu, tj. parametrów uzyskanych zeń jako całości. Typowym przykładem transformacji globalnej jest wyrównanie histogramu (linearyzacja dystrybuanty, ang. histogram equalization). Operacja ta pozwala skorygować obrazy, w których na skutek np. złych warunków oświetlenia zakres wartości pikseli jest mniejszy od teoretycznego zakresu dynamiki, wynikającego z liczby bitów na piksel. Efekt ten objawia się wizualnie w postaci niskiego kontrastu obrazu. Operację wyrównania histogramu zademonstrujemy na przykładzie zdjęcia przedstawionego na rysunku 15.8 (plik couple.bmp na płycie CD-ROM).
Rysunek 15.8. Zdjęcie o niskim kontraście
Histogram zdjęcia przedstawiono na rysunku 15.9. Reprezentuje on udział liczby pikseli o danym poziomie szarości w całej zawartości obrazu. Na osi poziomej znajdują się kolejne wartości poziomu szarości, zaś na osi pionowej - liczba pikseli o danym poziomie. Wysokość poszczególnych słupków histogramu odpowiada więc liczbie pikseli o określonym poziomie szarości. Jak widać z rysunku 15.9, „punkt ciężkości” histogramu przesunięty jest znacząco w lewo, co oznacza, że największy udział w obrazie mają punkty ciemne.
Rysunek 15.9. Histogram obrazu o małej jaskrawości i kontraście
Pierwszym etapem operacji wyrównania histogramu jest obliczenie tego ostatniego, czyli ustalenie udziału poszczególnych poziomów szarości w całym obrazie. Odpowiedni kod zawarto w funkcji TForm::Button10Click()programu IPro.
Używana na wydruku 15.9 tablica Histogram ma 256 elementów, z których każdy odpowiada pojedynczemu poziomowi szarości (dla 8-bitowej reprezentacji pikseli maksymalna liczba odcieni wynosi 256). Po wyzerowaniu wszystkich elementów („słupków”) histogramu obliczamy ich wartości w zagnieżdżonych pętlach for. Wartości poszczególnych pikseli są tu traktowane jako indeksy, wskazujące numer elementu tablicy („słupka”), który należy zwiększyć o jeden.
Wydruk 15.9. Obliczenie histogramu obrazu
// Inicjalizuj histogram.
for (i=0; i<=255; i++)
Histogram[i] = 0;
// Oblicz histogram.
for (y=0; y<=255; y++)
for (x=0; x<=255; x++)
Histogram[ImageData[x][y]]++;
-->
[Author:ts]
Po obliczeniu histogramu należy utworzyć 256-elementowy wektor transformacji, który wykorzystamy do przekształcenia pikseli oryginalnego obrazu na wartości skorygowane. Wartości elementów wektora dobiera się tak, aby uzyskać w miarę równomierny histogram obrazu wynikowego. Pozwolimy sobie tu pominąć wyprowadzenie i przytoczymy od razu gotowy
-->
wzór[Author:ts]
:
Sposób zapisania powyższego wzoru w C++ przedstawiono na wydruku 15.11.
Wydruk 15.11. Obliczenie wektora transformacji
// Oblicz wektor transformacji.
for (i=0; i<=255; i++)
{
sum = 0;
for (j=0; j<=i; j++)
sum += Histogram[j];
Transform[i] = INT(255.0*sum/(256*256));
}
Tablica Transform to 256-elementowy wektor przechowujący wartości współczynników transformacji vk dla poszczególnych poziomów szarości. Przekształcenie za jej pomocą pikseli oryginalnego obrazu przebiega następująco:
Wydruk 15.11. Przekształcenie oryginalnego obrazu z użyciem wektora transformacji
// Przelicz piksele obrazu.
for (y=0; y<=255; y++)
for (x=0; x<=255; x++)
ImageData[x][y] = Transform[ImageData[x][y]];
Wynik przekształcenia pokazano na rysunku 15.10, zaś histogram uzyskanego obrazu - na rysunku 15.11.
Rysunek 15.10. Zdjęcie z rysunku 15.8 po wyrównaniu histogramu
Rysunek 15.11. Histogram uzyskanego zdjęcia
Jak widać, skorygowane zdjęcie jest znacznie bardziej kontrastowe, a jego histogram bardziej równomierny, co oznacza lepsze wykorzystanie dostępnego zakresu dynamiki pikseli.
Przekształcenia geometryczne - powiększenie
Geometryczne przekształcenia obrazu można podzielić na dwie klasy - liniowe i nieliniowe. Przekształcenia liniowe (afiniczne) obejmują przesunięcia, obroty, powiększenia oraz ich złożenia. Do klasy przekształceń nieliniowych należą wszelkiego rodzaju zniekształcenia obrazu.
Przykładem przekształcenia liniowego jest powiększenie obrazu (ang. zoom), które przedstawimy w tym punkcie. Jako materiał doświadczalny wykorzystamy plik peppers8bit.bmp, który można znaleźć na płycie CD-ROM.
Rysunek 15.12. Obraz oryginalny
Równania pozwalające określić docelowe położenie przekształcanego piksela podano --> poniżej[Author:ts] .
Symbole x' i y' oznaczają tu nowe współrzędne piksela, x i y to jego pierwotne współrzędne, a jest współczynnikiem skali, zaś x0 i y0 to współrzędne środka obszaru powiększenia. Warto zauważyć, że uzyskana w ten sposób powiększona matryca pikseli będzie miejscami „dziurawa” (wynika to z rozłożenia tej samej liczby pikseli na większej powierzchni). Dlatego też w praktyce stosuje się przekształcenie odwrotne, wykonując obliczenia dla każdego piksela obrazu wynikowego i ustalając dlań współrzędne piksela
-->
źródłowego[Author:ts]
:
Jak widać z rysunku 15.13, również i w tym przypadku obliczone wartości współrzędnych będą niecałkowite, jednak metoda ta pozwala jednoznacznie ustalić wartość piksela źródłowego (tzw. najbliższego sąsiada, ang. nearest neighbor) dla każdego piksela wynikowego.
Rysunek 15.13. Obliczenie współrzędnych piksela wynikowego oraz transformacja odwrotna
Original pixel position - Położenie piksela źródłowego
New pixel position - Położenie piksela wynikowego
Właściwą wartość piksela wynikowego ustala się w najprostszym przypadku jako równą wartości najbliższego sąsiada. Kod realizujący tę metodę (zawarty w funkcji TForm1::Button6Click() programu IPro.bpr) przedstawiono na wydruku 15.12.
Wydruk 15.12. Powiększenie z wykorzystaniem wartości najbliższego sąsiada
// Oblicz piksele obrazu wynikowego.
for (y=0; y<=255; y++)
for (x=0; x<=255; x++)
{
i = INT((x+(zoom-1)*x0) / zoom ); // x0 = 127; y0 = 127
j = INT((y+(zoom-1)*y0) / zoom ); // zoom = 4
Output[x][y] = ImageData[i][j]; // zapisz wartość piksela
}
Obliczone wartości pikseli należy oczywiście zapisywać w nowej tablicy, innej niż przechowująca obraz źródłowy, w przeciwnym razie bowiem wyniki przekształcenia mogą okazać się nieco dziwne. Przeoczenie tego wymogu jest dość pospolitym błędem.
Bliższe przyjrzenie się powiększonemu obrazowi ukazuje jego „blokową” fakturę, co nie jest oczywiście zbyt pożądane. Znacznie lepszy efekt można uzyskać, zastępując proste odwołanie do wartości pojedynczego piksela tzw. interpolacją dwuliniową (biliniową). W metodzie tej wartość wynikowa obliczana jest poprzez ważone uśrednienie wartości czterech pikseli najbliższych punktowi o obliczonych współrzędnych, co zilustrowano na rysunku 15.14.
Rysunek 15.14. Zasada interpolacji dwuliniowej
Zapis metody interpolacji dwuliniowej w języku C, czyli kod funkcji Bilinear(), przedstawiono na wydruku 15.13. Funkcja ta wywoływana jest z kolei przez metodę Button7Click() obiektu Form1 w przykładowym programie (druga część wydruku).
Wydruk 15.13. Powiększenie z wykorzystaniem interpolacji dwuliniowej
int Bilinear(BYTE Image[][256], float i, float j)
{
int x1, y1, x2, y2;
float Gray;
x1 = (int)floor(i);
x2 = x1+1;
y1 = (int)floor(j);
y2 = y1+1;
Gray = (y2-j)*(x2-i)*Image[x1][y1] + (y2-j)*(i-x1)*Image[x2][y1]
+ (j-y1)*(x2-i)*Image[x1][y2] + (j-y1)*(i-x1)*Image[x2][y2];
return INT(Gray);
}
void __fastcall TForm1::Button7Click(TObject *Sender)
{
// inicjalizacja [...]
// Oblicz piksele obrazu wynikowego.
for (y=0; y<=255; y++)
for (x=0; x<=255; x++)
{
i = (x+(zoom-1)*x0) / zoom;
j = (y+(zoom-1)*y0) / zoom;
Output[x][y] = Bilinear(ImageData, i, j);
}
// Kopiowanie na ekran [...]
}
Wyniki użycia obu metod przedstawiono na rysunkach 15.15 i 15.16. Nie trzeba nikogo przekonywać, że interpolacja dwuliniowa pozwala uzyskać obraz gładszy i mniej zniekształcony. Dalszą poprawę jakości obrazu (kosztem wzrostu złożoności obliczeń) można uzyskać, wykorzystując interpolację wyższego rzędu (np. sześcienną).
Rysunek 15.15. Czterokrotne powiększenie z użyciem metody „najbliższego sąsiada”
Rysunek 15.16. Czterokrotne powiększenie z użyciem interpolacji dwuliniowej
Przekształcenia splotowe - wygładzanie i detekcja krawędzi
Mianem przekształceń lub filtracji splotowych (ang. spatial operation) określamy operacje przetwarzające grupy pikseli. Kategoria ta obejmuje wiele metod i zastosowań, z których zademonstrujemy dwa - wygładzanie obrazu (filtrację dolnoprzepustową) oraz wykrywanie krawędzi (filtrację górnoprzepustową).
Metody filtracji splotowej wykorzystują tzw. macierze filtrujące, określające obszar działania przekształcenia i udział w nim poszczególnych pikseli. Wartość obliczona w wyniku złożenia macierzy i danego fragmentu obrazu przypisywana jest następnie pikselowi odpowiadającemu centralnemu punktowi macierzy. W praktycznych zastosowaniach używa się najczęściej macierzy kwadratowych (np. 3×3 lub 5×5 pikseli), jednak niektóre algorytmy wykorzystują macierze większe i niekoniecznie kwadratowe. Przykładem może być filtr modelujący jeden z aspektów zachowania siatkówki oka, tzw. hamowanie oboczne (ang. Mexican hat function). Używany jest on w niektórych systemach komputerowego rozpoznawania obrazu i wykorzystuje macierz filtrującą o rozmiarach do 17×17 pikseli.
Najprostszym filtrem jest filtr dolnoprzepustowy, wykorzystujący macierz 3×3 przedstawioną poniżej:
1/9 1/9 1/9
1/9 1/9 1/9
1/9 1/9 1/9
Efektem jego zastosowania jest zastąpienie każdego piksela przez równomiernie uśrednione wartości z jego otoczenia o boku 3. Nazwa „dolnoprzepustowy” wynika z faktu, iż uśrednienie powoduje stłumienie wysokich częstotliwości w charakterystyce widmowej obrazu. Praktyczne zastosowanie filtru zawarto w kodzie funkcji Lowpass(), wywoływanej przez metodę TForm::Button8Click() programu IPro. Kod źródłowy obu funkcji przedstawiono poniżej.
Wydruk 15.14. Filtracja dolnoprzepustowa z użyciem macierzy 3×3
int Lowpass(BYTE Image[][256], int x, int y)
{
int Mask[3][3] = {{1, 1, 1,}, {1, 1, 1}, {1, 1, 1}};
int i, j, sum=0;
for (j=-1; j<=1; j++)
for (i=-1; i<=1; i++)
sum += Image[x+i][y+j]*Mask[i+1][j+1];
return INT(sum/9.0);
}
void __fastcall TForm1::Button8Click(TObject *Sender)
{
// Inicjalizacja [...]
// Zastosuj filtr dolnoprzepustowy.
for (y=1; y<=254; y++)
for (x=1; x<=254; x++)
Output[x][y] = Lowpass(ImageData, x, y);
// Kopiowanie na ekran [...]
}
Obrazy źródłowy i wynikowy pokazano odpowiednio na rysunkach 15.11 i 15.17. Wynik filtracji jest dość oczywisty; użycie większej macierzy filtrującej powoduje silniejsze wygładzenie konturów.
Rysunek 15.17. Obraz poddany filtracji dolnoprzepustowej z użyciem macierzy 3×3
Najprostsza macierz filtrująca używana w filtracji górnoprzepustowej ma postać:
-1/9 -1/9 -1/9
-1/9 1 -1/9
-1/9 -1/9 -1/9
Użycie wartości ujemnych powoduje wzmocnienie pikseli o wartościach „wystających” poza swoje otoczenie. Odmianą filtracji górnoprzepustowej jest tzw. detekcja krawędzi (ang. edge detection), pozwalająca uwydatnić fragmenty obrazu, zawierające piksele o zróżnicowanych wartościach (co przekłada się na większy udział wyższych częstotliwości w charakterystyce widmowej obrazu). Wykrywanie krawędzi jest istotnym elementem wstępnego przetwarzania danych w systemach rozpoznawania obrazu, pozwala bowiem określić kształty wizualizowanych elementów, dając podstawę do ich identyfikacji. Wynik przefiltrowania obrazu z rysunku 15.11 z wykorzystaniem macierzy podanej wyżej przedstawiono na rysunku 15.18.
Rysunek 15.18. Obraz poddany filtracji górnoprzepustowej z użyciem macierzy 3×3
Techniczna realizacja filtrowania wiąże się z koniecznością obsłużenia „przypadku specjalnego”, jakim są piksele położone na krawędziach obrazu. Jednym z rozwiązań tego problemu jest pominięcie punktów brzegowych podczas właściwego filtrowania i zastąpienie ich w obrazie wynikowym wartościami najbliższych sąsiadów. Inne rozwiązania wykorzystują interpolację, powielanie pikseli przed filtrowaniem lub wykorzystanie „lustrzanego odbicia” punktów brzegowych.
Odtwarzanie zapisów audio, wideo i płyt CD
Zaimplementowany w Windows podsystem obsługi multimediów udostępnia programiście standardowe mechanizmy sterowania urządzeniami zapisującymi i odtwarzającymi zapisy multimedialne. Działanie tych mechanizmów sprowadza się do pośredniczenia w komunikacji pomiędzy aplikacją a odpowiednimi sterownikami urządzeń. Jeszcze wyższy poziom abstrakcji reprezentuje tzw. interfejs sterowania mediami (Media Control Interface, MCI), reprezentujący ujednolicony mechanizm programowego dostępu do wszystkich urządzeń audio i wideo. Zwalnia to projektanta aplikacji z konieczności zajmowania się szczegółami działania, a często nawet typem danego urządzenia.
W pierwszej części tego podrozdziału zajmiemy się wykorzystaniem interfejsu MCI do realizacji ogólnych zadań związanych z odtwarzaniem zapisów dźwiękowych i wizyjnych. Jak się wkrótce okaże, wykorzystanie funkcji MCI jest wręcz dziecinnie proste. Kolejny punkt poświęcimy przetwarzaniu zapisów dźwiękowych zapisanych w postaci spróbkowanej i zastosowaniu systemowych mechanizmów odtwarzających do wzbogacania możliwości aplikacji. Na koniec zajmiemy się przetwarzaniem danych dźwiękowych w postaci strumieniowej oraz zapisywaniem i odczytywaniem plików typu WAV.
Interfejs MCI
Podobnie jak podsystem GDI, realizujący uogólnione metody dostępu do urządzeń graficznych, interfejs MCI udostępnia abstrakcyjne, niezależne od sprzętu mechanizmy programowej komunikacji z urządzeniami multimedialnymi. Przed pojawieniem się specyfikacji MCI programiści zmuszeni byli do tworzenia kodu specyficznego dla konkretnych urządzeń, co zwykle wiązało się z odwołaniami do określonych sterowników programowych. Jak nietrudno się domyślić, było to niezwykle uciążliwe zarówno dla projektantów oprogramowania, jak i użytkowników, skazanych na korzystanie z dość ograniczonego zestawu urządzeń. Przykładem mogą tu być wczesne gry przeznaczone dla systemu DOS, wymagające użycia kart dźwiękowych zgodnych ze standardem Sound Blaster.
Komunikaty i dane MCI
Komunikacja pomiędzy programem a funkcjami interfejsu MCI sprowadza się do wymiany standardowych komunikatów oraz stałych tekstowych. Mechanizm ten jest bardzo zbliżony do systemowego mechanizmu wymiany komunikatów zarządzających oknami. Większość komunikatów MCI posiada swoje odpowiedniki w formie łańcuchów, reprezentujących operacje w sposób bardziej zrozumiały i czytelny. Ze względu na ograniczenie miejsca w naszej dyskusji ograniczymy się do omówienia samych komunikatów; definicje odpowiednich stałych symbolicznych zawarto w pliku mmsystem.h, który można znaleźć w podkatalogu Include katalogu macierzystego C++Buildera.
Odpowiednikiem funkcji SendMessage(), używanej w Windows do przesyłania komunikatów pomiędzy oknami, jest w standardzie MCI funkcja mciSendCommand(). Umożliwia ona przekazywanie komunikatów sterujących działaniem urządzeń MCI, a jej deklaracja jest następująca:
MCIERROR mciSendCommand(
MCIDEVICEID IDDevice,
UINT uMsg,
DWORD fdwCommand,
DWORD dwParam
);
Pierwszy parametr funkcji, IDDevice, identyfikuje urządzenie będące odbiorcą komunikatu (podobnie jak uchwyt okna, będący pierwszym parametrem SendMessage(), identyfikuje okno docelowe). Wartość identyfikatora zwracana jest w wyniku otwarcia urządzenia komunikatem MCI_OPEN.
Kody błędów MCI
Funkcja mciSendCommand() zwraca 32-bitową wartość, oznaczającą sukces lub niepowodzenie operacji. W przypadku udanego wykonania operacji zwracana jest wartość MMSYSERR_NOERROR, zdefiniowana w pliku mmsystem.h jako zero. Błędy sygnalizowane są poprzez zwrócenie wartości niezerowej. Ponieważ wartości liczbowe są na ogół mało komunikatywne, interfejs MCI udostępnia funkcję mciGetErrorString(), tłumaczącą kod błędu na komunikat w postaci tekstowej (analogicznie działa funkcja Win32 API FormatMessage()). Użycie funkcji mciGetErrorString() pokazano na wydruku 15.15.
Wydruk 15.15. Dekodowanie komunikatów o błędach MCI za pomocą funkcji mciGetErrorString()
bool mciCheck(DWORD AErrorNum, bool AReport)
{
if (AErrorNum == MMSYSERR_NOERROR) return true;
if (AReport)
{
char buffer[MAXERRORLENGTH];
mciGetErrorString(AErrorNum, buffer, MAXERRORLENGTH);
MessageBox(NULL, buffer, "Błąd MCI", MB_OK | MB_ICONERROR);
}
return false;
}
Obsługa urządzeń MCI
Pierwszym krokiem w komunikacji z urządzeniem MCI jest jego otwarcie (zainicjalizowanie). Jak już wspomniano, operację tę realizuje się za pomocą komunikatu MCI_OPEN, przekazując funkcji mciSendCommand() identyfikator urządzenia o wartości zero (IDDevice = NULL). Wynikiem udanego otwarcia urządzenia jest zwrócenie jego identyfikatora w polu wDeviceID struktury typu MCI_OPEN_PARMS. Wskaźnik do tej ostatniej przekazuje się w parametrze dwParam funkcji mciSendCommand(). Typ strukturalny MCI_OPEN_PARMS zdefiniowany jest następująco:
typedef struct tagMCI_OPEN_PARMS {
DWORD dwCallback;
MCIDEVICEID wDeviceID;
LPCSTR lpstrDeviceType;
LPCSTR lpstrElementName;
LPCSTR lpstrAlias;
} MCI_OPEN_PARMS, *PMCI_OPEN_PARMS, *LPMCI_OPEN_PARMS;
Pole lpstrDeviceType jest najczęściej ustawiane na wartość NULL, zaś w polu lpstrElementName umieszcza się wskaźnik do nazwy pliku zawierającego dane. Podejście takie jest najbezpieczniejsze, nakazuje bowiem podsystemowi MCI automatyczne określenie typu urządzenia właściwego dla odtwarzanego zapisu. W przypadku gdy pole lpstrDeviceType zdefiniowane jest jawnie, zawiera ono na ogół wskaźnik do łańcucha identyfikującego typ urządzenia, np. cdaudio dla odtwarzacza CD (inne identyfikatory urządzeń to m.in.: avivideo, dat, digitalvideo, mmmovie, other, overlay, scanner, sequencer, vcr, videodisc i waveaudio). Należy tu jednak podkreślić, że automatyczna identyfikacja typu urządzenia jest metodą preferowaną i powinna być używana zawsze, z wyjątkiem sytuacji, gdy zależy nam na wymuszeniu określonego typu urządzenia. Jest to szczególnie istotne w przypadkach, gdy mamy do czynienia z medium, które nie posiada jeszcze swojego identyfikatora (np. zapisy dźwięku w plikach MP3).
Przykład funkcji wykorzystującej komunikat MCI_OPEN pokazano na wydruku 15.16.
Wydruk 15.16. Wykorzystanie komunikatu MCI_OPEN
bool mciOpen(MCIDEVICEID& ADevID, const char* AFileName,
const char* ADevType, HWND AHCallback, bool AReport)
{
MCI_OPEN_PARMS mop;
memset(&mop, 0, sizeof(MCI_OPEN_PARMS));
mop.dwCallback = reinterpret_cast<DWORD>(AHCallback);
mop.lpstrElementName = const_cast<char*>(AFileName);
mop.lpstrDeviceType = const_cast<char*>(ADevType);
DWORD flags = 0;
if (AHCallback) flags = flags | MCI_NOTIFY;
if (AFileName) flags = flags | MCI_OPEN_ELEMENT;
if (ADevType) flags = flags | MCI_OPEN_TYPE;
if (mciCheck(mciSendCommand(NULL, MCI_OPEN, flags,
reinterpret_cast<DWORD>(&mop)), AReport))
{
ADevID = mop.wDeviceID;
return true;
}
return false;
}
Po otwarciu urządzenia i uzyskaniu jego identyfikatora należy ustalić odpowiedni dla danego zapisu sposób określania czasu (a bardziej ogólnie - położenia odtwarzanego fragmentu w obrębie zapisu - przyp. tłum.). Zagadnienie to jest ściśle związane z typem urządzenia, bowiem określone urządzenia stosują specyficzne metody reprezentowania czasu - dla odtwarzacza CD prawidłową specyfikacją jest np. numer kolejnego utworu (ścieżki), który będzie z kolei całkowicie niezrozumiały dla urządzenia odtwarzającego pliki w formacie WAV. Do ustalenia reprezentacji czasu służy komunikat MCI_SET. O ile tylko jest to możliwe, należy stosować format MCI_FORMAT_MILLISECONDS (czas w milisekundach), rozpoznawany przez wszystkie urządzenia. Przykład użycia komunikatu MCI_SET przedstawiono na wydruku 15.17.
Wydruk 15.17. Wykorzystanie komunikatu MCI_SET
bool mciSetTimeFormat(MCIDEVICEID ADeviceID, DWORD ATimeFormat,
HWND AHCallback, bool AReport)
{
MCI_SET_PARMS msp;
memset(&msp, 0, sizeof(MCI_SET_PARMS));
msp.dwTimeFormat = ATimeFormat;
return mciCheck(mciSendCommand(ADeviceID, MCI_SET,
MCI_SET_TIME_FORMAT,
reinterpret_cast<DWORD>(&msp)), AReport);
}
Po ustaleniu sposobu określania czasu urządzenie jest gotowe do pracy. Komunikaty sterujące jego pracą przypominają metody wykorzystywane do sterowania fizycznymi urządzeniami - do uruchomienia odtwarzania służy komunikat MCI_PLAY, do jego wstrzymania - komunikat MCI_PAUSE, przewijanie realizuje komunikat MCI_SEEK, zatrzymanie - MCI_STOP, zaś zamknięcie urządzenia - komunikat MCI_CLOSE. Pełną listę komunikatów opisano w dokumentach dostępnych pod adresem http://msdn.microsoft.com/library/psdk/multimed/mci_7vvt.htm, zaś ich wykorzystanie ilustruje kod źródłowy przedstawiony na wydruku 15.18. Praktyczne zastosowanie komunikatów sterujących MCI zademonstrowano w projekcie Proj_mp3Demo.bpr, znajdującym się w katalogu MP3Demo na dołączonej do książki płycie CD (obsługę poleceń MCI zawarto w pliku źródłowym MCIManip.cpp).
Wydruk 15.18. Wykorzystanie komunikatów: MCI_PLAY, MCI_SEEK, MCI_PAUSE, MCI_STOP i MCI_CLOSE
bool mciPlay(MCIDEVICEID ADeviceID, DWORD AStart, DWORD AStop)
{
MCI_PLAY_PARMS mpp;
memset(&mpp, 0, sizeof(MCI_PLAY_PARMS));
mpp.dwCallback = reinterpret_cast<DWORD>(AHCallback);
mpp.dwFrom = AStart;
mpp.dwTo = AStop;
DWORD flags = 0;
if ((static_cast<int>(AStart) >= 0) &&
(static_cast<int>(AStop) >= 0))
flags = MCI_FROM | MCI_TO;
return mciCheck(mciSendCommand(ADeviceID, MCI_PLAY|flags, NULL,
reinterpret_cast<DWORD>(&mpp)));
}
bool mciSeek(MCIDEVICEID ADeviceID, DWORD APos)
{
MCI_SEEK_PARMS msp;
memset(&msp, 0, sizeof(MCI_SEEK_PARMS));
msp.dwTo = APos;
return mciCheck(mciSendCommand(ADeviceID, MCI_SEEK, MCI_TO,
reinterpret_cast<DWORD>(&msp)));
}
bool mciPause(MCIDEVICEID ADeviceID)
{
return mciCheck(mciSendCommand(ADeviceID, MCI_PAUSE, 0, 0));
}
bool mciStop(MCIDEVICEID ADeviceID)
{
return mciCheck(mciSendCommand(ADeviceID, MCI_STOP, 0, 0));
}
void mciClose(MCIDEVICEID ADeviceID)
{
mciCheck(mciSendCommand(ADeviceID, MCI_CLOSE, 0, NULL));
}
Odczyt stanu urządzenia
Nie trzeba nikogo przekonywać, że możliwość informowania użytkownika o bieżącym stanie urządzenia jest bardzo przydatna. Typowe informacje prezentowane przez np. program odtwarzacza CD zawierają numer bieżącej ścieżki, jej długość i bieżący czas odtwarzania. Inną grupę tworzą ogólne informacje o stanie urządzenia (np. odtwarzanie w toku, wstrzymane, przerwane itd.), pozwalające określić dostępne w danej chwili funkcje (np. funkcja pauzy nie będzie dostępna po przerwaniu odtwarzania). Informacje o stanie urządzenia można odczytać za pomocą komunikatu MCI_STATUS, którego użycie zademonstrowano na wydruku 15.19.
Wydruk 15.19. Wykorzystanie komunikatu MCI_STATUS
bool mciStatus(MCIDEVICEID ADeviceID, DWORD AQueryGroup,
DWORD AQueryItem, DWORD AQueryTrack, DWORD& AResult)
{
MCI_STATUS_PARMS msp;
memset(&msp, 0, sizeof(MCI_STATUS_PARMS));
msp.dwItem = AQueryItem;
msp.dwTrack = AQueryTrack;
if (mciCheck(mciSendCommand(ADeviceID, MCI_STATUS, AQueryGroup,
reinterpret_cast<DWORD>(&msp)), AReport))
{
AResult = msp.dwReturn;
return true;
}
return false;
}
Użyty w wywołaniu funkcji mciSendCommand() parametr AQueryGroup określa kategorię odczytywanej informacji, zaś parametry AQueryItem i AQueryTrack identyfikują konkretne parametry i wartości. Kategorie i parametry identyfikowane są licznymi stałymi, zdefiniowanymi w pliku mmsystem.h. Na przykład odczytanie liczby utworów (ścieżek) zapisanych na płycie kompaktowej wymaga przekazania w parametrze AQueryGroup stałej MCI_STATUS_ITEM, a w parametrze AQueryItem - stałej MCI_STATUS_NUMBER_OF_TRACKS. Warto przy tym pamiętać, że sposób zwracania informacji o długości i numerze ścieżki (klatki) oraz bieżącej pozycji odtwarzania zależy od używanej przez dane urządzenie reprezentacji czasu. Pełny opis stałych wykorzystywanych do odczytu stanu urządzeń MCI można znaleźć pod adresem http://msdn.microsoft.com/library/psdk/multimed/mci_7vvt.htm.
Odpytywanie urządzeń i powiadomienia systemu MCI
Wiemy już, w jaki sposób pobiera się informacje od urządzenia MCI, jednak w dalszym ciągu nie wiadomo, kiedy to robić. Niestety, obsługę powiadomień potraktowano w specyfikacji MCI nieco po macoszemu, definiując na jej użytek tylko dwa komunikaty: MM_MCINOTIFY i MM_MCISIGNAL (przy czym ostatni dostępny jest wyłącznie dla urządzeń wideo). Nazwa pierwszego komunikatu, MM_MCINOTIFY, brzmi co prawda obiecująco (tj. dość ogólnie), jednak okazuje się, że jest on wysyłany wyłącznie po zakończeniu wykonywania polecenia. Jeśli np. zażądamy odtworzenia pliku WAV za pomocą wywołania funkcji mciPlay() (zobacz wydruk 15.18), komunikat MM_MCINOTIFY zostanie wygenerowany tylko raz, po zakończeniu odtwarzania dźwięku.
Należy pamiętać, że komunikat MM_MCINOTIFY przesyłany jest do okna, którego uchwyt umieszczono w polu dwCallback struktury wskazanej parametrem dwParam w wywołaniu funkcji mciSendCommand(). Oznacza to konieczność uzupełnienia listy parametrów funkcji mciPlay() o uchwyt okna docelowego. Obsługę komunikatu MM_MCINOTIFY zademonstrowano w przykładowym projekcie Proj_mp3Demo.bpr, zawartym w katalogu MP3Demo na dołączonej do książki płycie CD-ROM.
Odebranie i obsłużenie komunikatu MM_MCINOTIFY jest na ogół wystarczająco skuteczną metodą określania i modyfikowania stanu urządzenia. Funkcja obsługi komunikatu może np. aktualizować stan elementów sterujących odtwarzaniem (blokować i udostępniać przyciski). Tym niemniej, zwłaszcza w przypadku stosunkowo szybkozmiennych parametrów (jak np. bieżąca pozycja odtwarzania), wykorzystanie komunikatu MM_MCINOTIFY okazuje się środkiem niewystarczającym. W takich sytuacjach rozwiązaniem może być cykliczne odpytywanie urządzenia (ang. polling), realizowane zwykle w funkcji obsługi komunikatów zegara (timera). W mniej wymagających zastosowaniach można do tego celu wykorzystać standardowy zegar systemowy (komunikaty WM_TIMER); w razie konieczności dokładniejszego odmierzania czasu zaleca się skorzystanie z usługi pomiaru czasu udostępnianej przez podsystem obsługi multimediów. Szczegółowe informacje na ten temat można znaleźć pod adresem http://msdn.microsoft.com/library/psdk/multimed/mmtime_4msz.htm.
Wykorzystanie przedstawionych tu mechanizmów MCI demonstruje projekt prostego odtwarzacza płyt kompaktowych Proj_CDDemo.bpr. Znajduje się on w katalogu CDDemo na dołączonej do książki płycie CD-ROM.
Uwagi końcowe
Możliwości funkcjonalne podsystemu MCI i zakres obsługiwanych przezeń formatów danych zależą od zestawu zainstalowanych w systemie koderów i dekoderów. Wiele standardowych koderów-dekoderów instaluje się wraz z programem Odtwarzacza multimedialnego (Media Player) i jego uaktualnieniami, można zatem założyć, że jeśli jakiś plik daje się odtworzyć za pomocą Odtwarzacza, odpowiedni format będzie obsługiwany przez podsystem MCI (nawiasem mówiąc, program ten w znacznym stopniu opiera się właśnie na funkcjach MCI).
Na dołączonej do książki płycie CD-ROM zamieszczono dwa projekty ilustrujące wykorzystanie funkcji MCI do odtwarzania plików multimedialnych - Proj_MP3Demo.bpr (w katalogu MP3Demo) oraz Proj_VideoDemo.bpr (w katalogu VideoDemo). Pierwszy z nich demonstruje użycie mechanizmów MCI do odtwarzania zapisów dźwiękowych w formacie MP3 oraz „surowych” plików próbek dźwiękowych (opartych na ogólnym standardzie RIFF); drugi pozwala odtwarzać zapisy wideo w formatach AVI i MPEG. Jeszcze raz należy podkreślić, że możliwości obu programów zależą od zestawu koderów-dekoderów zainstalowanych w systemie; dodatkowe informacje można znaleźć w komentarzach umieszczonych w kodzie źródłowym.
Interfejs MCI jest prawdopodobnie najłatwiejszym narzędziem do manipulowania danymi i urządzeniami multimedialnymi, jednak jego możliwości funkcjonalne są dość ograniczone. Na przykład podczas odtwarzania zapisu z wykorzystaniem funkcji MCI nie ma możliwości odwołania się do „surowego” strumienia danych odczytanych z pliku (lub płyty), co znacząco ogranicza możliwości obróbki zapisu (np. korekcji charakterystyki) i jego konwersji na inne formaty. W takich sytuacjach pozostaje odwołać się do innych, bardziej złożonych mechanizmów manipulowania danymi multimedialnymi, jak np. narzędzia do przetwarzania spróbkowanych zapisów dźwiękowych (Waveform Audio Interface).
Odtwarzanie spróbkowanych zapisów dźwięku
W skład podsystemu obsługi multimediów Windows wchodzi grupa funkcji określanych mianem Waveform Audio Interface, pozwalających na przetwarzanie danych dźwiękowych zapisanych w postaci spróbkowanej. Funkcje te umożliwiają bezpośredni dostęp do buforów zawierających zapis dźwięku, co daje możliwość jego konwersji na inne formaty. Omawiany mechanizm wykorzystywany jest np. w licznych programach odtwarzających i przetwarzających zapisy dźwięku w formacie MP3. Bezpośredni dostęp do spróbkowanego zapisu jest niezbędny także w przypadku narzędzi do cyfrowego przetwarzania sygnału (jak np. moduły wizualizacyjne wchodzące w skład wielu odtwarzaczy plików dźwiękowych).
Jak pamiętamy z poprzedniego punktu, w przypadku użycia interfejsu MCI wystarczyło umieścić nazwę odtwarzanego pliku w polu lpstrElementName struktury MCI_OPEN_PARMS i wywołać funkcję mciSendCommand(), automatycznie wykonującą czynności związane z otwarciem i odczytaniem pliku. Mechanizmy przetwarzania zapisu spróbkowanego nie korzystają z takiego rozwiązania, co pociąga za sobą konieczność „ręcznego” obsłużenia operacji wejścia-wyjścia. Jedną z możliwości jest wykorzystanie dostępnej w bibliotece VCL klasy TFileStream, jednak „niskopoziomowość” takiego podejścia zmusza programistę do zajęcia się dekodowaniem zapisu (czyli wymaga znajomości formatu RIFF, w którym kodowane są pliki WAV). Alternatywą jest skorzystanie z systemowych mechanizmów obsługi plików multimedialnych, jednak dostępne funkcje są na tyle abstrakcyjne, iż rozwiązanie to, choć ogólnie poprawne, jest w praktyce niemal tak samo skomplikowane, jak metoda „ręczna”. Na szczęście interfejs programowy Windows udostępnia jeszcze inne narzędzie - mowa tu o grupie funkcji i makrodefinicji skupionych w bibliotece AVIFile, a przeznaczonych do obsługi plików zawierających zapisy dźwiękowe i wizyjne w formatach RIFF (nazwy większości funkcji i makr rozpoczynają się przedrostkiem AVI - przyp. tłum.). Kod funkcji zawarto w pliku biblioteki dynamicznej avifil32.dll, zaś odpowiednie deklaracje można znaleźć w pliku nagłówkowym vfw.h. W dalszej części naszej dyskusji skupimy się zatem na możliwościach wykorzystania mechanizmów AVIFile.
Otwieranie i zamykanie plików próbek dźwiękowych
Mianem „pliku próbek dźwiękowych” (ang. waveform audio file) będziemy tu skrótowo określać plik zawierający przebieg czasowy sygnału dźwiękowego wyrażony w postaci spróbkowanej (w formie zapisu PCM - przyp. tłum.) i zapisany w formacie RIFF. Ten nieco formalny opis jest na nasze potrzeby całkowicie wystarczający - jak wspomniano wcześniej, użycie funkcji i makrodefinicji AVIFile zwalnia programistę od konieczności zajmowania się szczegółami formatu RIFF.
Przed rozpoczęciem korzystania z funkcji AVIFile konieczne jest zainicjalizowanie działania biblioteki wywołaniem funkcji AVIFileInit(), zaś do zakończenia pracy i zwolnienia zasobów służy funkcja AVIFileExit(). Dostęp do plików i strumieni w bibliotece AVIFile wykorzystuje mechanizmy OLE, czego konsekwencją jest konieczność zaprogramowania obsługi błędów. Dla funkcji AVIFile, zwracających wartości typu STDAPI, można w tym celu wykorzystać makro SUCCEEDED, co pokazano poniżej:
bool wavCheck(HRESULT AErrorCode)
{
return SUCCEEDED( --> AErrorCode[Author:ts] );
}
Przegląd mechanizmów AVIFile rozpoczniemy od najprostszego zadania, czyli otwarcia pliku próbek dźwiękowych. Służy do tego funkcja AVIFileOpen().
bool wavOpenFile(PAVIFILE &ApFile, const char* AFileName,
unsigned int AMode)
{
return wavCheck(AVIFileOpen(&ApFile, AFileName, AMode, NULL));
}
Parametr AMode określa tryb dostępu do pliku; stałe opisujące tryb dostępu są identyczne z używanymi przez funkcję API OpenFile() (np. OF_READ - otwarcie pliku itd.). W parametrze ApFile zwracany jest wskaźnik (typu PAVIFILE) do interfejsu OLE, udostępniającego strumień danych. Ponieważ zamknięcie interfejsu wymaga wyzerowania licznika odwołań, po zakończeniu korzystania z pliku należy wywołać funkcję AVIFileRelease() (funkcja ta zastąpiła starszą AVIFileClose() - przyp. tłum.), zwalniającą interfejs i dekrementującą licznik:
void wavCloseFile(PAVIFILE &ApFile)
{
AVIFileRelease(ApFile);
ApFile = NULL;
}
Obsługa strumieni danych dźwiękowych
Otwarcie i zamknięcie pliku próbek dźwiękowych jest zadaniem banalnym, jednak dostęp do zawartości pliku wymaga odwołania się do interfejsu OLE, udostępniającego strumień danych, co jest już nieco bardziej skomplikowane.
Jak powiedziano wcześniej, wskaźnik do interfejsu udostępniającego zawartość pliku zawarty jest w zmiennej typu PAVIFILE. Analogicznie wskaźnik do interfejsu strumienia danych można uzyskać za pomocą funkcji AVIFileGetStream(), zwracającej wskaźnik do zmiennej typu PAVISTREAM. Zwolnienia interfejsu dokonuje się za pomocą wywołania funkcji AVIStreamRelease(), zaś użycie obu funkcji zademonstrowano na wydruku 15.20.
Wydruk 15.20. Przykłady użycia funkcji AVIFileGetStream() i AVIStreamRelease()
bool wavOpenStream(PAVISTREAM& ApStream, PAVIFILE ApFile)
{
return wavCheck(AVIFileGetStream(ApFile, &ApStream,
streamtypeAUDIO, 0));
}
void wavCloseStream(PAVISTREAM& ApStream)
{
AVIStreamRelease(ApStream);
ApStream = NULL;
}
Wykorzystanie interfejsu strumienia danych AVI przypomina nieco użycie klasy TMemoryStream lub klas pochodnych od basic_streambuf, jednak programista ma do dyspozycji kilka dodatkowych funkcji, przeznaczonych wyłącznie do manipulowania zawartością plików AVI i plików próbek dźwiękowych. Przykładem może tu być funkcja AVIStreamInfo(), pozwalająca uzyskać informacje o zawartości strumienia, zwracane w postaci struktury typu AVISTREAMINFO:
bool wavGetStreamInfo(PAVISTREAM ApStream, AVISTREAMINFO& AStreamInfo)
{
return wavCheck(AVIStreamInfo(ApStream, &AStreamInfo,
sizeof(AVISTREAMINFO)));
}
Przetwarzanie strumienia próbek dźwiękowych wymaga znajomości parametrów ich zapisu. Odpowiednie informacje przekazywane są w strukturze typu WAVEFORMATEX, zawierającej informacje na temat sposobu kodowania próbek (typ zapisu, częstotliwość próbkowania itp.). Odczytanie parametrów strumienia realizuje funkcja AVIStreamReadFormat(), której trzecim parametrem jest właśnie wskaźnik do struktury typu WAVEFORMATEX. Uzupełnieniem wspomnianej funkcji jest makro AVIStreamFormatSize(), ustalające rozmiar bufora niezbędnego do przechowania danych o parametrach strumienia. Wykorzystanie obu narzędzi zademonstrowano na wydruku 15.21.
Wydruk 15.21. Wykorzystanie funkcji AVIStreamReadFormat() i makra AVIStreamFormatSize()
long wavCalcFormatStructSize(PAVISTREAM ApStream)
{
long required_bytes = 0;
AVIStreamFormatSize(ApStream, 0, &required_bytes);
return required_bytes;
}
bool wavReadFormatStruct(PAVISTREAM ApStream,
WAVEFORMATEX& ApFormatStruct)
{
memset(&ApFormatStruct, 0, sizeof(WAVEFORMATEX));
long size = wavCalcFormatStructSize(ApStream);
return wavCheck(AVIStreamReadFormat(ApStream, 0, &ApFormatStruct,
&size));
}
Biblioteka AVIFile udostępnia oczywiście także funkcje pozwalające odczytywać zawartość strumienia (podobne do metod TMemoryStream::Read()i basic_ifstream::read()). Nikogo nie trzeba przekonywać, że to właśnie mechanizmy dostępu do bufora strumienia są kluczowym elementem aplikacji odtwarzającej spróbkowany zapis dźwięku. Przesyłanie bloków danych ze strumienia do zdefiniowanego w programie bufora realizuje funkcja AVIStreamRead(), zaś zadanie odwrotne (przesyłanie danych z bufora do strumienia próbek) funkcja AVIStreamWrite(). Użycie obu funkcji przedstawiono na wydruku 15.22.
Wydruk 15.22. Odczyt i zapis danych dźwiękowych z i do strumienia
long wavCalcBufferSize(PAVISTREAM ApStream)
{
long required_bytes = 0;
AVISTREAMINFO StreamInfo;
if (wavGetStreamInfo(ApStream, StreamInfo))
{
required_bytes = StreamInfo.dwLength * StreamInfo.dwScale;
}
return required_bytes;
}
long wavReadStream(PAVISTREAM ApStream, long AStart, long ANumBytes,
char* ABuffer)
{
long bytes_read = 0;
AVISTREAMINFO StreamInfo;
if (wavGetStreamInfo(ApStream, StreamInfo))
{
long num_samples = static_cast<float>(ANumBytes) /
static_cast<float>(StreamInfo.dwScale);
AVIStreamRead(ApStream, AStart, num_samples, ABuffer, ANumBytes,
&bytes_read, NULL);
}
return bytes_read;
}
long wavWriteStream(PAVISTREAM ApStream, long AStart, long ANumBytes,
char* ABuffer)
{
long bytes_written = 0;
AVISTREAMINFO StreamInfo;
if (wavGetStreamInfo(ApStream, StreamInfo))
{
long num_samples = static_cast<float>(ANumBytes) /
static_cast<float>(StreamInfo.dwScale);
AVIStreamWrite(ApStream, AStart, num_samples, ABuffer, ANumBytes,
AVIIF_KEYFRAME, NULL, &bytes_written);
}
return bytes_written;
}
Funkcja wavCalculateBufferSize() jest odpowiednikiem właściwości Size klasy TMemoryStream. Warto przy okazji zauważyć, że wewnętrzna zależność interfejsów strumienia i pliku powoduje, iż dane umieszczane w strumieniu są w chwili jego zamknięcia automatycznie zapisywane w skojarzonym pliku. Oznacza to, że chcąc manipulować wyłącznie zawartością strumienia, należy utworzyć jego duplikat niezwiązany z plikiem źródłowym. Więcej informacji na ten temat można znaleźć pod adresem http://msdn.microsoft.com/library/psdk/multimed/avifile_4alv.htm.
W ten sposób opisaliśmy podstawowe metody dostępu do zawartości plików próbek dźwiękowych. Obecnie powrócimy do zagadnień związanych z przetwarzaniem i odtwarzaniem spróbkowanych zapisów dźwięku. Podobnie jak w przypadku interfejsu MCI, deklaracje odpowiednich funkcji i struktur zawarto w pliku nagłówkowym MMSYSTEM.H, zaś ich definicje - w pliku biblioteki dynamicznej WINMM. --> DLL[Author:ts] .
Otwieranie i zamykanie urządzeń odtwarzających spróbkowane zapisy dźwięku
Mechanizm przetwarzania spróbkowanych zapisów dźwięku obejmuje funkcję waveOutOpen(), umożliwiającą otwarcie urządzenia odtwarzającego. Jej trzeci parametr to wskaźnik do odpowiednio wypełnionej struktury WAVEFORMATEX, zawierającej parametry zapisu, zaś w parametrze pierwszym przekazywany jest wskaźnik do zmiennej typu HWAVEOUT, zawierającej uchwyt otwartego urządzenia. Do zamknięcia urządzenia służy, jak łatwo się domyślać, funkcja waveOutClose(). Wykorzystanie obu funkcji przedstawiono na wydruku 15.23.
Wydruk 15.23. Otwarcie i zamknięcie urządzenia odtwarzającego spróbkowany zapis dźwięku
bool wavPlayOpen(HWAVEOUT& AHWavOut, long ACallback,
DWORD ANotifyInstance, DWORD AOpenFlags,
WAVEFORMATEX& AFormatStruct)
{
return wavCheck(waveOutOpen(&AHWavOut, WAVE_MAPPER, &AFormatStruct,
ACallback, ANotifyInstance, AOpenFlags));
}
void wavPlayClose(HWAVEOUT AHWavOut)
{
waveOutReset(AHWavOut);
waveOutClose(AHWavOut);
}
Parametry ACallback, ANotifyInstance i AOpenFlags pozwalają określić sposób powiadamiania o zakończeniu odtwarzania; zagadnieniem tym zajmiemy się za chwilę. Do ustalenia zawartości struktury AFormatStruct można wykorzystać funkcję wavReadFormatStruct(), przedstawioną na wydruku 15.21.
Po pomyślnym otwarciu urządzenia można rozpocząć odtwarzanie wywołaniem funkcji waveOutWrite(). Jej parametrami są uchwyt urządzenia oraz wskaźnik do odtwarzanych danych. Ponieważ funkcja waveOutWrite() wymaga podania kilku dodatkowych parametrów odtwarzanego zapisu, informacje o tym ostatnim przekazuje się w postaci struktury typu WAVEHDR, zawierającej m.in. pola lpData i dwBufferLength, które przechowują adres bloku odtwarzanych próbek oraz informację o jego rozmiarze. Względy techniczne powodują, iż zawartość struktury typu WAVEHDR, przekazywanej do funkcji waveOutWrite(), musi zostać wcześniej „przygotowana” za pomocą funkcji waveOutPrepareHeader(), zaś po zakończeniu odtwarzania konieczne jest użycie funkcji waveOutUnprepareHeader(). Dopiero po wywołaniu tej ostatniej można zwolnić pamięć zajętą przez bufor zapisu. Użycie obu funkcji przedstawiono na wydruku 15.24.
Wydruk 15.24. Rozpoczęcie i zakończenie odtwarzania zapisu dźwięku
bool wavPlayBegin(HWAVEOUT AHWavOut, WAVEHDR& AWavHdr)
{
if (wavCheck(waveOutPrepareHeader(AHWavOut, &AWavHdr,
sizeof(WAVEHDR))))
{
return wavCheck(waveOutWrite(AHWavOut, &AWavHdr,
sizeof(WAVEHDR)));
}
return false;
}
void wavPlayEnd(HWAVEOUT AHWavOut, WAVEHDR& AWavHdr)
{
waveOutReset(AHWavOut);
waveOutUnprepareHeader(AHWavOut, &AWavHdr, sizeof(WAVEHDR));
}
Spróbujmy obecnie połączyć opisane tu mechanizmy w całość, tworząc prosty program przykładowy, odtwarzający zawartość wybranego pliku próbek dźwiękowych. Przypomnijmy raz jeszcze, iż ze względu na brak mechanizmów bezpośredniego odczytu danych dźwiękowych z pliku należy posłużyć się wywołaniami funkcji z grupy AVIFile. Po odczytaniu danych ze strumienia do bufora można już wykorzystać funkcje przedstawione na wydrukach 15.23 i 15.24. Kod źródłowy głównego modułu programu przedstawiono na wydruku 15.25, zaś kompletny projekt o nazwie Proj_DSPDemo.bpr można znaleźć w katalogu DSPDemo na dołączonej do książki płycie CD-ROM.
Wydruk 15.25. Odczytanie i odtworzenie pliku próbek dźwiękowych
const long MAX_BLOCK_SIZE = 6000 * 1024;
if (!OpenDialog1->Execute()) return;
const char* filename = OpenDialog1->FileName.c_str();
PAVIFILE pFile = NULL;
if (wavOpenFile(pFile, filename, OF_READ))
{
PAVISTREAM pStream = NULL;
if (wavOpenStream(pStream, pFile))
{
long block_size = wavCalcBufferSize(pStream);
if (block_size < MAX_BLOCK_SIZE)
{
char* buffer = new char[block_size];
if(wavReadStream(pStream, 0, block_size, buffer) == block_size)
{
// przetwarzanie zawartości bufora, np.:
if (RadioGroup1->ItemIndex == 1)
QuantizeBuffer(buffer, block_size);
WAVEFORMATEX FormatStruct;
if (wavReadFormatStruct(pStream, FormatStruct))
{
HWAVEOUT HWavOut;
if(wavPlayOpen(HWavOut,NULL,NULL,NULL,FormatStruct))
{
WAVEHDR WavHdr;
memset(&WavHdr, 0, sizeof(WAVEHDR));
WavHdr.lpData = buffer;
WavHdr.dwBufferLength = block_size;
if (wavPlayBegin(HWavOut, WavHdr))
{
ShowMessage("Odtwarzam: "+AnsiString(filename));
wavPlayEnd(HWavOut, WavHdr);
}
wavPlayClose(HWavOut);
}
}
}
delete [] buffer;
}
else ShowMessage("Plik jest zbyt duży - zwiększ --> wartość [Author:ts] MAX_BLOCK_SIZE");
wavCloseStream(pStream);
}
wavCloseFile(pFile);
}
void QuantizeBuffer(char* ABuffer, long ABufferLength)
{
short int min_val = 0, max_val = 0;
for (int index = 0; index < ABufferLength; ++index)
{
if (ABuffer[index] < min_val) min_val = ABuffer[index];
if (ABuffer[index] > max_val) max_val = ABuffer[index];
}
for (int index = 0; index < ABufferLength; ++index)
{
if (ABuffer[index] < 0) ABuffer[index] = min_val;
if (ABuffer[index] > 0) ABuffer[index] = max_val;
}
}
Warto zwrócić uwagę na ograniczenie rozmiaru bloku danych (pierwszy wiersz wydruku), pozwalające zabezpieczyć się przed nadmierną konsumpcją zasobów systemowych w trakcie działania programu. Odtwarzanie zawartości pliku sprowadza się w takiej sytuacji do cyklicznego odczytywania fragmentów jego zawartości ze strumienia i przesyłania ich do urządzenia wyjściowego. Pobranie kolejnego bloku danych ze strumienia i przekazanie ich do sterownika urządzenia wyjściowego powinno nastąpić w chwili zakończenia odtwarzania bieżącego bloku. Ponieważ jednak funkcja waveOutWrite() zwraca sterowanie do wywołującego programu natychmiast po przyjęciu bloku danych, nie możemy wykorzystać jej wprost do ustalenia momentu zakończenia odtwarzania. Rozwiązaniem tego problemu jest użycie parametru ACallback, pozwalającego na przekazanie do funkcji waveOutOpen() informacji o mechanizmie (uchwytu okna, zdarzenia, identyfikatora wątku lub adresu funkcji zwrotnej) realizującym powiadamianie o zakończeniu przetwarzania. Dodatkowe informacje na ten temat można znaleźć pod adresem http://msdn.microsoft.com/library/psdk/multimed/mmmsg_6g2t.htm.
Uwagi końcowe
Przedstawione tu funkcje, makrodefinicje, komunikaty i struktury danych dają podstawowe pojęcie o metodach przetwarzania zapisów multimedialnych, jednak omówienie to nie jest oczywiście wyczerpujące. Dostępne w systemie Windows mechanizmy obejmują wiele innych zagadnień, jak np. odtwarzanie zapisów MIDI i plików w formacie AVI. Należy także pamiętać, że zarówno interfejs MCI jak i podsystem przetwarzania próbek dźwiękowych umożliwiają nie tylko odtwarzanie, ale i zapisywanie dźwięku. Więcej informacji na ten temat można znaleźć pod adresem http://msdn.microsoft.com/library/psdk/multimed/mixer_10xf.htm. Zawarte tam wiadomości mogą przydać się zwłaszcza osobom zainteresowanym sterowaniem głośnością zapisu i odtwarzania w poszczególnych kanałach audio.
Podsumowanie
Omówione w tym rozdziale techniki i rozwiązania pozwalają wyposażyć aplikację w mechanizmy obsługi danych multimedialnych. W pierwszej części rozdziału przedstawiliśmy podstawowe funkcje interfejsu GDI, realizującego w systemie Windows operacje graficzne. Podstawowe klasy biblioteki VCL obsługujące rysowanie na graficznych urządzeniach wyjściowych to: TCanvas, TBrush, TPen i TFont.
W następnej kolejności przedstawiliśmy kilka rastrowych formatów graficznych oraz obsługujące je klasy, m.in. TBitmap i TJPEGImage. Omówiliśmy także podstawowe algorytmy przetwarzania dwuwymiarowych obrazów rastrowych i sposób ich realizacji w C++Builderze. Przedstawione tu metody można rozbudowywać i uzupełniać, tworząc bardziej skomplikowane narzędzia do przetwarzania obrazu.
W ostatniej części rozdziału zaprezentowaliśmy zagadnienia związane z odtwarzaniem zawartości zapisów dźwiękowych i wideo z wykorzystaniem interfejsu MCI, przedstawiając podstawowe komunikaty MCI i sposób ich wykorzystania do sterowania urządzeniami multimedialnymi. Opisaliśmy także krótko sposób wykorzystania funkcji z biblioteki AVIFile oraz funkcji przetwarzających spróbkowane zapisy dźwięku do odtwarzania plików dźwiękowych.
W dalszej części rozdziału powierzchnię, na której odbywa się rysowanie, będziemy określali nazwą „płótno” (ang. canvas) - przyp. tłum.
Z literatury dostępnej w języku polskim można polecić książkę Viktora Totha Programowanie Windows 98/NT. Księga eksperta (Helion, Gliwice 1998) - przyp. tłum.
W rozdziale tym skoncentrujemy się na obrazach zapisywanych w postaci rastrowej, tj. map bitowych, należy jednak pamiętać o istnieniu drugiej obszernej grupy formatów - formatach wektorowych, w których obraz reprezentowany jest nie jako matryca punktów, lecz zestaw linii i obiektów stanowiących „instrukcję” jego odtworzenia. W dalszej dyskusji pojęcie „obraz” będzie oznaczało obraz zapisany w formacie rastrowym - przyp. tłum.
Wartość piksela wyświetlana jest w formacie dziesiętnym, a przez to niezbyt czytelna. Wykorzystanie formatu szesnastkowego pozwoliłoby znacznie lepiej uwidocznić wartości składowych RGB; odpowiednie modyfikacje pozostawiamy Czytelnikowi - przyp. tłum.
Użycie w programie wartości 128 (a nie 127) wynika z zapisu operatorów nierówności w podanym powyżej wzorze - przyp. tłum.
Resource Interchange File Format - ogólny standard zapisu danych multimedialnych w postaci bloków opisanych nagłówkami (znacznikami), eliminujący część problemów związanych z przenośnością i zgodnością formatów - przyp. tłum.
1
użycie przez autorów określenia „some applications” to ogromny eufemizm.
o drukowaniu w rozdziale nie ma mowy (wbrew oryginałowi)
pliku GDI.DLL nie znalazłem ani w Windows 98, ani w NT (literówka?)
oryginał: hD (literówka)
oryginał: „definicje są zawarte w pliku... ale nie obejmują wszystkich funkcji GDI” - jednoz drugim nie ma związku.
w oryginale „If” (literówka)
oryginał „Rectange” (literówka)
w oryginale 255x255x255
wbrew oryginałowi, menu nie pozwala na zmianę stylu linii wskazówek
funkcja TransparentStretchBlt() nie jest zdefiniowana w interfejsie GDI; nie eksportuje jej także biblioteka GDI32 - usuwam.
zmieniam $.... na 0x..., bo taki zapis obowiązuje w C ($... to Pascal).
w oryginale tylko sqrt(), co zresztą nie zmienia konsekwencji.
w oryginale to jest rysunek, ale w polskiej literaturze technicznej wzory numeruje się oddzielnie albo wcale, więc usuwam numer rysunku. Pozostałe rysunki ulegają przenumerowaniu o jeden w dół.
wzór w oryginale jest nieścisły, uzupełniam zapis
jak poprzednia uwaga - usuwam numer z równania, od tej chwili rysunki są przenumerowane o dwa w dół
jak poprzednia uwaga - od tej chwili rysunki są przenumerowane o trzy w dół
jak poprzednia uwaga - od tej chwili rysunki są przenumerowane o cztery w dół
oryginał: AerrorCode (literówka)
w oryginale jest „respectively”, ale brak inf. o pliku DLL.
uwaga, złamanie wiersza