귀퉁이 서재

OpenCV - 8. 스레시홀딩(Thresholding), 오츠의 알고리즘(Otsu's Method) 본문

OpenCV

OpenCV - 8. 스레시홀딩(Thresholding), 오츠의 알고리즘(Otsu's Method)

Baek Kyun Shin 2020. 9. 19. 23:02

이번 포스팅에서는 바이너리 이미지를 만드는 대표적인 방법인 스레시홀딩에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

스레시홀딩(Thresholding)

스레시홀딩은 바이너리 이미지를 만드는 가장 대표적인 방법입니다. 바이너리 이미지(binary image)란 검은색과 흰색만으로 표현한 이미지를 의미합니다. 스레시홀딩이란 여러 값을 어떤 임계점을 기준으로 두 가지 부류로 나누는 방법을 의미합니다.

전역 스레시홀딩

어떤 임계값을 정한 뒤 픽셀 값이 임계값을 넘으면 255, 임계값을 넘지 않으면 0으로 지정하는 방식을 전역 스레시홀딩이라고 합니다. 이런 작업은 간단하게 numpy로 연산할 수 있지만, OpenCV에서 cv2.threshold() 함수로 구현할 수도 있습니다. 아래는 전역 스레시홀딩 작업을 numpy 연산과 cv2.threshold() 함수를 통해 수행하는 과정을 보여줍니다.

# 전역 스레시홀딩 (threshold.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

img = cv2.imread('../img/gray_gradient.jpg', cv2.IMREAD_GRAYSCALE) #이미지를 그레이 스케일로 읽기

# --- ① NumPy API로 바이너리 이미지 만들기
thresh_np = np.zeros_like(img)   # 원본과 동일한 크기의 0으로 채워진 이미지
thresh_np[ img > 127] = 255      # 127 보다 큰 값만 255로 변경

# ---② OpenCV API로 바이너리 이미지 만들기
ret, thresh_cv = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) 
print(ret)  # 127.0, 바이너리 이미지에 사용된 문턱 값 반환

# ---③ 원본과 결과물을 matplotlib으로 출력
imgs = {'Original': img, 'NumPy API':thresh_np, 'cv2.threshold': thresh_cv}
for i , (key, value) in enumerate(imgs.items()):
    plt.subplot(1, 3, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]); plt.yticks([])

plt.show()

우선, 검은색에서 흰색으로 점점 변하는 그라데이션 이미지를 회색조로 읽습니다. 첫 번째로, numpy 연산을 통해 픽셀 값이 127보다 크면 255, 픽셀 값이 127보다 작거나 같으면 0으로 바꾸는 작업을 수행했습니다. Numpy API가 그 결과입니다. 이 작업은 간단하게 cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) 함수를 호출하여 수행할 수도 있습니다. cv2.threshold() 함수 사용법은 다음과 같습니다.

type_flag 값은 다음과 같습니다.

cv2.THRESH_BINARY: 픽셀 값이 임계값을 넘으면 value로 지정하고, 넘지 못하면 0으로 지정
cv2.THRESH_BINARY_INV: cv.THRESH_BINARY의 반대
cv2.THRESH_TRUNC: 픽셀 값이 임계값을 넘으면 value로 지정하고, 넘지 못하면 원래 값 유지
cv2.THRESH_TOZERO: 픽셀 값이 임계값을 넘으면 원래 값 유지, 넘지 못하면 0으로 지정
cv2.THRESH_TOZERO_INV: cv2.THRESH_TOZERO의 반대

이 함수는 두 개의 결과를 반환하는데 첫 번째 결과인 ret은 스레시홀딩에 사용한 임계값이고, 두번째 결과인 out은 스레시홀딩이 적용된 바이너리 이미지입니다. 대부분 첫번째 결과인 ret은 threshold 파라미터로 전달한 값과 동일합니다.

아래 코드는 여러 type_flag를 활용한 예제입니다. 참고로 0은 검은색, 255는 흰색임에 유의하시기 바랍니다.

# 스레시홀딩 플래그 (threshold_flag.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

img = cv2.imread('../img/gray_gradient.jpg', cv2.IMREAD_GRAYSCALE)

_, t_bin = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
_, t_bininv = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
_, t_truc = cv2.threshold(img, 127, 255, cv2.THRESH_TRUNC)
_, t_2zr = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO)
_, t_2zrinv = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO_INV)

imgs = {'origin':img, 'BINARY':t_bin, 'BINARY_INV':t_bininv, \
        'TRUNC':t_truc, 'TOZERO':t_2zr, 'TOZERO_INV':t_2zrinv}
for i, (key, value) in enumerate(imgs.items()):
    plt.subplot(2,3, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]);    plt.yticks([])
    
plt.show()

type_flag 파라미터에 따라 다양한 결과가 도출된 것을 볼 수 있습니다.

오츠의 이진화 알고리즘

바이너리 이미지를 만들 때 가장 중요한 점은 임계값을 얼마로 정하냐 하는 것입니다. 1979년 오츠 노부유키는 반복적인 시도 없이 한 번에 임계값을 찾을 수 있는 방법을 찾아냈습니다. 이것이 바로 오츠의 이진화 알고리즘(Otsu's binarization method)입니다. 오츠의 알고리즘은 임계값을 임의로 정해 픽셀을 두 부류로 나누고 두 부류의 명암 분포를 구하는 작업을 반복합니다. 모든 경우의 수 중에서 두 부류의 명암 분포가 가장 균일할 때의 임계값을 선택합니다. 아래 예제에서는 임계값이 120~140 사이일 때 이미지가 가장 선명하다는 것을 알 수 있습니다.

OpenCV 함수를 활용하면 오츠의 알고리즘을 적용할 수 있습니다. cv2.threshold() 함수의 마지막 파라미터로 cv2.THRESH_OTSU를 전달하기만 하면 됩니다. 오츠의 알고리즘은 최적의 임계값을 찾아주므로 cv2.threshold() 함수에 전달하는 threshold 파라미터는 아무 값이어도 상관없습니다. 어차피 무시되기 때문입니다.

아래는 오츠의 알고리즘을 활용하여 최적의 임계값을 찾는 예제 코드입니다.

오츠의 알고리즘을 적용한 스레시홀딩 (threshold_otsu.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

# 이미지를 그레이 스케일로 읽기
img = cv2.imread('../img/scaned_paper.jpg', cv2.IMREAD_GRAYSCALE) 
# 경계 값을 130으로 지정  ---①
_, t_130 = cv2.threshold(img, 130, 255, cv2.THRESH_BINARY)        
# 경계 값을 지정하지 않고 OTSU 알고리즘 선택 ---②
t, t_otsu = cv2.threshold(img, -1, 255,  cv2.THRESH_BINARY | cv2.THRESH_OTSU) 
print('otsu threshold:', t)                 # Otsu 알고리즘으로 선택된 경계 값 출력

imgs = {'Original': img, 't:130':t_130, 'otsu:%d'%t: t_otsu}
for i , (key, value) in enumerate(imgs.items()):
    plt.subplot(1, 3, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]); plt.yticks([])

plt.show()

원본 이미지의 글씨는 선명하지 않습니다. 하지만 바이너리 이미지로 변환하니 글씨가 좀 더 선명해졌습니다. 맨 왼쪽은 원본 이미지, 두 번째 이미지는 임계값을 130으로 지정해준 바이너리 이미지, 세 번째 이미지는 오츠의 알고리즘을 적용한 바이너리 이미지입니다. 오츠의 알고리즘에 따르면 최적의 임계값은 131 임을 알 수 있습니다. (세 번째 이미지 상단에 otsu: 131이라고 표시되어 있음) 아래 코드는 오츠의 알고리즘을 적용하는 코드입니다.

t, t_otsu = cv2.threshold(img, -1, 255,  cv2.THRESH_BINARY | cv2.THRESH_OTSU) 

두 번째 파라미터인 -1은 threshold를 전달하는 값입니다. 이미 설명드린 대로 오츠의 알고리즘에서 이 값은 무시되므로 아무 값이나 넣어도 상관없습니다.

오츠의 알고리즘이 최적의 임계값을 자동으로 찾아준다는 장점이 있지만, 모든 경우의 수에 대해 조사해야 하므로 속도가 빠르지 않다는 단점도 있습니다.

적응형 스레시홀딩

위에서 설명한 전역 스레시홀딩이 매번 좋은 성능을 내는 것은 아닙니다. 원본 이미지에서 조명이 일정하지 않거나 배경색이 여러 개인 경우에는 하나의 임계값으로 선명한 바이너리 이미지를 만들어내기 힘들 수도 있습니다. 이때는 이미지를 여러 영역으로 나눈 뒤, 그 주변 픽셀 값만 활용하여 임계값을 구해야 하는데, 이를 적응형 스레시홀딩(Adaptive Thresholding)이라고 합니다. 적응형 스레시홀딩은 다음 함수로 제공합니다.

  • cv2.adaptiveThreshold(img, value, method, type_flag, block_size, C)
    img: 입력영상
    value: 임계값을 만족하는 픽셀에 적용할 값
    method: 임계값 결정 방법
    type_flag: 스레시홀딩 적용 방법 (cv2.threshod()와 동일)
    block_size: 영역으로 나눌 이웃의 크기(n x n), 홀수
    C: 계산된 임계값 결과에서 가감할 상수(음수 가능)

method 값은 다음과 같습니다.

cv2.ADAPTIVE_THRESH_MEAN_C: 이웃 픽셀의 평균으로 결정
cv2.ADAPTIVE_THRESH_GAUSSIAN_C: 가우시안 분포에 따른 가중치의 합으로 결정

아래 코드는 적응형 스레시홀딩을 적용한 예시입니다.

# 적응형 스레시홀딩 적용 (threshold_adapted.py)

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

blk_size = 9        # 블럭 사이즈
C = 5               # 차감 상수 
img = cv2.imread('../img/sudoku.png', cv2.IMREAD_GRAYSCALE) # 그레이 스케일로  읽기

# ---① 오츠의 알고리즘으로 단일 경계 값을 전체 이미지에 적용
ret, th1 = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

# ---② 어뎁티드 쓰레시홀드를 평균과 가우시안 분포로 각각 적용
th2 = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\
                                      cv2.THRESH_BINARY, blk_size, C)
th3 = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, \
                                     cv2.THRESH_BINARY, blk_size, C)

# ---③ 결과를 Matplot으로 출력
imgs = {'Original': img, 'Global-Otsu:%d'%ret:th1, \
        'Adapted-Mean':th2, 'Adapted-Gaussian': th3}
for i, (k, v) in enumerate(imgs.items()):
    plt.subplot(2,2,i+1)
    plt.title(k)
    plt.imshow(v,'gray')
    plt.xticks([]),plt.yticks([])

plt.show()

오른쪽 상단은 오츠의 알고리즘을 통해 전역 스레시홀딩을 적용한 바이너리 이미지입니다. 왼쪽 아래는 검정색으로, 오른쪽 위는 흰색으로 변해 이미지를 식별하기가 더 어려워졌습니다. 원본 이미지 좌측 하단이 우측 상단보다 더 그늘지고 어두워 발생하는 현상입니다. 전역 스레시홀딩을 하면 발생하는 전형적인 문제점입니다. 하지만 적응형 스레시홀딩을 적용한 아래 두 바이너리 이미지는 상당히 선명합니다. 평균값(Adapted-Mean)을 활용한 것이 가우시안 분포(Adapted-Gaussian)을 활용한 것보다 더 선명한데 그만큼 잡티가 조금 있습니다. 반면, 가우시안 분포를 활용한 것은 평균값을 활용한 것에 비해 선명도는 조금 떨어지지만 잡티가 더 적습니다.

위 예제의 적응형 스레시홀딩 알고리즘은 다음과 같습니다. 우선, 전체 이미지에 총 9개의 블록을 설정합니다. 이미지를 9등분 한다고 보시면 됩니다. 그 다음 각 블록별로 임계값을 정합니다. 이때, cv2.ADAPTIVE_THRESH_MEAN_C를 파라미터로 전달하면 각 블록의 이웃 픽셀의 평균으로 임계값을 정합니다. cv2.ADAPTIVE_THRESH_GAUSSIAN_C를 파라미터로 전달하면 가우시안 분포에 따른 가중치의 합으로 임계값을 정합니다. 정해진 임계값을 바탕으로 각 블록별로 스레시홀딩을 합니다. 그렇게 하면 전역 스레시홀딩을 적용한 것보다 더 선명하고 부드러운 결과를 얻을 수 있습니다.

대부분의 이미지는 그림자가 있거나 조명 차이가 있습니다. 따라서 전역 스레시홀딩보다 적응형 스레시홀딩을 더 많이 사용합니다. 

Comments