by museonghwang

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 배열의 연산은 수학에서의 행렬과 벡터의 연산과 비슷해 보이지만 배열의 곱셈 연산은 행렬 연산과는 다르니 주의해야합니다.