by museonghwang

OpenCV Image Processing 이미지 연산

|

해당 게시물은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저) 를 바탕으로 작성되었습니다.


이미지 연산

영상에 연산하는 방법을 알아봅니다. 연산 결과는 새로운 영상을 만들어 내므로 그 자체가 목적이 될 수도 있지만, 정보를 얻기 위한 과정일 수도 있습니다.

1. 영상과 영상의 연산

영상에 연산을 할 수 있는 방법은 NumPy의 브로드캐스팅 연산을 직접 적용하는 방법과 OpenCV에서 제공하는 네 가지 함수를 사용하는 방법이 있습니다. OpenCV에서 굳이 연산에 사용할 함수를 제공하는 이유는 영상에서의 한 픽셀이 가질 수 있는 값의 범위는 0~255인데, 더하기나 빼기 연산을 한 결과가 255보다 클 수도 있고 0보다 작을 수도 있어서 결과 값을 0과 255로 제한할 안전 장치가 필요하기 때문입니다. OpenCV에는 아래의 함수로 이와 같은 기능을 제공합니다.

  • dest = cv2.add(src1, src2[, dest, mask, dtype]) : src1과 src2 더하기
    • src1 : 입력 영상 1 또는 수
    • src2 : 입력 영상 2 또는 수
    • dest : 출력 영상
    • mask : 0이 아닌 픽셀만 연산
    • dtype : 출력 dtype
  • dest = cv2.substract(src1, src2[, dest, mask, dtype]) : src1에서 src2를 빼기
    • 모든 인자는 cv2.add() 함수와 동일
  • dest = cv2.multiply(src1, src2[, dest, scale, dtype]) : src1과 src2를 곱하기
    • scale : 연산 결과에 추가 연산할 값
  • dest = cv2.divide(src1, src2[, dest, scale, dtype]) : src1을 src2로 나누기
    • 모든 인자는 cv2.multiply()와 동일

영상에 사칙 연산을 적용해서 그 차이를 알아봅니다.

'''영상의 사칙 연산'''
import cv2
import numpy as np

# ---① 연산에 사용할 배열 생성
a = np.uint8([[200, 50]]) 
b = np.uint8([[100, 100]])

#---② NumPy 배열 직접 연산
add1 = a + b
sub1 = a - b
mult1 = a * 2
div1 = a / 3

# ---③ OpenCV API를 이용한 연산
add2 = cv2.add(a, b)
sub2 = cv2.subtract(a, b)
mult2 = cv2.multiply(a , 2)
div2 = cv2.divide(a, 3)

#---④ 각 연산 결과 출력
print(add1, add2)
print(sub1, sub2)
print(mult1, mult2)
print(div1, div2)

[output]
[[ 44 150]] [[255 150]]
[[100 206]] [[100   0]]
[[144 100]] [[255 100]]
[[66.66666667 16.66666667]] [[67 17]]

위 코드 ①에서 연산을 테스트할 대상으로 NumPy 배열을 생성합니다. 코드 ②는 사칙 연산자를 직접 사용했고, 코드 ③은 OpenCV의 4개의 함수를 이용했습니다. 코드 ④에서 결과를 각각 출력하고 있습니다.

출력 결과를 살펴보면 200과 100을 더하고 50과 100을 더한 결과가 각각 44 와 255, 150과 150으로 50과 100의 결과는 동일하게 나타납니다. 하지만, 200과 100을 더한 결과는 300인데, 더하기(+) 연산자로 직접 더한 결과는 255를 초과하는 44이고, cv2.add() 함수의 결과는 최대 값인 255입니다. 50에서 100을 빼는 연산은 -50인데, 마찬가지로 직접 빼기(-) 연산한 결과는 206으로 정상적이지 않지만, cv2.subtract() 함수의 결과는 최소 값인 0입니다. 곱하기와 나누기 연산도 OpenCV 함수의 결과는 255를 초과하지 않고 소수점 이하를 갖지 않습니다.

OpenCV의 네 가지 연산 함수 중에 cv2.add() 함수를 대표로 해서 좀 더 자세히 설명해 보겠습니다. 함수의 첫 번째와 두 번째 인자에는 연산의 대상을 NumPy 배열로 전달합니다. 그 두 인자를 더한 결과는 세 번째 인자로 전달한 배열에 할당하고 결과값으로 다시 반환합니다. 만약 c = a + b 와 같은 연산이 필요하다면 다음의 세 코드의 결과는 똑같습니다.

c = cv2.add(a, b) 또는 c = cv2.add(a, b, None) 또는 cv2.add(a, b, c)

만약 b += a 와 같이 두 입력의 합산 결과를 입력 인자의 하나에 재할당하고 싶을 때는 다음의 두 코드와 같이 작성할 수 있고 결과는 같습니다.

cv2.add(a, b, b) 또는 b = cv2.add(a, b)

하지만, 네 번째 인자인 mask를 지정하는 경우에는 얘기가 다릅니다. 네 번째 인자에 전달한 NumPy 배열에 어떤 요소 값이 0이면 그 위치의 픽셀은 연산을 하지 않습니다. 이때 세 번째 인자인 결과를 할당할 인자의 지정 여부에 따라 결과는 달라집니다. 코드로 예를 들어 보겠습니다.

'''mask와 누적 할당 연산'''
import cv2
import numpy as np

#---① 연산에 사용할 배열 생성
a = np.array([[1, 2]], dtype=np.uint8)
b = np.array([[10, 20]], dtype=np.uint8)

#---② 2번째 요소가 0인 마스크 배열 생성 
mask = np.array([[1, 0]], dtype=np.uint8)

#---③ 누적 할당과의 비교 연산
c1 = cv2.add(a, b, None, mask)
print(c1)
c2 = cv2.add(a, b, b, mask)
print(c2, b)

[output]
[[11  0]]
[[11 20]] [[11 20]]

예제에서 a와 b의 더하기 연산은 1+10, 2+20 연산이 각각 이뤄져야 하지만, 네번째 인자인 mask의 두 번째 요소의 값이 0이므로 2+20의 연산은 이루어지지 않습니다. 따라서 c1의 결과는 11과 0입니다. 하지만, 누적 할당을 적용한 c2의 두 번째 항목은 b의 두 번째 항목인 20을 그대로 갖게 됩니다. 이때 주의할 것은 b도 c2와 동일하게 연산의 결과를 갖게 되는 것입니다. 만약 b의 값이 연산 전 상태를 그대로 유지되길 원한다면 아래와 같이 수정해서 사용할 수 있습니다.

c2 = cv2.add(a, b, b.copy(), mask)


2. 알파 블렌딩

두 영상을 합성하려고 할 때 앞서 살펴본 더하기(+) 연산이나 cv2.add() 함수만으로는 좋은 결과를 얻을 수 없는 경우가 많습니다. 직접 더하기 연산을 하면 255를 넘는 경우 초과 값만을 가지므로 영상이 거뭇거뭇하게 나타나고 cv2.add() 연산을 하면 대부분의 픽셀 값이 255 가까이 몰리는 현상이 일어나서 영상이 하얗게 날아간 것처럼 보입니다. 아래는 이런 현상을 보여주고 있습니다.

'''이미지 단순 합성'''
import cv2
import numpy as np
import matplotlib.pylab as plt

# ---① 연산에 사용할 이미지 읽기
img1 = cv2.imread('./img/wing_wall.jpg')
img2 = cv2.imread('./img/yate.jpg')

# ---② 이미지 덧셈
img3 = img1 + img2  # 더하기 연산
img4 = cv2.add(img1, img2) # OpenCV 함수

imgs = {'img1':img1, 'img2':img2, 'img1+img2':img3, 'cv.add(img1, img2)':img4}

# ---③ 이미지 출력
for i, (k, v) in enumerate(imgs.items()):
    plt.subplot(2,2, i + 1)
    plt.imshow(v[:,:,::-1])
    plt.title(k)
    plt.xticks([]); plt.yticks([])

plt.show()

image

실행 결과의 img1+img2는 화소가 고르지 못하고 중간 중간 이상한 색을 띠고 있는 부분이 있는데, 그 부분이 255를 초과한 영역입니다. cv2.add(img1, img2) 의 실행 결과는 전체적으로 하얀 픽셀을 많이 가져가므로 좋은 결과로 볼 수 없습니다.

두 영상을 합성하려면 각 픽셀의 합이 255가 되지 않게 각각의 영상에 가중치를 줘서 계산해야 합니다. 예를 들어 두 영상이 정확히 절반씩 반영된 결과 영상을 원한다면 각 영상의 픽셀 값에 각각 50%씩 곱해서 새로운 영상을 생성하면 됩니다. 이것을 수식으로 나타내면 다음과 같고 이때 각 영상에 적용할 가중치를 알파(alpha) 값이라고 부릅니다. 알파 값을 조정해서 7:3, 6:4, 5:5 등과 같이 배분하는 방식입니다.

[g(x) = (1 - \alpha) f_0(x) + \alpha f_1(x)]

  • $f_0(x)$ : 첫 번째 이미지 픽셀 값
  • $f_1(x)$ : 두 번째 이미지 픽셀 값
  • $\alpha$ : 가중치(알파)
  • $g(x)$ : 합성 결과 픽셀 값


이 수식대로 NumPy 배열에 직접 연산해도 되지만, OpenCV는 이것을 구현한 함수를 제공합니다.

  • cv2.addWeight(img1, alpha, img2, beta, gamma)
    • img1, img2 : 합성할 두 영상
    • alpha : img1에 지정할 가중치(알파 값)
    • beta : img2에 지정할 가중치, 흔히 (1- alpha) 적용
    • gamma : 연산 결과에 가감할 상수, 흔히 0(zero) 적용

아래 코드는 각 영상에 대해서 50%씩 가중치로 앞서 실습한 영상을 다시 합성하고 있습니다.

'''50% 알파 블렌딩'''
import cv2
import numpy as np

alpha = 0.5 # 합성에 사용할 알파 값

#---① 합성에 사용할 영상 읽기
img1 = cv2.imread('./img/wing_wall.jpg')
img2 = cv2.imread('./img/yate.jpg')

# ---② NumPy 배열에 수식을 직접 연산해서 알파 블렌딩 적용
blended = img1 * alpha + img2 * (1-alpha)
blended = blended.astype(np.uint8) # 소수점 발생을 제거하기 위함
cv2.imshow('img1 * alpha + img2 * (1-alpha)', blended)

# ---③ addWeighted() 함수로 알파 블렌딩 적용
dst = cv2.addWeighted(img1, alpha, img2, (1-alpha), 0) 
cv2.imshow('cv2.addWeighted', dst)

cv2.waitKey(0)
cv2.destroyAllWindows()

image

image

위 코드 ②는 앞서 수식으로 나타낸 알파 블렌딩을 NumPy 배열에 직접 적용하였고, 코드 ③은 cv2.addWeighted() 함수로 적용해서 같은 결과를 가져오는 것을 보여주고 있습니다.

아래의 코드는 남자의 얼굴과 사자의 얼굴을 알파 블렌딩하는 데 트랙바로 알파 값을 조정할 수 있게 했습니다. 트랙바를 움직여서 알파 값을 조정하면 마치 사람이 서서히 사자로 바뀌는 것처럼 보입니다. 알파 블렌딩은 흔히 페이드-인/아웃(fade-in/out) 기법으로 영상이 전환되는 장면에서 자주 사용되며, 《구미호》나 《늑대인간》 같은 영화의 변신 장면에서 얼굴 모핑(face morphing)이라는 기법으로 효과를 내는데, 이 기법을 구성하는 한 가지 기술이기도 합니다.

'''트랙바로 알파 블렌딩'''
import cv2
import numpy as np

win_name = 'Alpha blending'     # 창 이름
trackbar_name = 'fade'          # 트렉바 이름

# ---① 트렉바 이벤트 핸들러 함수
def onChange(x):
    alpha = x/100
    dst = cv2.addWeighted(img1, 1-alpha, img2, alpha, 0) 
    cv2.imshow(win_name, dst)

# ---② 합성 영상 읽기
img1 = cv2.imread('./img/man_face.jpg')
img2 = cv2.imread('./img/lion_face.jpg')

# ---③ 이미지 표시 및 트렉바 붙이기
cv2.imshow(win_name, img1)
cv2.createTrackbar(trackbar_name, win_name, 0, 100, onChange)

cv2.waitKey()
cv2.destroyAllWindows()

image

값으로 직접 비교하면 다음과 같습니다.

image


3. 비트와이즈 연산

OpenCV는 두 영상의 각 픽셀에 대한 비트와이즈(bitwise, 비트 단위) 연산 기능을 제공합니다. 비트와이즈 연산은 영상을 합성할 때 특정 영역만 선택하거나 특정 영역만 제외하는 등의 선별적인 연산에 도움이 됩니다. OpenCV에서 제공하는 비트와이즈 연산 함수는 다음과 같습니다.

  • bitwise_and(img1, img2, mask=None) : 각 픽셀에 대해 비트와이즈 AND 연산
  • bitwise_or(img1, img2, mask=None) : 각 픽셀에 대해 비트와이즈 OR 연산
  • bitwise_xor(img1, img2, mask=None) : 각 픽셀에 대해 비트와이즈 XOR 연산
  • bitwise_not(img1, mask=None) : 각 픽셀에 대해 비트와이즈 NOT 연산
    • img1, img2 : 연산 대상 영상, 동일한 shape
    • mask : 0이 아닌 픽셀만 연산, 바이너리 이미지
'''비트와이즈 연산'''
import numpy as np, cv2
import matplotlib.pylab as plt

#--① 연산에 사용할 이미지 생성
img1 = np.zeros(( 200,400 ), dtype=np.uint8)
img2 = np.zeros(( 200,400 ), dtype=np.uint8)
img1[:, :200] = 255         # 왼쪽은 흰색(255), 오른쪽은 검정색(0)
img2[100:200, :] = 255      # 위쪽은 검정색(0), 아래쪽은 흰색(255)

#--② 비트와이즈 연산
bitAnd = cv2.bitwise_and(img1, img2)
bitOr = cv2.bitwise_or(img1, img2)
bitXor = cv2.bitwise_xor(img1, img2)
bitNot = cv2.bitwise_not(img1)

#--③ Plot으로 결과 출력
imgs = {'img1':img1, 'img2':img2, 'and':bitAnd, 
          'or':bitOr, 'xor':bitXor, 'not(img1)':bitNot}
for i, (title, img) in enumerate(imgs.items()):
    plt.subplot(3,2,i+1)
    plt.title(title)
    plt.imshow(img, 'gray')
    plt.xticks([]); plt.yticks([])

plt.show()

image

위 예제의 실행 결과를 보면 이해하기 쉬울 것입니다. img1은 좌우로, img2는 위아래로 0과 255로 나누어 200 × 400 크기의 영상을 생성했습니다. 이 두 영상에 대해서 각각 비트와이즈 연산을 한 결과입니다. cv2.bitwise_and() 연산은 두 영상에서 0으로 채워진 부분이 만나는 부분은 모두 0으로 채워졌습니다. cv2.bitwise_or() 연산은 두 영상에서 255로 채워진 부분은 모두 255로 채워졌습니다. cv2.bitwise_xor() 연산은 두 영상에서 서로 다른 값을 가진 부분은 255로, 서로 같은 값을 가진 부분은 0으로 채워졌습니다. img1에 대한 cv2.bitwise_not() 연산은 원래의 반대의 결과를 갖습니다.

다음 코드는 비트와이즈 연산으로 영상의 일부분을 원하는 모양으로 떼내는 예제입니다.

'''bitwise_and 연산으로 마스킹하기'''
import numpy as np, cv2
import matplotlib.pylab as plt

#--① 이미지 읽기
img = cv2.imread('./img/wonyoung.jpg')

#--② 마스크 만들기
mask = np.zeros_like(img)
cv2.circle(mask, (220,270), 150, (255,255,255), -1)
#cv2.circle(대상이미지, (원점x, 원점y), 반지름, (색상), 채우기)

#--③ 마스킹
masked = cv2.bitwise_and(img, mask)

#--④ 결과 출력
cv2.imshow('original', img)
cv2.imshow('mask', mask)
cv2.imshow('masked', masked)
cv2.waitKey()
cv2.destroyAllWindows()

image

image

image

위 코드 ②에서 원본 이미지와 동일한 shape의 O(zero)으로 채워진 배열을 만들고 원하는 위치에 (255,255,255)로 채워진 원을 그립니다. 이렇게 생성된 배열은 원을 제외한 나머지 영역은 모두 O(zero)으로 채워져 있고, 원은 모든 비트가 1로 채워져 있는 255입니다. 코드 ③에서는 이 영상과 원본 영상을 cv2.bitwise_and() 연산으로 원 이외의 부분을 모두 0으로 채워서 원하는 영역만 떼어낼 수 있습니다.

예제에서는 마스킹하기 위해 코드 ②에서 원본 영상과 똑같은 3채널 배열을 만들었지만, 비트와이즈 연산 함수의 세 번째 인자인 mask를 이용하면 2차원 배열만으로도 가능합니다.

#--② 마스크 만들기
mask = np.zeros(img.shape[:2], dtype=np.uint8)
cv2.circle(mask, (220,270), 150, (255), -1)
#cv2.circle(대상이미지, (원점x, 원점y), 반지름, (색상), 채우기)

#--③ 마스킹
masked = cv2.bitwise_and(img, img, mask=mask)


4. 차영상

영상에서 영상을 빼기 연산하면 두 영상의 차이, 즉 변화를 알 수 있는데, 이것을 차영상(image differencing)이라고 합니다. 심심풀이로 한 번쯤은 해봤을 법한 틀린 그림 찾기 놀이는 차영상으로 손쉽게 답을 찾을 수 있습니다. 놀이뿐만 아니라 산업현장에서 도면의 차이를 찾거나 전자제품의 PCB(Printable Circuit Board) 회로의 오류를 찾는 데도 사용할 수 있고, 카메라로 촬영한 영상에 실시간으로 움직임이 있는지를 알아내는 데도 유용합니다.

차영상을 구할 때 두 영상을 무턱대고 빼기 연산하면 음수가 나올 수 있으므로 절대 값을 구해야 합니다. 아래는 OpenCV에서 제공하는 절대 값의 차를 구하는 함수입니다.

  • diff = cv2.absdiff(img1, img2)
    • img1, img2 : 입력 영상
    • diff : 두 영상의 차의 절대 값 반환

다음 코드는 사람의 눈으로 찾기 힘든 두 도면의 차이를 찾아 표시합니다.

'''차영상으로 도면의 차이 찾아내기'''
import numpy as np, cv2

#--① 연산에 필요한 영상을 읽고 그레이스케일로 변환
img1 = cv2.imread('./img/robot_arm1.jpg')
img2 = cv2.imread('./img/robot_arm2.jpg')
img1_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

#--② 두 영상의 절대값 차 연산
diff = cv2.absdiff(img1_gray, img2_gray)

#--③ 차 영상을 극대화 하기 위해 쓰레시홀드 처리 및 컬러로 변환
_, diff = cv2.threshold(diff, 1, 255, cv2.THRESH_BINARY)
diff_red = cv2.cvtColor(diff, cv2.COLOR_GRAY2BGR)
diff_red[:,:,2] = 0

#--④ 두 번째 이미지에 변화 부분 표시
spot = cv2.bitwise_xor(img2, diff_red)

#--⑤ 결과 영상 출력
cv2.imshow('img1', img1)
cv2.imshow('img2', img2)
cv2.imshow('diff', diff)
cv2.imshow('spot', spot)
cv2.waitKey()
cv2.destroyAllWindows()

image

코드 ①은 연산에 필요한 두 영상을 읽어서 그레이 스케일로 변환합니다. 코드 ②에서 그레이 스케일로 변환된 두 영상의 차영상을 구합니다. 그 차이를 극대화해서 표현하기 위해 코드 ③에서는 1보다 큰 값은 모두 255로 바꾸고 색상을 표현하기 위해 컬러 스케일로 바꿉니다. 코드 ④는 원본 이미지의 어느 부분이 변경되었는지 표시해 주기 위해서 cv2.bitwise_xor() 연산을 합니다. 원본 이미지는 배경이 흰색이므로 255를 가지고 있고 차영상은 차이가 있는 빨간색 영역을 제외하고는 255이므로 XOR 연산을 하면 서로 다른 영역인 도면의 그림과 빨간색으로 표시된 차영상 부분이 합성됩니다.


5. 이미지 합성과 마스킹

두 개 이상의 영상에서 특정 영역끼리 합성하기 위해서는 전경이 될 영상과 배경이될 영상에서 합성하고자 하는 영역만 떼어내는 작업과 그것을 합하는 작업으로 나눌수 있습니다. 여기서 원하는 영역만을 떼어내는 데 꼭 필요한 것이 마스크(mask)입니다. 사람이 좌표를 입력하지 않고 정교한 마스크를 만드는 작업은 결코 쉽지 않습니다. 사실 원하는 영역을 배경에서 떼어내는 작업은 객체 인식과 분리라는 컴퓨터 비전 분야의 정점과도 같다고 볼 수 있습니다.

여기서는 우선 배경이 투명한 알파 채널 영상을 이용해서 영상을 합성해 봅니다. 배경이 투명한 영상은 4개 채널 중 마지막 채널은 배경에 해당하는 영역은 0 값을, 전경에 해당하는 영역은 255 값을 갖습니다. 이것을 이용하면 손쉽게 마스크를 만들 수 있습니다. 마스크를 이용해서 전경과 배경을 오려내는 것은 앞서 살펴본 cv2.bitwise_and() 연산을 이용하면 쉽습니다.

'''투명 배경 PNG 파일을 이용한 합성'''
import cv2
import numpy as np

#--① 합성에 사용할 영상 읽기, 전경 영상은 4채널 png 파일
img_fg = cv2.imread('./img/opencv_logo.png', cv2.IMREAD_UNCHANGED)
img_bg = cv2.imread('./img/wonyoung.jpg')

#--② 알파채널을 이용해서 마스크와 역마스크 생성
_, mask = cv2.threshold(img_fg[:,:,3], 1, 255, cv2.THRESH_BINARY)
mask_inv = cv2.bitwise_not(mask)

#--③ 전경 영상 크기로 배경 영상에서 ROI 잘라내기
img_fg = cv2.cvtColor(img_fg, cv2.COLOR_BGRA2BGR)
h, w = img_fg.shape[:2]
roi = img_bg[10:10+h, 10:10+w]

#--④ 마스크 이용해서 오려내기
masked_fg = cv2.bitwise_and(img_fg, img_fg, mask=mask)
masked_bg = cv2.bitwise_and(roi, roi, mask=mask_inv)

#--⑥ 이미지 합성
added = masked_fg + masked_bg
img_bg[10:10+h, 10:10+w] = added

cv2.imshow('mask', mask)
cv2.imshow('mask_inv', mask_inv)
cv2.imshow('masked_fg', masked_fg)
cv2.imshow('masked_bg', masked_bg)
cv2.imshow('added', added)
cv2.imshow('result', img_bg)
cv2.waitKey()
cv2.destroyAllWindows() 

image

image

image

image

image

image

위 코드는 배경이 투명한 OpenCV 로고 이미지를 사진과 합성하고 있습니다. 로고 이미지의 네 번째 채널이 배경과 전경을 분리할 수 있는 마스크 역할을 해주므로 앞서 설명한 몇 가지 함수의 조합만으로 손쉽게 이미지를 합성할 수 있습니다.

모양에 따라 영역을 떼어내려는 경우도 있지만, 색상에 따라 영역을 떼어내야 하는 경우도 있습니다. 이때는 색을 가지고 마스크를 만들어야 하는데, HSV로 변환하면 원하는 색상 범위의 것만 골라낼 수 있습니다. OpenCV는 특정 범위에 속하는지를 판단할 수 있는 함수를 아래와 같이 제공합니다. 이것을 이용하면 특정 범위 값을 만족하는 마스크를 만들기 쉽습니다.

  • dst = cv2.inRange(img, from, to) : 범위에 속하지 않은 픽셀 판단
    • img : 입력 영상
    • from : 범위의 시작 배열
    • to : 범위의 끝 배열
    • dst : ing가 from ~ to에 포함되면 255, 아니면 0을 픽셀 값으로 하는 배열

다음 코드는 컬러 큐브에서 색상별로 추출하는 예제입니다.

'''HSV 색상으로 마스킹'''
import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 큐브 영상 읽어서 HSV로 변환
img = cv2.imread("./img/cube.jpg")
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

#--② 색상별 영역 지정
blue1 = np.array([90, 50, 50])
blue2 = np.array([120, 255, 255])
green1 = np.array([45, 50, 50])
green2 = np.array([75, 255, 255])
red1 = np.array([0, 50, 50])
red2 = np.array([15, 255, 255])
red3 = np.array([165, 50, 50])
red4 = np.array([180, 255, 255])
yellow1 = np.array([20, 50, 50])
yellow2 = np.array([35, 255, 255])

# --③ 색상에 따른 마스크 생성
mask_blue = cv2.inRange(hsv, blue1, blue2)
mask_green = cv2.inRange(hsv, green1, green2)
mask_red = cv2.inRange(hsv, red1, red2)
mask_red2 = cv2.inRange(hsv, red3, red4)
mask_yellow = cv2.inRange(hsv, yellow1, yellow2)

#--④ 색상별 마스크로 색상만 추출
res_blue = cv2.bitwise_and(img, img, mask=mask_blue)
res_green = cv2.bitwise_and(img, img, mask=mask_green)
res_red1 = cv2.bitwise_and(img, img, mask=mask_red)
res_red2 = cv2.bitwise_and(img, img, mask=mask_red2)
res_red = cv2.bitwise_or(res_red1, res_red2)
res_yellow = cv2.bitwise_and(img, img, mask=mask_yellow)

#--⑤ 결과 출력
imgs = {'original': img, 'blue':res_blue, 'green':res_green, 
                            'red':res_red, 'yellow':res_yellow}
for i, (k, v) in enumerate(imgs.items()):
    plt.subplot(2, 3, i+1)
    plt.title(k)
    plt.imshow(v[:,:,::-1])
    plt.xticks([]); plt.yticks([])

plt.show()

image

코드 ②에서 지정한 색상별 영역은 HSV의 각 색상별 영역에서 설명한 것을 근거로 작성하였습니다. 빨강은 180을 기점으로 둘로 나뉘어(0~15, 165~180) 있으므로 마스크 생성과 색상 추출에도 두 번씩 사용했습니다. 코드 ③에서 cv2.inRange() 함수를 호출해서 각 색상 범위별 마스크를 만듭니다. 이 함수는 첫 번째 인자의 영상에서 두 번째와 세 번째 인자의 배열 구간에 포함되면 해당 픽셀의 값으로 255를 할당하고 그렇지 않으면 0을 할당합니다. 그래서 이 함수의 반환 결과는 바이너리 스케일이 되어 코드 ④의 cv2․bitwise_and() 함수의 mask로 사용하기 적합합니다.

이와 같이 색상을 이용한 마스크를 이용하는 것이 크로마 키(chroma key)의 원리입니다. 일기예보나 영화를 촬영할 때 초록색 또는 파란색 배경을 두고 찍어서 나중에 원하는 배경과 합성하는 것을 크로마 키잉(chroma keying)이라고 하고 그 초록색 배경을 크로마 키라고 합니다.

image

image

다음 코드는 크로마 키를 배경으로 한 영상에서 크로마 키 색상으로 마스크를 만들어 합성하는 예제로, 위 두 사진을 이용하겠습니다.

'''크로마키 마스킹과 합성'''
import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 크로마키 배경 영상과 합성할 배경 영상 읽기
img1 = cv2.imread('./img/man_chromakey.jpg')
img2 = cv2.imread('./img/street.jpg')

#--② ROI 선택을 위한 좌표 계산
height1, width1 = img1.shape[:2]
height2, width2 = img2.shape[:2]
x = (width2 - width1)//2
y = height2 - height1
w = x + width1
h = y + height1
print('height1 : ', height1, 'width1 : ', width1)
print('height2 : ', height2, 'width2 : ', width2)
print('x : ', x)
print('y : ', y)
print('w : ', w)
print('h : ', h)

#--③ 크로마키 배경 영상에서 크로마키 영역을 10픽셀 정도로 지정
chromakey = img1[:10, :10, :]
offset = 20

#--④ 크로마키 영역과 영상 전체를 HSV로 변경
hsv_chroma = cv2.cvtColor(chromakey, cv2.COLOR_BGR2HSV)
hsv_img = cv2.cvtColor(img1, cv2.COLOR_BGR2HSV)

#--⑤ 크로마키 영역의 H값에서 offset 만큼 여유를 두어서 범위 지정
# offset 값은 여러차례 시도 후 결정
#chroma_h = hsv_chroma[0]
chroma_h = hsv_chroma[:,:,0]
lower = np.array([chroma_h.min()-offset, 100, 100])
upper = np.array([chroma_h.max()+offset, 255, 255])

#--⑥ 마스크 생성 및 마스킹 후 합성
mask = cv2.inRange(hsv_img, lower, upper)
mask_inv = cv2.bitwise_not(mask)
roi = img2[y:h, x:w]
cv2.imshow('mask', mask)
cv2.imshow('mask_inv', mask_inv)
cv2.imshow('roi', roi)

fg = cv2.bitwise_and(img1, img1, mask=mask_inv)
bg = cv2.bitwise_and(roi, roi, mask=mask)
img2[y:h, x:w] = fg + bg

#--⑦ 결과 출력
cv2.imshow('fg', fg)
cv2.imshow('bg', bg)

cv2.imshow('chromakey', img1)
cv2.imshow('added', img2)
cv2.waitKey()
cv2.destroyAllWindows()

[output]
height1 :  400 width1 :  314
height2 :  426 width2 :  640
x :  163
y :  26
w :  477
h :  426

image

image

image

image

image

image

image

왼쪽에 한 남자가 크로마 키를 배경으로 찍은 사진을 어느 거리를 찍은사진과 합성한 것입니다. 코드 ②에서는 남자가 서 있는 왼쪽 끝 배경 10 × 10 픽셀 영역을 크로마 키가 있는 영역으로 어림잡아 지정했습니다. 이 영역의 색상 값 중에 가장 큰 값과 가장 작은 값을 범위로 지정해서 cv2.inRange() 함수를 사용하면 배경만 제거할 수 있습니다. 코드 ④에서는 앞서 어림잡아 선택한 영역의 색상 값보다 더 넓은 영역의 색상을 선택할 수 있도록 offset 만큼 가감하게 했고 그 수치는 결과를 확인하면서 경험적으로 얻어야 합니다. 크로마 키의 색상 값도 화면 전체적으로는 조금씩 다를 수 있기 때문입니다. S와 V 값의 선택 범위도 마찬가지입니다. 나머지 마스킹과 합성 작업은 이전에 했던 것과 크게 다르지 않습니다.

이렇게 영상 합성에는 대부분 알파 블렌딩 또는 마스킹이 필요합니다. 하지만, 이런 작업은 블렌딩을 위한 적절한 알파 값 선택과 마스킹을 위한 모양의 좌표나 색상값 선택에 많은 노력과 시간이 필요합니다. OpenCV는 3 버전에서 재미있는 함수를 추가했는데, 알아서 두 영상의 특징을 살려 합성하는 기능입니다. 이 함수의 설명은 아래와 같습니다.

  • dst = cv2.seamlessClone(src, dst, mask, coords, flags[, output])
    • src : 입력 영상, 일반적으로 전경
    • dst : 대상 영상, 일반적으로 배경
    • mask : 마스크, src에서 합성하고자 하는 영역은 255, 나머지는 0
    • coodrs : src가 놓여지기 원하는 dst의 좌표(중앙)
    • flags : 합성 방식
      • cv2.NORMAL_CLONE : 입력 원본 유지
      • cv2.MIXED_CLONE : 입력과 대상을 혼합
    • output : 합성 결과
    • dst : 합성 결과

image

image

위 사진을 이용하여, cv2.SeamlessClone() 함수로 사진을 합성해서 손에 꽃 문신을 한 것처럼 만들어 보겠습니다.

'''SeamlessClone으로 합성'''
import cv2
import numpy as np
import matplotlib.pylab as plt
 
#--① 합성 대상 영상 읽기
img1 = cv2.imread("./img/drawing.jpg")
img2 = cv2.imread("./img/my_hand.jpg")

#--② 마스크 생성, 합성할 이미지 전체 영역을 255로 셋팅
mask = np.full_like(img1, 255)
 
#--③ 합성 대상 좌표 계산(img2의 중앙)
height, width = img2.shape[:2]
center = (width//2, height//2)
 
#--④ seamlessClone 으로 합성 
normal = cv2.seamlessClone(img1, img2, mask, center, cv2.NORMAL_CLONE)
mixed = cv2.seamlessClone(img1, img2, mask, center, cv2.MIXED_CLONE)

#--⑤ 결과 출력
cv2.imshow('normal', normal)
cv2.imshow('mixed', mixed)
cv2.waitKey()
cv2.destroyAllWindows()

위 코드 ④가 이 예제의 핵심적인 코드입니다. img1을 img2에다가 mask에 지정된 영역만큼 center 좌표에 합성합니다. 이때 mask는 img1의 전체 영역을 255채워서 해당 영역 전부가 합성의 대상임을 표현합니다. 가급적이면 합성하려는 영역을 제외하고 0으로 채우는 것이 더 좋은 결과를 보여주지만 이번 예제에서는 일부러 대충해 보았습니다. 결과를 보면 함수의 마지막 인자 플래그가 cv2.NORMAL_CLONE 인 경우 꽃 그림이 선명하긴 하지만, 주변의 피부가 뭉개진 듯한 결과를 보입니다. 반면에, cv2.MIXED_CLONE 을 사용한 경우에는 감쪽같이 두 영상의 특징을 살려서 표현하고 있습니다. 이 함수는 이미지 합성에 꼭 필요한 알파 값이나 마스크에 대해 신경 쓰지 않아도 되서 무척 편리합니다.

image

image

Read more

OpenCV Image Processing 스레시홀딩

|

해당 게시물은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저) 를 바탕으로 작성되었습니다.


스레시홀딩

이미지를 검은색과 흰색만으로 표현한 것을 바이너리(binary, 이진화) 이미지라고 합니다. 이렇게 하는 이유는 이미지에서 원하는 피사체의 모양을 좀 더 정확히 판단하기 위해서입니다. 예를 들면, 종이에서 글씨만을 분리하거나 배경에서 전경을 분리하는 것과 같은 작업입니다.

스레시홀딩(thresholding)이란 여러 점수를 커트라인을 기준으로 합격과 불합격으로 나누는 것처럼 여러 값을 경계점을 기준으로 두 가지 부류로 나누는 것으로, 바이너리 이미지를 만드는 가장 대표적인 방법입니다.

1. 전역 스레시홀딩

바이너리 이미지를 만들기 위해서는 컬러 이미지를 그레이 스케일로 바꾸고 각 픽셀의 값이 경계 값을 넘으면 255, 넘지 못하면 0을 지정합니다. 이런 작업은 간단한 NumPy 연산만으로도 충분히 할 수 있지만, OpenCV는 cv2.threshold() 함수로 더 많은 기능을 제공합니다.

다음 코드는 NumPy 연산과 OpenCV 함수로 각각 바이너리 이미지를 만드는 과정을 보여줍니다.

'''바이너리 이미지 만들기'''
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()

[output]
127.0

image

위 예제는 검은색에서 흰색으로 점점 변하는 그러데이션 이미지를 그레이 스케일로 읽어서 바이너리 이미지를 만드는 예제입니다. 코드 ①에서 원본 이미지와 같은 크기이면서 O(zero)으로 채워진 NumPy 배열을 생성하고 나서 127보다 큰 값을 갖는 요소에 255를 할당하는 연산을 해서 바이너리 이미지로 만들고 있습니다.

코드 ②는 cv2.threshold() 함수를 이용해서 간단히 바이너리 이미지를 만들고 있습니다. 코드 ③은 각각 생성한 바이너리 이미지와 원본 이미지를 출력합니다. 이때 사용한 cv2.threshold() 함수의 사용법은 다음과 같습니다.

  • ret, out = cv2.threshold(img, threshold, value, type_flag)
    • img : NumPy 배열, 변환할 이미지
    • threshold : 경계 값
    • value : 경계 값 기준에 만족하는 픽셀에 적용할 값
    • type_flag : 스레시홀드 적용 방법 지정
      • cv2.THRESH_BINARY : px > threshold ? value : 0, 픽셀 값이 경계 값을 넘으면 value를 지정하고, 넘지 못하면 0을 지정
      • cv2.THRESH_BINARY_INV : px > threshold ? 0 : value, cv2.THRESH_BINARY의 반대
      • cv2.THRESH_TRUNC : px > threshold ? value : px, 픽셀 값이 경계 값을 넘으면 value를 지정하고, 넘지 못하면 원래의 값 유지
      • cv2.THRESH_TOZERO : px > threshold ? px : 0, 픽셀 값이 경계 값을 넘으면 원래 값을 유지, 넘지 못하면 0을 지정
      • cv2.THRESH_TOZERO_INV : px > threshold ? 0 : px, cv2.THRESH_TOZERO의 반대
    • ret : 스레시홀딩에 사용한 경계 값
    • out : 결과 바이너리 이미지

이 함수의 반환 값은 튜플로 2개의 값을 반환하는데, 첫 번째 항목은 스레시홀딩에 사용한 경계 값이고, 두 번째 항목은 스레시홀딩된 바이너리 이미지입니다. 대부분의 경우 첫 번째 반환 항목인 ret는 threshold 인자로 전달한 값과 같아서 쓸모 없습니다.

이 함수는 단순히 경계 값에 따라 0과 255로 나누는 cv2.THRESH_BINARY 말고도 몇 가지 기능의 플래그 상수를 사용할 수 있게 해줍니다. 다음 예제에서는 위에 나열한 몇 가지 다른 플래그를 이용한 스레시홀딩을 사례로 보여줍니다. 코드와 실행 결과만 봐도 쉽게 이해할 수 있을 것입니다.

'''스레시홀딩 플래그 실습'''
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()

image


2. 오츠의 알고리즘

바이너리 이미지를 만들 때 가장 중요한 작업은 경계 값을 얼마로 정하느냐입니다. 종이에 출력한 문서를 바이너리 이미지로 만드는 것을 예를 들면, 새하얀 종이에 검은색으로 출력된 문서의 영상이라면 굳이 스레시홀드를 적용할 필요가 없습니다. 하지만, 현실은 흰색, 누런색, 회색 종이에 검은색, 파란색 등으로 인쇄된 문서가 더 많기 때문에 적절한 경계 값을 정하기 위해서는 여러 차례에 걸쳐 경계 값을 조금씩 수정해 가면서 가장 좋은 경계 값을 찾아야 합니다.

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

img = cv2.imread('./img/scaned_paper.jpg', cv2.IMREAD_GRAYSCALE) #이미지를 그레이 스케일로 읽기
thresholds = [80, 100, 120, 140, 150, 170, 190]
imgs = {'Original' : img}
for t in thresholds:
    _, t_img = cv2.threshold(img, t, 255, cv2.THRESH_BINARY) 
    imgs['t:%d'%t] = t_img

for i , (key, value) in enumerate(imgs.items()):
    plt.subplot(2, 4, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]); plt.yticks([])

plt.show()

image

위 그림은 오래된 신문 기사를 스크랩해서 스캔한 영상인데, 왼쪽 처음 그림이 원본이고 경계 값을 80부터 20씩 증가시키면서 스레시홀딩한 결과입니다. 결과를 살펴보면 아마도 글씨와 그림을 가장 잘 얻을 수 있는 경계 값은 120과 140 사이쯤인 것을 알 수 있습니다. 그러니까 다음번에 시도해 볼 경계 값은 130 정도가 적당해 보입니다. 이와 같이 반복적인 경계 값 찾기 시도는 귀찮고 시간도 많이 걸립니다.

1979년 오츠 노부유키(Nobuyuki Otsu)는 반복적인 시도 없이 한 번에 효율적으로 경계 값을 찾을 수 있는 방법을 제안했는데, 그의 이름을 따서 그것을 오츠의 이진화 알고리즘(Otsu’s binarization method)이라고 합니다. 오츠의 알고리즘은 경계값을 임의로 정해서 픽셀들을 두 부류로 나누고 두 부류의 명암 분포를 반복해서 구한 다음 두 부류의 명암 분포를 가장 균일하게 하는 경계 값을 선택합니다. 이것을 수식으로 표현하면 다음과 같습니다.

[\sigma^2_w(t) = w_1(t)\sigma^2_1(t) + w_2(t)\sigma^2_2(t)]

  • $t$ : 0~255, 경계 값
  • $w_1, w_2$ : 각 부류의 비율 가중치
  • $\sigma^2_1(t), \sigma^2_2(t)$ : 각 부류의 분산


OpenCV는 이미 구현한 오츠의 알고리즘을 사용할 수 있게 제공해 주는데, 이것을 사용하려면 앞서 설명한 cv2.threshold() 함수의 마지막 인자에 cv2.THRESH_OTSU 를 추가해서 전달하기만 하면 됩니다. 그러면 원래 경계 값을 전달해야 하는 두 번째 인자 threshold는 무시되므로 아무 숫자나 전달해도 되고, 실행 후 결과 값으로 오츠의 알고리즘에 의해 선택된 경계 값은 반환 값 첫 번째 항목 ret로 받을 수 있습니다. 아래 코드는 cv2.threshold() 함수에 오츠의 알고리즘을 적용하는 코드입니다.

ret, t_img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

마지막 플래그에는 앞서 설명한 스레시홀드 방식을 결정하는 플래그와 파이프(|) 문자로 연결하여 전달합니다.

'''오츠의 알고리즘을 적용한 스레시홀드'''
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()

[output]
otsu threshold: 131.0

image

위 코드 ①에서는 기존에 여러 번 시도해서 알아낸 경계 값 130을 직접 지정해서 바이너리 이미지를 얻습니다. 반면에, 코드 ②에서는 오츠의 알고리즘을 적용하고 경계 값으로는 의미 없는 -1을 전달했더니 결과적으로 경계 값 131을 자동으로 계산해서 반환하고 적절한 바이너리 이미지를 얻게 됐습니다. 사람이 여러 번 시도해서 얻는 값과 거의 비슷한 것을 알 수 있습니다. 하지만, 오츠의 알고리즘은 모든 경우의 수에 대해 경계 값을 조사해야 하므로 속도가 빠르지 못하다는 단점이 있습니다. 또한 노이즈가 많은 영상에는 오츠의 알고리즘을 적용해도 좋은 결과를 얻지 못하는 경우가 많은데, 이때는 블러링 필터를 먼저 적용해야 합니다.


3. 적응형 스레시홀드

원본 영상에 조명이 일정하지 않거나 배경색이 여러 가지인 경우에는 아무리 여러번 경계 값을 바꿔가며 시도해도 하나의 경계 값을 이미지 전체에 적용해서는 좋은 결과를 얻지 못합니다. 이때는 이미지를 여러 영역으로 나눈 다음 그 주변 픽셀 값만 가지고 계산을 해서 경계 값을 구해야 하는데, 이것을 적응형 스레시홀드(adaptive threshold)라고 합니다.

OpenCV에서는 이 기능을 다음 함수로 제공합니다.

  • cv2.adaptiveThreshold(img, value, method, type_flag, block_size, C)
    • img : 입력 영상
    • value : 경계 값을 만족하는 픽셀에 적용할 값
    • method : 경계 값 결정 방법
      • cv2.ADPTIVE_THRESH_MEAN_C : 이웃 픽셀의 평균으로 결정
      • cv2.ADPTIVE_THRESH_GAUSSIAN_C : 가우시안 분포에 따른 가중치의 합으로 결정
    • type_flag : 스레시홀드 적용 방법 지정(cv2.threshold() 함수와 동일)
    • block_size : 영역으로 나눌 이웃의 크기($n \times n$), 홀수(3, 5, 7, …)
    • C : 계산된 경계값 결과에서 가감할 상수(음수 가능)
'''적응형 스레시홀드 적용'''
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()

image

위 코드 ①은 오츠의 알고리즘을 적용해서 얻은 96을 경계 값으로 전체 이미지에 적용했지만 결과를 보면 좌측 하단은 검게 타버리고, 우측 상단은 하얗게 날아간 것을 알 수 있습니다. 반면에, 코드 ②에서는 적응형 스레시홀드를 평균 값과 가우시안 분포를 각각 적용해서 훨씬 좋은 결과를 얻을 수 있습니다. 그중에서도 가우시안 분포를 이용한 결과는 선명함은 떨어지지만 잡티(noise)가 훨씬 적은 것을 알 수 있습니다.

경계 값을 전체 이미지에 적용하는 것을 전역적(global) 적용이라고 하는 반면에, 이미지를 여러 구역으로 나누어 그 구역에 맞는 경계 값을 찾는 것을 지역적(local)적용이라고 합니다. 대부분의 이미지는 조명 차이와 그림자 때문에 지역적 적용이 필요합니다.

Read more

OpenCV Image Processing 컬러 스페이스

|

해당 게시물은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저) 를 바탕으로 작성되었습니다.


컬러 스페이스

영상에 색상과 명암을 표현하는 방법들과 각각의 차이 그리고 활용 방법에 대해 살펴보겠습니다.

1. 디지털 영상의 종류

디지털화된 이미지는 픽셀(pixel, 화소)이라는 단위가 여러 개 모여서 그림을 표현합니다. 하나의 픽셀을 어떻게 구성하느냐에 따라 이미지를 구분할 수 있습니다.


바이너리(binary, 이진) 이미지

image

한 개의 픽셀을 두 가지 값으로만 표현한 이미지바이너리(binary, 이진) 이미지라고 합니다. 두 가지 값은 0과 1을 사용하기도 하고 0과 255를 사용하기도 합니다. 보통 0은 검은색, 1이나 255는 흰색을 표시해서 말 그대로 흰색과 검은색만으로 그림을 그리는 흑백 이미지입니다. 표현할 수 있는 값이 두 가지밖에 없어서 값으로는 명암을 표현할 수 없고, 점의 밀도로 명암을 표현할 수 있습니다.

영상 작업에서는 피사체의 색상과 명암 정보는 필요 없고 오직 피사체의 모양 정보만 필요할 때 이런 이미지를 사용합니다.


그레이 스케일 이미지

image

흔히 흑백 사진이라고 하는 것그레이 스케일 이미지입니다. 엄밀히 따지면, 흑백 이미지는 바로 앞서 설명한 바이너리 이미지를 말하는 것입니다.

그레이 스케일 이미지는 한 개의 픽셀을 0~255의 값으로 표현합니다. 픽셀 값의 크기로 명암을 표현하는데, 가장 작은 값인 0은 가장 어두운 검은색을 의미하고 값이 점점 커질수록 밝은 색을 의미하다가 255까지 가면 가장 밝은 흰색을 나타냅니다. 빛이 하나도 없는 O(영, zero)인 상태가 가장 어둡다고 생각하면 기억하기 쉽습니다. 한 픽셀이 가질 수 있는 값이 0~255이므로 음수가 없어서 부호 없는 1바이트의 크기로 표현하는 것이 일반적입니다. 이미지 프로세싱에서는 색상 정보가 쓸모없을 때 컬러 이미지의 색상 정보를 제거함으로써 연산의 양을 줄이려고 그레이 스케일 이미지를 사용합니다.


컬러 이미지

image

컬러 이미지에 색상을 표현하는 방법은 무척 다양합니다. 색상을 표현하는 방법에 따라 다르기는 하지만, 흔히 컬러 이미지는 한 픽셀당 0~255의 값 3개를 조합해서 표현합니다. 각 바이트마다 어떤 색상 표현의 역할을 맡을지를 결정하는 시스템컬러 스페이스(color space, 색공간)라고 합니다. 컬러 스페이스의 종류는 RGB, HSV, YUV(YCbCr), CMYK 등 여러 가지가 있습니다.


2. RGB, BGR, RGBA

컴퓨터로 이미지에 색상을 표현하는 방법 중 가장 많이 사용하는 방법이 RGB(Red,Green, Blue) 컬러 스페이스입니다. RGB는 빛의 3원소인 빨강, 초록, 파랑 세 가지색의 빛을 섞어서 원하는 색을 표현합니다.


image

각 색상은 0~255 범위로 표현하고 값이 커질수록 해당 색상의 빛이 밝아지는 원리로 색상의 값이 모두 255일 때 흰색으로 표현되고, 모든 색상 값이 0일 때 검은색이 표현됩니다.

세 가지 색상을 표현하므로 RGB 이미지는 3차원 배열로 표현됩니다.

[row \times column \times channel]

영상의 크기에 해당하는 행(row, height)과 열(column, width)에 세 가지 색상을 표현하는 차원이 추가되는데, 이것을 채널(channel)이라고 합니다. 그러니까 RGB는 3개의 채널로 색상을 표현하는 컬러 스페이스인데, OpenCV는 그 순서를 반대로 해서 BGR 순서를 사용합니다.

RGBA배경을 투명 처리하기 위해 알파(alpha) 채널을 추가한 것을 말합니다. 4번째 채널의 값은 0~255로 표현할 수 있지만, 배경의 투명도를 표현하기 위해서는 0과 255만을 사용하는 경우가 많습니다.


image

cv2.imread() 함수의 두 번째 인자가 cv2.IMREAD_COLOR 인 경우 BGR로 읽어 들이고 cv2.IMREAD_UNCHANGED 인 경우 대상 이미지가 알파 채널을 가지고 있다면 BGRA로 읽어 들입니다. 다음 예제는 배경이 투명한 OpenCV 로고 이미지를 두 가지 옵션을 지정해서 비교합니다.

'''BGR, BGRA, Ahlpha 채널'''
import cv2
import numpy as np

# 기본 값 옵션
img = cv2.imread('./img/opencv_logo.png')

# IMREAD_COLOR 옵션
bgr = cv2.imread('./img/opencv_logo.png', cv2.IMREAD_COLOR)

# IMREAD_UNCHANGED 옵션
bgra = cv2.imread('./img/opencv_logo.png', cv2.IMREAD_UNCHANGED)

# 각 옵션에 따른 이미지 shape
print("default", img.shape, "color", bgr.shape, "unchanged", bgra.shape)

cv2.imshow('bgr', bgr)
cv2.imshow('bgra', bgra)
cv2.imshow('alpha', bgra[:,:,3])  # 알파 채널만 표시
cv2.waitKey(0)
cv2.destroyAllWindows()

[output]
default (120, 98, 3) color (120, 98, 3) unchanged (120, 98, 4)

image

image

image

출력 내용을 보면 옵션을 따로 지정하지 않은 기본 옵션과 cv2.IMREAD_COLOR 옵션의 shape (240, 195, 3)로 동일한 것을 볼 수 있습니다. 위 두 그림은 투명한 배경이 검은색으로 표시되었고 로고 아래의 글씨도 검은색이다 보니 글씨가 보이지 않습니다. cv2.IMREAD_UNCHANGED 옵션으로 읽은 이미지는 shape가 (240, 195, 4)로 마지막 채널이 하나 더 있는 것을 알 수 있습니다. 이 채널만 떼어내서 따로 표시하였더니 로고와 글씨를 제외하고는 모두 검은색으로 표시됩니다. 즉, 전경은 255, 배경은 0의 값을 갖습니다. 이 알파 채널의 정보를 이용하면 전경과 배경을 손쉽게 분리할 수 있어마스크 채널(mask channel)이라고도 부릅니다.

3. 컬러 스페이스 변환

컬러 이미지를 그레이 스케일로 변환하는 것은 이미지 연산의 양을 줄여서 속도를 높이는 데 꼭 필요합니다. 이때 애초에 그레이 스케일로 읽어오는 방법은 cv2.imread(img, cv2.IMREAD_GRAYSCALE) 입니다. 그런데 맨 처음에는 컬러 스케일로 읽어 들이고 필요에 따라 그레이 스케일이나 다른 컬러 스페이스로 변환해야 할 때도 많습니다.

그레이 스케일이나 다른 컬러 스페이스로 변환하는 방법은 변환 알고리즘을 직접 구현할 수도 있고, OpenCV에서 제공하는 cv2.cvtColor() 함수를 이용할 수도 있습니다.

다음 코드는 컬러 스케일을 그레이 스케일로 변환하는 작업을 각각 보여줍니다. 이 예제에서 사용한 변환 알고리즘은 직접 구현하는 방법치고는 매우 쉬운 3채널의 평균 값을 구해서 그레이 스케일로 변환하는 방법입니다. 만약 변환 알고리즘이 매우 어렵다면 개발자에게는 큰 부담이 될 텐데 OpenCV를 사용하는 가장 큰 이유가 바로 이런 알고리즘을 정확히 몰라도 전체적인 원리만 알고 있으면 편리하게 작업할 수 있다는 것입니다.

'''BGR을 그레이 스케일로 변환'''
import cv2
import numpy as np

img = cv2.imread('./img/wonyoung.jpg')
img2 = img.astype(np.uint16)                # dtype 변경 ---①

# b,g,r = img2[:,:,0], img2[:,:,1], img2[:,:,2]
b,g,r = cv2.split(img2)                     # 채널 별로 분리 ---②
gray1 = ((b + g + r)/3).astype(np.uint8)    # 평균 값 연산후 dtype 변경 ---③
gray2 = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # BGR을 그레이 스케일로 변경 ---④

cv2.imshow('original', img)
cv2.imshow('gray1', gray1)
cv2.imshow('gray2', gray2)

cv2.waitKey(0)
cv2.destroyAllWindows()

image

image

image

코드 ①, ②, ③은 평균 값을 구하는 알고리즘을 직접 구현했고, 코드 ④는 OpenCV에서 제공하는 함수를 이용한 방법입니다. 코드 ①에서 dtype을 uint16 타입으로 변경한 이유는 원래의 dtype이 uint8인 경우 평균 값을 구하는 과정에서 3채널의 값을 합하면 255보다 큰 값이 나올 수 있으므로 unit16으로 변경해서 계산을 마치고 다시 코드 ③에서 uint8로 변경합니다. 코드 ②에서 사용한 cv2.split() 함수는 매개변수로 전달한 이미지를 채널별로 분리해서 튜플로 반환합니다. 이 코드는 아래와 같은 NumPy 슬라이싱과 동일합니다.

b, g, r = img2[:,:,0], img2[:,:,1], img2[:,:,2]

사실, 컬러 이미지를 그레이 스케일로 변환할 때 좀 더 정확한 명암을 얻으려면 단순히 평균 값만 계산하는 것보다 좀 더 정교한 연산이 필요합니다. 하지만, OpenCV에서 제공하는 cv2.cvtColor(img, flag) 함수는 이런 골치 아픈 알고리즘에서 우리를 자유롭게 해줍니다. 다음은 cv2.cvtColor() 함수에 대한 설명입니다.

  • out = cv2.cvtColor(img, flag)
    • img : NumPy 배열, 변환할 이미지
    • flag : 변환할 컬러 스페이스, cv2.COLOR_ 로 시작하는 이름(274개)
      • cv2.COLOR_BGR2GRAY : BGR 컬러 이미지를 그레이 스케일로 변환
      • cv2.COLOR_GRAY2BGR : 그레이 스케일 이미지를 BGR 컬러 이미지로 변환
      • cv2.COLOR_BGR2RGB : BGR 컬러 이미지를 RGB 컬러 이미지로 변환
      • cv2.COLOR_BGR2HSV : BGR 컬러 이미지를 HSV 컬러 이미지로 변환
      • cv2.COLOR_HSV2BGR : HSV 컬러 이미지를 BGR 컬러 이미지로 변환
      • cv2.COLOR_BGR2YUV : BGR 컬러 이미지를 YUV 컬러 이미지로 변환
      • cv2.COLOR_YUV2BGR : YUV 컬러 이미지를 BGR 컬러 이미지로 변환
    • out : 변환한 결과 이미지(NumPy 배열)

컬러 스페이스 변환에 사용할 수 있는 플래그 상수는 2백여 개가 넘으며, 모든 상수는 이름이 cv2.COLOR_ 로 시작합니다.

cv2.COLOR_GRAY2BGR 플래그는 그레이 스케일을 BGR 스케일로 변환하는데, 실제로 흑백 사진을 컬러 사진으로 바꿔주는 것은 아닙니다. 2차원 배열 이미지를 3개 채널이 모두 같은 값을 갖는 3차원 배열로 변환하는 것입니다. 이 플래그는 영상 간에 연산을 할 때 서로 차원이 다르면 연산을 할 수 없으므로 차원을 맞추는 용도로 주로 사용합니다.


4. HSV, HSI, HSL

HSV 포맷은 RGB와 마찬가지로 3채널로 컬러 이미지를 표시합니다. 3채널은 각각 H(Hue, 색조), S(Saturation, 채도), V(Value, 명도)입니다. 이때 명도를 표현하는 방법에 따라 마지막 V를 I(Intensity, 밀도)로 표기하는 HSI, 그리고 L(Lightness, 명도)로 표기하는 HSL 컬러 시스템도 있습니다. 이름에 차이가 있는 만큼 밝기 값을 계산하는 방법도 조금씩 차이가 있습니다. HSV를 설명하는 데 가장 흔히 사용하는 방법은 다음 그림과 같은 원통형 시스템입니다.

image

H 값그 픽셀이 어떤 색인지를 표현합니다. 원 위에 빨강에서 시작해서 노랑, 초록, 파랑을 거쳐 다시 빨강으로 돌아오는 방식으로 색상에 매칭되는 숫자를 매겨놓고 그 360° 범위의 값을 갖게 해서 색을 표현합니다. 하지만, OpenCV에서 영상을 표현할 때 사용하는 배열의 dtype은 최대 값이 255를 넘지 못하므로 360을 반으로 나누어 0~180 범위의 값으로 표현하고 180보다 큰 값인 경우에는 180으로 간주합니다.

image

앞의 그림은 H 값만을 원통의 위에서 바라보는 시각으로 다시 그린 것에 각 색상별로 수치를 표시한 것입니다. 이 그림을 요약해서 대략 R, G, B 색상의 범위에 맞는 H값을 표시하면 아래와 같습니다.

  • 빨강 : 165 ~ 180, 0 ~ 15
  • 초록 : 45 ~ 75
  • 파랑 : 90 ~ 120

S 값채도, 포화도, 또는 순도로 해석할 수 있는데, **해당 색상이 얼마나 순수하게 포함되어 있는지를 표현합니다. S 값은 0~255 범위로 표현하며, 255는 가장 순수한 색상을 표현합니다.

V 값은 명도로서 빛이 얼마나 밝은지 어두운지를 표현하는 값입니다. 이 값도 범위가 0~255이며, 255인 경우가 가장 밝은 상태이고 0(영, zero)인 경우가 가장 어두운 상태로 검은색이 표시됩니다.

BGR 포맷과 HSV 포맷 간의 변환은 cv2.cvtColor() 함수에 cv2.COLOR_BGR2HSV와 cv2.COLOR_HSV2BGR 플래그 상수를 이용합니다. 다음 코드는 완전한 빨강, 초록, 파랑 그리고 노랑을 BGR 포맷으로 표현해서 HSV로 변환하여 어떤 값인지를 알아보는 코드입니다.

'''BGR에서 HSV로 변환'''
import cv2
import numpy as np

#---① BGR 컬러 스페이스로 원색 픽셀 생성
red_bgr = np.array([[[0,0,255]]], dtype=np.uint8)       # 빨강 값만 갖는 픽셀
green_bgr = np.array([[[0,255,0]]], dtype=np.uint8)     # 초록 값만 갖는 픽셀
blue_bgr = np.array([[[255,0,0]]], dtype=np.uint8)      # 파랑 값만 갖는 픽셀
yellow_bgr = np.array([[[0,255,255]]], dtype=np.uint8)  # 노랑 값만 갖는 픽셀

#---② BGR 컬러 스페이스를 HSV 컬러 스페이스로 변환
red_hsv = cv2.cvtColor(red_bgr, cv2.COLOR_BGR2HSV);
green_hsv = cv2.cvtColor(green_bgr, cv2.COLOR_BGR2HSV);
blue_hsv = cv2.cvtColor(blue_bgr, cv2.COLOR_BGR2HSV);
yellow_hsv = cv2.cvtColor(yellow_bgr, cv2.COLOR_BGR2HSV);

#---③ HSV로 변환한 픽셀 출력
print("red:", red_hsv)
print("green:", green_hsv)
print("blue", blue_hsv)
print("yellow", yellow_hsv)

[output]
red: [[[  0 255 255]]]
green: [[[ 60 255 255]]]
blue [[[120 255 255]]]
yellow [[[ 30 255 255]]]

코드 ①에서 빨강, 초록, 파랑, 노랑에 해당하는 채널에만 최대 값인 255를 지정하고 나머지 채널에는 0(zero)을 지정해서 순도 높은 원색을 표현하고 코드 ②에서 HSV 컬러 스페이스로 변환한 후에 각 픽셀 값을 출력하고 있습니다. 출력 결과를 살펴보면 가장 순도 높은 빨강의 H 값은 0, 초록은 60, 파랑은 120, 노랑은 30인 것을 확인할 수 있습니다.

픽셀의 색상이 궁금할 때 RGB 포맷의 경우 세 가지 채널의 값을 모두 조사해야 하지만, HSV 포맷은 오직 H 채널 값만 확인하면 되므로 색상을 기반으로 하는 여러 가지 작업에 효과적입니다.


5. YUV, YCbCr

YUV 포맷사람이 색상을 인식할 때 밝기에 더 민감하고 색상은 상대적으로 둔감한 점을 고려해서 만든 컬러 스페이스입니다. Y는 밝기(Luma)를 표현하고, U(Chroma Blue, Cb)는 밝기와 파란색과의 색상 차, V(Chroma Red, Cr)는 밝기와 빨간색과의 색상 차를 표현합니다. Y(밝기)에는 많은 비트수를 할당하고 U(Cb)와 V(Cr)에는 적은 비트 수를 할당해서 데이터를 압축하는 효과를 갖습니다.

image

YUV라는 용어는 TV 방송에서 사용하는 아날로그 컬러 인코딩 시스템인 PAL(Phase Alternating Line)에서 정의한 용어입니다. YUV는 종종 YCbCr 포맷과 혼용되기도 하는데, 본래 YUV는 텔레비전 시스템에서 아날로그 컬러 정보를 인코딩하는 데 사용하고, YCbCr 포맷은 MPEG나 JPEG와 같은 디지털 컬러 정보를 인코딩하는 데 사용하였습니다. YUV는 요즘 들어 YCbCr로 인코딩된 파일 포맷을 설명하는 용어로 일반적으로 사용됩니다. 실제로도 YUV와 YCbCr은 RGB 포맷에서 변환하기 위한 공식이 달라서 OpenCV는 cv2.COLOR_BGR2YUV, cv2.COLOR_BGR2YCrCb 가 따로 있고 변환 결과도 미세하게 다릅니다. YUV는 밝기 정보와 컬러 정보를 분리해서 사용하므로 명암대비(contrast)가 좋지 않은 영상을 좋게 만드는 데 대표적으로 활용됩니다.

다음 코드는 완전히 어두운 값과 완전히 밝은 값 그리고 중간 값을 BGR로 표현한 후에 YUV로 변환한 3개 채널을 살펴봅니다.

'''BGR에서 YUV로 변환'''
import cv2
import numpy as np

#---① BGR 컬러 스페이스로 3가지 밝기의 픽셀 생성
dark = np.array([[[0,0,0]]], dtype=np.uint8)         # 3 채널 모두 0인 가장 어두운 픽셀
middle = np.array([[[127,127,127]]], dtype=np.uint8) # 3 채널 모두 127인 중간 밝기 픽셀
bright = np.array([[[255,255,255]]], dtype=np.uint8) # 3 채널 모두 255인 가장 밝은 픽셀

#---② BGR 컬러 스페이스를 YUV 컬러 스페이스로 변환
dark_yuv = cv2.cvtColor(dark, cv2.COLOR_BGR2YUV)
middle_yuv = cv2.cvtColor(middle, cv2.COLOR_BGR2YUV)
bright_yuv = cv2.cvtColor(bright, cv2.COLOR_BGR2YUV)

#---③ YUV로 변환한 픽셀 출력
print("dark:", dark_yuv)
print("middle:", middle_yuv)
print("bright", bright_yuv)

[output]
dark: [[[  0 128 128]]]
middle: [[[127 128 128]]]
bright [[[255 128 128]]]

위 코드 ①에서 세 가지 밝기의 픽셀을 BGR 컬러 스페이스로 생성하고 나서 코드 ②에서 YUV 컬러 스페이스로 변환하고 출력합니다. 출력 결과에서 밝기 정도는 첫 번째 Y 채널에만 나타나는 것을 알 수 있습니다. 픽셀의 밝기를 제어해야 할 때 BGR 포맷은 3채널을 모두 연산해야 하지만, YUV 포맺은 Y채널 하나만 작업하면 되므로 효과적입니다.

Read more

OpenCV Image Processing 관심영역

|

해당 게시물은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저) 를 바탕으로 작성되었습니다.


관심영역

이미지에 어떤 연산을 적용해서 새로운 이미지나 정보를 얻어내려고 할 때 전체 이미지를 대상으로 연산을 하는 것보다는 관심이 있는 부분만 잘라내서 하는 것이 훨씬 효과적입니다. 예를 들어 어떤 사진에 찍힌 사람이 누군지 알고 싶다면 사진 전체를 분석하는 것보다 인물이 있는 부분이나 얼굴이 있는 부분만 잘라서 분석하면 훨씬 쉽고 빠를 것입니다.

이렇게 관심 있는 영역만 잘라내서 연산을 하면 단순히 연산할 데이터의 양을 줄이고 수행 시간을 단축시키는 이점도 있지만, 데이터의 양이 줄어 들면 그 형태도 단순해지므로 적용해야 하는 알고리즘도 단순해지는 이점도 있습니다. 또한, 이미지 연산은 항상 좌표를 기반으로 해야 하는데, 그 영역이 클 때보다 작을 때 좌표 구하기가 쉽다는 이점도 있습니다.

  • NumPy를 이용해서 관심영역을 지정할 때 주의해야 할 사항 두 가지
    1. NumPy 배열은 행(row), 열(column) 순으로 접근하므로 반드시 높이(height), 폭(width) 순으로 지정해야 합니다.
    2. NumPy 배열의 슬라이싱(slicing)과 Python의 리스트(list)의 슬라이싱 방식이 다릅니다.
      • 파이썬 리스트의 슬라이싱은 새로운 리스트 객체를 반환하는 데 반해, NumPy 배열의 슬라이싱은 원본의 참조를 반환합니다.
      • NumPy 배열 객체는 슬라이싱 연산해서 얻은 결과의 값을 수정하면 슬라이싱하기 전의 원본 배열 객체에도 똑같이 값이 달라집니다.
      • 만약 원본과는 무관한 새로운 작업을 하려면 반드시 슬라이싱 결과에 복제본을 생성해서 작업해야 합니다. 복제본은 copy() 함수로 만들 수 있습니다.

1. 관심영역 지정

전체 이미지에서 연산과 분석의 대상이 되는 영역만을 지정하고 떼어내는 것관심영역(Region Of Interest, ROI)을 지정한다고 합니다.

전체 이미지가 img라는 변수에 있을 때, 관심 있는 영역의 좌표가 x, y이고 영역의폭이 w, 높이가 h라고 하면 이것을 이용하여 관심영역을 지정하는 코드는 다음과 같습니다. img의 y행에서부터 y+h행까지, x열에서 x+w열까지를 슬라이싱한 것입니다.

roi = img[y:y+h, x:x+w]

image

위 사진은 부두의 일몰 사진인데, 일몰 중인 태양을 관심영역으로 지정하고 사각형으로 표시했습니다. 이미지에서 태양 영역의 시작 좌표는 x:320, y:150이고, 태양 영역의 크기는 50 × 50입니다. 앞의 결과를 나타내는 코드는 다음과 같습니다.

'''관심영역 지정'''
import cv2
import numpy as np

img = cv2.imread('./img/sunset.jpg')

x = 320; y = 150; w = 50; h = 50    # roi 좌표
roi = img[y:y+h, x:x+w]             # roi 지정 ---①

print(roi.shape)    # roi shape, (50,50,3)

cv2.rectangle(roi, (0,0), (h-1, w-1), (0,255,0)) # roi 전체에 사각형 그리기 ---②
cv2.imshow("img", img)

key = cv2.waitKey(0)
print(key)
cv2.destroyAllWindows()

위 코드 ①에서 관심영역을 지정하고 있습니다. 좌표만 알고 있다면 관심영역을 지정하는 것은 별로 어렵지 않습니다. 이렇게 관심영역을 지정하고 나서 해당 영역에 사각형을 표시하기 위한 코드는 ②에 나타납니다. 만약 관심영역을 따로 지정하지 않았다면 이 코드는 다음과 같게 됩니다.

cv2.rectangle(img, (x,y), (x+w,y+h), (0,255,0))

코드의 양은 별로 차이가 없지만, 사각형을 그리기 위한 좌표를 지정하는 것이 위 코드 ② 에서보다 불편해 보입니다. 그저 0에서부터 끝까지 지정했기 때문입니다.

여기에 다음 코드처럼 간단한 코드를 추가하면 지정한 관심영역을 원본 이미지에 추가해서 태양이 두 개로 보이게 하거나 지정한 관심영역만 새 창에 표시할 수 있습니다.

'''관심영역 복제 및 새 창 띄우기'''
import cv2
import numpy as np

img = cv2.imread('./img/sunset.jpg')

x = 320; y = 150; w = 50; h = 50
roi = img[y:y+h, x:x+w]     # roi 지정
img2 = roi.copy()           # roi 배열 복제 ---①

img[y:y+h, x+w:x+w+w] = roi # 새로운 좌표에 roi 추가, 태양 2개 만들기
cv2.rectangle(img, (x,y), (x+w+w, y+h), (0,255,0)) # 2개의 태양 영역에 사각형 표시

cv2.imshow("img", img)      # 원본 이미지 출력
cv2.imshow("roi", img2)     # roi 만 따로 출력

cv2.waitKey(0)
cv2.destroyAllWindows()

image

image

위의 코드 ① img2 = roi.copy() 은 관심영역으로 지정한 배열을 복제해서 새로운 배열을 생성합니다. 만약 copy() 함수로 복제본을 만들지 않았다면 새 창에 띄운 태양 그림에도 초록색 사각형이 그려지게 됩니다. 이렇게 관심영역을 지정할 때는 원본 배열의 슬라이싱만을 이용하는 것이 편리할 때도 있고 원본하고는 완전히 분리된 복제본을 사용해야 할 때도 있습니다.

image

2. 마우스로 관심영역 지정

관심영역을 지정하려면 시작 좌표와 크기(폭, 높이) 값이 꼭 필요한데, 매번 눈 대중으로 값을 지정하면서 찾아내는 것은 무척 피곤합니다. 이럴 때는 마우스를 이용해서 원하는 영역을 직접 지정하고 좌표를 알아내면 편리합니다. 마우스 이벤트를 적용하면 쉽게 구현할 수 있습니다.

다음 예제는 마우스 이벤트 처리를 적용해서 마우스로 관심영역을 지정하고 잘라낸 부분만 새 창에 표시하고 파일로 저장하는 예제입니다.

'''마우스로 관심영역 지정'''
import cv2
import numpy as np

isDragging = False                      # 마우스 드래그 상태 저장 
x0, y0, w, h = -1, -1, -1, -1           # 영역 선택 좌표 저장
blue, red = (255,0,0), (0,0,255)        # 색상 값 

def onMouse(event, x, y, flags, param): # 마우스 이벤트 핸들 함수  ---①
    global isDragging, x0, y0, img      # 전역변수 참조
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 마우스 버튼 다운, 드래그 시작 ---②
        isDragging = True
        x0 = x
        y0 = y
    elif event == cv2.EVENT_MOUSEMOVE:  # 마우스 움직임 ---③
        if isDragging:                  # 드래그 진행 중
            img_draw = img.copy()       # 사각형 그림 표현을 위한 이미지 복제
            cv2.rectangle(img_draw, (x0, y0), (x, y), blue, 2) # 드래그 진행 영역 표시
            cv2.imshow('img', img_draw) # 사각형 표시된 그림 화면 출력
    elif event == cv2.EVENT_LBUTTONUP:  # 왼쪽 마우스 버튼 업 ---④
        if isDragging:                  # 드래그 중지
            isDragging = False          
            w = x - x0                  # 드래그 영역 폭 계산
            h = y - y0                  # 드래그 영역 높이 계산
            print("x:%d, y:%d, w:%d, h:%d" % (x0, y0, w, h))
            if w > 0 and h > 0:         # 폭과 높이가 양수이면 드래그 방향이 옳음 ---⑤
                img_draw = img.copy()   # 선택 영역에 사각형 그림을 표시할 이미지 복제
                # 선택 영역에 빨간 사각형 표시
                cv2.rectangle(img_draw, (x0, y0), (x, y), red, 2) 
                cv2.imshow('img', img_draw) # 빨간 사각형 그려진 이미지 화면 출력
                roi = img[y0:y0+h, x0:x0+w] # 원본 이미지에서 선택 영역만 ROI로 지정 ---⑥
                cv2.imshow('cropped', roi)  # ROI 지정 영역을 새창으로 표시
                cv2.moveWindow('cropped', 0, 0) # 새창을 화면 좌측 상단에 이동
                cv2.imwrite('./cropped.jpg', roi) # ROI 영역만 파일로 저장 ---⑦
                print("croped.")
            else:
                cv2.imshow('img', img)  # 드래그 방향이 잘못된 경우 사각형 그림이 없는 원본 이미지 출력
                print("좌측 상단에서 우측 하단으로 영역을 드래그 하세요.")

img = cv2.imread('./img/wonyoung.jpg')
cv2.imshow('img', img)
cv2.setMouseCallback('img', onMouse) # 마우스 이벤트 등록 ---⑧
cv2.waitKey()
cv2.destroyAllWindows()

[output]
x:309, y:143, w:73, h:64
croped.

코드 ①에서 선언한 onMouse 함수를 코드 ⑧에서 마우스 콜백으로 등록합니다. onMouse 함수는 마우스를 조작할 때마다 호출되고 그중 세 가지 이벤트에 따라 분기합니다.

코드 ②는 마우스 왼쪽 버튼이 눌렸을 때 반응합니다. 처음 마우스를 드래그하는 지점을 x0, y0 전역변수에 저장하고 드래그가 시작되었다는 것을 기억하기 위해 isDragging 변수를 변경합니다.

마우스가 움직이면 코드 ③이 반응합니다. 왼쪽 마우스를 누른 상태에서 움직이는지를 확인하고 앞서 저장해 둔 드래그 시작 좌표로부터 파란색 사각형을 그립니다. 사각형을 그리기 전에 img_draw = img.copy() 코드로 원본 이미지를 복사하는 이유는 마우스가 움직일 때마다 사각형을 그리게 되는데, 매번 같은 이미지에 사각형을그리면 사각형이 누적된 채 그려져서 보기에 좋지 않기 때문입니다. 아무것도 그리지 않은 깨끗한 원본 이미지를 매번 새롭게 복사해서 거기에 사각형을 표시하고 화면에 표시합니다.

마지막으로 코드 ④는 가장 중요한 이벤트인 왼쪽 마우스 버튼을 손에서 뗄 때입니다. 실제로 원하는 영역을 모두 선택한 상태이므로 여기서 최종적인 좌표를 구해야 합니다. 드래그 상태를 저장해 놓은 isDragging 변수를 원래대로 되돌려 놓고, 최초 드래그가 일어난 지점인 x0, y0에서 현재의 x, y 좌표를 빼서 선택한 영역의 폭과 높이를 구합니다. 이렇게 구한 x, y, w, h 값으로 관심영역을 지정하면 됩니다. 본 예제에서는 관심영역에 빨간 사각형을 그리고, 관심영역을 새로운 창에 표시한 후 ‘cropped.jpg’라는 이름의 파일로 저장하였습니다. 이때에도 원본을 복사해서 거기에 빨간 사각형을 그려서 화면에 표시하고 실제 관심영역을 지정한 것은 원본 이미지입니다. 그렇지 않으면 따로 창을 띄워 표시한 관심영역과 저장한 그림 파일에도 빨간 사각형이 그려지기 때문입니다.

image

image

OpenCV 3이상 버전에서는 관심영역을 지정하기 위한 새로운 함수를 제공합니다. 이 함수를 사용하면 마우스 이벤트 처리를 위한 코드 없이도 마우스로 간단히 ROI를 지정할 수 있습니다.

  • ret = cv2.selectROI([win_name,] img[, showCrossHair=True, fromCenter=False])
    • win_name : ROI 선택을 진행할 창의 이름, str
    • img : ROI 선택을 진행할 이미지, NumPy ndarray
    • showCrossHair : 선택 영역 중심에 십자 모양 표시 여부
    • fromCenter : 마우스 시작 지점을 영역의 중심으로 지정
    • ret : 선택한 영역 좌표와 크기(x, y, w, h), 선택을 취소한 경우 모두 0

cv2.selectROI() 함수의 win_name에 창 이름을 지정하고 ROI 선택에 사용할 이미지를 img에 전달하면 마우스로 영역을 선택할 수 있습니다. 영역을 선택하고 나서 키보드의 스페이스 또는 엔터 키를 누르면 선택한 영역의 x, y 좌표와 영역의 폭과 높이를 튜플에 담아 반환합니다. 만약 선택을 취소하고 싶으면 키보드의 ‘c’ 키를 누르면 되는데, 이때에는 반환하는 모든 값이 0입니다.

'''selectROI로 관심영역 지정'''
import cv2,  numpy as np

img = cv2.imread('./img/wonyoung.jpg')

x, y, w, h = cv2.selectROI('img', img, False)
if w and h:
    roi = img[y:y+h, x:x+w]
    cv2.imshow('cropped', roi)  # ROI 지정 영역을 새창으로 표시
    cv2.moveWindow('cropped', 0, 0) # 새창을 화면 좌측 상단에 이동
    cv2.imwrite('./cropped2.jpg', roi)   # ROI 영역만 파일로 저장

cv2.imshow('img', img)
cv2.waitKey(0)
cv2.destroyAllWindows()

image

image

Read more

OpenCV를 위한 Matplotlib

|

해당 게시물은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저) 를 바탕으로 작성되었습니다.


Matplotlib

Matplotlib은 파이썬에서 가장 인기 있는 데이터 시각화 라이브러리입니다. 이것을 이용하면 도표나 차트 등을 손쉽게 그릴 수 있습니다. 이미지 프로세싱이나 컴퓨터비전 분야에서는 여러 이미지를 화면에 띄우고 싶을 때 OpenCV의 cv2.imshow() 함수를 여러 번 호출하면 창이 여러 개 열리므로 한 화면에 여러 이미지를 띄우려는 단순한 이유로 Matplotlib를 사용하는 경우가 가장 많습니다. 물론 그뿐만 아니라 이미지로부터 각종 히스토그램 등의 통계 자료를 뽑아내어 그래프나 다이어그램으로 표시하는 용도로도 많이 사용합니다.

Matplotlib의 Pyplot 모듈은 다양한 종류의 도표를 빠르고 쉽게 생성할 수 있는 함수들을 모아놓은 모듈입니다.

1. plot

그래프를 그리는 가장 간단한 방법은 plot() 함수를 사용하는 것입니다.

1차원 배열을 인자로 전달하면 배열의 인덱스를 x 좌표로, 배열의 값을 y 좌표로써서 그래프를 그립니다. 아래의 코드는 가장 간단한 방법으로 그래프를 그리는 예제입니다.

'''plot 그리기'''
import matplotlib.pyplot as plt
import numpy as np

a = np.array([2,6,7,3,12,8,4,5])    # 배열 생성
plt.plot(a)                         # plot 생성
plt.show()                          # plot 그리기


image

두 배열의 상관관계를 그래프로 표시하려면 plot() 함수의 인자로 배열을 순차적으로 전달하면 차례대로 x, y 좌표로 사용해서 그래프를 그립니다. 2개의 배열로 그래프로 표시하는 예시는 아래와 같습니다.

'''y=x^2 그래프 그리기'''
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(10) # 0,1,2,3,4,5,6,7,8,9
y = x**2          # 0,1,4,9,16,25,36,49,64,81
plt.plot(x,y)     # plot 생성       ---①
plt.show()        # plot 화면에 표시  ---②


image

위의 코드 ①에서 배열 x, y의 값으로 플롯을 생성하고, 코드 ②에서 화면에 표시합니다.

2. color와 style

그래프의 선에 색상과 스타일을 지정할 수 있습니다. plot() 함수의 마지막 인자에 아래의 색상 기호 중 하나를 선택해서 문자로 전달하면 색상이 적용됩니다.

  • 색상 기호
    • b : 파란색(Blue)
    • g : 초록색(Green)
    • r : 빨간색(Red)
    • c : 청록색(Cyan)
    • m : 자홍색(Magenta)
    • y : 노란색(Yellow)
    • k : 검은색(black)
    • w : 흰색(White)

다음 코드는 선을 빨간색으로 표시한 예제입니다.

'''plot의 색 지정'''
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(10) # 0,1,2,3,4,5,6,7,8,9
y = x **2         # 0,1,4,9,16,25,36,49,64,81
plt.plot(x, y, 'r') # plot 생성 ---①
plt.show()        # plot 화면에 표시


image

위의 코드 ①에서 plot(x, y, 'r') 로 빨간색을 지정하여 선을 그립니다.

색상과 함께 스타일도 지정할 수 있는데, 스타일 기호 중 하나를 색상 값에 이어 붙여서 사용합니다.

'''다양한 스타일 지정'''
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(10)
f1 = x * 5
f2 = x **2
f3 = x **2 + x*2

plt.plot(x,'r--')   # 빨강색 이음선
plt.plot(f1, 'g.')  # 초록색 점
plt.plot(f2, 'bv')  # 파랑색 역 삼각형
plt.plot(f3, 'ks' ) # 검정색 사각형
plt.show()


image

앞의 코드에서 다양한 색상과 스타일의 그래프를 표시하고 있습니다.

3. subplot

앞서 살펴본 예제는 여러 배열 값을 이용해서 plt.plot() 함수를 여러 번 호출하면 하나의 다이어그램에 겹쳐서 그래프를 그렸습니다. 각각의 그래프를 분리해서 따로 그려야 할 때는 plt.subplot() 함수를 이용합니다. 이 함수는 3개의 인자를 이용해서 몇 행 몇 열로 분할된 그래프에 몇 번째 그래프를 그릴지를 먼저 지정한 후에 plt.plot() 함수를 호출하면 그 자리에 그래프를 그리게 됩니다.

'''subplot'''
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(10)

plt.subplot(2,2,1)  # 2행 2열 중에 1번째
plt.plot(x,x**2)

plt.subplot(2,2,2)  # 2행 2열 중에 2번째
plt.plot(x,x*5)

plt.subplot(223)    # 2행 2열 중에 3번째
plt.plot(x, np.sin(x))

plt.subplot(224)    # 2행 2열 중에 4번째
plt.plot(x,np.cos(x))

plt.show()


image

subplot(2,2,1) 처럼 3개의 인자를 전달하는 것과 subplot(221) 처럼 세 자리 숫자 한개를 전달하는 것은 똑같이 동작합니다.

4. 이미지 표시

plt.plot() 대신에 plt.imshow() 함수를 호출하면 OpenCV로 읽어들인 이미지를 그래프 영역에 출력할 수 있습니다.

'''plot으로 이미지 출력'''
import cv2
from matplotlib import pyplot as plt

img = cv2.imread('./img/wonyoung.jpg')

plt.imshow(img) # 이미지 표시
plt.show()


image

앞 예제의 실행 결과는 색상이 이상합니다. plt.imshow() 함수는 컬러 이미지인 경우 컬러 채널을 R, G, B 순으로 해석하지만 OpenCV 이미지는 B, G, R순으로 만들어져서 색상의 위치가 반대라서 그렇습니다. 그래서 OpenCV로 읽은 이미지를 R, G, B 순으로 순서를 바꾸어서 plt.imshow() 함수에 전달해야 제대로 된 색상으로 출력할 수 있습니다.

'''컬러 채널을 변경한 이미지 출력'''
import cv2
from matplotlib import pyplot as plt

img = cv2.imread('./img/wonyoung.jpg')

plt.imshow(img[:,:,::-1])   # 이미지 컬러 채널 변경해서 표시 ---①
plt.xticks([])              # x좌표 눈금 제거 ---②     
plt.yticks([])              # y좌표 눈금 제거 ---③
plt.show()


image

위의 코드 ① img[:,:,::-1] 은 컬러 채널의 순서를 바꾸는 것인데, 이 코드의 의미는 이렇습니다. 3차원 배열의 모든 내용을 선택하는 것은 img[:,:,:] 입니다. 이때 마지막 축의 길이가 3이므로 다시 img[:,:,::] 로 바꾸어 쓸수 있습니다. 이때 마지막 축의 요소의 순서를 거꾸로 뒤집기 위해 img[:,:,::−1] 로 쓸 수 있습니다. 이것을 풀어서 작성하면 다음 두 가지 코드와 같습니다.

img [:,:,(2,1,0)]

또는

img[:,:,2], img[:,:,1], img[:,:,0] = img[:,:,0], img[:,:,0], img[:,:,2]

위의 코드 ②와 ③은 이미지 출력 결과 화면에 나타나는 x, y 좌표 눈금을 제거하기 위한 코드입니다. 단순히 이미지만 보여주려고 하는데 눈금이 신경 쓰이면 필요에 따라 적용하면 됩니다.

앞서 설명한 대로 프로그램의 결과로 이미지를 여러 개 출력해야 하는 경우, OpenCV의 cv2.imshow() 함수는 여러 번 호출하면 매번 새로운 창이 열리기 때문에 귀찮습니다. plt.imshow() 함수는 plt.subplot() 함수와 함께 사용하면 하나의 창에 여러개의 이미지를 동시에 출력할 수 있으니 이런 경우 좋은 대안이 될 수 있습니다.

'''여러 이미지 동시 출력'''
import matplotlib.pyplot as plt
import numpy as np
import cv2

img1 = cv2.imread('./img/wonyoung.jpg')
img2 = cv2.imread('./img/wonyoung2.png')
img3 = cv2.imread('./img/wonyoung3.jpg')


plt.subplot(1,3,1)  # 1행 3열 중에 1번째
plt.imshow(img1[:,:,(2,1,0)])
plt.xticks([]); plt.yticks([])

plt.subplot(1,3,2)  # 1행 3열 중에 2번째
plt.imshow(img2[:,:,(2,1,0)])
plt.xticks([]); plt.yticks([])

plt.subplot(1,3,3)  # 1행 3열 중에 3번째
plt.imshow(img3[:,:,(2,1,0)])
plt.xticks([]); plt.yticks([])

plt.show()


image

Read more