この記事は2024年ゆる募AdventCalendar12日目の記事です.
最近C++ばっかりでPythonに触りたかったのと,tkinterを使ってみたかったのでCSVを読み込んでグラフを作成するアプリをつくってみました.
もっと,直したいところはありますがやったところまで...
依存関係
以下のライブラリを使用します.
import tkinter as tk
from tkinter import filedialog
import matplotlib.pyplot as plt
import japanize_matplotlib
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
import pandas as pd
import os
完成イメージ
今回の完成イメージは以下のようになります.
画面の左側で,csvファイルの選択・データの表示・グラフの設定を行い,画面の右側にグラフを表示します.
グラフの種類は「ヒストグラム」「散布図」「折れ線グラフ」「3D散布図」を選択できるようにしました.
また,どの列のデータを使うかをドロップダウンで選択,軸のラベル・軸の範囲をキーボードで入力できるようにしました.
コードの構成
このアプリは主に以下のクラスで構成されています:
- CsvLoader:CSVデータを読み込んでテキストウィンドウに表示.
- GraphSettings:グラフ設定を行うインターフェースを作成し入力された設定値を取得.
- GraphViewer:matplotlibを使用してデータをグラフとして描画.
- Csv2GraphApp:全体のアプリケーションのルートクラス.各コンポーネントを統合.
CsvLoader
CsvLoaderではCSVをpandasで読み込み,そのデータフレームを左画面中央のテキストボックスに表示します.
__init__
でこのクラスで必要なウィジェットを設置したり,変数を初期化しています.
それぞれのボタンや入力窓等を作成し,-.grid()
で表示しています.grid()
を使うことで特に難しいことを考えなくてもセル形式に配置できるので楽ちんです.ボタンに与えられているcommand
オプションは,そのボタンが押されたときに実行される関数を与えるものです.これがないと,ボタンを押しても何も起きないままになってしまいます.
class CsvLoader:
def __init__(self, parent,on_columns_loaded):
# フレームの設置
self.frame = tk.Frame(parent)
self.frame.grid(row=0, column=0, sticky="nsew")
# ファイル選択部の作成
self.file_label = tk.Label(self.frame, text="ファイル:")
self.file_entry = tk.Entry(self.frame, width=80)
self.file_button = tk.Button(self.frame, text="参照", command=self.load_file)
self.file_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.file_entry.grid(row=0, column=1, padx=5, pady=5, sticky="we")
self.file_button.grid(row=0, column=2, padx=5, pady=5)
# データ表示部の作成
self.data_view_button = tk.Button(self.frame, text="データ読み込み", command=self.view_data)
self.data_text = tk.Text(self.frame, height=30)
self.data_view_button.grid(row=1, column=0, columnspan=3, padx=5, pady=5)
self.data_text.grid(row=2, column=0, columnspan=3, padx=5, pady=5, sticky="nsew")
# 後で使う変数の初期化
self.file_path = None
self.data = None
self.on_columns_loaded = on_columns_loaded
このクラスでは,
load_file
view_data
の2つの関数を提供しています.それぞれ見ていきましょう.
load_file
class CsvLoader:
def load_file(self):
filetypes = [
("すべてのファイル","*"),
("すべてのデータファイル",["*.txt","*.csv","*.dat","*.log"]),
("TXTファイル","*.txt"),
("CSVファイル","*.csv"),
("DATファイル","*.dat"),
("LOGファイル","*.log")
]
self.file_path = filedialog.askopenfilename(filetypes=filetypes, initialdir=os.getcwd())
if self.file_path:
self.file_entry.delete(0, "end")
self.file_entry.insert(0, self.file_path)
この関数は,ファイル選択部の「参照」ボタンが押されたときに実行されます.
ファイルダイアログを開き,選択されたファイルのフルパスを取得しファイル選択部の入力窓に表示します.
filedialog.askopenfilename
を使用することで,上記のことが実行できます.
filetypes
のオプションではファイルダイアログに表示するファイル名や拡張子を絞り込むことが可能です.今回は,txt,csv,dat,logの拡張子で絞り込めるようにしました.
initialdir
はファイルダイアログを開いた最初の画面で表示されるディレクトリを指定するオプションで,今回はカレントディレクトリを指定しています.
パスを取得した後は,入力窓("self.file_entry")に表示します.entry.insert
で文字列を挿入することができますが,このまま挿入してしまうと予め入力されてた文字列がそのまま残ってしまいます.そのため,self.file_entry.delete
を実行することで一度entry
を空にしてからself.file_entry.insert
で挿入しています.
filetypes
のオプションで複数のタイプを与えると以下のように選ぶことができるようになります.
view_data
class CsvLoader:
def view_data(self):
file_path = self.file_entry.get()
if file_path:
try:
self.data = pd.read_csv(file_path)
self.data_text.delete("1.0", "end")
self.data_text.insert("1.0", self.data.to_string())
self.on_columns_loaded(self.data.columns.tolist()) # 列名を通知
except Exception as e:
self.data_text.delete("1.0", "end")
self.data_text.insert("1.0", f"エラー: {e}")
この関数は,「データ読み込み」のボタンが押されたときに実行され,先ほどの入力窓(file_entry
)に入力されているパスを取得し,そのファイルをおなじみのpd.read_csv
で読み込みます.読み込めた場合は,データフレームを文字列に変換し,テキストボックスに表示します.ここでも,先ほどと同じように一度テキストボックス中を削除してから表示します.
ファイルを読めなかった場合はエラー文が表示されます.
また,グラフに使うデータ列を選択するドロップダウンで列名のリストを用いるために,列名をon_columns_loaded
でコールバックしています.
GraphSettings
GraphSettingsでは,グラフを描画するのに用いる様々な設定の入力欄を作成し,その情報を返すはたらきをします.
__init__
ではCsvLoaderと同じように,このクラスで必要なウィジェットを設置したり,変数を初期化しています.
class GraphSettings:
def __init__(self, parent):
self.frame = tk.Frame(parent)
self.frame.grid(row=1, column=0, sticky="nsew")
# グラフ設定
self.graph_type_lists = [
"ヒストグラム",
"散布図",
"折れ線グラフ",
"3D散布図"
]
self.graph_type_label = tk.Label(self.frame, text="グラフの種類")
self.graph_type_var = tk.StringVar(value="選択")
self.graph_type_menu = tk.OptionMenu(self.frame, self.graph_type_var, *self.graph_type_lists)
self.graph_type_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.graph_type_menu.grid(row=0, column=1, padx=5, pady=5, sticky="we")
# パラメータ設定
self.setting_parameters_str = [# 文字列のパラメータ
"data1_index",
"axis_label",
]
self.setting_parameters_num = [# 数値のパラメータ
"axis_min",
"axis_max"
]
self.setting_parameters = self.setting_parameters_str + self.setting_parameters_num
self.labels = {
**{param: {axis: tk.StringVar() for axis in ["X軸", "Y軸", "Z軸"]} for param in self.setting_parameters_str},
**{param: {axis: tk.DoubleVar() for axis in ["X軸", "Y軸", "Z軸"]} for param in self.setting_parameters_num}
}
self.entries = {param: {} for param in self.setting_parameter[1:]}
self.data1_menus = {}
column = 1
for i in ["データ列","軸ラベル","最小値","最大値"]:
tk.Label(self.frame,text=i).grid(row=1, column=column, padx=5, pady=5, sticky="w")
column += 1
row = 2
column = 1
for axis in ["X軸", "Y軸", "Z軸"]:
tk.Label(self.frame, text=f"{axis}:").grid(row=row, column=0, padx=5, pady=5, sticky="w")
menu = tk.OptionMenu(self.frame, self.labels["data1_index"][axis], "選択")
menu.grid(row=row, column=1, padx=5, pady=5, sticky="w")
self.data1_menus[axis] = menu
column = 2
for param in self.setting_parameters[1:]:
entry = tk.Entry(self.frame, textvariable=self.labels[param][axis])
entry.grid(row=row, column=column, padx=5, pady=5, sticky="we")
self.entries[param][axis] = entry
column += 1
row += 1
tk.OptionMenu
はドロップダウンのウィジェットです.tk.StringVar
とtk.DoubleVar()
はtkinterで使われる変数オブジェクトで,ウィジェットに与えられた文字列や数値をPythonに橋渡しするために用いています.tk.OptionMenu(self.frame, self.graph_type_var, *self.graph_type_lists)
ではself.graph_type_lists
の要素がドロップダウンで表示され,選ばれた値がself.graph_type_var
(tk.StringVar
)に代入されます.ボタン上に表示されるのはself.graph_type_var
の値です.
self.labels
はグラフに使う列,軸のラベル名,軸の最大値・最小値を保持するためのtk.StringVar
とtk.DoubleVar
を辞書型で管理しています.それぞれのパラメータと軸に対してfor文で展開しています.これにより,以下のような辞書型リストが作成されます.
{
'data1_index': {
'X軸': <tk.StringVar instance>,
'Y軸': <tk.StringVar instance>,
'Z軸': <tk.StringVar instance>
},
'axis_label': {
'X軸': <tk.StringVar instance>,
'Y軸': <tk.StringVar instance>,
'Z軸': <tk.StringVar instance>
},
'axis_min': {
'X軸': <tk.DoubleVar instance>,
'Y軸': <tk.DoubleVar instance>,
'Z軸': <tk.DoubleVar instance>
},
'axis_max': {
'X軸': <tk.DoubleVar instance>,
'Y軸': <tk.DoubleVar instance>,
'Z軸': <tk.DoubleVar instance>
}
}
self.data1_menus = {}
以降のfor文で実際に以下の画面を作成しています.
1つ目のfor文で,表の1行目を作成し,2つ目のfor文で2行目以降を作成しています.
このクラスでは,
- update_columns
- get_settings
を提供しています.
update_columns
class GraphSettings:
def update_columns(self, columns):
for axis, menu in self.data1_menus.items():
menu['menu'].delete(0, 'end') # 既存の項目を削除
for column in columns:
menu['menu'].add_command(label=column, command=lambda axis=axis,value=column: self.labels["data1_index"][axis].set(value))
この関数はCsvLoaderに渡しています.csvを読み込み,列名のリストが通知されると,この関数によりデータ列のドロップダウンリストが更新されます.
menu['menu'].delete
でそれまでに持っていたドロップダウンリストを削除し,menu['menu'].add_command
で列名(column)をドロップダウンリストに追加していきます.command
オプションで,列名が選択されたときに対応するtk.StringVar
に代入するようにlambda式で記述しています.
get_settings
class GraphSettings:
def get_settings(self):
return {
"graph_type": self.graph_type_var.get(),
**{param : {axis: self.labels[param][axis].get() for axis in ["X軸", "Y軸", "Z軸"]} for param in self.setting_parameters}
}
この関数はグラフを作成する際に呼び出され,入力されているパラメータを返します.
GraphViewer
GraphViewerではグラフを作成し,表示するためのウィジェットと関数を提供しています.
__init__
ではグラフを表示するためのcanvas(FigureCanvasTkAgg
)とmatplotlibのツールバー(NavigationToolbar2Tk
)を表示しています.
plot_graph
関数では,既あるグラフを削除(self.fig.clear()
)し,先ほどのget_settings
で得たgraph_type
に合わせてグラフを作成します.
実際のグラフを記述する関数は_plot_hoogehoge
で,軸を記述する関数は_set_common_axes
です.これらの関数内に,get_settings
から得た情報をもとにグラフを記述する処理が書かれています.
class GraphViewer:
def __init__(self, parent):
self.frame = tk.Frame(parent, bg="yellow")
self.frame.grid(row=0, column=1, rowspan=2, sticky="nsew")
self.fig = plt.Figure()
self.canvas = FigureCanvasTkAgg(self.fig, self.frame)
self.toolbar = NavigationToolbar2Tk(self.canvas, self.frame)
self.canvas_widget = self.canvas.get_tk_widget()
self.toolbar.pack(side=tk.TOP, fill=tk.X)
self.canvas_widget.pack(fill=tk.BOTH, expand=True)
def plot_graph(self, data, settings):
self.fig.clear()
graph_type = settings.get("graph_type", "")
try:
if graph_type == "ヒストグラム":
self._plot_histogram(data, settings)
elif graph_type == "散布図":
self._plot_scatter(data, settings)
elif graph_type == "折れ線グラフ":
self._plot_line(data, settings)
elif graph_type == "3D散布図":
self._plot_3d_scatter(data, settings)
else:
raise ValueError(f"Unsupported graph type: {graph_type}")
except Exception as e:
print(f"Error plotting graph: {e}")
self.canvas.draw()
def _set_common_axes(self, ax, settings, is_3d=False):
if is_3d:
ax.set_xlabel(settings["axis_label"]["X軸"])
ax.set_ylabel(settings["axis_label"]["Y軸"])
ax.set_zlabel(settings["axis_label"]["Z軸"])
else:
ax.set_xlabel(settings["axis_label"]["X軸"])
ax.set_ylabel(settings["axis_label"]["Y軸"])
if is_3d:
ax.set_xlim(
settings["axis_min"]["X軸"],
settings["axis_max"]["X軸"]
)
ax.set_ylim(
settings["axis_min"]["Y軸"],
settings["axis_max"]["Y軸"]
)
ax.set_xlim(
settings["axis_min"]["Z軸"],
settings["axis_max"]["Z軸"]
)
else:
ax.set_xlim(
settings["axis_min"]["X軸"],
settings["axis_max"]["X軸"]
)
ax.set_ylim(
settings["axis_min"]["Y軸"],
settings["axis_max"]["Y軸"]
)
def _plot_histogram(self, data, settings):
ax = self.fig.add_subplot(111)
x = data[settings["data1_index"]["X軸"]]
ax.hist(x)
self._set_common_axes(ax, settings)
def _plot_scatter(self, data, settings):
ax = self.fig.add_subplot(111)
x = data[settings["data1_index"]["X軸"]]
y = data[settings["data1_index"]["Y軸"]]
ax.scatter(x, y)
self._set_common_axes(ax, settings)
def _plot_line(self, data, settings):
ax = self.fig.add_subplot(111)
x = data[settings["data1_index"]["X軸"]]
y = data[settings["data1_index"]["Y軸"]]
ax.plot(x, y)
self._set_common_axes(ax, settings)
def _plot_3d_scatter(self, data, settings):
ax = self.fig.add_subplot(111, projection='3d')
x = data[settings["data1_index"]["X軸"]]
y = data[settings["data1_index"]["Y軸"]]
z = data[settings["data1_index"]["Z軸"]]
ax.scatter(x, y, z)
self._set_common_axes(ax, settings, is_3d=True)
Csv2GraphApp
Csv2GraphAppでは,ここまでに作成したGraphSettings,GraphViewer,CsVLoaderを統合しています.
各コンポーネントのインスタンスを作成し,その中でon_columns_loadedにself.settings.update_columnsを渡すことで,データが読み込まれたときに列名のプルダウンを更新する関数を呼び出すようにしています.
また,プロット用のボタンを設置し,押されたときにplot_graph(GraphViewerにも同名の関数がってわかりにくい...)を実行しグラフを表示しています.
class Csv2GraphApp:
def __init__(self, root):
root.title("CSV to Graph")
# 各コンポーネントのインスタンス作成
self.settings = GraphSettings(root)
self.viewer = GraphViewer(root)
self.loader = CsvLoader(root, on_columns_loaded=self.settings.update_columns)
# プロットボタン
self.plot_button = tk.Button(root, text="グラフを表示", command=self.plot_graph)
self.plot_button.grid(row=2, column=0, columnspan=2, pady=10)
def plot_graph(self):
if self.loader.data is None:
print("データが読み込まれていません")
return
settings = self.settings.get_settings()
self.viewer.plot_graph(self.loader.data, settings)
今後の改善点
また,暇があれば以下のようなところを改善したいなと思いながら,今回の記事はこれで終わります.
- 複数のデータの同時プロット
- 複数データの読み込み
- 第2軸の設定
- エラーバーの実装
- エラー処理の実装
- 本記事の添削