TkinterによるPythonのGUIプログラミング

趣旨

  • たまに使おうとするとウェブを漁りまくることになるので,注意点だけまとめておく。

ドキュメント

tkとttk

  • テーマを設定すれば一括してウィジットの見た目を変更できるのがttk。
  • 多くのウィジットが同名のウィジットがあるけれど,canvas, menu, messageはttkにはないので注意する。

ttkテーマの設定

style = ttk.Style()
available_themes = style.theme_names() # 利用可能テーマの確認
style.theme_use('clam')  # テーマを指定する。

ウィジット配置

  • 3つのマネージャーが用意されているが,座標指定のplaceはほぼ使わない。1行または1列に並ぶだけの場合にはpackマネージャーがが有用。ただし,多くの場合はpackでは済まないので,gridを使うことが基本になる。グリッドと言っても,columnspan/rowspanで複数行列に跨ることは可能なので,大概は問題ない。
  • 適切な配置をするためにexpand/anchor/fill/padxy/ipadxyのオプションについて理解しておく。
    • packのオプション
      • expand:親ウィジットの拡大縮小に応じてリサイズするかどうか。
      • anchor:左寄せ,上寄せなど,スペースに空きがある場合にどこに寄せるか。東西南北(EWSN)により8方向の中から指定する。例えば,右上は'NE'
      • fill:スペースがある場合にウィジットを拡大する。x, y, bothでどちらに拡大するかを指定する。
      • padxy:外側のスペース。
      • ipadxy:内側のスペース。
    • gridのオプション
      • row/column:行列を指定。0から開始。
      • row/columnspan:ぶち抜く場合には幅を指定する。
      • padxy,ipadxy:packと同じ。
      • sticky:packのfill+anchorのオプション。sticky=tk.Eだと右寄せ。fillする場合は'+'でつなぐ。例えば,横に伸ばす場合にはsticky=tk.W+tk.E,全体はsticky=tk.W+tk.E+tk.N+tk.S,みたいな要領。

ウィンドウの拡大縮小

  • 親フレームをexpand, fill, anchorでrootと連動して拡大縮小するようにpackしておく。
  • 子ウィジットはgridで設置する際に,拡大縮小したいウィジットをのcolumnconfigure, rowconfigureのweightを1にする。デフォルトでは0になっている。よって,例えば,画面サイズに依存せず同じ大きさで良いボタンなどはデフォルトのまま(0)にしておいて,textやentryなどを1にしておくと,そのウィジットだけ拡大縮小される。
parent_frame.columnconfigure(1, weight=1)  # 列方向は1列目が拡大縮小される。
parent_frame.rowconfigure(2, weight=1)     # 行方向は2行目が拡大縮小される。

値の取得方法

  • textやentryなどの値は,ウィジット生成時にtextvariableにtk.Variableを指定しておけば自動的にその変数に値が格納される。
  • tk.Variableは型ごとにIntVar, StringVar, BooleanVarなどが用意されていて,変換もしてくれる。
  • 値を取得する場合にはget(),設定する場合はset()。生の変数(StringVarなど)はそのままでは値ではないので注意する。
entry_var = Tk.StringVar()
entry = Tk.Entry(self, textvariable=entry_var)

コールバックの設定(command/bind)について

  • ボタンを押した場合などの処理をcommandにて設定することが可能である。
  • 注意としては,commandに渡す関数は引数を取れないことである。よって,関数生成時に引数をクロージャで包み込んでおいて,それをcommandに渡すようにする。

textウィジット

  • 文字列の書き込みはinsertを使う。第一引数で書き込み開始位置を,第2引数で文字列を渡す。
  • 現在の最後尾がtk.ENDで指定できる。
  • 書き込むだけだと,表示位置は動いてくれないので,seeで表示位置も動かす。
txt_wdgt.insert(tk.END, (''hogehoge))  # 文字列はなぜかカッコで囲む。
txt_wdgt.see(tk.END)

イベント処理

キーイベント

  • ウィジット毎にバインドできる。当然rootウィンドウにもバインドできる。
  • callbackは1引数としてevent情報を取る関数。(クラス内ならselfも入れて2引数)
  • イベントには下記のプロパティが設定されている。
    • x,y: マウス位置
    • num: マウスボタンの番号(1:左,2:真ん中,3:右)
    • time: イベント時刻
    • char: キー文字
    • keysym: キーに対する名前
# widget.bind(<Event Sequence>, callback)の書式
# Event Sequence = <modifier>-<modifier>-<type>-<detail>の書式
#  i.e.) <Control-Shift-A>, <Buttun-1-KeyPress>, <Button-1-KeyPress-Motion>
root.bind('<Right>', move_right)  # arrow key:[Right, Left, Up, Down]
root.bind('<Escape>', move_right)  # arrow key:[Right, Left, Up, Down]

def move_right(e):
    print(e.x)

ウィジット情報の取得と設定

  • cgetで取得し,configureで設定する。ただし,canvasの項に記載の通り,ウィジットの大きさに関してはcgetは初期値の設定情報のようで,リアルタイムな値はwinfo_xxxを確認する。
  • ウィジットはそれぞれwinfo_xxx(widget information)を持っている。
  • xxxとしてはgeometory, width, height, x, y, rootx, rootyがある。x,yは親ウィジット内での位置,rootx,yはディスプレイ上の位置。
  • ウィジットが生成された直後に出力してもwinfo_xxxが正しく取れない場合があるので注意。その場合はupdate_idletasksをして実行させるか,他の処理で時間を消費してからアクセスする。

Canvasウィジット

色の指定

  • '#RGB'で各色16進(0〜F)で指定する。各色2バイトにも出来る(0〜FF)。

オブジェクト操作

  • Canvas内のオブジェクト(Rectangle)などはそれぞれIDを持っており,move, deleteなどIDを介して操作が可能。
  • scale関数もあるので,ウィンドウのリサイズに応じてオブジェクトをリサイズすることも可能。

ウィンドウのリサイズに伴うオブジェクトのリサイズ

  • Canvasウィジット自体はpackならexpandに,gridならcolumnconfigureなどで自動でリサイズできる。
  • ただし,Canvas内の描画オブジェクトはリサイズされないので別途対処が必要。
  • ウィンドウ(rootウィジット)のりサイズはにて検出する。そして,検出イベントのコールバックとして各図形をリサイズする。
  • キャンバスのりサイズ後の大きさはcget(configure get)では得られない。cgetでは初期設定値が返ってくる。代わりに,winfo_width(), winfo_height()にて取得可能。
  • ウィジットが生成された直後に出力してもwinfo_xxxが正しく取れない場合があるので注意。その場合はupdate_idletasksをして実行させるか,他の処理で時間を消費してからアクセスする。
root.bind('<Configure>', rescale_objects)

クラスとして書く

  • フラットに書かれている例が多いが,クラス化しておくと見通しも良いので,クラスの書き方テンプレートをもとに拡張する。
  • フラットに書いてあるものと基本は同じ。
import tkinter as tk

class Application(tk.Frame):
    def __init__(self, master=None):
        tk.Frame.__init__(self, master)
        master.title("My application")
        self.pack(expand=1, fill=tk.BOTH, anchor=tk.NW)
        self.create_widgets()

    def create_widgets(self):
        self.label = tk.Label(self, text='Input file')
        self.label.grid(column=0, row=0)
        
root = tk.Tk()
app = Application(master=root)
app.mainloop()

その他

バージョン確認

  • Python3.6付属のTkinter8.0以降は見た目がきれいになっている。
$ python -c "import tkinter;print(tkinter.TkVersion)"

ウィジットのプロパティへのアクセス

  • 各ウィジットのプロパティ一覧はconfigure()にて取得できる。
w = tk.WidgetXXX()
print(w.configure())

スクロールについて

  • textなどにおいて,スクロールバーは付けなくてもどんどん延長は自動でしてくれる。記述が煩雑で見た目もきれいではないので付けなくても良い気がする。

MatplotlibのimshowでRGBデータを表示

  • imshowは2Dデータだけでなく,RGBデータを含む(H, W, CH)のデータを表示することができる。
  • その際に,floatかintかによって,値の範囲が異なるようだ(調べきれていないので自信が無いけど)
  • float32のときは,[0,1]の実数値,int32などの整数値のときは[0,255]の256値でやるとうまく行く。
  • vmin, vmaxを設定してもうまく認識されなかった。
# uintの場合
import numpy as np
import matplotlib.pyplot as plt
img = np.array([ [[255,0,0], [0, 255,0], [0, 0, 255]],
                 [[255,255,0], [0, 255,255], [255, 0, 255]],
                 [[0,0,0], [128, 255,128], [255, 255, 255]]])
plt.imshow(img, vmin=0, vmax=255, interpolation='none')
plt.show()

f:id:nobUnaga:20180612234017p:plain

# floatの場合
import numpy as np
import matplotlib.pyplot as plt
img = np.random.randint(0, 2, (5,5,3)).astype(np.float32)
plt.imshow(img, vmin=0, vmax=1, interpolation='none')
plt.show()

f:id:nobUnaga:20180612234009p:plain

Pythonのパッケージ構成とimport文

パッケージについて

PyPIとかへの登録とかはおいておいて,自作パッケージをimportしたり,他のプログラムから使おうとするときの方法を整理しておく。下記に従えば,最悪,mypackage以下を開発中のパッケージにコピーすれば,そのまま使える。

ディレクトリ構成

  • git管理ディレクトリMyPackageに以下の構成にてディレクトリを作成する。
  • パッケージ名はmypackage(全部小文字)とし,すべてMyPackage/mypackage以下に関連ソースコード,データを配置する。基本的にはこのディレクトリがパッケージのトップディレクトリであると考える。
  • パッケージの中で静的データを使う場合(例えばゲームのマップファイルなど)は,mypackage以下に置く。間違ってMyPackage以下に置かない。繰り返すが,トップディレクトリはmypackage。
    • サンプル実行コードてテストコード,ドキュメントはMyPackage以下に配置する。これらはpackageのインストール自体では入らない。
MyPackage
    - mypakcage  # ソースコードをインストールするディレクトリ
        - hoge.py
        - foo
            - bar.py
        - data
            - data1.dat
            - data2.dat
    - sample
        - sample.py
    - doc
    - test

各ファイルの注意

パッケージ内のimport文

  • 基本的に相対インポートは書かない。絶対インポートを書く。(相対インポートよりもわかりやすいと思う。ディレクトリ構成がコロコロ変わると厄介だけど)
  • 例えば,bar.pyをhoge.pyがインポートする場合は下記のように書く。
# hoge.py
from mypackage.foo import bar  # Good
from foo import bar # <= Bad

sample/test内のimport文

  • sample/sample.pyは兄弟ディレクトリのmypakcage以下のファイルをそのままではインポートできない。よって,pathに追加する。
  • その際にはfileがそのファイルのパスを表すこと活用して,親ディレクトリをパスに追加する。os.path.dirnameを2回適用することで,親ディレクトリの絶対パスを求めている。".."を使うと実行ディレクトリに依存するのでその記述は避ける(Pythonは実行ディレクトリをカレントディレクトリとする)
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

静的データのパス

  • マップデータやイメージなどの静的データをロードする場合も同様に,fileがそのファイルのパスを表すことを利用して,パッケージが配置される絶対パスからパスを指定する。
# hoge.pyからdata/data1.datをロードする場合
import os
dir_path = os.path.dirname(os.path.abspath(__file__))
data = dir_path + '/data/data1.dat'

VSCodeのMarkdown Preview Enhanced の色設定

  • コマンドパレットにて(Alt-x), Markdown Preview Enhanced: Customize CSSにて設定ファイルを開く。
  • 設定ファイルに下記を追記する。
.markdown-preview.markdown-preview {
  // modify your style here
  // eg: background-color: blue;  
  background-color: #222;
  color: white;
  h1, h2, h3, h4, h5, h6 {
    color: wheat;
  }
}

gifアニメーション(convert)

やりたいこと

評価結果などをアニメーション表示したい時がある。リアルタイムなグラフのプロットならGnuplotでも良いけれど,ゲームプレイ画像などを表示したい場合には画像からアニメーションを作れると良い。

方法

  1. "連番"の画像ファイルを用意する。注意は0埋めしてファイルの順序通りに読み込まれるようにする。つまり,1〜99までの画像がある場合,01〜99にする。pythonだとformat({:03})みたいな記述するだけ。
  2. convert(ImageMagic)でgif化する。注意はLayer optimizeしないとフィアルサイズが大きくなること。loop=0指定で無限ループするgifになる。delayはmilli secオーダのアニメーション時間間隔。

サンプル

  1. 連番画像ファイルを生成する。ここでは少しずつ移動する(位相が進む)sin波のアニメーション用に,./fig以下にfig_0001.png - fig_0100.pngを生成している。 Matplotlibでアニメーションを検索すると,Matplotlibの中だけでgif生成までやる例があるけれど,画像サイズの調整やリサイズなども後から出来るので,このように一度連番画像を生成するのが良い気がする。 注意点として,20枚以上を一度に描画しようとするとエラーメッセージが出されるため,plt.close()にて毎回閉じる。
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

fig = plt.figure()
x = np.arange(0, 10, 0.1)

fig_dir = './fig/'

for i in range(1,101):
    plt.clf()
    y = np.sin(x - i/10.0)
    plt.plot(x, y, "r")
    plt.savefig(fig_dir+'fig_{:04}'.format(i)+'.png')
    plt.close()
  1. convertする。
$ cd ./fig
$ convert -layers optimize -loop 0 -delay 10 fig_*.png animation.gif

f:id:nobUnaga:20180607081215g:plain

機械学習の前処理でのデータの正規化/標準化

前処理の目的

  • 体重と身長など複数の異なる特徴量を生で扱ってしまうと、出力に対して使いやすい(大まかにFitしやすい)方を優先してしまう問題がある。

内容

  • 問題解決の単純な方法は異なるカテゴリのデータを同じ範囲のデータに変換する。
  • 変換は入力だけでなく、出力も変換する。
  • また、一般的には各特徴量毎に変換する。ただし、画像のように2次元(行/列)ではあるが、意味が全ての次元で等しい場合には、全体で変換することも考えられる。

具体的な変換方法

  • 一般的には正規化と標準化(ZScore化とも言われる)が良く使われる。
  • 正規化は、変換後のデータの最小値, 最大値を0, 1変換する。背景にデータが一様分布に従う場合を想定している。
  • 標準化は、平均0, 標準偏差1に変換する。背景にデータが正規化分布に従うことを想定している。

実践

  • 2次元データ(n_batch, n_feature)のデータであればSklearnのpreprocessingモジュールで簡単に変換できる。
  • 3次元データ、例えば(n_batch, n_object_type, n_object_feature)のような場合にはSklearnが使えない。その場合は下記のような関数を使う。
import sys
import numpy as np
import logging

logger = logging.getLogger(__name__)


class Scaler():
    '''
    Sklearn-API like simple scaler for data preprocessing.
    '''
    def __init__(self, mode):
        if not(mode in ['maxmin', 'standard']):
            logger.error("The mode must be 'maxmin' or 'standard'.")
            sys.exit(1)
        self.mode = mode
        self.maxs = None
        self.mins = None
        self.means = None
        self.stds = None

    def fit(self, data, axis=0):
        '''
        Args:
          data: Ndim numpy array .
          axis: scaling axis, usually batch dimension.
        '''
        if self.mode == 'maxmin':
            self.maxs = data.max(axis=axis)
            self.mins = data.min(axis=axis)
        elif self.mode == 'standard':
            self.means = data.mean(axis=axis)
            self.stds = data.std(axis=axis)
        return

    def transform(self, data):
        if self.mode == 'maxmin':
            d = (data - self.mins)/(self.maxs - self.mins)
        elif self.mode == 'standard':
            d = (data - self.means)/(self.stds)
        return d

    def fit_transform(self, data, axis=0):
        self.fit(data, axis)
        d = self.transform(data)
        return d

    def inverse_transform(self, data):
        if self.mode == 'maxmin':
            d = data*(self.maxs - self.mins) + self.mins
        elif self.mode == 'standard':
            d = (data - self.means)/(self.stds)
            d = data*self.stds + self.means
        return d        

    
if __name__ == '__main__':
    d0 = np.random.randn(100,4,2)

    s = Scaler('maxmin')
    s.fit(d0)
    d1 = s.transform(d0)
    d2 = s.inverse_transform(d1)

    s = Scaler('standard')
    s.fit(d0)
    d1 = s.transform(d0)
    d2 = s.inverse_transform(d1)

ArgparserとConfigparser

Deep Learning関連のプログラムを試していると、やたらと設定パラメタが多い。 これまではargparseを使ってきたけど、コードが煩雑になるのでconfigファイルの扱い方を調べてみた。

argparse

  • まずはargparseの基本的な使い方。
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('--hoge', action='store', type=int, default=0)
args = parser.parse_args()

print('hoge:', args.hoge)  # python3 xxx.py --map 2 #=> 'hoge: 2'

ConfigParser

  • 次のようなconfig.iniファイルがあるとする。
  • 基本的にはセクションとパラメタ名を書いていくだけ。
  • 注意点として,値がブランクのものは,hoge=Noneではなく,'='を書かずにhogeだけ。
# config.ini
[model] ; Section名
type = MLP
input_size = 81
output_size = 81
hidden_sizes = 64,32

[learning]
data_file = /home/hoge/project/data/xxx.csv
train_ratio = 0.8
n_epoch = 30
lr = 0.0001
hoge # Noneの場合は'='を書かない
  • 使う側の例
import configparser

if __name__ == '__main__':
    config = configparser.ConfigParser()
    config.read('./config.ini')
    # Parameters
    train_ratio = config.getfloat('learning', 'train_ratio')
    lr = config.getfloat('learning', 'lr')
    n_epoch = config.getint('learning', 'n_epoch')
    file_path = config.get('learning', 'data_file')
    
    # Make model and set optimizer
    if config.get('model','type') == 'MLP':
        input_size = config.getint('model', 'input_size')
        output_size = config.getint('model', 'output_size')
        hidden_sizes = list(map(int, config.get('model', 'hidden_sizes').split(',')))
        model = models.MLP(input_size, output_size, hidden_sizes)
...