Python + Numba CUDA で画像処理

Python + Numba CUDA で GPU プログラミングしてみるシリーズ

- その０： [Python + Numba CUDA で GPU プログラミング入門以前](https://qiita.com/hoto17296/items/332da1d57173534c3aa7)
- その１： Python + Numba CUDA で画像処理 **[← イマココ]**
- その２： [Python + Numba CUDA でライフゲーム](https://qiita.com/hoto17296/items/80dd5ee389135abd2ed7)

前回は GPU マシンに Numba と CUDA Toolkit を入れて、とりあえず GPU で簡単な計算をさせるプログラムを動かすことができた。

今回は CUDA のプログラミングモデルを理解しながら、GPU で画像を扱えるところまでやっていく。

## CUDA のプログラミングモデル
- ゼロから始めるGPU Computing | G-DEP
    - [第５回　GPUの構造](http://www.gdep.jp/page/view/252)
    - [第６回　CUDAプログラミングモデル①](http://www.gdep.jp/page/view/253)

このページがとても詳しくてわかりやすいのでここを読んでもらったほうが早い。

### ホストとデバイス
- CPU 側のことを**ホスト**、GPU 側のことを**デバイス**と呼ぶ
- デバイス側で実行される処理を**カーネル関数**を呼ぶ
- 並列処理をする流れは
    0. カーネル関数をデバイスに転送する
    0. データをデバイスのメモリに転送する
    0. カーネル関数を起動する

### スレッド・ブロック・グリッド
並列処理の並列数はスレッド・ブロック・グリッドという単位で管理される。

- **スレッド**はカーネル関数が実行される最小単位
    - ひとつのスレッドはひとつの CUDA コア内で実行される
- **ブロック**はスレッドをまとめた単位
    - 1ブロックあたり最大で 512 スレッドをまとめられる
    - ３次元的な表現で管理でき、例えば「8x8x8 の３次元で合計 512 スレッド」のようにまとめられる
    - ひとつのブロックの処理はひとつの SM (ストリーミングマルチプロセッサ) 内で実行される
- **グリッド**はブロックをまとめた単位
    - 1グリッドあたり最大で 65536x65536 ブロックをまとめられる
    - ひとつのグリッドはひとつのデバイス (GPU) 内で実行される

カーネル関数を起動する際は「１ブロックあたりのスレッド数」「１グリッドあたりのブロック数」を指定する必要がある。

## Numba CUDA の使い方
ざっくり解説するが、詳しくは公式ドキュメント見て欲しい。

[Numba for CUDA GPUs — Numba documentation](http://numba.pydata.org/numba-doc/latest/cuda/index.html)

### カーネル関数の定義
- `@cuda.jit` デコレータをつけて関数を定義するとそれがカーネル関数になる。
- カーネル関数内で `cuda` オブジェクトから現在のスレッドに関する情報が得られる
    - これにより、現在のスレッドでは入力データのどの要素を対象に処理をすればいいかを計算することができる

例えば、入力が二次元配列 (行列) の場合は以下のようにして処理対象の座標 x, y を計算する。

``` python
from numba import cuda
import numpy as np

@cuda.jit
def increment_by_one(arr):
    """行列の要素に 1 を足すカーネル関数"""
    tx = cuda.threadIdx.x
    ty = cuda.threadIdx.y
    bx = cuda.blockIdx.x
    by = cuda.blockIdx.y
    bw = cuda.blockDim.x
    bh = cuda.blockDim.y
    x = tx + bx * bw
    y = ty + by * bh

    arr[x, y] += 1
```

また、これは Numba CUDA の便利メソッドを利用すると以下のように書き換えることができる。

``` python
from numba import cuda
import numpy as np

@cuda.jit
def increment_by_one(arr):
    """行列の要素に 1 を足すカーネル関数"""
    x, y = cuda.grid(2)
    arr[x, y] += 1
```

### カーネル関数の起動
定義したカーネル関数に対して、griddim と blockdim を指定した上で関数を実行する。

``` python
griddim = 1  # 1グリッドあたりのブロック数
blockdim = 3, 3  # 1ブロックあたりのスレッド数

# 入力する行列
arr = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])

increment_by_one[griddim, blockdim](arr)

print(arr)  #=> [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
```

## 画像を読み込んで NumPy 配列に変換する
画像処理を行うための事前準備として、今回利用する画像ファイルを読み込めるようにしておく。

Python では、PIL で読み込んだ画像オブジェクトを `np.array` に渡すことで画像を NumPy 配列に変換することができる。

``` python
import numpy as np
from PIL import Image

img = Image.open('lenna.gif')
arr = np.array(img)
```

NumPy 配列の画像は、PIL の関数を使うことで PIL 画像に戻すことができる。

``` python
Image.fromarray(img_inverted)
```

![lenna.gif](https://qiita-image-store.s3.amazonaws.com/0/16369/3a015d65-ff4d-3dab-bb92-240269d123fa.gif)

## 画像の色を反転させる
簡単な例として、画像の色を反転させるだけの処理を GPU で行ってみる。

``` python
from numba import cuda
import numpy as np
from PIL import Image

@cuda.jit
def invert_color(img_in, img_out):
    """画像の色を反転させるカーネル関数"""
    x, y = cuda.grid(2)
    if x < img_in.shape[0] and y < img_in.shape[1]:
        img_out[x, y] = 0xFF - img_in[x, y]

# 画像を読み込んで NumPy 配列に変換する
img = np.array(Image.open('lenna.gif'))

# 変換結果を保存する NumPy 配列を作る
img_inverted = np.zeros(img.shape, dtype=np.uint8)

# データを GPU に転送してカーネル関数を実行する
invert_color[img.shape, 1](img, img_inverted)
```

`img_inverted` の配列を画像として表示してみると、反転できていることがわかる。

![img_inverted](https://qiita-image-store.s3.amazonaws.com/0/16369/1c651628-5869-f330-e4b2-7c22bc5cd386.png)

## 畳み込み演算
もう少し複雑な計算の例として、ラプラシアンフィルタでの画像のエッジ検出を試してみる。

ラプラシアンフィルタでは、以下のカーネル行列[^kernel]を用いて畳み込み演算を行う。

[^kernel]: CUDA のカーネル関数と畳み込み演算のカーネル行列で「カーネル」という言葉が被っていて紛らわしいが、今回の話ではこれらは別物と考えてもらってよい

```math
\begin{bmatrix}
1 & 1 & 1 \\
1 & -8 & 1 \\
1 & 1 & 1
\end{bmatrix}
```

``` python
from numba import cuda
import numpy as np
from PIL import Image

@cuda.jit
def convolution(kernel, img_in, img_out):
    """畳み込み演算を行うカーネル関数"""
    x, y = cuda.grid(2)
    if x < img_in.shape[0] and y < img_in.shape[1]:
        val = 0
        for i in range(kernel.shape[0]):
            if x + i == 0 or x + i == img_in.shape[0]:
                continue
            for j in range(kernel.shape[1]):
                if y + j == 0 or y + j == img_in.shape[1]:
                    continue
                val += img_in[x+i-1, y+j-1] * kernel[i, j]
        img_out[x, y] = val if val > 0 else 0

# 画像を読み込んで NumPy 配列に変換する
img = np.array(Image.open('lenna.gif'))

# 変換結果を保存する NumPy 配列を作る
img_filtered = np.zeros(img.shape, dtype=np.uint8)

# 畳み込み演算に用いるカーネル行列を定義する
kernel = np.array([
    [ 1,  1,  1],
    [ 1, -8,  1],
    [ 1,  1,  1],
])

# 畳み込み演算を実行する
convolution[img.shape, 1](kernel, img, img_filtered)
```

フィルタ後の画像を表示すると、ちゃんとエッジが検出できていることがわかる。

![img_filtered](https://qiita-image-store.s3.amazonaws.com/0/16369/06b5d433-1ed3-71ed-1afe-7cfb73811ae7.png)

## まとめ
GPU を用いて画像の畳み込み演算を行うことができた。

この程度の処理であれば CPU でやった方が速い (GPU はデータ転送のオーバーヘッドが大きい) のだが、より計算量の多い処理を行う際には GPU の恩恵が得られるに違いない。

ひとまず今回はここまで。