CS50 Probelm set 4 - 이미지 필터링

dondonee·2023년 2월 24일
0

CS50

목록 보기
11/12
post-thumbnail

비트맵

이미지를 표현하는 가장 단순한 방법은 픽셀 그리드일 것이다. 각 픽셀은 서로 다른 색을 가질 수 있는데, 아래와 같은 흑백 이미지를 표현하기 위해서 흑색을 의미하는 0과 백색을 의미하는 1이 필요하므로 픽셀당 1비트만 있으면 된다.

https://cs50.harvard.edu/college/2019/fall/psets/4/filter/less/bitmap.png

그러한 의미에서 이 이미지는 비트들의 지도, 즉 비트맵인 것이다. 만약 좀 더 다양한 색의 이미지를 원한다면 더 많은 비트가 필요하다.

‘24비트 컬러’를 지원하는 파일 포맷은 픽셀당 24비트를 사용한다는 뜻이다. 24비트 BMP 포맷은 한 픽셀에 들어간 적색의 양을 표현하는데 8비트를 사용하고, 녹색과 청색을 위해서도 각각 8비트를 사용한다. 이것이 RGB 컬러이다. 만약 BMP 포맷의 한 픽셀의 RGB 값이 16진수로 0xff, 0x00, 0x00이라면 그 픽셀은 녹색이나 청색이 전혀 들어있지 않은 순수한 적색을 의미한다(0xff는 10진수로 255이다).

비트맵 포맷

24비트 비트맵 파일이란 특정 방식으로 줄지어져 있는 비트들이며, 하나의 24비트 묶음은 (대부분) 색상을 표현하는 데 사용된다. 하지만 이미지의 높이나 너비와 같은 ‘메타데이터’라고 하는 정보를 나타내는 경우도 있다. 메타데이터는 파일의 시작 부분에 보통 ‘헤더’라고 하는 두 개의 데이터 구조 형태로 저장된다(이 헤더는 계속 발전되어 왔는데, 이 문제에서는 윈도우 95와 함께 처음 등장한 마이크로소프트의 최신 BMP 포맷 버전인 4.0 버전을 사용한다).

헤더의 첫 번째인 BITMAPFILEHEADER는 14바이트, 두 번째인 BITMAPINFOHEADER는 40바이트 길이다. 이 헤더의 바로 다음에 오는 것이 진짜 비트맵이다. 각 픽셀의 색상은 3차원 배열로 나타내는데, BMP는 RGB 정보를 거꾸로, 즉 BGR의 순서로 저장한다(어떤 BMP는 비트맵 전체를 거꾸로 저장하기도 한다. 이미지의 윗 부분이 BMP 파일의 끝 부분에 저장하는 식이다. 이 과제을 위한 BMP 파일들은 윗부분은 처음에, 아랫 부분은 나중에 저장했다).

https://cs50.harvard.edu/college/2019/fall/psets/4/filter/more/red_smile.png

처음의 1비트 흑백 스마일리를 흰색과 빨간색의 24비트로 바꾼다면 위와 같은 형태가 된다. 0000ff는 빨간색을, ffffff는 흰색을 의미한다.

16진법의 한 자릿수는 4비트를 나타낸다. 즉 16진수인 ffffff는 2진수인 111111111111111111111111과 동일하다. 또한 비트맵 이미지는 2차원의 픽셀 배열로 이루어져 있다는 것을 기억하자. 하나의 이미지는 여러 행의 배열로 이루어져 있으며, 각 행은 픽셀들의 배열로 구성되어 있다. 이번 과제로 비트맵 이미지를 다루는 것이 선택된 이유이다.

이미지 필터

이제 우리는 이미지 필터링이란 특정 효과를 위해 원본 이미지의 각 픽셀을 수정하는 작업인 것을 이해할 수 있다.

Grayscale

흔히 쓰이는 그레이스케일 필터는 이미지를 흑백으로 변환해준다. 어떤 방식으로 작동하는 걸까?

만약 RGB의 값이 모두 0x00이라면 해당 픽셀은 검정색이다. 반면 모두 0xff라면 하얀색이다. RGB의 값이 모두 같을 때 픽셀은 흑백 스펙트럼의 색이 되는데, 값이 작을수록 흑색에 가까운 어두운 회색이 되고, 값이 클수록 백색에 가까운 회색이 된다.

따라서 픽셀을 그레이스케일로 변환하기 위해서는 적, 녹, 청의 값이 모두 같도록 만들어야 한다. 하지만 적절한 값을 어떻게 알 수 있을까? 각 픽셀의 RGB 값을 평균하면 원본과 같은 명도의 이미지로 변환할 수 있다.

이러한 방식을 각 픽셀에 적용하면 그레이스케일로 변환한 이미지를 얻을 수 있다.

Sepia

세피아 필터는 이미지를 적갈색으로 만들어 오래된 듯한 느낌을 준다.

세피아 필터를 위한 여러 알고리즘이 존재하지만, 이 과제에서는 아래의 공식을 사용한다.

sepiaRed = .393 * originalRed + .769 * originalGreen + .189 * originalBlue
sepiaGreen = .349 * originalRed + .686 * originalGreen + .168 * originalBlue
sepiaBlue = .272 * originalRed + .534 * originalGreen + .131 * originalBlue

계산된 값은 가까운 정수로 반올림한다. 또한 값이 8비트 컬러의 최대값인 255를 초과할 수 있는데, 이 경우 값을 255로 제한한다. 모든 값은 0에서 255 사이가 되어야 한다.

반전 효과

어떤 필터는 픽셀을 이동시키기도 한다. 이미지 반전이란 원본 이미지를 거울 앞에 놓았을 때 얻을 수 있는 이미지이다. 왼쪽에 있는 픽셀은 오른쪽에, 오른쪽에 있는 픽셀은 왼쪽에 위치한다.

원본 이미지의 모든 픽셀은 반전된 이미지에 모두 그대로 존재하고, 단지 위치가 재배열된 것이다.

Blur

이미지를 흐리게 혹은 부드럽게 만드는 효과를 주는 방법에는 여러 가지가 있다. 이 과제에서는 ‘박스 블러’를 사용한다. 각 픽셀을 불러와 인접한 픽셀들의 색상 값을 평균하여 새로운 색상값을 부여하는 방법이다.

https://cs50.harvard.edu/college/2019/fall/psets/4/filter/less/grid.png

각 픽셀의 새로운 값은 원본 픽셀에서 1행 및 1열 거리에 인접한 픽셀들의 색상값을 평균한 값이다(3x3 상자 형태). 예를들어 픽셀 6은 자신을 포함한 원본 픽셀 1, 2, 3, 5, 6, 7, 9, 10, 11의 평균 색상 값을 부여받는다. 모서리에 위치한 픽셀의 경우도 마찬가지다. 하단 모서리에 위치한 픽셀 15의 경우 픽셀 10, 11, 12, 13, 15, 16의 평균 색상 값을 부여받는다.

Edges

이미지에서 어떤 물체의 테두리를 감지하는 기능은 이미지 처리를 위한 인공지능 알고리즘에서 유용하게 사용된다. 이 기능을 구현하는 한 가지 방법으로 Sobel 연산자를 적용하는 방법이 있다.

블러 효과를 주었던 방법과 마찬가지로, 한 픽셀을 감싸는 3x3 크기의 픽셀들의 값을 취한다. 하지만 Sobel 연산자는 9개의 픽셀의 평균을 구하는 것이 아니라, 각 픽셀들의 가중 합계를 구한다. 어떤 물체의 경계는 수직의 형태일 수도 수평의 형태일 수도 있기 때문에 두 종류의 가중 합계를 계산해야 한다. 하나는 x 방향의 경계를 감지하는 것이고 다른 하나는 y 방향의 경계를 감지하는 것으로, 이를 위해 아래와 같은 두 가지의 커널을 사용한다.

https://cs50.harvard.edu/college/2019/fall/psets/4/filter/more/sobel.png

예를 들어 원본 이미지의 적색 값에 대한 Gx 값을 구한다면, 해당 픽셀을 둘러싼 9개의 픽셀의 적색 값에 각 픽셀에 상응하는 Gx 커널의 값을 곱한 뒤 총 합을 구한다.

왜 픽셀마다 다른 크기의 가중치가 부여되었을까? Gx 커널을 보면 오른쪽 픽셀에는 양수의 값이, 왼쪽 픽셀에는 음수의 값이 부여되었다. 만약 오른쪽 픽셀들과 왼쪽 픽셀들이 비슷한 색상이라면, 가중치를 곱한 뒤 그 양쪽의 합계를 구하면 결과값은 0에 가깝게 나올 것이다. 하지만 만약 양쪽의 색상 차이가 크다면 결과값은 큰 값의 양수이거나 음수가 될 것이고, 이러한 색상 변화는 물체의 경계를 의미할 것이다.

이러한 커널을 이용해서 각 픽셀의 RGB에 해당하는 GxGy 값을 구한다. 하지만 각 색상 채널은 하나의 값만 가질 수 있으므로, 최종 값은 Gx^2 + Gx^2의 제곱근으로 한다. 또한 채널의 값은 0에서 255 사이의 정수형이기 때문에 결과값이 범위를 초과하는 경우 가장 가까운 정수로 변환하고 최대값은 255로 제한한다.

이미지의 가장자리나 모서리에 위치한 픽셀의 경우는 어떻게 처리할까? 이 경우에는 1픽셀의 검은 테두리를 만난 것처럼 처리하면 된다. 즉 RGB의 값이 모두 0인 픽셀인 것처럼 처리한다면, Gx와 Gy의 계산에서 이미지 범위 밖의 픽셀들은 효과적으로 무시된다.



Filter 과제

아래 예시와 같이 지정한 필터에 맞게 원본 이미지 파일을 변환한 새 이미지 파일을 생성하는 프로그램을 작성한다.

$ ./filter -g infile.bmp outfile.bmp

과제용 뼈대 코드

bmp.h

앞에서 언급한 비트맵 포맷 헤더의 두 가지의 데이터구조가 두 개의 구조체 BITMAPINFOHEADERBITMAPFILEHEADER로 선언되어 있다. 또한 BYTE, DWORD, LONG, WORD라는 새로운 자료형이 정의되어 있는 것을 볼 수 있는데, 단지 우리가 이미 알고 있는 자료형들의 별칭에 불과하다.

typedef uint8_t  BYTE;
typedef uint32_t DWORD;
typedef int32_t  LONG;
typedef uint16_t WORD;

그 다음에는 이번 과제에서 가장 중요하게 사용될 RGBTRIPLE 구조체가 선언되어있다. 이것은 세 가지 색상 채널을 ‘캡슐화’한 것으로, BGR의 순서로 배치되어 있다.

구조체는 어떤 면에서 유용할까? 파일이란 일련의 바이트 묶음에 불과하다는 것을 떠올려보자. 바이트들은 보통 특정 방식으로 정렬되어 있어서 첫 몇 바이트들은 어떤 특정한 의미를 갖고, 그 다음 몇 바이트들은 또 다른 의미를 갖는 식이다. ‘파일 포맷’이란 어떤 바이트가 어떤 것을 의미하는지를 표준화한 것이기 때문에, 우리는 디스크에 존재하는 파일의 바이트들을 RAM에 하나의 큰 배열로 가져올 수 있게 되는 것이다. 각각의 작은 배열이 특정한 정보를 뜻한다면, 배열마다 이름을 부여해준다면 우리는 더 쉽게 사용할 수 있을 것이다. 정보를 수많은 바이트들의 묶음 보다는 구조체로 다루는 것이 이해하기 더 쉽다는 것은 명백하기 때문이다. bmp.h는 이러한 목적으로 자료형에 별칭을 부여하고 구조체를 정의한다.

**filter.c**

라인 11에 선언된 filters 문자열은 명령행 인자의 옵션으로 b, g, r, s, e를 받을 수 있도록 정의한 것이다. 각 문자는 blur, grayscale, reflection, sepia, edges를 의미한다. getopt()getopt.h에 정의된 함수로, 명령행 인자로 들어온 문자열의 파싱(문자열의 내용을 문법에 맞게 분석)을 수행한다.

char *filters = "bgrse";
char filter = getopt(argc, argv, filters);

그 다음에는 이미지 파일을 열어 BMP 파일이 맞는지 확인한 후, 모든 픽셀 정보를 image라는 2차원 배열로 불러들인다.

더 아래의, 라인 102에 위치한 switch문은 명령행 인자로 받은 filter 값에 따라 다른 필터 함수를 실행한다. 또한 실행된 필터 효과 함수들은 이미지의 높이 및 너비와 2차원 픽셀 배열을 인자로 전달한다. 이 필터 함수들이 이번 과제에서 작성해야 할 것들이다.

이후의 코드들은 결과값인 image를 받아 새로운 이미지 파일로 생성해주는 기능을 한다.

helpers.h

이번 과제에서 직접 작성해야할 필터 함수들이 선언되어 있다.

  • grayscale : 흑백 버전으로 변환
  • reflect : 이미지 수평 반전
  • blur : 박스 블러를 이용해 흐릿한 효과를 적용
  • sepia : 오래된 느낌이 나는 적갈색 이미지로 변환
  • edges : Sebel 연산자를 이용해 물체의 테두리를 강조

**Makefile**

이 파일은 우리가 make filter와 같은 명령을 실행했을 때 어떻게 작동해야 하는지 안내해주는 파일이다. 그동안 작성했던 프로그램들은 하나의 파일로만 구성된 반면, 이번 프로그램은 여러 파일들을 사용하기 때문에 이 파일을 컴파일하는 방법을 알려주어야 한다.



✍️ 풀이

Grayscale

void grayscale(int height, int width, RGBTRIPLE image[height][width])
{
    double result;

    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            result = round((image[i][j].rgbtBlue + image[i][j].rgbtGreen + image[i][j].rgbtRed) / 3.0);
            image[i][j].rgbtBlue = image[i][j].rgbtGreen = image[i][j].rgbtRed = (BYTE)result;
        }
    }

    return;
}
  • 이중 반복문으로 이미지의 픽셀 배열에 순서대로 접근해 RGB 값을 평균하여 일괄 적용

Sepia

void sepia(int height, int width, RGBTRIPLE image[height][width])
{
    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            get_sepia_color(&image[i][j]);
        }
    }

    return;
}
  • sepia()에서 이중 반복문으로 이미지의 픽셀 배열에 순서대로 접근
  • 대상 픽셀인 RGBTRIPLE 구조체의 주소를 get_sepia_color()에 인자로 전달
void get_sepia_color(RGBTRIPLE *pixel)
{
    BYTE originalBlue = pixel->rgbtBlue;
    BYTE originalGreen = pixel->rgbtGreen;
    BYTE originalRed = pixel->rgbtRed;

    double Blue = round(.272 * originalRed + .534 * originalGreen + .131 * originalBlue);
    double Green = round(.349 * originalRed + .686 * originalGreen + .168 * originalBlue);
    double Red = round(.393 * originalRed + .769 * originalGreen + .189 * originalBlue);

    pixel->rgbtBlue = (Blue > 255) ? 255 : (BYTE)Blue;
    pixel->rgbtGreen = (Green > 255) ? 255 : (BYTE)Green;
    pixel->rgbtRed = (Red > 255) ? 255 : (BYTE)Red;
}
  • 원본의 RGB값을 변수에 별도로 저장
  • double 변수에 각 색상 채널에 맞게 sepia 알고리즘에 따라 계산한 뒤 round()로 반올림한 값을 저장
  • 해당 픽셀의 주소를 참조해 직접 접근하여 멤버의 값을 변경
    • 값이 255를 초과하는 경우 값을 255로 제한

reflect

void reflect(int height, int width, RGBTRIPLE image[height][width])
{
    RGBTRIPLE temp[height][width];
    memcpy(temp, image, height * width * sizeof(RGBTRIPLE));

    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            image[i][j] = temp[i][width - j - 1];
        }
    }

    return;
}
  • memcpy()를 이용해 이미지의 원본 픽셀 베열을 temp에 별도로 저장
  • 이중 반복문을 이용, temp에 저장한 각 행의 픽셀을 역순으로 image에 대입

blur

void blur(int height, int width, RGBTRIPLE image[height][width])
{
    RGBTRIPLE temp[height][width];
    memcpy(temp, image, height * width * sizeof(RGBTRIPLE));

    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            blur_pixel(height, width, temp, image, i, j);
        }
    }

    return;
}
  • memcpy()를 이용해 이미지의 원본 픽셀 베열을 temp에 별도로 저장
  • blur()에서 이중 반복문으로 이미지의 픽셀 배열에 순서대로 접근하여 blur_pixel()을 호출
void blur_pixel(int height, int width, RGBTRIPLE temp[height][width], RGBTRIPLE image[height][width], int k, int l)
{
    int count = 0;
    double blue = 0;
    double green = 0;
    double red = 0;

    for (int i = k - 1; i <= k + 1; i++)
    {
        if (i < 0 || i >= height)
        {
            continue;
        }

        for (int j = l - 1; j <= l + 1; j++)
        {
            if (j < 0 || j >= width)
            {
                continue;
            }

            blue += temp[i][j].rgbtBlue;
            green += temp[i][j].rgbtGreen;
            red += temp[i][j].rgbtRed;

            count++;
        }
    }

    image[k][l].rgbtBlue = (BYTE)round(blue / count);
    image[k][l].rgbtGreen = (BYTE)round(green / count);
    image[k][l].rgbtRed = (BYTE)round(red / count);

    return;
}
  • double 변수 blue, green, red 및 int 변수 count를 0으로 초기화.
  • 이중 반복문을 이용, 대상 픽셀을 둘러싼 자신과 다른 픽셀들의 RGB값 합산 및 count 변수에 합산 수 카운트
    • 픽셀이 이미지의 범위를 벗어나는 경우, 픽셀의 인덱스가 0보다 작거나 (height - 1) 또는 (width - 1)를 초과하는 경우는 합산에서 제외
  • 각 색상 채널에 평균값을 적용

Edges

void edges(int height, int width, RGBTRIPLE image[height][width])
{
    RGBTRIPLE temp[height][width];
    memcpy(temp, image, height * width * sizeof(RGBTRIPLE));

    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            int *Gx = get_Gx_Gy(height, width, temp, i, j, 'x');
            int *Gy = get_Gx_Gy(height, width, temp, i, j, 'y');

            sobel_operator(&image[i][j], Gx, Gy);
        }
    }

    return;
}
  • memcpy()를 이용해 이미지의 원본 픽셀 베열을 temp에 별도로 저장
  • edges()에서 이중 반복문으로 이미지의 픽셀 배열에 순서대로 접근
    1. int 배열 변수 Gx와 Gy에 get_Gx_Gy()를 호출해 각각 값 저장
    2. sobel_operator()를 호출하여 최종 값 할당
int *get_Gx_Gy(int height, int width, RGBTRIPLE temp[height][width], int k, int l, char xy)
{
    int *G = malloc(3 * sizeof(int));

    int blue = 0;
    int green = 0;
    int red = 0;

    int offset[3][3];
    switch (xy)
    {
    case 'x':
        memcpy(offset, (int[3][3]){{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}}, sizeof(offset));
        break;

    case 'y':
        memcpy(offset, (int[3][3]){{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}}, sizeof(offset));
        break;
    }

    int x = 0;

    for (int i = k - 1; i <= k + 1; i++)
    {
        int y = 0;

        for (int j = l - 1; j <= l + 1; j++)
        {
            if (i < 0 || i >= height)
            {
                break;
            }

            if (j < 0 || j >= width)
            {
                y++;
                continue;
            }

            blue += temp[i][j].rgbtBlue * offset[x][y];
            green += temp[i][j].rgbtGreen * offset[x][y];
            red += temp[i][j].rgbtRed * offset[x][y];

            y++;
        }

        x++;
    }

    G[0] = blue;
    G[1] = green;
    G[2] = red;

    return G;
}
  • Gx와 Gy의 계산은 곱해지는 값, 즉 offset 배열의 값만 달라진다. Gx는 문자 ‘x’, Gy는 문자 ‘y’를 인자로 받아 switch 조건문을 이용해 offset[3][3] 배열 변수에 해당하는 값을 할당한다.
  • 이중 반복문을 이용, 계산 대상이 되는 temp[i][j]에 offset[x][y]을 대응하여 계산한다. 변수 i, j의 증감에 따라 x, y도 동일하게 증감시킨다.
  • 계산된 값을 int 배열 변수 G에 차례로 저장한다. 인덱스 0, 1, 2의 원소는 차례대로 blue, green, red 채널의 값이 된다.
  • int 배열 변수 G를 일괄 반환한다.
void sobel_operator(RGBTRIPLE *pixel, int *Gx, int *Gy)
{
    double result[3];
    for (int i = 0; i < 3; i++)
    {
        result[i] = round(sqrt(Gx[i] * Gx[i] + Gy[i] * Gy[i]));
    }

    pixel->rgbtBlue = (result[0] > 255) ? 255 : (BYTE)result[0];
    pixel->rgbtGreen = (result[1] > 255) ? 255 : (BYTE)result[1];
    pixel->rgbtRed = (result[2] > 255) ? 255 : (BYTE)result[2];
}
  • double 배열 변수 result를 선언한다. 반복문을 이용해 result에 Gx^2+Gy^2의 제곱근을 반올림하여 대입한다. result의 인덱스 0, 1, 2의 원소는 차례대로 blue, green, red 채널의 값이 된다.
  • 인자로 전달받은 주소값을 참조하여 pixel의 원본에 계산한 값을 대입한다. 단, 값이 255를 초과하는 경우 값을 255로 제한한다.


✍️ 후기

이번 과제는 Tideman 과제의 재귀함수처럼 어려운 부분은 없었다. 가장 시간을 많이 쓴 부분은 edges 효과였다. check50으로 테스트를 돌려보니 결과값이 너무 이상했는데, 아무리 코드를 보고 또 봐도 잘못된 부분이 없어 보였다. ChatGPT도 오류를 잡지 못했다.

사실 디버깅 도구를 썼다면 쉽게 해결할 수 있는 문제였는데, 번거로워서 디버깅을 미뤘던게 오히려 시간을 더 쓴 결과가 되었다. 예전 과제에 비해 코드도 길어졌고 입력값으로 이미지 파일의 배열이 통째로 들어가니까 디버깅을 하기가 좀 막막했던 것이다. 하지만 생각해보니 간단한 방법이 있었다. 입력값으로 1픽셀의 이미지만 주고 edges 효과 함수만 따로 떼어와서 테스트를 하는 방법이었다. 그렇게 lldb를 실행해보니 문제는 아주 사소한 부분에 있었다. Gy값을 계산하기 위한 offset 값에서 -2가 2로 되어있었던 거였다…

해야할 일을 미루지 말자!


References

0개의 댓글