본문 바로가기
프로그래밍/OpenCV

[OpenCV] 영상 밝기/명암비, 히스토그램

by 별준 2022. 5. 5.

References

Contents

  • 영상의 밝기/명암비 조절
  • 히스토그램 분석

영상의 밝기 조절

영상의 밝기 조절은 행렬의 덧셈과 뺄셈 연산을 이용하여 간단하게 구현할 수 있습니다. 이번에는 OpenCV에서 제공하는 영상 밝기 조절 기법과 사용자가 직접 영상의 픽셀 값을 참조하여 밝기를 조절하는 방법에 대해 살펴보겠습니다.

 

Grayscale 영상 처리

이번 포스팅부터 본격적으로 다양한 영상 처리 알고리즘에 대해 살펴볼텐데, 지금은 컬러 영상이 아닌 그레이스케일 영상을 대상으로 합니다. 이는 과거에 개발되었던 많은 영상 처리 알고리즘이 주로 그레이스케일 영상을 대상으로 개발되었기 때문입니다. 일반적으로 컬러 영상은 BGR 3개의 색상 채널을 가지고 있기 때문에 컬러 영상을 다루는 작업은 1채널인 그레이스케일 영상을 다루는 것보다 3배 많은 메모리와 연산 시간을 필요로 하기 때문에, 특별히 컬러 정보를 이용해야 하는 경우가 아니라면 컬러 영상도 그레이스케일로 변환해서 사용하는 경우가 많습니다. 컬러 영상을 처리하는 방법은 추후에 따로 살펴보도록 하겠습니다.

 

OpenCV에서 영상 파일을 그레이스케일 형태로 불러오려면 imread() 함수의 두 번째 인사에 IMREAD_GRAYSCALE 플래그를 설정해야 합니다. 예를 들면, 다음과 같습니다.

cv::Mat img1 = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);

이전 포스팅에서도 사용했던 lena.bmp 파일에는 원래 트루컬러 비트맵 이미지가 저장되어 있지만, imread() 함수 두 번째 인자에 IMREAD_GRAYSCALE을 지정했기 때문에 그레이스케일 형식으로 변환된 이미지가 img1 변수에 저장됩니다.

 

프로그램 동작 중 그레이스케일 이미지를 저장할 새로운 Mat 객체를 생성하려면 CV_8UC1 타입의 객체를 생성해야 합니다. 예를 들어, 아래 코드는 모든 픽셀 값이 0으로 초기화된 640x480 그레이스케일 이미지를 생성합니다.

cv::Mat img2(480, 640, CV_8UC1, cv::Scalar(0));

 

만약 이미 3채널 컬러 영상이 있고, 이 영상을 그레이스케일로 변환하려면 cvtColor() 함수를 사용합니다. 다음 코드는 3채널 컬러 영상을 1채널 그레이스케일 영상으로 변환하는 코드입니다.

cv::Mat img3 = cv::imread("lena.bmp", cv::IMREAD_COLOR);
cv::Mat img4;
cv::cvtColor(img3, img4, cv::COLOR_BGR2GRAY);

위 코드에서는 먼저 img3 변수에 3채널 BGR 컬러의 이미지가 저장됩니다. cvtColor() 함수는 Mat 객체에 저장된 색상 정보를 변경할 때 사용하는 함수이며, cvtColor() 함수에 전달하는 인자는 차례대로 입력 이미지, 출력 이미지, 컬러 변환 코드입니다. 위에서 사용한 컬러 변환 코드 COLOR_BGR2GRAY는 BGR 3채널을 1채널 그레이스케일로 변환할 때 사용합니다.

 

 

영상의 밝기 조절

아시다시피 영상의 밝기(brightness) 조절은 영상의 전체적인 밝기를 조절하여 좀 더 밝거나 어두운 영상을 만드는 작업입니다. 영상의 밝기를 조절하려면 입력 영상의 모든 픽셀에 일정 값을 더하거나 빼는 작업을 수행합니다. 입력 영상의 모든 픽셀에 양수 값을 더하면 영상이 밝아지고, 반대로 양수 값을 빼면 영상이 어두워집니다.

 

영상의 밝기 조절을 수식으로 나타내면 다음과 같습니다.

\[\text{dst}(x, y) = \text{src}(x, y) + n\]

위 수식에서 src는 입력 영상, dst는 출력 영상, n은 조절할 밝기 값을 나타냅니다. n이 양수이면 출력 영상 dst의 전체적인 밝기가 증가하고, 음수이면 밝기가 감소하여 어두워집니다. 앞 수식을 그래프 형태로 나타내면 아래와 같습니다.

왼쪽 그래프는 n이 양수인 경우이며 결과 영상의 밝기가 밝아지고, 오른쪽 그래프는 n이 음수인 경우이며 어두워진 결과 영상이 생성됩니다.

 

그런데 위 그래프를 살펴보면 dst 영상의 픽셀 값이 0보다 작아지거나 255보다 커지는 부분에서 직선이 꺾여져 있습니다. 위의 밝기 조절 수식을 그대로 사용하면 픽셀 값이 255보다 커지거나 0보다 작아지는 경우가 생길 수 있습니다. 그러나 255보다 큰 값을 결과 영상의 픽셀 값으로 사용할 수 없기 때문에 이러한 경우에는 결과 영상의 픽셀 값을 그레이스케일 값의 최댓값 255나 최솟값 0으로 설정해야 합니다. 이처럼 행렬의 원소 값을 설정할 때, 원소 자료형이 가질 수 있는 값이 범위를 벗어나는 경우 해당 자료형의 최솟값 또는 최댓값으로 원소값을 설정하는 연산을 OpenCV에서 포화(saturate) 연산이라고 합니다. uchar 타입을 사용하는 그레이스케일 영상에 대해 포화 연산을 수식으로 표현하면 다음과 같습니다.

\[\text{saturate}(x) = \left\{\begin{matrix} 0 & \text{if } x < 0\\ 255 & \text{if } x > 255\\ x & \text{others} \end{matrix}\right.\]

그러므로 실제로 영상의 밝기 조절을 구현할 때는 다음과 같이 포화 연산을 함께 고려한 수식을 사용해야 합니다.

\[\text{dst}(x, y) = \text{saturate}(\text{src}(x, y) + n)\]

 

이제 위에서 살펴본 밝기 조절을 어떻게 OpenCV를 사용해서 작성하는지 살펴보겠습니다.

#include <iostream>
#include "opencv2/opencv.hpp"

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    cv::Mat dst = src + 100;

    cv::imshow("src", src);
    cv::imshow("dst", dst);
    cv::waitKey();

    cv::destroyAllWindows();
}

위 코드는 lena.bmp 파일의 밝기는 100만큼 증가시켜 화면에 출력합니다.

cv::Mat dst = src + 100;

위 코드에서 src와 dst 변수는 Mat 클래스 타입이고, 숫자 100은 일반적인 int 타입입니다. OpenCV에서는 덧셈, 뺄셈 연산자에 대하여 연산자 오버로딩이 되어 있어서 Mat 객체와 C/C++ 기본 자료형과의 덧셈/뺄셈 연산이 가능합니다. 따라서 위 코드를 만나면 src 행렬의 모든 원소에 각각 100을 더하고, 포화 연산까지 수행한 결과를 dst 행렬에 저장합니다.

위 코드의 결과는 다음과 같습니다.

 

만약 영상을 전체적으로 어둡게 만들고 싶다면, 덧셈 대신 뺄셈 연산을 수행하면 됩니다. 아래 코드는 입력 영상 src보다 픽셀 값이 100만큼 어두운 결과 영상 dst를 생성합니다.

cv::Mat dst = src - 100;

 

만약 영상의 밝기 조절 결과를 dst와 같은 새로운 이미지가 아닌 자기 자신에게 저장하려면 += 연산자 오버로딩을 사용하면 됩니다. 아래는 img에 저장된 영상 밝기를 100만큼 증가시키는 코드입니다.

cv::Mat img = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);
img += 100;

 

영상의 밝기 조절을 위해 덧셈/뺄셈 연산자를 사용하는 방법 외에 명시적으로 행렬의 덧셈 또는 뺄셈 함수를 사용할 수 있습니다. OpenCV는 행렬의 덧셈과 뺄셈을 위해 add() 함수와 subtract() 함수를 제공하며, add() 함수를 사용하여 밝기를 100만큼 증가시키려면 다음과 같이 작성하면 됩니다.

cv::Mat img = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);
cv::Mat dst;
cv::add(src, 100, dst);

 

 

영상 밝기 조절 직접 구현하기

위에서 살펴본 것처럼 덧셈과 뺄셈 연산자를 사용하지 않고, 직접 영상의 밝기를 조절할 수 있습니다. Mat 행렬의 원소 값을 참조하는 방법을 사용하면 쉽게 구현할 수 있는데, 즉, 입력 영상의 모든 픽셀을 방문하면서 픽셀 값에 일정한 상수를 더하거나 빼면 밝기 조절이 가능합니다. 아래 코드는 픽셀 값을 직접 참조하여 밝기를 100만큼 증가시킵니다.

#include <iostream>
#include "opencv2/opencv.hpp"

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    cv::Mat dst(src.rows, src.cols, src.type());

    for (int r = 0; r < src.rows; r++) {
        for (int c = 0; c < src.cols; c++) {
            dst.at<uchar>(r, c) = src.at<uchar>(r, c) + 100;
        }
    }

    cv::imshow("src", src);
    cv::imshow("dst", dst);
    cv::waitKey();

    cv::destroyAllWindows();
}

위 코드를 실행한 결과는 다음과 같습니다.

dst를 보면 영상 내부에 매우 밝은 영역과 어두운 영역이 비정상적으로 섞여 있는 것을 볼 수 있습니다. 이와 같은 결과는 앞서 설명한 포화 연산을 수행하지 않았기 때문입니다. 즉, 오버플로우가 발생하여 256은 0으로, 257은 1로 픽셀 값이 설정된 것입니다.

 

따라서, 위 코드에서 2중 for문 내의 코드를 다음과 같이 변경하면 정상적으로 밝기 조절된 결과를 얻을 수 있습니다.

for (int r = 0; r < src.rows; r++) {
    for (int c = 0; c < src.cols; c++) {
        int v = src.at<uchar>(r, c) + 100;
        dst.at<uchar>(r, c) = v > 255 ? 255 : v < 0 ? 0 : v;
    }
}

 

하지만 이와 같이 그레이스케일 값 범위에 맞게끔 결과 영상 픽셀 값을 설정하는 작업은 컴퓨터 비전 프로그래밍에서 매우 빈번하게 발생합니다. 따라서 OpenCV는 행렬의 자료형에 맞게끔 포화 연산을 수행하는 saturate_cast()라는 이름의 캐스팅 함수를 제공합니다. 이 함수는 템플릿 함수로 정의되어 있으며 그레이스케일 영상에 대해 자주 사용되는 형식은 다음과 같습니다.

template<> inline uchar saturate_cast<uchar>(int v) {
    return (uchar)((unsigned)v <= UCHAR_MAX ? v : v > 0 ? UCHAR_MAX : 0);
}

이렇게 변경하여 실행해보면, 영상의 밝기가 정상적으로 밝아지는 것을 확인할 수 있습니다.

 


영상의 명암비 조절

이번에는 영상의 명암비를 조절하는 방법에 대해 살펴보겠습니다. 위에서 설명한 밝기 조절이 기본적으로 덧셈 연산을 사용하는 방식이라면, 영상의 명암비 조절은 전체 픽셀에 적절한 실수를 곱하는 곱셈 연산을 사용합니다. 다만 명암비 조절을 위한 곱셈 수식을 어떻게 적용하느냐에 따라 결과 영상의 품질에서 차이가 발생할 수 있습니다.

 

기본적인 명암비 조절 방법

명암비는 영상에서 밝은 영역과 어두운 영역 사이에 드러나는 밝기 차이의 강도를 의미하고, 명암 대비 또는 콘트라스트(contrast)라고도 합니다. 영상이 전반적으로 어둡거나 또는 전반적으로 밝은 픽셀로만 구성된 경우, 명암비가 낮다고 표현합니다. 반면에 밝은 영역과 어두운 영역이 골고루 섞여 있는 영상은 명암비가 높다고 이야기 합니다. 일반적으로 명암비가 낮은 영상은 객체 간의 구분이 잘 되지 않아서 흐릿하게 느껴지고, 명암비가 높은 영상은 사물의 구분이 잘 되며 선명한 느낌을 줍니다.

 

일반적으로 명암비가 높은 사진이 잘 찍은 사진으로 보이기 때문에 대부분의 디지털 카메라는 명암비가 높은 사진을 촬영하도록 설정되어 있습니다. 이미 촬영된 영상의 경우에는 픽셀의 밝기 값을 조절하여 전체적인 명암비를 높이거나 낮출 수 있습니다. 명암비 조절은 기본적으로 곱셈 연산을 사용하여 구현하는데, 기본적인 명암비 조절 수식은 다음과 같습니다.

\[\text{dst}(x, y) = \text{saturate}(s \cdot \text{src}(x, y))\]

위 수식에서 src는 입력, dst는 출력, 그리고 상수 s는 0보다 큰 양의 실수입니다. 입력 영상의 픽셀 값에 상수 s를 곱한 결과가 255보다 커지는 경우가 발생할 수 있기 때문에 포화 연산도 함께 적용합니다. 상수 s가 1보다 작은 경우에는 명암비가 낮아지는 효과가 있고, 1보다 큰 경우에는 명암비가 높아지는 효과가 있습니다.

예를 들어, s=2인 경우에 원본 영상에서 밝기가 60이었던 픽셀은 120이 되고, 100이었던 픽셀은 200이 됩니다. 그 결과 두 픽셀의 밝기 차이가 원본 영상에서는 40이었지만, 결과 영상에서는 80으로 증가합니다. 반면 s=0.5인 경우, 밝기 값이 60이었던 픽셀은 30이 되고, 100이었던 픽셀은 50으로 감소합니다. 그 결과 두 픽셀의 밝기 차이는 40에서 20으로 감소합니다.

 

그러나 위 수식을 이용하여 명암비를 조절하면 결과 영상이 전반적으로 어두워지거나, 또는 결과 영상의 밝기가 너무 쉽게 포화되는 단점이 있습니다. 위의 예시에서 s=0.5인 경우와 s=2인 경우의 그래프는 다음과 같습니다.

s=0.5인 그래프의 경우, 결과 영상의 픽셀이 가질 수 있는 값의 범위가 0부터 128 사이로 제한되기 때문에 전체적으로 어두워지면서 명암비가 감소합니다. s=2인 그래프의 경우, 입력 영상에서 0부터 128 사이의 값을 갖는 픽셀은 0부터 255 사이의 값으로 변하기 때문에 명암비가 높아집니다. 그러나 128 이상의 값을 갖는 픽셀은 모두 포화되어 255가 됩니다.

 

기본적인 명암비 조절 방법을 구현한 코드는 다음과 같습니다.

#include <iostream>
#include "opencv2/opencv.hpp"

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    float s = 2.f;
    cv::Mat dst = s * src;

    cv::imshow("src", src);
    cv::imshow("dst", dst);
    cv::waitKey();

    cv::destroyAllWindows();
}

결과 영상을 보면 전체적으로 픽셀 값이 포화되어 흰색으로 나타나는 영역이 너무 많으며, 이로 인해 사물의 윤관 구분이 더 어려워졌습니다. 사실상 픽셀 값에 일정 상수를 단순히 곱하여 명암비를 조절하는 방식은 잘 사용되지 않습니다.

 

효과적인 명암비 조절 방법

명암비를 효과적으로 높이기 위해서는 밝은 픽셀은 더욱 밝게, 어두운 픽셀은 더욱 어두워지게 변경해야 합니다. 이때 픽셀 값이 밝고 어둡다는 기준을 어떻게 설정할 것인지가 명암비 조절 결과 영상의 품질 차이를 가져올 수 있습니다. 그레이스케일 범위 중간값인 128을 기준으로 설정할 수도 있고, 입력 영상의 평균 밝기를 구하여 기준으로 삼을 수도 있습니다. 여기서는 간단하게 그레이스케일 범위의 중간값인 128을 기준으로 명암비를 조절하는 방법을 구현해보겠습니다. 즉, 픽셀 값이 128보다 크면 더욱 밝게 만들고, 128보다 작으면 더 작게 만드는 방법을 사용합니다. 반대로 명암비를 감소시키려면 128보다 큰 픽셀 값은 좀 더 작게, 128보다 작은 픽셀 값은 오히려 128에 가깝게 증가시킵니다. 이러한 변경 방식을 수식으로 나타내면 다음과 같습니다.

\[\text{dst}(x, y) = \text{src}(x, y) + (\text{src}(x, y) - 128)\cdot \alpha\]

이 수식에서 \(\alpha\)는 -1보다 같거나 큰 실수입니다. 이 수식은 항상 (128, 128) 좌표를 지나가고 \(\alpha\)에 의해 기울기가 변경되는 직선의 방정식입니다. \(\alpha\)의 범위가 \(-1 \leq \alpha \leq 0\)이면 기울기가 0부터 1사이의 직선이 되며, 이는 명암비를 감소시키는 변환 함수가 됩니다. 반면 \(\alpha\)의 범위가 \(\alpha > 0\)이면 기울기가 1보다 큰 직선의 방정식이며, 이는 명암비를 증가시키는 변환 함수가 됩니다. 앞 수식에 의해 계산되는 픽셀 값은 0보다 작거나 255보다 커지는 경우가 발생할 수 있으므로 포화 연산도 함께 수행해야 합니다. 이를 포함한 수식은 다음과 같습니다.

\[\text{dst}(x, y) = \text{saturate}(\text{src}(x, y) + (\text{src}(x, y) - 128)\cdot \alpha)\]

 

위 수식에서 \(\alpha = -0.5\)인 경우와 \(\alpha = 1.0\)인 경우의 그래프는 다음과 같습니다.

왼쪽 그래프는 명암비를 감소시키는 함수의 그래프이며, 이 경우 결과 영상의 픽셀 값이 가질 수 있는 범위는 64부터 192로 한정됩니다. 오른쪽 그래프는 명암비가 높은 결과 영상을 생성합니다. 대부분의 영상에서 픽셀 값이 0 또는 255에 가까운 픽셀보다 중간 밝기의 픽셀이 많기 때문에 포화 연산의 영향을 받는 픽셀은 그리 많지 않습니다.

 

이 수식을 이용하여 작성한 코드는 다음과 같습니다.

#include <iostream>
#include "opencv2/opencv.hpp"

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    float alpha = 1.f;
    cv::Mat dst = src + (src - 128) * alpha;

    cv::imshow("src", src);
    cv::imshow("dst", dst);
    cv::waitKey();

    cv::destroyAllWindows();
}

코드의 실행 결과는 다음과 같습니다.

이전 결과보다 좀 더 자연스럽게 명암비가 증가되었음을 확인할 수 있습니다.

 

 


히스토그램 분석

이번에는 주어진 영상의 픽셀 밝기 분포를 조사하여 밝기 및 명암비를 적절하게 조절하는 방법에 대해 알아보겠습니다.

 

히스토그램 구하기

영상의 히스토그램(histogram)이란 영상의 픽셀 값 분포를 그래프 형태로 표현한 것을 의미합니다. 그레이스케일 영상의 경우, 각 그레이스케일 값에 해당하는 픽셀의 개수를 구하고 이를 막대 그래프 형태로 표현함으로써 히스토그램을 구할 수 있습니다. 컬러 영상에 대해서도 3개의 색상 성분 조합에 따른 픽셀 개수를 계산하여 히스토그램을 구할 수 있습니다.

 

먼저 단순한 형태의 영상을 이용하여 히스토그램 계산 과정을 살펴보겠습니다. 

위 그림 왼쪽에 나타난 4x4 입력 영상은 각 픽셀이 0부터 7사이의 밝기를 가지는 단순한 형태의 영상입니다. 이 영상에서 값이 0인 픽셀 개수를 세어 보면 4이고, 밝기 값이 1인 픽셀 개수는 3입니다. 이처럼 각각의 밝기에 해당하는 픽셀 개수를 세어서 막대그래프 형태로 표현한 히스토그램이 위 그림의 오른쪽입니다.

 

히스토그램 그래프에서 가로축을 히스토그램의 빈(bin)이라고 합니다. 따라서 위 그림의 히스토그램에서 빈 개수는 8개입니다. 그레이스케일 영상의 경우에는 256개의 빈을 갖는 히스토그램을 구하는 것이 일반적입니다.

 

하지만, 히스토그램의 빈 개수가 항상 픽셀 값 범위와 같아야 하는 것은 아닙니다. 경우에 따라서는 히스토그램의 빈 개수를 픽셀 값 범위보다 작게 설정할 수 있습니다. 예를 들어, 위 그림과 같이 여덟 개의 밝기 값을 가질 수 있는 영상에서 히스토그램 빈 개수를 4개로 설정할 수도 있습니다. 이 경우에는 0번 빈은 픽셀 값이 0 또는 1인 픽셀 개수이고, 1번 빈의 히스토그램 값은 픽셀 값이 2 또는 3인 픽셀 개수입니다. 나머지 경우도 이와 같은 방식으로 구할 수 있습니다. 일반적으로 히스토그램의 빈 개수가 줄어들면 히스토그램이 표현하는 영상의 픽셀 값 분포 모양이 조금 더 대략적인 형태로 바뀝니다. 반대로 빈 개수가 많으면 세밀한 픽셀 값 분포 표현이 가능합니다.

 

OpenCV에서 영상의 히스토그램을 구하려면 calcHist() 함수를 사용합니다. 이 함수는 한 장의 영상뿐만 아니라 여러 장의 영상으로부터 히스토그램을 구할 수 있고, 여러 채널로부터 히스토그램을 구할 수도 있습니다. 또한 히스토그램 빈 개수도 조절할 수 있습니다. 다양한 형식의 히스토그램 생성을 지원하기 때문에 이 함수의 사용법은 꽤 복잡한 편인데, 기본적인 calcHist() 함수의 원형과 파라미터는 다음과 같습니다.

이 함수는 모두 10개의 인자를 가지고 맨 뒤에 나오는 uniform과 accumulate는 기본값을 가지고 있기 때문에 최소 여덟 개의 인자를 설정해야 합니다. uniform과 accumulate는 명시적으로 지정하지 않으면 자동으로 각각 true, false가 설정되며 이 경우 hist 배열을 0으로 초기화한 후 등간격 히스토그램을 계산합니다. 이는 빈이 표현하는 밝기 값이 균일하다는 것을 의미합니다.

 

calcHist() 함수의 사용법을 살펴보기 위해 하나의 그레이스케일 영상으로부터 히스토그램을 구하는 함수를 살펴보겠습니다. 이 함수는 그레이스케일 입력 영상으로부터 256개의 빈으로 구성된 히스토그램을 생성합니다.

cv::Mat calcGrayHist(const cv::Mat& img)
{
    CV_Assert(img.type() == CV_8UC1); // check grayscale img

    cv::Mat hist;
    int channels[] = { 0 };
    int dims = 1;
    const int histSize[] = { 256 };
    float graylevel[] = { 0, 256 };
    const float* ranges[] = { graylevel };

    cv::calcHist(&img, 1, channels, cv::noArray(), hist, dims, histSize, ranges);

    return hist;
}

이 함수 내부에서 OpenCV 함수 calcHist()를 이용하여 그레이스케일 영상의 히스토그램을 표현하는 행렬 hist를 구하여 반환합니다. 이때 반환되는 hist는 CV_32FC1 타입을 갖는 256x1 크기의 행렬입니다. 즉, hist 행렬의 행 개수는 256이고, 열 개수는 1입니다.

 

calcGrayHist() 함수로 구한 히스토그램 행렬을 막대그래프 형태로 나타내려면 직접 hist 행렬을 참조하여 막대그래프 영상을 생성해야 합니다. 아래 함수는 256개의 빈을 갖는 hist 행렬로부터가로가 256픽셀, 세로가 100픽셀인 크기의 히스토그램 그래프 영상을 생성하는 getGrayHistImage() 함수입니다.

cv::Mat getGrayHistImage(const cv::Mat& hist)
{
    CV_Assert(hist.type() == CV_32FC1);
    CV_Assert(hist.size() == cv::Size(1, 256));

    double histMax;
    cv::minMaxLoc(hist, 0, &histMax);

    cv::Mat imgHist(100, 256, CV_8UC1, cv::Scalar(255));
    for (int i = 0; i < 256; i++) {
        cv::line(imgHist, cv::Point(i, 100), cv::Point(i, 100 - cvRound(hist.at<float>(i, 0) * 100 / histMax)), cv::Scalar(0));
    }

    return imgHist;
}

이 함수는 히스토그램 그래프에서 최대 빈도수를 표현하는 막대그래프 길이가 100픽셀이 되도록 그래프를 그립니다.

 

위 함수들을 이용하여 구현한 전체 코드는 다음과 같습니다.

#include <iostream>
#include "opencv2/opencv.hpp"

cv::Mat calcGrayHist(const cv::Mat& img)
{
    CV_Assert(img.type() == CV_8UC1); // check grayscale img

    cv::Mat hist;
    int channels[] = { 0 };
    int dims = 1;
    const int histSize[] = { 256 };
    float graylevel[] = { 0, 256 };
    const float* ranges[] = { graylevel };

    cv::calcHist(&img, 1, channels, cv::noArray(), hist, dims, histSize, ranges);

    return hist;
}

cv::Mat getGrayHistImage(const cv::Mat& hist)
{
    CV_Assert(hist.type() == CV_32FC1);
    CV_Assert(hist.size() == cv::Size(1, 256));

    double histMax;
    cv::minMaxLoc(hist, 0, &histMax);

    cv::Mat imgHist(100, 256, CV_8UC1, cv::Scalar(255));
    for (int i = 0; i < 256; i++) {
        cv::line(imgHist, cv::Point(i, 100), cv::Point(i, 100 - cvRound(hist.at<float>(i, 0) * 100 / histMax)), cv::Scalar(0));
    }

    return imgHist;
}

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("lena.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    auto hist = calcGrayHist(src);
    auto hist_img = getGrayHistImage(hist);

    cv::imshow("src", src);
    cv::imshow("srcHist", hist_img);
    cv::waitKey();

    cv::destroyAllWindows();
}

 

아래 이미지는 밝기가 다른 여러 영상을 이용하여 입력 영상과 히스토그램의 상관관계를 보여줍니다.

왼쪽 이미지가 원본, 가운데 이미지가 밝기를 증가시킨 이미지, 오른쪽 이미지가 밝기를 감소시킨 이미지입니다. 전체적으로 히스토그램이 왼쪽으로 이동한 것을 확인할 수 있습니다.

 

아래 이미지는 명암비를 증가시킨 이미지와 감소시킨 이미지와 해당하는 히스토그램입니다.

명암비를 증가시킨 이미지는 히스토그램 그래프가 전체 값 범위에서 골고루 나타나는 것을 볼 수 있습니다. 반면 명암비를 감소시킨 이미지에서는 히스토그램 그래프가 일부 구간에 몰려서 나타나는 것을 확인할 수 있습니다.

 

이처럼 히스토그램의 픽셀 분포 그래프는 영상의 밝기와 명암비를 가늠할 수 있는 유용한 도구로 사용될 수 있습니다.

 

히스토그램 스트레칭

히스토그램 스트레칭(histogram stretching)은 영상의 히스토그램이 그레이스케일 전 구간에 걸쳐서 나타나도록 변경하는 선형 변환 기법입니다. 보통 명암비가 낮은 영상은 히스토그램이 특정 구간에 집중되어 나타나는데, 이러한 히스토그램을 마치 고무줄을 잡아 늘이듯이 펼쳐서 히스토그램 그래프가 그레이스케일 전 구간에서 나타나도록 변환하는 기법입니다. 히스토그램 스트레칭을 수행한 영상은 명암비가 높아지기 때문에 대체로 보기 좋은 사진으로 바뀌게 됩니다.

 

히스토그램 스트레칭을 수식으로 나타내면 다음과 같습니다.

\[\text{dst}(x, y) = \frac{\text{src}(x, y) - G_{\text{min}}}{G_{\text{max}} - G_{\text{min}}} \times 255\]

이 식에서 src와 dst는 각각 입력과 출력을 나타내고, \(G_{\text{min}}\)과 \(G_{\text{max}}\)는 입력 영상의 픽셀 값 중에서 가장 큰 그레이스케일 값과 가장 작은 그레이스케일 값을 나타냅니다.

 

히스토그램 스트레칭의 동작 방식을 이해하기 위해 아래 그림을 살펴보겠습니다.

왼쪽 그림은 원본 영상과 원본 영상의 히스토그램입니다. \(G_{\text{min}}\)과 \(G_{\text{max}}\)은 이 영상에서 최소 픽셀 값과 최대 픽셀 값이며, 이 영상의 히스토그램 분포는 \(G_{\text{min}}\)과 \(G_{\text{max}}\) 사이에서만 나타납니다. 이러한 히스토그램 그래프를 그레이스케일 양방향으로 늘려서 \(G_{\text{min}}\)은 0이 되게 하고, \(G_{\text{max}}\)은 255가 되도록 변환하면 히스토그램이 그레이스케일 전체 구간에 대해 나타나게 됩니다. 즉, 위 그림의 오른쪽과 같이 변경되도록 하는 것이 히스토그램 스트레칭입니다.

 

그렇다면 어떻게 해야 이 변환을 수행할 수 있을까요?

그 해답은 바로 위 그림의 가운데 그래프에서와 같이 (\(G_{\text{min}}\), 0)과 (\(G_{\text{max}}\), 255)를 지나가는 직선의 방정식을 구해서 이를 변환 함수로 사용하면 히스토그램 스트레칭을 수행할 수 있습니다. 이 직선의 방정식을 구하려면 직선의 기울기와 y 절편을 구하면 됩니다. 직선의 기울기는 \(\frac{255}{G_{\text{max}} - G_{\text{min}}}\)이고, y 절편은 비례식을 이용하여 구하면 \(\frac{-255 \cdot G_{\text{min}}}{G_{\text{max}} - G_{\text{min}}}\)이 됩니다. 따라서 직선의 방정식은 다음과 같습니다.

\[\begin{align*} \text{dst}(x, y) &= \frac{255}{G_{\text{max}} - G_{\text{min}}} \times \text{src}(x, y) - \frac{255 \cdot G_{\text{min}}}{G_{\text{max}} - G_{\text{min}}} \\ &= \frac{\text{src}(x, y) - G_{\text{min}}}{G_{\text{max}} - G_{\text{min}}} \times 255 \end{align*}\]

 

히스토그램 스트레칭을 위한 함수를 OpenCV에서는 따로 제공하지 않습니다만, 기본적인 산술 연산에대한 연산자 오버로딩을 지원하기 때문에 위의 수식을 코드로 변경하는 것은 그리 어렵진 않습니다.

 

실제 영상에 대하여 히스토그램 스트레칭을 수행하는 코드는 다음과 같습니다.

#include <iostream>
#include "opencv2/opencv.hpp"

cv::Mat calcGrayHist(const cv::Mat& img)
{
    CV_Assert(img.type() == CV_8UC1); // check grayscale img

    cv::Mat hist;
    int channels[] = { 0 };
    int dims = 1;
    const int histSize[] = { 256 };
    float graylevel[] = { 0, 256 };
    const float* ranges[] = { graylevel };

    cv::calcHist(&img, 1, channels, cv::noArray(), hist, dims, histSize, ranges);

    return hist;
}

cv::Mat getGrayHistImage(const cv::Mat& hist)
{
    CV_Assert(hist.type() == CV_32FC1);
    CV_Assert(hist.size() == cv::Size(1, 256));

    double histMax;
    cv::minMaxLoc(hist, 0, &histMax);

    cv::Mat imgHist(100, 256, CV_8UC1, cv::Scalar(255));
    for (int i = 0; i < 256; i++) {
        cv::line(imgHist, cv::Point(i, 100), cv::Point(i, 100 - cvRound(hist.at<float>(i, 0) * 100 / histMax)), cv::Scalar(0));
    }

    return imgHist;
}

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("hawkes.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    double gmin, gmax;
    cv::minMaxLoc(src, &gmin, &gmax);

    cv::Mat dst = (src - gmin) * 255 / (gmax - gmin);

    cv::imshow("src", src);
    cv::imshow("srcHist", getGrayHistImage(calcGrayHist(src)));

    cv::imshow("dst", dst);
    cv::imshow("dstHist", getGrayHistImage(calcGrayHist(dst)));

    cv::waitKey();

    cv::destroyAllWindows();
}

위 코드의 실행 결과는 다음과 같습니다.

원본 영상이 전체적으로 뿌옇게 보이는 것과 달리, 히스토그램 스트레칭이 수행된 결과 영상 dst는 어두운 영역과 밝은 영역이 골고루 분포하는, 명암비가 높은 영상으로 변환되었습니다. 히스토그램 그래프를 살펴보면, distHist의 결과가 srcHist의 히스토그램이 양 옆으로 늘어난 듯한 형태로 변경된 것을 확인할 수 있습니다.

 

히스토그램 평활화

히스토그램 평활화(histogram equalization)는 히스토그램 스트레칭과 더불어 영상의 픽셀 값 분포가 그레이스케일 전체 영역에 골고루 나타나도록 변경하는 알고리즘의 하나입니다. 히스토그램 평활화는 히스토그램 그래프에서 특정 그레이스케일 값 근방에서 픽셀 분포가 너무 많이 뭉쳐 있는 경우 이를 넓게 펼쳐 주는 방식으로 픽셀 값 분포를 조절합니다. 히스토그램 평활화는 히스토그램 균등화 또는 히스토그램 평탄화라는 용어로도 번역되어 사용됩니다.

 

히스토그램 평활화를 구현하기 위해서는 먼저 히스토그램을 구해야 합니다. 편의상 그레이스케일 영상의 히스토그램을 그레이스케일 값 g에 대한 함수 h(g)로 표현하도록 하겠습니다. 즉, h(g)는 영상에서 그레이스케일 값이 g인 픽셀 개수를 나타냅니다. 히스토그램 평활화를 계산하기 위해서는 h(g)로부터 히스토그램 누적 함수 H(g)를 구해야 합니다. 히스토그램 누적 함수 H(g)는 다음 수식으로 정의됩니다.

\[H(g) = \sum_{0 \leq i \leq g} h(i)\]

히스토그램 평활화는 이 히스토그램 누적 함수 H(g)를 픽셀 값 변환 함수로 사용합니다. 다만 H(g) 값의 범위가 보통 그레이스케일 값의 범위보다 훨씬 크기 때문에 H(g) 함수의 최댓값이 255가 되도록 정규화 과정을 거쳐야 합니다. 만약 입력 영상의 픽셀 개수를 N이라고 표현하면 히스토그램 평활화는 다음과 같은 형태로 정의됩니다.

\[\text{dst}(x, y) = \text{round}\left (H(\text{src}(x, y)) \times \frac{L_{\text{max}}}{N} \right )\]

위 수식에서 \(L_{\text{max}}\)는 영상이 가질 수 있는 최대 밝기 값을 의미하며 일반적으로 그레이스케일인 경우 255입니다. round()는 반올림 함수를 나타냅니다.

 

단순한 영상을 가지고 실제 히스토그램 평활화가 동작하는 방식을 살펴보겠습니다.

(a) 이미지는 4x4 크기를 갖고, 픽셀의 밝기 값 범위를 0부터 7사이로 설정하였습니다. 이 영상으로부터 구한 히스토그램 h(g)와 히스토그램의 누적 함수 H(g)를 (b)에 표로 나타냈습니다. 영상의 픽셀 수가 16개이므로 H(7)의 값이 16인 것을 확인할 수 있습니다. 테스트로 사용한 영상은 최대 밝기 값이 7이고 전체 픽셀 개수가 16이므로 정규화를 위한 상수는 7/16을 사용해야 합니다. 즉, H(g)에 7/16을 곱한 값을 결과 영상의 픽셀 값으로 설정해야 합니다. 다만 결과 값이 실수로 계산되기 때문에 이 값을 반올림하여 결과 영상의 픽셀 값으로 설정합니다. 결국 입력 영상에서 그레이스케일 값이 0이었던 픽셀은 변환 함수를 거쳐 2로 변경되고, 1이었던 픽셀은 3으로, 2였던 픽셀은 4로 변경됩니다. 최종적으로 위 그림의 (d)와 같이 변환된 결과를 얻을 수 있습니다.

 

OpenCV에서는 그레이스케일 영상의 히스토그램 평활화를 수행하는 equalizeHist() 함수를 제공합니다.

이 함수는 CV_8UC1 타입을 사용하는 그레이스케일 영상만 입력으로 받습니다. 3채널로 구성된 컬러 영상을 입력으로 사용하면 에러가 발생합니다.

 

이 함수를 사용하여 히스토그램 평활화를 수행하는 코드는 다음과 같습니다.

#include <iostream>
#include "opencv2/opencv.hpp"

int main(int argc, char* argv[])
{
    cv::Mat src = cv::imread("hawkes.bmp", cv::IMREAD_GRAYSCALE);

    if (src.empty()) {
        std::cout << "Image load failed!\n";
        return -1;
    }

    cv::Mat dst;
    cv::equalizeHist(src, dst);

    cv::imshow("src", src);
    cv::imshow("srcHist", getGrayHistImage(calcGrayHist(src)));

    cv::imshow("dst", dst);
    cv::imshow("dstHist", getGrayHistImage(calcGrayHist(dst)));

    cv::waitKey();

    cv::destroyAllWindows();
}

히스토그램 평활화 결과는 위와 같습니다. 결과 영상이 전체적으로 밝은 영역과 어두운 영역의 대비가 크게 증가한 것을 확인할 수 있습니다. 히스토그램 그래프를 살펴보면, srcHist 그래프에서 큰 값이 몰려 있던 부분의 히스토그램 그래프가 disHist 그래프에서는 전체 범위로 넓게 펼쳐진 것을 확인할 수 있습니다.

댓글