kivyを使ってGUIプログラミング ~その3 動画とシークバー~

#はじめに
[前回の記事](https://qiita.com/kurodae/items/ba0d1c7115d1dc9ad7cf)では、プログレスバーについて色々書きました。
今回の記事では、自作で動画プレイヤー的な物を作って見ようと思います。（音は出ません。。。）元々は、動画解析(物体検出とか)の結果を可視化するために、kivyで用意されている[動画プレイヤー](https://kivy.org/doc/stable/api-kivy.uix.videoplayer.html)ではなく、カスタマイズ性のあるプレイヤーを作ろうとしたのがきっかけです。
タイトルにあるように、動画プレイヤーのシークバーの実装に手こずったので、情報共有としてこの記事を書いています。

#環境構築
はじめにでも触れたように、動画解析結果の可視化を目的としていますので、動画(画像)解析用のライブラリ[opencv](https://opencv.org/)をインストールします。

私の動作環境は、以下の通りです。
> OSX 10.14.6
Python 3.7.2

pipを使っているようであれば、下記のコマンドだけでインストールできます。

```
pip install python-opencv
```

#シークバーとは
[wiki](https://www.weblio.jp/content/%E3%82%B7%E3%83%BC%E3%82%AF%E3%83%90%E3%83%BC)から引用します。
>シークバーとは、音楽・動画再生ソフトなどに備わる機能のひとつで、データの再生箇所を表示する機能のことである。音楽やムービーの始めから終わりまでのうちどこまでを再生しているのかが「スライダー」の位置によって視覚的に把握できる、スライダーをマウスで直接移動させ、それによって任意の箇所から再生を始めることができる、といった利点がある。

シークバーはスライダーの一種ということです。ではスライダーとは？
[wiki](https://ja.wikipedia.org/wiki/%E3%82%A6%E3%82%A3%E3%82%B8%E3%82%A7%E3%83%83%E3%83%88_(GUI)#%E9%81%B8%E6%8A%9E)から引用します。
>スライダー — スクロールバーに似ているが、何らかの値を設定するのに使われるウィジェットであって、スクロールのためのものではない。

kivyは、[スライダー](https://kivy.org/doc/stable/api-kivy.uix.slider.html)のウィジェットが用意されているため、こちらをシークバーに仕立て上げます。

#kivy.uix.sliderの使い方

こんな感じになります。
![シークバー.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421817/7cab2024-0f55-21ee-28cc-dcd02deeccaa.gif)

ソースは以下の通りです。

```python

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.clock import Clock

Builder.load_string('''
<MySlider>
    orientation: 'vertical'
    Label : 
        text: "{}".format(slider.value)
        font_size: slider.value
        
    Slider: 
        id: slider
        step: 1
        min: 200
        max: 500
        
    Button: 
        text: "press"
        on_press: root.move_slider_start()
        
''')

class MySlider(BoxLayout):
    def __init__(self, **kwargs):
        super(MySlider, self).__init__(**kwargs)

    def move_slider_start(self):
        Clock.schedule_interval(self.move_slider, 1 / 60)

    def move_slider(self, dt):
        slider = self.ids['slider']
        if slider.max > slider.value:
            slider.value += 1
        else:
            return False
        
class sliderTest(App):

    def build(self):
        return MySlider()

sliderTest().run()
```

スライダーの値を、画面上部のラベルのテキストとフォントに紐づけて、スライダーを動かすとラベルも変化するといったソースです。また、下のボタンを押すとクロックでスライダーの値を連続的に更新するような処理も加えました(あとで動画プレイヤーで似たようなことをします。)

kv言語では、Sliderのidを使ってLabelのテキストを更新しています。

```python
    Label : 
        text: "{}".format(slider.value)
        font_size: slider.value

    Slider: 
        id: slider #　idです。kv言語やPythonで呼び出すことができます。
        step: 1 # スライダーを動かす時の最小値。設定しないとめちゃくちゃfloatな値が出ます
        min: 200 # スライダーの最小値
        max: 500 # スライダーの最大値
```

また、自動でスライダーを動かす処理は、ウィジェットに割り当てたidが連想配列として格納されている"ids"から下記のような感じでウィジェットを選択してパラメータを変更します。前回の記事の通り、for文では動作させることができないので、Clockでスライダーの値を変更し画面描画も更新しています。

```python

    def move_slider(self, dt):
        slider = self.ids['slider'] #ここ！
        if slider.max > slider.value:
            slider.value += 1
        else:
            return False
```




#作ろうとしている物
今回作ろうとしているものは下図のようなイメージです。

![動画プレイヤー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421817/ab0cdae9-36e3-d6f5-4b06-118bf1d37e72.png)

最低限の機能のみの動画プレイヤーといった感じです。動画を読み込んで、再生と、任意の再生場所の指定、現在のフレーム数の確認だけです。

動画は、opencvで扱っていく感じです。

#ソース

``` python:VideoApp.py
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.graphics.texture import Texture
from kivy.properties import ObjectProperty
from kivy.clock import Clock

from kivy.uix.floatlayout import FloatLayout
from kivy.uix.popup import Popup

import cv2

Builder.load_file('VideoApp.kv')

#動画選択ポップアップ
class LoadDialog(FloatLayout):
    load = ObjectProperty(None)
    cancel = ObjectProperty(None)

class MyVideoPlayer(BoxLayout):
    image_texture = ObjectProperty(None)
    image_capture = ObjectProperty(None)

    def __init__(self, **kwargs):
        super(MyVideoPlayer, self).__init__(**kwargs)
        self.flagPlay = False # 動画が再生されているか
        self.now_frame = 0 # シークバー用の動画の再生フレームを確認する変数
        self.image_index = [] # シークバー用のopencv画像を保存する配列

    # 動画をロードするポップアップ
    def fileSelect(self):
        content = LoadDialog(load = self.load, cancel = self.dismiss_popup)
        self._popup = Popup( title="File Select", content=content, size_hint=(0.9,0.9))
        self._popup.open()

    # 動画ファイルのロード
    def load (self, path, filename):
        txtFName = self.ids['txtFName']
        txtFName.text = filename[0]
        self.image_capture = cv2.VideoCapture(txtFName.text)
        self.sliderSetting()
        self.dismiss_popup()

    # ポップアップを閉じる
    def dismiss_popup(self):
        self._popup.dismiss()

    # シークバーの設定
    def sliderSetting(self):
        count = self.image_capture.get(cv2.CAP_PROP_FRAME_COUNT)
        self.ids["timeSlider"].max = count

        # 一度動画を読み込み、全てのフレームを配列に保存する
        while True:
            ret, frame = self.image_capture.read()
            if ret:
                self.image_index.append(frame)

            else:
                self.image_capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
                break

    # 動画再生
    def play(self):
        self.flagPlay = not self.flagPlay
        if self.flagPlay == True:
            self.image_capture.set(cv2.CAP_PROP_POS_FRAMES, self.now_frame)
            Clock.schedule_interval(self.update, 1.0 / self.image_capture.get(cv2.CAP_PROP_FPS))
        else:
            Clock.unschedule(self.update)

    # 動画再生のクロック処理
    def update(self, dt):
        ret, frame = self.image_capture.read()
        # 次のフレームが読み込めたら
        if ret:
            self.update_image(frame)
            time = self.image_capture.get(cv2.CAP_PROP_POS_FRAMES)
            self.ids["timeSlider"].value = time
            self.now_frame = int(time)

    #　シークバー
    def siderTouchMove(self):
        Clock.schedule_interval(self.sliderUpdate, 0)

    #　シークバーを動かした時の画面描画処理
    def sliderUpdate(self, dt):
        # シークバーの値と、再生フレームの値が異なった時
        if self.now_frame != int(self.ids["timeSlider"].value):
            frame = self.image_index[self.now_frame-1]
            self.update_image(frame)
            self.now_frame = int(self.ids["timeSlider"].value)

    def update_image(self, frame):
        ##############################
        #ここに画像処理系のソースを書く！！
        ##############################
        
        # 上下反転
        buf = cv2.flip(frame, 0)
        image_texture = Texture.create(size=(frame.shape[1], frame.shape[0]), colorfmt='bgr')
        image_texture.blit_buffer(buf.tostring(), colorfmt='bgr', bufferfmt='ubyte')
        video = self.ids['video']
        video.texture = image_texture

class TestVideo(App):

    def build(self):
        return MyVideoPlayer()

TestVideo().run()
```

kvファイル

``` python:VideoApp.kv
<MyVideoPlayer>:
    orientation: 'vertical'
    padding: 0
    spacing: 1

    BoxLayout:
        orientation: 'horizontal'
        padding: 0
        spacing: 1
        size_hint: (1.0, 0.1)

        TextInput:
            id: txtFName
            text: ''
            multiline: False

        Button:
            text: 'file load'
            on_press: root.fileSelect()

    BoxLayout:
        orientation: 'horizontal'
        padding: 0
        spacing: 1

        Image:
            id: video

    BoxLayout:
        orientation: 'horizontal'
        padding: 0
        spacing: 1
        size_hint: (1.0, 0.1)
        Slider:
            id: timeSlider
            value: 0.0
            max: 0.0
            min: 0.0
            step: 1
            on_touch_move: root.siderTouchMove()

    BoxLayout:
        orientation: 'horizontal'
        padding: 0
        spacing: 1
        size_hint: (1.0, 0.1)

        ToggleButton:
            size_hint: (0.2, 1)
            text: 'Play'
            on_press: root.play()

        Label:
            size_hint: (0.2, 1)
            text: str(timeSlider.value) + "/" + str(timeSlider.max)

<LoadDialog>:
    BoxLayout:
        size: root.size
        pos: root.pos
        orientation: 'vertical'
        FileChooserListView:
            id: filechooser
            path: "./"

        BoxLayout:
            size_hint_y : None
            height : 30
            Button:
                text: 'Cancel'
                on_release: root.cancel()

            Button:
                text: 'Load'
                on_release: root.load(filechooser.path, filechooser.selection)
```

実行してみると、こんな感じで再生できたり、シークバーが動かせたりします。
動画は[こちら](https://mixkit.co/free-stock-video/petting-cute-cat-close-up-1778/)のサイトからお借りいたしました。
![Player.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421817/a819aad6-e2a5-f0d6-7b45-f0781973dd52.gif)
※あまりにも画質がよかったり、長い動画だったりすると、シークバーの処理で固まってしまうと思うので、短めの動画でお試しください。

#少し解説

動画の再生自体は、opencvの[VideoCupture](http://opencv.jp/opencv-2svn/cpp/reading_and_writing_images_and_video.html)と同じ用量で行います。使ったことがある方はなくわかると思いますが、while文で1フレームごと動画のコマを読み込んで表示するみたいな感じのやつで([こんな感じです](https://note.nkmk.me/python-opencv-videocapture-file-camera/))。繰り返しになりますが、forやwhileを使うと固まってしまうので、Clockを用います。

```python
    # 動画再生のクロック処理
    def update(self, dt):
        ret, frame = self.image_capture.read()
        # 次のフレームが読み込めたら
        if ret:
            self.update_image(frame) # 画面に写す処理
            time = self.image_capture.get(cv2.CAP_PROP_POS_FRAMES) #  シークバー用にフレーム数を取得
            self.ids["timeSlider"].value = time # シークバーの値に再生フレーム数を代入
            self.now_frame = int(time) # シークバーを動かす時用
```

また、kivyに画像を表示する時には、[Texture](https://kivy.org/doc/stable/api-kivy.graphics.texture.html)をいうクラスを用いて```blit_buffer```でバッファー化して画像を扱います。この時、opencvの画像データは上下逆さまになってしまうため、反転する処理を追加します。
こうすることで、普通にopenCVの動画再生と同じような感覚で動画再生機能を実装できます。今回は、画像処理は行いませんが、ここにopencvなどの処理を加えれば簡単に処理結果の可視化等もできます。

```python
    def update_image(self, frame):
        ##############################
        #ここに画像処理系のソースを書く！！
        ##############################
        
        # 上下反転
        buf = cv2.flip(frame, 0)
        image_texture = Texture.create(size=(frame.shape[1], frame.shape[0]), colorfmt='bgr')
        image_texture.blit_buffer(buf.tostring(), colorfmt='bgr', bufferfmt='ubyte')
        video = self.ids['video']
        video.texture = image_texture
```

動画のシークバーでは、当初はopencvのVideoCupterクラスの[set関数](http://opencv.jp/opencv-2svn/cpp/highgui_reading_and_writing_images_and_video.html#cv-videocapture-set)(再生フレームを指定する関数)から、Sliderの値を動画のフレーム番号に当てはめて、シークバーを実装しようとしていました。しかし、実装してみるとめちゃくちゃ動作が重くなってしまいました。原因、set関数が非常に重い処理のようで他の実装方法を模索しました。

結果として、配列にそのままopencvの画像データを格納して、Sliderの値と画像の配列の添字を紐づけることで簡単に実装することができました。そのため、動画の読み込みを行う時に、1度動画を再生して配列に代入する処理を設けています。

プログラム内では、一度読み込んだVideoCuptureを使いまわしているため、動画の再生位置を最初の状態に戻す```self.image_capture.set(cv2.CAP_PROP_POS_FRAMES, 0)```を指定してあげないと、動画の再生ができなくなります。

```python
    # シークバーの設定
    def sliderSetting(self):
        count = self.image_capture.get(cv2.CAP_PROP_FRAME_COUNT) # 動画のフレーム数を取得
        self.ids["timeSlider"].max = count # 動画のフレーム数をスライダーの最大値に代入

        # 一度動画を読み込み、全てのフレームを配列に保存する
        while True:
            ret, frame = self.image_capture.read()
            if ret:
                self.image_index.append(frame)

            else:
                #最後のフレームまで読み込んだら、最初のフレームに戻す
                self.image_capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
                break
```


#参考文献
大変お世話になりました。

[Python KivyでOpencvやPillowで画像を表示する方法](https://qiita.com/dario_okazaki/items/fc8ae0525f2e8036cde7)
Textureの扱い等

[Pythonをはじめよう　第７回　ファイルを選択して動画再生 - あきらちんの技術メモ](https://www.akiratin.com/start-python-no7/)
動画でかなりお世話になりました。