Skip to content

73. 비트맵 파일


1. 키워드

  • 비트맵 파일(.bmp)


2. 비트맵 파일을 아스키 아트로 변환하기

  • 아스키 아트는 보통 프로그램을 사용하여 원본 그림 파일을 아스키 아트로 변환해서 만든다.
  • 비트맵 그림 파일의 픽셀 정보를 읽어서 아스키 아트로 출력하는 프로그램을 만들어보자.


3. 비트맵 파일 포맷 알아보기

  • 비트맵 파일은 바이너리 형식이므로 메모장 등 텍스트 편집기로 열어도 내용을 알아볼 수 없다.
  • 따라서 비트맵 파일에서 픽셀 정보를 읽으려면 먼저 비트맵 파일의 구조를 알아야 한다.


  • 다음은 비트맵 파일의 구조이며 비트맵 파일은 픽셀 하나를 몇 비트로 저장하느냐에 따라 구조가 달라진다.


001


  • 비트맵 파일 헤더는 비트맵 파일 식별 정보, 파일 크기, 데이터 위치 등의 정보를 담고 있으며 DIB(Device Independent Bitmap) 헤더는 가로, 세로 크기, 해상도, 픽셀의 비트 수 등 그림의 자세한 정보를 담고 있다.
  • 픽셀 데이터에는 그림 파일의 실제 색상 정보가 들어간다.
  • 단, 픽셀당 색상 수가 16비트 미만일 때는 색상 테이블에 따로 색상 정보를 저장하고, 픽셀 데이터에서는 색상 테이블의 인덱스를 저장한다.


  • 다음은 비트맵 파일 헤더(BITMAPFILEHEADER)의 구조이다.


002


  • 다음은 비트맵 정보 헤더(BITMAPINFOHEADER)의 구조이다.


003


  • 24비트 비트맵은 픽셀(RGBTRIPLE)을 파랑(B), 초록(G), 빨강(R) 순서로 저장하며 각 색상의 크기는 1바이트이다.
  • 따라서 픽셀당 3바이트를 사용한다.


004


  • 즉, 우리가 화면에서 보는 24비트 비트맵 파일의 픽셀은 3바이트로 되어 있다.


005


  • 여기서 색상이 파랑, 초록, 빨강 3색이라서 RGBTRIPLE 구조체의 이름에 TRIPLE이 들어간다.
  • 그리고 구조체 멤버에서 rgb 뒤에 붙는 ttriple을 의미한다.


4. 비트맵 구조체 작성하기

  • 비트맵 파일의 구조를 알아봤으니 이제 비트맵 구조체를 작성한다.
  • 이때 주의할 점은 반드시 구조체를 1바이트 크기로 정렬해야 한다는 점이다.
  • 즉, 비트맵 파일에서 각 정보는 위치와 크기가 정확하게 정해져 있으므로 반드시 구조체의 크기와 형태 그대로 읽어야 한다.


#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)


  • 24비트 비트맵을 처리할 것이므로 비트맵 파일 헤더, 비트맵 정보 헤더, 픽셀 구조체만 정의했다.
  • 왜냐하면 24비트 비트맵은 색상 테이블을 사용하지 않으므로 색상 테이블 구조체는 정의하지 않아도 된다.


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

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


  • 다음은 비트맵 파일을 ASCII 문자로 저장하는 과정이다.


006


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


1] PIXEL_SIZE

  • 픽셀 한 개의 크기이다.
  • 24비트 비트맵은 파랑(B), 초록(G), 빨강(R)을 1바이트씩 저장하므로 3바이트이다.

2] PIXEL_ALIGN

  • 픽셀 데이터의 가로 한 줄에서 남는 공간을 구하기 위한 정렬값이다.
  • 비트맵 포맷은 픽셀 데이터의 가로 한 줄을 저장할 때 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("Peppers80x80.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보다 작은 수를 반환한다.
  • 따라서 파일 읽기에 실패했을 때는 파일 포인터를 닫고 프로그램을 종료한다.
  • 이후에도 각종 실패 상황이나 조건에 맞지 않으면 앞에서 열었던 파일 포인터를 닫고 프로그램을 종료해 준다.


  • 이번에는 비트맵 정보 헤더를 읽는다.
  • 비트맵 정보 헤더는 비트맵 파일 헤더 바로 뒤에 있으므로 파일 포인터를 이동시키지 않고 바로 fread 함수로 읽으면 된다.
  • 비트맵 정보 헤더를 읽었으면 biBitCount의 값이 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;  // 비트맵 이미지의 세로 크기


  • 이번에는 픽셀 데이터의 가로 공간이 저장될 때 남는 공간을 구해야 한다.
  • 비트맵 포맷은 픽셀의 가로 한 줄을 저장할 때 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;


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


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


  • 픽셀 데이터의 크기 size0일 때는 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); // 픽셀 데이터를 저장한 동적 메모리 해제


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


007


  • 먼저 세로를 처리해야 하는데 배열의 인덱스는 0부터 시작하므로 세로의 시작 값은 height가 아닌 height - 1이 된다.
  • 즉, height - 1부터 0까지 감소한다.
  • 단, 가로는 0부터 증가해서 width보다 작을 때까지만 반복하므로 따로 -1을 하지 않아도 된다.


    // 픽셀 데이터는 아래 위가 뒤집혀서 저장되므로 아래쪽부터 반복
    // 세로 크기만큼 반복
    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 중 어느 한 값을 사용하는 것보다는 픽셀 값을 흑백으로 바꾼 뒤 변환하는 것이 좀 더 보기가 좋다.
  • 흑백 값은 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~2550~10으로 변환하는 것이다.
  • 흑백 값 graysizeof(ascii)로 곱한 뒤 다시 256으로 나누면 된다.
  • gray * sizeof(ascii) / 256 식에 따라 gray0, 100, 200, 255일 때 예를 확인해 보자.


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


  • gray 값에 따라 적절한 인덱스가 나왔다.
  • 만약 256 대신 255를 나눠버리면 gray255일 때 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"); // 가로 픽셀 저장이 끝났으면 줄바꿈을 해줌
    }


  • 마지막으로 텍스트 파일(fpTxt)을 닫고, 픽셀 데이터를 저장한 동적 메모리(image)를 해제해 준다.


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

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

    return 0;
}


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


#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("Peppers80x80.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;
}

References