はじめに
今回は、ボタンや入力欄で構成された画像解析のアプリのようなものを作成しnapariと連携する方法についてまとめます。
なお、本記事はシリーズになっています。
前の記事
napari
はQtという、GUI作成用ツールをベースに作られています。Qtは歴史が古く、用意されているウィジェットも非常に豊富です。洗練されたウィジェットを作成する場合はQtを直接扱う必要がありますが、napari
のプロジェクトにはmagicgui
(公式ドキュメント)という、QtをPythonに特化した形に拡張した便利なライブラリもあり、なんと関数を@magicgui
でデコレートするだけでGUIやウィジェットを簡単に作れてしまうのです。
この記事では
について解説します。
準備
import napari
from magicgui import magicgui
from magicgui.widgets import FunctionGui
viewer = napari.Viewer()
代表的なウィジェット一覧
画像解析でよく使うウィジェットを挙げていきます。参考までに、ウィジェットをnapariに表示させた際の画像を下に載せています。ここで、Pythonの型との対応に注意してください。関数からウィジェットを作成する際に非常に重要になります。
-
Label
... 編集できない文字。 -
LineEdit
...str
を編集する。 -
FileEdit
... ファイルダイアログからファイルを選択してパスをpathlib
のPath
オブジェクト(パスに特化したstrのようなもの)に格納。 -
SpinBox
... ボタンでint
を調節する。 -
FloatSpinBox
... ボタンでfloat
を調節する。 -
CheckBox
... チェックを入れる/外すことでTrue
/False
を変更する。 -
ComboBox
... 選択肢の中から一つ選択し、適当な型の変数に格納する。 -
PushButton
... クリックできるボタンで、クリック時に関数が呼び出される。
なお、たとえば同じint
型でも、スライダーでなめらかに動かしたいときがあります。ウィジェットのバリエーションはいくつかあるので、こだわりたいときは公式ドキュメントのこちらを確認してください。
magicgui
で関数のウィジェット化し、napariに送り込む
magicgui
による、関数のウィジェット化
続いて、@magicgui
デコレータで関数をウィジェット化する方法を解説します。使い方自体は簡単で、例えば足し算をするだけの関数を
@magicgui
def add(a=1, b=1):
print(a+b)
return a + b
のようにデコレートすると、
print(type(add))
<class 'magicgui.widgets._function_gui.FunctionGui'>
のように、FunctionGui
オブジェクトが作られます。FunctionGui
オブジェクトはnapari
がウィジェットとして認知できる型である一方で、__call__
メソッドが定義されているので、add(3,5)
のように普通の関数としても使えます。さらに、元となった関数の引数a
が
のように、引数の型に対応する子ウィジェットとして保有している状態になっています。
したがって、FunctionGui
は次のように動作していると考えるとよいです。
- "run"ボタンが押される。
- 引数を自身に紐づいたウィジェットから読み込む。上の例では
add.a
とadd.b
の値が参照される。 - 読み込んだ引数を用いて、自身を関数として呼び出す。
napari
にウィジェットを送り込む
window
のadd_dock_widget
メソッドにFunctionGui
オブジェクトを渡すだけです。
viewer.window.add_dock_widget(add)
これにより「ドックウィジェット」という、メインウィンドウにドッキングできるウィジェットが作成され、次のGIFのようになります。
キーワード引数で次を指定できます(deprecateされたものは除いています)。
viewer.window.add_dock_widget(add, name="My Function", area="right", allowed_areas=["right", "bottom"])
-
name
... ドックウィジェットの名前。 -
area
... ドッキングされる初期位置。"left", "right", "bottom", "top"のいずれか。 -
allowed_areas
... ドッキングが起きる位置のリスト。むやみにドッキングされると厄介な場合に、個別で指定する。
@magicgui
のオプションを使う
デコレータに引数を渡すと、より高度なウィジェットが作れます。magicgui
単体で使う場合にしか指定しないmainwindow=...
などの引数は割愛します。
@magicgui(layout='vertical', labels=True, tooltips=True, call_button=None,
auto_call=False, result_widget=False, **param_options)
def func(...):
...
-
layout
str ... "horizontal" (ウィジェットを横に並べる) または "vertical" (ウィジェットを縦に並べる)。 -
labels
bool ... 引数名をLabelとして表示するか否か。 -
tooltips
bool ... マウスを当てた時にtooltipを表示するか否か。tooltipsはdocstring (関数直下に書く説明文) から読み込まれる。例えば以下の通り。
tooltipsの例
-
call_button
bool, str, None ... デフォルトでは、次のauto_call=False
であれば、関数を呼び出すためのボタンが用意される。str
を与えた場合、call buttonにその文字が書かれる。 -
auto_call
bool ...True
の場合、値の変更が起きるたびにcall buttonを押さなくても自動で関数が呼び出される。数十ミリ秒以内で終わる計算であればスムーズに動く(1024×1024の画像のガウシアンフィルタくらいなら余裕)。 -
result_widget
bool ...True
の場合、返り値を表示するための読み取り専用のウィジェットがcall buttonの下に追加される。 -
param_options
... デコレートされた関数の引数にa
があれば、a={...}
のようにその引数のデザインをdict
で指定できる。少し複雑になるので、詳細はこちらなどを参照。例として、上のadd
関数の引数a
に範囲指定し、b
をスライダーに変更したものを載せる。
param_optionsの例
なぜ関数だけでウィジェット化できるのか?
そもそも、なぜ単純な関数だけからFunctionGui
のような高度なオブジェクトか作れるのでしょうか。この仕組みを理解することは、エラーなくウィジェットを作る手助けになるので、説明しておきます。
Pythonで関数を定義したとき、その関数には様々な情報が紐づけられています。標準のinspect
モジュールを使うことでその情報を参照できます。例えば
def func(a: int = 0, b: str = "x") -> str:
return f"{b}={a}"
という関数があれば、
import inspect
sig = inspect.signature(func)
sig.parameters # mappingproxy({'a': <Parameter "a: int = 0">, 'b': <Parameter "b: str = 'x'">})
sig.return_annotation # str
のように、関数func
から引数の型注釈、デフォルト値、返り値の型注釈などの情報が得られます。magicgui
では、create_widget
という関数がこのsignatureを読み込み、適切なウィジェットを自動で作成してくれます。逆に言えば、def func(a): ...
のように引数に型注釈もデフォルト値も与えられていなければ、型推定に必要な情報がsignatureに乗らないので、エラーや不完全なウィジェットが作られる原因になります。
※@magicgui
のparam_optionsで"widget_type": "FloatSlider"
のように、ウィジェットのクラス名を指定しておけば型推定が必要なくなるので、問題なく動作するようになります。
型とウィジェットの対応
関数にどの型を与えるとどのウィジェットが作られるのか、create_widget
の対応付けのルールを見ていきます。
-
str
→LineEdit
-
int
→SpinBox
-
float
→FloatSpinBox
-
bool
→CheckBox
-
関数を呼び出す →
PushButton
は直観的に分かると思います。問題はComboBox
とFileEdit
をどう使うかになります。
ComboBox
Pythonの列挙型Enum
クラスを使います。なじみがない方は次の記事などが参考になるかと思います。
決まった選択肢をあらかじめ用意しておけばよいので、例えば
from enum import Enum, auto
class Filters(Enum):
GaussianFilter = auto()
MedianFilter = auto()
というフィルタセットを定義し、Filters
を型注釈で
@magicgui
def run_filter(method:Filters): # もしくはデフォルトでmethod=Filters.GaussianFilterでもよい
...
viewer.window.add_dock_widget(run_filter, name="Filter")
とすればComboBox
として認識されます。
もしくは@magicgui
の引数で明示的に与えるという方法もあります(こちらの方が楽といえば楽ですが、文字列が多いと見づらいし予測変換が利かないので、個人的には避けています)。
@magicgui(method={"choices":["GaussianFilter", "MedianFilter"]})
def run_filter(method):
...
FileEdit
pathlib
のPath
を使います。普通str
で済ませるファイルパスですが、Path
を使えばそれがパスだと一目でわかるうえ、os.path
よりも楽にパスが扱えます。次の記事などが参考になると思います。
@magicgui
もPath
オブジェクトが渡されれば自然とそれがただの文字列ではなくファイルパスだと認識でき、FileEdit
が用意されます。
from pathlib import Path
@magicgui
def open_something(path:Path):
...
ちなみに過去に開いたパスはnapari
内で履歴として残してくれるので、2回目以降楽です。
ウィジェットとnapari
のレイヤーの間でやりとりをする
画像データの入出力
画像解析では当然画像を引数や返り値にします。しかし、上で見てきた方法では、せいぜいFileEdit
で画像を読み込める程度のことしかできません。できればnapari
のレイヤーを読み込み、レイヤーを出力したいです。
magicgui
はnapari
のプロジェクトで開発が進んでいるので、napari
の型との相性が非常に良いです。例えば
from napari.types import ImageData
により、Imageレイヤーのデータを表す型ImageData
(np.ndarray
と同等)をインポートしておくと、型注釈で
@magicgui
def add(img0: ImageData, img1: ImageData) -> ImageData:
return img0 + img1
とするだけで、Imageレイヤーを選択するComboBox
が2つ作成され、返り値はレイヤーリストに追加されます。
レイヤーの入出力
レイヤーにはmetadata
など、有用な情報が乗っています。また、画像認識の関数を走らせるときなど、返り値をPointsレイヤーやShapesなどで追加したい場合もあります。@magicgui
は次のルールでレイヤーを取り扱います。
- 引数の型がレイヤー型の場合、対応するレイヤーを列挙した
ComboBox
を追加する - 返り値の型が
LayerDataTuple
の場合、(data, kwargs, type_)
の順番で認識される。type_=="image"
であればviewer.add_image(data, **kwargs)
が呼び出され、他の"shapes", "points"などがtype_
で与えられれば対応する適切な関数が呼び出される。
例として、scikit-image
のコーナー検出を実装してみましょう。
公式ドキュメントでは、matplotlib
で結果を表示していますが、napari
内でauto call付きで動かすことでパラメータの調整結果がリアルタイムで反映されるので、非常にスムーズに解析できます。
from napari.types import LayerDataTuple
from skimage.feature import corner_harris, corner_peaks
@magicgui(k={"step":0.05}, sigma={"step":0.5}, auto_call=True)
def corner_det(img: napari.layers.Image, k=0.05, sigma=1.0) -> LayerDataTuple:
# corner response
res = corner_harris(img.data, k=k, sigma=sigma)
# get corner coordinates
coords = corner_peaks(res, min_distance=5, threshold_rel=0.02)
# keyword arguments
kwargs = dict(edge_color="lime", face_color=[0,0,0,0], name="Corners")
return (coords, kwargs, "points")
viewer.window.add_dock_widget(corner_det, name="Corner Detector")
最後に
数行のコードでGUIが作れてしまうのはPythonならではの強みですね。
次回はマウスカーソルの移動やクリックをカスタマイズする方法についてまとめようと思います。他にリクエストなどありましたらコメントをください。