본문 바로가기

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

[OpenCV][C++] 영상 분할(image segmentation) 총정리 (1) - 워터쉐드 (watershed) 알고리즘 rainfall

이번에는 영상 분할의 기본적인 방법인 워터쉐드 알고리즘에 대해 간단히 알아 본 후 영상 분할 실습을 해 보도록 하겠습니다.

watershed 알고리즘

watershed 알고리즘은 영역 기반 분할(region-based segmentation) 의 한 방법으로 영상의 밝기 값의 유사성에 근거하여 영역을 분할하는 방법입니다. 다시 말하면, 영상의 픽셀 값을 높이로 생각하고 영상을 2차원 지형으로 가정할 때 물을 채우고 물 웅덩이로 분할된 영역으로 분할하는 알고리즘 입니다.

watershed는 크게 Rainfall, Flooding 의 두가지 방법으로 나눌 수 있습니다.

  • Rainfall: 지형의 고도가 높은 지점에서 물방울을 떨어뜨려 고도가 낮은 물 웅덩이를 만드는 방법
  • Flooding: 물을 고도가 낮은 골짜기부터 채워 물 웅덩이를 만드는 방법

 

높이가 높은 값을 갖는 픽셀(밝은 값)을 봉우리(peak) 또는 워터쉐드 라인(watershed line)이라고 하고, 낮은 값을 갖는 픽셀을 골짜기(valley)라고 표현합니다.

두 가지 방법 모두 valley부터 물이 채워지기 시작할테고, 다른 두 영역이 만날 때 watershed line을 만들고 멈추게 됩니다.

워터쉐드 알고리즘의 원래 버전은 영상을 과도하게 분할하여 작은 영역을 만들어버리는 문제점이 있다고 합니다. 그래서 OpenCV에서는 이러한 문제점을 해결하기 위해 변형된 마커 기반의 watershed 알고리즘을 제안하고 구현하였다고 합니다. 이는 병합될 계곡 그렇지 않을 계곡을 지정해주는 방법입니다.

이는 전경 또는 물체라고 하는 지역에 대한 색상에 라벨을 붙이고, 배경 또는 물체가 아니라고 확신하는 지역에 다른 색상으로 라벨을 붙이고, 마지막으로 아무것도 확신하지 못하는 지역 0으로 라벨을 붙이면 되고, 이것이 바로 marker 입니다. 그리고 물체의 경계 -1 값을 갖게 됩니다.

watershed 기법을 사용하여 segmentation 해보기

OpenCV에서 제공하는 watershed 함수의 원형은 아래와 같습니다.

src
입력 영상, CV_UC3 컬러 영상
markers
마커 영상, 32bit 1채널 라벨링된 영상

 

앞부분은 똑같습니다. 마우스 이벤트를 받아서 마스크 영상을 생성하는 부분입니다.

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

cv::Point prevPt;

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;
	switch (event) {
	case cv::EVENT_LBUTTONDOWN:
		prevPt = cv::Point(x, y);

		break;
	case cv::EVENT_MOUSEMOVE:
		if (flags & cv::EVENT_FLAG_LBUTTON) {
			cv::line(dst, prevPt, cv::Point(x, y), cv::Scalar(0, 255, 255), 4);
			cv::line(mask, prevPt, cv::Point(x, y), cv::Scalar(255), 5, -1);
			prevPt = cv::Point(x, y);
		}
	}
	imshow("result", dst);
}
 

이번에는 분리가 잘 되는 inRange()에서 사용했던 제 손 영상을 가져와 볼께요.

영상을 읽어 오고, mask 영상(마우스로부터 그릴 영상)과 marker 영상(라벨링된 영상)을 생성합니다.

그리고 마우스 이벤트를 등록해 줍니다.

cv::Mat src = cv::imread("../../images/hand.jpg");	
if (src.empty())  return 1;

cv::Mat dst = src.clone();
cv::Mat mask = cv::Mat::zeros(src.size(), CV_8UC1);
cv::Mat markers = cv::Mat::zeros(src.size(), CV_32SC1);
imshow("result", dst);

Data_t data = { dst, mask };
cv::setMouseCallback("result", mouseEvent, (void*)&data);
 
int mode = cv::RETR_LIST;
int method = cv::CHAIN_APPROX_SIMPLE;
std::vector<std::vector<cv::Point >> contours;
 

그리고 inpaint()에서 했던 것처럼 키보드 입력을 받는 루틴을 생성합니다.

watershed 'w'키를 누르면 동작하도록 하였습니다.

bool bEscKey = false;
int nKey;

while (!bEscKey) {
nKey = cv::waitKey(0);
switch (nKey) {
	case 27:		// esc
		bEscKey = true;
		break;
	case 'r':		// restore
		mask = 0;
		src.copyTo(dst);
		imshow("result", dst);
		break;
	case 'w':		// watershed algorithm body
		
		break;
	}
}
 

이제부터 watershed body 쪽에 들어갈 쪽을 알아볼께요.

우선 마우스로부터 생성한 마스크 영상을 분할하기 위해 contour를 생성합니다.

findContours()를 사용해야겠죠?

cv::findContours(mask, contours, cv::noArray(), mode, method);
if (contours.size() < 1)		break;
 

contours 2개가 생겼습니다.

이제 drawContours()로 컨투어를 그려주는데, 라벨링을 하면서 그려주면 됩니다.

그럼 내가 전경 배경이라고 생각하는 영역들을 마우스로 그릴 때 마스크 영상이 생성되고 그 때 생성된 line들의 집합을 1부터 라벨링해서 채우게 됩니다.

for (int i = 0; i < contours.size(); ++i) 
	cv::drawContours(markers, contours, i, i + 1, -1);
 

마커 영상에는 같은 영상이 그려져있는 것처럼 보이지만 픽셀 값이 아래와 같이 라벨링 된 값으로 채워져있습니다.

이렇게 라벨링 된 마커 영상을 넣으면, watershed 함수를 통과한 후 marker 영상이 아래와 같이 바뀌어집니다.

cv::watershed(src, markers);
 

경계는 0으로, 손 영역은 1로 채워져 있고, 배경은 2로 채워진 영상이 생성됩니다. 즉,원본 영상의 밝기 값을 보고 marker seed index를 기준으로 valley를 채워서 확장시킨 결과 입니다.

이것을 이쁘게 보이게 하려면 뭔가 컬러로 바꿔주면 좋겠죠?

cv::RNG를 이용하여 contour 개수만큼 colorTable을 생성합니다.

cv::RNG rng(12345);
cv::Mat colorTable(contours.size(), 1, CV_8UC3);
cv::Vec3b color;
for (int j = 0; j < contours.size(); ++j) {				
	colorTable.at<cv::Vec3b>(j, 0) = cv::Vec3b(rng.uniform(0, 256), rng.uniform(0, 256), rng.uniform(0, 256));
}
 

그리고 segmentation 된 index에 맞도록 컬러 테이블 기준으로 색칠한 영상을 만든 후 cv::addWeighted() 함수를 이용하여 두 영상을 합성해줍니다.

for (int y = 0; y < markers.rows; ++y) {
	for (int x = 0; x < markers.cols; ++x) {
		int k = markers.at<int>(y, x);
		if (k == -1) // boundary
			color[0] = color[1] = color[2] = 255;
		else if (k <= 0 || k > contours.size())
			color[0] = color[1] = color[2] = 0;
		else {
			color = colorTable.at<cv::Vec3b>(k - 1, 0); 
			dst.at<cv::Vec3b>(y, x) = color;
		}
	}				
}	
cv::addWeighted(dst, 0.5, src, 0.5, 0, dst);
imshow("result", dst);
 

그럼 아래와 같이 random한 컬러가 적용된 segmentation 된 영상을 얻을 수 있습니다.

경계는 흰색으로 칠해졌어요.

색상을 보니 마치 가오갤의 욘두같네요. ㅋㅋ

전체 소스는 아래와 같습니다.

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

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

cv::Point prevPt;

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;
	switch (event) {
	case cv::EVENT_LBUTTONDOWN:
		prevPt = cv::Point(x, y);

		break;
	case cv::EVENT_MOUSEMOVE:
		if (flags & cv::EVENT_FLAG_LBUTTON) {
			cv::line(dst, prevPt, cv::Point(x, y), cv::Scalar(0, 255, 255), 4);
			cv::line(mask, prevPt, cv::Point(x, y), cv::Scalar(255), 5, -1);
			prevPt = cv::Point(x, y);
		}
	}
	imshow("result", dst);
}

int main()
{
	cv::Mat src = cv::imread("../../images/hand.jpg");		
	if (src.empty())  return 1;

	cv::Mat dst = src.clone();
	cv::Mat mask = cv::Mat::zeros(src.size(), CV_8UC1);
	cv::Mat markers = cv::Mat::zeros(src.size(), CV_32SC1);
	imshow("result", dst);

	Data_t data = { dst, mask };
	cv::setMouseCallback("result", mouseEvent, (void*)&data);

	int mode = cv::RETR_LIST;
	int method = cv::CHAIN_APPROX_SIMPLE;
	std::vector<std::vector<cv::Point >> contours;
	
	bool bEscKey = false;
	int nKey;
	cv::RNG rng(12345);
	while (!bEscKey) {
		nKey = cv::waitKey(0);
		switch (nKey) {
		case 27:		// esc
			bEscKey = true;
			break;
		case 'r':		// restore
			mask = 0;
			src.copyTo(dst);
			imshow("result", dst);
			break;
		case 'w':		// 
			cv::findContours(mask, contours, cv::noArray(), mode, method);
			if (contours.size() < 1)		break;
			markers = 0;
			for (int i = 0; i < contours.size(); ++i) 
				cv::drawContours(markers, contours, i, i + 1, -1);
			cv::watershed(src, markers);

			// 객체 표시를 위한 컬러 테이블 생성
			cv::Mat colorTable(contours.size(), 1, CV_8UC3);
			cv::Vec3b color;
			for (int j = 0; j < contours.size(); ++j) {				
				colorTable.at<cv::Vec3b>(j, 0) = cv::Vec3b(rng.uniform(0, 256), rng.uniform(0, 256), rng.uniform(0, 256));
			}

			// 마커 기반 객체 표시
			for (int y = 0; y < markers.rows; ++y) {
				for (int x = 0; x < markers.cols; ++x) {
					int k = markers.at<int>(y, x);
					if (k == -1) // boundary
						color[0] = color[1] = color[2] = 255;
					else if (k <= 0 || k > contours.size())
						color[0] = color[1] = color[2] = 0;
					else {
						color = colorTable.at<cv::Vec3b>(k - 1, 0); 
						dst.at<cv::Vec3b>(y, x) = color;
					}
				}				
			}	
			cv::addWeighted(dst, 0.5, src, 0.5, 0, dst);
			imshow("result", dst);
			break;
		}
	}

	return 0;
}	
 

그럼 다른 영상으로 한번 더 watershed를 돌려보겠습니다.

다음과 같이 전경과 배경을 알려주고 watershed를 돌리면,

아래와 같은 결과를 얻을 수 있습니다.

완전 정확하다고는 볼 수 없지만 그래도 어느정도 동작하는 소스를 얻었어요.

사실 watershed는 실제로 많이 사용되지 않고, 그 이후에 나온 segmentation 알고리즘들을 사용하고 있어요.

최근에는 물론 딥러닝을 적용해서 segmentation을 하죠.