python+kivyで物体検出する動画プレイヤーを作る

# あらまし
この記事は，ドコモアドベントカレンダー8日目の記事になります。

Pythonでアプリを作成できるライブラリの[kivy](https://kivy.org/)を使って，動画プレイヤーを作ります。
再生中の動画に何が写っているか検出する機能も付けました。
LinuxもGPUも不要です。Windowsのノーパソが1台あればOK。
![shoukai.gif](https://qiita-image-store.s3.amazonaws.com/0/320446/88b01369-193d-25a6-ee77-ebfc64735640.gif)

## 目的
「スライダーの値がある変数に代入されて，ボタンを押すとある関数が実行される動画プレイヤー」を一度作っておけば，関数部分を書き換えるだけでいろんな機能を持った動画プレイヤーが作れそうなので，作ってみました。
（```スライダーの値```倍速で再生するプレイヤーとか）

もう一つの動機としては，GitHubからディープラーニングのモデルを拾ってきたとき，それを手持ちの動画に適用しようとすると，

1. 動画を画像に変換する
1. 各画像の認識結果を得る
1. 画像ごとの認識結果を結合して動画に戻す

と面倒なので，たいてい入っている```demo.py```的な動作確認用のサンプル（画像を入れると，認識結果が返ってくる）を内包する動画プレイヤーを作ってみました。
（最近は動画を入力できるサンプルがついていることも多いですが）
![2.png](https://qiita-image-store.s3.amazonaws.com/0/320446/6c280678-83fa-28c5-6d89-3acfe07a4d64.png)


## 検証環境
- Windows10 64bit
  - OSによってkivyの[インストール方法](https://kivy.org/doc/stable/installation/installation.html)が違います
- Python 3.6
- kivy 1.10.0
- torch 0.4.1
  - 使いたいディープラーニングのモデル次第
  
動作確認用のサンプル動画として下記を用いました。
https://videos.pexels.com/videos/cat-playing-with-tape-1358988
https://videos.pexels.com/videos/man-walking-the-dog-at-the-street-992590


## kivyについて
PythonのGUI Toolkitはkivy以外にもいろいろ（PyQt, Tkinter等）ありますが，kivyは下記を満たすので使いやすいです。

- モジュールが豊富で多機能
- ライセンスがゆるい（MITライセンスなので商用にも無償で使える）
- マルチプラットフォーム対応（スマホ向けアプリも作れる）

非公式ですが，日本語に翻訳されたドキュメントがあります。
https://pyky.github.io/kivy-doc-ja/guide/lang.html

下記の記事，そしてこれを執筆したお二方の他の記事は，kivyのkの字もわからなかったときに非常に参考になりました。

- https://qiita.com/dario_okazaki/items/7892b24fcfa787faface
  - kivyの基礎。まずはこれをコピペして動かすところから
- https://qiita.com/gotta_dive_into_python/items/4180a6601502486918d7
  - ちょっと高度な機能の紹介


# 準備体操：ただの動画プレイヤーを作る
まずは動作確認も兼ねて下記の2つの機能だけを持った動画プレイヤーを作ってみます。

- ファイルを選択する
- 選択した動画を再生する

kivyでアプリを作る際は，**中身の処理を記述する.pyファイル**と，**アプリ画面のレイアウトを記述する.kvファイル**の2つを作成する必要があります。[^1]
後者はkivy言語という独自言語で書かれますが，配置したいものを上から順に書いていくだけなので簡単です。

## kivy

```shell:deepplayer.kv
<LoadDialog>:
    BoxLayout:
        orientation: "vertical"
        size: root.size
        pos: root.pos
        FileChooserListView:
            id: filechooser
            path: './'
        BoxLayout:
            size_hint_y: None
            height: 30
            Button:
                text: "Load"
                on_release: root.load(filechooser.selection)
            Button:
                text: "Cancel"
                on_release: root.cancel()

<DeepPlayer>:
    BoxLayout:
        orientation: "vertical"
        ActionBar:
            ActionView:
                ActionPrevious:
                ActionButton:
                    text: 'SELECT FILE'
                    on_press: root.show_load()
        VideoPlayer:
            id: video
            source: root.fn_video
```


## python
```python:deppplayer.py
# -*- coding: utf-8 -*-
import os

from kivy.app import App
from kivy.properties import ObjectProperty, StringProperty
from kivy.uix.widget import Widget
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.popup import Popup

class LoadDialog(FloatLayout):
    load = ObjectProperty(None)
    cancel = ObjectProperty(None)

class DeepPlayer(BoxLayout):
    fn_video = StringProperty()
    def __init__(self):
        super().__init__()

    # ファイル選択
    def dismiss_popup(self):
        self._popup.dismiss()

    def load(self, filename):
        self.fn_video = filename[0]
        self.dismiss_popup()

    def show_load(self):
        content = LoadDialog(load=self.load, cancel=self.dismiss_popup)
        self._popup = Popup(title="Load file", content=content,
                            size_hint=(.8, .8))
        self._popup.open()

class DeepPlayerApp(App):
    def __init__(self):
        super().__init__()
        self.title = "Deep Learning Video Player"

    def build(self):
        return DeepPlayer()

if __name__ == '__main__':
    DeepPlayerApp().run()
```

## 実行結果
ちゃんと右上のボタンからファイルが選択でき，動画も再生できました。
![junbi_1.png](https://qiita-image-store.s3.amazonaws.com/0/320446/b6915dc2-8d72-ef40-48b8-15e53e7bdee2.png)
![junbi_2.png](https://qiita-image-store.s3.amazonaws.com/0/320446/0ddfb67b-ac12-75d2-85ad-0f87c14495ff.png)


## 解説
たった70行程度で動画プレイヤーができました。
**python側のAppクラス名（"DeepPlayerApp"のAppの前まで）とkivy側のファイル名（deepplayer.kv）が対応していないとダメ**なので注意してください。[^2]

### ファイル選択部分
kivyには[FileChooser](https://kivy.org/doc/stable/api-kivy.uix.filechooser.html)というモジュールがあるので，そのまま使えば良いです。

### 動画再生部分
kivyには[VideoPlayer](https://kivy.org/doc/stable/api-kivy.uix.videoplayer.html)という動画再生用のモジュールがあるため，この変数であるsourceに動画のパスを渡せば良いです。

実装方法として，

- 動画のパスをpython側で変数として持ち，kivy側で必要なときはそこを参照する
- 動画のパスをkivy側で変数として持ち，python側で必要なときはそこを参照する

の2つがありますが，今回は前者を使いました。後者の実装だと[こちら](http://hhsprings.pinoko.jp/site-hhs/2016/08/kivylauncher-%E3%81%A7%E3%83%93%E3%83%87%E3%82%AA%E3%83%97%E3%83%AC%E3%82%A4%E3%83%A4%E3%83%BC%E3%82%92%E4%BD%9C%E3%82%8A%E3%81%9F%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%8F%E3%81%91%E3%81%A7/)のようになります。


# 本番：物体検出機能付きの動画プレイヤーを作る
カメラの映像をリアルタイムで物体検出するようなデモは学会やイベントでもよく見ます。今回は動画プレイヤーを作るので，リアルタイムではなく，いったん動画全体の物体検出を行うような実装にします。
GPU有りで，シークバーでの移動周りを制御すれば，再生している部分だけリアルタイムで処理するような実装も可能なはずです。


## 下準備：ディープラーニングのモデルを用意する
せっかくなので一番有名なyoloにしよう，ということで今回は下記のyolo v3のpytorch実装を用います。```git clone```で持ってきます。
https://github.com/ayooshkathuria/pytorch-yolo-v3
（動画に物体検出を適用したいだけであれば，yoloならもともと動画を入力するデモもついています）


demo.pyの入出力仕様も実装によって多少異なりますが，下記のような仕様を想定します。

- 画像を格納したフォルダを入力すると，出力用フォルダに推論の結果を吐く
    - ```python demo.py --images (画像が入ったフォルダ) --det (結果を格納するフォルダ)```のような動作
- ```hoge.jpg```の入力に対して，```hoge.csv```という出力ファイルが得られる
- csvの各行は以下の通りで，検出された物体数が行数となる
    - ```クラス名,xmin,ymin,xmax,ymax```

上記のリポジトリでは```detect.py```がdemo.pyに相当するプログラムですが，上記の仕様を満たしていない（画像を入力すると，矩形が描画された画像が出力される）ので，csvを出力するように何行か書き直します。
285行目から304行目を下記のように書き換えます。

```python:detect.py
    def write_csv(x, det_names):
        cls = int(x[-1])
        label = "{0}".format(classes[cls])
        with open('{}\\{}.csv'.format(args.det, det_names[int(x[0])].split('\\')[-1].split('.')[0]), 'a') as dst:
            dst.write('{},{},{},{},{}\n'.format(label, x[1], x[2], x[3], x[4]))

    det_names = pd.Series(imlist).apply(lambda x: "{}\\{}".format(args.det,x.split("\\")[-1]))
    list(map(lambda x: write_csv(x, det_names), output))
```

フォルダ構造は下記のようにしておきます。

```shell
├─deepplayer.py
├─deepplayer.kv
├─data
|  └─Pexels Videos 992590.mp4：動作確認用のサンプル動画
├─model
│  └─pytorch-yolo-v3：上記のリポジトリのclone
|      └─（省略）
└─tmp
    ├─csv：結果を出力するフォルダ（detect.pyの出力）
    └─img：動画をいったん画像に変換して格納するフォルダ（detect.pyの入力）
```

## kivy
```shell:deepplayer.kv
<LoadDialog>:
    BoxLayout:
        orientation: "vertical"
        size: root.size
        pos: root.pos
        FileChooserListView:
            id: filechooser
            path: './'
        BoxLayout:
            size_hint_y: None
            height: 30
            Button:
                text: "Load"
                on_release: root.load(filechooser.selection)
            Button:
                text: "Cancel"
                on_release: root.cancel()

<DeepPlayer>:
    BoxLayout:
        orientation: "vertical"
        ActionBar:
            size_hint_y: 1
            ActionView:
                ActionPrevious:
                ActionButton:
                    text: 'SELECT FILE'
                    on_press: root.show_load()
        VideoPlayer:
            id: video
            size_hint_y: 8
            source: root.fn_video
        BoxLayout:
            orientation: "horizontal"
            size_hint_y: 1
            BoxLayout:
                orientation: "vertical"
                BoxLayout:
                    orientation: "horizontal"
                    Label:
                        text: "FPS:"
                    Label:
                        text: "{}".format(int(slider.value))
                Slider:
                    id: slider
                    min: 1
                    max: 30
                    value: 1
            Button:
                size_hint_y: 1
                text: "deep de pon"
                on_press: root.clicked_deep()
```

## python
```python:deepplayer.py
# -*- coding: utf-8 -*-
import os
import subprocess
import glob
import csv

import numpy as np
import cv2

from kivy.config import Config
from kivy.app import App
from kivy.clock import Clock
from kivy.properties import ObjectProperty, StringProperty, DictProperty
from kivy.uix.label import Label
from kivy.uix.widget import Widget
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.popup import Popup
from kivy.graphics import Line, Rectangle, Color
from kivy.graphics.instructions import InstructionGroup

class LoadDialog(FloatLayout):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
    load = ObjectProperty(None)
    cancel = ObjectProperty(None)

class DeepPlayer(FloatLayout):
    fn_video = StringProperty()
    dic_bbox = DictProperty()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.line_width = 2
        self.font_size = 15
        self.fps = 30
        self.df_dl = 30
        self.video_h = 0
        self.video_w = 0
        self.rectangles = []
        self.labels = []

    # cancelボタン押したときの動作
    def dismiss_popup(self):
        self._popup.dismiss()

    # loadボタン押したときの動作
    def load_file(self, filename):
        # 前の動画の認識結果をクリア
        Clock.unschedule(self.update)
        for l in self.labels:
            self.canvas.children.remove(l.canvas)
        self.labels = []
        with self.canvas:
            list(map(lambda x:self.canvas.remove(x), self.rectangles))
        self.rectangles=[]

        self.fn_video = filename[0]
        self.dismiss_popup()

    # SELECT FILEボタン押したときの動作
    def show_load(self):
        content = LoadDialog(load=self.load_file, cancel=self.dismiss_popup)
        self._popup = Popup(title="Load file", content=content,
                            size_hint=(.8, .8))
        self._popup.open()

    # 矩形を描画する
    def update(self, dt):
        video_screen = self.ids.video.children[1]
        w_0, h_0 = video_screen.pos # 動画再生部分の左下の座標
        w, h = video_screen.size # 動画再生部分のサイズ

        # 再生する動画のアス比を見て動画再生部分のサイズを調整
        aspect = self.video_h / self.video_w
        if h/w > aspect:
            h_0 += (h - w * aspect)/2
            h = w * aspect
        else:
            w_0 += (w - h / aspect)/2
            w = h / aspect

        scale_w = w/self.video_w
        scale_h = h/self.video_h

        t = round(self.ids["video"].position, 1)
        l = list(self.dic_bbox.keys())
        bboxes = self.dic_bbox[l[np.abs(np.asarray(l) - t * self.fps).argmin()]] # t * self.fpsに一番近い結果を持ってくる

        for l in self.labels:
            self.canvas.children.remove(l.canvas)
        self.labels = []

        with self.canvas:
            list(map(lambda x:self.canvas.remove(x), self.rectangles))
            self.rectangles = []

            for bbox in bboxes:
                xmin = bbox[1][0] * scale_w + w_0
                ymin = h - bbox[1][3] * scale_h + h_0
                xmax = bbox[1][2] * scale_w + w_0
                ymax = h - bbox[1][1] * scale_h + h_0

                Color(1,0,0,1)
                self.rectangles.append(Line(points=[xmin,ymin,xmax,ymin,xmax,ymax,xmin,ymax], width=self.line_width, close='True'))
                self.rectangles.append(Rectangle(pos=(xmin-self.line_width,ymax), size=(xmax-xmin+2*self.line_width, self.font_size)))
                Color(1,1,1,1)
                self.labels.append(Label(text=bbox[0], pos=(xmin, ymax), size=(xmax-xmin,self.font_size), font_size=self.font_size))

    # はじめに呼ぶ処理
    def init_deep(self):
        for fn in glob.glob('./tmp/*/*'):
            os.remove(fn)
        Clock.unschedule(self.update)
        video = cv2.VideoCapture(self.fn_video)
        self.video_h = video.get(cv2.CAP_PROP_FRAME_HEIGHT)
        self.video_w = video.get(cv2.CAP_PROP_FRAME_WIDTH)
        self.fps = video.get(cv2.CAP_PROP_FPS)
        self.df_dl = max(1, int(self.fps/int(self.ids.slider.value))) # fpsを達成するため，何フレームごとにDEEPする必要あるか
        n_frames = 0

        while video.isOpened():
            ret, frame = video.read()
            if ret:
                if n_frames%self.df_dl == 0:
                    cv2.imwrite('./tmp/img/{}.jpg'.format(n_frames), frame)
            else:
                break
            n_frames += 1

    # 最後に呼ぶ処理
    def del_deep(self):
        Clock.schedule_interval(self.update, self.df_dl/self.fps)

    def clicked_deep(self):
        self.init_deep()

        # deep learningする
        os.chdir('./model/pytorch-yolo-v3')
        cmd = 'python detect.py --images ../../tmp/img --det ../../tmp/csv'
        p = subprocess.Popen(cmd.split(' '), shell=False)
        p.wait()
        os.chdir('../..')

        # csvを読み込む
        self.dic_bbox = {}
        for fn in glob.glob('./tmp/csv/*.csv'):
            n_frame = int(fn.split('\\')[-1].split('.')[0])
            with open(fn) as f:
                reader = csv.reader(f)
                bbox = []
                for row in reader:
                    bbox.append([row[0], list(map(float, row[1:]))])
                self.dic_bbox[n_frame] = bbox
        self.del_deep()

class DeepPlayerApp(App):
    def __init__(self):
        super().__init__()
        self.title = "Deep Learning Video Player"

    def build(self):
        return DeepPlayer()

if __name__ == '__main__':
    DeepPlayerApp().run()
```

## 実行結果
3fps (frames per second)で物体検出を行った結果です。
再生中の動画に対して物体検出を行い，結果を表示できることを確認できました。
![honban_1.gif](https://qiita-image-store.s3.amazonaws.com/0/320446/ff642ffa-e4ac-a8c6-286b-d9662e912601.gif)

## 解説
合計220行程度で書けました。

### 推論するfpsの指定

推論だけとはいえど，CPUだとそこそこ時間がかかるので，物体検出を行うfpsを指定するためのスライダーを左下に追加しました。
（自分のPCだと推論に0.8秒/枚ほどかかったので，3fpsだと動画長の2.4倍くらいかかります）

kivy側のコードの下記に相当します。

```shell:deepplayer.kv
                Slider:
                    id: slider
                    min: 1
                    max: 30
                    value: 1
```

これをpython側の下記で受け取っています。

```python:deepplayer.py

        self.df_dl = max(1, int(self.fps/int(self.ids.slider.value))) # fpsを達成するため，何フレームごとにDEEPする必要あるか
```

### 推論する処理の追加
ここがただの動画プレイヤーとの差分で，内包するモデルによって多少書き換えが必要になりうる部分です。

kivy側ではただボタンを追加するだけです。クリック時にpython側の関数```clicked_deep()```を呼ぶように紐づけます。

```shell:deepplayer.kv
            Button:
                size_hint_y: 1
                text: "deep de pon"
                on_press: root.clicked_deep()
```

python側には4つの関数を追加しました。処理順に説明します。

#### clicked_deep()
1つ目は```clicked_deep()```で，ボタンが押されたときにまず呼ばれる関数です。中では，

1. ```init_deep()```を呼ぶ
2. subprocessを使って```./tmp/img```に格納された画像に推論を行い，```./tmp/csv```に格納する
3. 格納されたcsvを読み込み，```dic_bbox```にフレーム数をkey, クラス名と矩形の座標をvalueとして格納する
4. ```del_deep()```を呼ぶ

の4つを行っています。
```p.wait()```を挟むことで，推論の処理中はGUIに触れないようになっています。

#### init_deep()
2つ目は```init_deep()```で，ディープラーニングの前処理を想定した関数です。中では動画をスライダーで指定したfpsごとに画像化して```./tmp/img```に格納する処理を行っています。

#### del_deep()
3つ目は```del_deep()```で，ディープラーニングの後処理を想定した関数です。今回は無くても良いのですが，中で```Clock.schedule_interval(self.update, self.df_dl/self.fps)```を呼ぶ役割を持たせました。以後，一定間隔ごとに```update()```が呼ばれます。

#### update()
4つ目は，上で一定間隔ごとに呼ぶことにした```update()```関数です。
この関数が呼ばれるたびに，各フレームの物体検出結果を参照し，

- 前回呼ばれたときに描画した矩形とクラス名の文字を消去
- 今のフレームに対する矩形の描画および，クラス名の描画

を行っています。これをfpsの逆数の間隔で呼ぶことで，指定したfpsで物体検出の結果が動画に重ねて描画されます。

注意点として，**多くのライブラリでは画像の左上の座標を(0,0)とする**のに対し，**kivyでは画面の左下の座標が(0,0)である**ため，y座標の反転が必要です。
（このkivyの仕様だけ意味がわからないのですが，左下を原点にする意味って何なんでしょう）


# おわりに
Python使いがGUIツールを作成する必要に迫られたとき，簡単に使えるkivyは魅力的です。
数個の機能なら100~200行くらいで書けるので，打ち合わせのときに資料といっしょに作っていくとウケが良かったりします。

kivyとディープラーニングを絡めた話を見たことがないため，今回取り組んでみました。kivyを使う人が増えて，もっと日本語の情報が増えればいいなと思っています。

内容・コードに気になる点があった場合には，ご指摘いただけると大変ありがたいです。

[^1]: [ひとつにまとめる方法](http://code.tiblab.net/python/kivy/kv_load)もありますが，今回はふたつに分けます
[^2]: [異なるファイル名をつける方法](http://code.tiblab.net/python/kivy/kv_load)もありますが，今回は同じにします