본문 바로가기

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

[OpenCV][C++] 영상 분할 (image segmentation) 총정리(2) - 워터쉐드 (watershed) 알고리즘, cv::distanceTransform

이전에는 마우스 이벤트를 활용하여 markers 영상을 만들고 이를 기반으로 watershed 알고리즘을 수행하는 방법을 알아보았습니다.

(1) Watershed Segmentation

watershed segmentation을 수행하는 방법을 알아보겠습니다.

이번에는 거리변환(distance Transform) 함수에 대해 알아볼텐데요.

거리 변환이라고 불리는 distanceTransform은 이진화 영상에서 픽셀값이 0인 배경으로부터의 거리를 나타냅니다. 즉 배경으로부터 멀리 떨어져있을수록 높은 픽셀 값을 가지고 있습니다.

아래와 같은 전경과 배경을 가진 이진화 영상이 있다고 치면,

distance 영상은 오른쪽과 같이 나타낼 수 있습니다.

일단 오늘 사용할 함수에 대한 설명을 하면, 함수 원형은 아래와 같습니다.

src
입력 영상
dst
distance 영상, CV_8UC1 또는 CV_32FC1 타입
distanceType
L1거리, L2 거리 등 distance 종류 설정
maskSize
distanceTransformMask 종류 설정
dstType
결과 distance 영상 타입, 기본 = CV_32FC1

imgproc.hpp 에 보면 distanceType maskSize 관련 설명이 있습니다.

distance는 결국 similarity의 개념으로 많이 사용됩니다. 거리가 가까우면 유사한 것, 멀면 유사하지 않은 것을 나타냅니다.

 

distanceType에서 가장 많이 사용되는 것은 L1 거리와 L2 거리입니다.

각각 맨하탄 거리(Manhattan Distance)라고 불리는 L1-norm은 좌표끼리의 절대값의 합으로 나타내며, 유클리디안 거리(Euclidean Distance)라고 불리는 L2-norm은 두 좌표의 최단거리를 구하는 것 입니다.

mask size는 3, 5 또는 DIST_MASK_PRECISE를 가질 수 있는데, 만약 DIST_L1 또는 DIST_C type을 선택했을 경우에는 mask Size는 강제로 3이 됩니다. 왜냐하면 3x3 마스크와 5x5 마스크 결과가 같기 때문이죠.

자 그럼 실제로 위 함수를 사용해보겠습니다.

1. 영상 읽어오기

영상을 읽어보겠습니다. 이번에는 카드 영상입니다.

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

2. 배경을 검게 하기

배경을 검게 칠해보겠습니다.

컬러 영상이기 때문에 cv::threshold() 함수를 사용할 수 없어요.

그럼 inRange()함수를 사용하면 됩니다.

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

 

[OpenCV][C++] Thresholding 이진화 영상 만들기 총정리 (3) - inRange threshold 범위 binarization bitwise_xor

지난번에 이진화에 관련해서 알아봤습니다. https://m.blog.naver.com/dorergiverny/223059732009 하나의 t...

blog.naver.com

inRange()로 255 밝기를 가진 background를 white로 나머지를 검정색인 마스크영상을 만들고 원본 영상에서 setTo로 마스크의 white 영역만 black으로 처리합니다.

cv::Mat mask;	
cv::inRange(src, cv::Scalar(255,255,255), cv::Scalar(255,255,255), mask);
src.setTo(cv::Scalar(0, 0, 0), mask);
 

3. 영상을 날카롭게 만들기

이진화 결과를 좋게 하기 위해 영상을 sharpening 합니다. 이를 위해 간략화된 2차 미분 커널을 만들고 커널 결과가 음수값을 가질 수 있기 때문에 CV_8U보다 깊은 depth의 영상인 CV_32F로 결과를 생성하였습니다. 그 이후 원본 영상에서 2차 미분 영상을 빼보도록 하겟습니다. 영상을 보기 위해 8bit로 확인해볼께요.

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

 

[OpenCV] [C++] 샤프닝 sharpening 필터 연산 총정리 (1) - 1차 미분 sobel 소벨 scharr 샤르 filter 에지 검출

지난번에는 영상을 흐릿하게 만드는 스무딩 필터인 평균 필터에 대해 알아봤습니다. https://m.blog.naver....

blog.naver.com

 

cv::Mat kernel = (cv::Mat_<float>(3, 3) << 1, 1, 1, 1, -8, 1, 1, 1, 1);
cv::Mat src_lap;
cv::filter2D(src, src_lap, CV_32F, kernel);
cv::Mat src_sharp;
src.convertTo(src_sharp, CV_32F);
cv::Mat src_res = src_sharp - src_lap;

src_res.convertTo(src_res, CV_8UC3);
src_lap.convertTo(src_lap, CV_8UC3);
 

2차 미분 결과와 sharpening 된 결과 영상은 아래와 같습니다.

4. 이진화 영상 생성하기

cv::threshold()를 이용하여 영상을 이진화 해 보겠습니다.

컬러 영상을 gray-scale 영상으로 변환한 후 OTSU 방법으로 이진화를 수행합니다.

cv::Mat src_bin;
cv::cvtColor(src_res, src_bin, cv::COLOR_BGR2GRAY);
cv::threshold(src_bin, src_bin, 0, 255, cv::THRESH_BINARY + cv::THRESH_OTSU);
 

5. distance transform 영상 생성하기

watershed 알고리즘 적용을 위한 marker 영상을 만들기 위한 distance transform 영상을 생성합니다. 그리고 영상을 잘 보기 위해 0~1 값을 갖도록 normalize를 합니다.

cv::Mat src_dist;
cv::distanceTransform(src_bin, src_dist, cv::DIST_L2, 3);
cv::normalize(src_dist, src_dist, 0, 1.0, cv::NORM_MINMAX);
 

5. 이진화된 marker 영상 생성하기

전경(foreground)를 추출하기 위해 0.4 초과하는 값을 갖는 픽셀을 255로, 나머지는 0으로 하는 영상을 생성합니다. 그리고 cv::dilate() 함수를 이용하여 전경을 약간 팽창시킵니다.

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

 

[OpenCV][C++] 모폴로지(morphology) 연산 총정리(1) - 침식(Erode), 팽창(Dilate) getStructuringElement

지난 번에는 영상을 이진화하는 방법에 대해 상세히 알아봤습니다. https://m.blog.naver.com/dorergiverny...

blog.naver.com

 

src_dist = src_dist > 0.4;
cv::Mat kernel1 = cv::Mat::ones(3, 3, CV_8U);
cv::dilate(src_dist, src_dist, kernel1);
 

이진화 영상

팽창된 영상

6. 라벨링된 marker 영상 생성하기

각 마커마다 index를 붙여줘야 겠죠?

지난번에 알아본 것과 같은 방법으로 0부터 인덱싱을 해보겠습니다.

std::vector<std::vector<cv::Point>> contours;
cv::findContours(src_dist_8u, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

cv::Mat markers = cv::Mat::zeros(src_dist.size(), CV_32S);
for (size_t i = 0; i < contours.size(); ++i) {
	cv::drawContours(markers, contours, static_cast<int>(i), cv::Scalar(static_cast<int>(i) + 1), -1);
}
 

 0(background) ~ 14까지 인덱싱이 되었습니다.

배경도 segmentation을 해야 하니 배경에도 라벨링을 해야겠죠?

일단 배경은 255로 된 circle을 하나 그려줍니다.

그리고 라벨링된 영상을 8bit 영상으로 보게 되면 아래와 같아요.

cv::circle(markers, cv::Point(5, 5), 3, cv::Scalar(255), -1);
cv::Mat markers_8u;
markers.convertTo(markers_8u, CV_8U, 10);
 

7. watershed 분할하기

watershed 알고리즘으로 marker 기반 영상 분할을 수행합니다.

cv::watershed(src_res, markers);
 

markers 영상이 아래와 같이 변경되었습니다.

8. 결과 컬러로 표시하기

segmentation 결과를 컬러로 표시하기 위해 colorTable을 만들고 컬러를 씌웠습니다.

배경은 검은색으로 처리하였습니다.

cv::Mat mark;
markers.convertTo(mark, CV_8U);
cv::bitwise_not(mark, mark);

std::vector<cv::Vec3b> colors;
for (size_t i = 0; i < contours.size(); ++i) {
	int b = cv::theRNG().uniform(0, 256);
	int g = cv::theRNG().uniform(0, 256);
	int r = cv::theRNG().uniform(0, 256);
	colors.push_back(cv::Vec3b((uchar)b, (uchar)g, (uchar)r));
}

cv::Mat dst = cv::Mat::zeros(markers.size(), CV_8UC3);

for (int i = 0; i < markers.rows; ++i) {
	for (int j = 0; j < markers.cols; ++j) {
		int idx = markers.at<int>(i, j);
		if (idx > 0 && idx <= static_cast<int>(contours.size())) {
			dst.at<cv::Vec3b>(i, j) = colors[idx - 1];
		}
	}
}
 

그럼 아래와 같은 카드끼리 segmentation된 영상을 얻을 수 있습니다.

위와 같은 카드 영상에서 카드를 segmentation하는 전체 소스는 아래와 같습니다.

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

int main()
{
	cv::Mat src = cv::imread("../../images/cards.png");	
	if (src.empty())  return 1;
		
	cv::Mat mask;	
	cv::inRange(src, cv::Scalar(255,255,255), cv::Scalar(255,255,255), mask);
	src.setTo(cv::Scalar(0, 0, 0), mask);

	// 2차 미분 커널	
	cv::Mat kernel = (cv::Mat_<float>(3, 3) << 1, 1, 1, 1, -8, 1, 1, 1, 1);
	cv::Mat src_lap;
	cv::filter2D(src, src_lap, CV_32F, kernel);
	cv::Mat src_sharp;
	src.convertTo(src_sharp, CV_32F);
	cv::Mat src_res = src_sharp - src_lap;

	// 8bit 영상으로 다시변경
	src_res.convertTo(src_res, CV_8UC3);
	src_lap.convertTo(src_lap, CV_8UC3);
	imshow("Sharpened Image", src_res);

	// binary 영상 생성
	cv::Mat src_bin;
	cv::cvtColor(src_res, src_bin, cv::COLOR_BGR2GRAY);
	cv::threshold(src_bin, src_bin, 0, 255, cv::THRESH_BINARY + cv::THRESH_OTSU);
	imshow("Binary Image", src_bin);

	// markers 생성(distanceTransform 이용)
	cv::Mat src_dist;
	cv::distanceTransform(src_bin, src_dist, cv::DIST_L2, 3);

	// dist 영상을 normalize 하기 (0~1 값)
	cv::normalize(src_dist, src_dist, 0, 1.0, cv::NORM_MINMAX);
	imshow("DistanceTransform Image", src_dist);

	// peak를 얻기 위해 thresholding
	// foreground 객체를 위한 marker 영상	
	src_dist = src_dist > 0.4;
	cv::Mat kernel1 = cv::Mat::ones(3, 3, CV_8U);
	cv::dilate(src_dist, src_dist, kernel1);
	imshow("markers Image", src_dist);

	cv::Mat src_dist_8u;
	src_dist.convertTo(src_dist_8u, CV_8U);

	// markers 영상 최종 버전
	std::vector<std::vector<cv::Point>> contours;
	cv::findContours(src_dist_8u, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

	cv::Mat markers = cv::Mat::zeros(src_dist.size(), CV_32S);
	for (size_t i = 0; i < contours.size(); ++i) {
		cv::drawContours(markers, contours, static_cast<int>(i), cv::Scalar(static_cast<int>(i) + 1), -1);
	}

	// background marker 생성
	cv::circle(markers, cv::Point(5, 5), 3, cv::Scalar(255), -1);
	cv::Mat markers_8u;
	markers.convertTo(markers_8u, CV_8U, 10);

	// watershed 알고리즘 돌리기
	cv::watershed(src_res, markers);

	cv::Mat mark;
	markers.convertTo(mark, CV_8U);
	cv::bitwise_not(mark, mark);

	std::vector<cv::Vec3b> colors;
	for (size_t i = 0; i < contours.size(); ++i) {
		int b = cv::theRNG().uniform(0, 256);
		int g = cv::theRNG().uniform(0, 256);
		int r = cv::theRNG().uniform(0, 256);
		colors.push_back(cv::Vec3b((uchar)b, (uchar)g, (uchar)r));
	}

	// 결과 영상 그리기
	cv::Mat dst = cv::Mat::zeros(markers.size(), CV_8UC3);

	for (int i = 0; i < markers.rows; ++i) {
		for (int j = 0; j < markers.cols; ++j) {
			int idx = markers.at<int>(i, j);
			if (idx > 0 && idx <= static_cast<int>(contours.size())) {
				dst.at<cv::Vec3b>(i, j) = colors[idx - 1];
			}
		}
	}

	imshow("result", dst);
	cv::waitKey();
	return 0;
}