출처 : http://www.winapi.co.kr/project/library/gdiplus/1-6.htm
이미지
가.이미지 클래스
GDI+의 큰 장점 중의 하나가 여러 가지 포맷의 그림 파일을 다룰 수 있다는 점이다. 이전의 GDI는 오로지 윈도우즈의 기본 이미지 포맷인 BMP 파일만 다룰 수 있어 무척 불편했다. BMP는 압축률이 좋지 않으며 이미지 용량이 너무 커서 부담없이 사용하기 어렵다. 웹, 디지털 카메라의 표준 포맷인 JPG을 출력해야 할 경우 LeadTools같은 별도의 상용 라이브러리를 사용해야만 했다.
하지만 이런 불편함이 드디어 GDI+에 의해 말끔히 해결되었는데 JPG, GIF뿐만 아니라 TIFF, PNG 등의 자주 사용되는 그래픽 포맷을 모두 지원하므로 별도의 외부 라이브러리없이도 수준높은 그래픽 처리가 가능해졌다. JPG 출력이 가능하다는 것은 GDI+의 가장 매력적인 기능이며 또한 GDI+를 사용해야 하는 가장 현실적인 이유이기도 하다.
GDI+의 기본 이미지 클래스는 Image이며 이 클래스에 이미지와 관련된 대부분의 기능들이 캡슐화되어 있다. Image로부터 좀 더 전문적이고도 섬세한 처리가 가능한 Bitmap, MetaFile 등의 특수한 클래스들이 파생된다. Image 클래스의 생성자는 다음 두 가지가 준비되어 있다.
Image(const WCHAR *filename, BOOL useEmbeddedColorManagement);
Image(IStream *stream, BOOL useEmbeddedColorManagement);
하드 디스크상의 파일로부터 또는 메모리의 스트림으로부터 이미지를 읽을 수 있다. 첫 번째 인수로 이미지 파일의 절대 경로 또는 상대 경로를 전달한다. GDI+에서 사용하는 문자열은 모두 유니코드이므로 반드시 유니코드 문자열을 주어야 한다. 두 번째 인수는 이미지 파일에 포함되어 있는 색상 관리 정보를 사용하여 색상 보정을 할 것인지 아닌지를 지정하는데 생략할 경우 FALSE가 적용된다. 다음 코드는 JPG 파일을 읽어 화면으로 출력한다.
예 제 : DrawImage |
Image I(L"노을.jpg");
G.DrawImage(&I,0,0);
현재 디렉토리에서 "노을.jpg" 파일을 읽어와 작업 영역의 (0,0)에 이미지 크기대로 출력한다. JPG 파일의 내부는 굉장히 복잡하지만 아주 간단하게 파일을 읽어서 출력할 수 있다. 생성자가 파일을 읽어 압축을 해제하고 출력에 필요한 모든 예비 동작을 다 해 놓을 것이다. 또한 I가 지역 변수로 선언되어 있으므로 따로 해제할 필요도 없다. I 변수가 범위를 벗어날 때 파괴자가 자동으로 호출되어 필요한 정리 작업을 수행하기 때문이다.
단 두 줄의 코드 "읽어", "그려" 만으로 고도의 기술로 압축되어 있는 그래픽 파일이 출력된다. 이렇게 간단한 방법으로 JPG 파일을 출력할 수 있도록 하는 것이 객체 지향의 힘이라고 할 수 있다. 단 이미지 파일을 읽는 중에 실패할 가능성이 높으므로 에러 처리는 반드시 해야 한다. 생성자는 별도의 리턴값을 반환할 수 없으므로 GetLastStatus 함수로 최종 작업을 결과를 점검해 보고 이 값이 Ok가 아니면 이미지 파일 읽기에 실패한 것이다.
Image I(L"노을.jpg");
if (I.GetLastStatus() != Ok) {
MessageBox(hWndMain,TEXT("이미지 파일을 읽을 수 없습니다."),
TEXT("에러"),MB_OK);
return;
}
G.DrawImage(&I,0,0);
위 코드에서는 OnPaint에서 이미지 객체를 생성하고 있는데 이는 어디까지나 예제이기 때문에 코드를 간단하게 작성하기 위해 그렇게 한 것이지 실제 코드에서는 이미지를 미리 읽어 놓아야 그리기 속도가 빨라진다. WM_PAINT 메시지에서 LoadBitmap을 호출하지 말아야 하는 이유와 똑같다.
Image 클래스에는 이미지를 관리하는 많은 멤버 함수들이 포함되어 있다. 주요 멤버 함수들 중 쉽게 이해할 수 있는 것들에 대해서만 도표로 간단하게 정리해 보고 나머지는 하나씩 실습해 보도록 하자.
멤버 함수 |
설명 |
FromFile |
디스크의 파일로부터 이미지 파일을 읽어들인다. |
FromStream |
스트림으로부터 이미지를 읽어들인다. |
GetWidth |
이미지의 폭을 구한다. |
GetHeight |
이미지의 높이를 구한다. |
GetType |
이미지의 타입(메타, 비트맵)을 구한다. |
RotateFlip |
이미지를 회전 또는 반전시킨다. |
GetRawFormat |
이미지의 포맷을 구한다. |
Save |
이미지를 디스크의 파일로 저장한다. |
FromFile 정적 함수를 사용하면 실행중에 언제든지 이미지 파일을 새로 읽을 수 있다. Image의 포인터만 선언한 후 이 함수를 호출하면 한 포인터 변수로 이미지를 바꿔 가면서 사용할 수 있다. 다음 코드는 앞의 코드와 동일하게 동작한다. FromFile 함수는 Image 객체를 새로 생성한 후 그 포인터를 리턴하므로 다 사용한 후 delete는 직접 해야 한다.
Image *pI;
pI=Image::FromFile(L"노을.jpg");
G.DrawImage(pI,0,0);
delete pI;
디스크상의 파일에서 이미지를 읽는 것은 코드가 간단해서 좋기는 하지만 여러 가지 문제가 있다. 이미지 파일을 실행 파일과 같이 배포해야 하는 번거로움이 있고 파일이 누락되거나 손상되면 프로그램의 안전성에도 좋지 않은 영향을 미친다. 또 실행중에 디스크를 읽어야 하므로 속도상으로도 불리하다. 프로그램 실행에 꼭 필요한 이미지는 아예 리소스로 실행 파일에 박아 버리는 것이 안전하다. 실습을 위해 리소스 스크립트를 만들고 Pride640.jpg 파일을 커스텀 리소스로 임포트해 보자.
리소스 타입 이름은 JPG로 주고 ID는 디폴트로 주어지는 IDR_JPG1을 받아들인다. 사용자 정의 리소스를 읽어 스트림 객체를 만들고 이 객체를 Image의 생성자로 전달한다. 코드는 다소 길고 복잡하다.
#include "resource.h"
void OnPaint(HDC hdc)
{
Graphics G(hdc);
HRSRC hResource = FindResource(g_hInst, MAKEINTRESOURCE(IDR_JPG1),
TEXT("JPG"));
if (!hResource) return;
DWORD imageSize = SizeofResource(g_hInst, hResource);
HGLOBAL hGlobal = LoadResource(g_hInst, hResource);
LPVOID pData = LockResource(hGlobal);
HGLOBAL hBuffer = GlobalAlloc(GMEM_MOVEABLE,imageSize);
LPVOID pBuffer = GlobalLock(hBuffer);
CopyMemory(pBuffer,pData,imageSize);
GlobalUnlock(hBuffer);
IStream *pStream;
HRESULT hr=CreateStreamOnHGlobal(hBuffer,TRUE,&pStream);
Image I(pStream);
pStream->Release();
if (I.GetLastStatus() != Ok) return;
G.DrawImage(&I,0,0);
}
커스텀 리소스를 읽는 코드는 일반적인 Win32 코드이다. 전역 메모리 핸들로부터 스트림 객체를 만들기 위해서는 이동 가능하고 비워지지 않는 속성을 가져야 하는데 사용자 정의 리소스는 이 요건을 만족하지 못하므로 사본을 따로 만들었다. 이 핸들을 CreateStreamOnHGlobal 함수로 전달하면 스트림 객체가 생성되며 생성된 스트림을 Image의 생성자로 전달하면 된다. 이미지 객체를 만든 후에는 스트림을 파괴하며 이때 전역 핸들도 같이 할당 해제된다. 실행해 보면 예쁜 차 사진이 출력된다.
이 사진은 실행 파일에 같이 포함되어 있는 것이므로 배포상의 문제가 없으며 읽는 속도도 빠르다. 별도의 사진 파일을 배포할 필요없이 실행 파일만으로도 단독 실행된다. 참고로 위 코드는 공식 문서상에서 예제를 찾지 못해 아는 범위내에서 대충 작성해 본 것이라 신뢰성이 떨어진다. 또 에러 처리도 거의 하지 않고 있으므로 실무에서 사용할 때는 적당히 손을 좀 본 후 사용하기 바란다.
나.이미지 출력
다른 작도 함수들과 마찬가지로 이미지를 출력하는 함수도 Graphics 클래스의 멤버 함수로 포함되어 있는데 이름은 모두 DrawImage이지만 형식이 다른 16개의 함수가 오버로딩되어 있다. 이 중 실수 버전을 빼면 정수 버전만 8개가 있는 셈이다. 모든 함수의 첫 번째 인수는 항상 출력 대상 이미지의 포인터이다. 쉬운 순서대로 이미지를 출력하는 함수들에 대해 실습해 보자. 다음 함수는 지정한 좌표에 이미지를 출력하는데 좌표를 직접 전달할 수도 있고 Point 객체의 레퍼런스를 전달할 수도 있다.
Status DrawImage(Image *image, INT x, INT y);
Status DrawImage(Image *image, const Point &point);
위치만 지정했으므로 이미지의 일부만 출력하거나 확대, 축소하는 기능은 없다. 이미지를 있는 그대로 출력하고자 할 때 가장 간편하게 사용할 수 있는 함수이며 이미 앞에서 사용해 보았다. 그러나 이 간단해 보이는 함수에도 함정이 있다. 이 함수는 이미지의 원래 크기를 사용하지 않고 해상도를 고려하여 이미지를 적당히 확대, 축소한다.
이미지 파일에는 여러 가지 정보가 기록되는데 정확한 출력을 위해 이미지를 만든 장치의 해상도가 기록되어 있다. 만약 이미지에 기록된 해상도가 출력 장비의 해상도와 다르다면 두 해상도의 차이만큼 이미지를 스케일링하여 가급적이면 원본 장치와 같은 크기에 맞게 출력한다.
매킨토시에서 만든 이미지가 가로, 세로 5Cm 였다면 윈도우즈에서 이 이미지를 열어도 그 크기대로 출력하며 이때 이미지의 해상도 정보를 활용한다. 매킨토시의 모니터는 일반적으로 72dpi이며 윈도우즈의 모니터는 96dpi이므로 적당히 확대해야 비슷한 크기로 보인다. 불행하게도 대부분의 디지털 카메라도 72dpi를 가정하기 때문에 윈도우즈에서는 항상 원본 이미지보다 더 크게 보인다.
GDI+의 이런 동작은 논리적인 면에서는 옳다고 할 수 있으나 실제로 사용하기에는 오히려 더 불편하다. 해상도나 논리적인 크기보다는 픽셀 크기대로 출력되는 것이 더 바람직한 경우가 많다. 이때는 좌표만 지정하지 말고 이미지의 폭과 높이를 직접 조사하여 전달해야 한다. 다음 코드는 똑같은 이미지를 두가지 방식으로 각각 출력한 것이다.
Image I(L"코스모스.jpg");
G.DrawImage(&I,0,0);
G.DrawImage(&I,300,0,I.GetWidth(),I.GetHeight());
(0,0)에 그냥 출력하면 이미지가 확대되어 나타나며 확대에 의해 이미지가 흐릿해져 품질도 떨어진다. 정확한 픽셀 크기대로 출력하려면 이미지 객체의 GetWidth, GetHeight 멤버 함수로 픽셀 크기를 조사하여 영역을 지정하는 것이 좋다. 이렇게 하면 정확한 크기대로 출력될 뿐만 아니라 스케일링을 하지 않음으로써 속도도 더 빨라진다. 출력 영역의 폭과 높이를 지정할 때는 다음 함수를 사용하며 지정한 사각 영역에 이미지를 출력한다.
Status DrawImage(Image *image, INT x, INT y, INT width, INT height);
Status DrawImage(Image *image, const Rect &rect);
사각 영역에 이미지를 맞추어 출력하므로 확대 및 축소가 가능하다. 이미지 크기보다 더 큰 영역을 주면 확대가 될 것이고 더 작은 영역에 출력하면 축소될 것이다. 다음 코드는 "조롱박.jpg"를 두 번 출력하되 한 번은 2배 확대, 한 번은 1/2 축소하여 출력했다.
Image I(L"조롱박.jpg");
G.DrawImage(&I,0,0,I.GetWidth()*2, I.GetHeight()*2);
G.DrawImage(&I,650,0,I.GetWidth()/2, I.GetHeight()/2);
임의의 사각 영역을 마음대로 지정할 수도 있지만 그럴 경우 그림의 종횡비가 유지되지 않으므로 GetWidth, GetHeight 멤버 함수로 이미지의 원래 크기를 구한 후 일정 배율을 곱해 확대 및 축소해야 한다. 종횡비를 무시하고 사각 영역에 무조건 출력해 버리면 이미지가 찌그러질 것이다. 확대나 축소를 하더라도 폭과 높이에 적용되는 배율이 같아야 종횡비가 유지된다.
이미지를 확대하거나 축소할 때는 추가로 픽셀이 삽입되거나 합쳐지는데 이때 적용되는 알고리즘을 보간(Interpolation)이라고 한다. 보간이란 사이 사이에 새로 생기는 점들을 보충한다는 뜻인데 예를 들어 노란색과 빨간색 사이에 점을 삽입할 때 주황색을 사용하는 기법이라고 이해하면 된다. GDI의 SetStretchBltMode 함수로 지정하는 스트레칭 모드와 동일한 기능이라고 할 수 있다. Graphics의 다음 멤버 함수로 보간 모드를 변경할 수 있다.
Status SetInterpolationMode(InterpolationMode interpolationMode);
보간 모드는 확대, 축소 품질에 따라 모두 8가지가 제공되는데 품질이 높을수록 처리 시간은 더 오래 걸린다. 품질과 속도는 항상 반비례 관계이다. 다음 테스트 코드는 보간 모드를 바꿔 가며 이미지를 확대 및 축소함으로써 차이점을 확인해 본다.
InterpolationMode imode=InterpolationModeDefault;
void OnPaint(HDC hdc)
{
Graphics G(hdc);
Image I(L"조롱박.jpg");
G.SetInterpolationMode(imode);
G.DrawImage(&I,0,0,I.GetWidth()*2, I.GetHeight()*2);
G.DrawImage(&I,I.GetWidth()*2+10,0,I.GetWidth()/2, I.GetHeight()/2);
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
switch(iMessage) {
case WM_KEYDOWN:
if (Mode == TEXT('6')) {
switch (wParam) {
case 'I':
if (imode == InterpolationModeHighQualityBicubic) {
imode=InterpolationModeDefault;
} else {
imode=(InterpolationMode)(imode+1);
}
InvalidateRect(hWnd,NULL,FALSE);
return 0;
}
}
....
키보드의 I키를 누르면 보간 모드를 순환하면서 다시 그리기를 하므로 차이점을 눈으로 쉽게 확인할 수 있을 것이다. 다음 함수는 이미지를 평행 사변형안에 출력한다.
Status DrawImage(Image *image, const Point *destPoints, INT count);
배열에 평행 사변형의 좌상단, 우상단, 좌하단 꼭지점 3개의 좌표를 저장하고 개수를 3으로 전달한다. 나머지 우하단 점의 좌표는 계산으로 구할 수 있으므로 별도로 지정하지 않아도 상관없다.
Image I(L"조롱박.jpg");
Point pts[]={Point(10,10),Point(300,50),Point(100,300)};
G.DrawImage(&I,pts,3);
평행 사변형 영역에 맞추어 이미지가 출력되므로 이미지는 자연스럽게 평행 사변형 크기에 맞도록 확장 또는 축소될 것이다. 이미지의 일부만 출력할 때는 다음 함수를 사용한다.
Status DrawImage(Image *image, INT x, INT y, INT srcx, INT srcy, INT srcwidth, INT srcheight, Unit srcUnit);
(x,y)는 출력할 화면 좌표이며 (srcx,srcy)는 이미지의 출력 시작 좌표, srcwidth, srcheight는 이미지의 출력 크기이다. 마지막 인수 srcUnit은 이미지의 좌표와 크기 지정에 사용되는 단위를 지정하는데 픽셀단위나 인치, 밀리미터 단위로 출력할 부분을 지정할 수 있다. 그러나 출력할 좌표만 지정할 수 있으므로 확대, 축소 기능은 없다. 다음 코드는 "호랑나비.jpg" 그림에서 호랑나비가 있는 부분만 출력한다.
Image I(L"호랑나비.jpg");
G.DrawImage(&I,0,0,150,40,220,300,UnitPixel);
"호랑나비.jpg" 그림 파일의 (150,40)에서부터 폭 220, 높이 300부분만 화면의 (0,0) 좌표에 출력하도록 했다.
다음 함수는 가장 옵션이 많은 출력 함수이며 이미지의 일부분만 화면의 원하는 부분에 출력할 수 있다. 뒤쪽 세 인수는 출력 효과나 출력 과정 보고를 위해 사용하는데 필요없을 경우 생략할 수 있다.
Status DrawImage(Image *image, const Rect &destRect, INT srcx, INT srcy, INT srcwidth, INT srcheight, Unit srcUnit, ImageAttributes *imageAttributes, DrawImageAbort callback, VOID *callbackData);
인수로 출력 영역이 전달되면 확대/축소가 가능하고 이미지 영역이 전달되면 이미지의 원하는 부분만 출력할 수 있다. 이 함수는 화면 영역, 이미지의 시작점과 폭, 높이 등을 모두 인수로 받아 들이므로 이미지 일부를 원하는 영역에 원하는 크기로 출력할 수 있다. 다음 코드는 호랑나비만 절반으로 축소해서 화면의 원점에 출력한다.
Image I(L"호랑나비.jpg");
G.DrawImage(&I,Rect(0,0,110,150),150,40,220,300,UnitPixel);
이 함수가 모든 옵션을 다 지원하는 가장 완벽한 이미지 출력 함수라고 할 수 있다. 이미지를 출력하는 마지막 함수는 평행 사변형에 이미지의 일부분만 출력하는 함수이다.
Image I(L"호랑나비.jpg");
Point pts[]={Point(10,10),Point(300,50),Point(100,300)};
G.DrawImage(&I,pts,3,150,40,220,300,UnitPixel);
호랑나비만 지정한 평행 사변형에 맞추어 출력될 것이다. 실용성은 다소 떨어진다.
다.비트맵
Image 클래스는 GDI+의 기본 이미지 클래스이며 이미지를 읽고, 쓰고 출력하는 대부분의 기능을 제공한다. 이 클래스로부터 Bitmap과 MetaFile 클래스가 파생되는데 Bitmap은 Image에 비해 좀 더 정밀한 레스터 데이터 관리 능력을 가지고 있으며 MetaFile은 벡터를 기록하거나 검사하는 기능을 추가로 가진다. 다음은 Bitmap 클래스의 생성자들 중 일부이다.
Bitmap(INT width, INT height, Grpaphics *target);
Bitmap(INT width, INT height, PixelFormat format);
Bitmap(HBITMAP hbm, HPALETTE hpal);
Bitmap(const BITMAPINFO *gdiBitmapInfo, VOID *gdiBitmapData);
Bitmap(HICON hicon);
Image는 파일이나 스트림으로부터, 즉 이미 존재하는 것만 생성할 수 있지만 Bitmap은 좀 더 다양한 방법으로 생성할 수 있다. 폭과 높이, 그리고 참조 그래픽 객체나 색상 포맷 정보만으로 비어 있는 비트맵을 만들 수도 있다. GDI의 DDB핸들이나 DIB 정보 구조체 또는 아이콘으로부터 GDI+와 호환되는 비트맵을 생성하기도 한다. 다음 코드는 아이콘으로부터 비트맵을 생성하여 출력하는 예이다.
HICON hIcon;
hIcon=LoadIcon(NULL,IDI_EXCLAMATION);
Bitmap B(hIcon);
G.DrawImage(&B,10,10);
LoadIcon 함수로 읽은 아이콘 핸들로부터 비트맵 객체를 생성하고 이 객체를 DrawImage 함수로 출력했다. GDI+는 아이콘을 출력하거나 관리하는 함수를 따로 제공하지 않으므로 비트맵을 통해 아이콘을 출력해야 한다. Bitmap 클래스의 생성자 중 래스터 데이터없이 크기와 색상 포맷만 지정하여 빈 비트맵을 만드는 생성자를 사용하면 더블 버퍼링에 사용할 수도 있다.
원리는 GDI의 경우와 동일하다. Graphics 클래스의 생성자 중에 Image와 연결되는 Graphics 객체를 만드는 것이 있는데 이 생성자로 만들어진 Graphics 객체는 화면으로 보이지 않고 모든 출력을 이미지 표면으로 내 보낸다. 마치 GDI의 메모리 DC로 출력을 보내면 이 DC에 선택되어 있는 비트맵으로 출력되는 것과 같다.
원하는 크기로 비트맵을 만들고 이 비트맵으로부터 Graphics 객체를 생성한다. 그리고 이렇게 만들어진 Graphics 객체로 출력하면 백그라운드에서 그림을 그릴 수 있다. 완성된 비트맵을 빠른 속도로 화면에 전송하면 이것이 바로 더블 버퍼링이다. 다음 코드는 극단적으로 간단한 더블 버퍼링 시범을 보인다.
Bitmap B(100,100,&G);
Graphics G2(&B);
Pen P(Color(0,0,0),3);
G2.DrawEllipse(&P,10,10,80,80);
G.DrawImage(&B,0,0);
크기 (100,100)의 비트맵을 화면과 호환되는 색상 포맷으로 생성(CreateCompatibleBitmap에 해당)하고 이 비트맵으로부터 G2 객체를 생성(CreateCompatibleDC에 해당)했다. 이후 G2로 보내지는 출력은 모두 비트맵 표면에 그려질 것이다. 예제 코드에서는 타원만 하나 그려 보았다. 그리고 그 결과를 화면 Graphics 객체인 G로 DrawImage 함수를 사용하여 빠른 속도로 전송했다. 그리는 중간 과정은 메모리 내부에서 일어나는 일이므로 사용자 눈에 보이지 않으며 깜박임도 없다.
CachedBitmap 클래스는 출력 장치와 호환되는 포맷으로 변환한 이미지를 가지는 클래스이다. 단순한 래스터 데이터의 집합만을 가지므로 Image의 파생 클래스는 아니며 비트맵으로부터 생성된다. CachedBitmap의 생성자는 다음 하나 뿐이다.
CachedBitmap(Bitmap *bitmap, Graphics *graphics);
래스터 데이터를 가지는 Bitmap 객체와 출력 장치를 표현하는 Graphics 객체를 인수로 받아들여 장치에 맞는 포맷 형식으로 래스터 데이터를 가공한 결과를 가진다. 이 정보는 장치에 종속적이며 그래서 별도의 변환을 거치지 않고도 곧바로 화면으로 출력할 수 있다. CachedBitmap을 출력할 때는 Graphics 클래스에 포함된 다음 함수를 사용한다.
Status DrawCachedBitmap(CachedBitmap *cb, INT x, INT y);
DrawImage 함수처럼 확대나 축소, 이미지의 일부만 출력하는 기능은 없고 오로지 지정한 위치에 이미지 전체를 출력할 수 있을 뿐이다. Bitmap이나 Image는 파일로부터 읽어들인 장치 독립적인 정보를 가지고 있으므로 DrawImage 함수가 출력할 때 장치에 맞게 포맷을 변환해야 한다. 따라서 출력 속도가 느리지만 CachedBitmap은 생성할 때 미리 변환해 놓으므로 반복적인 출력을 할 때 대단히 속도가 빠르다. 쉽게 비유를 하자면 Bitmap은 GDI의 DIB에 해당하고 CachedBitmap은 DDB에 해당한다고 할 수 있다. 다음 코드는 비트맵을 캐시 비트맵으로 바꾼 후 출력한다.
Bitmap B(L"노을.jpg");
CachedBitmap CB(&B,&G);
G.DrawCachedBitmap(&CB,0,0);
물론 이런 식으로 OnPaint에서 캐시 비트맵을 만들어서는 속도상의 이득을 볼 수 없으며 코드의 다른 곳에서 이미지 파일을 읽어 캐시 비트맵을 미리 만들어 놓아야 할 것이다. 예를 들어 이미지 파일을 열 때 캐시 비트맵을 미리 만들어 놓고 OnPaint에서는 이 비트맵을 무조건 화면에 뿌리기만 하면 된다. 만약 캐시 비트맵을 만들어 놓은 상태에서 디스플레이 세팅이 변경되면, 예를 들어 16비트 모드에서 24비트 모드로 바뀌면 이 비트맵도 변경된 모드에 맞게 다시 만들어야 한다.
더블 버퍼링을 제대로 구현하려면 캐시 비트맵을 사용해야 한다. GpDoubleBuffer 예제는 파란색 격자들을 먼저 출력해 놓고 그 위에서 반투명한 빨간색 타원을 커서 이동키로 움직이는데 더블 버퍼링을 할 때와 그렇지 않을 때를 비교해 본다. 먼저 더블 버퍼링을 하지 않을 때의 코드를 보자.
예 제 : GpDoubleBuffer |
int ex=100,ey=100;
void OnPaint(HDC hdc)
{
Graphics G(hdc);
SolidBrush S(Color(0,0,255));
int x,y;
for (x=0;x<=800;x+=50) {
for (y=0;y<=600;y+=50) {
G.FillRectangle(&S,x,y,40,40);
}
}
SolidBrush S2(Color(128,255,0,0));
G.FillEllipse(&S2,ex,ey,150,150);
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
switch(iMessage) {
case WM_CREATE:
hWndMain=hWnd;
return 0;
case WM_KEYDOWN:
switch (wParam) {
case VK_LEFT:
ex-=5;
InvalidateRect(hWnd,NULL,TRUE);
break;
case VK_RIGHT:
ex+=5;
InvalidateRect(hWnd,NULL,TRUE);
break;
case VK_UP:
ey-=5;
InvalidateRect(hWnd,NULL,TRUE);
break;
case VK_DOWN:
ey+=5;
InvalidateRect(hWnd,NULL,TRUE);
break;
}
return 0;
case WM_PAINT:
hdc=BeginPaint(hWnd, &ps);
OnPaint(hdc);
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
OnPaint에서는 사각형 무늬와 ex, ey에 반투명 타원을 그리고 WM_KEYDOWN에서는 ex, ey의 좌표를 변경하여 다시 그린다. 키를 반복적으로 누르면 반투명한 타원이 사각형 격자 사이를 이동할 것이다.
타원이 잘 이동되기는 하지만 좌표가 조금이라도 바뀔 때마다 매번 다시 그려야 하므로 깜박임이 무척 심하다. 출력 품질이 저하됨은 물론이고 이런 화면을 오래 보고 있으면 눈이 금방 피로해져 건강에도 좋지 못하다. 다음 코드는 똑같은 동작을 하되 더블 버퍼링을 하도록 수정한 것이다.
int ex=100,ey=100;
CachedBitmap *pCBit;
void UpdateScreen()
{
Graphics G(hWndMain);
RECT crt;
GetClientRect(hWndMain,&crt);
Bitmap *pBit=new Bitmap(crt.right,crt.bottom,&G);
Graphics *memG=new Graphics(pBit);
memG->FillRectangle(&SolidBrush(Color(255,255,255)),0,0,crt.right,crt.bottom);
SolidBrush S(Color(0,0,255));
int x,y;
for (x=0;x<=800;x+=50) {
for (y=0;y<=600;y+=50) {
memG->FillRectangle(&S,x,y,40,40);
}
}
SolidBrush S2(Color(128,255,0,0));
memG->FillEllipse(&S2,ex,ey,150,150);
if (pCBit) {
delete pCBit;
}
pCBit=new CachedBitmap(pBit,&G);
delete pBit;
delete memG;
InvalidateRect(hWndMain,NULL,FALSE);
}
void OnPaint(HDC hdc)
{
Graphics G(hdc);
if (pCBit == NULL) {
UpdateScreen();
}
G.DrawCachedBitmap(pCBit,0,0);
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
switch(iMessage) {
case WM_CREATE:
hWndMain=hWnd;
return 0;
case WM_KEYDOWN:
switch (wParam) {
case VK_LEFT:
ex-=5;
UpdateScreen();
break;
case VK_RIGHT:
ex+=5;
UpdateScreen();
break;
case VK_UP:
ey-=5;
UpdateScreen();
break;
case VK_DOWN:
ey+=5;
UpdateScreen();
break;
}
return 0;
case WM_PAINT:
hdc=BeginPaint(hWnd, &ps);
OnPaint(hdc);
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
if (pCBit) {
delete pCBit;
}
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
전역 변수로 CachedBitmap의 포인터 pCBit를 선언해 두고 UpdateScreen 함수는 이 비트맵 표면에 그림을 그려 놓는다. 작업 영역 크기와 같은 Bitmap 객체 pBit를 생성하고 이 비트맵으로부터 memG 객체를 만든 후 이 객체에 출력했다. 출력 결과는 비트맵 표면에 그대로 기록될 것이며 최종적으로 캐시 비트맵으로 변환해 둔다. 그려진 그림의 최종 결과는 pCBit이므로 pBit나 memG는 저장할 필요가 없다.
OnPaint에서는 DrawCachedBitmap 함수로 그 결과만 출력하면 된다. 이미 화면의 색상 포맷에 맞게 변환된 정보를 가지고 있으므로 비디오 램에 전송하기만 하면 빠른 속도로 그림을 그릴 수 있으며 다른 윈도우에 의해 언커버되더라도 사각형과 타원을 다시 그릴 필요없이 캐시 비트맵만 다시 출력한다. WndProc에서는 화면을 직접 수정할 필요없이 캐시 비트맵을 수정하는 UpdateScreen 함수만 호출하면 된다.
GDI와 GDI+의 더블 버퍼링 방법은 원론적으로 동일하다. 다만 GDI의 메모리 비트맵은 생성할 때부터(CreateCompatibleBitmap) 장치에 종속적이므로 곧바로 화면으로 출력할 수 있지만 GDI+의 메모리 비트맵인 Bitmap 객체는 그렇지 않으므로 캐시 비트맵으로 한 번 더 변환을 해야 한다는 점이 다를 뿐이다. 잠시 후 이미지 뷰어 예제에서 더블 버퍼링을 사용하는 실제 예를 보게 될 것이다.
MetaFile 클래스는 이름 그대로 메타 파일을 관리하는 클래스이다. Image 클래스로도 메타 파일을 출력하는 정도는 할 수 있지만 MetaFile 클래스는 메타 기록이나 레코드 분석, Win32 메타 파일로의 변환 등 좀 더 전문적인 처리를 할 수 있다. 다음 코드는 Image 클래스로 메타 파일을 출력한다.
Image I(L"testenh.emf");
G.DrawImage(&I,10,10);
GDI+는 Win32의 확장 메타 파일만 읽을 수 있으므로 16비트 메타 파일이나 플레이스블 메타 파일은 확장 포맷으로 변환해야만 출력할 수 있다. MetaFile 클래스를 사용하면 GDI+ 호출을 메타 파일로도 작성할 수 있다.
Metafile M(L"GpMeta.emf",hdc);
Graphics *pG=new Graphics(&M);
Pen P(Color(255,0,0),5);
Pen P2(Color(0,0,0));
pG->DrawEllipse(&P,10,10,100,100);
pG->DrawRectangle(&P2,30,30,50,50);
delete pG;
Metafile M2(L"GpMeta.emf");
G.DrawImage(&M2,10,10);
MetaFile 객체 M을 만든 후 이 객체로부터 Graphics 객체 pG를 생성하고 pG로 출력을 내 보내면 이 출력은 메타 파일에 부호화되어 저장된다. 최종적으로 pG가 파괴될 때 GDI+ 출력문들이 메타 파일에 기록된다. 예제에서는 GpMeta.emf라는 이름으로 메타 파일을 만들었고 확인을 위해 M2 객체로 다시 열어 출력해 보았다.
라.이미지 프로퍼티
이미지 파일에는 그림만 저장되어 있는 것이 아니라 그림과 관련된 여러 가지 세부 정보들도 포함되어 있다. 예를 들어 디지털 카메라로 촬영된 이미지에는 어떤 모델의 어떤 카메라로 찍었는지에 대한 장비 정보, 날짜와 해상도 등이 이미지 파일에 같이 기록된다. 어떤 카메라는 촬영할 때의 노출 정도, 줌 상태, 셔터 속도 등의 상세한 정보까지도 써 넣는다. 또한 촬영한 이미지에 별도의 소프트웨어를 사용하여 제목이나 간단한 설명을 달 수 있는데 이 사진을 편집한 소프트웨어는 자신의 존재를 이미지에 새겨 넣기도 한다.
이미지에 포함되는 이런 세부적인 정보들은 이미지를 인화한다거나 보정할 때 참고 정보로 사용되기도 하고 어떤 정보는 쓸데없이 자리만 차지하기도 한다. 아뭏든 이 정보들은 이미지의 활용성을 높여 주는데 이런 정보들을 이미지 프로퍼티 또는 메타 데이터라고 한다. GDI+는 이미지 파일로부터 프로퍼티를 조사하거나 변경하는 함수도 제공한다. 분량에 비해 별 소득은 없으므로 관심없는 사람은 읽지 않아도 좋다.
이미지 프로퍼티는 폭이나 높이 같은 기본적인 정보들보다 다루기가 훨씬 더 까다로운데 왜냐하면 이미지 포맷별로 또 이미지를 만들어내는 장비별로 생성하는 프로퍼티의 종류나 형식이 다양하기 때문이다. 프로퍼티의 종류도 가변적이고 개수도 가변적이어서 일관된 방법으로 다룰 수가 없다. 디지털의 세계에서 가변적이라는 말은 유연하다고 표현되기도 하지만 다른 말로 표현하면 귀찮아진다는 뜻과도 같다. 가변 개수의 자료 집합을 다루는 방법은 여러 가지가 있다.
① 윈도우즈가 가장 흔하게 사용하는 방법은 열거를 하는 방법인데 발견될 때마다 콜백 함수를 호출하는 식이다. 메모리 소모가 거의 없고 반응이 빠르다는 이점이 있지만 콜백 함수를 작성해야 하므로 번거롭고 콜백 함수는 멤버 함수가 될 수 없으므로 객체 지향적인 방법과는 어울리지 않는다.
② 개수를 먼저 조사하고 개별 요소를 구하는 방법이 있는데 DragQueryFile 함수가 이 방법을 사용하는 대표적인 예이다. 사용하기는 간단하지만 속도는 느리며 임의 접근이 가능해야만 이 방법을 쓸 수 있다.
③ 개수를 먼저 조사한 후 이 개수만큼의 배열을 할당하여 포인터를 전달하면 배열에 구하는 모든 자료를 한꺼번에 복사한다. 가장 간편하며 또한 가장 빠른 방법이기도 하지만 물리적인 메모리를 많이 소모한다는 것이 단점이다.
GDI+는 주로 세 번째 방법을 사용하는데 메모리가 과거보다 저렴해졌기 때문에 모든 정보를 한꺼번에 조사해도 별 부담이 되지 않기 때문이다. 이미지의 프로퍼티 목록을 구할 때는 Image 클래스의 다음 두 멤버 함수를 사용한다.
Status GetPropertySize(UINT *totalBufferSize, UINT *numProperties);
Status GetAllPropertyItems(UINT totalBufferSize, UINT numProperties, PropertyItem *allItems);
프로퍼티의 개수가 가변적이기 때문에 우선 GetPropertySize 함수로 총 크기와 개수를 조사해야 한다. 그리고 조사된 크기만큼 메모리를 할당하여 그 번지를 GetAllPropertyItems 함수로 전달하면 이미지 프로퍼티의 배열을 메모리에 채워준다. 이미지 프로퍼티는 PropertyItem 클래스로 나타내는데 이 클래스는 다음 4개의 멤버를 가진다.
멤버 |
설명 |
id |
어떤 종류의 프로퍼티인가를 나타낸다. 장비, 모델, 해상도, 제작자 등등의 정보에 대해 고유의 16진 ID가 부여되어 있다. 이 ID의 목록은 너무 길어서 도움말에도 정리되어 있지 않으며 GdiPlusimaging.h 헤더 파일을 열어서 직접 확인해 보아야 한다. |
length |
프로퍼티의 길이를 바이트 단위로 나타낸다. 문자열일 경우 가변 길이를 가질 수 있다. |
type |
프로퍼티의 타입을 나타내는 정수값이다. 1이면 BYTE, 2면 ASCII(문자열), 3이면 short, 4면 long 등으로 정의된다. 총 10가지 종류의 타입을 정의하는데 주로 2번 아니면 3,4번인 경우가 많다. 이 타입 정보로부터 실제 값을 읽는 방법이 달라진다. |
value |
프로퍼티의 실제값을 가지는 void *형 멤버이다. 임의의 타입을 가리키므로 읽을 때 원하는 타입으로 캐스팅해야 한다. 타입과 길이 정보대로 이 배열을 읽으면 원하는 값을 조사할 수 있다. |
다음 코드는 "노을.jpg" 파일의 프로퍼티를 대충 조사해 본 것이다. 디버깅해보면 40여개의 프로퍼티들이 포함되어 있는데 대부분은 사진에 대한 전문적인 지식이 있어야만 이해할 수 있는 것들이라 직관적으로 이해하기 쉬운 정보만 출력해 보았다.
Image I(L"노을.jpg");
UINT size,count,i,y=0;
PropertyItem *arPro;
TCHAR str[1024];
I.GetPropertySize(&size,&count);
arPro=(PropertyItem *)malloc(size);
I.GetAllPropertyItems(size,count,arPro);
for (i=0;i<count;i++) {
switch (arPro[i].id) {
case PropertyTagImageTitle:
wsprintf(str,TEXT("제목 : %S"),(TCHAR *)arPro[i].value);
break;
case PropertyTagImageDescription:
wsprintf(str,TEXT("설명 : %S"),(TCHAR *)arPro[i].value);
break;
case PropertyTagCopyright:
wsprintf(str,TEXT("저작권 : %S"),(TCHAR *)arPro[i].value);
break;
case PropertyTagEquipMake:
wsprintf(str,TEXT("장비 : %S"),(TCHAR *)arPro[i].value);
break;
case PropertyTagEquipModel:
wsprintf(str,TEXT("모델 : %S"),(TCHAR *)arPro[i].value);
break;
case PropertyTagXResolution:
wsprintf(str,TEXT("가로 해상도 : %d"),*(LONG *)arPro[i].value);
break;
case PropertyTagYResolution:
wsprintf(str,TEXT("세로 해상도 : %d"),*(LONG *)arPro[i].value);
break;
case PropertyTagSoftwareUsed:
wsprintf(str,TEXT("사용한 소프트웨어 : %S"),(TCHAR *)arPro[i].value);
break;
case PropertyTagDateTime:
wsprintf(str,TEXT("날짜 : %S"),(TCHAR *)arPro[i].value);
break;
default:
continue;
}
TextOut(hdc,0,y*20,str,lstrlen(str));
y++;
}
free(arPro);
id값으로 프로퍼티 종류별로 분기하고 value값을 읽어 문자열로 조립하는 단순한 출력문일 뿐이다. 단, 여기서 주의할 점은 이미지 프로퍼티에 저장된 문자열은 ANSI이므로 유니코드로 바꾼 후 출력해야 제대로 보인다는 점이다. 디지털 카메라가 유니코드를 인식하기를 바라는 것은 아직까지는 시기 상조인 듯 하다. wsprintf로 ANSI 문자열을 조립할 때는 대문자의 %S 서식을 사용하면 알아서 변환하므로 편리하다. 실행 결과는 다음과 같다.
이미지에 포함된 모든 프로퍼티를 타입에 맞게 제대로 출력하면 상당히 긴 코드가 필요할 것이다. 그만큼 프로퍼티의 종류가 다양하기 때문이다. 이미지의 프로퍼티값을 변경하거나 추가하고 싶다면 다음 함수를 사용한다.
Status SetPropertyItem(const PropertyItem* item);
기록하고 싶은 정보를 PropertyItem 객체에 작성한 후 이 함수를 호출하면 이미지에 프로퍼티 정보가 수정 또는 추가된다.
마.이미지 뷰어
GDI+는 여러 가지 면에서 활용성이 높지만 그 중에 가장 매력적인 기능은 다양한 포맷의 이미지를 별도의 외부 라이브러리 없이도 읽고 쓸 수 있다는 점이다. 여기서는 지금까지 배운 GDI+의 이미지 관련 기능을 실습해 보기 위해 간단한 이미지 뷰어 프로그램을 만들어 보기로 한다. GrimBoa 프로젝트는 특정 디렉토리에 있는 이미지를 순서대로 보여 주는 간단한 이미지 뷰어 프로그램이다.
이 강좌를 다시 쓰면서 모든 예제를 VS 2008용으로 다시 작성하였으며 변화된 환경에 맞게 유니코드 프로젝트로 만들었다. 이 예제도 유니코드로 다시 깔끔하게 만들려고 했으나 문자열이 너무 많고 군데 군데 문자열 변환 루틴까지 있어서 도저히 짧은 시간안에 유니코드로 변환할 수가 없었다. 그래서 불가피하게 이 예제는 이전 예제를 새로 만들지 못했으며 여전히 ANSI 프로젝트이다. 소스가 무척 길기 때문에 리스트는 생략한다.
예 제 : GrimBoa |
========== 소스 생략 ==========
프로그램을 실행한 후 파일/디렉토리 선택 메뉴를 통해 그림 파일이 있는 폴더를 지정하면 이 폴더의 첫 번째 그림이 화면으로 출력된다. 그리고 PgUp, PgDn키나 마우스 휠로 앞 뒤로 이동하며 그림을 감상할 수 있다. 이미지가 창보다 클 경우 창에 맞추기, 전체 화면, 목록 순환, 슬라이더 쇼 등의 기본적인 기능이 제공된다.
GDI+가 이미지와 관련된 기능을 모두 제공하므로 메인 모듈의 길이는 그다지 길지 않다. 다만 백그라운드 작업을 위해 스레드를 사용하기 때문에 약간 난이도가 높기는 하다. 이 프로그램은 두 개의 윈도우를 가지는데 메인 윈도우는 전반적인 프로그램의 기능을 처리하며 앨범 차일드 윈도우는 그림을 보여주기만 한다. 메인 윈도우는 실제 작업을 하지 말아야 하며 이미지를 출력하는 윈도우를 따로 분리해 두어야 이후 프로그램의 확장성이 좋아진다.
목록 작성
이 프로그램은 지정한 폴더의 모든 이미지를 보여 주므로 먼저 이미지의 목록을 조사해야 한다. 한 디렉토리에 포함되는 이미지의 개수에는 상한값이 없기 때문에 이미지 목록은 동적으로 관리한다. 다음과 같이 선언된 구조체를 정의하고 이 구조체 타입의 포인터로 동적 배열을 작성하고 있다.
struct tag_File {
TCHAR Name[MAX_PATH];
int Size;
int Order;
FILETIME time;
BOOL bMark;
};
Size와 Order 멤버는 파일 크기와 발견 순서를 기록한다. 이미지를 보여주는 순서는 디폴트로 발견된 순서대로이지만 크기순이나 이름순, 시간순으로 정렬할 때 이 멤버들이 사용된다. bMark는 일괄적인 파일 삭제를 위해 삭제 대상 파일임을 기억하는 멤버이다. 파일의 목록을 작성하는 시점은 두 군데가 있는데 첫 번째는 메뉴에서 명시적인 명령을 내렸을 때이고 두 번째는 탐색기에서 파일을 드래그해서 떨어뜨렸을 때(WM_DROPFILES)이다.
두 경우 모두 선택한 디렉토리의 파일 목록을 조사하고 그림 보여 주기를 시작하되 메뉴로부터의 명령일 때는 첫 번째 파일부터 출력하면 되고 파일을 드래그했을 때는 드래그한 파일부터 출력해야 한다. 파일의 목록을 작성하는 작업은 MakeList가 담당한다. 이 함수는 FileList 배열을 최초 1000의 크기로 할당하고 FindInFiles 함수를 호출하여 검색을 시작한다.
FindInFiles 유틸리티 함수는 지정한 폴더의 특정 패턴 파일을 검색해 콜백 함수를 호출하는데 서브 디렉토리 검색, 복잡한 패턴 처리, 숨은 파일 처리 등의 기능을 가지고 있다. 패턴으로 GDI+가 지원하는 모든 래스터 이미지 파일을 전달했다. 이 함수에 의해 이미지 파일이 발견될 때마다 OnFindImage 콜백 함수가 호출되며 이 함수에서 검색된 파일의 이름을 FileList 배열에 차곡 차곡 모아둔다. 물론 동적 배열이 가득찰 경우를 대비해서 재할당 처리도 하고 있다.
검색이 끝나면 FileList 배열에는 이미지 파일의 이름이 발견된 순서대로 저장되어 있을 것이며 ListNum 변수에는 발견된 이미지 파일의 개수가 기록된다. 이후 목록의 순서대로 앨범에 이미지를 출력하면 된다.
슬롯 관리
목록에 있는 파일을 순서대로 출력하는 것은 아주 쉽다. 그러나 사용자의 요청이 있을 때 다음 이미지를 보여 주려면 시간이 너무 오래 걸린다는 것이 문제다. 디지털 카메라의 해상도가 높아져 보통 2048*1536 정도 되는 이미지가 많고 이런 이미지들은 또한 압축되어 있는데다 화면 크기에 맞게 축소까지 해야 하기 때문에 이미지 한 장을 그리는데 수초 정도의 시간이 필요하다. 사용자들은 참을성이 없기 때문에 이렇게 오래 걸려서는 뷰어로써의 가치가 떨어진다.
그래서 이 프로그램은 사용자가 그림을 감상하는 동안에도 부지런히 다음 그림을 읽어 두는 백그라운드 작업을 하고 있는데 이를 위해 슬롯이라는 개념을 도입했다. 스레드에게 어떤 작업을 일일이 지시하는 것은 동기화 문제로 인하여 무척 골치아픈데 슬롯을 통해 주 스레드와 작업 스레드의 통신을 단순화시키는 것이다. 슬롯은 다음과 같이 정의된 구조체이다.
struct tag_Slot {
CachedBitmap *pCB;
int idx;
};
tag_Slot Slot[3];
pCB는 캐시 비트맵 포인터이며 idx는 목록상의 이미지 첨자이다. 이런 구조체를 세 개 모아 Slot 배열을 선언했는데 Slot[1]은 주 스레드가 출력할 이미지이며 Slot[0], Slot[2]는 이전, 이후 이미지이다. 목록이 작성된 직후 SetSlot(0) 함수는 슬롯을 다음 상태로 초기화한다.
idx에 읽을 이미지의 첨자를 기록하는데 Slot[1]에는 첫 그림인 0번, Slot[2]에는 다음 그림인 1번이 기록되어 있다. 이전 그림인 Slot[0]에는 보여줄 그림이 없으므로 -1로 초기화하되 bWrap 옵션이 켜져 있으면 마지막 이미지 번호를 적어 준다. pCB 멤버는 모두 NULL로 초기화하고 이전에 작성되어 있던 캐시 비트맵은 모두 파괴한다.
이 상태를 만들어 놓으면 백그라운드의 작업 스레드인 PrepareThread는 세 슬롯을 차례대로 점검하여 idx가 -1이 아니고 pCB가 NULL인 슬롯의 이미지를 캐시 비트맵에 작성한다. 주 스레드는 자신이 당장 그려야 할 Slot[1].pCB를 대기하는데 동기화 오브젝트를 쓰지 않고 단순한 while문으로 이 멤버가 NULL이 아닌 상태가 될 때까지 기다리도록 했다. 단, Sleep(0)를 실행함으로써 조건이 만족되지 않을 때는 즉시 실행 시간을 양보한다.
이벤트같은 동기화 객체를 사용할 수도 있지만 이런 객체를 사용하려면 신호, 비신호 상태를 잘 관리해야 하므로 논리가 복잡해진다. 이 프로그램은 Slot[1].pCB 전역 변수 자체를 동기화 객체로 사용하는데 대기하는 방식이 조금 비효율적이지만 코드가 직관적이어서 관리하기는 훨씬 더 쉬워진다. 때로는 무식해 보이는 방법도 나름대로 쓸만하다.
앨범 윈도우의 WM_PAINT 처리 루틴은 지극히 간단해서 DrawCachedBitmap 함수로 무조건 Slot[1].pCB를 화면으로 출력하기만 하면 된다. 캐시 비트맵을 작성하는 작업은 스레드가 대신 하고 있으며 주 스레드는 작업 스레드가 그림을 완성할 때까지 기다리므로 WM_PAINT는 별로 할 일이 없다. 그림 출력 후 삭제 표시된 이미지에 대해 반투명한 빨간색으로 X자를 그려 삭제 대상임을 표시했다.
이 프로그램이 채용하고 있는 슬롯의 개념은 사실 무척 간단한 분업의 예라고 할 수 있다. 주 스레드는 슬롯에 어떤 이미지를 보여줘야 하는지에 대한 요청 사항만 기록하고 작업 스레드는 끊임없이 슬롯을 감시하여 요청이 들어온 그림을 그리기만 한다. 주 스레드는 목록상의 변화나 출력 방법(화면 크기, 옵션)의 변화가 있을 때 슬롯만 관리하고 이미지는 직접 그리지 않으며 다만 작업이 완료될 때까지 잠시 기다리기만 하면 된다.
더블 버퍼링
캐시 비트맵을 만드는 PrepareThread 함수는 무한 루프를 돌며 끊임없이 슬롯을 감시한다. 만약 아직 파일 목록이 작성되지 않았다면 0.05초간 실행을 양보하여 CPU를 너무 괴롭히지 않도록 했다. 파일 목록이 있다면 이후부터 빈 슬롯을 찾아 PrepareImage 함수를 호출하여 캐시 비트맵을 작성한다.
PrepareImage 함수는 메모리 비트맵 pBit의 표면에 슬롯의 이미지 파일을 찾아 출력하되 출력 옵션과 윈도우의 크기, 이미지의 크기 등을 고려하여 이미지를 중앙에 적당하게 그린다. 그리고 최종 출력 결과를 캐시 비트맵으로 작성한다. 작업 스레드는 끊임없이 무효화된 캐시 비트맵을 찾아 PrepareImage를 호출하므로 이미지 첨자가 할당된 슬롯에 대해서는 항상 캐시 비트맵을 만들고 있는 것이다.
하나의 비트맵을 작성 완료했으면 일단 Sleep(0)으로 실행 시간을 양보하는데 이는 주 스레드가 완성된 이미지를 최대한 빨리 그리도록 하기 위한 배려이다. 만약 여기서 양보를 하지 않고 캐시 비트맵 작성에 몰두해 버리면 주 스레드의 반응이 늦어지므로 사용자들은 이미지를 좀 더 늦게 보게 될 것이고 그만큼 프로그램의 반응성은 떨어질 것이다. 같은 이유로 세 슬롯을 한 번 점검할 때마다 21만큼 더 쉬어 주 스레드가 좀 더 시간을 쓸 수 있도록 한다. 여기서 21은 한 퀀텀보다 조금 더 긴 시간으로 정해진 값이다.
슬롯은 주 스레드와 작업 스레드가 통신을 하는 주요 수단이다. 그래서 한쪽에서 슬롯을 참조하고 있는 동안 다른쪽에서는 슬롯을 건드리지 말아야 한다. 작업 스레드가 2번 슬롯의 이미지가 무효하다는 것을 확인하고 PrepareImage 함수를 호출했는데 그 사이에 주 스레드가 2번 슬롯의 이미지 첨자를 -1로 지워 버리면 PrepareImage 함수는 목록상의 엉뚱한 첨자를 참조하게 될 것이다. 그래서 슬롯을 참조하는 모든 문장들은 hMutex에 의해 철저하게 동기화된다.
작업 스레드가 이미지를 만들고 있는 동안에는 주 스레드가 슬롯을 변경할 수 없으며 작업이 완료될 때까지 기다려야 한다. 마찬가지로 주 스레드가 슬롯을 조작하고 있는 중이라면 작업 스레드가 슬롯을 참조해서는 안되며 그럴 필요도 없다. 잠시 후 슬롯 정보가 무효화될 예정이므로 작업 스레드는 주 스레드가 슬롯에 정보를 완전히 써 넣을 때까지 대기해야 한다.
이미지 이동
사용자가 3번 이미지를 보고 있는 상태에서 PgDn으로 다음 이미지로 이동한다고 해 보자. PgDn이 눌러지면 MovePicture 함수가 호출되는데 이때의 슬롯 변화는 다음과 같을 것이다.
사용자가 3번 이미지를 감상하는 동안에 작업 스레드는 이미 Slot[2]의 4번 이미지에 대해 캐시 비트맵을 만들어 놓았을 것이다. 그래서 슬롯을 한칸씩 위로 올리기만 하면 Slot[2]가 Slot[1]이 되어 준비된 그림을 즉시 출력할 수 있다. 그리고 Slot[2]는 다음 그림인 5번 그림의 첨자가 기록되며 pCB는 NULL로 초기화되어 아직 캐시 비트맵이 만들어지지 않았음을 기록한다.
PgDn에서 이렇게 슬롯만 조작해 놓으면 작업 스레드가 첨자는 있는데 캐시 비트맵이 없는 슬롯을 찾을 것이고 사용자가 4번 이미지를 감상하는 동안 백그라운드에서 5번 이미지의 캐시 비트맵을 열심히 작성한다. 다시 사용자가 PgDn을 누르면 이번에는 완성된 5번 이미지가 Slot[1]로 올라오고 Slot[2]에는 6번 이미지에 대한 캐시 비트맵 작성 요청이 기록될 것이다. 반대로 만약 PgUp을 누르면 이때는 슬롯이 아래쪽으로 움직여서 앞에 이미 작성했던 캐시 비트맵을 다시 재활용하게 될 것이다.
이런 식으로 이미지를 이동할 때는 슬롯만 회전시켜 작업 스레드가 이미 만들어 놓은 이미지를 쓰기만 한다. 사용자의 손동작보다 스레드가 더 빠르기만 하다면 사용자는 항상 준비된 이미지만 보게 될 것이다. 만약 아직 다음 이미지가 준비되지 않은 상황에서 사용자가 PgDn을 누르면 어떻게 될까? 이때는 어쩔 수 없이 주 스레드가 작업 스레드를 기다리는 수밖에 없다.
옵션의 변경
PrepareImage 함수에서 만드는 캐시 이미지는 앨범 윈도우의 작업 영역과 똑같은 크기를 가지고 있으며 bFitWindow 옵션에 따라 이미지의 크기가 달라진다. 이런 옵션이나 윈도우의 크기가 바뀌면 이미지의 이동이 없더라도 캐시 비트맵을 완전히 다시 만들어야 하는데 이 작업을 하는 함수가 ResetCache 함수이다.
ResetCache는 모든 슬롯의 pCB 객체를 삭제하고 NULL로 만든다. 그러면 작업 스레드가 즉시 이 사실을 눈치채고 변경된 옵션이나 윈도우 크기에 맞게 캐시 비트맵을 다시 작성할 것이다. ResetCache 함수는 Slot[1]의 캐시 비트맵이 완성되는 동안 잠시 대기하는 역할도 하므로 이 함수만 호출하면 옵션에 맞게 그림이 조정되어 화면에 출력될 것이다.
ResetCache 함수는 bFitWindow 옵션이 변경될 때, 그리고 앨범 윈도우의 크기가 바뀔 때 호출된다. 전체 화면 모드로 전환할 때는 앨범 윈도우의 WM_SIZE가 호출되므로 따로 ResetCache 함수를 호출할 필요가 없다.
기타 기능
그림보아는 그림 보기 외에도 몇 가지 편의 기능을 가지고 있다. 상태란에 그림의 총 수, 그림의 크기와 색상 등을 출력하고 마음에 안드는 그림을 삭제할 수 있으며 삭제 표시만 한 후 일괄 삭제하는 기능도 제공된다. 또한 간단하지만 몇 가지 변경 가능한 옵션도 가지고 있다.
설정된 옵션들은 레지스트리에 완벽하게 저장되어 다음번 실행할 때 다시 읽어온다. 이런 기능들도 물론 꽤 많은 코드를 필요로 하지만 이 실습에서 공부하고자 하는 주제는 아니므로 설명은 하지 않기로 한다. 어려운 코드들이 아니므로 직접 분석해 보아라.
업그레이드 힌트
이 예제는 짧은 길이로 간단한 이미지 뷰어를 만드는 것을 목표로 했으므로 상용 프로그램 정도의 고급 기능들은 작성되어 있지 않다. 훨씬 더 많은 기능을 넣을 수 있지만 너무 길어지면 예제로서의 가치가 떨어지므로 이쯤에서 자재하기로 한다. 이런 기능에 대해서는 분석이 완료된 후 직접 작업해 보기 바란다. 다음은 업그레이드해 볼만한 힌트들이다.
■ 이미지를 강제로 확대 또는 축소하는 줌 기능을 추가한다.
■ 이미지가 화면보다 더 커서 일부만 보일 경우 패닝으로 이미지를 이동시킨다.
■ 툴바, 디렉토리 목록 등의 UI를 붙이면 훨씬 더 쓰기 편리하다.
■ 밝기와 대비를 조정하는 옵션을 넣는다. GDI+에 있는 기능이다.
■ 그림을 작게 보는 썸 네일 기능을 작성한다.
■ 그림을 프린터로 인쇄하는 기능을 작성한다.
■ 복사, 이동, 좌우 회전 등 간단한 파일 관리 기능을 제공한다.
■ 스레드 작업중에라도 해당 이미지가 필요없으면 취소시켜 반응성을 높인다. GDI+의 한계로 인해 기술적으로 다소 어렵다. DrawImage가 콜백 함수를 호출하기는 하지만 자주 호출되지 않아 즉시 반응하지 않는 문제가 있다.
■ 그외 필요한 최적화를 한다. 현재 버전은 전체 화면 모드로 변경할 때 WM_SIZE가 두 번 전달되는데 이런 문제만 해결해도 상당한 속도 개선 효과가 있다.
■ 이미지를 바탕 화면 벽지로 만든다.
GrimBoa는 1000줄이 훨씬 넘어서 예제라기보다는 유틸리티 프로그램이라고 해야 할 정도다. 이 프로그램을 만드는데 풀타임으로 대략 사흘 정도가 걸렸는데 아직도 기능상으로는 많이 부족하다. 아주 작은 프로그램임에도 불구하고 개선의 여지가 정말 많다는 것을 알 수 있다.
바.포맷 변환
이미지는 픽셀들의 색상 정보에 대한 집합이므로 이미지 파일은 대단히 크기가 크다. 1024*768 크기의 트루컬러 이미지라면 2M가 훨씬 넘기 때문에 이대로 파일로 저장하면 디스크 공간을 너무 많이 차지할 뿐만 아니라 네트워크로 전송하는 시간도 오래 걸려 무척 불편해진다. 그래서 이미지 파일은 기본적으로 압축을 하는데 압축된 이미지를 화면에 출력하기 위해서는 압축을 먼저 풀어야 한다.
압축된 이미지를 풀어서 그림을 얻는 장치를 디코더(Decoder)라고 하며 반대로 화면에 출력되어 있는 그림을 압축하여 이미지 파일로 만드는 장치를 인코더(Encoder)라고 한다. 이 둘을 합쳐 코덱(codec)이라고 한다. GDI+는 자주 사용되는 몇 가지 포맷에 대한 인코더와 디코더를 내장하고 있으며 이미지를 읽거나 저장할 때 이 모듈을 호출하여 압축 또는 해제를 한다.
GDI+가 제공하는 인코더와 디코더의 목록을 조사하려면 다음 함수들을 사용한다. 이 함수들은 모두 전역 함수들이므로 객체없이 언제든지 호출할 수 있다. GDI+는 스타트 업, 셧 다운 함수와 다음 4가지 함수 등 총 6개의 전역 함수만 가진다.
Status GetImageDecodersSize(UINT *numDecoders, UINT *size);
Status GetImageDecoders(UINT numDecoders, UINT size, ImageCodecInfo *decoders);
Status GetImageEncodersSize(UINT *numEncoders, UINT *size);
Status GetImageEncoders(UINT numEncoders, UINT size, ImageCodecInfo *encoders);
인코더 디코더 각각에 대해 목록의 크기를 조사하는 함수와 목록을 조사하는 함수가 제공된다. 몇 개나 제공되는지 개수를 미리 알 수 없기 때문에 Get*Size 함수로 필요한 메모리 양과 코덱의 개수를 조사하여 이 크기만큼 메모리를 할당한 후 Get* 함수를 호출하는 방식이다. 이 함수로 조사되는 코덱에 대한 정보는 ImageCodecInfo 클래스의 배열이다. 이 클래스는 다음과 같은 멤버들을 가진다.
멤버 |
타입 |
설명 |
Clsid |
CLSID |
코덱의 ID |
FormatID |
GUID |
파일 포맷의 ID이며 Gdiplusimaging.h에 정의되어 있다. |
CodecName |
WCHAR * |
코덱의 이름 문자열이다. |
Dllname |
WCHAR * |
코덱을 가지고 있는 DLL의 이름이며 DLL에 포함된 코덱이 아닐 경우는 NULL이다. |
FormatDescription |
WCHAR * |
코덱에 대한 설명 |
FileNameExtension |
WHCAR * |
코덱과 연결된 파일의 확장자이며 세미콜론으로 구분하여 여러 개의 확장자를 동시에 지정한다. |
MimeType |
WHCAR * |
코덱의 마임 타입이다. |
Flags |
DWORD |
ImageCodecFlags 열거형의 조합 플래그 |
Version |
DWORD |
코덱의 버전 |
SigCount |
DWORD |
파일 포멧이 사용하는 시그네처의 개수 |
SigSize |
DWORD |
시그네처의 크기이며 바이트 단위이다. |
SigPattern |
BYTE * |
각 시그네처의 패턴 |
SigMask |
BYTE * |
각 시그네처의 마스크 |
이중 코덱을 식별하는 가장 중요한 정보는 MimeType이다. MIME(Multipurpose Internet Mail Extensions)이란 인터넷 메일로 전송되는 정보의 형태에 대한 표식이며 보내는 쪽과 받는 쪽이 정보를 해석하는 방식을 지정한다. 예를 들어 보내는 쪽이 text/plain 타입의 메일을 보내면 받는 쪽은 이 문서를 텍스트로 읽어야 하고 text/html 타입의 메일을 보내면 웹 문서로 읽어야 한다.
MIME타입은 인터넷 메일 프로토콜에 대한 확장 규격으로 제안된 것이며 메일에 첨부된 사진, 오디오, 비디오 등의 파일에 대한 포맷을 지정하는데 현재는 메일 외의 문서에서도 포맷 정보 표시를 위해서 MIME 타입을 사용한다. MIME 타입을 쉽게 이해하려면 일반적으로 흔히 얘기하는 포맷과 비슷한 용어라고 생각하면 된다. 다음 예제는 GDI+가 지원하는 인코더와 디코더의 목록을 조사하여 화면으로 출력한다.
예 제 : EncDec |
void OnPaint(HDC hdc)
{
UINT num,size,i;
ImageCodecInfo *arCod;
TCHAR str[128];
int y=-20;
lstrcpy(str,TEXT("인코더 목록"));
TextOut(hdc,0,y+=20,str,lstrlen(str));
GetImageEncodersSize(&num,&size);
arCod=(ImageCodecInfo *)malloc(size);
GetImageEncoders(num,size,arCod);
for (i=0;i<num;i++) {
wsprintf(str,TEXT("MIME=%s, Name=%s, Version=%d,"),
arCod[i].MimeType,arCod[i].CodecName,arCod[i].Version);
TextOut(hdc,0,y+=20,str,lstrlen(str));
}
free(arCod);
y+=20;
lstrcpy(str,TEXT("디코더 목록"));
TextOut(hdc,0,y+=20,str,lstrlen(str));
GetImageDecodersSize(&num,&size);
arCod=(ImageCodecInfo *)malloc(size);
GetImageDecoders(num,size,arCod);
for (i=0;i<num;i++) {
wsprintf(str,TEXT("MIME=%s, Name=%s, Version=%d,"),
arCod[i].MimeType,arCod[i].CodecName,arCod[i].Version);
TextOut(hdc,0,y+=20,str,lstrlen(str));
}
free(arCod);
}
Get*Size 함수로 코덱 배열을 저장하기 위한 메모리 크기를 조사하고 이 크기만큼 메모리를 할당한 후 Get* 함수를 호출하면 코덱의 배열을 돌려준다. 이 배열로부터 원하는 모든 정보를 조사할 수 있다. 코덱과 연결된 파일의 확장자나 설명 등을 얻을 수도 있고 GDI+로 어떤 파일을 다룰 수 있는지도 알게 된다. 실행 결과는 다음과 같다.
GDI+는 다섯개의 인코더와 여덟개의 디코드를 내장하고 있음을 알 수 있다. 대중적으로 많이 사용되는 포맷들은 잘 지원하지만 아직도 충분하지는 않은 편이다. 차후 GDI+의 버전이 높아지면 지원하는 포맷이 더 늘어날 것이다.
코덱 정보 중 가장 자주 사용되는 정보는 코덱의 CLSID인데 특정한 인코더를 사용하고 싶을 때는 CLSID 정보가 필요하다. 디스크의 이미지 파일을 읽을 때는 파일 이름만 주면 GDI+가 포맷을 자동으로 판별하여 적절한 디코더를 호출하지만 저장할 때는 사용자가 어떤 포맷으로 저장하고 싶은지를 지정해야 한다. 이미지를 파일로 저장할 때는 Image 클래스의 다음 멤버 함수를 사용한다.
Status Save(const WCHAR *filename, const CLSID *clsidEncoder, const EncoderParameters *encoderParams);
첫 번째 인수는 저장하고자 하는 파일의 경로이며 두 번째 인수가 어떤 인코딩 방식을 사용할 것인지를 지정하는 인코더 CLSID이다. JPEG 인코더를 사용하면 이미지를 JPG로 저장할 수 있고 BMP 인코더를 사용하면 BMP로 저장할 수도 있는 것이다. 마지막 인수는 인코더로 전달되는 파라미터이며 이미지 파일을 만드는 여러 가지 옵션(예를 들어 압축률, 스캔 라인 순서)을 지정한다.
이미지를 원하는 포맷으로 저장하려면 원하는 포맷의 인코더 CLSID를 알아야 하며 또한 인코더가 특별한 파라미터를 요구할 경우 파라미터를 작성하는 방법도 알아야 한다. 다음 예제는 BMP 파일을 읽어 PNG와 JPG 포맷으로 변환하는 기본적인 방법을 보여 준다.
예 제 : ImageConverter |
#include <tchar.h>
TCHAR BmpPath[MAX_PATH];
BOOL GetEncCLSID(WCHAR *mime, CLSID *pClsid)
{
UINT num,size,i;
ImageCodecInfo *arCod;
BOOL bFound=FALSE;
GetImageEncodersSize(&num,&size);
arCod=(ImageCodecInfo *)malloc(size);
GetImageEncoders(num,size,arCod);
for (i=0;i<num;i++) {
if(wcscmp(arCod[i].MimeType,mime)==0) {
*pClsid=arCod[i].Clsid;
bFound=TRUE;
break;
}
}
free(arCod);
return bFound;
}
void OnPaint(HDC hdc)
{
Graphics G(hdc);
if (lstrlen(BmpPath) != 0) {
Image I(BmpPath);
G.DrawImage(&I,0,20);
}
}
void PrintParaList(WCHAR *MimeType)
{
HDC hdc;
CLSID Clsid;
UINT size,i;
EncoderParameters *pPara;
WCHAR ParaGuid[39];
TCHAR str[128];
int y=0;
if (GetEncCLSID(MimeType,&Clsid) == FALSE) {
return;
}
InvalidateRect(hWndMain,NULL,TRUE);
UpdateWindow(hWndMain);
hdc=GetDC(hWndMain);
Bitmap *pB=new Bitmap(1,1);
size=pB->GetEncoderParameterListSize(&Clsid);
if (size == 0) {
wsprintf(str, TEXT("MIME : %s, 파라미터 없음"), MimeType);
TextOut(hdc,0,y+=20,str,lstrlen(str));
} else {
pPara=(EncoderParameters *)malloc(size);
pB->GetEncoderParameterList(&Clsid,size,pPara);
wsprintf(str, TEXT("MIME : %s, 파라미터 개수 : %d"), MimeType, pPara->Count);
TextOut(hdc,0,y+=20,str,lstrlen(str));
for (i=0;i<pPara->Count;i++) {
StringFromGUID2(pPara->Parameter[i].Guid,ParaGuid,39);
wsprintf(str,TEXT("GUID:%s, 타입:%d, 개수:%d"),ParaGuid,
pPara->Parameter[i].Type,pPara->Parameter[i].NumberOfValues);
TextOut(hdc,0,y+=20,str,lstrlen(str));
}
free(pPara);
}
delete(pB);
ReleaseDC(hWndMain,hdc);
}
void SavePng(TCHAR *Path)
{
TCHAR NewName[MAX_PATH];
Image *pI;
CLSID Clsid;
TCHAR drive[_MAX_DRIVE];
TCHAR dir[_MAX_DIR];
TCHAR fname[_MAX_FNAME];
TCHAR ext[_MAX_EXT];
pI=Image::FromFile(Path);
GetEncCLSID(L"image/png",&Clsid);
_tsplitpath(Path,drive,dir,fname,ext);
wsprintf(NewName,TEXT("%s%s%s.png"),drive,dir,fname);
pI->Save(NewName,&Clsid,NULL);
delete pI;
}
void SaveJpg(TCHAR *Path,ULONG Quality)
{
TCHAR NewName[MAX_PATH];
Image *pI;
CLSID Clsid;
TCHAR drive[_MAX_DRIVE];
TCHAR dir[_MAX_DIR];
TCHAR fname[_MAX_FNAME];
TCHAR ext[_MAX_EXT];
EncoderParameters Para;
pI=Image::FromFile(Path);
GetEncCLSID(L"image/jpeg",&Clsid);
_tsplitpath(Path,drive,dir,fname,ext);
wsprintf(NewName,TEXT("%s%s%s_Q_%03d.jpg"),drive,dir,fname,Quality);
Para.Count=1;
Para.Parameter[0].Guid=EncoderQuality;
Para.Parameter[0].Type=EncoderParameterValueTypeLong;
Para.Parameter[0].NumberOfValues=1;
Para.Parameter[0].Value=&Quality;
pI->Save(NewName,&Clsid,&Para);
delete pI;
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
TCHAR *Mes=TEXT("B:비트맵 읽기, P:PNG로 저장, J:Jpeg로 저장, 1~5:파라미터 목록 출력");
OPENFILENAME OFN;
TCHAR lpstrFile[MAX_PATH]=TEXT("");
switch(iMessage) {
case WM_CREATE:
hWndMain=hWnd;
return 0;
case WM_KEYDOWN:
switch (wParam) {
case TEXT('B'):
memset(&OFN, 0, sizeof(OPENFILENAME));
OFN.lStructSize = sizeof(OPENFILENAME);
OFN.hwndOwner=hWnd;
OFN.lpstrFilter=TEXT("비트맵 파일(*.BMP)\0*.BMP\0");
OFN.lpstrFile=lpstrFile;
OFN.nMaxFile=MAX_PATH;
if (GetOpenFileName(&OFN)!=0) {
lstrcpy(BmpPath,lpstrFile);
InvalidateRect(hWnd,NULL,TRUE);
}
break;
case TEXT('P'):
if (lstrlen(BmpPath) != 0) {
SavePng(BmpPath);
MessageBox(hWnd,TEXT("PNG 포맷으로 변경했습니다"),TEXT("알림"),MB_OK);
}
break;
case TEXT('J'):
if (lstrlen(BmpPath) != 0) {
SaveJpg(BmpPath,0);
SaveJpg(BmpPath,5);
SaveJpg(BmpPath,10);
SaveJpg(BmpPath,50);
SaveJpg(BmpPath,100);
MessageBox(hWnd,TEXT("JPG 포맷으로 변경했습니다"),TEXT("알림"),MB_OK);
}
break;
case TEXT('1'):
PrintParaList(TEXT("image/jpeg"));
break;
case TEXT('2'):
PrintParaList(TEXT("image/png"));
break;
case TEXT('3'):
PrintParaList(TEXT("image/bmp"));
break;
case TEXT('4'):
PrintParaList(TEXT("image/gif"));
break;
case TEXT('5'):
PrintParaList(TEXT("image/tiff"));
break;
}
return 0;
case WM_PAINT:
hdc=BeginPaint(hWnd, &ps);
TextOut(hdc,0,0,Mes,lstrlen(Mes));
OnPaint(hdc);
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
사용 방법은 아주 간단해서 B 키를 누르면 BMP 파일을 읽어 오고 P키는 이미지를 PNG 포맷으로, J키는 JPG 포맷으로 변환하여 저장한다. B키 처리 코드는 파일 열기 공통 대화상자로 파일명을 입력받아 전역 변수 BmpPath에 저장하고 화면을 무효화하는 간단한 처리를 한다. OnPaint에서는 BmpPath에 파일명이 들어 있을 경우 이 파일을 읽어와 화면에 출력하기만 한다.
포맷을 변환하는 코드 중에 상대적으로 간단한 PNG 변환 코드부터 보자. 이 함수는 PNG 인코더의 CLSID를 조사하기 위해 GetEncCLSID라는 도우미 함수를 호출한다. GetEncCLSID 함수는 인코더의 목록을 모두 조사한 후 마임 타입과 일치하는 인코더의 CLSID를 조사한다. PNG 인코더의 마임 타입이 "image/png"라는 것을 이미 알고 있으므로 이 이름으로부터 CLSID를 구해 Save 함수의 두 번째 인수로 넘기기만 하면 된다. 확장자를 png로 바꾸는 코드 때문에 길어 보이지만 알맹이는 결국 Save 함수 호출문밖에 없다.
프로젝트 디렉토리의 bird.bmp를 읽어 bird.png로 바꾸어 보자. bmp는 440K인데 비해 png는 236K로 절반 정도로 압축된다. png는 비손실 압축이기 때문에 압축 효율은 그다지 좋지 못하다. 두 파일은 완전히 동일한 이미지를 가진다.
다음은 JPG로 변환하는 방법에 대해 알아 보자. JPG는 다른 그래픽 포맷과는 달리 손실 압축을 사용하는데 어느 정도로 이미지를 압축할 것인가를 파라미터로 지정할 수 있다. 압축을 많이 하면 이미지의 정보가 많이 손실되므로 품질이 떨어지지만 파일 크기는 작아진다. 반면 압축을 너무 적게 하면 이미지의 품질은 좋아지지만 파일의 크기가 커지는 단점이 있으므로 적당한 수준에서 압축률을 선택하는 것이 좋다.
JPG 인코더의 압축률처럼 각 인코더는 고유의 파라미터를 가질 수 있는데 이 파라미터 목록은 다음 함수들로 구할 수 있다. 코덱과 마찬가지로 파라미터도 개수와 타입이 가변적이기 때문에 크기를 구하는 함수와 실제 파라미터를 구하는 함수가 따로 제공되며 사용하는 방법도 거의 비슷하다. 크기를 먼저 구하고 배열에 통채로 조사한다.
UINT GetEncoderParameterListSize(const CLSID *clsidEncoder);
Status GetEncoderParameterList(const CLSID *clsidEncoder, UINT size, EncoderParameters *buffer);
인코더의 파라미터는 EncoderParameters 클래스로 표현되는데 이 클래스에는 파라미터의 개수를 가지는 Count와 파라미터 배열 Parameter[]가 포함되어 있으며 각 파라미터는 다음과 같은 멤버를 가지는 EncoderParameter 클래스로 표현된다.
멤버 |
설명 |
Guid |
파라미터의 고유 식별자이며 전체 목록은 Gdiplusimaging.h 헤더 파일을 참조하기 바란다. |
NumberOfValues |
Value배열에 몇 개의 값이 들어 있는지를 나타낸다. |
Type |
파라미터의 타입을 지정하는 EncoderParameterValueType 열거형이다. |
Value |
값의 배열이다. |
이 값들로부터 파라미터의 의미와 타입, 값을 조사하거나 지정할 수 있다. 예제의 PrintParaList 함수에는 인수로 전달된 인코더의 파라미터 개수와 각 파라미터의 GUID, 타입, 값 개수 등을 조사해 출력하는 코드가 작성되어 있으므로 참고하기 바란다. JPEG는 4개, TIFF는 3개의 파라미터를 가지며 나머지 포맷은 파라미터를 가지지 않는다. 물론 이는 현재 버전의 GDI+에서 그렇다는 것이지 다음 버전에서는 달라질 수도 있다.
JPG 포맷으로 저장할 때는 압축률을 지정하는 파라미터를 줄 수 있는데 압축률은 EncodeQuality GUID를 가지며 Long 타입의 압축률값 하나를 지정할 수 있다. 압축률은 0~100까지의 범위 중 하나를 선택할 수 있는데 0이 가장 압축률이 높고 100이 가장 품질이 좋다. 예제의 SaveJpg 함수는 압축률을 인수로 받아들여 인코더에게 이 인수의 값을 파라미터로 전달한다. Para에 압축률 지정 파라미터 하나만 넣고 Save 함수의 세 번째 인수로 Para의 포인터를 넘기면 인코더는 이 파라미터의 지시대로 압축한다.
예제에서는 J키를 누를 때 압축률을 바꿔 가며 여러 가지 버전의 이미지 파일을 생성하는데 이때 각 파일명의 끝에는 Q_압축률을 붙여 어떤 품질로 압축된 파일인지를 쉽게 알아 볼 수 있도록 했다. 압축률에 따라 크기와 품질에 어떤 차이가 발생하는지 변환된 결과를 보자. 100%의 압축률을 적용하면 파일 크기는 98K가 되며 이미지는 원본과 거의 차이가 없다. 50%로 압축하면 11K로 대폭 줄어들지만 품질은 다소 떨어진다.
육안으로 보기에는 원본과 별 차이가 없어 보이지만 색상 변화가 많은 부분에서 디테일이 다소 떨어진다. 10%로 압축하면 4.9K로 줄어 거의 백배 가까이 압축되지만 품질은 만족스럽지 못하다. 과하게 압축을 하면 색상 정보를 많이 버려야 하므로 품질이 떨어질 수밖에 없다.
다음은 0%, 즉 최대 압축률로 압축해 본 것이다. 크기는 3.4K이며 130배나 압축되지만 그림은 거의 못아볼 지경이다.
변환 결과를 비교해 보면 압축률에 따라 이미지의 크기와 품질에 상당한 차이가 있음을 확인할 수 있다. 실제 프로그램에서는 대화상자를 통해 사용자에게 원하는 압축률을 선택하도록 하거나 아니면 내정된 압축률을 가지고 있어야 할 것이다.
사.이미지 변형
GDI+에는 비록 간단하기는 하지만 이미지의 여러 가지 속성을 편집할 수 있는 기능이 있다. 다음 예제는 GDI+로 할 수 있는 이미지의 여러 가지 조작 방법을 보여 준다. 여러 가지 편집 명령들이 필요하므로 메뉴를 만들어 두었다. 파일 메뉴에서 이미지를 읽은 후 이미지 메뉴의 여러 가지 명령을 사용해서 이미지를 조작한다.
예 제 : ImageAdjust |
#include "resource.h"
int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance
,LPSTR lpszCmdParam,int nCmdShow)
{
....
WndClass.lpszMenuName=MAKEINTRESOURCE(IDR_MENU1);
}
Image *pImage=NULL;
void OnPaint(HDC hdc)
{
Graphics G(hdc);
if (pImage) {
G.DrawImage(pImage,0,0,pImage->GetWidth(),pImage->GetHeight());
}
}
void OpenFile()
{
OPENFILENAME OFN;
TCHAR lpstrFile[MAX_PATH]=TEXT("");
memset(&OFN, 0, sizeof(OPENFILENAME));
OFN.lStructSize = sizeof(OPENFILENAME);
OFN.hwndOwner=hWndMain;
OFN.lpstrFilter=TEXT("모든 그래픽 파일\0*.bmp;*.jpg;*.gif;*.png\0모든 파일(*.*)\0*.*\0");
OFN.lpstrFile=lpstrFile;
OFN.nMaxFile=MAX_PATH;
if (GetOpenFileName(&OFN)!=0) {
if (pImage) {
delete pImage;
}
pImage=Image::FromFile(lpstrFile);
InvalidateRect(hWndMain,NULL,TRUE);
}
}
void Threshold(float value)
{
Graphics *pG=Graphics::FromImage(pImage);
ImageAttributes IA;
INT Width,Height;
Width=pImage->GetWidth();
Height=pImage->GetHeight();
IA.SetThreshold(value,ColorAdjustTypeDefault);
pG->DrawImage(pImage,Rect(0,0,Width,Height),
0,0,Width,Height,UnitPixel,&IA);
delete pG;
}
void Gamma(float value)
{
Graphics *pG=Graphics::FromImage(pImage);
ImageAttributes IA;
INT Width,Height;
Width=pImage->GetWidth();
Height=pImage->GetHeight();
IA.SetGamma(value,ColorAdjustTypeDefault);
pG->DrawImage(pImage,Rect(0,0,Width,Height),
0,0,Width,Height,UnitPixel,&IA);
delete pG;
}
void Lighten(float value)
{
Graphics *pG=Graphics::FromImage(pImage);
ImageAttributes IA;
INT Width,Height;
ColorMatrix matrix = {
1.0f, 0.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
value, value, value, 0.0f, 1.0f
};
Width=pImage->GetWidth();
Height=pImage->GetHeight();
IA.SetColorMatrix(&matrix,ColorMatrixFlagsDefault,ColorAdjustTypeBitmap);
pG->DrawImage(pImage,Rect(0,0,Width,Height),
0,0,Width,Height,UnitPixel,&IA);
delete pG;
}
void Negative()
{
Graphics *pG=Graphics::FromImage(pImage);
ImageAttributes IA;
INT Width,Height;
ColorMatrix matrix = {
-1.0f, 0.0f, 0.0f, 0.0f, 0.0f,
0.0f, -1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 1.0f, 1.0f, 0.0f, 1.0f
};
Width=pImage->GetWidth();
Height=pImage->GetHeight();
IA.SetColorMatrix(&matrix,ColorMatrixFlagsDefault,ColorAdjustTypeBitmap);
pG->DrawImage(pImage,Rect(0,0,Width,Height),
0,0,Width,Height,UnitPixel,&IA);
delete pG;
}
void GrayScale()
{
Graphics *pG=Graphics::FromImage(pImage);
ImageAttributes IA;
INT Width,Height;
ColorMatrix matrix = {
0.299f, 0.299f, 0.299f, 0.0f, 0.0f,
0.587f, 0.587f, 0.587f, 0.0f, 0.0f,
0.114f, 0.114f, 0.114f, 0.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 0.0f, 1.0f
};
Width=pImage->GetWidth();
Height=pImage->GetHeight();
IA.SetColorMatrix(&matrix,ColorMatrixFlagsDefault,ColorAdjustTypeBitmap);
pG->DrawImage(pImage,Rect(0,0,Width,Height),
0,0,Width,Height,UnitPixel,&IA);
delete pG;
}
LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
switch(iMessage) {
case WM_CREATE:
hWndMain=hWnd;
return 0;
case WM_COMMAND:
switch (wParam) {
case IDM_FILE_OPEN:
OpenFile();
break;
case IDM_IMAGE_HORZFLIP:
if (pImage) {
pImage->RotateFlip(RotateNoneFlipX);
InvalidateRect(hWnd,NULL,FALSE);
}
break;
case IDM_IMAGE_VERTFLIP:
if (pImage) {
pImage->RotateFlip(RotateNoneFlipY);
InvalidateRect(hWnd,NULL,FALSE);
}
break;
case IDM_IMAGE_LEFTROTATE:
if (pImage) {
pImage->RotateFlip(Rotate270FlipNone);
InvalidateRect(hWnd,NULL,TRUE);
}
break;
case IDM_IMAGE_RIGHTROTATE:
if (pImage) {
pImage->RotateFlip(Rotate90FlipNone);
InvalidateRect(hWnd,NULL,TRUE);
}
break;
case IDM_IMAGE_180ROTATE:
if (pImage) {
pImage->RotateFlip(Rotate180FlipNone);
InvalidateRect(hWnd,NULL,TRUE);
}
break;
case IDM_IMAGE_THRESHOLD:
if (pImage) {
Threshold(0.5f);
InvalidateRect(hWnd,NULL,TRUE);
}
break;
case IDM_IMAGE_GAMMA:
if (pImage) {
Gamma(0.5f);
InvalidateRect(hWnd,NULL,TRUE);
}
break;
case IDM_IMAGE_LIGHT:
if (pImage) {
Lighten(0.1f);
InvalidateRect(hWnd,NULL,FALSE);
}
break;
case IDM_IMAGE_DARK:
if (pImage) {
Lighten(-0.1f);
InvalidateRect(hWnd,NULL,FALSE);
}
break;
case IDM_IMAGE_NEGATIVE:
if (pImage) {
Negative();
InvalidateRect(hWnd,NULL,FALSE);
}
break;
case IDM_IMAGE_GRAYSCALE:
if (pImage) {
GrayScale();
InvalidateRect(hWnd,NULL,FALSE);
}
break;
}
return 0;
case WM_PAINT:
hdc=BeginPaint(hWnd, &ps);
OnPaint(hdc);
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
if (pImage) {
delete pImage;
}
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}
이미지를 회전하거나 대칭 모양을 만들 때는 다음 함수를 사용하며 인수로 회전 또는 대칭 등의 동작을 지정하는 열거 상수 중 하나를 지정한다.
Status RotateFlip(RotateFlipType rotateFlipType);
typedef enum {
RotateNoneFlipNone = 0,
Rotate90FlipNone = 1,
Rotate180FlipNone = 2,
Rotate270FlipNone = 3,
RotateNoneFlipX = 4,
Rotate90FlipX = 5,
Rotate180FlipX = 6,
Rotate270FlipX = 7,
RotateNoneFlipY = Rotate180FlipX,
Rotate90FlipY = Rotate270FlipX,
Rotate180FlipY = RotateNoneFlipX,
Rotate270FlipY = Rotate90FlipX,
RotateNoneFlipXY = Rotate180FlipNone,
Rotate90FlipXY = Rotate270FlipNone,
Rotate180FlipXY = RotateNoneFlipNone,
Rotate270FlipXY = Rotate90FlipNone
} RotateFlipType;
하나의 열거형으로 4방향의 회전과 4가지 종류의 대칭을 모두 처리하므로 열거 상수의 개수는 총 16개이지만 중복되는 동작이 있어 실제로 의미있는 값은 8개밖에 되지 않는다. 예를 들어 수직 대칭은 180도 회전시킨 후 수평으로 대칭시키는 것과 같은 셈이다. 차 그림을 읽어 놓고 수평 대칭 명령을 내리면 차의 방향이 바뀔 것이다.
다음은 이미지의 색상을 변형하는 방법에 대해 알아 보자. 이때는 ImageAttributes 클래스를 사용하는데 이 클래스는 이미지나 메타파일을 그릴 때 색상을 어떻게 다룰 것인가에 대한 정보를 가진다. 이 클래스의 여러 가지 멤버 함수를 통해 색상 조정 정보를 변경하면 이미지를 출력할 때 원래 이미지와는 조금 다른 색상으로 그릴 수 있다. 이 클래스를 사용하면 다음 원형의 DrawImage 함수로 이미지를 출력해야 한다.
Status DrawImage(Image *image, const Rect &destRect, INT srcx, INT srcy, INT srcwidth, INT srcheight, Unit srcUnit, ImageAttributes *imageAttributes, DrawImageAbort callback, VOID *callbackData);
이 함수의 마지막 세 인수는 생략 가능한데 ImageAttributes를 생략하거나 NULL로 지정하면 이미지의 원 색상 그대로 출력되지만 이 객체를 만든 후 인수로 전달하면 객체에 포함된 색상 재배치 정보를 참조하여 이미지의 색상을 변형하여 그린다. ImageAttributes 클래스에는 색상 재배치 정보를 지정하는 많은 멤버 함수들이 포함되어 있는데 가장 이해하기 쉬운 다음 함수부터 실습해 보자.
Status SetThreshold(REAL threshold, ColorAdjustType type);
이 함수는 색상값을 버릴 임계치를 지정하는데 각 색상 요소(R,G,B)가 이 함수가 지정하는 임계치보다 더 작을 경우 0으로, 크면 255로 바꾼다. 첫 번째 인수로 임계치를 지정하는데 0~1사이의 실수를 지정하며 이 값을 기준으로 색상값을 조작한다. 이 값이 0.5라면 128이 기준이 되며 0.2라면 255*0.2=51이 기준이 될 것이다. 기준값보다 더 작은 값은 0이 되고 더 큰 값은 255가 되는데 예를 들어 임계치가 0.5이고 한 점의 색상이 (120,130,190)이라면 128보다 더 작은 120은 0이 되고 130, 190은 255가 되어 결과 색은 (0,255,255)가 된다.
임계치를 적용하면 모든 색상 요소는 임계치를 기준으로 0 아니면 255가 되어 원색 계통의 색상만 남게 되어 이미지의 색상 구조가 아주 단순해질 것이다. 이런 효과는 사진을 포스터 형태로 변색시키고자 할 때 흔히 사용된다. 예제에서는 임계치를 0.5로 지정하여 중간쯤을 기준으로 색상을 변경시킨다.
색상이 획일적으로 통일되어 사진이 그림처럼 바뀌어 버린다. 두 번째 인수는 다음 열거형 중 하나를 지정한다.
typedef enum {
ColorAdjustTypeDefault = 0,
ColorAdjustTypeBitmap = 1,
ColorAdjustTypeBrush = 2,
ColorAdjustTypePen = 3,
ColorAdjustTypeText = 4,
ColorAdjustTypeCount = 5,
ColorAdjustTypeAny = 6
} ColorAdjustType;
ImageAttributes 객체는 다섯 가지의 색상 조정 대상을 관리하는데 이 열거값으로 어떤 대상에 대해 색상 조정을 적용할 것인가를 지정한다. Default는 별도의 색상 조정 상태를 가지지 않는 모든 대상에 대해 적용하는 것이며 Bitmap은 비트맵 이미지에 대해 적용한다. 나머지 브러시, 펜, 텍스트는 메타 파일을 출력할 때 GDI 오브젝트의 색상을 조정한다.
Threshold 함수는 현재 열려진 이미지 pImage로부터 Graphics 객체 pG를 생성하고 이 표면에 다시 pImage를 출력하되 이때 임계치를 적용했다. pG의 표면이 곧 pImage인 상태에서 이 이미지를 다시 출력하므로 결국 pImage의 이미지에 임계치가 적용되는 것이다. 같은 이미지에 대해 임계치를 두 번 적용하는 것은 아무런 의미가 없다. 다음 함수는 감마와 색상 행렬을 적용한다.
Status SetGamma(REAL gamma, ColorAdjustType type);
Status SetColorMatrix(const ColorMatrix *colorMatrix, ColorMatrixFlags mode, ColorAdjustType type);
감마값도 임계치와 똑같은 절차대로 적용되는데 결과 색상값을 계산하는 방식이 다르며 이미지가 조금 밝아지는 효과가 있다. 좀 더 복잡한 색상 변환은 색상 행렬을 사용한다. ColorMatrix 구조체로 표현되는 색상 행렬은 5*5 정방 행렬이며 원본 색상에 이 행렬을 곱해 결과 색상을 만들어낸다. 색상을 구성하는 요소는 0~255까지의 강도를 가지지만 색상 행렬이 적용될 때는 0~1사이의 실수로 표현된다.
GDI+에서 색상을 구성하는 요소는 R,G,B,A 네 가지이지만 여기에 더미값 하나를 더해 5*5 행렬을 곱하면 각 요소끼리의 변환 방정식을 만들 수 있다. 마지막 행은 각 요소에 더해지는 값이며 마지막 열은 더미값이므로 모든 행이 0이되 5행 5열은 반드시 1이어야 한다. 어떤 색상 (r1,g1,b1,a1,d1)에 대해 5*5 행렬을 곱하면 결과 색상 (r2,g2,b2,a2,d2)가 나올 것이다.
입력 색상의 d1은 항상 1이며 m04~m34까는 항상 0이며 m44는 항상 1이므로 출력 색상의 d2도 항상 1이다. 이 값들은 어디까지나 계산을 도와줄 뿐이다. 결과 색상은 다음과 같은 방정식으로 계산된다.
r2=r1*m00 + g1*m10 + b1*m20 + a1*m30 + m40
g2=r1*m01 + g1*m11 + b1*m21 + a1*m31 + m41
b2=r1*m02 + g1*m12 + b1*m22 + a1*m32 + m42
a2=r1*m03 + g1*m13 + b1*m23 + a1*m33 + m43
d2=r1*m04 + g1*m14 + b1*m24 + a1*m34 + m44 = 항상 1
만약 색상 행렬이 대각선 방향만 1이고 나머지는 모두 0인 단위 행렬이라면 입력 색상은 그대로 출력 색상이 되겠지만 그렇지 않다면 각 색상 요소끼리 서로 영향을 미쳐 다른 결과가 나올 것이다. 결국 색상 행렬은 결과값을 만들어 내는 4개의 방정식을 구성하는 곱해지는 값, 더해지는 값의 집합인 셈이다. 밝기를 조정하는 Lighten 함수에 의해 만들어지는 결과 색상은 다음과 같아진다.
r2=r1+value;
g2=g1+value;
b2=b1+value;
a2=a1;
색상 행렬에 의해 이런 4개의 방정식이 만들어지고 이 방정식의 결과대로 각 픽셀의 점을 조정한다. 따라서 value가 양수이면 밝아질 것이고 음수이면 어두워질 것이다. 예제에서는 value로 0.1과 -0.1을 지정했으므로 10%씩 밝아지거나 어두워진다. 다음은 코스모스 그림을 열어 놓고 밝게와 어둡게를 각각 두번씩 실행한 결과이다.
이미지를 역상으로 만들 때는 1.0에서 원래 값을 빼면 되는데 r2=1.0-r1에 의해 색상값이 반대로 뒤집어지는 결과가 나온다. 검정색은 흰색이 되고 색상값은 보색으로 바뀐다. 다음은 버섯 그림을 열어 놓고 역상을 적용한 것인데 네거티브 사진처럼 보인다.
그레이스케일로 만드는 행렬은 다소 복잡한데 각 색상 요소끼리 일정 비율을 곱한 값을 취해 똑같은 강도를 가지도록 함으로써 회색 계통의 색상이 되도록 했다. 이때 눈에 가장 강하게 띄는 G가 좀 더 큰 비율을 가지고 R이 그 다음, B가 가장 작은 비율로 영향을 미친다. 초록색이 가장 밝기 때문에 그레이 스케일로 바뀔 때 가장 많은 비중을 차지한다. RGB각각 29:58:14의 비율로 섞이는데 이 비율은 그래픽 전문가들의 연구 결과 가장 자연스러운 그레이 스케일을 만드는 비율이다.
원래 컬러로 찍은 사진인데 그레이 스케일로 변환하여 흑백 사진 효과를 내 보았다. 흑백으로 봐도 애들이 어쩜 저렇게 예쁠까?
'MS > API' 카테고리의 다른 글
[API] WNetAddConnection2 (0) | 2010.07.07 |
---|---|
[API] LogonUser (0) | 2010.07.07 |