PySimpleGUIとOpenCVによる動画の表示とYOLOv8への応用

　PySimpleGUIのウィンドウ上で動画を再生する方法と、ついでにultralyticsのYOLOv8を使った簡単なデモを紹介します。この記事はPySimpleGUIの[Screens2]('https://www.pysimplegui.org/en/latest/Screens2/')で紹介されているものを参考にしています。GitHubへのリンクは[こちら]('https://github.com/chanon-kr/simple_imagui_app')です。
# 環境
* Windows11
* Python 3.11.0
* PySimpleGUI 4.60.5
* ultralytics 8.0.124
# ロードマップ
　だいたい以下の流れで話を進めます。
* OpenCVの`VideoCapture`クラスの簡単な使い方について
* PySimpleGUIで動画を表示する方法について
* YOLOv8のデモ


# opencv-python：VideoCaptureクラス
　`opencv-python`はコンピュータビジョンの分野でよく知られた、OpenCVのPythonパッケージです。詳しくは[こちら]('https://pypi.org/project/opencv-python/')をどうぞ。[公式のチュートリアル]('https://docs.opencv.org/4.6.0/d6/d00/tutorial_py_root.html')も充実しているので、そっちを見たほうが早いかもしれません。
　`opencv-python`で動画を扱うには`VideoCapture`クラスを利用します。以下は動画ファイルを再生するスクリプトです。
```python:sample01.py
import cv2 as cv

path = '動画ファイルへのパス'
vc = cv.VideoCapture(path)

while vc.isOpened():
    ret, flame = vc.read()
    if ret:
        cv.imshow('test', flame)
        cv.waitKey(25)
    else:
        print('フレームが読み込めないか終端に達したかしました')
        break
vc.release()
cv.destroyAllWindows ()
```
　`VideoCapture`クラスのコンストラクタには、「動画ファイルのパス」かwebカメラなどのデバイスを示す「整数値」かのどちらかを渡します。単に動画を表示したいというだけなら、`VideoCapture`クラスについては次の三つの関数を押さえておけば十分です。
* `VideoCapture.isOpened()`
動画ファイルが正しく開けているかどうかをBoolで返す
* `VideoCapture.read()`
動画を構成するフレーム画像を「一枚だけ」`numpy`配列として、さらに画像を正しく取得できたかどうかを示すBoolを返す
* `VideoCapture.release()`
動画ファイルを閉じる

　`read()`は呼び出されるたびに動画の並びに沿って順番にフレーム画像を返します。最初の呼び出しでは先頭のフレームを、二度目の呼び出しでは二番目のフレームを、三度目には…といった格好です。動画の再生は、このフレーム画像をループを使って順次表示してやれば実現できます。
　フレーム画像の表示には次の二つの関数を使います。
* `cv.imshow()`
画像を表示するウィンドウを生成し、引数で指定した`numpy`配列による画像を表示する。最初の引数はウィンドウタイトルです
* `cv.waitKey()`
引数で指定した時間（ミリ秒）だけ、キーボードの入力を待機する

　`cv.waitKey()`の待機時間は動画のフレームレートに応じて選ぶ必要があります。[チュートリアル](https://docs.opencv.org/4.6.0/dd/d43/tutorial_py_video_display.html)では、一般に25ミリ秒程度でよいとあります。フレームレート30（一秒間の表現に30枚の画像）の動画であれば、一枚の画像が1/30秒（≒33ミリ秒）で切り替わるので、`read()`や`imshow()`の処理を加味すればだいたい適当な間隔で表示できるだろう、ということだと思います。
# PySimpleGUIでの動画の表示
　画像の表示とその切り替えさえできれば、動画の再生が可能になることがわかりました。PySimpleGUIには画像を表示するための`Image`エレメントクラスがあるので、動画の表示もまた可能になります（PySimpleGUIの基本的な扱い方についてはほかの記事を参照してください）。
　Imageエレメントの要点は大体次のようなものです。
* 表示したい画像をパスかバイト列で指定する
* エレメントの内容を変更する`update()`関数にイメージデータを直接渡すことができる

　ここで問題になるのが、`opencv-python`とPySimpleGUIでの画像の形式の違いです。`numpy`配列の画像をバイト列に変換する必要があります。
* `cv.imencode()`
`numpy`配列による画像表現を、指定した画像ファイルの形式に変換する

　あとは取得したフレーム画像をエンコードして、イメージエレメントに渡してやるだけです。

```python:sample02.py
image_element = PySimpleGUI.Image(key='IMAGE')
```
　このようなイメージエレメントを用意して、メッセージループの中で次のようにアップデートを繰り返えせば動画が表示されます。
```python:sample03.py
while True:
    ret, flame = vc.read()
    if ret:
        img = cv.imencode('.png', flame)[1].tobytes()
        window['IMAGE'].update(img)
        cv.waitKey(25)
    else:
        print('フレームが読み込めないか終端に達したかしました')
        break
```
　ただし、このループをボタンエレメントなどのイベントとしてメッセージループに組み込むと、動画の再生中は何の操作も受け付けないものになってしまいます。
　そこで、メッセージループにタイムアウト設定を追加して、`read()`の呼び出しをメッセージループの一連の処理に組み込んでしまいます。
```python:sample04.py
import cv2 as cv
import PySimpleGUI as sg

path = '動画へのパス'
switch = False

layout = [[sg.Text('', key='TEXT')],
          [sg.Button('読み込み', key='BUTTON1')],
          [sg.Button('停止', key='BUTTON2', disabled=True)],
          [sg.Image(key='IMAGE', size=(400, 400))]]

window = sg.Window('v_cap_test', layout)

while True:
    event, values = window.read(timeout=0)
    
    if event  in (sg.WIN_CLOSED, 'Exit'):
        if switch:
            vc.release()
        break

    if event == 'BUTTON1':
        vc = cv.VideoCapture(path)
        switch = vc.isOpened()
        window['BUTTON2'].update(disabled=False)
        window['BUTTON1'].update(disabled=True)

    if event == 'BUTTON2':
        vc.release()
        switch = False
        window['BUTTON2'].update(disabled=True)
        window['BUTTON1'].update(disabled=False)

    if switch:
        ret, flame = vc.read()
        if ret:
            img = cv.imencode('.png', flame)[1].tobytes()
            window['IMAGE'].update(img)
        else:
            print('動画が読み込めないか終端に達したかしました')

window.close()
```
　`window.read()`関数の`timeout=`に値をセットすると、メッセージの待機を指定した時間（ミリ秒）で切り上げるようになります（この時`__TIMEOUT__`というイベントが発生します）。
　このメッセージループの処理の一つに、イベントとは関係なく`VideoCapture.read()`を呼び出す処理を加えれば、`cv.waitKey()`で時間調整をしなくても、適切な速度で画像の切り替えが実行できます。
　なお、上の例では`timeout=0`になっていますが、このままスクリプトを実行してもやや早いかな、くらいの速度で動画が再生できます。これは以下のようなことが理由だと思われます。
* 都度行われる`if`の評価に一定の時間がいる
* `imencode()`と`update()`に時間がかかる

　簡単な計測を行ったところ、`imencode()`と`update()`の実行だけで、それぞれに10ミリ秒弱かかっていました。正直、動画を表示するだけにしては時間がかかりすぎてるように思います。画像の変換処理などを加えた場合さらに時間がかかるわけですから、PySimpleGUI向きではないのかもしれません。
# YOLOv8への応用
　最後に`ultralytics`のYOLOv8で遊んでみようと思います。`ultralytics`ではシンプルなインターフェイスから訓練済みのYOLOv8モデルを利用できます。詳しくは[こちらから](https://docs.ultralytics.com/"Ultralytics")。
```python:sample05.py
import cv2 as cv
import PySimpleGUI as sg
from ultralytics import YOLO

path = '動画へのパス'
switch = False
font = cv.FONT_HERSHEY_SIMPLEX
model = YOLO('yolov8n.pt')

layout = [[sg.Text('', key='TEXT')],
          [sg.Button('読み込み', key='BUTTON1')],
          [sg.Button('停止', key='BUTTON2', disabled=True)],
          [sg.Image(key='IMAGE', size=(400, 400))]]

window = sg.Window('v_cap_test', layout)

def draw_res(img, results):
    for i in results:
        for j in i.boxes:
            xy = j.xyxy.numpy().astype(int)
            cv.rectangle(img, (xy[0][0], xy[0][3]), (xy[0][2], xy[0][1]), (0,0,255), 4)
            cls = int(j.cls)
            cv.putText(img, model.model.names[cls], (xy[0][0], xy[0][3]), font, 1,(0, 0, 0),2,cv.LINE_AA)
    return 0

while True:
    event, values = window.read(timeout=0)
    
    if event  in (sg.WIN_CLOSED, 'Exit'):
        if switch:
            vc.release()
        break

    if event == 'BUTTON1':
        vc = cv.VideoCapture(path)
        switch = vc.isOpened()
        window['BUTTON2'].update(disabled=False)
        window['BUTTON1'].update(disabled=True)

    if event == 'BUTTON2':
        vc.release()
        switch = False
        window['BUTTON2'].update(disabled=True)
        window['BUTTON1'].update(disabled=False)

    if switch:
        ret, flame = vc.read()
        if ret:
            results = model(flame, device='cpu')
            draw_res(flame, results)
            img = cv.imencode('.png', flame)[1].tobytes()
            window['IMAGE'].update(img)
        else:
            print('フレームが読み込めないか終端に達したかしました')

window.close()
```
　最初に実行するときに`yolov8n`モデルのダウンロードが自動的に行われます。
![cv_video_cap03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3469668/17ce72ad-c735-16fd-ccef-c6f7d12baf5f.png)
　[公式](https://docs.ultralytics.com/tasks/detect/)のデータでは、速度重視のモデル`YOLOv8n`であっても、GPUがないと一枚の画像の処理（この場合物体検出）に80ミリ秒かかるとあります。私の環境では100ミリ秒ちょっとと、さらに動画の表示にかかる時間も含めて、1フレームあたり150ミリ秒ほど必要でした。
# 参考
* https://github.com/chanon-kr/simple_imagui_app
* https://docs.opencv.org/4.6.0/d6/d00/tutorial_py_root.html
* https://docs.ultralytics.com/
* https://github.com/opencv/opencv/tree/master/samples/data
動画のサンプルにOpenCVのものを使いました（最後のスクリーンショットにしか登場しませんが）。