はじめに
今回は、ボタンや入力欄で構成された画像解析のアプリのようなものを作成し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(...):
...
-
layoutstr ... "horizontal" (ウィジェットを横に並べる) または "vertical" (ウィジェットを縦に並べる)。 -
labelsbool ... 引数名をLabelとして表示するか否か。 -
tooltipsbool ... マウスを当てた時にtooltipを表示するか否か。tooltipsはdocstring (関数直下に書く説明文) から読み込まれる。例えば以下の通り。
tooltipsの例
-
call_buttonbool, str, None ... デフォルトでは、次のauto_call=Falseであれば、関数を呼び出すためのボタンが用意される。strを与えた場合、call buttonにその文字が書かれる。 -
auto_callbool ...Trueの場合、値の変更が起きるたびにcall buttonを押さなくても自動で関数が呼び出される。数十ミリ秒以内で終わる計算であればスムーズに動く(1024×1024の画像のガウシアンフィルタくらいなら余裕)。 -
result_widgetbool ...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ならではの強みですね。
次回はマウスカーソルの移動やクリックをカスタマイズする方法についてまとめようと思います。他にリクエストなどありましたらコメントをください。






