PySimpleGUIで「Kivy+PyTorchでお絵かきクイズを作る」のGUI部分を書き直してみた

# やったこと

ドコモアドベントカレンダー2日目の記事の[kivy+PyTorchでお絵かきクイズを作る](https://qiita.com/dcm_fukushima/items/75f1bb7204470181a16d)のKivyで書かれているGUI部分を、PythonのGUIライブラリであるPySimpleGUIで書き直してみました。

# きっかけ
元記事のネタが個人的にとても素敵だったので写経して動かしてみようと思ったのですが、ただ写経してもつまらないのでGUIの部分を書き直してみようと思いました。
その中でここ最近はPySimpleGUIを使ってGUIを書いていたのでPySimpleGUIで書いてみることにしました。

# できたもの

できたものは以下の通りです
![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11002/0d604fe6-7fe4-3903-4ee0-1d1bd806d4fa.png)

動画版はこちら
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">動作テスト、 <a href="https://t.co/8TodE0UVEq">pic.twitter.com/8TodE0UVEq</a></p>&mdash; okazaki jun (@dario_okazaki) <a href="https://twitter.com/dario_okazaki/status/1204021025408552961?ref_src=twsrc%5Etfw">December 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

動作的にはKivyと同じものがほぼ実現できています。
ただ動画をみるとわかりますがKivyに比べると線がつながっていない時があったりして改良の余地があるのがわかります。

# 書いたコード

書いたコードは以下になります。コードですが整理していないところがあるのでだいぶ汚いです。
時間があるときに書き直します。
また物体検出の`Net`classとボタンの配置のレイアウトは[kivy+PyTorchでお絵かきクイズを作る](https://qiita.com/dcm_fukushima/items/75f1bb7204470181a16d)からお借りしています。

```python
#!/usr/bin/python3
import random
import PySimpleGUI as sg
from PIL import Image, ImageDraw, ImageOps

import numpy as np
import cv2
import torch
import torch.nn as nn

class Net():
    """  
    このクラスは、kivy+PyTorchでお絵かきクイズを作る（https://qiita.com/dcm_fukushima/items/75f1bb7204470181a16d）をそのままお借りしました
    """
    # 認識モデルを読み込む
    def __init__(self):
        self.model = torch.load("./trained_models/whole_model_quickdraw", map_location=lambda storage, loc: storage) # 学習済みの重みファイルを読み込む
        self.model.eval() # 今回は学習は行わない
        self.sm = nn.Softmax(dim=1) # どの結果も信頼性が低い場合にスコアで足切りしたいのでsoftmaxで正規化する

    # 画像ファイル名を入力して認識結果を返す
    def predict(self, fn, th=.5):
        image = cv2.imread(fn, cv2.IMREAD_UNCHANGED)[:,:,-1] # alpha channelを取得して2値画像へ
        image = cv2.resize(image, (28, 28))
        image = np.array(image, dtype=np.float32)[None, None, :, :]
        image = torch.from_numpy(image)
        pred = self.model(image)
        pred = self.sm(pred)
        return torch.max(pred[0]), torch.argmax(pred[0]) # 認識スコアと認識クラスを返す

# 描画で使っている変数
white = (255, 255, 255)
draw_color='white'
app_size = (600, 700)
draw_canvas_size =(600, 490)
draw_thickness = 10
im = Image.new('RGB', draw_canvas_size, white)
draw = ImageDraw.Draw(im)
filename = "my_drawing.png"

action_list = [] # アクションの回数
# 認識できるクラス一覧
classes = ["apple", "book", "bowtie", "candle", "cloud", "cup", "door", "envelope", "eyeglasses", "guitar", "hammer",
           "hat", "ice cream", "leaf", "scissors", "star", "t-shirt", "pants", "lightning", "tree"]

def theme_change(classes):
    return 'Draw "{}"'.format(random.choice(classes).upper())

layout = [
    [sg.Text(theme_change(classes), key='_THEME_',  background_color='blue', size=(30,4), font=("Helvetica", 15), text_color='white'),sg.Submit(button_text='change', key='_CHANGETHEME_',size=(15,4) )],
    [
        sg.Graph(
            canvas_size=draw_canvas_size,
            graph_top_right=(0, 0),
            graph_bottom_left=draw_canvas_size,
            key="graph",
            change_submits=True,
            background_color=draw_color,
            # enabling drag_submits enables mouse_drags, but disables mouse_up events
            drag_submits=True
        )
    ],
    [sg.Button('undo', key='__UNDO__',  size=(35,2)), sg.Button('clear', key='_CLEAR_', size=(35,2))],
    [sg.Text('No input',background_color='red', size=(100,2), key='_RESULT_', text_color='white') ],
]

window = sg.Window("drawing", layout,size=app_size)
window.Finalize()

graph = window.Element("graph")
image_model = Net()
while True:
    event, values = window.Read()
    if event is None:
        break  # exit
    # print(event, values)

    if event.startswith("graph"):
        x, y = values["graph"]
        if event.endswith('+UP'):
            print(f"UP {values['graph']}")
        else:
            print(f"DOWN {values['graph']}")

            graph.DrawCircle(values['graph'], draw_thickness, fill_color='black',line_color='black') 
            action_list.append(values['graph'])

            # 画像に描く、canvasと原点座標のｘが左端と右端なので画像サイズから引く
            draw.ellipse((draw_canvas_size[0]-values['graph'][0]-draw_thickness,values['graph'][1]-draw_thickness, draw_canvas_size[0]-values['graph'][0]+draw_thickness,values['graph'][1]+draw_thickness), fill=(0, 0, 0), outline=(0, 0, 0))
            with torch.no_grad():
                score, label = image_model.predict(filename)

                # 認識スコアが一定以下の場合にはわからないと表示
                if score < 0.5:
                    result_txt = "CPU: I have no idea"
                else:
                    result_txt = 'CPU: I guess it is {}'.format(theme_change(classes))

                window.Element('_RESULT_').Update(result_txt)
    elif event == '__UNDO__':
        if len(action_list) > 5:
            for i in range(5):
                x,y =action_list.pop()
                graph.DrawCircle((x , y), draw_thickness, fill_color=draw_color,line_color=draw_color) 
                
                draw.ellipse((draw_canvas_size[0]-x-draw_thickness, y-draw_thickness, draw_canvas_size[0]-x+draw_thickness, y+draw_thickness), fill=(255, 255, 255), outline=(255, 255, 255))
        else:
            for i in range(len(action_list)):
                x,y =action_list.pop()
                graph.DrawCircle((x, y), draw_thickness, fill_color=draw_color,line_color=draw_color)
                draw.ellipse((draw_canvas_size[0]-x-draw_thickness, y-draw_thickness, draw_canvas_size[0]-x+draw_thickness, y+draw_thickness), fill=(255, 255, 255), outline=(255, 255, 255))
    elif event == '_CLEAR_':
        graph.DrawRectangle(
            top_left=(0,0),
            bottom_right=draw_canvas_size,
            fill_color=draw_color,
            line_color=draw_color
            )
        draw.rectangle((0, 0, draw_canvas_size), fill=white, outline=white)
        action_list=[]

    elif event == '_CHANGETHEME_':
        window.Element('_THEME_').Update(theme_change(classes))
    else:
        print(event, values)

    # ネガポジ反転して白黒をひっくり返す
    # ひっくり返した画像をαチャンネルにいれて、透過pngを作成する
    im_invert = ImageOps.invert(im.convert('RGB'))
    im.putalpha(im_invert.convert('L'))
    im.save(filename)
```
コード自体は131行で書きました

> pythonが約120行，kivyが約60行，計約180行で実装できました．

もとのKivyで書かれたコードに比べると若干少ないです。
ただし元のコードで会った機能に関しては1点だけ再現できていない機能があります。
Kivy側では`Clock.schedule_interval`を使用して1秒ごとにPyTorchで描いたもの判定を**非同期**で行っています。PySimpleGUIだと`threading`を使えば再現できるかと考えましたが、実際に動かしてみると`threading`を使わなくても体感では遅延がなかったので実装しませんでした。

#解説
PySimpleGUIは2018年に誕生したライブラリで、tkinterをラッパーしてPython上で使いやすくしたライブラリです。公式ではもとのライブラリーで書く場合と比べて、コード量が2分の1から10分の1程度でかけると書いてあります。特色としてはボタンの配置などのレイアウトをlist形式で書くことができます。
詳しくは以前説明した記事があるのでそちらを参考にしてみてください

- [Tkinterを使うのであればPySimpleGUIを使ってみたらという話](https://qiita.com/dario_okazaki/items/656de21cab5c81cabe59)


今回のお絵かきクイズですが元のKivyで書かれたコードをみると大体以下の順番で物体検出をしています。

1. Kivyのキャンバスで線を引く
2. キャンバスで書かれたコードを`export_to_png`でアルファチャンネル付きの透過pngファイル（image.png）に出力する
3. image.pngファイルを`Nat()`クラスに渡してお絵かき判定を行い判定結果を返す
4. 判定結果を表示する

この内容のうち1の「Kivyのキャンバスで線を引く」に関してはPysimpleGUIで実現するために`Graph`モジュールを使って実現しました。

- 参考（公式サイト）：[Graphing with Graph Element](https://pysimplegui.readthedocs.io/en/latest/cookbook/#graphing-with-graph-element)

元のkivyのファイルでは線を引いて書いていますが、今回は`DrawCircle`を用いてマウスがクリックされている間はマウスの座標に連続して円を描くことで線を引いています。

次に2のアルファチャンネル付き透過pngファイルですが、これはPySimpleGUIでは実現していません。Pythonの画像処理ライブラリであるPillowで処理しています。
内容的には、PySimpleGUIのキャンバスで書かれた内容をPillowを使って、キャンバスと同じサイズの画像オブジェクトを作成して、線を描くのと同時に同じ座標に書き込んで実現しています。

また最終的に透過pngを作るためにアルファチャンネル用のオブジェクトを作成してアルファチャンネルにしています。
この辺りの処理は以下の記事を参考にしました。

- [Python, Pillowで透過png画像を作成するputalpha](https://note.nkmk.me/python-pillow-putalpha/)

ちなみに画像を作る方法ですが、キャンバスのスクリーンショットをPillowの`ImageGrab`を用いて取得して保存する方法もあります。PySimpleGUIの公式twitterから教えてもらいました。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">canvas = window[&#39;-GRAPH ELEM-&#39;].Widget <br>box = (canvas.winfo_rootx(),canvas.winfo_rooty(),canvas.winfo_rootx()+canvas.winfo_width(),canvas.winfo_rooty() + canvas.winfo_height())<br>grab = ImageGrab.grab(bbox = box)<a href="https://t.co/iO7mVb9LkC">https://t.co/iO7mVb9LkC</a>(&quot;saved canvas.jpg&quot;)</p>&mdash; PySimpleGUI (@PySimpleGUI) <a href="https://twitter.com/PySimpleGUI/status/1203007549366247424?ref_src=twsrc%5Etfw">December 6, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

透過png以降の処理は元のKivyの処理と一緒です。
またアンドウ、画面クリアの方法も基本は同じです。

# KivyとPySimpleGUIとの比較
個人的なスタンスとしては個人的にはライブラリには優越はないと考えています。
あるのはライブラリの思想の違いと向き不向きがあると思います。

その上で両方のライブラリを触ってみた感想は以下の通りです。
なおKivyに関しては公式マニュアルの日本語翻訳に関わったり、紹介記事をQiitaで書いたりしていて多少知見があります。

- Kivyの良い点
   - 見ばえが何もしなくてもよい
   - 道具としていろいろそろっているのでGUIに関してはKivyだけで完結できる
   - Kv Languageを使用してレイアウトを書けば、レイアウトがわかりやすい
   - Kviewerを使用することでレイアウトのプレビューができる

- PySimpleGUIの良い点
    - 起動/動作が早い
    - 覚えることが少ない
    - ボタンの大きさなどウィジェットのパラメータがデフォルトの値でよければ、凝ったレイアウトがすぐ組める

デスクトップアプリを作るという点においてはどちらを使うかですが、個人的な印象は以下の通りです。

- Kivyをお勧めする人
    - 見た目（GUI）にこだわる人
    - がんばれる人
    - クロスプラットフォームという言葉に幻想と浪漫を感じる人

- PySimpleGUIをお勧めする人
    - 見た目（GUI）にあまりこだわらない人
        - GUIはインプット的な要素で会って、機械学習など内部的な処理に興味がある人

あとKivyは最近は解消され始めては来ていますが日本語入力に難があり日本語入力を多用するのであればPySimpleGUIの方がお勧めかもしれないです

# その他
今回、元の[kivy+PyTorchでお絵かきクイズを作る](https://qiita.com/dcm_fukushima/items/75f1bb7204470181a16d)を作られたドコモの方は、去年は[python+kivyで物体検出する動画プレイヤーを作る](https://qiita.com/dcm_fukushima/items/6e0c7bd232b335bdeb10)という素晴らしい記事を書いておられます。一度見られることをお勧めします。

なおPySimpleGUIでも公式で動画プレイヤーで物体検出を行っているサンプルプログラムがあるので紹介しておきます。

- https://github.com/PySimpleGUI/PySimpleGUI/tree/master/YoloObjectDetection)

公式から転載した実行結果
![58117189-017c9f00-7bcc-11e9-9569-c65775d15559.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11002/03945718-bab6-9c53-bf55-0ea2c4948d35.gif)

また、キャンバスに絵を描く機能ですが、
時間があれば[Plynth](https://www.plynth.net/)というElectronベースでJsコードの処理をPythonで書くことができる新しいライブラリーが今年の11月にリリースされているのでそちらで書いてみて感想をどこかに書こうと思っています。