Python과 OpenCV – 28 : Watershed 알고리즘을 이용한 이미지 분할

이 글의 원문은 https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_watershed/py_watershed.html 입니다.

회색조 이미지는 지형처럼 해석할 수 있는데, 값이 높은 픽셀 위치는 산꼭대기이고 값이 낮은 픽셀 위치는 계곡이라고 해석할 수 있습니다. 지형이므로 고립되어 분리된 계곡이 있을 것이고 이 계곡들을 서로 다른 색의 물로 채우기 시작하면 물이 점점 차오르다가 이웃한 계곡의 언저리에서 물이 합쳐지게 됩니다. 물이 합쳐지는 것을 피하기 위해서 합쳐지는 순간에서의 위치에 경계를 생성하는거죠. 그럼 이 경계선이 이미지 분할의 결과가 됩니다. 이것이 바로 Watershed 알고리즘의 기본철학입니다. 아래의 동영상 이미지를 보면 좀더 직관적으로 이해할 수 있습니다.

이 방식을 통해 이미지를 분할하게 되면 분할에 오류가 발생할 수 있는데, 이는 이미지의 잡음이나 어떤 불규칙한 것들로 인한 요소 등이 이유입니다. 그래서 OpenCV는 마커 기반의 Watershed 알고리즘을 구현해 제공하는데.. 각 계곡을 구성하는 화소들을 병합시켜 번호를 매기고, 병합 수 없는 애매한 화소는 0값을 매깁니다. 이를 인터렉티브한 이미지 분할 기법이라고 합니다. 우리가 알고 있는 객체에 각각에 대해 0 이상의 번호를 매기는 것인데요. 전경이 되거나 객체인 것에, 또 배경에도 0 이상의 값을 매깁니다. 그러나 그외 불명확하다라고 판단되는 것은 0을 매깁니다. 이 불명확한 것이 어떤 요소, 즉 배경인지 전경인지 또는 어떤 객체의 소유인지는 Watershed 알고리즘을 통해 결정됩니다. Watershed 알고리즘을 통해 분할 경계선이 생길 것이고 이 경계선에 대해서는 -1 값을 매깁니다.

자, 이제 이론에 대한 설명은 끝났으므로 예제 코드를 살펴보겠습니다.

import cv2
import numpy as np
from matplotlib import pyplot as plt

img = cv2.imread('./data/water_coins.jpg')

# 이진 이미지로 변환
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)

# 잡음 제거
kernel = np.ones((3,3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)

# 이미지 확장을 통해 확실한 배경 요소 확보
sure_bg = cv2.dilate(opening, kernel, iterations=3)

# distance transform을 적용하면 중심으로 부터 Skeleton Image를 얻을 수 있음.
# 이 결과에 Threshold를 적용하여 확실한 객체 또는 전경 요소를 확보
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
ret, sure_fg = cv2.threshold(dist_transform, 0.5*dist_transform.max(), 255, 0)
sure_fg = np.uint8(sure_fg)

# 배경과 전경을 제외한 영역 곳을 확보
unknown = cv2.subtract(sure_bg, sure_fg)

# 마커 생성 작성
ret, markers = cv2.connectedComponents(sure_fg)
markers = markers + 1
markers[unknown == 255] = 0

# 앞서 생성한 마커를 이용해 Watershed 알고리즘을 적용
markers = cv2.watershed(img, markers)
img[markers == -1] = [255,0,0]

images = [gray,thresh,opening, sure_bg, dist_transform, sure_fg, unknown, markers, img]
titles = ['Gray', 'Binary', 'Opening', 'Sure BG', 'Distance', 'Sure FG', 'Unknow', 'Markers', 'Result']

for i in range(len(images)):
    plt.subplot(3,3,i+1)
    plt.imshow(images[i])
    plt.title(titles[i])
    plt.xticks([])
    plt.yticks([])

plt.show()

실행 결과는 다음과 같습니다.

코드와 실행 결과를 비교해 가며, 설명을 해 보면.. 먼저 5번 코드에서 입력 이미지를 파일로부터 읽고 이 이미지를 2진 이미지로 생성하는 것이 5-9번 코드이고 결과 이미지의 Binary입니다. 잡음을 제거 하기 위해 12-13번 코드가 실행되고 결과 이미지의 Opening입니다. 잡음을 제거한 이미지에 dilate 함수를 통해 이미지의 객체를 확장시킨 것이 16번 코드이고 결과 이미지의 Sure_BG입니다. 이제 확실한 전경 또는 객체에 대한 화소를 얻기 위해 18-22번 코드가 실행되고 그 결과 이미지는 Sure_FG입니다. Sure_FG는 결과 이미지의 Distance 이미지로부터 threshold 처리를 통해 얻어진 것입니다. 이제 배경인 Sure_BG에서 전경인 Sure_FG를 빼면 애매모호한 영역을 얻을 수 있게 되는데, 25번 코드가 이에 해당되고 그 결과 이미지는 Unknonw입니다. 즉 어떤 문제를 해결하기 위해 문제의 범위를 좁혀 나가고 있다는 것을 직감할 수 있습니다. 이제 마커 이미지를 sure_fg를 이용해 생성하는데 28-30번 코드입니다. 머커는 0값부터 지정되므로 결과 마커에 1씩 증감시키고, 애매모호한 부분에 대해서는 0 값을 지정합니다. 앞서 이론에 언급했던 것처럼요. 마커가 준비되었으므로, 이제 Watershed 알고리즘을 적용하고 분할 경계선에 해당되는 화소에 지정된 값인 -1을 가지는 부분을 [255,0,0] 색상으로 지정하는 것이 33-34번 코드이며, 최종 결과 이미지인 Result입니다.

Python과 OpenCV – 27 : 이미지에서 원형 도형 검출(Hough Circle Transform)

이 글의 원문은 https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_houghcircles/py_houghcircles.html#hough-circles 입니다.

앞서 이미지에서 선형 도형을 검출하는데, Hough Transform 알고리즘을 사용했습니다. 이 알고리즘은 수학적 모델링이 가능한 모든 도형을 이미지에서 검출할 수 있는 방법입니다. 그렇다면 원형에 대한 수학적 모델식을 이용해 Hought Transform을 적용할 수 있는데, 문제는 원에 대한 수학식이 중심점 (x, y)와 반지름(r)이라는 3개의 매개변수로 구성되어 있고, 결국 3차원 배열이라는 저장소를 요구한다는 점. 그럼으로 인해 연산이 매우 비효율적이라는 점입니다. 이에 대한 개선으로 Gradient(가장자리에서의 기울기값)을 이용하여 Hought Transform을 적용할 수 있고, 이에 대한 구현으로 OpenCV에서는 cv2.HoughCircles 함수를 제공합니다. 이 함수의 예는 다음과 같습니다.

import cv2
import numpy as np

img = cv2.imread('./data/opencv_logo.png',0)
img = cv2.medianBlur(img,5)
cimg = cv2.cvtColor(img,cv2.COLOR_GRAY2BGR)

circles = cv2.HoughCircles(img,cv2.HOUGH_GRADIENT,1,20,
                            param1=50,param2=30,minRadius=0,maxRadius=0)

circles = np.uint16(np.around(circles))
for i in circles[0,:]:
    cv2.circle(cimg,(i[0],i[1]),i[2],(0,255,0),2)

cv2.imshow('detected circles',cimg)
cv2.waitKey(0)
cv2.destroyAllWindows()

cv2.HoughCircles는 제법 많은 인자를 받는데요. 위의 예제를 통해 보면, 첫번째는 입력 이미지로써 8비트 단일 채널인 Grayscale 이미지, 두번째는 방식으로써 현재는 cv2.HOUGH_GRADIENT만을 지원합니다. 세번째는 대부분 1을 지정하는데, 이 경우 입력 이미지와 동일한 해상도가 사용됩니다. 네번째는 검출한 원의 중심과의 최소거리값으로 이 최소값보다 작으면 원으로 판별되지 않습니다. 그리고 param1은 Canny Edge에 전달되는 인자값, param2는 검출 결과를 보면서 적당이 조정해야 하는 값으로 작으면 오류가 높고 크면 검출률이 낮아진다고 합니다. minRadius와 masRadius는 각각 원의 최소, 최대 반지름이고 0으로 지정하면 사용되지 않습니다. 결과는 다음과 같습니다.

Python과 OpenCV – 26 : 이미지에서 선형 도형 검출(Hough Line Transform)

이 글의 원문은 https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_houghlines/py_houghlines.html#hough-lines 입니다.

이미지에서 선 모양의 도형을 검출하는 Hough Transform에 대해 살펴보고, 이 알고리즘을 구현한 OpenCV의 함수를 살펴봅니다.

먼저 Hough Transfom은 이미지에서 수학적으로 표현 가능한 도형을 검색하는 기술입니다. 그 도형 중 선형에 대해 검색해 볼텐데요. 선에 대한 방정식은 우리가 흔히 알고 있는 기울기(m)와 y절편(c)로 표현되는 𝑦=m𝑥+c도 있지만 삼각함수에 의한 매개변수 방정식으로써는 r = 𝑥cos𝜃 + 𝑦sin𝜃 로도 표현됩니다.

r는 원점으로부터 직선까지의 수직거리이고, 𝜃는 수직선으로부터 x축까지의 반시계방향 각도입니다. 이 선에 대한 모델은 (r, 𝜃)로 표현되고, 이를 2차원 배열에 맵핑하는 것을 생각해 볼 수 있습니다. 즉, 2차원 배열의 열(row)가 r이고 행(column)이 𝜃로 정합니다. 거리 r과 각도 𝜃에 대한 정밀도를 고려하지 않으면 배열의 크기는 무한대가 됩니다. 그래서 r은 이미지의 대각 크기로 하며 단위는 1로 합니다. 이미지는 픽셀로 구분되니까요. 그리고 𝜃는 0-180의 범위로 하고 단위는 1로 합니다. 𝜃를 0-180로 하면 어떠한 선도 표현 가능하고, 단위를 1로 한다는 것은 회전된 선을 총 180개로만 표현 가능하다는 것입니다. 즉, 1도 단위는 정밀도의 단위입니다. 이제, 100×100 크기의 이미지에 시작점(30,50)과 끝점(70,50)으로 연결된 선에 대해 Hough Transfrom 알고리즘을 고려해 보겠습니다. 이 선을 구성하는 모든 픽셀들을 고려하는 것이 맞겠으나, 여기서는 편의상 시작점과 끝점만을 고려해 보겠습니다. r = 𝑥cos𝜃 + 𝑦sin𝜃 식에 대해서 (𝑥, 𝑦)가 (30, 50)인 𝜃는 모든 0-180까지에 대해 r을 구하고 구해진 (r, 𝜃)에 해당하는 배열 요소의 값을 1 증가시킵니다. 물론 초기 배열의 모든 항목은 0으로 초기화되어 있겠지요. 그 다음에 다시 (𝑥, 𝑦)가 (70, 50)인 𝜃는 모든 0-180까지에 대해 r을 구하고 구해진 (r, 𝜃)에 해당하는 배열 요소의 값을 1 증가시킵니다. 결과적으로 배열의 항목 중 가장 큰 값을 찾아 보면 r=50, 𝜃=90이 되고 이미지에서 선에 대한 매개변수 정의의 값과 일치합니다.

이제 이 Hough Transform을 OpenCV에서 구현한 예제를 살펴 보겠습니다.

import cv2
import numpy as np

img = cv2.imread('./data/dave.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray,50,150,apertureSize = 3)

lines = cv2.HoughLines(edges,1,np.pi/180,150)
for line in lines:
    rho,theta = line[0]
    a = np.cos(theta)
    b = np.sin(theta)
    x0 = a*rho
    y0 = b*rho
    x1 = int(x0 + 1000*(-b))
    y1 = int(y0 + 1000*(a))
    x2 = int(x0 - 1000*(-b))
    y2 = int(y0 - 1000*(a))

    cv2.line(img,(x1,y1),(x2,y2),(0,0,255),1)

cv2.imshow('edges', edges)
cv2.imshow('result', img)
cv2.waitKey()
cv2.destroyAllWindows()

실제 칼러 영상에 대해 바로 Hough Transform를 적용하기에는 너무 복잡해서 먼저 Gray 영상으로 변환하고 경계선을 추출한 뒤에 Hough Transform를 적용합니다. Hough Transform를 적용하는 코드가 8번인데, cv2.HoughLines 함수의 인자는 살펴보면, 첫번째는 입력 이미지, 두번째는 r의 정밀도(1로 지정했으며 1픽셀 단위), 세번째는 𝜃의 정밀도(1도로 지정했으며 라이언 단위), 네번째는 앞서 언급한 (r, 𝜃)에 해당하는 배열 요소에 저장된 값 중 네번째 인자로 지정된 값 이상인 것만을 추출하기 위한 조건값입니다.

실행해 보면 결과는 다음과 같습니다.

앞서 살펴본 Hough Transform 알고리즘은 선을 구성하는 모든 픽셀에 대해 2차원 배열 항목에 대한 값 처리를 수행하고 있어 퍼포먼스가 제법 떨어집니다. 이에 대해서 모든 픽셀 값을 처리하지 않고 적당히 필요한 만큼 확률적으로 픽셀들을 선택해 연산하는 방법(Probabilistic Hough Transform)이 있는데, 그 예가 아래와 같습니다.

import cv2
import numpy as np

img = cv2.imread('./data/dave.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray,50,150,apertureSize = 3)
minLineLength = 50
maxLineGap = 10

lines = cv2.HoughLinesP(edges,1,np.pi/180,100,minLineLength,maxLineGap)

for line in lines:
    x1,y1,x2,y2 = line[0]
    cv2.line(img,(x1,y1),(x2,y2),(0,255,0),1)

cv2.imshow('edges', edges)
cv2.imshow('result', img)
cv2.waitKey()
cv2.destroyAllWindows()

10번 코드의 cv2.HoughLinesP 함수가 바로 Probabilistic Hough Transform 방법을 사용하는데, 기존의 cv2.HoughLines 함수와 4개의 인자는 동일하지만 추가적으로 2개 인자가 지정되어 있습니다. 첫번째는 minLineLength로 검출된 선의 길이가 이 인자값보다 작으면 선이 아니라는 조건값이고, 두번째는 maxLineGap로 2개의 선 사이의 거리가 이 값보다 작다면 하나의 선으로 인식하라는 조건값입니다. 결과는 다음과 같습니다.

[OpenLayers] AnimatedCluster 확장 기능

ol은 그 자체의 기능을 확장시키기 위한 확장 기능이 존재합니다. 그 중 하나가 바로 ol-ext이고, 이 곳에서 제공하는 AnimatedCluster 기능에 대해 살펴 보겠습니다.

먼저 ol-ext 기능을 ES6의 모듈방식으로 개발하기 위해 다음과 같은 ol-ext 모듈 설치가 필요합니다.

npm install ol-ext

이제 ol에도 이미 Cluster 기능이 존재하는데, 이 Cluster에 시각적인 효과를 추가한 것이 AnimatedCluster 입니다. 이에 대한 예제를 만들어 만들어 갈건데요. 먼저 완성된 예제의 실행 결과를 동영상으로 살펴보면 아래와 같습니다.

위의 결과는 지도 상에 10000개의 포인트 피쳐를 추가하여 인접한 포인트 간의 클러스터링을 적용하여 시각화하고 있습니다. 이제 예제 코드를 하나씩 살펴보겠습니다.

먼저 index.html 파일입니다.



    
        
        OpenLayers
        
    
                    
        

지도를 위한 div 하나와 index.js에 대한 스크립트 파일입니다. index.js 파일의 코드를 하나씩 살펴보면.. 먼저 필요한 모듈을 추가합니다.

import 'ol/ol.css';

import Feature from 'ol/Feature.js';
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import Point from 'ol/geom/Point.js';
import VectorSource from 'ol/source/Vector.js';
import {Fill, Stroke, Style, Circle, Text} from 'ol/style.js';
import {Tile} from 'ol/layer.js';
import {XYZ, Cluster} from 'ol/source.js';
import AnimatedCluster from 'ol-ext/layer/AnimatedCluster.js'
import SelectCluster from 'ol-ext/interaction/SelectCluster.js'

먼저 10000개의 포인트 피쳐를 가지는 VectorSource 객체를 생성합니다. 이 벡터소스가 클러스터링 대상이 되는 데이터의 제공자입니다.

var featureCount = 10000;
var features = new Array(featureCount);
var feature, geometry;
 
for (var i = 0; i < featureCount; ++i) {
    geometry = new Point([14078579 + Math.random()*100000, 4487570 +  Math.random()*50000]);
    feature = new Feature(geometry);
    feature.set('id', i);
    features[i] = feature;
}

var vectorSource = new VectorSource({
    features: features
});

위의 벡터소스를 통해 클러스터링 알고리즘이 적용되어 또 다른 데이터소스가 만들어지게 됩니다. 아래의 코드는 클러스터링 알고리즘이 적용되어 만들어지는 데이터소스 객체와 이 데이터소스를 기반으로 하는 레이어로써 AnimatedCluster 객체를 생성합니다.

var clusterSource = new Cluster({
    distance: 200,
    source: vectorSource
});

var clusterLayer = new AnimatedCluster({	
    name: 'Cluster',
    source: clusterSource,
    animationDuration: 400,
    style: getStyle
});

clusterSource 객체를 생성하기 위한 옵션으로 distance는 화면의 px 단위 좌표로써 해당 px 값 범위 안의 포인트 요소를 하나의 그룹으로 묶는 기준값입니다. clusterLayer 객체 생성을 위한 옵션으로 animationDuration은 지도를 확대, 축소시 포인트 요소가 클러스터링 되는 것을 애니메이션으로 시각화해주는 시간을 ms 단위로 지정하며 style은 클래스터링 된 포인트 피쳐를 어떤 모양으로 지도 상에 표시할 것인지를 결정하는 함수입니다. getStyle이라는 함수가 지정되어 있는데, 해당 함수는 다음과 같습니다.

var styleCache = {};

function getStyle (feature, resolution) {	
    var size = feature.get('features').length;
    var style = styleCache[size];
    if (!style)
    {	
        var color = size>25 ? '192,0,0' : size>8 ? '255,128,0' : '0,128,0';
        var radius = Math.max(8, Math.min(size*0.75, 20));
        var dash = 2*Math.PI*radius/6;
        var dash = [ 0, dash, dash, dash, dash, dash, dash ];

        style = styleCache[size] = new Style({	
            image: new Circle({	
                radius: radius,
                stroke: new Stroke({
                    color: 'rgba('+color+',0.5)', 
                    width: 15,
                    lineDash: dash,
                    lineCap: 'butt'
                }),
                fill: new Fill({
                    color:'rgba('+color+',1)'
                })
            }),
            text: new Text({
                text: size.toString(),
                fill: new Fill({
                    color: '#fff'
                }),
                font: '10px Arial',
            })
        });
    }

    return [style];
}

이제 지도 객체를 생성합니다. 지도 객체는 다수의 레이어로 구성되어 있으며 앞서 생성한 클러스터 레이어와 함께 배경지도로써 VWorld의 TMS 배경지도 레이어로 구성합니다.

var base = new Tile({
    source: new XYZ({
        url: 'http://xdworld.vworld.kr:8080/2d/Base/service/{z}/{x}/{y}.png',
    })
});

var map = new Map({
    layers: [base, clusterLayer],
    target: document.getElementById('map'),
    view: new View({
        center:  [14128579.82, 4512570.74],
        zoom: 15
    })
});

여기까지만 실행해도 시각적인 클러스터링은 모두 완성된 것입니다. 여기에 더해 클러스터링 된 포인트 피처를 마우스나 터치로 선택할 경우 해당 피쳐의 ID값이나 선택된 그룹(부모) 피쳐가 몇개의 자식 피쳐로 구성되는지를 파악하는 코드를 추가해 보겠습니다.

부모 피쳐를 마우스나 터치로 선택하면 구성되는 자식 포인트 피쳐를 원형으로 펼쳐 표시하는데, 이때 사용되는 스타일 객체를 다음처럼 생성합니다.

// 자식 포인트의 스타일
var img = new Circle({	
    radius: 5,
    stroke: new Stroke({
        color: 'rgba(0,255,255,1)', 
        width: 1 
    }),
    fill: new Fill({
        color: 'rgba(0,255,255,0.3)'
    })
});

// 자식 포인트와 부모 피쳐 사이에 그릴 선에 대한 스타일
var linkStyle = new Style({
    image: img,
    stroke: new Stroke({
        color: '#fff', 
        width: 1 
    }) 
});

확장 기능으로써 AnimatedCluster에 대한 선택 기능 역시 확장 기능으로써 SelectCluster라는 인터랙션(Interaction)으로 수행됩니다. 아래의 코드가 이 인터랙션 객체를 생성합니다.

var selectCluster = new SelectCluster({	
    // 부모를 클릭하여 자식이 표시될때 부모와 자식간의 거리(px 단위)
    pointRadius:7,
    animate: true,
    // 부모와 자식 사이에 그려질 선에 대한 스타일
    featureStyle: function() {
        return [ linkStyle ];
    },
    // 부모가 선택된 상태에서 다시 부모와 자식이 선택될때 선택된 요소의 스타일
    style: function(f, res) {
        var cluster = f.get('features');
        if (cluster.length>1) {	 // 부모 스타일
            return getStyle(f,res);
        } else { // 자식 스타일
            return [
                new Style({	
                    image: new Circle({	
                        stroke: new Stroke({ color: 'rgba(0,0,192,0.5)', width:2 }),
                        fill: new Fill({ color: 'rgba(0,0,192,0.3)' }),
                        radius: 5
                    })
                })
            ];
        }
    }
});

부모나 자식이 선택될 경우에 이벤트를 받아 처리하여 원하는 기능을 원할하게 추가해야 합니다. 아래의 코드처럼요.

selectCluster.getFeatures().on(['add'], function (e)
{
    var c = e.element.get('features');
    if (c.length==1) { // 자식을 선택하때, 자식의 id 속성을 표시	
        var feature = c[0];
        console.log('Selected Feature Id = ' + feature.get('id'));
    } else { // 부모를 선택할때, 부모가 가진 자식의 개수를 표시
        console.log('Count = ' + c.length); 
    }
});

selectCluster.getFeatures().on(['remove'], function (e)
{	
    //.
});

마지막으로 생성한 인터렉션을 받을 지도에 추가합니다.

map.addInteraction(selectCluster);