by museonghwang

OpenCV를 위한 NumPy 2

|

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


NumPy

NumPy는 OpenCV-Python 모듈의 필수 라이브러리기도 하지만, 데이터 과학 분야와 관련된 일을 파이썬 언어로 할 때 전반적으로 사용하는 라이브러리입니다.

9. 인덱싱과 슬라이싱

NumPy 배열은 파이썬의 리스트처럼 인덱스로 각 요소에 접근할 수 있습니다. 당연히 배열의 차원에 따라서 인덱스의 개수도 달라집니다.

>>> a = np.arange(10)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a[5]
5
>>> b = np.arange(12).reshape(3,4)
>>> b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> b[1]
array([4, 5, 6, 7])
>>> b[1, 2]
6

위 코드에서 배열 a는 1차원, b는 2차원입니다. 이때 2차원인 배열 b에 1개의 인덱스만 사용하면 1개의 행 모두가 선택됩니다. 2차원일 때는 인덱스 2개를 사용해서 열과 행을 지정해야 1개의 요소를 선택할 수 있습니다.

>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a[5] = 9
>>> a
array([0, 1, 2, 3, 4, 9, 6, 7, 8, 9])
>>> b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> b[0] = 0
>>> b
array([[ 0,  0,  0,  0],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> b[1,2] = 99
>>> b
array([[ 0,  0,  0,  0],
       [ 4,  5, 99,  7],
       [ 8,  9, 10, 11]])

위 코드에서 보여주는 것처럼 값의 변경도 마찬가지로 인덱스로 정확히 1개의 요소를 지정하면 1개의 요소만 변경되지만, 인덱스를 적게 지정해서 행 단위로 지정하면 브로드캐스팅 연산이 일어나서 해당 단위 모두를 같은 값으로 변경합니다.

인덱스 자리에 콜론(:)을 이용해서 범위를 지정하면 슬라이싱(slicing)을 할 수 있습니다. 이때 범위의 끝 인덱스는 슬라이싱 결과에 포함되지 않습니다. 시작과 끝 인덱스를 각각 생략하면 처음부터 끝까지라는 의미입니다.

>>> a = np.arange(10);
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a[2:5]
array([2, 3, 4])
>>> a[5:]
array([5, 6, 7, 8, 9])
>>> a[:]
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> b = np.arange(12).reshape(3,4)
>>> b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> b[0:2, 1]
array([1, 5])
>>> b[0:2, 1:3]
array([[1, 2],
       [5, 6]])
>>> b[2, :]
array([ 8, 9, 10, 11])
>>> b[:, 1]
array([1, 5, 9])
>>> b[0:2, 1:3] = 0
array([[ 0,  0,  0,  3],
       [ 4,  0,  0,  7],
       [ 8,  9, 10, 11]])

b[0:2, 1] 은 b 배열 0~1행의 1열 요소들을 의미하고, b[0:2, 1:3] 은 b 배열 0~1행의 1~2열 요소들을 의미합니다.

값의 할당은 앞서 살펴본 것처럼 브로드캐스팅 연산으로 이루어집니다. 파이썬 기본형인 리스트와의 가장 큰 차이점은 슬라이싱의 결과가 복제본이 아닌 원본이라는 것입니다. 흔히 다음 코드와 같이 슬라이싱으로 전체 배열 중 일부를 다른 변수에 할당하는 경우 별도의 배열로 착각하는 경우가 많습니다. 그러나 이는 슬라이싱 해당 영역에 대한 참조일 뿐 값의 변경은 원본에도 그대로 반영됩니다.

>>> b
array([[ 0,  0,  0,  3],
       [ 4,  0,  0,  7],
       [ 8,  9, 10, 11]])
>>> bb = b[0:2, 1:3]
>>> bb
array([[0, 0],
       [0, 0]])
>>> bb[0] = 99
>>> b
array([[ 0, 99, 99,  3],
       [ 4,  0,  0,  7],
       [ 8,  9, 10, 11]])

만약 파이썬 리스트처럼 복제본을 얻고 싶다면 ndarray.copy() 함수를 명시적으로 호출해야 합니다.

10. 팬시 인덱싱

배열 인덱스에 다른 배열을 전달해서 원하는 요소를 선택하는 방법팬시 인덱싱(fancy indexing)이라고 합니다. 전달하는 배열에 숫자를 포함하고 있으면 해당 인덱스에 맞게 선택되고, 배열에 불(boolean) 값을 포함하면 True인 값을 갖는 요소만 선택됩니다.

>>> a = np.arange(5)
>>> a
array([0, 1, 2, 3, 4])
>>> a[[1, 3]]
array([1, 3])
>>> a[[True, False, True, False, True]]
array([0, 2, 4])

NumPy 배열에 비교 연산을 하면 개별 요소들이 조건을 만족하는지를 알 수 있습니다. 반대로 불 값을 갖는 배열을 배열의 인덱스 대신 사용하면 True 값 위치의 값들만 얻을 수 있는데, 이 둘을 한번에 합해서 실행하면 원하는 조건의 값만을 얻을 수 있습니다.

>>> a = np.arange(10)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> b = a > 5
>>> b
array([False, False, False, False, False, False, True, True, True, True])
>>> a[b]
array([6, 7, 8, 9])
>>> a[a>5]
array([6, 7, 8, 9])
>>> a[a>5] = 1
>>> a
array([0, 1, 2, 3, 4, 5, 1, 1, 1, 1])

이때에도 새로운 값을 지정하면 브로드캐스팅 연산으로 해당 요소들 모두의 값을 바꿀 수 있습니다.

다차원인 경우 인덱스 배열도 다차원으로 지정할 수 있고, 이때는 교차하는 인덱스의 것이 선택됩니다.

>>> b = np.arange(12).reshape(3,4)
>>> b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> b[[0,2]]
array([[ 0,  1,  2,  3],
       [ 8,  9, 10, 11]])
>>> b[[0,2], [2,3]]
array([2, 11])

11. 병합과 분리

2개 이상의 NumPy 배열을 병합하는 방법은 크게 두 가지가 있습니다. 단순히 배열들을 이어 붙여서 크기를 키우는 방법새로운 차원을 만들어 서로서로 끼워넣는 방법입니다.

여기서부터는 축(axis)이라는 용어를 꼭 알아야 합니다. NumPy 배열의 shape 속성을 확인하면 튜플 형식으로 몇 개의 숫자가 나오는 것은 이미 알아보았습니다. 각 숫자의 개수는 차원을 의미하며, 맨 앞부터 0, 1, 2,… 순으로 축 번호를 사용합니다. 예를 들어 어느 배열의 shape가 (10,20,3)이면 3개의 축이 있고 10은 0번 축, 20은 1번 축, 3은 2번 축을 나타냅니다. 축을 기준으로 작업을 한다는 뜻은 바로 shape의 각 순서에 따라 작업을 한다는 뜻입니다.

여기서 알아볼 병합에 사용하는 함수는 다음과 같습니다.

  • numpy.hstack(arrays) : arrays 배열을 수평으로 병합
  • numpy.vstack(arrays) : arrays 배열을 수직으로 병합
  • numpy.concatenate(arrays, axis=0) : arrays 배열을 지정한 축 기준으로 병합
  • numpy.stack(arrays, axis=0) : arrays 배열을 새로운 축으로 병합
    • arrays : 병합 대상 배열(튜플)
    • axis : 작업할 대상 축 번호

위 함수 중에 일단 가장 손쉽게 쓸 수 있는 함수는 numpy.vstack()numpy.hstack() 입니다.

>>> a = np.arange(4).reshape(2,2)
>>> a
array([[0, 1],
       [2, 3]])
>>> b = np.arange(10, 14).reshape(2,2)
>>> b
array([[10, 11],
       [12, 13]])
>>> np.vstack((a,b))
array([[ 0,  1],
       [ 2,  3],
       [10, 11],
       [12, 13]])
>>> np.hstack((a,b))
array([[0, 1, 10, 11],
       [2, 3, 12, 13]])
>>> np.concatenate((a,b), 0)
array([[ 0,  1],
       [ 2,  3],
       [10, 11],
       [12, 13]])
>>> np.concatenate((a,b), 1)
array([[ 0, 1, 10, 11],
       [ 2, 3, 12, 13]])

위 코드는 2행 2열인 배열 a와 b를 numpy.vstack() 으로 수직 병합해서 4행 2열 배열로 만들고, numpy.hstack() 으로 수평 병합해서 2행 4열 배열로 만듭니다. numpy.vstack() 함수는 수직으로 배열을 병합하는데, numpy.concatenate() 함수에 축 번호로 0을 지정해서도 같은 결과를 얻을 수 있습니다. 2개의 (2,2) 배열의 0번 축 방향 그러니까 행 방향으로 병합하므로 (4,2)가 되게 하는 것입니다. hstack() 함수는 수평으로 배열을 병합하는데, concatenate() 함수에 축 번호로 1을 지정해서도 (2,4) 크기의 배열을 얻을 수 있습니다.

numpy.stack() 함수는 차원(축)이 새로 늘어나는 방법으로 병합을 하는데, 축 번호를 지정하지 않으면 0번을 의미하고, -1은 마지막 축 번호를 의미합니다.

>>> a = np.arange(12).reshape(4,3)
>>> b = np.arange(10, 130, 10).reshape(4,3)
>>> a
array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])
>>> b
array([[ 10, 20, 30],
       [ 40, 50, 60],
       [ 70, 80, 90],
       [100, 110, 120]])
>>> c = np.stack((a,b), 0)
>>> c.shape
(2, 4, 3)
>>> c
array([[[ 0,  1,  2],
        [ 3,  4,  5],
        [ 6,  7,  8],
        [ 9, 10, 11]],
        
       [[ 10, 20, 30],
        [ 40, 50, 60],
        [ 70, 80, 90],
        [100, 110, 120]]])

위 코드는 (4,3) 배열 2개를 np.stack((a, b), 0) 함수로 병합하고 있으므로 원래 4행 3열인 2차원 배열은 병합하고 나면 3차원이 되고 축 번호도 0, 1, 2로 3개가 됩니다. 이때 축 번호로 0을 전달하므로 맨 앞 축 번호가 새로 생성되어 (2, 4, 3)인 배열이 만들어집니다. 이때 축 번호를 생략하면 0과 같습니다.

다음은 같은 배열을 축 번호 1과 2로 각각 생성한 코드입니다.

>>> d = np.stack((a,b), 1)
>>> d.shape
(4, 2, 3)
>>> d
array([[[  0,   1,   2],
        [ 10,  20,  30]],
        
       [[  3,   4,   5],
        [ 40,  50,  60]],
        
       [[  6,   7,   8],
        [ 70,  80,  90]],
        
       [[  9,  10,  11],
        [100, 110, 120]]])
>>> e = np.stack((a,b), 2)
>>> e.shape
(4, 3, 2)
>>> e
array([[[ 0, 10],
        [ 1, 20],
        [ 2, 30]],

       [[ 3, 40],
        [ 4, 50],
        [ 5, 60]],
        
       [[ 6, 70],
        [ 7, 80],
        [ 8, 90]],
        
       [[ 9, 100],
        [10, 110],
        [11, 120]]])
>>> ee = np.stack((a,b), -1)
>>> ee.shape
(4, 3, 2)

위 코드는 앞서 사용했던 코드에서 (4,3) 인 배열 a와 b를 병합합니다. np.stack((a,b), 1) 로 병합할 때 전달한 축 번호는 1이므로 새로운 축은 1번 축에 추가되어 (4,2,3)이 되고, np.stack((a,b), 2) 로 병합할 때 축 번호는 2이므로 새로운 축은 2번 축에 추가되어 (4,3,2)가 됩니다. 이때 축 번호로 -1을 전달하면 마지막 축 번호,즉 여기서는 2와 같은 의미를 갖습니다.

이 함수들은 이미지 작업을 완료하고 작업 전과 후 이미지를 병합해서 나란히 출력할 때 자주 씁니다.

배열을 분리할 때 사용하는 함수는 아래와 같습니다.

  • numpy.vsplit(array, indice) : array 배열을 수평으로 분리
  • numpy.hsplit(array, indice) : array 배열을 수직으로 분리
  • numpy.split(array, indice, axis=0) : array 배열을 axis 축으로 분리
    • array : 분리할 배열
    • indice : 분리할 개수 또는 인덱스
    • axis : 기준 축 번호

indice 는 어떻게 나눌지를 정하는 인자인데, 정수 또는 1차원 배열을 사용할 수 있습니다. 정수를 전달하면 배열을 그 수로 나누고, 1차원 배열을 전달하면 나누고자 하는 인덱스로 사용합니다. indice 인자의 사용 예를 들면 아래와 같습니다.

>>> a = np.arange(12)
>>> a
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
>>> np.hsplit(a, 3)
[array([0, 1, 2, 3]), array([4, 5, 6, 7]), array([ 8, 9, 10, 11])]
>>> np.hsplit(a, [3,6])
[array([0, 1, 2]), array([3, 4, 5]), array([ 6, 7, 8, 9, 10, 11])]
>>> np.hsplit(a, [3,6,9])
[array([0, 1, 2]), array([3, 4, 5]), array([6, 7, 8]), array([ 9, 10, 11])]
>>> np.split(a, 3, 0)
[array([0, 1, 2, 3]), array([4, 5, 6, 7]), array([ 8, 9, 10, 11])]
>>> np.split(a, [3,6,9], 0)
[array([0, 1, 2]), array([3, 4, 5]), array([6, 7, 8]), array([ 9, 10, 11])]

위 코드에서 np.hsplit(a, 3) 은 12개의 요소를 갖는 배열을 수평으로 분리하는데, indice 항목에 3이 전달되었으므로 배열을 3개로 쪼개어 각 배열은 4개 요소씩 갖습니다.

np.hsplit(a, [3,6]) 은 3과 6을 배열로 표시했기 때문에 인덱스로 사용합니다. 이것은 [0:3], [3:6], [6:] 과 같은 의미입니다. 위 코드에서처럼 나누고 싶은 구역의 인덱스를 좀 더 자세히 전달하려면 np.hsplit(a, [3,6,9]) 와 같이 전달할 수도 있습니다.

위 코드에서는 1차원 배열을 사용했으므로 numpy.hsplit() 함수만 사용할 수 있습니다. 1차원인 경우 축 번호도 1개, 즉 0만 사용할 수 있으므로 numpy.split() 함수에서도 축(axis)에 사용할 수 있는 값은 0뿐이며, 같은 결과를 반환합니다.

>>> b = np.arange(12).reshape(4,3)
>>> b
array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])
>>> np.vsplit(b, 2)
[array([[ 0,  1,  2],
        [ 3,  4,  5]]),
 array([[ 6,  7,  8],
        [ 9, 10, 11]])]
>>> np.split(b, 2, 0)
[array([[ 0,  1,  2],
        [ 3,  4,  5]]),
 array([[ 6,  7,  8],
        [ 9, 10, 11]])]
>>> np.hsplit(b, [1])
[array([[0],
        [3],
        [6],
        [9]]),
 array([[ 1, 2],
        [ 4, 5],
        [ 7, 8],
        [10, 11]])]
>>> np.split(b, [1], 1)
[array([[0],
        [3],
        [6],
        [9]]),
 array([[ 1, 2],
        [ 4, 5],
        [ 7, 8],
        [10, 11]])]

위 코드는 나누려는 배열이 2차원일 때 numpy.vsplit() 를 사용한 것과 numpy.split() 에 축 번호 0을 지정했을 때 같은 결과가 나오는 것을 보여주고 있습니다. 배열의 shape가 (4,3)이므로 vsplit(b, 2) 는 수직으로 나누어 4행을 2로 나누어 2행씩 갖게하고, split(b, 2, 0) 함수는 0번 축을 기준으로 2로 나누게 됩니다.

4행 3열 배열을 수평으로 나누는 것은 1번 축으로 나누는 것입니다. 3을 2로 나눌수 없으므로 특정 인덱스로 지정해서 나눠보면 indice 인자에 [1] 을 지정한 것은 [1:] 과 같고 numpy.hsplit() 함수를 쓴 것과 numpy.split() 함수에 1을 전달한 결과는 같습니다.

12. 검색

NumPy 배열을 사용하는 이유는 수많은 데이터를 쉽고 빠르게 다루려는 이유가 가장 크며, 이미지 작업도 마찬가지입니다. 그래서 NumPy를 쓰다 보면 배열 안에서 관심 있는 데이터만을 찾거나 찾아서 바꾸는 일이 자주 필요합니다. 이와 관련한 함수는 다음과 같습니다.

  • ret = numpy.where(condition [, t, f]) : 조건에 맞는 요소를 찾기
    • ret : 검색 조건에 맞는 요소의 인덱스 또는 변경된 값으로 채워진 배열(튜플)
    • condition : 검색에 사용할 조건식
    • t, f : 조건에 따라 지정할 값 또는 배열, 배열의 경우 조건에 사용한 배열과 같은 shape
      • t : 조건에 맞는 값에 지정할 값이나 배열
      • f : 조건에 틀린 값에 지정할 값이나 배열
  • numpy.nonzero(array) : array에서 요소 중에 0(영, zero)이 아닌 요소의 인덱스들을 반환(튜플)
  • numpy.all(array [, axis]) : array의 모든 요소가 True인지 검색
    • array : 검색 대상 배열
    • axis : 검색할 기준 축, 생략하면 모든 요소 검색, 지정하면 축 개수별로 결과 반환
  • numpy.any(array [, axis]) : array의 어느 요소이든 True가 있는지 검색

아래 코드는 배열에서 조건에 맞는 인덱스를 찾아오는 사례, 그리고 찾은 값을 새로운 값으로 변경한 배열을 구하는 사례를 보여줍니다.

>>> a = np.arange(10, 20)
>>> a
array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
>>> np.where(a > 15)
(array([6, 7, 8, 9]),)
>>> np.where(a > 15, 1, 0)
array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1])
>>> a
array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])

위 코드 np.where(a > 15) 는 10부터 19까지 값을 갖는 배열 a에서 15보다 큰 값을 갖는 요소의 인덱스를 구합니다. np.where(a > 15, 1, 0) 은 15보다 큰 값을 1로 채우고 그렇지 않은 값은 0으로 채운 새로운 배열을 구합니다.

만약 조건에 맞는 요소만 특정한 값으로 변경하고 맞지 않는 요소는 기존 값을 그대로 갖게 하려면 다음과 같은 코드로 할 수 있습니다.

>>> a
array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
>>> np.where(a > 15, 99, a)
array([10, 11, 12, 13, 14, 15, 99, 99, 99, 99])
>>> np.where(a > 15, a, 0)
array([0, 0, 0, 0, 0, 0, 16, 17, 18, 19])
>>> a
array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])

위 코드는 조건에 맞거나 틀린 경우에 할당할 값으로, 원래의 검색 대상 배열을 그대로 지정해서 조건에 맞는 값만 새로운 값으로 지정하거나 그 반대로도 가능합니다. 결과는 새로운 배열을 반환하므로 원본 배열은 그대로 유지됩니다.

다차원 배열인 경우 원하는 요소를 검색만 한다면 해당하는 요소의 인덱스는 여러 개를 반환합니다.

>>> b = np.arange(12).reshape(3,4)
>>> b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> coords = np.where(b>6)
>>> coords
(array([1, 2, 2, 2, 2]), array([3, 0, 1, 2, 3]))
>> np.stack((coords[0], coords[1]), -1)
array([[1, 3],
       [2, 0],
       [2, 1],
       [2, 2],
       [2, 3]])

위 코드는 3행 4열의 배열에서 6보다 큰 수만 검색하는 코드인데, 검색 결과는 행 번호(axis=0)만 갖는 배열과 열 번호(axis=1)만 갖는 배열 2개를 반환합니다. 이때 따로 떨어진 2개의 배열을 짝지어진 좌표 (x,y) 모양으로 얻으려면 앞서 살펴본 stack() 함수를 이용해서 병합하면 됩니다. 위 코드 np.stack((coords[0], coords[1]), -1) 에서 -1은 1로 바꾸어도 같습니다.

배열 요소 중에 0(zero)이 아닌 요소를 찾을 때는 numpy.nonzero() 함수를 사용할 수 있습니다. 이 함수는 0이 아닌 요소의 인덱스를 배열로 만들어서 반환합니다.

>>> z = np.array([0,1,2,0,1,2])
>>> np.nonzero(z)
(array([1, 2, 4, 5]),)
>>> zz = np.array([[0,1,2], [1,2,0], [2,0,1]])
>>> zz
array([[0, 1, 2],
       [1, 2, 0],
       [2, 0, 1]])
>>> coords = np.nonzero(zz)
>>> coords
(array([0, 0, 1, 1, 2, 2]), array([1, 2, 0, 1, 0, 2]))
>>> np.stack((coords[0], coords[1]), -1)
array([[0, 1],
       [0, 2],
       [1, 0],
       [1, 1],
       [2, 0],
       [2, 2]])

다차원 배열인 경우 numpy.where() 함수와 마찬가지로 차원 수만큼의 배열로 반환하므로 앞서 설명한 방법대로 필요에 따라 numpy.stack() 함수로 병합하여 좌표 꼴로 만들 수 있습니다.

numpy.nonzero() 함수는 True나 False 같은 불 값에 대해서는 False를 O(영, zero)으로 간주하고 동작하므로 numpy.where() 함수처럼 조건을 만족하는 요소의 인덱스를 찾을 수도 있습니다.

>>> a
array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
>>> np.nonzero(a > 15)
(array([6, 7, 8, 9]),)
>>> np.where(a > 15)
(array([6, 7, 8, 9]),)
>>> b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> np.nonzero(b > 6)
(array([1, 2, 2, 2, 2]), array([3, 0, 1, 2, 3]))
>>> np.where(b > 6)
(array([1, 2, 2, 2, 2]), array([3, 0, 1, 2, 3]))

NumPy 배열에 모든 요소가 참 또는 거짓인지 확인할 때는 all() 함수를 사용할 수 있습니다.

>>> t = np.array([True, True, True])
>>> np.all(t)
True
>>> t[1] = False
>>> t
array([ True, False, True])
>>> np.all(t)
False

위 코드에서 배열의 모든 요소가 True일 때는 np.all(t) 가 True를 반환하지만 1개의 요소를 False로 바꾸자 그 결과가 False로 나타나는 것을 보여줍니다.

>>> tt = np.array([[True, True], [False, True], [True, True]])
>>> tt
array([[ True, True],
       [False, True],
       [ True, True]])
>>> np.all(tt, 0)
array([False, True])
>>> np.all(tt, 1)
array([True, False, True])

all() 함수에 축(axis) 인자를 지정하지 않으면 모든 요소에 대해서 True를 만족하는지 검색하지만, 축 인자를 지정하면 해당 축을 기준으로 True를 만족하는지 반환합니다.

NumPy 배열에 조건식 연산을 하면 True, False를 갖는 배열이 생성되는 것을 앞서 “연산”에서 살펴보았습니다. 이것을 numpy.all()numpy.where() 함수를 이용하면 2개의 배열이 서로 같은지 다른지, 다르다면 어느 항목이 다른지를 찾을 수 있습니다.

>>> a = np.arange(10)
>>> b = np.arange(10)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> b
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a==b
array([ True, True, True, True, True, True, True, True, True, True])
>>> np.all(a==b)
True
>>> b[5] = -1
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> b
array([0, 1, 2, 3, 4, -1, 6, 7, 8, 9])
>>> np.all(a==b)
False
>>> np.where(a==b)
(array([0, 1, 2, 3, 4, 6, 7, 8, 9]),)
>>> np.where(a!=b)
(array([5]), )

위 코드에서 배열 a와 b는 처음에는 동일하지만 b[5] = -1 연산으로 다르게 했습니다. np.all(a==b) 연산으로 두 배열이 서로 같은 값으로 채워졌는지 아닌지를 확인할 수 있고, 만약 아니라면 np.where(a!=b) 로 다른 요소의 인덱스를 찾을 수 있습니다.

이미지 작업에서는 이전 프레임과 다음 프레임 간의 픽셀 값의 변화가 있는지, 변화가 있는 픽셀의 위치가 어디인지를 찾는 방법으로 움직임을 감지하거나 객체 추적과 같은 작업을 하는 데 이 함수들을 사용합니다.

13. 기초 통계 함수

배열의 값이 하나하나를 확인할 수 없을 만큼 많을 때는 평균, 최대 값, 최소 값 같은 통계값들이 의미 있는 정보가 될 때가 많습니다. 대표적인 함수는 다음과 같습니다.

  • numpy.sum(array [, axis]) : 배열의 합계 계산
  • numpy.mean(array [, axis]) : 배열의 평균 계산
  • numpy.amin(array [, axis]) : 배열의 최소 값 계산
  • numpy.min(array [, axis]) : numpy.amin()과 동일
  • numpy.amax(array [, axis]) : 배열의 최대 값 계산
  • numpy.max(array [, axis]) : numpy.amax()와 동일
    • array : 계산의 대상 배열
    • axis : 계산 기준 축, 생략하면 모든 요소를 대상
>>> a = np.arange(12).reshape(3, 4)
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> np.sum(a)
66
>>> np.sum(a, 0)
array([12, 15, 18, 21])
>>> np.sum(a, 1)
array([ 6, 22, 38])

위 코드는 0부터 11까지 값으로 갖는 배열을 3행 4열로 만들었습니다. np.sum(a) 함수는 모든 요소 값의 합계를 내줍니다. 축 번호를 지정하면 행과 열을 기준으로 각각 합산하는 것을 알 수 있습니다.

다음 코드는 같은 방법으로 평균, 최소 값, 최대 값을 각각 구하고 있습니다.

>>> np.mean(a)
5.5
>>> np.mean(a, 0)
array([4., 5., 6., 7.])
>>> np.mean(a, 1)
array([1.5, 5.5, 9.5])
>>> np.amin(a)
0
>>> np.amin(a, 0)
array([0, 1, 2, 3])
>>> np.amin(a, 1)
array([0, 4, 8])
>>> np.amax(a)
11
>>> np.amax(a, 0)
array([ 8, 9, 10, 11])
>>> np.amax(a, 1)
array([ 3, 7, 11])

다음 코드는 amin()min(), amax()max() 함수가 내부적으로 동일하다는 것을 보여주고 있습니다. 따라서 각각의 함수는 어느 것을 사용하든 차이가 없습니다.

>>> np.amin is np.min
True
>>> np.max is np.amax
True

14. 이미지 생성

지금까지 NumPy를 사용하기 위한 기본적인 내용을 살펴보았습니다. 다음 코드는 지금까지 다룬 지식으로 간단한 이미지를 생성해 보는 사례입니다.

'''NumPy 배열로 체크무늬 그레이 스케일 이미지 생성(np_gray.py)'''
import cv2
import numpy as np

img = np.zeros((120,120), dtype=np.uint8)   # 120x120 2차원 배열 생성, 검은색 흑백 이미지
img[25:35, :] = 45                          # 25~35행 모든 열에 45 할당 
img[55:65, :] = 115                         # 55~65행 모든 열에 115 할당 
img[85:95, :] = 160                         # 85~95행 모든 열에 160 할당 
img[:, 35:45] = 205                         # 모든행 35~45 열에 205 할당 
img[:, 75:85] = 255                         # 모든행 75~85 열에 255 할당 

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


image

위 코드는 120 × 120 크기의 2차원 배열을 dtype은 uint8로 생성했습니다. 2차원 배열이고 가질 수 있는 값은 0~255 사이가 됩니다. OpenCV에서 이미지를 표현하기 위한 NumPy 배열은 반드시 dtype이 uint8이어야 합니다.

0은 검은색을 의미하고 255는 흰색을 의미하므로 그 사이의 값은 값이 커질수록 밝은 색을 나타내고 값이 작아질수록 어두운 색을 표현하는 1채널 그레이 스케일 이미지를 표현할 수 있습니다. 완전히 검은 바탕에 행 단위와 열 단위로 점점 밝은 값을 지정해서 체크무늬 이미지를 만들었습니다.

'''NumPy 배열로 체크무늬 BGR 스케일 이미지 생성(np_bgr.py)'''
import cv2
import numpy as np

img = np.zeros((120,120, 3), dtype=np.uint8)    # 120x120 2차원 배열 생성, 3채널 컬러 이미지
img[25:35, :] = [255,0,0]                       # 25~35행 모든 열에 [255,0,0], 파랑색 할당 
img[55:65, :] = [0, 255, 0]                     # 55~65행 모든 열에 [0,255,0], 초록색 할당 
img[85:95, :] = [0,0,255]                       # 85~95행 모든 열에 [0,0,255], 빨강색 할당 
img[:, 35:45] = [255,255,0]                     # 모든행 35~45 열에 [255,255,0], 하늘색 할당 
img[:, 75:85] = [255,0,255]                     # 모든행 75~85 열에 [255,0,255], 분홍색 할당 

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


image

다음 코드는 np.zeros((120,120,3), dtype=np.uint8) 로 120행, 120열 크기의 3개의 채널을 갖는 3차원 배열을 생성했습니다. 3개의 채널은 각각 B(Blue, 파랑), G(Green, 초록), R(Red, 빨강) 값을 나타내는 0~255 사이의 값을 나타냅니다. 앞 코드와 같은 방법으로 3개의 행과 2개의 열 단위로 10픽셀씩 색상을 지정해서 검은 바탕에 여러 가지 색상의 체크무늬 이미지를 생성했습니다.

Read more

OpenCV를 위한 NumPy 1

|

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


NumPy

NumPy는 OpenCV-Python 모듈의 필수 라이브러리기도 하지만, 데이터 과학 분야와 관련된 일을 파이썬 언어로 할 때 전반적으로 사용하는 라이브러리입니다.

1. 이미지와 NumPy

OpenCV에서 이미지나 동영상을 읽어들이는 함수 cv2.imread() 는 NumPy 배열을 반환합니다. 따라서 OpenCV를 파이썬 언어로 프로그래밍한다는 것은 NumPy 배열을 다룬다는 것과 같은 말입니다. 그만큼 NumPy에 대해 잘 알지 못하면 파이썬 언어로 OpenCV를 사용하는 게 불가능하다는 뜻이기도 합니다.

NumPy 배열에서 정보를 얻는 기본 속성은 다음과 같습니다.

  • ndim : 차원(축)의 수
  • shape : 각 차원의 크기(튜플)
  • size : 전체 요소의 개수, shape의 각 항목의 곱
  • dtype : 요소의 데이터 타입
  • itemsize : 각 요소의 바이트 크기

이미지는 여러 개의 픽셀들의 값으로 구성되므로 수많은 행과 열로 구성된 픽셀 데이터의 모음이라고 볼 수 있습니다. 이 픽셀 데이터들을 프로그래밍 영역에서 다루려면 픽셀 값들을 저장하고 관리할 적절한 자료구조가 필요하기 마련입니다. OpenCV-Python은 예전에는 독자적인 자료구조를 사용했지만, 버전 2부터 NumPy라이브러리의 ndarray(N-Dimensional Array)를 가져다 쓰고 있습니다.

다음 코드는 파이썬 대화형 콘솔에서 OpenCV로 읽어들인 500 × 500 픽셀 이미지정보를 담은 NumPy 배열의 속성 정보를 출력합니다.

>>> import cv2
>>> img = cv2.imread('./img/blank_500.jpg') # OpenCV로 이미지 읽기
>>> type(img) # img의 데이터 타입
<class 'numpy.ndarray'>

ndarray는 N-Dimensional Array의 약자로 N차원 배열, 즉 다차원 배열을 의미합니다. OpenCV는 기본적으로 이미지를 3차원 배열, 즉 ‘행 × 열 × 채널’ 로 표현합니다. 행과 열은 이미지의 크기, 즉 높이와 폭만큼의 길이를 갖고 채널은 컬러인 경우 파랑, 초록, 빨강 3개의 길이를 갖습니다. 따라서 일반적인 이미지를 읽었을 때 3차원 배열은 ‘높이 x 폭 × 3’의 형태입니다.

>>> img.ndim  # 배열의 차원 수
3
>>> img.shape # 각 차원의 크기
(500, 500, 3)
>>> img.size  # 전체 요소의 갯수
750000

위의 코드를 통해 3차원이고 배열이 500 × 500 × 3인 것을 확인할 수 있습니다. img.size 는 전체 요소의 개수로 각 차원의 길이를 곱한 값과 같습니다.


image

파이썬 언어는 데이터 타입이나 데이터 크기를 따로 지정하지 않지만, 수많은 데이터를 처리해야 하는 NumPy 배열은 명시적인 데이터 타입을 지정하는 것이 효과적일 수밖에 없습니다. 이미지 픽셀 데이터는 음수이거나 소수점을 갖는 경우가 없고 값의 크기도 최대 255이므로 부호 없는 8비트, 그러니까 uint8을 데이터 타입으로 사용합니다. 다음과 같이 dtype 속성으로 데이터 타입을 확인할 수 있습니다.

>>> img.dtype    # 데이터 타입
dtype('uint8')
>>> img.itemsize # 각 요소의 바이트 크기
1

img.itemsize 결과 값은 각 요소의 크기가 1바이트인 것을 나타냅니다.

NumPy 배열은 모든 요소에 동일한 연산을 수행하는 브로드캐스팅 연산, 선형대수학의 벡터와 행렬 연산, 푸리에 변환 등 이미지 프로세싱이나 컴퓨터 비전 분야에 활용할 수 있는 방대한 기능을 제공하므로 OpenCV 라이브러리의 도움 없이도 컴퓨터 비전 작업을 할 수 있을 정도입니다. 실제로 OpenCV의 함수와 NumPy의 함수 기능이 중복되는 것도 많습니다.

물론, 영상 처리와 컴퓨터 비전 알고리즘이 매우 복잡하고 구현하기 어렵기도 하고 자잘한 실수로 성능에 문제가 있는 경우도 많으므로 NumPy로 직접 구현하는 것보다는 최적화된 OpenCV 라이브러리를 쓰는 것이 효과적입니다. 하지만, OpenCV에 따로 구현되지 않은 간단한 연산은 NumPy 기능을 이용해서 직접 처리해야 하는 경우가 많으니 기초적인 NumPy 사용법은 반드시 알고 있어야 합니다.

2. NumPy 배열 생성

NumPy 배열을 만드는 방법은 값을 가지고 생성하는 방법크기만 지정해서 생성하는 방법으로 나누어 지며, 크기만 지정해서 생성하는 방법은 다시 특정한 초기 값을 모든 요소에 지정하는 경우값의 범위를 지정해서 순차적으로 증가 또는 감소하는 값을 갖게 하는 방법으로 나눌 수 있습니다.

다음 목록은 NumPy 배열 생성에 사용할 함수들입니다.

  • 값으로 생성 : array()
  • 초기 값으로 생성 : empty(), zeros(), ones(), full()
  • 기존 배열로 생성 : empty_like(), zeros_like(), ones_like(), full_like()
  • 순차적인 값으로 생성 : arange()
  • 난수 생성 : random.rand(), random.randn()

3. 값으로 생성

배열 생성에 사용할 값을 가지고 있는 경우에는 numpy.array() 함수로 간단히 생성할 수 있습니다.

  • numpy.array(list [, dtype]) : 지정한 값들로 NumPy 배열 생성
    • list : 배열 생성에 사용할 값을 갖는 파이썬 리스트 객체
    • dtype : 데이터 타입(생략하면 값에 의해 자동 결정)
      • int8, int16, int32, int64 : 부호 있는 정수
      • uint8, uint16, uint32, uint64 : 부호 없는 정수
      • float16, float32, float64, float128 : 부동 소수점을 갖는 실수
      • complex64, complex128, complex256 : 부동 소수점을 갖는 복소수
      • bool : 불(boolean)

배열을 생성할 때 dtype을 지정하지 않으면 리스트의 항목 값에 따라 자동으로 결정됩니다. dtype 종류는 위에 나열한 것보다 많습니다. OpenCV에서 주로 사용하는 dtype은 uint8, int8, float32 정도가 전부이므로 굳이 모두 외울 필요는 없습니다.

이제 numpy.array() 함수로 파이썬 리스트에 값을 지정해서 생성하는 코드를 작성해 보겠습니다.

import numpy as np

a = np.array([1,2,3,4])     # 정수를 갖는 리스트로 생성
b = np.array([[1,2,3,4],    # 2차원 리스트로 생성
              [5,6,7,8]])
c = np.array([1,2,3.14,4])  # 정수와 소수점이 혼재된 리스트
d = np.array([1,2,3,4], dtype=np.float32)   # dtype을 지정해서 생성

print(a, a.dtype, a.shape)  # ---①
print(b, b.dtype, b.shape)  # ---②
print(c, c.dtype, c.shape)  # ---③
print(d, d.dtype, d.shape)  # ---④

[output]
[1 2 3 4] int64 (4,)
[[1 2 3 4]
 [5 6 7 8]] int64 (2, 4)
[1.   2.   3.14 4.  ] float64 (4,)
[1. 2. 3. 4.] float32 (4,)

①, ②의 경우 배열을 생성할 때 dtype을 따로 지정하지 않았지만, 리스트 항목이 모두 정수라서 알아서 int64가 된 것을 볼 수 있습니다. 또한 ③의 경우 정수와 소수점이 있는 실수를 섞어서 배열을 만들었더니 dtype은 float64로 알아서 정해졌습니다. 그리고 ④는 정수만을 가지고 배열을 만들었지만 명시적으로 dtype=np.float32 로 지정해서 데이터 타입이 float32 입니다.

명시적인 dtype을 지정해서 생성해야 할 때를 위해서 NumPy 생성함수는 dtype과 동일한 이름의 함수를 제공합니다. 예를 들어 dtype=np.uint8 로 생성하려고 한다면 아래와 같이 할 수 있습니다.

>>> a = np.uint8([1,2,3,4])

4. 크기와 초기 값으로 생성

우리는 NumPy 배열 생성에 사용할 값을 가지고 있지 않는 경우가 더 많습니다. 특히 이미지 작업을 할 때 픽셀 값을 타이핑해서 입력한다는 것은 거의 있을 수 없는 일입니다. NumPy 배열을 생성할 때 더 많이 사용하는 방법은 배열의 차수와 크기 그리고 초기 값을 지정해서 생성하는 방법입니다. 이때 쓸 수 있는 함수는 초기 값을 지정하는 방법에 따라 여러 가지가 있는데, 튜플로 차수와 크기를 지정하는 방법은 모두 같습니다.

  • numpy.empty(shape [, dtype]) : 초기화되지 않은 값(쓰레기 값)으로 배열 생성
    • shape : 튜플, 배열의 각 차수의 크기 지정
  • ndarray.fill(value) : 배열의 모든 요소를 value로 채움
  • numpy.zeros(shape [, dtype]) : O(영, zero)으로 초기화된 배열 생성
  • numpy.ones(shape [, dtype]) : 1로 초기화된 배열 생성
  • numpy.full(shape, fill_value [, dtype]) : fill_value로 초기화된 배열 생성

아래 코드는 numpy.empty() 함수의 사용 사례를 보여주고 있습니다.

>>> a=np.empty((2,3))
>>> a
array([[0.00000000e+000, 0.00000000e+000, 2.31730644e-314],
       [2.12709886e-314, 2.31869532e-314, 2.13584050e-314]])
>>> a.dtype
dtype('float64')

위의 코드에서 보는 것처럼 2행 3열 배열이 만들어졌지만 초기 값은 제각각이고, dtype은 float64가 기본 값으로 사용된 것을 알 수 있습니다. 쓰레기 값을 갖는 배열이므로 어떤 값으로 초기화를 하고 싶으면 fill() 함수를 사용하는 것이 좋습니다.

>>> a.fill(255)
>>> a
array([[255., 255., 255.],
       [255., 255., 255.]])

이렇게 배열을 만들고 어떤 특정한 값으로 모든 요소를 초기화하는 일이 많을 텐데, 이런 작업을 한번에 해주는 함수가 zeros(), ones(), full() 입니다. 함수의 이름만 봐도 알 수 있듯이 초기화해 주는 값만 서로 다릅니다.

>>> b=np.zeros( (2,3) )
>>> b
array([[0., 0., 0.],
       [0., 0., 0.]])
>>> b.dtype
dtype('float64')

zeros() 함수로 생성한 2열 3행 배열은 모두 0으로 채워져 있지만, dtype을 따로 지정하지 않아서 역시 float64로 생성된 것을 알 수 있습니다.

>>> c=np.zeros((2,3), dtype=np.int8)
>>> c
array([[0, 0, 0],
       [0, 0, 0]], dtype=int8)

위의 코드처럼 생성할 때 원하는 dtype을 명시적으로 지정할 수 있습니다.

>>> d=np.ones((2,3), dtype=np.int16)
>>> d
array([[1, 1, 1],
       [1, 1, 1]], dtype=int16)

위 코드는 1로 채워진 배열을 만듭니다.

>>> e=np.full((2,3,4), 255, dtype=np.uint8)
>>> e
array([[[255, 255, 255, 255],
        [255, 255, 255, 255],
        [255, 255, 255, 255]],
    
       [[255, 255, 255, 255],
        [255, 255, 255, 255],
        [255, 255, 255, 255]]], dtype=uint8)

0(영)이나 1이 아닌 다른 원하는 초기 값을 지정하고 싶을 때는 앞의 코드처럼 full() 함수를 쓸 수 있습니다.

새로운 배열을 생성할 때 기존에 있던 배열과 같은 크기의 배열을 만들어야 할 때도 있는데, 그때 사용하는 함수는 다음과 같습니다.

  • empty_like(array [, dtype]) : 초기화되지 않은, array와 같은 shape와 dtype의 배열 생성
  • zeros_like(array [, dtype]) : O(영, zero)으로 초기화된, array와 같은 shape와 dtype의 배열 생성
  • ones_like(array [, dtype]) : 1로 초기화된, array와 같은 shape와 dtype의 배열 생성
  • full_like(array, fill_value [, dtype]) : fill_value로 초기화된, array와 같은 shape와 dtype의 배열 생성

주로 이미지를 읽어서 필요한 연산을 한 후에 결과 이미지를 생성할 때 원본 이미지와 동일한 크기의 배열을 생성해야 하는 경우가 많은데, 이때 이런 함수들을 자주 씁니다.

>>> img = cv2.imread('./img/girl.jpg')
>>> img
array([[[ 54,  70,  76],
        [ 47,  67,  70],
        [ 41,  68,  65],
        ...
        [ 24,  72,  54],
        [ 31,  76,  59],
        [ 35,  80,  63]],
        ...
        [223, 193, 206],
        [226, 196, 207],
        [229, 199, 210]]], dtype=uint8)
>>> img.shape
(293, 406, 3)

위 코드는 사진을 배열로 읽어들입니다. 이 이미지와 동일한 크기의 배열을 생성하는 코드를 작성하면 다음과 같습니다.

>>> a = np.empty_like(img)
>>> b = np.zeros_like(img)
>>> c = np.ones_like(img)
>>> d = np.full_like(img, 255)
>>> a
array([[[ 54,  70,  76],
        [ 47,  67,  70],
        [ 41,  68,  65],
        ...
        [223, 193, 206],
        [226, 196, 207],
        [229, 199, 210]]], dtype=uint8)
>>> a.shape
(293, 406, 3)

>>> b
array([[[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0],
        ...
        [0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]]], dtype=uint8)
>>> b.shape
(293, 406, 3)

>>> c
array([[[1, 1, 1],
        [1, 1, 1],
        [1, 1, 1],
        ...
        [1, 1, 1],
        [1, 1, 1],
        [1, 1, 1]]], dtype=uint8)
>>> c.shape
(293, 406, 3)

>>> d
array([[[255, 255, 255],
        [255, 255, 255],
        [255, 255, 255],
        ...
        [255, 255, 255],
        [255, 255, 255],
        [255, 255, 255]]], dtype=uint8)
>>> d.shape
(293, 406, 3)

실행시켜보면 원본 이미지와 동일한 shape와 dtype을 갖지만 초기화된 값만 다릅니다.

5. 시퀀스와 난수로 생성

NumPy 배열을 생성하는 방법 중에는 일정한 범위 내에서 순차적인 값을 갖게 하는 방법난수로 채우는 방법이 있습니다.

  • numpy.arange([start=0, ] stop [, step=1, dtype=float64]) : 순차적인 값으로 생성
    • start : 시작 값
    • stop : 종료 값, 범위에 포함하는 수는 stop-1 까지
    • step : 증가 값
    • dtype : 데이터 타입.
  • numpy.random.rand([d0 [, d1 [..., dn]]]) : 0과 1사이의 무작위 수로 생성
    • d0, d1..dn : shape, 생략하면 난수 한 개 반환
  • numpy.random.randn([d0 [, d1 [..., dn]]]) : 표준정규분포(평균: 0, 분산: 1)를 따르는 무작위 수로 생성

numpy.arange() 함수는 파이썬 기본 함수인 range() 와 사용 방법이 거의 같습니다. 다만 이 함수는 리스트가 아닌 NumPy 배열을 반환한다는 차이가 있습니다.

>>> a = np.arange(5)
>>> a
array([0, 1, 2, 3, 4])
>>> a.dtype
dtype('int64')
>>> a.shape
(5,)

숫자 하나를 인자로 전달하면 0부터 시작하는 값을 순차적으로 갖는 1차원 배열을 반환합니다. 이때 dtype은 요소들이 정수만으로 이루어졌으므로 int64를 갖습니다. 만약 아래의 코드처럼 인자로 소수점이 있는 수를 사용하면 dtype은 float64로 지정되며, 필요에 따라 명시적으로 지정할 수도 있습니다.

>>> b = np.arange(5.0)
>>> b
array([0., 1., 2., 3., 4.])
>>> b.dtype
dtype('float64')

이 함수는 시작 값과 종료 값 그리고 증가 값을 모두 지정해서 생성할 수도 있습니다. 반드시 기억해야 할 점은 지정한 범위의 마지막 값은 항목에 포함되지 않는다는 것입니다.

>>> c = np.arange(3, 9, 2)
>>> c
array([3, 5, 7])

위 코드는 3에서 시작해서 9의 바로 앞, 그러니까 8까지 2씩 증가하는 수를 갖는 배열을 생성합니다. arange() 함수는 1차원 배열만을 생성할 수 있으므로 다차원 배열, 특히 이미지 데이터를 갖는 3차원 배열로 만들기 위해서는 ‘차원 변경’ 함수와 함께 써야 하는 경우가 많습니다.

난수를 발생하는 함수로는 random.rand()random.randn() 이 있습니다. rand() 함수는 0과 1 사이의 값을 무작위로 만들고, randn() 함수는 평균이 0이고 분산이 1인 정규 분포를 따르는 무작위 수를 만들어 냅니다.

두 함수 모두 인자 없이 호출하면 난수 1개를 반환하고, 원하는 차수(shape)에 맞게 인자를 전달하면 해당 차수에 맞는 배열을 난수로 채워서 반환합니다.

>>> np.random.rand()
0.29534415433963335
>>> np.random.randn()
0.881280912289447
>>> a = np.random.rand(2,3)
>>> a
array([[0.66166792, 0.03228477, 0.51420786],
       [0.48056368, 0.59934148, 0.26385163]])
>>> b = np.random.randn(2,3)
>>> b
array([[ 0.54229341, -1,40139248,  1.0294928 ],
       [ 3.0041478 ,  0.84068375, -0.24592442]])

위 코드의 a와 b 배열은 난수로 채워진 2행 3 열의 배열입니다.

이 함수들은 결과 값이 소수점을 갖는 데다가 특정 범위 내에서 난수를 추출하므로 이미지 작업에 필요한 원하는 범위 내에서 난수를 발생하기 위해서는 브로드캐스팅 연산과 dtype 변경이 필요할 때가 많습니다.

6. dtype 변경

배열의 데이터 타입을 변경하는 함수는 다음과 같습니다.

  • ndarray.astype(dtype)
    • dtype : 변경하고 싶은 dtype, 문자열 또는 dtype
  • numpy.uintXX(array) : array를 부호 없는 정수(uint) 타입으로 변경해서 반환
    • uintXX : uint8, unit 16, uint32, uint64
  • numpy.intXX(array) : array를 int 타입으로 변경해서 반환
    • intXX : int8, int16, int32, int64
  • numpy.floatXX(array) : array를 float 타입으로 변경해서 반환
    • floatXX : float16, float32, float64, float128
  • numpy.complexXX(array) : array를 복소수(complex) 타입으로 변경해서 반환
    • complexXX : complex64, complex128, complex256
>>> a=np.arange(5)
>>> a
array([0, 1, 2, 3, 4])
>>> a.dtype
dtype('int64')
>>> b = a.astype('float32')
>>> b
array([0., 1., 2., 3., 4.], dtype=float32)

위 코드는 처음 int64 타입으로 생성한 배열을 float32로 변경하는 모습을 보여주고 있습니다. 이때 astype('float32') 함수에 전달한 인자는 문자열을 사용하고 있는데, 이것은 앞서 설명한 dtype 이름을 그대로 문자열로 작성하면 됩니다. 문자열에 오타가 있거나 일치하지 않는 경우 오류가 발생하니 주의해야 합니다.

아래 코드처럼 astype(np.float64) 함수에 NumPy 모듈에 선언된 변수를 이용하는 방법도 있습니다.

>>> a.dtype
dtype('int64')
>>> c = a.astype(np.float64)
>>> c
array([0., 1., 2., 3., 4.])
>>> c.dtype
dtype('float64')

배열 객체의 dtype을 변경하는 방법으로는 배열 객체의 astype() 메서드를 호출하는 방법이 있는 반면 또 다른 방법도 있습니다. NumPy 모듈 정적 함수에는 NumPy에서 지원하는 dtype들과 같은 이름의 함수들이 있는데, 이 함수들 중에 변경을 원하는 dtype 이름의 함수를 호출하면서 배열 객체를 인자로 전달하는 방법도 있습니다.

>>> a.dtype
dtype('int64')
>>> d = np.uint8(a)
>>> d
array([0, 1, 2, 3, 4], dtype=uint8)

위 코드는 원래 데이터 타입이 int64였던 배열을 np.uint8() 함수에 전달했더니 uint8로 변경돼서 반환되는 것을 보여줍니다.

7. 차원 변경

원래는 1차원이던 배열을 2행 3열 배열로 바꾼다든지, 100 × 200 × 3인 배열을 1차원으로 바꾸는 식의 작업이 필요할 때가 많은데, 이때 필요한 함수는 다음과 같습니다.

  • ndarray.reshape(newshape) : ndarray의 shape를 newshape로 차원 변경
  • numpy.reshape(ndarray, newshape) : ndarray의 shape를 newshape로 차원 변경
    • ndarray : 원본 배열 객체
    • newshape : 변경하고자 하는 새로운 shape(튜플)
  • numpy.ravel(ndarray) : 1차원 배열로 차원 변경
    • ndarray : 변경할 원본 배열
  • ndarray.T : 전치배열(transpose)

앞서 살펴본 numpy.arange() 함수는 1차원 배열만을 생성할 수 있으므로 원하는 차원의 모양으로 변경하는 함수가 거의 대부분 필요합니다. 모양을 변경하는 함수는 원본 배열의 메서드로 호출하거나 NumPy 모듈에 있는 정적함수에 배열을 인자로 전달해서 호출합니다. 결과는 같으므로 그때그때 편리한 방법을 쓰면 됩니다.


>>> a = np.arange(6)
>>> a
array([0, 1, 2, 3, 4, 5])
>>> b = a.reshape(2,3)
>>> b
array([[0, 1, 2],
       [3, 4, 5]])
>>> c = np.reshape(a, (2,3))
>>> c
array([[0, 1, 2],
       [3, 4, 5]])

위 코드는 1차원 배열 a를 2행 3열로 바꾸는 작업을 두 가지 함수로 각각 보여주고 있습니다.

이 두 함수는 새로운 shape를 지정할 때 -1을 포함해서 전달할 수 있습니다. -1의 의미는 해당 차수에 대해서는 크기를 지정하지 않겠다는 뜻이고, 그것은 나머지 차수를 이용해서 알아서 계산해 달라는 뜻입니다.

>>> d = np.arange(24).reshape(2,3,4)
>>> d
array([[[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]]])
>>> e = np.arange(100).reshape(2, -1)
>>> e
array([[ 0,  1,  2, ... 47, 48, 49],
       [50, 51, 52, ... 97, 98, 99]])
>>> f = np.arange(100).reshape(-1, 5)
>>> f
array([[ 0,  1,  2,  3,  4],
       [ 5,  6,  7,  8,  9]
       ...
       [90, 91, 92, 93, 94]
       [95, 96, 97, 98, 99]])

위 코드는 100개의 1차원 배열의 차원을 변경하기 위해 지정한 shape에 -1을 쓴 예를 보여주고 있습니다. (2, -1)의 의미는 2행으로 나누고 열은 알아서 맞추라는 뜻입니다. 결국 100개의 요소를 2행으로 나누어 열이 50개가 되므로 2행 50열이 됩니다.

(-1, 5)는 -1행 5열을 생성하겠다는 것인데, 5열에 맞춰서 행을 알아서 계산하면 20행이 나오므로 (20, 5)가 출력됩니다. -1은 개발자에게 불필요한 계산을 하지 않아도 되게 해주므로 아주 편리합니다. 하지만 101개의 1차원 배열을 2열로 나누라는 식의 연산은 오류가 발생하므로 주의해야 합니다.

어떤 배열을 1차원 배열로 재정렬할 수 있는 방법은 방금 설명한 reshape() 함수를 이용해도 되고 numpy.ravel() 함수를 사용해도 됩니다.

>>> f = np.zeros((2,3))
>>> f
array([[0., 0., 0.],
       [0., 0., 0.]])
>>> f.reshape((6,))
array([0., 0., 0., 0., 0., 0.])
>>> f.reshape(-1)
array([0., 0., 0., 0., 0., 0.])
>>> np.ravel(f)
array([0., 0., 0., 0., 0., 0.])

위 코드는 2행 3열 배열을 1차원 배열로 바꾸는 방법을 보여주고 있습니다. 첫 번째 방법은 f.reshape((6,)) 과 같이 요소의 개수를 직접 전달하는 방법이며, 일일이 계산해야 하니 불편합니다. 그래서 조금 전 설명한 -1을 이용하면 간단히 해결할 수 있습니다. 마지막으로 np.ravel() 함수로도 똑같은 결과를 얻을 수 있습니다.

NumPy 배열(ndarray) 객체에는 ndarray.T 라는 속성이 있습니다. 이 속성을 이용하면 행과 열을 서로 바꾸는 전치배열을 얻을 수 있습니다.

>>> g = np.arange(10).reshape(2,-1)
>>> g
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])
>>> g.T
array([[0, 5],
       [1, 6],
       [2, 7],
       [3, 8],
       [4, 9]])

8. 브로드캐스팅 연산

아마도 NumPy 배열을 사용하는 가장 큰 이유는 브로드캐스팅(broadcasting) 연산 때문일 것입니다. 다음 코드의 예처럼 0부터 9까지 있는 파이썬 리스트의 모든 항목 값을 1씩 증가시키려면 반복문을 작성해야 합니다.

import numpy as np

mylist = list(range(10))
print(mylist)

for i in range(len(mylist)):
    mylist[i] = mylist[i] + 1
print(mylist)

[output]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

하지만, NumPy 배열에 +1(더하기 1) 연산을 한 번만 해도 같은 결과를 얻게 되는데, 이것을 브로드캐스팅 연산이라고 합니다.

>>> a = np.arange(10)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a + 1
array([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

브로드캐스팅 연산은 더하기 연산뿐만 아니라 모든 산술 연산이 가능합니다. 아래코드는 NumPy 배열과 스칼라(Scalar, 스케일러) 값 간의 여러 가지 연산의 예를 보여주고 있습니다.

>>> a = np.arange(5)
>>> a
array([0, 1, 2, 3, 4])
>>> a + 5
array([5, 6, 7, 8, 9])
>>> a - 2
array([-2, -1, 0, 1, 2])
>>> a * 2
array([0, 2, 4, 6, 8])
>>> a / 2
array([0., 0.5, 1., 1.5, 2. ])
>>> a ** 2
array([ 0, 1, 4, 9, 16])
>>> b = np.arange(6).reshape(2, -1)
>>> b
array([[0, 1, 2],
       [3, 4, 5]])
>>> b * 2
array([[ 0, 2,  4],
       [ 6, 8, 10]])

다차원 배열에도 똑같이 연산이 적용되는 것을 알 수 있습니다.

산술 연산뿐만 아니라 비교 연산도 가능합니다. 비교 연산의 결과는 각 항목에 대해 만족 여부를 불값(True/False)으로 갖는 동일한 크기의 배열로 반환합니다.

>>> a
array([0, 1, 2, 3, 4])
>>> a > 2
array([False, False, False, True, True])

배열과 숫자 값 간의 연산뿐만 아니라 배열끼리의 연산도 가능합니다.

>>> a = np.arange(10, 60, 10)
>>> b = np.arange(1, 6)
>>> a
array([10, 20, 30, 40, 50])
>>> b
array([1, 2, 3, 4, 5])
>>> a + b
array([11, 22, 33, 44, 55])
>>> a - b
array([ 9, 18, 27, 36, 45])
>>> a * b
array([ 10, 40, 90, 160, 250])
>>> a / b
array([10., 10., 10., 10., 10.])
>>> a ** b
array([ 10, 400, 27000, 2560000, 312500000])

하지만, 배열 간의 연산에는 약간의 제약이 있습니다. 두 배열의 shape가 완전히 동일하거나 둘 중 하나가 1차원이면서 1차원 배열의 축의 길이가 같아야 합니다.

>>> a = np.ones((2,3))
>>> b = np.ones((3,2))
>>> a + b
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ValueError: operands could not be broadcast together with shapes (2,3) (3,2)

위 코드는 두 배열의 shape가 일치하지 않아서 연산에 실패합니다.

>>> a
array([[1., 1., 1.],
       [1., 1., 1.]])
>>> c = np.arange(3)
>>> c
array([0, 1, 2])
>>> a + c
array([[1., 2., 3.],
       [1., 2., 3.]])

위 코드는 배열 c가 1차원이고 1차원 배열의 열의 개수가 a 배열의 열의 개수와 같아서 연산이 가능합니다. 만약 1차원 배열이라고 해도 열의 개수가 맞지 않으면 아래 코드처럼 연산은 실패합니다.

>>> d = np.arange(2)
>>> a + d
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ValueError: operands could not be broadcast together with shapes (2,3) (2,)

이 경우 열 단위 연산을 하려면 배열 d의 모양을 바꾸어 연산할 수 있습니다.

>>> a
array([[1., 1., 1.],
       [1., 1., 1.]])
>>> d = np.arange(2).reshape(2,1)
>>> d
array([[0],
       [1]])
>>> a + d
array([[1., 1., 1.],
       [2., 2., 2.]])

NumPy 배열의 연산은 수학에서의 행렬과 벡터의 연산과 비슷해 보이지만 배열의 곱셈 연산은 행렬 연산과는 다르니 주의해야합니다.

Read more

OpenCV 창 관리 및 이벤트 처리

|

Hits


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


1. 창 관리

한 개 이상의 이미지를 여러 창에 띄우거나 각 창에 키보드와 마우스 이벤트를 처리하려면 창을 관리하는 기능이 필요합니다. 다음은 OpenCV가 제공하는 창 관리 관련 API들을 요약한 것입니다.

  • cv2.namedWindow(title [, option]) : 이름을 갖는 창 열기
    • title : 창 이름, 제목 줄에 표시
    • option : 창 옵션, cv2.WINDOW_ 로 시작.
      • cv2․WINDOW_NORMAL : 임의의 크기, 사용자 창 크기 조정 가능
      • cv2.WINDOW_AUTOSIZE : 이미지와 같은 크기, 창 크기 재조정 불가능
  • cv2.moveWindow(title, x, y) : 창 위치 이동
    • title : 위치를 변경할 창의 이름
    • x, y : 이동할 창의 위치
  • cv2.resizeWindow(title, width, height) : 창 크기 변경
    • title : 크기를 경할 이름
    • width, height : 크기를 변경할 창의 폭과 높이
  • cv2.destroyWindow(title) : 창닫기
    • title : 닫을 대상 창 이름
  • cv2.destroyAllWindows() : 열린 모든 창 닫기

다음 코드는 창 관리 함수를 이용하는 예제입니다.

'''창 관리 API 활용하기'''
import cv2

file_path = './img/girl.jpg'
img = cv2.imread(file_path)                             # 이미지를 기본 값으로 읽기
img_gray = cv2.imread(file_path, cv2.IMREAD_GRAYSCALE)  # 이미지를 그레이 스케일로 읽기

cv2.namedWindow('origin')                   # origin 이름으로 창 생성
cv2.namedWindow('gray', cv2.WINDOW_NORMAL)  # gray 이름으로 창 생성
cv2.imshow('origin', img)                   # origin 창에 이미지 표시
cv2.imshow('gray', img_gray)                # gray 창에 이미지 표시

cv2.moveWindow('origin', 0, 0)              # 창 위치 변경
cv2.moveWindow('gray', 100, 100)            # 창 위치 변경

cv2.waitKey(0)                              # 아무키나 누르면
cv2.resizeWindow('origin', 200, 200)        # 창 크기 변경 (변경 안됨)
cv2.resizeWindow('gray', 100, 100)          # 창 크기 변경 (변경 됨))

cv2.waitKey(0)                              # 아무키나 누르면
cv2.destroyWindow("gray")                   # gray 창 닫기

cv2.waitKey(0)                              # 아무키나 누르면
cv2.destroyAllWindows()                     # 모든 창 닫기


image

위 예제는 최초에 ‘origin’과 ‘gray’라는 2개의 창을 띄워서 원본 이미지와 그레이 스케일 이미지를 각각 보여주는데, 이때 ‘origin’ 창은 cv2.WINDOW_AUTOSIZE 옵션으로 열었고, ‘gray’ 창은 cv2.WINDOW_NORMAL 옵션으로 열었습니다.

화면을 표시한 다음 cv2.moveWindow() 함수로 각각의 창을 모니터 좌측 상단으로 이동시킨 다음 아무 키나 누르면 cv2.resizeWindow() 함수로 창의 크기를 변경합니다. 이때, ‘origin’ 창은 cv2.WINDOW_AUTOSIZE 로 창을 열었으므로 창의 크기는 변경되지 않고, cv2.WINDOW_NORMAL 옵션을 사용한 ‘gray’ 창은 창의 크기가 변경됩니다. 사용자의 마우스를 이용해서 창의 크기를 변경하는 것도 같습니다.

창의 크기가 변경되고 나서 다시 한번 아무 키나 누르면 ‘gray’ 창만 닫히고 다시한번 아무 키나 누르면 나머지 ‘origin’ 창도 닫힙니다.

이와 같이 OpenCV에서 제공하는 창과 관련한 API는 창을 열 때 사용한 이름을 기반으로 연결되는 것이 특징입니다.


2. 이벤트 처리

키보드와 마우스 입력 방법에 대해 알아봅니다.

1. 키보드 이벤트

cv2.waitKey(delay) 함수를 쓰면 키보드의 입력을 알아낼 수 있습니다. 이 함수는 delay 인자에 밀리초(ms, 0.001초) 단위로 숫자를 전달하면 해당 시간 동안 프로그램을 멈추고 대기하다가 키보드의 눌린 키에 대응하는 코드 값을 정수로 반환합니다. 지정한 시간까지 키보드 입력이 없으면 -1 을 반환합니다. delay 인자에 0 을 전달하면 대기 시간을 무한대로 하겠다는 의미이므로 키를 누를 때까지 프로그램은 멈추고 이때는 -1 을 반환할 일은 없습니다.

키보드에서 어떤 키를 눌렀는지를 알아내려면 cv2.waitKey() 함수의 반환 값을 출력해 보면 됩니다.

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

출력되는 키 값을 확인해 보면 ASCII 코드와 같다는 것을 알 수 있습니다. 환경에 따라 한글 모드에서 키를 입력하면 오류가 발생할 수 있으니 키를 입력할 때 한글은 사용하지 않는 것이 좋습니다.

입력된 키를 특정 문자와 비교할 때는 파이썬 기본 함수인 ord() 함수를 사용하면 편리합니다. 예를 들어 키보드의 ‘a’ 키를 눌렀는지 확인하기 위한 코드는 다음과 같습니다.

if cv2.waitKey(0) == ord('a'):

그런데 몇몇 64비트 환경에서 cv2.waitKey() 함수는 8비트(ASCII 코드 크기)보다 큰 32비트 정수를 반환해서 그 값을 ord() 함수를 통해 비교하면 서로 다른 값으로 판단할 때가 있습니다. 그래서 하위 8비트를 제외한 비트를 지워야 하는 경우가 있습니다. 0xFF는 하위 8비트가 모두 1로 채워진 숫자이므로 이것과 & 연산을 수행하면 하위 8비트보다 높은 비트는 모두 0으로 채울 수 있습니다.

key = cv2.waitKey(0) & 0xFF
if key == ord('a') :

다음 예제는 화면에 이미지를 표시하고 키보드의 ‘a’, ‘w’, ‘s’, ‘d’ 키를 누르면 창의위치가 좌, 상, 하, 우 방향으로 10픽셀씩 움직이고, ‘esc’ 키 또는 ‘q’ 키를 누르면 종료되는 코드입니다.

'''키 이벤트'''
import cv2

img_file = "./img/wonyoung.jpg"
img = cv2.imread(img_file)
title = 'IMG'                   # 창 이름
x, y = 100, 100                 # 최초 좌표

while True:
    cv2.imshow(title, img)
    cv2.moveWindow(title, x, y)
    key = cv2.waitKey(0) & 0xFF # 키보드 입력을 무한 대기, 8비트 마스크처리
    print(key, chr(key))        # 키보드 입력 값, 문자 값 출력
    if key == ord('a'):         # 'a' 키 이면 좌로 이동
        x -= 10
    elif key == ord('s'):       # 's' 키 이면 아래로 이동
        y += 10
    elif key == ord('w'):       # 'w' 키 이면 위로 이동
        y -= 10
    elif key == ord('d'):       # 'd' 키 이면 오른쪽으로 이동
        x += 10
    elif key == ord('q') or key == 27: # 'q' 이거나 'esc' 이면 종료
        break
        cv2.destroyAllWindows()
    cv2.moveWindow(title, x, y )   # 새로운 좌표로 창 이동

[output]
97 a
119 w
120 x
100 d
113 q

2. 마우스 이벤트

마우스에서 입력을 받으려면 이벤트를 처리할 함수를 미리 선언해 놓고 cv2.setMouseCallback() 함수에 그 함수를 전달합니다. 코드로 간단히 묘사하면 다음과 같습니다.

def onMouse(event, x, y, flags, param):
    # 여기에 마우스 이벤트에 맞게 해야 할 작업을 작성합니다.
    pass

cv2.setMouseCallback('title', onMouse)

이 두 함수의 모양은 아래와 같습니다.

  • cv2.setMouseCallback(win_name, onMouse [, param]) : onMouse 함수를 등록
    • win_name : 이벤트를 등록할 윈도 이름
    • onMouse : 이벤트 처리를 위해 미리 선언해 놓은 콜백 함수
    • param : 필요에 따라 onMouse 함수에 전달할 인자
  • MouseCallback(event, x, y, flags, param) : 콜백 함수 선언부
    • event : 마우스 이벤트 종류, cv2.EVENT_로 시작하는 상수(12가지)
      • cv2.EVENT_MOSEMOVE : 마우스 움직임
      • cv2.EVENT_LBUTTONDOWN : 왼쪽 버튼 누름
      • cv2.EVENT_RBUTTONDOWN : 오른쪽 버튼 누름
      • cv2.EVENT_MBUTTONDOWN : 가운데 버튼 누름
      • cv2.EVENT_LBUTTONUP : 왼쪽 버튼 뗌
      • cv2.EVENT_RBUTTONUP : 오른쪽 버튼 뗌
      • cv2.EVENT_MBUTTONUP : 가운데 버튼 뗌
      • cv2.EVENT_LBUTTONDBLCLK: 왼쪽 버튼 더블 클릭
      • cv2.EVENT_RBUTTONDBLCLK : 오른쪽 버튼 더블 클릭
      • cv2.EVENT_MBUTTONDBLCLK : 가운데 버튼 더블 클릭
      • cv2.EVENT_MOUSEWHEEL : 휠 스크롤
      • cv2.EVENT_MOUSEHWHEEL : 휠 가로 스크롤
    • x, y : 마우스 좌표
    • flags : 마우스 동작과 함께 일어난 상태, cv2.EVENT_FLAG_ 로 시작하는 상수(6가지)
      • cv2.EVENT_FLAG_LBUTTON(1) : 왼쪽 버튼 누름
      • cv2.EVENT_FLAG_RBUTTON(2) : 오른쪽 버튼 누름
      • cv2.EVENT_FLAG_MBUTTON(4) : 가운데 버튼 누름
      • cv2.EVENT_FLAG_CTRLKEY(8) : Ctrl 키 누름
      • cv2.EVENT_FLAG_SHIFTKEY(16) : Shift 키 누름
      • cv2.EVENT_FLAG_ALTKEY(32) : Alt 키 누름
    • param : cv2.setMouseCallback() 함수에서 전달한 인자

다음은 마우스를 클릭하면 지름이 30픽셀인 동그라미를 그리는 예제입니다.

'''마우스 이벤트로 동그라미 그리기'''
import cv2

title = 'mouse event'                       # 창 제목
img = cv2.imread('./img/blank_500.jpg')     # 백색 이미지 읽기
cv2.imshow(title, img)                      # 백색 이미지 표시

def onMouse(event, x, y, flags, param):     # 마우스 콜백 함수 구현 ---①
    print(event, x, y, )                    # 파라미터 출력
    if event == cv2.EVENT_LBUTTONDOWN:      # 왼쪽 버튼 누름인 경우 ---②
        cv2.circle(img, (x,y), 30, (0,0,0), -1) # 지름 30 크기의 검은색 원을 해당 좌표에 그림
        cv2.imshow(title, img)              # 그려진 이미지를 다시 표시 ---③

cv2.setMouseCallback(title, onMouse)        # 마우스 콜백 함수를 GUI 윈도우에 등록 ---④

while True:
    if cv2.waitKey(0) & 0xFF == 27:         # esc로 종료
        break
cv2.destroyAllWindows()


image

위 예제의 코드 ①에서 마우스 이벤트를 처리하기 위한 함수를 구현하고 있습니다. 이 함수를 코드 ④에서 윈도에 등록하고 있습니다. 이 함수의 주요 내용은 코드 ②에서처럼 왼쪽 버튼이 눌려지는 것을 알아내는 것입니다. 또 하나 주의해야 할 것은 코드 ③에서처럼 이벤트 내에서 그리기를 했다면 반드시 그림이 그려진 이미지를 다시 화면에 표시해야 한다는 것입니다.

아래의 코드는 이벤트 처리 함수의 선언부인데, 모두 5개의 인자를 선언해야 합니다.

def onMouse(event, x, y, flags, param):

함수 내부에서 사용하지 않더라도 5개의 인자는 모두 선언부에 기재해야 하며, 그렇지 않으면 오류가 발생합니다. 이 함수의 첫 번째 인자 event 는 발생한 이벤트의 종류를 나타내는 것으로 cv2.EVENT_ 로 시작하는 상수 값 중에 하나입니다. API에 선언되어 있는 모든 이벤트 상수에 대응하는 이벤트는 코드를 실행하는 환경에 따라 작동하지 않는 경우도 있으니 주요한 것 위주로 사용하는 것이 좋습니다. x, y 는 이벤트가 발생한 마우스의 좌표입니다. flags 는 이벤트가 발생할 때 키보드나 마우스의 추가적인 상태를 알려줍니다. 이 값과 비교할 상수는 이름이 cv2.EVENT_FALG_ 로 시작하는 선언되어 있는 상수들입니다.

이 플래그는 시프트 키와 컨트롤 키를 함께 누른 상태처럼 여러 가지 상태를 하나의 값으로 한꺼번에 나타낼 수 있어야 합니다. 그래서 선언된 상수들이 실제로 갖는 값은 0, 1, 2, 3, 4처럼 순차적으로 증가하는 값이 아니라 1, 2, 4, 8, 16, 32 순으로 2진수 비트 자릿수에 맞는 값을 각각 갖습니다. 따라서 함수의 인자로 전달되는 값은 여러 상태를 나타내는 값을 조합한 값으로, 어떤 상태인지 알기 위해서는 비트 단위 &(논리 곱) 또는 |(논리합) 연산을 써서 알아내야 합니다.

예를 들어 flag 값이 8이라면 cv2.EVENT_FLAG_CTRLKEY 의 값과 같습니다. 이런 경우 flag 값과 관심 있는 상수를 비교해서 맞으면 컨트롤 키가 눌러진 상태로 판단하면 됩니다.

하지만, 만약 flag 값이 25라면 어떤 플래그 상수와 비교해도 맞는 것을 찾을 수 없습니다. 이 경우 25 = 1 + 8 + 16이므로 1, 8, 16에 맞는 플래그 상수와 따로따로 비교해서 찾아내야 합니다. 이것은 각각 cv2.EVENT_FLAG_LBUTTON, cv2.EVENT_FLAG_CTRLKEY, CV2.EVENT_FLAG_SHIFTKEY 에 해당합니다.

flags로부터 상태를 각각 알아내는 방법은 다음 코드와 같습니다.

if flags & cv2.EVENT_FLAG_LBUTTON:
    pass    # 마우스 왼쪽 버튼 눌림
if flags & cv2.EVENT_FLAG_CTRLKEY:
    pass    # 컨트롤 키눌림
if flags & cv2.EVENT_FLAG_SHIFTKEY:
    pass    # 시프트 키눌림

결국 관심 있는 상태 플래그 값과 인자값을 & 연산하면 됩니다. 그러면 각각의 조건문에 모두 True로 반환되어 처리됩니다.

다음 코드는 앞서 다룬 마우스로 동그라미 그리기 예제를 컨트롤 키를 누르면 빨간색으로, 시프트 키를 누르면 파란색으로, 시프트 키와 컨트롤 키를 동시에 누르면 초록색으로 그리게 수정한 것입니다.

'''플래그를 이용한 동그라미 그리기'''
import cv2

title = 'mouse event'                       # 창 제목
img = cv2.imread('./img/blank_500.jpg')    # 백색 이미지 읽기
cv2.imshow(title, img)                      # 백색 이미지 표시

colors = {'black' : (0,0,0),
          'red' : (0,0,255),
          'blue' : (255,0,0),
          'green' : (0,255,0)}   # 색상 미리 정의

def onMouse(event, x, y, flags, param): # 아무스 콜백 함수 구현 ---①
    print(event, x, y, flags)           # 파라미터 출력
    color = colors['black']
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 버튼 누름인 경우 ---②
        # 컨트롤키와 쉬프트 키를 모두 누른 경우
        if flags & cv2.EVENT_FLAG_CTRLKEY and flags & cv2.EVENT_FLAG_SHIFTKEY : 
            color = colors['green']
        elif flags & cv2.EVENT_FLAG_SHIFTKEY :  # 쉬프트 키를 누른 경우
            color = colors['blue']
        elif flags & cv2.EVENT_FLAG_CTRLKEY :   # 컨트롤 키를 누른 경우
            color = colors['red']
        # 지름 30 크기의 검은색 원을 해당 좌표에 그림
        cv2.circle(img, (x,y), 30, color, -1) 
        cv2.imshow(title, img)          # 그려진 이미지를 다시 표시 ---③

cv2.setMouseCallback(title, onMouse)    # 마우스 콜백 함수를 GUI 윈도우에 등록 ---④

while True:
    if cv2.waitKey(0) & 0xFF == 27:     # esc로 종료
        break
cv2.destroyAllWindows()


image

Read more

OpenCV로 그림 그리기

|

Hits


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


그림 그리기

이미지나 비디오에 그림을 그리는 방법을 알아봅니다. 객체나 얼굴을 인식해서 그 영역에 사각형을 그려서 표시하고 그 이름을 글씨로 표시하는 등의 용도로 자주 활용됩니다.

그리기 예제를 진행하기 위해서는 그림판 역할을 할 이미지가 하나 필요한데, blank_500.jpg라는 이름의 500 × 500 픽셀 크기의 아무것도 없는 완전히 하얀 이미지를 사용합니다. 이 이미지는 아래의 코드로 생성하기 바랍니다.

import cv2
import numpy as np

img = np.full((500,500,3), 255, dtype=np.uint8)
cv2.imwrite('./img/blank_500.jpg', img)

1. 직선 그리기

이미지에 직선을 그리는 함수는 cv2.line() 입니다.

  • cv2.line(img, start, end, color [, thickness, lineType]) : 직선 그리기
    • img : 그림 그릴 대상 이미지, NumPy 배열
    • start : 선 시작 지점 좌표(x, y)
    • end : 선 끝 지점 좌표(x, y)
    • color : 선 색상, (Blue, Green, Red), 0~255
    • thickness=1 : 선 두께
    • lineType : 선 그리기 형식
      • cv2.LINE_4 : 4 연결 선 알고리즘
      • cv2.LINE_8 : 8 연결 선 알고리즘
      • cv2.LINE_AA : 안티에일리어싱(antialiasing, 계단 현상 없는 선)

img 이미지에 start 지점에서 end 지점까지 선을 그립니다. color 는 선의 색상을 표현하는 것으로 0~255 사이의 값 3개로 구성해서 표현합니다. 각 숫자는 파랑, 초록, 빨강(BGR) 순서이며, 이 색상을 섞어서 다양한 색상을 표현합니다. 일반적으로 웹에서 사용하는 RGB 순서와 반대라는 것이 특징입니다. thickness 는 선의 두께를 픽셀 단위로 지시하는데, 생략하면 1픽셀이 적용됩니다. lineType 은 선을 표현하는 방식을 나타내는 것으로 사선을 표현하거나 두꺼운 선의 끝을 표현할 때 픽셀에 따른 계단 현상을 최소화하기 위한 알고리즘을 선택합니다. cv2.LINE_ 으로 시작하는 3개의 상수를 선택할 수 있습니다. cv2.LINE_4cv2.LINE_8 은 각각 브레젠햄(Bresenham) 알고리즘의 4연결, 8연결을 의미하고 cv2.LINE_AA 는 가우시안 필터를 이용합니다.

다음 코드에서 다양한 선을 그려보면서 cv2.line() 함수의 매개변수의 의미를 알아봅니다.

'''다양한 선 그리기'''
import cv2

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

# ---①
cv2.line(img, (50, 50), (150, 50), (255,0,0))   # 파란색 1픽셀 선
cv2.line(img, (200, 50), (300, 50), (0,255,0))  # 초록색 1픽셀 선
cv2.line(img, (350, 50), (450, 50), (0,0,255))  # 빨간색 1픽셀 선

# ---②
# 하늘색(파랑+초록) 10픽셀 선
cv2.line(img, (100, 100), (400, 100), (255,255,0), 10)
# 분홍(파랑+빨강) 10픽셀 선
cv2.line(img, (100, 150), (400, 150), (255,0,255), 10)
# 노랑(초록+빨강) 10픽셀 선
cv2.line(img, (100, 200), (400, 200), (0,255,255), 10)
# 회색(파랑+초록+빨강) 10픽셀 선
cv2.line(img, (100, 250), (400, 250), (200,200,200), 10)
# 검정 10픽셀 선
cv2.line(img, (100, 300), (400, 300), (0,0,0), 10)

# ---③
# 4연결 선
cv2.line(img, (100, 350), (400, 400), (0,0,255), 20, cv2.LINE_4)
# 8연결 선
cv2.line(img, (100, 400), (400, 450), (0,0,255), 20, cv2.LINE_8)
# 안티에일리어싱 선
cv2.line(img, (100, 450), (400, 500), (0,0,255), 20, cv2.LINE_AA)
# 이미지 전체에 대각선
cv2.line(img, (0, 0), (500, 500), (0,0,255))

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


image

사용한 이미지 blank_500.jpg는 흰색 배경에 아무 그림도 없는 텅빈 500 × 500 픽셀 크기의 이미지입니다. 코드 ①에서는 선 두께는 생략하여 두께가 1픽셀인 파란색, 초록색, 빨간색 선을 하나씩 그리고 있습니다. 코드 ②에서는 색상을 섞어서 다양한 색상의 10픽셀 선을 그리고 있습니다. 코드 ③은 사선이면서 두꺼운 선을 그리면서 계단 현상이 일어나는 것을 보여주고 있습니다. LINE_4와 LINE_8은 큰 차이를 느낄 수 없고, LINE_AA는 계단 현상을 없애는 것을 볼 수 있습니다.

2. 사각형 그리기

사각형을 그리는 함수는 cv2.rectangle() 입니다.

  • cv2.rectangle(img, start, end, color [, thickness, lineType]) : 사각형 그리기
    • img : 그림 그릴 대상 이미지, NumPy 배열
    • start : 사각형 시작 꼭짓점(x, y)
    • end : 사각형 끝 꼭짓점(x, y)
    • color : 색상(Blue, Green, Red)
    • thickness: 선 두께
      • -1 : 채우기
    • lineType : 선 타입, cv2.line() 과 동일

사각형을 그릴 때 사용하는 cv2.rectangle() 함수는 앞서 설명한 cv2.line() 함수와 사용법이 거의 비슷합니다. 다만, 선이 아닌 면을 그리는 것이므로 선의 두께를 지시하는 thickness-1 을 지정하면 사각형 면 전체를 color로 채우기를 합니다. 사각형을 그리기 위한 좌표는 시작 지점의 좌표 두 쌍과 그 반대 지점의 좌표 두 쌍으로 표현합니다.

'''사각형 그리기'''
import cv2

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

# 좌상, 우하 좌표로 사각형 그리기
cv2.rectangle(img, (50, 50), (150, 150), (255,0,0))
# 우하, 좌상 좌표로 사각형 그리기
cv2.rectangle(img, (300, 300), (100, 100), (0,255,0), 10)
# 우상, 좌하 좌표로 사각형 채워 그리기 ---①
cv2.rectangle(img, (450, 200), (200, 450), (0,0,255), -1)

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


image

보통 많은 그리기 도구에서 사각형을 그릴 때는 좌상단 꼭짓점과 우하단 꼭짓점 좌표를 사용하는 경우가 많은데, cv2.rectangle() 함수는 어느 지점이든 시작 지점과 그 반대 지점을 사용한다는 것이 특징입니다. 사각형의 크기는 두 좌표의 차이만큼이 됩니다. 코드 ①에서 선의 두께를 지정해야 하는 값에 -1을 전달해서 채우기로 그렸습니다.

3. 다각형 그리기

다각형을 그리는 함수는 cv2.polylines() 입니다.

  • cv2.polylines(img, points, isClosed, color [, thickness, lineType]) : 다각형 그리기
    • img : 그림 그릴 대상 이미지
    • points : 꼭짓점 좌표, NumPy 배열 리스트
    • isClosed : 닫힌 도형 여부, True/False
    • color : 색상(Blue, Green, Red)
    • thickness : 선 두께
    • lineType : 선 타입, cv2.line() 과 동일

이 함수의 points 인자는 다각형을 그리기 위한 여러 개의 꼭짓점 좌표를 전달합니다. 이때 좌표를 전달하는 형식이 지금까지와는 달리 NumPy 배열 형식입니다. isClosed 인자는 Boolean 타입인데, True 는 첫 꼭짓점과 마지막 꼭짓점을 연결해서 닫힌 도형(면)을 그리게 하고, False 는 단순히 여러 꼭짓점을 잇는 선을 그리게 합니다.

'''다각형 그리기'''
import cv2
import numpy as np  # 좌표 표현을 위한 numpy 모듈 ---①

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

# Numpy array로 좌표 생성 ---②
# 번개 모양 선 좌표
pts1 = np.array([[50, 50], [150, 150], [100, 140], [200, 240]], dtype=np.int32)
# 삼각형 좌표
pts2 = np.array([[350, 50], [250, 200], [450, 200]], dtype=np.int32)
# 삼각형 좌표
pts3 = np.array([[150, 300], [50, 450], [250, 450]], dtype=np.int32)
# 5각형 좌표
pts4 = np.array([[350, 250], [450, 350], [400, 450], [300, 450], [250, 350]],\
                 dtype=np.int32) 

# 다각형 그리기 ---③
cv2.polylines(img, [pts1], False, (255,0,0))    # 번개 모양 선 그리기
cv2.polylines(img, [pts2], False, (0,0,0), 10)  # 3각형 열린 선 그리기 ---④
cv2.polylines(img, [pts3], True, (0,0,255), 10) # 3각형 닫힌 도형 그리기 ---⑤
cv2.polylines(img, [pts4], True, (0,0,0))       # 5각형 닫힌 도형 그리기

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


image

코드 ①에서 NumPy 배열을 생성하기 위해 새로운 모듈을 임포트합니다. 코드 ②와 같이 그리기에 사용할 좌표들을 작성합니다. 실제로 다각형을 그리는 데 사용하는 함수는 코드 ③에서부터 나타납니다. 코드 ④와 ⑤에 사용한 좌표는 시작 위치만 다를 뿐 3개의 꼭짓점을 같은 비율로 표현하고 있는데, 열린 도형과 닫힌 도형의 차이를 지정하는 세 번째 인자의 차이로 각각 선과 도형으로 그려지는 것을 알 수 있습니다. cv2.polylines() 함수는 선의 굵기를 표현하는 인자에 -1을 지정해서 채우기 효과를 나타내는 것은 지원하지 않습니다.

4. 원, 타원, 호 그리기

원과 타원 그리고 호를 그리기 위한 함수는 다음과 같습니다.

  • cv2.circle(img, center, radius, color [, thickness, lineTypel) : 원 그리기 함수
    • img : 그림 대상 이미지
    • center : 원점 좌표(x, y)
    • radius : 원의 반지름
    • color : 색상(Blue, Green, Red)
    • thickness : 선 두께(-1: 채우기)
    • lineType : 선 타입, cv2.line() 과 동일
  • cv2.ellipse(img, center, axes, angle, from, to, color [, thickness, lineType]) : 호나 타원 그리기 함수
    • img : 그림 대상 이미지
    • center : 원점 좌표(x, y)
    • axes : 기준 축 길이
    • angle : 기준 축 회전 각도
    • from, to : 호를 그릴 시작 각도와 끝 각도

완전한 동그라미를 그릴 때 가장 좋은 함수는 cv2.circle() 입니다. 하지만, 이 함수로는 동그라미의 일부분, 즉 호를 그리거나 찌그러진 동그라미인 타원을 그리는 것은 불가능하며, 이런 호나 타원을 그리려면 cv2.ellipse() 함수를 써야 합니다. 당연히 cv2.ellipse() 함수를 쓰는 것이 조금 더 어렵습니다.

다음 코드는 원과 타원 그리고 호를 그리는 방법을 보여주고 있습니다.

'''원, 타원, 호 그리기'''
import cv2

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

# 원점(150,150), 반지름 100 ---①
cv2.circle(img, (150, 150), 100, (255,0,0))
# 원점(300,150), 반지름 70 ---②
cv2.circle(img, (300, 150), 70, (0,255,0), 5)
# 원점(400,150), 반지름 50, 채우기 ---③
cv2.circle(img, (400, 150), 50, (0,0,255), -1)

# 원점(50,300), 반지름(50), 회전 0, 0도 부터 360도 그리기 ---④
cv2.ellipse(img, (50, 300), (50, 50), 0, 0, 360, (0,0,255))
# 원점(150, 300), 아래 반원 그리기 ---⑤
cv2.ellipse(img, (150, 300), (50, 50), 0, 0, 180, (255,0,0))
#원점(200, 300), 윗 반원 그리기 ---⑥
cv2.ellipse(img, (200, 300), (50, 50), 0, 181, 360, (0,0,255))

# 원점(325, 300), 반지름(75,50) 납작한 타원 그리기 ---⑦
cv2.ellipse(img, (325, 300), (75, 50), 0, 0, 360, (0,255,0))
# 원점(450,300), 반지름(50,75) 홀쭉한 타원 그리기 ---⑧
cv2.ellipse(img, (450, 300), (50, 75), 0, 0, 360, (255,0,255))

# 원점(50, 425), 반지름(50,75), 회전 15도 ---⑨
cv2.ellipse(img, (50, 425), (50, 75), 15, 0, 360, (0,0,0))
# 원점(200,425), 반지름(50,75), 회전 45도 ---⑩
cv2.ellipse(img, (200, 425), (50, 75), 45, 0, 360, (0,0,0))

# 원점(350,425), 홀쭉한 타원 45도 회전 후 아랫 반원 그리기 ---⑪
cv2.ellipse(img, (350, 425), (50, 75), 45, 0, 180, (0,0,255))
# 원점(400,425), 홀쭉한 타원 45도 회전 후 윗 반원 그리기 ---⑫
cv2.ellipse(img, (400, 425), (50, 75), 45, 181, 360, (255,0,0))

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


image

코드 ①, ②, ③은 cv2․circle() 함수를 이용해서 원을 그리고 있습니다. 주요 인자는 원점의 좌표와 반지름 값이므로 사용이 편리합니다. ③에서는 선의 두께 값에 -1을 전달하여 채우기 효과를 내고 있습니다.

나머지 코드는 모두 cv2.ellipse() 함수로 원, 타원, 호를 그리고 있습니다. 코드 ④처럼 이 함수로도 완전한 원을 그릴 수 있습니다. 반지름의 크기를 같은 비율로 지정하고, 회전 각도는 0으로, 표시할 호는 0도에서 360도를 모두 지정하였습니다. 코드 ⑤와 ⑥은 코드 ④와 똑같이 정확한 원을 표시하고 나서 표시할 호의 시작과 끝각을 0, 180 그리고 181, 360으로 원의 아랫부분과 윗부분에 해당하는 반원만 그렸습니다. 이렇게 호를 표시하고자 할 때 시작 각도는 3시 방향에서 시작하여 시계 방향으로 돌면서 6시 방향에서 90도, 9시 방향에서 180도와 같은 방식으로 3시 방향에서 360도까지 진행합니다.

코드 ⑦과 ⑧은 원의 반지름 값을 50과 75로 각각 다르게 지정해서 타원을 그립니다. 코드 ⑨와 ⑩은 타원을 15도와 45도만큼 회전하였습니다. 회전 각도는 0~360 사이의 각도를 지정하고, 필요에 따라 음수를 지정해서 회전 방향을 반대로 할 수도 있습니다.

코드 ⑪과 ⑫는 회전한 타원의 표시 각을 지정해서 타원의 아랫부분과 윗부분 호를 표시합니다. 회전한 원이나 타원에 대한 호를 표시할 때의 각도 값은 원래의 3시 방향에서 0도였던 것보다 회전한 각도만큼 더 이동해서 시작합니다.

5. 글씨 그리기

문자열을 이미지에 표시하는 함수는 cv2.putText() 입니다.

  • cv2.putText(img, text, point, fontFace, fontSize, color [, thickness, lineType])
    • img : 글씨를 표시할 이미지
    • text : 표시할 문자열
    • point : 글씨를 표시할 좌표(좌측 하단 기준)(x, y)
    • fontFace : 글꼴
      • cv2.FONT_HERSHEY_PLAIN : 산세리프체 작은 글꼴
      • cv2.FONT_HERSHEY_SIMPLEX : 산세리프체 일반 글꼴
      • cv2.FONT_HERSHEY_DUPLEX : 산세리프체 진한 글꼴
      • cv2.FONT_HERSHEY_COMPLEX_SMALL : 세리프체 작은 글꼴
      • cv2.FONT_HERSHEY_COMPLEX : 세리프체 일반 글꼴
      • cv2.FONT_HERSHEY_TRIPLEX : 세리프체 진한 글꼴
      • cv2.FONT_HERSHEY_SCRIPT_SIMPLEX : 필기체 산세리프 글꼴
      • cv2.FONT_HERSHEY_SCRIPT_COMPLEX : 필기체 세리프 글꼴
      • cv2.FONT_ITALIC : 이탤릭체 플래그
    • fontSize : 글꼴 크기
    • color, thickness, lineType : cv2.retangle() 과 동일

point 좌표는 문자열의 좌측 하단을 기준으로 지정해야 합니다. 선택할 수 있는 글꼴의 종류는 위의 설명처럼 cv2.FONT_HERSHEY_ 로 시작하는 상수로 정해져 있습니다. 크게 세리프(serif)체와 산세리프(sans-serif)체 그리고 필기체로 나뉘는데, 세리프체는 한글 글꼴의 명조체처럼 글자 끝에 장식을 붙여 모양을 낸 글꼴을 통틀어 말하며, 산세리프체는 고딕체처럼 획에 특별히 모양을 낸 것이 없는 글꼴을 말합니다. sans는 프랑스어로 ‘없다’는 뜻이고, serif는 타이포그래피에서 획의 끝이 돌출된 부분을 말하는 것으로 산세리프는 세리프가 없다는 뜻입니다.

OpenCV 상수에서는 상대적으로 단순한 모양인 산세리프체에 SIMPLEX라는 이름을 붙였고, 상대적으로 복잡한 모양인 세리프체에 COMLEX라는 이름을 붙인 것을 볼 수 있습니다.

'''글씨 그리기'''
import cv2

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

# sans-serif small
cv2.putText(img, "Plain", (50, 30), cv2.FONT_HERSHEY_PLAIN, 1, (0,0,0))
# sans-serif normal
cv2.putText(img, "Simplex", (50, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0,0,0))
# sans-serif bold
cv2.putText(img, "Duplex", (50, 110), cv2.FONT_HERSHEY_DUPLEX, 1, (0,0,0))
# sans-serif normall X2 ---①
cv2.putText(img, "Simplex", (200, 110), cv2.FONT_HERSHEY_SIMPLEX, 2, (0,0,250))

# serif small
cv2.putText(img, "Complex Small", (50, 180), cv2.FONT_HERSHEY_COMPLEX_SMALL, 1, (0, 0, 0))
# serif normal
cv2.putText(img, "Complex", (50, 220), cv2.FONT_HERSHEY_COMPLEX, 1, (0,0,0))
# serif bold
cv2.putText(img, "Triplex", (50, 260), cv2.FONT_HERSHEY_TRIPLEX, 1, (0,0,0))
# serif normal X2 ---②
cv2.putText(img, "Complex", (200, 260), cv2.FONT_HERSHEY_TRIPLEX, 2, (0,0,255))

# hand-wringing sans-serif
cv2.putText(img, "Script Simplex", (50, 330), cv2.FONT_HERSHEY_SCRIPT_SIMPLEX, 1, (0,0,0))
# hand-wringing serif
cv2.putText(img, "Script Complex", (50, 370), cv2.FONT_HERSHEY_SCRIPT_COMPLEX, 1, (0,0,0))

# sans-serif + italic ---③
cv2.putText(img, "Plain Italic", (50, 430), cv2.FONT_HERSHEY_PLAIN | cv2.FONT_ITALIC, 1, (0,0,0))
# sarif + italic
cv2.putText(img, "Complex Italic", (50, 470), cv2.FONT_HERSHEY_COMPLEX | cv2.FONT_ITALIC, 1, (0,0,0))

cv2.imshow('draw text', img)
cv2.waitKey()
cv2.destroyAllWindows()


image

위 코드는 각각의 글꼴을 보여주고 있습니다. 코드 ①에서 산세리프체의 일반 글꼴 크기를 2배로 표시하고, 코드 ②에서 세리프체의 일반 글꼴 크기를 2배로 표시하고 있습니다. 코드 ③은 산세리프체와 세리프체를 이탤릭 플래그와 함께 사용하는 방법을 보여주고 있습니다.

Read more

OpenCV를 이용한 이미지와 비디오 입출력

|

Hits


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


이미지와 비디오 입출력

OpenCV를 이용한 대부분의 작업은 파일로 된 이미지를 읽어서 적절한 연산을 적용하고 그 결과를 화면에 표시하거나 다른 파일로 저장하는 것입니다. 여기서는 이미지 파일을 읽고 화면에 표시하고 저장하는 방법을 중점적으로 살펴보겠습니다.

1. 이미지 읽기

OpenCV를 사용해서 이미지를 읽고 화면에 표시하는 가장 간단한 코드는 아래와 같습니다.

'''이미지 파일을 화면에 표시'''
import cv2

img_file = "./img/wonyoung.jpg" # 표시할 이미지 경로            ---①
img = cv2.imread(img_file)      # 이미지를 읽어서 img 변수에 할당 ---②

if img is not None:
    cv2.imshow('IMG', img)      # 읽은 이미지를 화면에 표시  ---③
    cv2.waitKey()               # 키가 입력될 때 까지 대기  ---④
    cv2.destroyAllWindows()     # 창 모두 닫기           ---⑤
else:
    print('No image file.')


image

코드 ①의 경로에 표시할 이미지 파일이 저장되어 있어야 합니다. 이 파일을 코드 ②에서 cv2.imread() 함수로 읽어들입니다. 이 함수가 반환하는 타입은 NumPy 배열입니다. 이 반환 값이 정상인지 아닌지 확인하고 나서 코드 ③에서 cv2․imshow() 함수를 써서 화면에 표시합니다. 이미지와 함께 전달한 문자열 ‘IMG’는 창의 제목줄에 나타납니다.

만약 코드가 코드 ③까지만 작성되어 있다면 더 이상 실행할 코드가 없어서 프로그램은 바로 종료될 것입니다. 그렇게 되면 사진을 표시한 이 창은 아주 짧은 시간 동안만 나타나 우리 눈으로는 볼 수 없게 됩니다. 그래서 코드 ④가 필요합니다. cv2.waitKey() 함수는 키보드의 입력이 있을 때까지 프로그램을 기다리게 합니다. 키가 입력되면 코드는 코드 ⑤의 cv2.destroyAllWindows() 함수에 의해서 표시한 창을 모두 닫고 나서 프로그램을 종료합니다.

위에서 사용한 함수는 다음과 같습니다.

  • img = cv2.imread(file_name [, mode_flag]) : 파일로부터 이미지 읽기
    • file_name : 이미지 경로, 문자열
    • mode_flag=cv2.IMREAD_COLOR : 읽기 모드 지정
      • cv2.IMREAD_COLOR : 컬러(BGR) 스케일로 읽기, 기본 값
      • cv2.IMREAD_UNCHANGED : 파일 그대로 읽기
      • cv2.IMREAD_GRAYSCALE : 그레이(흑백) 스케일로 읽기
    • img : 읽은 이미지, NumPy 배열
  • cv2.imshow(title, img) : 이미지를 화면에 표시
    • title : 창 제목, 문자열
    • img : 표시할 이미지, NumPy 배열
  • key = cv2.waitKey([delay]) : 키보드 입력 대기
    • delay=0 : 키보드 입력을 대기할 시간(ms), 0: 무한대(기본 값)
    • key : 사용자가 입력한 키 값, 정수
      • -1 : 대기시간 동안 키 입력 없음

cv2.imread() 함수는 파일로부터 이미지를 읽을 때 모드를 지정할 수 있습니다. 별도로 모드를 지정하지 않으면 3개 채널(B, G, R)로 구성된 컬러 스케일로 읽어들이지만, 필요에 따라 그레이 스케일 또는 파일에 저장된 스케일 그대로 읽을 수 있습니다.

img = cv2.imread(file_name, cv2.IMREAD_GRAYSCALE)

위의 코드와 같이 읽기 모드를 그레이 스케일로 지정하면 원래의 파일이 컬러 이미지일지라도 그레이 스케일로 읽습니다. 물론 그레이 이미지 파일을 cv2.IMREAD_COLOR 옵션을 지정해서 읽는다고 컬러 이미지로 읽어올 수 있는 것은 아닙니다.

'''이미지 파일을 그레이 스케일로 화면에 표시'''
import cv2

img_file = "./img/wonyoung.jpg" 
img = cv2.imread(img_file, cv2.IMREAD_GRAYSCALE) # 그레이 스케일로 읽기

if img is not None:
    cv2.imshow('IMG', img)
    cv2.waitKey()
    cv2.destroyAllWindows()
else:
    print('No image file.')


image

2. 이미지 저장하기

OpenCV로 읽어들인 이미지를 다시 파일로 저장하는 함수는 cv2.imwrite() 입니다.

  • cv2.imwrite(file_path, img) : 이미지를 파일에 저장
    • file_path : 저장할 파일 경로 이름, 문자열
    • img : 저장할 영상, NumPy 배열

다음 코드는 컬러 이미지 파일을 그레이 스케일로 읽어들여서 파일로 저장하는 예제입니다. 탐색기나 파인더 등과 같은 파일 관리자로 해당 경로를 살펴보면 그레이 스케일로 바뀐 새로운 파일이 저장된 것을 확인할 수 있습니다. 저장하는 이미지의 파일 포맷은 지정한 파일 이름의 확장자에 따라서 알아서 바뀝니다.

'''컬러 이미지를 그레이 스케일로 저장'''
import cv2

img_file = './img/wonyoung.jpg'
save_file = './img/wonyoung_gray.jpg'

img = cv2.imread(img_file, cv2.IMREAD_GRAYSCALE)
cv2.imshow(img_file, img)
cv2.imwrite(save_file, img) # 파일로 저장, 포맷은 확장에 따름
cv2.waitKey()
cv2.destroyAllWindows()

3. 동영상 및 카메라 프레임 읽기

OpenCV는 동영상 파일이나 컴퓨터에 연결한 카메라 장치로부터 연속된 이미지 프레임을 읽을 수 있는 API를 제공합니다.

다음은 동영상 파일이나 연속된 이미지 프레임을 읽을 수 있는 API의 주요 내용입니다.

  • cap = cv2․VideoCapture(file_path 또는 index) : 비디오 캡처 객체 생성자
    • file_path : 동영상 파일 경로
    • index : 카메라 장치 번호, 0부터 순차적으로 증가(0, 1, 2, …)
    • cap : VideoCapture 객체
  • ret = cap.isOpened() : 객체 초기화 확인
    • ret : 초기화 여부, True/False
  • ret, img = cap.read() : 영상 프레임 읽기
    • ret : 프레임 읽기 성공 또는 실패 여부, True/False
    • img : 프레임 이미지, NumPy 배열 또는 None
  • cap.set(id, value) : 프로퍼티 변경
  • cap.get(id) : 프로퍼티 확인
  • cap.release() : 캡처 자원 반납

동영상 파일이나 컴퓨터에 연결한 카메라 장치로부터 영상 프레임을 읽기 위해서는 cv2.VideoCapture() 생성자 함수를 사용하여 객체를 생성해야 합니다. 이 함수에 동영상 파일 경로 이름을 전달하면 동영상 파일에 저장된 프레임을 읽을 수 있고, 카메라 장치 번호를 전달하면 카메라로 촬영하는 프레임을 읽을 수 있습니다.

객체를 생성하고 나면 isOpened() 함수로 파일이나 카메라 장치에 제대로 연결되었는지 확인할 수 있고, 연결이 잘 되었다면 read() 함수로 다음 프레임을 읽을 수 있습니다. read() 함수는 Boolean과 NumPy 배열 객체를 쌍으로 갖는 튜플 (ret, img) 객체를 반환하는데, 다음 프레임을 제대로 읽었는지에 따라 ret 값이 정해집니다. 만약 ret 값이 True 이면 다음 프레임 읽기에 성공한 것이고, img 를 꺼내서 사용하면 됩니다. 만약 ret 값이 False 이면 다음 프레임 읽기에 실패한 것이고, 튜플의 나머지 값인 imgNone 입니다. 다음 프레임 읽기에 실패하는 경우는 파일이나 장치에 문제가 있거나 파일의 끝에 도달했을 경우입니다.

비디오 캡처 객체의 set(), get() 함수를 이용하면 여러 가지 속성을 얻거나 지정할 수 있으며, 프로그램을 종료하기 전에 release() 함수를 호출해서 자원을 반납해야 합니다.

4. 동영상 파일 읽기

다음은 동영상 파일을 읽기 위한 간단한 코드입니다.

'''동영상 파일 재생'''
import cv2

video_file = "./img/big_buck.avi"   # 동영상 파일 경로

cap = cv2.VideoCapture(video_file)  # 동영상 캡쳐 객체 생성 ---①
if cap.isOpened():                  # 캡쳐 객체 초기화 확인
    while True:
        ret, img = cap.read()       # 다음 프레임 읽기 ---②
        if ret:                     # 프레임 읽기 정상
            cv2.imshow(video_file, img) # 화면에 표시 ---③
            cv2.waitKey(25)             # 25ms 지연(40fps로 가정) ---④
        else:                       # 다음 프레임 읽을 수 없슴,
            break                   # 재생 완료
else:
    print("can't open video.")      # 캡쳐 객체 초기화 실패

cap.release()                       # 캡쳐 자원 반납
cv2.destroyAllWindows()


image

코드 ①에서는 cv2.VideoCapture() 함수에 동영상 파일 경로를 전달해서 캡처 객체 cap 을 생성합니다. 캡처 객체가 정상적으로 지정한 파일로 초기화되면 cap.isOpened() 함수는 True 를 반환합니다. 연속해서 파일의 프레임을 읽어오기 위해서 무한 루프를 돌리면서 cap.read() 를 호출하는데, 이 함수는 정상적인 프레임 읽기가 되었는지를 확인할 수 있는 불(boolean) 변수와 한 개의 프레임 이미지를 표현한 NumPy 배열 객체를 쌍으로 갖는 튜플 객체를 반환합니다. 그 다음 프레임 이미지를 화면에 표시하는 것은 이전의 코드와 거의 비슷합니다.

코드 ④에서 cv2.waitKey(25) 가 필요한 이유는 각 프레임을 화면에 표시하는 시간이 너무 빠르면 우리 눈으로 볼 수 없기 때문입니다. 이때 지연 시간은 동영상의 FPS(Frames Per Second, 초당 프레임 수) 에 맞게 조정해서 적절한 속도로 영상을 재생하게 해야 합니다.

FPS와 지연 시간 구하기

동영상 파일의 정확한 FPS를 쉽게 얻는 방법은 곰플레이어, 다음팟플레이어, VLC 등과 같은 무료 동영상 플레이어에서 속성 값을 확인하는 것입니다. FPS를 대충 추정하거나 다른플레이어로 구했다면 이에 맞는 지연 시간을 구해야 할 것입니다. FPS에 맞는 지연 시간을 구하는 공식은 1초에 몇 개의 사진이 들어가야 하는가를 구하는 것으로 다음과 같습니다.

[지연시간 = 1000 / fps]

1,000으로 계산하는 이유는 1초를 밀리초(ms) 단위로 환산해서 제공해야 하기 때문입니다. FPS를 40으로 가정해서 대입한 결과는 다음과 같습니다.

[25 = 1000 / 40]

5. 카메라(웹캠) 프레임 읽기

카메라로 프레임을 읽기 위해서는 cv2.VideoCapture() 함수에 동영상 파일 경로 대신에 카메라 장치 인덱스 번호를 정수로 지정해 주면 됩니다. 카메라 장치 인덱스 번호는 0부터 시작해서 1씩 증가합니다. 만약 카메라가 하나만 연결되어 있으면 당연히 0번 인덱스를 사용하면 됩니다. 이 부분을 제외하고는 나머지 코드는 동영상 파일을 읽는 것과 거의 똑같습니다.

'''카메라 프레임 읽기'''
import cv2

cap = cv2.VideoCapture(0)               # 0번 카메라 장치 연결 ---①
if cap.isOpened():                      # 캡쳐 객체 연결 확인
    while True:
        ret, img = cap.read()           # 다음 프레임 읽기
        if ret:
            cv2.imshow('camera', img)   # 다음 프레임 이미지 표시
            if cv2.waitKey(1) != -1:    # 1ms 동안 키 입력 대기 ---②
                break                   # 아무 키라도 입력이 있으면 중지
        else:
            print('no frame')
            break
else:
    print("can't open camera.")

cap.release()                           # 자원 반납
cv2.destroyAllWindows()


image

코드 ①에서는 0번 카메라 장치에서 촬영한 프레임을 읽어서 화면에 표시합니다. 동영상 파일과는 다르게 카메라로부터 프레임을 읽는 경우 파일의 끝이 정해져 있지 않으므로 무한루프를 빠져 나올 조건이 없습니다. 그래서 코드 ②에서 사용자가 아무 키나 누르면 빠져 나오게 했습니다. 따라서 이 프로그램을 종료하려면 키보드의 아무 키나 누르면 됩니다. cv2.waitKey() 함수는 지정한 대기 시간 동안 키 입력이 없으면 -1을 반환합니다. 반환된 값이 -1이 아니면 당연히 아무 키나 입력되었다는 뜻입니다.

6. 카메라 비디오 속성 제어

캡처 객체에는 영상 또는 카메라의 여러 가지 속성을 확인하고 설정할 수 있는 get(id), set(id, value) 함수를 제공합니다. 속성을 나타내는 아이디는 cv2.CAP_PROP_ 으로 시작하는 상수로 정의되어 있습니다.

  • 속성 ID : cv2.CAP_PROP_ 로 시작하는 상수
    • cv2.CAP_PROP_FRAME_WIDTH : 프레임 폭
    • cv2.CAP_PROP_FRAME_HEIGHT : 프레임 높이
    • cv2.CAP_PROP_FPS : 초당 프레임 수
    • cv2.CAP_PROP_POS_MSEC : 동영상 파일의 프레임 위치(ms)
    • cv2.CAP_PROP_POS_AVI_RATIO : 동영상 파일의 상대위치(0: 시작, 1: 끝)
    • cv2.CAP_PROP_FOURCC : 동영상 파일 코덱 문자
    • cv2.CAP_PROP_AUTOFOCUS : 카메라 자동 초점 조절
    • cv2.CAP_PROP_ZOOM : 카메라 줌

각 속성 아이디를 get() 에 전달하면 해당 속성의 값을 구할 수 있고, set() 함수에 아이디와 값을 함께 전달하면 값을 지정할 수 있습니다.

앞서 동영상 파일을 재생하는 코드에서는 적절한 FPS에 따라 지연 시간을 설정해야하지만, FPS를 대충 짐작하거나 별도의 플레이어를 활용해서 알아내야 했습니다. 비디오 속성 중에 FPS를 구하는 상수는 cv2.CAP_PROP_FPS 이고 이것으로 동영상의 FPS를 구하고 다음과 같이 적절한 지연 시간을 계산해서 지정할 수 있습니다.

fps = cap.get(cv2.CAP_PROP_FPS) # 초당 프레임 수 구하기
delay = int(1000/fps)           # 지연 시간 구하기

cv2.waitKey() 함수에 전달하는 지연 시간은 밀리초(1/1000초) 단위이고 정수만 전달할 수 있으므로 1초를 1000으로 환산해서 계산한 뒤 정수형으로 바꿉니다. FPS에 맞는 지연 시간을 지정해서 완성한 코드는 다음과 같습니다.

'''FPS를 지정해서 동영상 재생'''
import cv2

video_file = "./img/big_buck.avi"   # 동영상 파일 경로

cap = cv2.VideoCapture(video_file)  # 동영상 캡쳐 객체 생성
if cap.isOpened():                  # 캡쳐 객체 초기화 확인
    fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
    delay = int(1000/fps)
    print("FPS: %f, Delay: %dms" %(fps, delay))

    while True:
        ret, img = cap.read()       # 다음 프레임 읽기
        if ret:                     # 프레임 읽기 정상
            cv2.imshow(video_file, img) # 화면에 표시
            cv2.waitKey(delay)          # fps에 맞게 시간 지연
        else:
            break                       # 다음 프레임 읽을 수 없음, 재생 완료
else:
    print("can't open video.")      # 캡쳐 객체 초기화 실패

cap.release()                       # 캡쳐 자원 반납
cv2.destroyAllWindows()

[output]
FPS: 24.000000, Delay: 41ms

아쉽게도 FPS 속성을 카메라 장치로부터 읽을 때는 대부분 정상적인 값을 가져오지 못합니다.

다른 예시로 카메라로부터 읽은 영상이 너무 고화질인 경우 픽셀 수가 많아 연산하는 데 시간이 많이 걸리는 경우가 있습니다. 이때 프레임의 폭과 높이를 제어해서 픽셀 수를 줄일 수 있습니다. 프레임의 폭과 높이 속성 아이디 상수는 cv2.CAP_PROP_FRAME_WIDTHcv2.CAP_PROP_FRAME_HEIGHT 입니다. 카메라 기본 영상 프레임의 폭과 높이를 구해서 출력하고 새로운 크기를 지정하는 코드는 다음과 같습니다.

'''카메라 프레임 크기 설정'''
import cv2

cap = cv2.VideoCapture(0)                   # 카메라 0번 장치 연결

width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)   # 프레임 폭 값 구하기
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) # 프레임 높이 값 구하기
print("Original width: %d, height:%d" % (width, height) ) 

cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)      # 프레임 폭을 320으로 설정 
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)     # 프레임 높이를 240으로 설정

width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)   # 재지정한 프레임 폭 값 구하기
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) # 재지정한 프레임 폭 값 구하기
print("Resized width: %d, height:%d" % (width, height) )

if cap.isOpened():
    while True:
        ret, img = cap.read()
        if ret:
            cv2.imshow('camera', img)
            if cv2.waitKey(1) != -1:
                break
        else:
            print('no frame!')
            break
else:
    print("can't open camera!")

cap.release()
cv2.destroyAllWindows()

[output]
Original width: 1280, height:720
Resized width: 640, height:480


image

파이썬 콘솔에는 위와 같이 원래의 프레임 크기와 새로 지정한 프레임 크기가 출력됩니다. 아쉽게도 카메라가 아닌 동영상 파일에 프레임 크기를 재지정하는 것은 적용되지 않습니다.

7. 비디오 파일 저장하기

카메라나 동영상 파일을 재생하는 도중 특정한 프레임만 이미지로 저장하거나 특정 구간을 동영상 파일로 저장할 수도 있습니다. 한 개의 특정 프레임만 파일로 저장하는 방법은 cv2.imwirte() 함수를 그대로 사용하면 됩니다.

다음 예제는 카메라로부터 프레임을 표시하다가 아무 키나 누르면 해당 프레임을 파일로 저장하는 코드입니다. 흔히 디지털 카메라로 사진을 찍는 것과 같다고 할 수 있습니다.

'''카메라로 사진 찍기'''
import cv2

cap = cv2.VideoCapture(0)                       # 0번 카메라 연결
if cap.isOpened() :
    while True:
        ret, frame = cap.read()                 # 카메라 프레임 읽기
        if ret:
            cv2.imshow('camera', frame)         # 프레임 화면에 표시
            if cv2.waitKey(1) != -1:            # 아무 키나 누르면
                cv2.imwrite('photo.jpg', frame) # 프레임을 'photo.jpg'에 저장
                break
        else:
            print('no frame!')
            break
else:
    print('no camera!')

cap.release()
cv2.destroyAllWindows()

위 코드를 실행하면 카메라로부터 촬영한 영상이 화면에 나오는데, 카메라를 보고자 세를 취하면서 키보드의 아무 키나 누르면 코드를 실행한 디렉터리에 photo.jpg로 사진이 저장됩니다.

하나의 프레임이 아닌 여러 프레임을 동영상으로 저장하려고 할 때는 cv2.VideoWriter() 라는 API가 필요합니다.

  • writer = cv2.VideoWriter(file_path, fourcc, fps, (width, height)) : 비디오 저장 클래스 생성자 함수
    • file_path : 비디오 파일 저장 경로
    • fourcc : 비디오 인코딩 형식 4글자
    • fps : 초당 프레임 수
    • (width, height) : 프레임 폭과 프레임 높이
    • writer : 생성된 비디오 저장 객체
  • writer.write(frame) : 프레임 저장
    • frame : 저장할 프레임, NumPy 배열
  • writer.set(id, value) : 프로퍼티 변경
  • writer.get(id) : 프로퍼티 확인
  • ret = writer.fourcc(c1, c2, c3, c4) : fourcc 코드 생성
    • c1, c2, c3, c4 : 인코딩 형식 4글자, ‘MJPG’, ‘DIVX’ 등
    • ret : fourcc 코드
  • cv2.VideoWriter_fourcc(c1, c2, c3, c4) : cv2.VideoWriter.fourcc() 와 동일

cv2․VideoWriter() 생성자 함수에 저장할 파일 이름과 인코딩 포맷 문자, fps, 프레임 크기를 지정해서 객체를 생성하고 write() 함수로 프레임을 파일에 저장하면 됩니다.

cv2.VideoWriter_fourcc() 함수는 4개의 인코딩 포맷 문자를 전달하면 코드 값을 생성해 내는 함수로, ‘DIVX’를 예로 들면 다음 두 코드는 그 결과가 똑같습니다.

fourcc = cv2.VideoWriter_foucc(*"DIVX")

# or
fourcc = ord('D') + (ord('I') << 8) + (ord('V') << 16) + (ord('X') << 24)

결국 4개의 문자를 한 문자당 8비트씩을 사용해서 각 자릿수에 맞게 표현한 것입니다.

'''카메라로 녹화하기'''
import cv2

cap = cv2.VideoCapture(0)   # 0번 카메라 연결

if cap.isOpened:
    file_path = './record.avi'  # 저장할 파일 경로 이름 ---①
    fps = 30.0                  # FPS, 초당 프레임 수
    fourcc = cv2.VideoWriter_fourcc(*'DIVX')    # 인코딩 포맷 문자
    width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
    height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
    size = (int(width), int(height))                    # 프레임 크기
    out = cv2.VideoWriter(file_path, fourcc, fps, size) # VideoWriter 객체 생성
    
    while True:
        ret, frame = cap.read()
        if ret:
            cv2.imshow('camera-recording',frame)
            out.write(frame)                        # 파일 저장
            if cv2.waitKey(int(1000/fps)) != -1: 
                break
        else:
            print("no frame!")
            break
    out.release()                                   # 파일 닫기
else:
    print("can't open camera!")

cap.release()
cv2.destroyAllWindows()

위 코드를 실행하면 카메라 영상이 화면에 나타나고 코드 ①에서 지정한 경로에 동영상이 녹화되어 저장되기 시작하고 키보드의 아무 키나 누르면 종료됩니다. 탐색기나 파인더와 같은 파일 관리자로 코드 ①에서 지정한 경로를 살펴보면 동영상이 저장된 것을 확인할 수 있습니다.

Read more