귀퉁이 서재

OpenCV - 24. 연속 영역 분할 (거리 변환, 레이블링, 색 채우기, 워터셰드, 그랩컷, 평균 이동 필터) 본문

OpenCV

OpenCV - 24. 연속 영역 분할 (거리 변환, 레이블링, 색 채우기, 워터셰드, 그랩컷, 평균 이동 필터)

Baek Kyun Shin 2020. 10. 25. 23:58

이전 포스팅에서는 외곽 경계를 이용해서 객체 영역을 분할하는 방법에 대해 알아봤습니다. 하지만 실제 이미지에는 노이즈도 많고, 경계선이 명확하지 않아 객체 영역을 정확히 분할하는 것이 힘든 경우도 있습니다. 그래서 연속된 영역을 찾아 분할하는 방법도 필요합니다. 이번 포스팅에서는 연속 영역 분할에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/07.segmentation

거리 변환 (Distatnce Transformation)

이미지에서 물체 영역을 정확히 파악하기 위해서는 물체 영역의 뼈대를 찾아야 합니다. 뼈대를 검출하는 방법 중 하나가 외곽 경계로부터 가장 멀리 떨어진 곳을 찾는 방법인 거리 변환입니다. OpenCV에는 거리 변환을 해주는 cv2.distanceTransform() 함수가 있습니다.

  • cv2.distanceTransform(src, distanceType, maskSize)
    src: 입력 영상, 바이너리 스케일
    distanceType: 거리 계산 방식 (cv2.DIST_L2, cv2.DIST_L1, cv2.DIST_L12, cv2.DIST_FAIR, cv2.DIST_WELSCH, cv2.DIST_HUBER)
    maskSize: 거리 변환 커널 크기
# 거리 변환으로 전신 스켈레톤 찾기 (distanceTrans.py)

import cv2
import numpy as np

# 이미지를 읽어서 바이너리 스케일로 변환
img = cv2.imread('../img/full_body.jpg', cv2.IMREAD_GRAYSCALE)
_, biimg = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)

# 거리 변환 ---①
dst = cv2.distanceTransform(biimg, cv2.DIST_L2, 5)
# 거리 값을 0 ~ 255 범위로 정규화 ---②
dst = (dst/(dst.max()-dst.min()) * 255).astype(np.uint8)
# 거리 값에 쓰레시홀드로 완전한 뼈대 찾기 ---③
skeleton = cv2.adaptiveThreshold(dst, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, \
                                                 cv2.THRESH_BINARY, 7, -3)
# 결과 출력
cv2.imshow('origin', img)
cv2.imshow('dist', dst)
cv2.imshow('skel', skeleton)
cv2.waitKey(0)
cv2.destroyAllWindows()

왼쪽 이미지는 원본 이미지를 바이너리 스케일로 변환한 이미지입니다. 가운데 이미지는 0 ~ 255 범위로 정규화한 거리 변환 결과입니다. 외곽 경계로부터 멀어질수록 흰색이 짙어지는 걸 볼 수 있습니다. 오른쪽 이미지는 가운데 이미지에서 픽셀 값=255(흰색)에 해당하는 부분만 추출한 것입니다. 이는 이미지의 뼈대라고도 볼 수 있습니다. 경계로부터 가장 멀리 떨어져 있는 부분만 추출한 것입니다. 이를 거리 변환을 통한 뼈대 추출이라고 합니다.

레이블링 (Labeling)

연결된 요소끼리 분리하는 방법 중 레이블링이라는 방법이 있습니다. 아래와 같이 이미지에서 픽셀 값이 0으로 끊어지지 않는 부분끼리 같은 값을 부여해서 분리를 할 수 있습니다. OpenCV에서 제공하는 cv2.connectedComponents() 함수를 활용하면 이를 구현할 수 있습니다. 이 함수는 이미지 전체에서 0으로 끊어지지 않는 부분끼리 같은 값을 부여합니다.


retval, labels = cv2.connectedComponents(src, labels, connectivity=8, ltype)
: 연결 요소 레이블링과 개수 반환

  • src: 입력 이미지, 바이너리 스케일
    labels(optional): 레이블링된 입력 이미지와 같은 크기의 배열
    connectivity(optional): 연결성을 검사할 방향 개수(4, 8 중 선택)
    ltype(optional): 결과 레이블 배열 dtype
    retval(optional): 레이블 개수
  • retval, labels, stats, centroids = cv2.connectedComponentsWithStats(src, labels, stats, centroids, connectivity, ltype): 레이블링된 각종 상태 정보 반환
    stats: N x 5 행렬 (N: 레이블 개수) [x좌표, y좌표, 폭, 높이, 너비]
    centroids: 각 레이블의 중심점 좌표, N x 2 행렬 (N: 레이블 개수)

cv2.connectedComponents() 함수를 활용해서 연결된 요소끼리 같은 색상을 칠해보겠습니다. 주석 처리된 cv2.connectedComponentsWithStats()로 코드를 돌려도 동일한 결과가 나올 겁니다.

# 연결된 영역 레이블링 (connected_label.py)

import cv2
import numpy as np

# 이미지 읽기
img = cv2.imread('../img/shapes_donut.png')
# 결과 이미지 생성
img2 = np.zeros_like(img)
# 그레이 스케일과 바이너리 스케일 변환
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, th = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)

# 연결된 요소 레이블링 적용 ---①
cnt, labels = cv2.connectedComponents(th)
#retval, labels, stats, cent = cv2.connectedComponentsWithStats(th)

# 레이블 갯수 만큼 순회
for i in range(cnt):
    # 레이블이 같은 영역에 랜덤한 색상 적용 ---②
    img2[labels==i] =  [int(j) for j in np.random.randint(0,255, 3)]

# 결과 출력
cv2.imshow('origin', img)
cv2.imshow('labeled', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()

연결된 부분끼리 같은 색상이 칠해진 것을 볼 수 있습니다. 위에서 말했다시피 cv2.connectedComponents() 함수는 이미지 전체에서 0으로 끊어지지 않는 부분끼리 같은 값을 부여합니다. 반환된 결과를 순회하면서 같은 값끼리는 같은 색상을 칠해주었습니다.

색 채우기

그림판 같은 그리기 도구에서 채우기 기능을 활용하여 색상을 칠해본 경험이 있을 겁니다. OpenCV의 cv2.floodFill()은 이런 기능을 제공합니다. 연속되는 영역에 같은 색상을 채워 넣는 기능을 합니다.

  • retval, img, mask, rect = cv2.floodFill(img, mask, seed, newVal, loDiff, upDiff, flags)
    img: 입력 이미지, 1 또는 3채널
    mask: 입력 이미지보다 2 x 2 픽셀이 더 큰 배열, 0이 아닌 영역을 만나면 채우기 중지
    seed: 채우기 시작할 좌표
    newVal: 채우기에 사용할 색상 값
    loDiff, upDiff(optional): 채우기 진행을 결정할 최소/최대 차이 값
    flags(optional): 채우기 방식 선택 (cv2.FLOODFILL_MASK_ONLY: img가 아닌 mask에만 채우기 적용, cv2.FLOODFILL_FIXED_RANGE: 이웃 픽셀이 아닌 seed 픽셀과 비교)
    retval: 채우기 한 픽셀의 개수
    rect: 채우기가 이루어진 영역을 감싸는 사각형

이 함수는 img 이미지의 seed 좌표에서부터 시작해서 newVal의 값으로 채우기를 시작합니다. 이때 이웃하는 픽셀에 채우기를 계속하려면 현재 픽셀이 이웃 픽셀의 loDiff를 뺀 값보다 크거나 같고 upDiff를 더한 값보다 작거나 같아야 합니다. 이것을 식으로 정리하면 아래와 같습니다. (만약 loDiff와 upDiff를 생략하면 seed의 픽셀 값과 같은 값을 갖는 이웃 픽셀만 채우기를 진행합니다.)

이웃 픽셀 - loDiff <= 현재 픽셀 <= 이웃 픽셀 + upDiff

하지만, 마지막 인자인 flags에 cv2.FLOODFILL_FIXED_RANGE가 전달되면 이웃 픽셀이 아닌 seed 픽셀과 비교하며 색을 채웁니다. 또한, flags에 cv2.FLOODFILL_MASK_ONLY가 전달되면 img에 채우기를 하지 않고 mask에만 채우기를 합니다. 

# 마우스로 색 채우기 (flood_fill.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
rows, cols = img.shape[:2]
# 마스크 생성, 원래 이미지 보다 2픽셀 크게 ---①
mask = np.zeros((rows+2, cols+2), np.uint8)
# 채우기에 사용할 색 ---②
newVal = (255,255,255)
# 최소 최대 차이 값 ---③
loDiff, upDiff = (10,10,10), (10,10,10)

# 마우스 이벤트 처리 함수
def onMouse(event, x, y, flags, param):
    global mask, img
    if event == cv2.EVENT_LBUTTONDOWN:
        seed = (x,y)
        # 색 채우기 적용 ---④
        retval = cv2.floodFill(img, mask, seed, newVal, loDiff, upDiff)
        # 채우기 변경 결과 표시 ---⑤
        cv2.imshow('img', img)

# 화면 출력
cv2.imshow('img', img)
cv2.setMouseCallback('img', onMouse)
cv2.waitKey(0)
cv2.destroyAllWindows()

마우스로 특정 영역을 클릭하면 특정 영역을 흰색으로 채우는 코드입니다. 위 코드에서 mask는 원본 이미지의 크기보다 높이와 너비를 각 2씩 크게 만들어야 합니다. 채우기에 사용할 색은 (255, 255, 255) 즉 흰색을 사용했습니다. loDiff와 upDiff, 즉 이웃한 픽셀과의 최소/최대 차이 값을 각 10으로 정했습니다.

워터셰드 (Watershed)

워터셰드(watershed)는 강물이 한 줄기로 흐르다가 갈라지는 경계인 분수령을 뜻합니다. 워터셰드는 앞서 살펴본 색 채우기(flood fill)과 비슷한 방식으로 연속된 영역을 찾는 것이라고 볼 수 있습니다. 다만, seed를 하나가 아닌 여러 개를 지정할 수 있고 이를 마커라고 합니다. 

  • markers = cv2.watershed(img, markers)
    img: 입력 이미지
    markers: 마커, 입력 이미지와 크기가 같은 1차원 배열(int32)

markers는 입력 이미지와 행과 열 크기가 같은 1차원 배열로 전달해야 합니다. markers의 값은 경계를 찾고자 하는 픽셀 영역은 -1을 갖게 하고 나머지 연결된 영역에 대해서는 동일한 정수 값을 갖게 합니다. 예를 들어 1은 배경, 2는 전경인 식입니다. cv2.watershed() 함수를 활용해 경계를 나눠보겠습니다. 

# 마우스와 워터셰드로 배경 분리 (watershed.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
rows, cols = img.shape[:2]
img_draw = img.copy()

# 마커 생성, 모든 요소는 0으로 초기화 ---①
marker = np.zeros((rows, cols), np.int32)
markerId = 1        # 마커 아이디는 1에서 시작
colors = []         # 마커 선택한 영역 색상 저장할 공간
isDragging = False  # 드래그 여부 확인 변수

# 마우스 이벤트 처리 함수
def onMouse(event, x, y, flags, param):
    global img_draw, marker, markerId, isDragging
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 마우스 버튼 다운, 드래그 시작 
        isDragging = True
        # 각 마커의 아이디와 현 위치의 색상 값을 쌍으로 매핑해서 저장 
        colors.append((markerId, img[y,x]))
    elif event == cv2.EVENT_MOUSEMOVE:  # 마우스 움직임
        if isDragging:                  # 드래그 진행 중
            # 마우스 좌표에 해당하는 마커의 좌표에 동일한 마커 아이디로 채워 넣기 ---②
            marker[y,x] = markerId
            # 마커 표시한 곳을 빨강색점으로 표시해서 출력
            cv2.circle(img_draw, (x,y), 3, (0,0,255), -1)
            cv2.imshow('watershed', img_draw)
    elif event == cv2.EVENT_LBUTTONUP:  # 왼쪽 마우스 버튼 업
        if isDragging:                  
            isDragging = False          # 드래그 중지
            # 다음 마커 선택을 위해 마커 아이디 증가 ---③
            markerId +=1
    elif event == cv2.EVENT_RBUTTONDOWN: # 오른쪽 마우스 버튼 누름
            # 모아 놓은 마커를 이용해서 워터 쉐드 적용 ---④
            cv2.watershed(img, marker)
            # 마커에 -1로 표시된 경계를 초록색으로 표시  ---⑤
            img_draw[marker == -1] = (0,255,0)
            for mid, color in colors: # 선택한 마커 아이디 갯수 만큼 반복
                # 같은 마커 아이디 값을 갖는 영역을 마커 선택한 색상으로 채우기 ---⑥
                img_draw[marker==mid] = color
            cv2.imshow('watershed', img_draw) # 표시한 결과 출력

# 화면 출력
cv2.imshow('watershed', img)
cv2.setMouseCallback('watershed', onMouse)
cv2.waitKey(0)
cv2.destroyAllWindows()

이미지의 전경과 배경을 분리하는 코드입니다. 마우스를 드래그하여 로봇 태권 V 내부를 표시해주고, 또 배경도 따로 표시해줍니다. 그런 다음 오른쪽 마우스 버튼을 클릭하면 전경과 배경이 구분된 이미지를 얻을 수 있습니다.

우선은 아래 코드를 통해 0으로 채워진 마커를 생성합니다.

marker = np.zeros((rows, cols), np.int32)

그다음 아래 코드를 통해 마우스 드래그한 부분의 좌표에 해당하는 마커 좌표에 현재의 마커 아이디를 채웁니다. 이 예제에서는 전경은 1, 배경은 2로 채웁니다. 이것은 앞서 살펴본 색 채우기(flood fill)의 seed 값이 여러 개인 것 과 같은 의미입니다. 

marker[y,x] = markerId

그 다음 마우스 오른쪽 버튼을 클릭하면 아래 코드로 워터셰드를 실행합니다. 워터셰드를 실행하면 경계에 해당하는 영역은 -1로 채워지고 전경은 1, 배경은 2로 채워집니다.

cv2.watershed(img, marker)

마지막으로 -1로 채워진 마커와 같은 좌표의 이미지 픽셀은 초록색으로 바꾸고, 같은 마커 아이디 값을 갖는 영역끼리 같은 색으로 채웁니다. 이때 색은 맨 처음 마우스 왼쪽 버튼을 클릭했을 때 좌표의 픽셀 값으로 지정했습니다. 그래서 위 그림에서는 전경은 빨간색으로 채워졌고, 배경은 회색으로 채워졌습니다. 맨 처음 전경을 선택할 때 귀부분(빨간색)을 클릭했습니다.

워터셰드는 경계 검출이 어려운 경우 사용할 수 있습니다. 전경이나 배경으로 확신할 수 있는 몇몇 픽셀을 지정해줌으로써 경계를 찾을 수 있습니다. 

그랩컷 (Graph Cut)

그랩컷은 사용자가 전경(배경이 아닌 부분)으로 분리할 부분에 사각형 표시를 해주면 전경과 배경의 색상 분포를 추정해서 동일한 레이블을 가진 연결된 영역에서 전경과 배경을 분리합니다. 아래의 함수로 그랩컷을 구현할 수 있습니다.

  • mask, bgdModel, fgdModel = cv2.grabCut(img, mask, rect, bgdModel, fgdModel, iterCount, mode)
    img: 입력 이미지
    mask: 입력 이미지와 크기가 같은 1 채널 배열, 배경과 전경을 구분하는 값을 저장 (cv2.GC_BGD: 확실한 배경(0), cv2.GC_FGD: 확실한 전경(1), cv2.GC_PR_BGD: 아마도 배경(2), cv2.GC_PR_FGD: 아마도 전경(3))
    rect: 전경이 있을 것으로 추측되는 영역의 사각형 좌표, 튜플 (x1, y1, x2, y2)
    bgdModel, fgdModel: 함수 내에서 사용할 임시 배열 버퍼 (재사용할 경우 수정하지 말 것)
    iterCount: 반복 횟수
    mode(optional): 동작 방법 (cv2.GC_INIT_WITH_RECT: rect에 지정한 좌표를 기준으로 그랩컷 수행, cv2.GC_INIT_WITH_MASK: mask에 지정한 값을 기준으로 그랩컷 수행, cv2.GC_EVAL: 재시도)

mode에 cv2.GC_INIT_WITH_RECT를 전달하면 세 번째 파라미터인 rect에 전달한 사각형 좌표를 가지고 전경과 배경을 분리합니다. 그 결과를 두 번째 파라미터인 mask에 할당해 반환합니다. mask에 할당받은 값이 0과 1이면 확실한 배경, 전경을 의미하고, 2와 3이면 아마도 배경, 전경일 가능성이 있다는 뜻입니다. 이렇게 1차적으로 배경과 전경을 구분한 뒤 mode에 cv2.GC_INIT_WITH_MASK를 지정해서 다시 호출하면 좀 더 정확한 mask를 얻을 수 있습니다. 이때 bgdModel과 fgdModel은 함수가 내부적으로 연산에 사용하는 임시 배열로 다음 호출 시 이전 연산을 반영하기 위해 재사용하므로 그 내용을 수정하면 안 됩니다. 

아래는 그랩컷을 활용하여 배경을 분리하는 예제 코드입니다. 우선 마우스로 드래그하여 전경 외곽 영역을 표시해줍니다. 1차적으로 배경과 전경이 분리됩니다. 배경을 추가로 제거하고 싶으면 원본 이미지에 쉬프트 키를 누른 상태로 마우스로 검은색 선을 그어주면 됩니다. 잘못 제거된 전경을 추가하고 싶으면 원본 이미지에 컨트롤키를 누른 상태로 마우스로 흰색 선을 그어주면 됩니다.

# 마우스와 그랩컷으로 배경과 전경 분리 (grabcut.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
img_draw = img.copy()
mask = np.zeros(img.shape[:2], dtype=np.uint8)  # 마스크 생성
rect = [0,0,0,0]    # 사각형 영역 좌표 초기화
mode = cv2.GC_EVAL  # 그랩컷 초기 모드
# 배경 및 전경 모델 버퍼
bgdmodel = np.zeros((1,65),np.float64)
fgdmodel = np.zeros((1,65),np.float64)

# 마우스 이벤트 처리 함수
def onMouse(event, x, y, flags, param):
    global mouse_mode, rect, mask, mode
    if event == cv2.EVENT_LBUTTONDOWN : # 왼쪽 마우스 누름
        if flags <= 1: # 아무 키도 안 눌렀으면
            mode = cv2.GC_INIT_WITH_RECT # 드래그 시작, 사각형 모드 ---①
            rect[:2] = x, y # 시작 좌표 저장
    # 마우스가 움직이고 왼쪽 버튼이 눌러진 상태
    elif event == cv2.EVENT_MOUSEMOVE and flags & cv2.EVENT_FLAG_LBUTTON :
        if mode == cv2.GC_INIT_WITH_RECT: # 드래그 진행 중 ---②
            img_temp = img.copy()
            # 드래그 사각형 화면에 표시
            cv2.rectangle(img_temp, (rect[0], rect[1]), (x, y), (0,255,0), 2)
            cv2.imshow('img', img_temp)
        elif flags > 1: # 키가 눌러진 상태
            mode = cv2.GC_INIT_WITH_MASK    # 마스크 모드 ---③
            if flags & cv2.EVENT_FLAG_CTRLKEY :# 컨트롤 키, 분명한 전경
                # 흰색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (255,255,255),-1)
                # 마스크에 GC_FGD로 채우기      ---④
                cv2.circle(mask,(x,y),3, cv2.GC_FGD,-1)
            if flags & cv2.EVENT_FLAG_SHIFTKEY : # 쉬프트키, 분명한 배경
                # 검정색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (0,0,0),-1)
                # 마스크에 GC_BGD로 채우기      ---⑤
                cv2.circle(mask,(x,y),3, cv2.GC_BGD,-1)
            cv2.imshow('img', img_draw) # 그려진 모습 화면에 출력
    elif event == cv2.EVENT_LBUTTONUP: # 마우스 왼쪽 버튼 뗀 상태 ---⑥
        if mode == cv2.GC_INIT_WITH_RECT : # 사각형 그리기 종료
            rect[2:] =x, y # 사각형 마지막 좌표 수집
            # 사각형 그려서 화면에 출력 ---⑦
            cv2.rectangle(img_draw, (rect[0], rect[1]), (x, y), (255,0,0), 2)
            cv2.imshow('img', img_draw)
        # 그랩컷 적용 ---⑧
        cv2.grabCut(img, mask, tuple(rect), bgdmodel, fgdmodel, 1, mode)
        img2 = img.copy()
        # 마스크에 확실한 배경, 아마도 배경으로 표시된 영역을 0으로 채우기
        img2[(mask==cv2.GC_BGD) | (mask==cv2.GC_PR_BGD)] = 0
        cv2.imshow('grabcut', img2) # 최종 결과 출력
        mode = cv2.GC_EVAL # 그랩컷 모드 리셋
# 초기 화면 출력 및 마우스 이벤트 등록
cv2.imshow('img', img)
cv2.setMouseCallback('img', onMouse)
while True:    
    if cv2.waitKey(0) & 0xFF == 27 : # esc
        break
cv2.destroyAllWindows()

마우스 이벤트 처리 때문에 코드가 다소 길어졌습니다. 맨 처음 마우스 드래그로 사각형을 그려주었습니다. 처음 마우스 버튼을 누른 좌표와 마지막으로 마우스 버튼을 뗀 좌표를 구해서 cv2.grabCut()을 호출할 때 mode를 cv2.GC_INIT_WITH_RECT로 설정해서 호출하면 됩니다. 그다음 쉬프트와 컨트롤키를 누른 상태로 마우스 드래그를 해주었을 때의 좌표를 mask에 반영했다가 마우스 뗀 시점에 cv2.grabCut()을 호출하여 mode에 cv2.GC_INIT_WITH_MASK를 전달하면 됩니다. 이때 쉬프트와 컨트롤키에 따라 mask에 반영할 값이 cv2.GC_BGD 또는 cv2.GC_FGD가 됩니다. 

마우스 이벤트를 처리하는 onMouse() 함수를 뜯어보겠습니다. 우선 아래 코드는 키보드의 아무 키도 누르지 않은 상태로 마우스 왼쪽 버튼을 클릭했을 때를 처리해줍니다. mode = cv2.GC_INIT_WITH_RECT으로 설정하고 시작 좌표를 구합니다.

    if event == cv2.EVENT_LBUTTONDOWN : # 왼쪽 마우스 누름
        if flags <= 1: # 아무 키도 안 눌렀으면
            mode = cv2.GC_INIT_WITH_RECT # 드래그 시작, 사각형 모드 ---①
            rect[:2] = x, y # 시작 좌표 저장

아래 코드는 마우스 왼쪽 버튼이 눌러진 상태로 드래그되었을 때를 처리해줍니다. 마우스 왼쪽 버튼이 눌러진 상태로 드래그가 되었는데 그때의 mode가 cv2.GC_INIT_WITH_RECT이라면, 단순히 마우스가 움직이는 동안 화면에 사각형을 표시합니다. mode가 cv2.GC_INIT_WITH_RECT라는 것은 키보드를 아무것도 누르지 않았다는 뜻입니다.

# 마우스가 움직이고 왼쪽 버튼이 눌러진 상태
    elif event == cv2.EVENT_MOUSEMOVE and flags & cv2.EVENT_FLAG_LBUTTON :
        if mode == cv2.GC_INIT_WITH_RECT: # 드래그 진행 중 ---②
            img_temp = img.copy()
            # 드래그 사각형 화면에 표시
            cv2.rectangle(img_temp, (rect[0], rect[1]), (x, y), (0,255,0), 2)
            cv2.imshow('img', img_temp)

반면, 마우스 왼쪽 버튼이 눌러진 상태로 드래그가 되었는데 그때의 mode가 cv2.GC_INIT_WITH_RECT이 아니고, flags가 1보다 크다면 아래의 코드가 실행됩니다. flags가 1보다 크다는 것은 키보드의 어떤 버튼이 눌렸다는 뜻입니다. 쉬프트든 컨트롤이든 눌렸다는 거죠. 이때는 mode를 cv2.GC_INIT_WITH_MASK로 설정합니다. 그리고 컨트롤/쉬프트 키가 눌렸을 때에 대해 각각 화면에 흰색 점, 검은색 점을 표시합니다. 또한 마우스가 움직인 좌표에 해당하는 mask 인덱스에 각각 cv2.GC_FGD/cv2.GC_BGD을 반영했습니다. cv2.GC_FGD는 확실한 전경, cv2.GC_BGD는 확실한 배경을 뜻합니다.

        elif flags > 1: # 키가 눌러진 상태
            mode = cv2.GC_INIT_WITH_MASK    # 마스크 모드 ---③
            if flags & cv2.EVENT_FLAG_CTRLKEY :# 컨트롤 키, 분명한 전경
                # 흰색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (255,255,255),-1)
                # 마스크에 GC_FGD로 채우기      ---④
                cv2.circle(mask,(x,y),3, cv2.GC_FGD,-1)
            if flags & cv2.EVENT_FLAG_SHIFTKEY : # 쉬프트키, 분명한 배경
                # 검정색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (0,0,0),-1)
                # 마스크에 GC_BGD로 채우기      ---⑤
                cv2.circle(mask,(x,y),3, cv2.GC_BGD,-1)
            cv2.imshow('img', img_draw) # 그려진 모습 화면에 출력

아래 코드는 마우스를 뗀 지점의 좌표를 구해서 사각형을 표시합니다.

    elif event == cv2.EVENT_LBUTTONUP: # 마우스 왼쪽 버튼 뗀 상태 ---⑥
        if mode == cv2.GC_INIT_WITH_RECT : # 사각형 그리기 종료
            rect[2:] =x, y # 사각형 마지막 좌표 수집
            # 사각형 그려서 화면에 출력 ---⑦
            cv2.rectangle(img_draw, (rect[0], rect[1]), (x, y), (255,0,0), 2)
            cv2.imshow('img', img_draw)

최종적으로 아래의 코드로 그랩컷을 적용합니다. mask에서 배경으로 표시된 cv2.GC_BGD(확실한 배경), cv2.GC_PR_BGD(아마도 배경)에 해당하는 좌표를 0으로 채워서 배경을 제거합니다. 그리고 최종 결과를 출력합니다.

        cv2.grabCut(img, mask, tuple(rect), bgdmodel, fgdmodel, 1, mode)
        img2 = img.copy()
        # 마스크에 확실한 배경, 아마도 배경으로 표시된 영역을 0으로 채우기
        img2[(mask==cv2.GC_BGD) | (mask==cv2.GC_PR_BGD)] = 0
        cv2.imshow('grabcut', img2) # 최종 결과 출력
        mode = cv2.GC_EVAL # 그랩컷 모드 리셋

사실 그랩컷을 적용하는 코드는 한 줄이지만, 마우스 이벤트 처리 때문에 코드가 다소 복잡해졌습니다.

평균 이동 필터

평균 이동 필터를 활용하면 물감으로 그림을 그린 것과 같이 이미지를 바꿀 수 있습니다. 평균 이동 필터를 제공하는 OpenCV 함수는 아래와 같습니다.

  • dst = cv2.pyrMeanShiftFiltering(src, sp, sr, dst, maxLevel, termcrit)
    src: 입력 이미지
    sp: 공간 윈도 반지름 크기
    sr: 색상 윈도 반지름 크기
    maxLevel(optional): 이미지 피라미드 최대 레벨
    termcrit(optional): 반복 중지 요건 (cv2.TERM_CRITERIA_EPS: 정확도가 최소 정확도(epsilon) 보다 작아지면 중지, cv2.TERM_CRITERIA_MAX_ITER: 최대 반복 횟수(max_iter)에 도달하면 중지) -> default epsilon=1, max_iter=5

이 함수는 내부적으로 이미지 피라미드를 만들어 작은 이미지의 평균 이동 결과를 큰 이미지에 적용합니다. 그래서 함수 이름 앞에 pyr가 붙었습니다. src에는 입력 이미지가 전달되는데 그레이 스케일과 컬러 스케일 모두 가능합니다. sp 파라미터는 평균 이동(MeanShift)에 사용할 윈도 크기입니다. 몇 픽셀씩 묶어서 평균을 내어 이동할지를 결정합니다. sr 파라미터는 색상 윈도 크기로 색상 값의 차이 범위를 지정합니다. 평균을 계산할 때 값의 차이가 sr 값의 범위 안에 있는 픽셀만을 대상으로 합니다. 따라서 sr이 너무 작으면 원본과 별 차이가 없고, 너무 크면 원본과 많이 달라집니다. maxLevel은 이미지 피라미드 최대 레벨입니다. 이 값이 0보다 크면 그 값만큼 작은 이미지 피라미드로 평균 이동해서 얻은 결과를 적용합니다. 값이 클수록 속도가 빨라지지만 영역과 색상이 거칠어집니다. termcrit은 반복을 중지할 기준을 지정하는 파라미터입니다. 

# 평균 이동 세그멘테이션 필터 (mean_shift.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
# 트랙바 이벤트 처리 함수
def onChange(x):
    #sp, sr, level 선택 값 수집
    sp = cv2.getTrackbarPos('sp', 'img')
    sr = cv2.getTrackbarPos('sr', 'img')
    lv = cv2.getTrackbarPos('lv', 'img')

    # 평균 이동 필터 적용 ---①
    mean = cv2.pyrMeanShiftFiltering(img, sp, sr, None, lv)
    # 변환 이미지 출력
    cv2.imshow('img', np.hstack((img, mean)))

# 초기 화면 출력
cv2.imshow('img', np.hstack((img, img)))
# 트랙바 이벤트 함수 연결
cv2.createTrackbar('sp', 'img', 0,100, onChange)
cv2.createTrackbar('sr', 'img', 0,100, onChange)
cv2.createTrackbar('lv', 'img', 0,5, onChange)
cv2.waitKey(0)
cv2.destroyAllWindows()

트랙바로 정한 sp, sr, maxLevel을 설정해서 평균 이동 필터를 적용한 예제입니다.

Comments