81.3 픽셀을 아스키 문자로 저장하기

이제 앞에서 정의한 각 구조체를 사용하여 이미지의 픽셀에 접근해보고, 각 픽셀을 ASCII 문자로 저장해보겠습니다.

다음은 앞으로 비트맵 파일을 ASCII 문자로 저장하는 과정입니다.

그림 81‑4 비트맵 파일을 ASCII 문자로 저장하는 과정
그림 81-4 

먼저 다음과 같이 자주 사용하는 고정값은 매크로로 정의합니다. 코드에서 3과 4를 그대로 사용하면 3과 4가 계속 나올 때마다 이 숫자는 무엇을 뜻하는지 한 번 더 생각해야 합니다. 하지만 PIXEL_SIZE, PIXEL_ALIGN과 같이 이름을 정해주면 의미가 명확해지므로 코드가 읽기 쉬워집니다.

  • PIXEL_SIZE: 픽셀 한 개의 크기입니다. 24비트 비트맵은 파랑(B), 초록(G), 빨강(R)을 1바이트씩 저장하므로 3바이트입니다.
  • PIXEL_ALIGN: 픽셀 데이터의 가로 한 줄에서 남는 공간(padding)을 구하기 위한 정렬값입니다(비트맵 포맷은 픽셀 데이터의 가로 한 줄을 저장할 때 4의 배수 크기로 저장합니다). 이 부분은 뒤에서 자세히 설명하겠습니다.
#define PIXEL_SIZE   3    // 픽셀 한 개의 크기 3바이트(24비트)
#define PIXEL_ALIGN  4    // 픽셀 데이터 가로 한 줄은 4의 배수 크기로 저장됨

이제 main 함수에서 비트맵 파일 포인터, 출력 결과를 저장할 텍스트 파일 포인터, 비트맵 파일 헤더 구조체 변수, 비트맵 정보 헤더 구조체 변수를 선언합니다. 그리고 픽셀 데이터를 읽기 위한 포인터, 픽셀 데이터 크기, 비트맵 이미지의 가로와 세로 크기, 픽셀 데이터의 가로 크기가 4의 배수가 아닐 때 남는 공간을 저장할 변수를 선언합니다.

int main()
{
    FILE *fpBmp;                    // 비트맵 파일 포인터
    FILE *fpTxt;                    // 텍스트 파일 포인터
    BITMAPFILEHEADER fileHeader;    // 비트맵 파일 헤더 구조체 변수
    BITMAPINFOHEADER infoHeader;    // 비트맵 정보 헤더 구조체 변수

    unsigned char *image;    // 픽셀 데이터 포인터
    int size;                // 픽셀 데이터 크기
    int width, height;       // 비트맵 이미지의 가로, 세로 크기
    int padding;             // 픽셀 데이터의 가로 크기가 4의 배수가 아닐 때 남는 공간의 크기

각 픽셀을 표현할 ASCII 문자를 배열로 만듭니다. 비트맵 이미지에서 픽셀의 RGB 색상값이 모두 0이면 검정색이고, 모두 255이면 흰색입니다. 즉, 값이 작을수록 어두워지고 값이 클수록 밝아집니다. 따라서 배열의 낮은 인덱스에는 획수가 많은 문자를 배치하고 높은 인덱스에는 획수가 적은 문자를 배치합니다. 그리고 가장 큰 인덱스에는 공백 문자를 넣습니다.

    // 각 픽셀을 표현할 ASCII 문자. 인덱스가 높을 수록 밝아지는 것을 표현
    char ascii[] = { '#', '#', '@', '%', '=', '+', '*', ':', '-', '.', ' ' };    // 11개

fopen 함수로 비트맵 파일을 바이너리 모드(rb)로 연 뒤 fread 함수로 비트맵 파일 헤더를 읽습니다. 그다음에 비트맵 파일이 맞는지 확인하기 위해 bfType의 값이 'MB'가 맞는지 확인합니다(2바이트 크기의 'BM'을 리틀 엔디언 방식으로 읽었으므로 'B''M'이 뒤집혀서 'MB'가 됩니다). 만약 이 값이 맞지 않으면 비트맵 파일이 아닙니다.

    fpBmp = fopen("Lenna80x80.bmp", "rb");    // 비트맵 파일을 바이너리 모드로 열기
    if (fpBmp == NULL)   // 파일 열기에 실패하면
        return 0;        // 프로그램 종료

    // 비트맵 파일 헤더 읽기. 읽기에 실패하면 파일 포인터를 닫고 프로그램 종료
    if (fread(&fileHeader, sizeof(BITMAPFILEHEADER), 1, fpBmp) < 1)
    {
        fclose(fpBmp);
        return 0;
    }

    // 매직 넘버가 MB가 맞는지 확인(2바이트 크기의 BM을 리틀 엔디언으로 읽었으므로 MB가 됨)
    // 매직 넘버가 맞지 않으면 프로그램 종료
    if (fileHeader.bfType != 'MB')
    {
        fclose(fpBmp);
        return 0;
    }

fread 함수로 sizeof(BITMAPFILEHEADER)만큼 1번 읽었을 때 읽기에 성공하면 읽은 횟수 1을 반환하고, 실패하면 1보다 작은 수를 반환합니다. 따라서 파일 읽기에 실패했을 때는 파일 포인터를 닫고 프로그램을 종료합니다. 이후에도 각종 실패 상황이나 조건에 맞지 않으면 앞에서 열었던 파일 포인터를 닫고 프로그램을 종료해줍니다.

이번에는 비트맵 정보 헤더를 읽습니다. 비트맵 정보 헤더는 비트맵 파일 헤더 바로 뒤에 있으므로 파일 포인터를 이동시키지 않고 바로 fread 함수로 읽으면 됩니다. 비트맵 정보 헤더를 읽었으면 biBitCount의 값이 24인지 확인합니다. 이번 예제에서는 24비트 비트맵만 처리할 것이므로 24가 아니면 파일 포인터를 닫고 프로그램을 종료합니다.

    // 비트맵 정보 헤더 읽기. 읽기에 실패하면 파일 포인터를 닫고 프로그램 종료
    if (fread(&infoHeader, sizeof(BITMAPINFOHEADER), 1, fpBmp) < 1)
    {
        fclose(fpBmp);
        return 0;
    }

    // 24비트 비트맵이 아니면 프로그램 종료
    if (infoHeader.biBitCount != 24)
    {
        fclose(fpBmp);
        return 0;
    }

픽셀 데이터의 크기, 이미지의 가로, 세로 크기와 같이 자주 사용하는 정보는 변수에 따로 저장해놓습니다. 매번 구조체에서 끌어다 써도 상관없지만 변수에 저장해놓고 사용하면 코드가 좀 더 간단해집니다(타이핑도 줄일 수 있죠).

    size = infoHeader.biSizeImage;    // 픽셀 데이터 크기
    width = infoHeader.biWidth;       // 비트맵 이미지의 가로 크기
    height = infoHeader.biHeight;     // 비트맵 이미지의 세로 크기

이번에는 픽셀 데이터의 가로 공간이 저장될 때 남는 공간(padding)을 구해야 합니다. 비트맵 포맷은 픽셀의 가로 한 줄을 저장할 때 4의 배수 크기로 저장하는데 만약 가로 한 줄의 크기가 4의 배수가 아니라면 남는 공간은 0으로 채워서 저장합니다. 따라서 픽셀 데이터를 읽기 위해서는 남는 공간을 알고 있어야 합니다.

이런 방식을 사용하는 이유는 CPU가 데이터를 처리할 때 4바이트 크기가 효율적이기 때문입니다. 예를 들어 3, 7, 11처럼 4로 나누어 떨어지지 않는 크기보다는 4, 8, 12처럼 4로 나누어 떨어지는 크기가 효율적입니다. 데이터 크기를 4의 배수로 맞추고 0을 채워서 저장 공간이 좀 늘더라도 성능을 위해 이렇게 만들어져 있습니다.

남는 공간을 구하기 전에 먼저 나머지를 구해야 되는데 계산식은 (가로 크기 * PIXEL_SIZE) % PIXEL_ALIGN이 됩니다. 예를 들어 가로 크기가 27일 때 나머지를 구하면 다음과 같습니다.

(27 * PIXEL_SIZE) % PIXEL_ALIGN
(27 * 3) % 4
81 % 4
1

나머지는 1이 나왔습니다. 여기서 남는 공간을 구하려면 배수 4에서 1을 빼면 됩니다.

PIXEL_ALIGN - 1
4 - 1
3

따라서 남는 공간은 3이 됩니다. 만약 가로 크기가 20이라면 어떻게 될까요?

(20 * PIXEL_SIZE) % PIXEL_ALIGN
(20 * 3) % 4
60 % 4
0

나머지가 0이 나왔습니다. 하지만 이렇게 되면 4에서 0을 뺐을 때 4가 나오므로 남는 공간이 0이 아닌 4가 되어버립니다. 잘못된 값이죠.

PIXEL_ALIGN - 0
4 - 0
4      // 남는 공간이 잘못된 값이 나옴

따라서 나머지가 0일 때를 대비해서 PIXEL_ALIGN - 나머지 값을 다시 한번 PIXEL_ALIGN으로 나머지 연산을 해줍니다.

(PIXEL_ALIGN - 0) % PIXEL_ALIGN
(4 - 0) % 4
4 % 4
0      // 나누어 떨어지므로 나머지는 0

이제 최종 남는 공간이 0이 나왔습니다. 만약 PIXEL_ALIGN - 나머지PIXEL_ALIGN - 1이라 하더라도 3 % 4가 되므로 3을 그대로 구할 수 있습니다.

(PIXEL_ALIGN - 1) % PIXEL_ALIGN
(4 - 1) % 4
3 % 4
3       // 3은 4로 나누어지지 않고(몫은 0), 3이 남음

지금까지 알아본 계산식을 코드로 만들면 다음과 같이 됩니다.

    // 이미지의 가로 크기에 픽셀 크기를 곱하여 가로 한 줄의 크기를 구하고 4로 나머지를 구함
    // 그리고 4에서 나머지를 빼주면 남는 공간을 구할 수 있음.
    // 만약 남는 공간이 0이라면 최종 결과가 4가 되므로 여기서 다시 4로 나머지를 구함
    padding = (PIXEL_ALIGN - ((width * PIXEL_SIZE) % PIXEL_ALIGN)) % PIXEL_ALIGN;

비트맵 파일 중에 픽셀 데이터의 크기 size가 0인 경우가 있습니다. 이때는 이미지의 가로 크기에 픽셀 크기를 곱한 뒤 남는 공간을 더해주면 완전한 가로 한 줄의 크기를 구할 수 있습니다. 그리고 여기에 이미지의 세로 크기를 곱해주면 픽셀 데이터의 크기를 구할 수 있습니다.

    if (size == 0)    // 픽셀 데이터 크기가 0이라면
    {
        // 이미지의 가로 크기 * 픽셀 크기에 남는 공간을 더해주면 완전한 가로 한 줄 크기가 나옴
        // 여기에 이미지의 세로 크기를 곱해주면 픽셀 데이터의 크기를 구할 수 있음
        size = (width * PIXEL_SIZE + padding) * height;
    }

픽셀 데이터의 크기 size가 0일 때는 size를 다시 구했고, 0이 아니라면 원래 값을 쓰면 됩니다. size를 이용하여 image 포인터에 픽셀 데이터의 크기만큼 동적 메모리를 할당합니다.

   image = malloc(size);    // 픽셀 데이터의 크기만큼 동적 메모리 할당

이제 비트맵 이미지의 픽셀 데이터를 읽습니다. 24비트 비트맵 파일에서는 비트맵 정보 헤더 바로 다음에 픽셀 데이터가 있어서 바로 fread 함수로 읽어도 됩니다. 하지만 여기서는 bfOffBits 값을 활용하여 파일 포인터를 픽셀 데이터의 시작 위치로 이동시켰습니다.

    // 파일 포인터를 픽셀 데이터의 시작 위치로 이동
    fseek(fpBmp, fileHeader.bfOffBits, SEEK_SET);

    // 파일에서 픽셀 데이터 크기만큼 읽음. 읽기에 실패하면 파일 포인터를 닫고 프로그램 종료
    if (fread(image, size, 1, fpBmp) < 1)
    {
        fclose(fpBmp);
        return 0;
    }

    fclose(fpBmp);    // 비트맵 파일 닫기

결과 출력용 텍스트 파일을 쓰기 모드(w)로 엽니다. 변환된 ASCII 문자를 콘솔(명령 프롬프트)에 바로 출력해도 되지만, 여기서는 파일에 저장해보겠습니다. 만약 파일 열기에 실패하면 픽셀 데이터를 저장한 동적 메모리를 해제하고 프로그램을 종료합니다.

    fpTxt = fopen("ascii.txt", "w");    // 결과 출력용 텍스트 파일 열기
    if (fpTxt == NULL)    // 파일 열기에 실패하면
    {
        free(image);      // 픽셀 데이터를 저장한 동적 메모리 해제
        return 0;         // 프로그램 종료
    }

이제 픽셀 데이터를 ASCII 문자로 변환하는 부분입니다.

    // 픽셀 데이터는 아래 위가 뒤집혀서 저장되므로 아래쪽부터 반복
    // 세로 크기만큼 반복
    for (int y = height - 1; y >= 0; y--)
    {
        // 가로 크기만큼 반복
        for (int x = 0; x < width; x++)
        {
            // 일렬로 된 배열에 접근하기 위해 인덱스를 계산
            // (x * 픽셀 크기)는 픽셀의 가로 위치
            // (y * (세로 크기 * 픽셀 크기))는 픽셀이 몇 번째 줄인지 계산
            // 남는 공간 * y는 줄별로 누적된 남는 공간
            int index = (x * PIXEL_SIZE) + (y * (width * PIXEL_SIZE)) + (padding * y);

            // 현재 픽셀의 주소를 RGBTRIPLE 포인터로 변환하여 RGBTRIPLE 포인터에 저장
            RGBTRIPLE *pixel = (RGBTRIPLE *)&image[index];

            // RGBTRIPLE 구조체로 파랑, 초록, 빨강값을 가져옴
            unsigned char blue = pixel->rgbtBlue;
            unsigned char green = pixel->rgbtGreen;
            unsigned char red = pixel->rgbtRed;

            // 파랑, 초록, 빨강값의 평균을 구하면 흑백 이미지를 얻을 수 있음
            unsigned char gray = (red + green + blue) / PIXEL_SIZE;

            // 흑백값에 ASCII 문자의 개수를 곱한 뒤 256으로 나누면 흑백값에 따라 
            // ASCII 문자의 인덱스를 얻을 수 있음
            char c = ascii[gray * sizeof(ascii) / 256];

            // 비트맵 이미지에서는 픽셀의 가로, 세로 크기가 똑같지만
            // 보통 ASCII 문자는 세로로 길쭉한 형태이므로 정사각형 모양과 비슷하게 보여주기 위해
            // 같은 문자를 두 번 저장해줌
            fprintf(fpTxt, "%c%c", c, c);    // 텍스트 파일에 문자 출력
        }

        fprintf(fpTxt, "\n");    // 가로 픽셀 저장이 끝났으면 줄바꿈을 해줌
    }

    fclose(fpTxt);    // 텍스트 파일 닫기

    free(image);      // 픽셀 데이터를 저장한 동적 메모리 해제

보통 비트맵 파일의 픽셀 데이터는 아래 위가 뒤집혀서 저장됩니다. 따라서 반복문에서는 그림의 아래쪽부터 처리해야 합니다.

그림 81‑5 픽셀 데이터는 아래 위가 뒤집혀서 저장됨

먼저 세로를 처리해야 하는데 배열의 인덱스는 0부터 시작하므로 세로의 시작 값은 height가 아닌 height - 1가 됩니다. 즉, height - 1부터 0까지 감소하죠(y >= 0). 단, 가로는 0부터 증가해서 width보다 작을 때까지만 반복하므로 따로 -1을 하지 않아도 됩니다(x < width). 조건식의 부등호에 등호가 같이 있는지 아닌지를 잘 살펴보세요.

    // 픽셀 데이터는 아래 위가 뒤집혀서 저장되므로 아래쪽부터 반복
    // 세로 크기만큼 반복
    for (int y = height - 1; y >= 0; y--)
    {
        // 가로 크기만큼 반복
        for (int x = 0; x < width; x++)
        {

가로와 세로를 반복하면서 픽셀 정보를 가져옵니다. 이때 image 포인터는 일렬로 된 배열이므로 xy, padding을 이용하여 인덱스를 계산해야 합니다. 먼저 (x * 픽셀 크기)는 픽셀의 가로 위치입니다. 그리고 (y * (세로 크기 * 픽셀 크기))는 픽셀이 몇 번째 줄인지 계산합니다. 마지막으로 padding * y는 누적된 남는 공간입니다(반복할 때마다 y가 줄어서 누적된 남는 공간도 줄어들게 됩니다). 이렇게 구한 모든 값을 더해주면 픽셀에 접근할 수 있는 인덱스가 됩니다.

            // 일렬로 된 배열에 접근하기 위해 인덱스를 계산
            // (x * 픽셀 크기)는 픽셀의 가로 위치
            // (y * (세로 크기 * 픽셀 크기))는 픽셀이 몇 번째 줄인지 계산
            // 남는 공간 * y는 줄별로 누적된 남는 공간
            int index = (x * PIXEL_SIZE) + (y * (width * PIXEL_SIZE)) + (padding * y);

인덱스를 계산했으면 &image[index]와 같이 배열(포인터)에 인덱스를 지정하여 현재 픽셀에 접근한 뒤 앞에 &를 붙여서 현재 픽셀의 메모리 주소를 구합니다. 그리고 RGBTRIPLE 구조체로 선언한 포인터에 저장해줍니다. 이렇게 하면 RGBTRIPLE 구조체를 이용하여 각 픽셀의 빨강, 초록, 파랑(RGB) 값을 손쉽게 가져올 수 있습니다.

            // 현재 픽셀의 주소를 RGBTRIPLE 포인터로 변환하여 RGBTRIPLE 포인터에 저장
            RGBTRIPLE *pixel = (RGBTRIPLE *)&image[index];

이제 픽셀을 ASCII 문자로 변환해야 하는데 RGB 중 어느 한 값을 사용하는 것 보다는 픽셀 값을 흑백(grayscale)으로 바꾼 뒤 변환하는 것이 좀 더 보기가 좋습니다(흑백값은 RGB값의 평균을 구하면 됩니다).

            // RGBTRIPLE 구조체로 파랑, 초록, 빨강값을 가져옴
            unsigned char blue = pixel->rgbtBlue;
            unsigned char green = pixel->rgbtGreen;
            unsigned char red = pixel->rgbtRed;

            // 파랑, 초록, 빨강 값의 평균을 구하면 흑백 이미지를 얻을 수 있음
            unsigned char gray = (red + green + blue) / PIXEL_SIZE;

흑백(회색)값을 얻었으니 이 값을 ASCII 문자로 변환해야 합니다. 여기서 ascii 배열에 들어는 ASCII 문자를 사용하려면 흑백값 gray를 배열의 인덱스로 만들면 됩니다. 즉, unsigned char의 범위인 0~255를 0~10으로 변환하는 것이죠.

    // 각 픽셀을 표현할 ASCII 문자. 인덱스가 높을 수록 밝아지는 것을 표현
    char ascii[] = { '#', '#', '@', '%', '=', '+', '*', ':', '-', '.', ' ' };    // 11개

변환하는 방법은 간단합니다. 흑백값 graysizeof(ascii)로 곱한 뒤 다시 256으로 나누면 됩니다. 어렵게 생각할 것 없이 0~255인 gray를 256으로 나눠서 0~0.99사이의 값으로 만든다고 생각하면 됩니다. 0~0.99를 sizeof(ascii)에 곱하면 ascii 배열의 인덱스가 되겠죠? 단, 우리는 실수가 아닌 정수를 계산하므로 먼저 graysizeof(ascii)를 곱하여 숫자를 크게 키운 다음 256으로 나누는 것입니다(정수에서 255 / 256은 0.99609375가 아닌 0입니다. 이러면 계산이 안 되죠).

gray * sizeof(ascii) / 256 식에 따라 gray가 0, 100, 200, 255일 때 예를 들어보겠습니다.

0 * 11 / 256 = 0
100 * 11 / 256 = 4
200 * 11 / 256 = 8
255 * 11 / 256 = 10

gray 값에 따라 적절한 인덱스가 나왔죠?

만약 256대신 255를 나눠버리면 gray가 255일 때 ascii 배열 범위를 벗어나므로 주의해야 합니다.

255 * 11 / 255 = 11  // 255로 나누면 11이 되는데 ascii의 인덱스는 0~10이므로 배열의 범위를 벗어남

grayascii 배열의 인덱스로 변환한 뒤 ASCII 문자를 가져오는 방법을 코드로 나타내면 다음과 같이 됩니다.

            // 흑백값에 ASCII 문자의 개수를 곱한 뒤 256으로 나누면 흑백값에 따라 
            // ASCII 문자의 인덱스를 얻을 수 있음
            char c = ascii[gray * sizeof(ascii) / 256];

이제 fprintf 함수에 서식을 지정하여 ASCII 문자를 파일에 저장합니다.

            // 비트맵 이미지에서는 픽셀의 가로, 세로 크기가 똑같지만
            // 보통 ASCII 문자는 세로로 길쭉한 형태이므로 정사각형 모양과 비슷하게 보여주기 위해
            // 같은 문자를 두 번 저장해줌
            fprintf(fpTxt, "%c%c", c, c); // 텍스트 파일에 문자 출력

픽셀을 ASCII 문자로 저장할 때 문자 하나로만 저장하면 정사각형 비트맵 이미지라도 막상 출력해보면 세로로 길쭉한 직사각형으로 보입니다. 왜냐하면 보통 ASCII 문자는 세로로 길쭉한 형태이기 때문에 가로 세로를 같은 개수로 출력하더라도 세로로 길쭉한 형태가 됩니다. 따라서 정사각형 모양과 최대한 비슷하게 보여주기 위해 같은 문자를 두 번 저장해줍니다.

가로 픽셀 저장이 끝났으면 \n을 저장해서 줄바꿈을 해줍니다.

        fprintf(fpTxt, "\n");     // 가로 픽셀 저장이 끝났으면 줄바꿈을 해줌

전체 소스 코드는 다음과 같습니다.

bitmap_asciiart.c

#define _CRT_SECURE_NO_WARNINGS    // fopen 보안 경고로 인한 컴파일 에러 방지
#include <stdio.h>    // fopen, fread, fseek, fprintf, fclose 함수가 선언된 헤더 파일
#include <stdlib.h>   // malloc, free 함수가 선언된 헤더 파일

#pragma pack(push, 1)                // 구조체를 1바이트 크기로 정렬

typedef struct _BITMAPFILEHEADER     // BMP 비트맵 파일 헤더 구조체
{
    unsigned short bfType;           // BMP 파일 매직 넘버
    unsigned int   bfSize;           // 파일 크기
    unsigned short bfReserved1;      // 예약
    unsigned short bfReserved2;      // 예약
    unsigned int   bfOffBits;        // 비트맵 데이터의 시작 위치
} BITMAPFILEHEADER;

typedef struct _BITMAPINFOHEADER     // BMP 비트맵 정보 헤더 구조체(DIB 헤더)
{
    unsigned int   biSize;           // 현재 구조체의 크기
    int            biWidth;          // 비트맵 이미지의 가로 크기
    int            biHeight;         // 비트맵 이미지의 세로 크기
    unsigned short biPlanes;         // 사용하는 색상판의 수
    unsigned short biBitCount;       // 픽셀 하나를 표현하는 비트 수
    unsigned int   biCompression;    // 압축 방식
    unsigned int   biSizeImage;      // 비트맵 이미지의 픽셀 데이터 크기
    int            biXPelsPerMeter;  // 그림의 가로 해상도(미터당 픽셀)
    int            biYPelsPerMeter;  // 그림의 세로 해상도(미터당 픽셀)
    unsigned int   biClrUsed;        // 색상 테이블에서 실제 사용되는 색상 수
    unsigned int   biClrImportant;   // 비트맵을 표현하기 위해 필요한 색상 인덱스 수
} BITMAPINFOHEADER;

typedef struct _RGBTRIPLE            // 24비트 비트맵 이미지의 픽셀 구조체
{
    unsigned char rgbtBlue;          // 파랑
    unsigned char rgbtGreen;         // 초록
    unsigned char rgbtRed;           // 빨강
} RGBTRIPLE;

#pragma pack(pop)

#define PIXEL_SIZE   3    // 픽셀 한 개의 크기 3바이트(24비트)
#define PIXEL_ALIGN  4    // 픽셀 데이터 가로 한 줄은 4의 배수 크기로 저장됨

int main()
{
    FILE *fpBmp;                    // 비트맵 파일 포인터
    FILE *fpTxt;                    // 텍스트 파일 포인터
    BITMAPFILEHEADER fileHeader;    // 비트맵 파일 헤더 구조체 변수
    BITMAPINFOHEADER infoHeader;    // 비트맵 정보 헤더 구조체 변수

    unsigned char *image;    // 픽셀 데이터 포인터
    int size;                // 픽셀 데이터 크기
    int width, height;       // 비트맵 이미지의 가로, 세로 크기
    int padding;             // 픽셀 데이터의 가로 크기가 4의 배수가 아닐 때 남는 공간의 크기

    // 각 픽셀을 표현할 ASCII 문자. 인덱스가 높을 수록 밝아지는 것을 표현
    char ascii[] = { '#', '#', '@', '%', '=', '+', '*', ':', '-', '.', ' ' };   // 11개

    fpBmp = fopen("Lenna80x80.bmp", "rb");    // 비트맵 파일을 바이너리 모드로 열기
    if (fpBmp == NULL)    // 파일 열기에 실패하면
        return 0;         // 프로그램 종료

    // 비트맵 파일 헤더 읽기. 읽기에 실패하면 파일 포인터를 닫고 프로그램 종료
    if (fread(&fileHeader, sizeof(BITMAPFILEHEADER), 1, fpBmp) < 1)
    {
        fclose(fpBmp);
        return 0;
    }

    // 매직 넘버가 MB가 맞는지 확인(2바이트 크기의 BM을 리틀 엔디언으로 읽었으므로 MB가 됨)
    // 매직 넘버가 맞지 않으면 프로그램 종료
    if (fileHeader.bfType != 'MB')
    {
        fclose(fpBmp);
        return 0;
    }

    // 비트맵 정보 헤더 읽기. 읽기에 실패하면 파일 포인터를 닫고 프로그램 종료
    if (fread(&infoHeader, sizeof(BITMAPINFOHEADER), 1, fpBmp) < 1)
    {
        fclose(fpBmp);
        return 0;
    }

    // 24비트 비트맵이 아니면 프로그램 종료
    if (infoHeader.biBitCount != 24)
    {
        fclose(fpBmp);
        return 0;
    }

    size = infoHeader.biSizeImage;    // 픽셀 데이터 크기
    width = infoHeader.biWidth;       // 비트맵 이미지의 가로 크기
    height = infoHeader.biHeight;     // 비트맵 이미지의 세로 크기

    // 이미지의 가로 크기에 픽셀 크기를 곱하여 가로 한 줄의 크기를 구하고 4로 나머지를 구함
    // 그리고 4에서 나머지를 빼주면 남는 공간을 구할 수 있음.
    // 만약 남는 공간이 0이라면 최종 결과가 4가 되므로 여기서 다시 4로 나머지를 구함
    padding = (PIXEL_ALIGN - ((width * PIXEL_SIZE) % PIXEL_ALIGN)) % PIXEL_ALIGN;

    if (size == 0)    // 픽셀 데이터 크기가 0이라면
    {
        // 이미지의 가로 크기 * 픽셀 크기에 남는 공간을 더해주면 완전한 가로 한 줄 크기가 나옴
        // 여기에 이미지의 세로 크기를 곱해주면 픽셀 데이터의 크기를 구할 수 있음
        size = (width * PIXEL_SIZE + padding) * height;
    }

    image = malloc(size);    // 픽셀 데이터의 크기만큼 동적 메모리 할당

    // 파일 포인터를 픽셀 데이터의 시작 위치로 이동
    fseek(fpBmp, fileHeader.bfOffBits, SEEK_SET);

    // 파일에서 픽셀 데이터 크기만큼 읽음. 읽기에 실패하면 파일 포인터를 닫고 프로그램 종료
    if (fread(image, size, 1, fpBmp) < 1)
    {
        fclose(fpBmp);
        return 0;
    }

    fclose(fpBmp);    // 비트맵 파일 닫기

    fpTxt = fopen("ascii.txt", "w");    // 결과 출력용 텍스트 파일 열기
    if (fpTxt == NULL)    // 파일 열기에 실패하면
    {
        free(image);      // 픽셀 데이터를 저장한 동적 메모리 해제
        return 0;         // 프로그램 종료
    }

    // 픽셀 데이터는 아래 위가 뒤집혀서 저장되므로 아래쪽부터 반복
    // 세로 크기만큼 반복
    for (int y = height - 1; y >= 0; y--)
    {
        // 가로 크기만큼 반복
        for (int x = 0; x < width; x++)
        {
            // 일렬로 된 배열에 접근하기 위해 인덱스를 계산
            // (x * 픽셀 크기)는 픽셀의 가로 위치
            // (y * (세로 크기 * 픽셀 크기))는 픽셀이 몇 번째 줄인지 계산
            // 남는 공간 * y는 줄별로 누적된 남는 공간
            int index = (x * PIXEL_SIZE) + (y * (width * PIXEL_SIZE)) + (padding * y);

            // 현재 픽셀의 주소를 RGBTRIPLE 포인터로 변환하여 RGBTRIPLE 포인터에 저장
            RGBTRIPLE *pixel = (RGBTRIPLE *)&image[index];

            // RGBTRIPLE 구조체로 파랑, 초록, 빨강값을 가져옴
            unsigned char blue = pixel->rgbtBlue;
            unsigned char green = pixel->rgbtGreen;
            unsigned char red = pixel->rgbtRed;

            // 파랑, 초록, 빨강값의 평균을 구하면 흑백 이미지를 얻을 수 있음
            unsigned char gray = (red + green + blue) / PIXEL_SIZE;

            // 흑백값에 ASCII 문자의 개수를 곱한 뒤 256으로 나누면 흑백값에 따라 
            // ASCII 문자의 인덱스를 얻을 수 있음
            char c = ascii[gray * sizeof(ascii) / 256];

            // 비트맵 이미지에서는 픽셀의 가로, 세로 크기가 똑같지만
            // 보통 ASCII 문자는 세로로 길쭉한 형태이므로 정사각형 모양과 비슷하게 보여주기 위해
            // 같은 문자를 두 번 저장해줌
            fprintf(fpTxt, "%c%c", c, c);    // 텍스트 파일에 문자 출력
        }

        fprintf(fpTxt, "\n");    // 가로 픽셀 저장이 끝났으면 줄바꿈을 해줌
    }

    fclose(fpTxt);    // 텍스트 파일 닫기

    free(image);      // 픽셀 데이터를 저장한 동적 메모리 해제

    return 0;
}