👏

Python GUIソフトのexeファイル作成

2024/02/21に公開

目標

Pythonで作成したGUIソフトをexeファイルにし、配布可能なGUIソフトを作成する。

Python環境の設定

最終的にexeファイルを作成する時、Pythonの場合は現在のPython環境にインストールしたライブラリが全てコンパイルされてしまう。その場合、作成されたソフトの動作が遅くなったり、不必要にソフトのファイル容量が重くなる。
Pythonでコンパイルを行う場合は、先に特別環境を作成することが望ましい。
pyenv, pyenv-virtualenv, venv, Anaconda, Pipenv場合によってはDockerなど選択枝はあるが、今回はPipenvを使用する。

使い方は以下参照。上から順にインストール、pythonバージョン指定して初期化。仮想環境へ入る。仮想環境を出る。仮想環境を削除。pipfileから仮想環境再構築。pipenv shellで仮想環境に入ってからrequirements.txtを用いてpipでライブラリをインストール。

$ pip install pipenv
$ pipenv --python 3.8
$ pipenv shell
$ exit
$ pipenv --rm
$ pipenv install

$ pip install -r requirements.txt

Pythonのバージョンとライブラリを確認する。

$ pip list
$ python --version 

Python GUIの作成

Tkinter, PySimpleGUI, Kivy, PyQt, wxPython,fletから選択する。
今回は簡単に使えるfletを用いる。
fletはマルチプラットフォーム対応、ホットリロード対応などの利点があり、現状最もモダンなライブラリと考えた。
GUIの書き方について、tkinterなどのよく使うライブラリであれば、ChatGPTに書き方を指南していただくこともできるが、ChatGPTはfletについての知識がない。
簡単な操作を学ぶため、以下の記事のスクリプトを使う。

$ git clone https://github.com/AWtnb/flet_4up_idphoto

上記参考をもとに私が作成したスクリプトを以下に示す。

gui_flet2.py
from pathlib import Path  # ファイルパスを操作するためのPathクラスをインポート
import flet as ft  # fletライブラリをインポート
import plot_dynamic_max as pdm  # プロット関連の関数を含むスクリプトをインポート

def main(page: ft.Page):
    # ページのタイトルとテーマを設定
    page.title = "structure dynamic grapher"
    page.theme_mode = "light" # light or dark

    # UI要素のための変数を作成
    target_file = ft.Ref[ft.Text]() # CSVファイルの読み込み先を保持する変数
    output_folder = ft.Ref[ft.Text]() # グラフの保存先を保持する変数
    result_message = ft.Ref[ft.Text]() # 処理結果のメッセージを保持する変数

    file_extensions = ["csv"]  # 許可されるファイルの拡張子のリスト
    ui_rows = []  # UI要素の行を格納するリスト

    ###################################
    # file picker
    ###################################

    def on_file_picked(e: ft.FilePickerResultEvent): # ファイルピッカーでファイルが選択されたときの処理
        if e.files:
            print(e.files[0], type(e.files[0]))
            target_file.current.value = e.files[0].path  # 選択されたファイルのパスを取得
            output_folder.current.value = str(Path(target_file.current.value).parent)  # 選択されたファイルの親ディレクトリを取得
            page.update()

    file_picker = ft.FilePicker(on_result=on_file_picked)  # ファイルピッカーを作成
    page.overlay.append(file_picker)  # ページにファイルピッカーを追加

    def show_file_picker(_: ft.ControlEvent): # ファイルピッカーを表示する関数
        file_picker.pick_files(
            allow_multiple=False,
            file_type="custom",
            allowed_extensions=file_extensions
        )

    ui_rows.append(ft.Row(controls=[
        ft.ElevatedButton("Select File", on_click=show_file_picker),
        ft.Text(ref=target_file)
    ]))

    #select_file_button = ft.ElevatedButton("Select File", on_click=show_file_picker)
    #select_file_button_container = ft.Column(controls=[select_file_button], )

    # ボタンを円形にする場合
    """
    ui_rows.append(ft.Column(
        controls=[
            ft.ElevatedButton(
                "Select File", on_click=show_file_picker,
                style=ft.ButtonStyle(shape=ft.CircleBorder(), padding=30),
            ),
        ]
    ))
    """
    
    ###################################
    # folder picker
    ###################################

    def on_folder_picked(e: ft.FilePickerResultEvent): # フォルダピッカーでフォルダが選択されたときの処理
        if e.path:
            output_folder.current.value = e.path  # 選択されたフォルダのパスを取得
            page.update()

    folder_picker = ft.FilePicker(on_result=on_folder_picked)  # フォルダピッカーを作成
    page.overlay.append(folder_picker)  # ページにフォルダピッカーを追加

    def show_pick_folder(_: ft.ControlEvent): # フォルダピッカーを表示する関数
        folder_picker.get_directory_path()

    ui_rows.append(ft.Row(controls=[ # フォルダ選択ボタンと選択結果のテキストを含むUI要素を追加
        ft.ElevatedButton("Select Output Folder", on_click=show_pick_folder),  # フォルダ選択ボタン
        ft.Text(ref=output_folder)  # 選択されたフォルダのパスを表示するテキスト
    ]))


    ###################################
    # execute button
    ###################################

    def execute(_: ft.ControlEvent): # 実行ボタンがクリックされたときの処理
        if not target_file.current.value or not output_folder.current.value:  # ファイルまたはフォルダが選択されていない場合は処理しない
            return
        result_message.current.value = ""  # 結果メッセージをクリア
        page.update()  # ページを更新
        if str(Path(target_file.current.value).suffix)[1:].lower() in file_extensions:  # 選択されたファイルの拡張子が許可されている場合
            ui_controls.disabled = True  # UIを無効化
            save_ = pdm.path_to_graph(target_file.current.value, output_folder.current.value) # グラフ作成関数呼び出し
            ui_controls.disabled = False  # UIを有効化
            result_message.current.value = "FINISHED!"  # 処理結果を表示
            ui_rows.append(ft.Image(save_, height=500, fit=ft.ImageFit.CONTAIN))  # 生成されたグラフを表示
        else:
            result_message.current.value = "incorrect file..."  # 不正なファイルが選択された場合はエラーメッセージを表示

        page.update()  # ページを更新

    ui_rows.append(ft.Row(controls=[ # 実行ボタンと結果メッセージを含むUI要素を追加
        ft.FilledButton("GO!", on_click=execute),  # 実行ボタン
        ft.Text(ref=result_message)  # 処理結果のメッセージを表示するテキスト
    ]))

    ###################################
    # render page
    ###################################
    
    ui_rows = [ft.Text(
        "地震応答解析最大応答値",
        style="labelSmall",
        weight="bold",
        color=ft.colors.BLUE, # ラベルのスタイルを設定
    )] + ui_rows

    ui_controls = ft.Column(controls=ui_rows)  # UI要素を含むコラムを作成
    page.add(ui_controls)  # UIをページに追加

ft.app(target=main)


# GUIテスト : flet run gui_flet2.py -d

GUIソフトの機能例

地震応答解析の最大応答値データのグラフ化を行う。

plot_dynamic_max.py

import csv # csv読取にもpandasというライブラリを使うことが多い
import os
from pathlib import Path
import mc_list as mc
from matplotlib import pyplot as plt
import japanize_matplotlib

def read_csv(filename):
    csv_data = []
    with open(filename, encoding='ms932', newline='') as f:
        csvreader = csv.reader(f)
        for row in csvreader:
            csv_data.append(row)

    csv_datat = list(zip(*csv_data)) # 2次元配列転置

    csv_dict = {} # dict形式に
    for i in range(len(csv_datat)):
        row = list(csv_datat[i][1:]) # 反転のためにリスト化
        row.reverse() # データの反転
        try:
            # float加工できるものは加工
            csv_dict[csv_datat[i][0]] = [float(x) for x in row]
        except: # 加工できないものは文字列のまま
            csv_dict[csv_datat[i][0]] = row

    return csv_dict

def mm_to_inch(mm):
    return mm/25.4

def graph_Q(csv_dict, save):
    plt.rcParams['figure.figsize'] = (mm_to_inch(70), mm_to_inch(140)) # figure size in inch, 横×縦
    plt.rcParams['font.family'] = 'Times New Roman' # font familyの設定
    plt.rcParams['mathtext.fontset'] = 'stix' # math fontの設定
    plt.rcParams["font.size"] = 9 # 全体のフォントサイズが変更されます。
    plt.rcParams['xtick.labelsize'] = 9 # 軸だけ変更されます。
    plt.rcParams['ytick.labelsize'] = 9 # 軸だけ変更されます
    plt.rcParams['legend.fontsize'] = 6 # 凡例の文字だけ小さく 
    plt.rcParams['xtick.direction'] = 'in' # x axis in
    plt.rcParams['ytick.direction'] = 'in' # y axis in 
    plt.rcParams['axes.linewidth'] = 1.0 # axis line width
    plt.rcParams['axes.grid'] = True # make grid
    
    plt.rcParams["legend.fancybox"] = False # 丸角
    plt.rcParams["legend.framealpha"] = 1 # 透明度の指定、0で塗りつぶしなし
    plt.rcParams["legend.edgecolor"] = 'black' # edgeの色を変更
    #plt.rcParams["legend.handlelength"] = 1 # 凡例の線の長さを調節
    #plt.rcParams["legend.labelspacing"] = 5. # 垂直(縦)方向の距離の各凡例の距離
    #plt.rcParams["legend.handletextpad"] = 3. # 凡例の線と文字の距離の長さ
    #plt.rcParams["legend.markerscale"] = 2 # 点がある場合のmarker scale
    #plt.rcParams["legend.borderaxespad"] = 0. # 凡例の端とグラフの端を合わせる
    plt.rcParams['figure.dpi'] = 300
    
    fig = plt.figure()
    fig_1 = fig.add_subplot(111)
    numeric_y_data_10 = range(len(csv_dict['階'])) # 階のデータ個数に対応するy軸リスト。[0,1,***,30]

    waves = list(csv_dict.keys())[1:] # 地震波名
    
    # 各波のデータをプロット
    max_Q = 0
    for i in range(len(waves)):
        print(f"地震波名:{waves[i]}")
        wave_name = waves[i][7:11] # 地震波名_短縮系
        fig_1.plot(csv_dict[waves[i]], numeric_y_data_10, marker=mc.mc_list[i][0], markersize=3, markeredgewidth=0.5, markeredgecolor='k', color=mc.mc_list[i][1], label=wave_name)
        if max(csv_dict[waves[i]]) > max_Q:max_Q=max(csv_dict[waves[i]]) # 全ての波での最大せん断力取得

    fig_1.set_xlabel("せん断力 [kN]", font='IPAexGothic')
    fig_1.set_ylabel("階", font='IPAexGothic')
    
    # グラフの設定
    #plt.xlabel("せん断力")
    
    y_max = max(numeric_y_data_10) + 0.5
    y_min = min(numeric_y_data_10) - 0.5
    plt.ylim(y_min, y_max)  # Y軸の範囲を1から30に設定
    plt.yticks(numeric_y_data_10, csv_dict['階']) # 数値と階を対応させる。
    plt.xticks(rotation=45)  # X軸のラベルを45度回転して表示
    xdelta = 2000
    plt.xticks(range(0, int(max_Q)+xdelta, xdelta))  # X軸の目盛りを1000刻みに設定
    plt.yticks(numeric_y_data_10, csv_dict['階']) # 数値と階を対応させる。

    plt.legend()

    #plt.show()
    # save
    fig.savefig(save, bbox_inches="tight", pad_inches=0.05)

def path_to_graph(file_path, dir): # GUIからの呼び出し用
    #file_name, file_extension = os.path.splitext(file_path)
    name_only = Path(file_path).stem # ファイルの拡張子抜きの名前のみ
    save1 = name_only + '.png'
    save = os.path.join(dir, save1)
    csv_dict = read_csv(file_path)
    graph_Q(csv_dict, save)
    return save

def main(): # 単体テスト用
    file_path = 'せん断力最大応答.csv'
    file_name, file_extension = os.path.splitext(file_path)
    save = file_name + '.png'
    csv_dict = read_csv(file_path)
    graph_Q(csv_dict, save)

if __name__ == "__main__":
    main()

exeファイルの作成

Pyinstaller, Nuitka(ニュイティカ),cx_Freezeから選択。
Pyinstaller:重いファイルが作成される。起動時間が長い。
Nuitka :ビルド時間が長いが軽いファイルができる。私の環境では起動が安定しなかった。fletには現状対応していないらしい。
cx_Freeze:重いファイルが作成される。起動時間が短い。
今回はcx_Freezeを用いる。
仮想環境内で以下のコマンドを入力する。

$ cxfreeze -c gui_flet2.py --target-dir gui_flet2

以上で自作ソフトが配布可能になる。

参考

Discussion