OpenCVの画像をPygameで描画する方法

PythonでOpenCVを動かし、解析・加工した結果を表示したい場合、`cv2.imshow()`メソッドを使うのが一番簡単です。
しかし、ただ表示するだけ以上のことをしたい場合、OpenCVのウィンドウでは機能不足です。
このような問題を解決する方法の一つとして、PythonのGUIフレームワークであるPygameを使うやり方があります。
この投稿では、OpenCVの画像をPygame向けの画像に変換するやり方について書きます。

## 環境

<dl>
<dt>ハードウェア
<dd><a href="https://www.raspberrypi.org/products/raspberry-pi-3-model-b-plus/">Raspberry Pi 3 Model B+</a>
<dt>OS
<dd>Raspbian Buster Lite / 2019-09-26
<dt>Python
<dd>3.7.3
<dt>OpenCV
<dd>3.2.0
<dt>Pygame
<dd>1.9.4.post1
</dl>

## インストールコマンド

```shell
# OSの書き込み後、sshでログイン

# パッケージ・リストを更新
sudo apt update

# Python3用のOpenCVをインストール
sudo apt install python3-opencv

# Python3用のPygameをインストール
sudo apt install python3-pygame

# Pythonのバージョンを確認
python3 --version

# OpenCVのバージョンを確認
python3 -c 'import cv2; print(cv2.__version__)'

# Pygameのバージョンを確認
python3 -c 'import pygame; print(pygame.version.ver)'
```

## Web上で見つかるやり方

「[opencvで作った画像をpygameで描画する。 - BlankTar](https://blanktar.jp/blog/2016/01/pygame-draw-opencv-image.html)」によれば、以下のような方法で変換することができるようです。

```python
opencv_image = opencv_image[:,:,::-1]  # OpenCVはBGR、pygameはRGBなので変換してやる必要がある。
shape = opencv_image.shape[1::-1]  # OpenCVは(高さ, 幅, 色数)、pygameは(幅, 高さ)なのでこれも変換。
pygame_image = pygame.image.frombuffer(opencv_image.tostring(), shape, 'RGB')
```

この方法で変換するコードを書いてみましょう。

```python:show-image.py
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://blanktar.jp/blog/2016/01/pygame-draw-opencv-image.html
    """
    opencv_image = opencv_image[:,:,::-1]  # OpenCVはBGR、pygameはRGBなので変換してやる必要がある。
    shape = opencv_image.shape[1::-1]  # OpenCVは(高さ, 幅, 色数)、pygameは(幅, 高さ)なのでこれも変換。
    pygame_image = pygame.image.frombuffer(opencv_image.tostring(), shape, 'RGB')

    return pygame_image

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    pygame_image = convert_opencv_img_to_pygame(opencv_image)

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 5秒待って終了する
    time.sleep(5)
    pygame.quit()


if __name__ == '__main__':
    main()
```

```shell:起動コマンド
sudo python3 show-image.py
# Note: SDLライブラリを使用して表示させるために、「sudo」を追加してroot権限で実行する必要があります。
#       Liteではない「with desktop」版Raspbianや、sshコマンドに「-X」オプションを追加して接続している場合などは、
#       X Window Systemを使用して表示できるため、「sudo」コマンドは不要です。
#       see https://www.subthread.co.jp/blog/20181206/
```

これを起動すると、5秒間画像が表示され、自動で終了します。変換は正しく動作します。

## 変換速度

さて、上の方法で、解析した映像をリアルタイムに表示する場合を考えます。カメラから読み取った映像を解析・表示する場合など、そのような用途はよくあります。この場合、重要なのは変換速度です。計測してみましょう。

```python:show-image.py
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://blanktar.jp/blog/2016/01/pygame-draw-opencv-image.html
    """
    opencv_image = opencv_image[:,:,::-1]  # OpenCVはBGR、pygameはRGBなので変換してやる必要がある。
    shape = opencv_image.shape[1::-1]  # OpenCVは(高さ, 幅, 色数)、pygameは(幅, 高さ)なのでこれも変換。
    pygame_image = pygame.image.frombuffer(opencv_image.tostring(), shape, 'RGB')

    return pygame_image

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    time_start = time.perf_counter()  # 計測開始
    pygame_image = convert_opencv_img_to_pygame(opencv_image)
    time_end = time.perf_counter()  # 計測終了
    print(f'変換時間: {time_end - time_start}秒 / {1/(time_end - time_start)}fps')

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 5秒待って終了する
    time.sleep(5)
    pygame.quit()


if __name__ == '__main__':
    main()
```

```
変換時間: 0.14485926300017127秒 / 6.903252020540914fps
```

**遅い！！！**

ただ表示するだけのコードで0.14秒。フレームレート換算で7fpsと書けば、この遅さがよく分かると思います。

これがOpenCV+Pygameの限界なのでしょうか？いいえ、違います。やり方を間違えているだけです。ここまで遅いのは、画像データを`tostring()`メソッドでわざわざ文字列化しているせいです。計測すればわかりますが、この重さの9割は`tostring()`メソッドのせいです。

## もっと高速なやり方

Python向けOpenCVの画像データの実態は、NumPyのndarrayです。Pygameには、NumPyの配列から画像データを読み込む関数が存在します。これを駆使したやり方が、「[OpenCV VideoCapture running on PyGame](https://gist.github.com/radames/1e7c794842755683162b)」に記載されています。

```python
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frame = np.rot90(frame)
frame = pygame.surfarray.make_surface(frame)
```

また、[「OpenCV VideoCapture running on PyGame」のコメント欄](https://gist.github.com/radames/1e7c794842755683162b#gistcomment-2981660)では、この方法で生じる画像の反転問題の解決策+さらなる高速化についても記載されています。

```diff
 frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
-frame = np.rot90(frame)
+frame = frame.swapaxes(0,1)
 frame = pygame.surfarray.make_surface(frame)
```

この方法に変更し、再度計測を行います。

```python:show-image.py
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    """
    rgb_image = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2RGB).swapaxes(0, 1)
    # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
    pygame_image = pygame.surfarray.make_surface(rgb_image)

    return pygame_image

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    time_start = time.perf_counter()  # 計測開始
    pygame_image = convert_opencv_img_to_pygame(opencv_image)
    time_end = time.perf_counter()  # 計測終了
    print(f'変換時間: {time_end - time_start}秒 / {1/(time_end - time_start)}fps')

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 5秒待って終了する
    time.sleep(5)
    pygame.quit()


if __name__ == '__main__':
    main()
```

```
変換時間: 0.030075492999912967秒 / 33.24966277370395fps
```

ずいぶんと改善しました。

## グレイスケール画像の描画

速度の問題が改善したため、OpenCVで何か処理をした結果を表示してみましょう。例えば、二値化した結果を表示する場合を考えます。

```python:show-image.py
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    """
    rgb_image = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2RGB).swapaxes(0, 1)
    # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
    pygame_image = pygame.surfarray.make_surface(rgb_image)

    return pygame_image

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # 画像を処理
    opencv_gray_image = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2GRAY)
    ret, opencv_threshold_image = cv2.threshold(opencv_gray_image, 128, 255, cv2.THRESH_BINARY)
    opencv_image = opencv_threshold_image

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    pygame_image = convert_opencv_img_to_pygame(opencv_image)

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 5秒待って終了する
    time.sleep(5)
    pygame.quit()


if __name__ == '__main__':
    main()
```

しかし、次のエラーが発生して実行は失敗します。

```
OpenCV Error: Assertion failed (scn == 3 || scn == 4) in cvtColor, file /build/opencv-L65chJ/opencv-3.2.0+dfsg/modules/imgproc/src/color.cpp, line 9716
Traceback (most recent call last):
  File "show-image.py", line 51, in <module>
    main()
  File "show-image.py", line 39, in main
    pygame_image = convert_opencv_img_to_pygame(opencv_image)
  File "show-image.py", line 17, in convert_opencv_img_to_pygame
    rgb_image = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2RGB).swapaxes(0, 1)
cv2.error: /build/opencv-L65chJ/opencv-3.2.0+dfsg/modules/imgproc/src/color.cpp:9716: error: (-215) scn == 3 || scn == 4 in function cvtColor
```

二値化された画像は、色成分の無いグレイスケール画像となります。しかし、Pygame用画像に変換する処理では、入力された画像の色をBGRからRGBに変換するコードが存在します。よって、この部分でエラーが発生してしまいます。

この問題を解決するために、グレイスケール画像の場合は`cv2.cvtColor()`メソッドの第二引数を`cv2.COLOR_GRAY2RGB`に変更する分岐処理が必要になります。

```python:show-image.py
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    see https://github.com/atinfinity/lab/wiki/%5BOpenCV-Python%5D%E7%94%BB%E5%83%8F%E3%81%AE%E5%B9%85%E3%80%81%E9%AB%98%E3%81%95%E3%80%81%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E6%95%B0%E3%80%81depth%E5%8F%96%E5%BE%97
    """
    if len(opencv_image.shape) == 2:
        # グレイスケール画像の場合
        cvt_code = cv2.COLOR_GRAY2RGB
    else:
        # その他の場合:
        cvt_code = cv2.COLOR_BGR2RGB
    rgb_image = cv2.cvtColor(opencv_image, cvt_code).swapaxes(0, 1)
    # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
    pygame_image = pygame.surfarray.make_surface(rgb_image)

    return pygame_image

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # 画像を処理
    opencv_gray_image = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2GRAY)
    ret, opencv_threshold_image = cv2.threshold(opencv_gray_image, 128, 255, cv2.THRESH_BINARY)
    opencv_image = opencv_threshold_image

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    pygame_image = convert_opencv_img_to_pygame(opencv_image)

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 5秒待って終了する
    time.sleep(5)
    pygame.quit()


if __name__ == '__main__':
    main()
```

これで、二値化されたグレイスケール画像も表示できるはずです。

## さらに高速化

カメラの映像を連続して処理する場合など、[同じ大きさ・色の画像を何度も変換する場合は、`pygame.surfarray.make_surface()`メソッドの代わりに`pygame.surfarray.blit_array()`メソッドを使用して高速化できます](https://stackoverflow.com/a/42589544/4907315)。

まず、変換のたびに`pygame.surfarray.make_surface()`メソッドを使用する今までのやり方を計測してみます。

```python:show-image.py
import statistics
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    see https://github.com/atinfinity/lab/wiki/%5BOpenCV-Python%5D%E7%94%BB%E5%83%8F%E3%81%AE%E5%B9%85%E3%80%81%E9%AB%98%E3%81%95%E3%80%81%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E6%95%B0%E3%80%81depth%E5%8F%96%E5%BE%97
    """
    if len(opencv_image.shape) == 2:
        # グレイスケール画像の場合
        cvt_code = cv2.COLOR_GRAY2RGB
    else:
        # その他の場合:
        cvt_code = cv2.COLOR_BGR2RGB
    rgb_image = cv2.cvtColor(opencv_image, cvt_code).swapaxes(0, 1)
    # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
    pygame_image = pygame.surfarray.make_surface(rgb_image)

    return pygame_image

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    time_diff_list = []
    for _ in range(500):
      time_start = time.perf_counter()  # 計測開始
      pygame_image = convert_opencv_img_to_pygame(opencv_image)
      time_end = time.perf_counter()  # 計測終了
      time_diff_list.append(time_end - time_start)
    time_diff = statistics.mean(time_diff_list)
    print(f'平均変換時間: {time_diff}秒 / {1/time_diff}fps')

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 1秒待って終了する
    time.sleep(1)
    pygame.quit()


if __name__ == '__main__':
    main()
```

```
平均変換時間: 0.01808052065800075秒 / 55.30814177950641fps
```

60fpsに迫ろうかという速さです。これでも十分に早いですが、ただ変換しているだけだと考えると少し気になる速度です。

続いて、`pygame.surfarray.blit_array()`メソッドを使用して高速化したやり方を計測してみます。

```python:show-image.py
import statistics
import time

import cv2
import pygame


def get_opencv_img_res(opencv_image):
    height, width = opencv_image.shape[:2]
    return width, height

pygame_surface_cache = {}

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    see https://github.com/atinfinity/lab/wiki/%5BOpenCV-Python%5D%E7%94%BB%E5%83%8F%E3%81%AE%E5%B9%85%E3%80%81%E9%AB%98%E3%81%95%E3%80%81%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E6%95%B0%E3%80%81depth%E5%8F%96%E5%BE%97
    see https://stackoverflow.com/a/42589544/4907315
    """
    if len(opencv_image.shape) == 2:
        # グレイスケール画像の場合
        cvt_code = cv2.COLOR_GRAY2RGB
    else:
        # その他の場合:
        cvt_code = cv2.COLOR_BGR2RGB
    rgb_image = cv2.cvtColor(opencv_image, cvt_code).swapaxes(0, 1)

    # 同じ画像サイズで生成済のSurfaceをキャッシュから取得する
    cache_key = rgb_image.shape
    cached_surface = pygame_surface_cache.get(cache_key)

    if cached_surface is None:
        # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
        cached_surface = pygame.surfarray.make_surface(rgb_image)
        # Surfaceをキャッシュに追加
        pygame_surface_cache[cache_key] = cached_surface
    else:
        # 同じ画像サイズのSurfaceが見つかった場合は、すでに生成したSurfaceを使い回す
        pygame.surfarray.blit_array(cached_surface, rgb_image)

    return cached_surface

def main():
    # OpenCVで画像を読み込む
    image_path = '/usr/share/info/gnupg-module-overview.png'  # Raspbian Buster Liteに最初から含まれていた画像ファイルのパス
    opencv_image = cv2.imread(image_path)

    # Pygameを初期化
    pygame.init()
    width, height = get_opencv_img_res(opencv_image)
    screen = pygame.display.set_mode((width, height))

    # OpenCVの画像をPygame用に変換
    time_diff_list = []
    for _ in range(500):
      time_start = time.perf_counter()  # 計測開始
      pygame_image = convert_opencv_img_to_pygame(opencv_image)
      time_end = time.perf_counter()  # 計測終了
      time_diff_list.append(time_end - time_start)
    time_diff = statistics.mean(time_diff_list)
    print(f'平均変換時間: {time_diff}秒 / {1/time_diff}fps')

    # 画像を描画
    screen.blit(pygame_image, (0, 0))
    pygame.display.update()  # 画面を更新

    # 1秒待って終了する
    time.sleep(1)
    pygame.quit()


if __name__ == '__main__':
    main()
```

```
平均変換時間: 0.013679669161995207秒 / 73.1011830884182fps
```

60fpsを超えました！ここまで早ければ、十分実用的に使えるでしょう。

## OpenCVの画像をPygame向けに変換するやり方

```python
def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    see https://github.com/atinfinity/lab/wiki/%5BOpenCV-Python%5D%E7%94%BB%E5%83%8F%E3%81%AE%E5%B9%85%E3%80%81%E9%AB%98%E3%81%95%E3%80%81%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E6%95%B0%E3%80%81depth%E5%8F%96%E5%BE%97
    """
    if len(opencv_image.shape) == 2:
        # グレイスケール画像の場合
        cvt_code = cv2.COLOR_GRAY2RGB
    else:
        # その他の場合:
        cvt_code = cv2.COLOR_BGR2RGB
    rgb_image = cv2.cvtColor(opencv_image, cvt_code).swapaxes(0, 1)
    # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
    pygame_image = pygame.surfarray.make_surface(rgb_image)

    return pygame_image
```

同じ大きさの画像を何度も変換するなら：

```python
pygame_surface_cache = {}

def convert_opencv_img_to_pygame(opencv_image):
    """
    OpenCVの画像をPygame用に変換.

    see https://gist.github.com/radames/1e7c794842755683162b
    see https://github.com/atinfinity/lab/wiki/%5BOpenCV-Python%5D%E7%94%BB%E5%83%8F%E3%81%AE%E5%B9%85%E3%80%81%E9%AB%98%E3%81%95%E3%80%81%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E6%95%B0%E3%80%81depth%E5%8F%96%E5%BE%97
    see https://stackoverflow.com/a/42589544/4907315
    """
    if len(opencv_image.shape) == 2:
        # グレイスケール画像の場合
        cvt_code = cv2.COLOR_GRAY2RGB
    else:
        # その他の場合:
        cvt_code = cv2.COLOR_BGR2RGB
    rgb_image = cv2.cvtColor(opencv_image, cvt_code).swapaxes(0, 1)

    # 同じ画像サイズで生成済のSurfaceをキャッシュから取得する
    cache_key = rgb_image.shape
    cached_surface = pygame_surface_cache.get(cache_key)

    if cached_surface is None:
        # OpenCVの画像を元に、Pygameで画像を描画するためのSurfaceを生成する
        cached_surface = pygame.surfarray.make_surface(rgb_image)
        # Surfaceをキャッシュに追加
        pygame_surface_cache[cache_key] = cached_surface
    else:
        # 同じ画像サイズのSurfaceが見つかった場合は、すでに生成したSurfaceを使い回す
        pygame.surfarray.blit_array(cached_surface, rgb_image)

    return cached_surface
```

## 参考

* [opencvで作った画像をpygameで描画する。 - BlankTar](https://blanktar.jp/blog/2016/01/pygame-draw-opencv-image.html)
* [OpenCV VideoCapture running on PyGame](https://gist.github.com/radames/1e7c794842755683162b)
* [[OpenCV Python]画像の幅、高さ、チャンネル数、depth取得 · atinfinity/lab Wiki](https://github.com/atinfinity/lab/wiki/%5BOpenCV-Python%5D%E7%94%BB%E5%83%8F%E3%81%AE%E5%B9%85%E3%80%81%E9%AB%98%E3%81%95%E3%80%81%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E6%95%B0%E3%80%81depth%E5%8F%96%E5%BE%97)
* [python - How to present numpy array into pygame surface? - Stack Overflow](https://stackoverflow.com/a/42589544/4907315)
* [【Python】timeで経過時間を測定する(perf_counter) | 鎖プログラム](https://pg-chain.com/python-time-perf_counter)
* [PyGame over ssh on Raspberry Pi：株式会社サブスレッド](https://www.subthread.co.jp/blog/20181206/)