본문 바로가기

프로그래밍 강좌/C++ - OpenCV

[OpenCV][C++] 영상 분할 ( image segmentation ) 총정리(3) - GrabCut graphcut setmousecallback 그랩컷

지난번에는 워터쉐드 (watershed) 알고리즘에 대해 알아봤습니다.

이번에는 GrabCut 알고리즘에 대해 간단히 알아보도록 하겠습니다.

GrabCut 알고리즘이란?

GrabCut 알고리즘은 영상에서 배경과 전경을 구분하여 전경을 추출하는 알고리즘으로 그래프 컷(graph cut) 기반의 영역 분할 알고리즘 입니다.

그래프 컷 알고리즘은은 영상을 그래프라고 생각해보면, 각 픽셀들을 정점으로 생각할 수 있고 정점간 간선을 유사도라고 생각하면 아래와 같은 그래프로 생각해볼 수 있습니다. 이 그래프에서 최대 유량 알고리즘을 이용하여 정점을 두 집합으로 나누는 알고리즘입니다.

 

출처:  http://www.mathcs.emory.edu/~cheung/Courses/323/Syllabus/NetFlow/max-flow-min-cut.html
최대 유량 알고리즘은 간선을 물이 흐를 수 있는 통로라고 생각하고 만약 물이 흐른다면 이 수많은 통로로 이루어진 구조에서 어떤 식으로 물이 흘러야 가장 많이 흐를 수 있는지를 찾아내는 알고리즘 입니다.
 
OpenCV에서의 GrabCut

GrabCut알고리즘은 사용자가 사전에 지정한 ROI(Region Of Interest) 영역을 기반으로 이미지 분할을 수행합니다. 분할을 위해 먼저 ROI 영역을 기반으로 초기 분할 결과를 생성하고, 이후 반복적으로 업데이트하여 최종적으로 객체와 배경을 분리하는 알고리즘 입니다.

 

GrabCut 알고리즘의 동작 방식은 아래와 같습니다.

 

1. 전경을 모두 포함하도록 사용자가 사각형의 ROI 영역을 선택합니다.

2. 사각형으로 구분된 배경과 전경에 레이블링을 합니다.

3. 가우시안 혼합 모델(Gaussian Mixture Model)을 이용하여 모델링 과정을 거치고 새로운 픽셀 분포를 생성합니다. (GMM이 전경과 배경을 모델링하는데 사용됨)

4. 배경과 전경에 대한 추가 정보를 입력하면 GMM은 학습하고 새로운 픽셀 분포를 생성합니다.

5. 4번을 계속 반복하면서 배경을 지운 영상을 feedback 받아서 영상을 만듭니다. 그래프의 노드들은 픽셀입니다. 추가적으로 source node와 sink node라는 두 개의 노드들이 추가되는데, 전경은 source node와, 배경은 sink node와 연결이 되어 있습니다.

6. source node sink node에 연결하는 가장 자리의 가중치는 픽셀이 전경/배경일 확률을 통해 정의됩니다.

7. 최종 그래프 분할 시 mincut 알고리즘을 사용합니다. 이는 최소 비용 함수를 가진 두 개의 source node와 sink node로 분리합니다.

 

GrabCut 알고리즘은 초기 분할 결과 모델 학습, 모델 업데이트 등 다양한 파라미터를 조정하여 성능을 최적화 할 수 있습니다. 이를 이용하여 다양한 응용 분야에서 객체 검출, 추적, 영상 처리 등에 활용할 수 있습니다.

 

자 그럼 하나씩 구현해볼께요.

grabCut() 함수의 원형을 살펴보면,

img
입력 영상, 8bit 3channel 영상이어야 함
mask
마스크 영상, GC_BGD(0), GC_FGD(1), GC_PR_BGD(2), GC_PR_FGD(3)
rect
ROI 영역, GC_INIT_WITH_RECT 모드에서 사용
bgdModel
임시 배경 모델 행렬
fgdModel
임시 전경 모델 행렬
iterCount
결과 생성을 위한 반복 횟수
mode
grabCut 동작 모드, GC_INIT_WITH_MASK, GC_INIT_WITH_RECT

 

bgdModel, fgdModel 재귀 형태 계속 분할하고 결과가 좋아지는 방향으로 업데이트 하는 것을 원할 때 bgdModel, fgdModel 을 설정합니다.

 

일단 마우스 이벤트를 사용할 예정이오니 마우스 이벤트 방법을 숙지하시길 바랍니다.

https://blog.naver.com/dorergiverny/223084092098

 

[OpenCV][C++] 마우스 이벤트 처리 총정리 - onMouse setMouseCallback EVENT_MOUSEMOVE LBUTTONDOWN

이전에는 윈도우 생성 방법과 키보드 입력을 받아 이벤트를 처리하는 방법에 대해 알아보았습니다. https:/...

blog.naver.com

 

마우스 이벤트 및 전체에 걸쳐 사용할 변수들을 전역 변수로 설정을 합니다.

처음에 사각형을 선택해야 하기 때문에 initState 변수를 설정해 놓고, 사각형 입력 후에는 initState가 완료 된 것으로 세팅하기 위함 입니다.

struct Data_t {
	cv::Mat image;
	cv::Mat mask;
};

cv::Point prevPt;
cv::Rect initRect(0,0,0,0);
bool initState = true;
cv::Mat bgdModel, fgdModel;
cv::Mat res;
 

마우스 이벤트를 구현합니다. 일단 마우스 이벤트 콜백 함수 을 만들고 시나리오에 따라 하나씩 구현을 하면 됩니다.

void mouseEvent(int event, int x, int y, int flags, void* userData)
{
}
 

userData로 입력받는 변수를 Data_t*로 변환하여 해당 구조체의 변수에 접근할 수 있도록 합니다.

Data_t* data = (Data_t*)userData;
cv::Mat mask = data->mask;
cv::Mat dst = data->image;
 

초기 상태의 경우는 사각형을 입력 받아야 합니다.

따라서 마우스 왼쪽 버튼이 눌리고 마우스 움직이고 최종 왼쪽 버튼이 release 될 때까지가 사각형 입력, 즉 초기 상태인 것 입니다.

LBUTTONUP이 될 때 마스크의 값을 GC_BGD(확실히 배경인 화소) 값으로, 사각형 안쪽 GC_PR_FGD(전경에 속할 수도 있는 화소)로 설정을 합니다. 사각형 반드시 전경을 모두 포함해야 하며 배경이 포함될 수 있기 때문입니다.

사각형 설정이 끝나면 grabCut() 함수를 call 합니다. 모드는 GC_INIT_WITH_RECT 로 설정합니다. 그러면 mask 영상이 업데이트가 되고, 확실히 배경이 아닌 모든 값을 white로 변환 후 원본 영상과 blending(addWeighted())을 하여 결과 영상을 화면에 뿌려줍니다.

https://blog.naver.com/dorergiverny/223051510275

 

[OpenCV][C++] 영상에 다른 영상(로고) 삽입하기(1) - ROI 설정, 영상 가중합 로고 넣기 합성 addWeighted()

이번에는 영상에 다른 영상을 삽입하는 방법에 대해 알아볼께요. ROI(Region Of Interest, 관심영역)...

blog.naver.com

 

if (initState) {
		switch (event) {
		case cv::EVENT_LBUTTONDOWN:
			initRect.x = x;
			initRect.y = y;
			break;
		case cv::EVENT_LBUTTONUP:
		{
			initRect.width = x - initRect.x;
			initRect.height = y - initRect.y;

			initState = false;			
			mask.setTo(cv::GC_BGD);
			initRect.x = std::max(0, initRect.x);
			initRect.y = std::max(0, initRect.y);
			initRect.width = std::min(initRect.width, mask.cols - initRect.x);
			initRect.height = std::min(initRect.height, mask.rows - initRect.y);
			(mask(initRect)).setTo(cv::Scalar(cv::GC_PR_FGD));
			grabCut(dst, mask, initRect, bgdModel, fgdModel, 1, cv::GC_INIT_WITH_RECT);

			cv::Mat binMask = mask & 1;
			cv::Mat black(binMask.rows, binMask.cols, CV_8UC3, cv::Scalar(0, 0, 0));
			black.setTo(cv::Scalar::all(255), binMask);
			res = dst.clone();
			addWeighted(black, 0.5, res, 0.5, 0.0, res);

			cv::rectangle(res, initRect, cv::Scalar(0, 0, 255), 2);
			imshow("src", res);
		}		

			break;
		case cv::EVENT_MOUSEMOVE:
			if (flags & cv::EVENT_FLAG_LBUTTON) {
				cv::Mat ddst = dst.clone();
				cv::rectangle(ddst, cv::Rect(initRect.x, initRect.y, x - initRect.x, y - initRect.y), cv::Scalar(0, 0, 255), 2);
				imshow("src", ddst);
			}
			break;
		}
	}
 

초기에 생성된 마스크는 아래와 같습니다. 초기값으로도 그럭저럭 쓸만한 결과가 나왔습니다.

initState가 끝났으니 이제 마우스로 전경인 화소와 배경인 화소를 구분해서 다시 업데이트를 해 볼께요. initState false인 경우 아래의 switch 문을 탑니다.

ctrl + 마우스 왼쪽 조합을 GC_BGD(확실한 배경)으로,

shift + 마우스 왼쪽 조합을 GC_FGD(확실한 전경)으로 해서 마스크를 업데이트 합니다.

else {
		switch (event) {

		case cv::EVENT_LBUTTONDOWN:
			prevPt = cv::Point(x, y);
			break;
		case cv::EVENT_MOUSEMOVE:
			if ((flags & cv::EVENT_FLAG_LBUTTON) && (flags & cv::EVENT_FLAG_CTRLKEY)) {
				// Background
				cv::line(res, prevPt, cv::Point(x, y), cv::Scalar(0, 255, 255), 4);
				cv::line(mask, prevPt, cv::Point(x, y), cv::Scalar(cv::GC_BGD), 4, -1);
				prevPt = cv::Point(x, y);
			}
			else if ((flags & cv::EVENT_FLAG_LBUTTON) && (flags & cv::EVENT_FLAG_SHIFTKEY)) {
				// Foreground
				cv::line(res, prevPt, cv::Point(x, y), cv::Scalar(255, 255, 0), 4);
				cv::line(mask, prevPt, cv::Point(x, y), cv::Scalar(cv::GC_FGD), 4, -1);
				prevPt = cv::Point(x, y);
			}
		}
		imshow("src", res);
	}	
 

전경은 cyan 색으로, 배경은 yellow 색으로 칠했습니다.

손가락 부분을 세밀하게 추가 수정해서 한번 더 grabCut을 수행하면 아래와 같은 결과를 얻을 수 있습니다.

 

esc 키를 누르면 프로그램을 종료하고,

'c' 키를 누르면 현재까지 설정된 마스크 기반 grabCut()을 수행 후 결과를 보여주고,

'r' 키를 누르면 원본 영상 처음부터 다시 시작하고,

'd' 키를 누르면 현재까지 생성된 결과를 이용하여 원본 영상을 segmentation한 결과를 보여주는 이벤트 입니다.

int main()
{
    cv::Mat src = cv::imread("../../images/girl.png", cv::IMREAD_UNCHANGED);	
    if (src.empty())         return 1;
	
    imshow("src", src);

	cv::Mat dst = src.clone();
	cv::Mat mask = cv::Mat::zeros(src.size(), CV_8UC1);
	Data_t data = { dst, mask };
	cv::setMouseCallback("src", mouseEvent, (void*)&data);
	cv::Mat resultImg;
	
	bool bEscKey = false;
	int nKey;
	while (!bEscKey) {
		nKey = cv::waitKey(0);
		switch (nKey) {
		case 27:		// esc
			bEscKey = true;
			break;
		case 'c':		// grabcut
			if (!initState) {
				grabCut(dst, mask, initRect, bgdModel, fgdModel, 1);

				cv::Mat binMask = mask & 1;
				cv::Mat black(binMask.rows, binMask.cols, CV_8UC3, cv::Scalar(0, 0, 0));
				black.setTo(cv::Scalar::all(255), binMask);
				res = dst.clone();
				addWeighted(black, 0.5, res, 0.5, 0.0, res);

				cv::rectangle(res, initRect, cv::Scalar(0, 0, 255), 2);
				imshow("src", res);				
			}
			break;
		case 'r':		// restore
			mask = 0;
			src.copyTo(dst);
			initRect = cv::Rect(0, 0, 0, 0);			
			initState = true;
			imshow("src", dst);
			break;
		case 'd':		// show result image			
			cv::Mat binMask = mask & 1;
			cv::Mat black(binMask.rows, binMask.cols, CV_8UC3, cv::Scalar(0, 0, 0));
			black.setTo(cv::Scalar::all(255), binMask);
			resultImg = 0;
			src.copyTo(resultImg, black);
						
			imshow("result", resultImg);
			
			break;
		}
	}

    return 0;
}
 

그럼 전체 소스를 한번 보여드릴께요.

리팩토링을 하지 않은 지저분한 소스입니다. 각자 반복되는 부분을 함수화 해보시기 바랍니다.

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

struct Data_t {
	cv::Mat image;
	cv::Mat mask;
};

cv::Point prevPt;
cv::Rect initRect(0,0,0,0);
bool initState = true;
cv::Mat bgdModel, fgdModel;
cv::Mat res;

void mouseEvent(int event, int x, int y, int flags, void* userData)
{
	Data_t* data = (Data_t*)userData;
	cv::Mat mask = data->mask;
	cv::Mat dst = data->image;
	if (initState) {
		switch (event) {
		case cv::EVENT_LBUTTONDOWN:
			initRect.x = x;
			initRect.y = y;
			break;
		case cv::EVENT_LBUTTONUP:
		{
			initRect.width = x - initRect.x;
			initRect.height = y - initRect.y;

			initState = false;			
			mask.setTo(cv::GC_BGD);
			initRect.x = std::max(0, initRect.x);
			initRect.y = std::max(0, initRect.y);
			initRect.width = std::min(initRect.width, mask.cols - initRect.x);
			initRect.height = std::min(initRect.height, mask.rows - initRect.y);
			(mask(initRect)).setTo(cv::Scalar(cv::GC_PR_FGD));
			grabCut(dst, mask, initRect, bgdModel, fgdModel, 1, cv::GC_INIT_WITH_RECT);

			cv::Mat binMask = mask & 1;
			cv::Mat black(binMask.rows, binMask.cols, CV_8UC3, cv::Scalar(0, 0, 0));
			black.setTo(cv::Scalar::all(255), binMask);
			res = dst.clone();
			addWeighted(black, 0.5, res, 0.5, 0.0, res);

			cv::rectangle(res, initRect, cv::Scalar(0, 0, 255), 2);
			imshow("src", res);
		}		

			break;
		case cv::EVENT_MOUSEMOVE:
			if (flags & cv::EVENT_FLAG_LBUTTON) {
				cv::Mat ddst = dst.clone();
				cv::rectangle(ddst, cv::Rect(initRect.x, initRect.y, x - initRect.x, y - initRect.y), cv::Scalar(0, 0, 255), 2);
				imshow("src", ddst);
			}
			break;
		}
	}
	else {
		switch (event) {

		case cv::EVENT_LBUTTONDOWN:
			prevPt = cv::Point(x, y);
			break;
		case cv::EVENT_MOUSEMOVE:
			if ((flags & cv::EVENT_FLAG_LBUTTON) && (flags & cv::EVENT_FLAG_CTRLKEY)) {
				// Background
				cv::line(res, prevPt, cv::Point(x, y), cv::Scalar(0, 255, 255), 4);
				cv::line(mask, prevPt, cv::Point(x, y), cv::Scalar(cv::GC_BGD), 4, -1);
				prevPt = cv::Point(x, y);
			}
			else if ((flags & cv::EVENT_FLAG_LBUTTON) && (flags & cv::EVENT_FLAG_SHIFTKEY)) {
				// Foreground
				cv::line(res, prevPt, cv::Point(x, y), cv::Scalar(255, 255, 0), 4);
				cv::line(mask, prevPt, cv::Point(x, y), cv::Scalar(cv::GC_FGD), 4, -1);
				prevPt = cv::Point(x, y);
			}
		}
		imshow("src", res);
	}	
}

int main()
{
    cv::Mat src = cv::imread("../../images/girl.png", cv::IMREAD_UNCHANGED);	
    if (src.empty())         return 1;
	
    imshow("src", src);

	cv::Mat dst = src.clone();
	cv::Mat mask = cv::Mat::zeros(src.size(), CV_8UC1);
	Data_t data = { dst, mask };
	cv::setMouseCallback("src", mouseEvent, (void*)&data);
	cv::Mat resultImg;
	
	bool bEscKey = false;
	int nKey;
	while (!bEscKey) {
		nKey = cv::waitKey(0);
		switch (nKey) {
		case 27:		// esc
			bEscKey = true;
			break;
		case 'c':		// grabcut
			if (!initState) {
				grabCut(dst, mask, initRect, bgdModel, fgdModel, 1);

				cv::Mat binMask = mask & 1;
				cv::Mat black(binMask.rows, binMask.cols, CV_8UC3, cv::Scalar(0, 0, 0));
				black.setTo(cv::Scalar::all(255), binMask);
				res = dst.clone();
				addWeighted(black, 0.5, res, 0.5, 0.0, res);

				cv::rectangle(res, initRect, cv::Scalar(0, 0, 255), 2);
				imshow("src", res);				
			}
			break;
		case 'r':		// restore
			mask = 0;
			src.copyTo(dst);
			initRect = cv::Rect(0, 0, 0, 0);			
			initState = true;
			imshow("src", dst);
			break;
		case 'd':		// show result image			
			cv::Mat binMask = mask & 1;
			cv::Mat black(binMask.rows, binMask.cols, CV_8UC3, cv::Scalar(0, 0, 0));
			black.setTo(cv::Scalar::all(255), binMask);
			resultImg = 0;
			src.copyTo(resultImg, black);
						
			imshow("result", resultImg);
			
			break;
		}
	}

    return 0;
}
 

이번에는 다른 영상으로 다른 실험을 해볼께요.

첫 사각형 초기화 시 전경을 다 포함하지 않을 경우 어떻게 될까요?

이미 사각형 바깥 부분 확실한 배경으로 결정을 했기 때문에 전체를 전경으로 칠하지 않는한 segmentation이 업데이트 되지 않습니다. 초기화 루틴에서 사각형 바깥 부분 GC_PR_BGD로 바꿨는데도 제대로 되지 않네요.

그냥 무조건 사각형은 전경을 모두 포함하는 것으로 해야 할 것 같아요.

이제는 iteration이 어떻게 사용되는지 확인해볼께요.

저희는 'c' 버튼을 누를 때마다 iteration = 1로 설정하여 grabCut()을 불렀습니다.

'c' 버튼을 누른 횟수가 iteration 횟수라고 생각하시면 됩니다.

 

5회 이상부터는 아무리 'c' 버튼을 눌러도 더 이상 업데이트가 되지 않습니다. 모델이 saturation 된 모양입니다. 이럴 때에는 추가 정보를 더 넣어 줍니다.

결국 우리는 훌륭한 segmentation 결과를 얻었습니다.