PythonアプリをGUI化、Exe化で使いやすくしよう

# はじめに
Pythonは機械学習を気軽に試すには便利な言語です。しかしながら、作ったものを非エンジニアの人に使ってもらおうと思うと、環境構築やコマンドライン実行はハードルが高いものです。
非エンジニアの人が気軽に使えるように、GUIの導入とExe化を行ったので、記事にまとめました。
GUI化はTkinter、Exe化はPyinstallerを使います。

# 開発環境
* python:3.10.9
* conda:4.10.1
* pyinstaller:5.6.2
* pillow:9.3.0 (画像変換用)


#  Tkinterとは？
PythonでGUIを構築するためのライブラリです。ティーケーインターと読みます。
Pythonの標準ライブラリなので、追加でパッケージを入れる必要はありません。

公式ドキュメント：https://docs.python.org/ja/3.10/library/tk.html

## Widget
WidgetはTkinterのGUIの部品です。
Widget内の文字列やチェックボタンの状態を動的に設定・取得したい場合は、ウィジェット変数(StringVar・BooleanVar・IntVar・DoubleVar)をバインドして使用します。
Tkinterが用意してくれているWidgetはいっぱいありますが、よく使いそうなWidgetを記載します。

| Widget | 用途| 見た目| 機械学習での利用例 |
| --- |  --- |  --- | --- |
|Label | テキストラベル|   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/1f48f624-9a6e-7005-ec5b-fe1553a1538b.png)| 説明の表示 |
| Entry | テキストボックス(1行) |   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/97479cf3-e364-4d60-c197-0c83a2a90d3c.png)| パラメータの指定 |
| Text | テキストボックス(複数行) |  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/f0d3cf61-4e0a-715d-b5a1-60c8f8cee0bd.png) | ログの表示 |
| Button |ボタン |  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/67d87340-8cf0-4285-99a5-3be26dce0d73.png) | 開始・停止・ファイル読み込みボタン |
| Checkbutton |チェックボタン |   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/4cf4e22d-3a24-3efa-7802-a83c03642191.png) | パラメータの指定 |
| Radiobutton |ラジオボタン |  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/176a39cb-cbd7-9628-95eb-6aa67bc87182.png) | パラメータの指定 |
| Canvas | 画像表示  | - | 読み込んだ画像の表示、画像認識した結果の表示 |
| Frame |Widgetをまとめる箱   | -  | Widgetの配置を見やすくするために |


## Widgetの配置に関して
Widgetを配置する方法として pack, grid, placeがあります。

| 配置する方法 | 用途 | 備考| 
| --- | --- | --- | 
| pack | 指定した方向から詰めて配置 | 2段以上で配置したい場合はパラメータの調整はかなり面倒です |
| place | 指定した座標に配置 | 配置は楽だが、後で配置をずらしたいときに、修正範囲が大きくなります |
| grid | グリッド形式で配置 | 行列の境界線を引きたいときは、専用のWidget(Separator)を配置します |


## サンプルアプリ
以下のような機能を持つサンプルアプリを作ります。
* 画像を選択して表示する
* ラジオボタン、チェックボタンの切り替えを、ログに表示する
* キー入力の結果を表示する (Tkinterのbindを使用します)

以下のコードでこのような見た目になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438288/e0195934-9eb9-acd9-8d3d-67fab6f1eb34.png)



``` sample.py
# Tkinterライブラリのインポート(ティーケーインター ： Python標準)
import tkinter as tk
from tkinter import filedialog
import tkinter.ttk as ttk
import os
# jpeg変換用
from PIL import Image, ImageTk

IMG_WIDTH  = 250
IMG_HEIGHT = 250

# Window作成
root = tk.Tk()
# 常に最前表示
root.attributes("-topmost", True)
# Windowsのタイトル設定
root.title("Tkinter Sample")
# 「幅x高さ+x座標+y座標」のフォーマットで指定
root.geometry('350x500+50+100')

####################################################################
# 画像を選択して表示する
# 配置：グリッド形式
####################################################################
# 画像ファイル選択ボタンが押された時の処理
def select(): 
    # 選択可能な拡張子を指定
    filetype = [("Image file", ".png .jpg .gif")]
    # ファイル選択の初期表示のディレクトリ
    path = os.path.abspath(os.path.dirname(__file__))
    # ファイル選択ダイアログ表示
    cap_filepath = filedialog.askopenfilename(filetype = filetype, initialdir = path)
    # Widgetにbindされた変数にファイル名を設定
    filename = os.path.basename(cap_filepath)
    cap_filename.set( filename )
    display_image(cap_filepath)

# 画像表示
def display_image(file): 
    # create_image()実行には時間がかかる。
    # 関数を抜けてもImageを保持しておく必要があるので、globalで定義
    global img
    img = None
    # tkinterではjpegをサポートしてないので、他のライブラリを使って変換が必要
    img = Image.open(open(file, 'rb'))
    img.thumbnail((IMG_WIDTH, IMG_HEIGHT), Image.ANTIALIAS)
    img = ImageTk.PhotoImage(img)
    canvas.create_image(  # キャンバス上にイメージを作成
       0,  # x座標
       0,  # y座標
       image=img,
       anchor=tk.NW  # 配置の起点を指定
    )

# グリッド配置用のフレームを作成
frame_grid = tk.Frame(root, 
    relief=tk.GROOVE,  # フレームの枠線の種類を指定
    borderwidth=2)
frame_grid.pack(side=tk.TOP)
# グリッド内にフレームを作成
frame_file = tk.Frame(frame_grid)
# 1行、1列目に配置
frame_file.grid(column=0, row=0, padx=5, pady=5)
# 画像ファイル選択ボタン
btn_select = tk.Button(frame_file, 
    text="File Select",  # ボタンに表示するテキスト
    command=select,      # ボタン選択時の処理
    bg='#2196f3')
btn_select.pack(side=tk.LEFT, padx=10)

# ファイル名表示用のテキストボックス
# 文字列型のウィジェット変数を準備
cap_filename = tk.StringVar()
textbox_cap = tk.Entry(frame_file, 
    textvariable=cap_filename,  # ウィジェット変数をバインドする
    width=30) # widthはピクセル数ではなく、システムフォント'0'の入る個数
textbox_cap.pack(side=tk.LEFT, padx=10)

# グリッドの境界線を引く
separator = ttk.Separator(frame_grid, 
    orient="horizontal")  # 境界線の方向(horizontal：水平方向、vartival：垂直方向)
separator.grid(column=0, row=1, 
    sticky="ew") # ew：水平方向に配置

# 画像ファイル表示
canvas = tk.Canvas(frame_grid, width=IMG_WIDTH,  height=IMG_HEIGHT )
canvas.grid(column=0, row=2, padx=5, pady=5)


####################################################################
# ラジオボタン、チェックボタンの切り替えを、ログに表示する
# 配置：pack形式
####################################################################
# ラジオボタンが押されたときの処理
def display_radio() : 
    if radio_var.get() == 0:
        # 1行目、0文字目に表示
        logbox.insert(1.0, "ラジオボタン：A\n")
    else:
        logbox.insert(1.0, "ラジオボタン：B\n")

# チェックボタンが押されたときの処理
def display_chkbox() : 
    if chkbox_var.get():
        logbox.insert(1.0, "チェックボックス：ON\n")
    else:
        logbox.insert(1.0, "チェックボックス：OFF\n")

# グリッド配置用のフレームを作成
frame_pack = tk.Frame(root, relief=tk.GROOVE, borderwidth=2)
frame_pack.pack(side=tk.TOP, pady=5)

# ラジオボタン
radio_frame = tk.Frame(frame_pack, padx=3, pady=3)
radio_frame.pack(side=tk.TOP)
# 整数型のウィジェット変数を準備
radio_var = tk.IntVar( value = 1 )  # 初期値を設定
radio1 = tk.Radiobutton(radio_frame, 
    value=0,
    variable=radio_var, # ウィジェット変数をバインドする
    text='A',
    command=display_radio) # ラジオボタン選択時の処理
radio1.pack(side=tk.LEFT)
radio2 = tk.Radiobutton(radio_frame,
    value=1,
    variable=radio_var,
    text='B',
    command=display_radio)
radio2.pack(side=tk.LEFT)

# チェックボタン
# Boolean型のウィジェット変数を準備
chkbox_var = tk.BooleanVar()
chkbox_var.set(True) # 初期値を設定
chkbox = tk.Checkbutton(frame_pack, 
    variable=chkbox_var,
    text="Checkbutton",
    command=display_chkbox) # チェックボタン選択時の処理
chkbox.pack(side=tk.TOP)

# ログ表示 エリア
log_label = tk.Label(frame_pack, text='ログ')
log_label.pack(side=tk.LEFT, padx=5)

logbox = tk.Text(frame_pack, width=40, height=5)
logbox.pack(side=tk.TOP)

####################################################################
# キー入力の結果を表示する (Tkinterのbindを使用します)
# 配置：place形式
####################################################################
# 入力されたキーをtextbox_keyに表示
def key_press(e):
    # Entryの中を一度消して、再設定
    textbox_key.delete( 0, tk.END ) 
    textbox_key.insert( 0, e.char+" Press" ) 

# キー入力確認
lbl_key = tk.Label(root, text='キー入力')
lbl_key.place(x=20, y=445)

textbox_key = tk.Entry(root, width=20)
textbox_key.place(x=50, y=470)
textbox_key.insert(0, "キーを押してください") 
# キーバインド
root.bind('<KeyPress>', key_press)


# 表示開始＋ループしてイベント発生を待つ
root.mainloop()
```

# Pyinstallerとは？
Pythonアプリケーションとその依存関係を1つのパッケージするためのツールです。
公式ドキュメント：https://pyinstaller.org/en/stable/

## インストール
condaで環境構築しているので、condaでインストールします。
pipで環境構築している人はpipを使用してください。
``` sh
conda install pyinstaller
```

## Exe化
``` sh
pyinstaller sample.py --onefile --noconsole
```
このコマンドで、distフォルダの下にsample.exe が作成できます。

**オプションの説明**
--onefile：1ファイルで実行ファイルを作成します。アプリの起動は、1ファイルにしない方が早いです。
--noconsole：標準的な入出力のためのコンソールウィンドウを表示しない。


# まとめ
今回は各Widgetの詳細な説明や個人的に利用頻度の低いと思うWidgetの説明は省略しましたが、他にも様々な機能があるのでもっとリッチなUIにすることもできます。
この記事がPythonアプリGUI化、Exe化の導入の助けになれば、幸いです。