【ImageJからPythonへ】napariの使い方 (3)

# はじめに

今回は、ボタンや入力欄で構成された画像解析のアプリのようなものを作成しnapariと連携する方法についてまとめます。

なお、本記事はシリーズになっています。

##### 前の記事

- [第1回](https://qiita.com/Hanjin_Liu/items/fcdd9dbbc9fa4fd7df18) ... napariの基本操作やレイヤーの扱い方について

- [第2回](https://qiita.com/Hanjin_Liu/items/8a6605cd136e7bf7f352) ... スクリプトからUIの各構成要素を操作する方法とカスタムキーバインドについて


`napari`はQtという、GUI作成用ツールをベースに作られています。Qtは歴史が古く、用意されているウィジェットも非常に豊富です。洗練されたウィジェットを作成する場合はQtを直接扱う必要がありますが、`napari`のプロジェクトには`magicgui`（[公式ドキュメント](https://napari.org/magicgui/index.html)）という、QtをPythonに特化した形に拡張した便利なライブラリもあり、なんと**関数を**`@magicgui`**でデコレートするだけでGUIやウィジェットを簡単に作れてしまう**のです。

この記事では

- [代表的なウィジェット一覧](#代表的なウィジェット一覧)
- [magicguiで関数のウィジェット化し、napariに送り込む](#magicguiで関数のウィジェット化しnapariに送り込む)
    - [magicguiによる、関数のウィジェット化](#magicguiによる関数のウィジェット化)
    - [napariにウィジェットを送り込む](#napariにウィジェットを送り込む)
    - [magicguiのオプションを使う](#magicguiのオプションを使う)
- [ウィジェットとnapariのレイヤーの間でやりとりをする](#ウィジェットとnapariのレイヤーの間でやりとりをする)
    - [画像データの入出力](#画像データの入出力)
    - [レイヤーの入出力](#レイヤーの入出力)

について解説します。

# 準備

```python
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` ... クリックできるボタンで、クリック時に関数が呼び出される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/5d57b867-7db5-098c-7145-12f470d65dfa.png)

なお、たとえば同じ`int`型でも、スライダーでなめらかに動かしたいときがあります。ウィジェットのバリエーションはいくつかあるので、こだわりたいときは公式ドキュメントの[こちら](https://napari.org/magicgui/api/widgets.html)を確認してください。

# `magicgui`で関数のウィジェット化し、napariに送り込む

### `magicgui`による、関数のウィジェット化

続いて、`@magicgui`デコレータで関数をウィジェット化する方法を解説します。使い方自体は簡単で、例えば足し算をするだけの関数を

```python
@magicgui
def add(a=1, b=1):
    print(a+b)
    return a + b
```

のようにデコレートすると、

```python
print(type(add))
```
```
<class 'magicgui.widgets._function_gui.FunctionGui'>
```

のように、`FunctionGui`オブジェクトが作られます。`FunctionGui`オブジェクトは`napari`がウィジェットとして認知できる型である一方で、`__call__`メソッドが定義されているので、`add(3,5)`のように普通の関数としても使えます。さらに、元となった関数の引数`a`が

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/1e0db40c-f39a-2c2c-60eb-d0a8f88766f9.png)

のように、引数の型に対応する子ウィジェットとして保有している状態になっています。

したがって、`FunctionGui`は次のように動作していると考えるとよいです。

1. "run"ボタンが押される。
2. 引数を自身に紐づいたウィジェットから読み込む。上の例では`add.a`と`add.b`の値が参照される。
3. 読み込んだ引数を用いて、自身を関数として呼び出す。

### `napari`にウィジェットを送り込む

`window`の`add_dock_widget`メソッドに`FunctionGui`オブジェクトを渡すだけです。

```python
viewer.window.add_dock_widget(add)
```

これにより「ドックウィジェット」という、メインウィンドウにドッキングできるウィジェットが作成され、次のGIFのようになります。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/22683d76-0ed6-5ce5-8989-27b3aa3f4394.gif)

キーワード引数で次を指定できます（deprecateされたものは除いています）。

```python
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=...`などの引数は割愛します。

```python
@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 (関数直下に書く説明文) から読み込まれる。例えば以下の通り。


<details><summary>tooltipsの例</summary><div>

```python
@magicgui
def add(a=1, b=1):
    """
    Calculate a+b.

    Parameters
    ----------
    a : int
        First parameter.
    b : int
        Second parameter.
    """
    print(a+b)
    return a + b
viewer.window.add_dock_widget(add, name="Add integers")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/3f70fdcf-3ca2-550c-dcb1-ed878c873d3a.png)

</div></details>

- `call_button` *bool*, *str*, *None* ... デフォルトでは、次の`auto_call=False`であれば、関数を呼び出すためのボタンが用意される。`str`を与えた場合、call buttonにその文字が書かれる。
- `auto_call` *bool* ... `True`の場合、値の変更が起きるたびにcall buttonを押さなくても自動で関数が呼び出される。数十ミリ秒以内で終わる計算であればスムーズに動く（1024&times;1024の画像のガウシアンフィルタくらいなら余裕）。
- `result_widget` *bool* ... `True`の場合、返り値を表示するための読み取り専用のウィジェットがcall buttonの下に追加される。
- `param_options` ... デコレートされた関数の引数に`a`があれば、`a={...}`のようにその引数のデザインを`dict`で指定できる。少し複雑になるので、詳細は[こちら](https://napari.org/magicgui/examples/widget_demo.html)などを参照。例として、上の`add`関数の引数`a`に範囲指定し、`b`をスライダーに変更したものを載せる。

<details><summary>param_optionsの例</summary><div>

```python
@magicgui(a={"label": "First", "min": 0, "max":10},
          b={"widget_type": "FloatSlider", "label": "Second", 
             "min": 0, "max":10, "step": 0.2}
          )
def add(a=1, b=1):
    print(a+b)
    return a + b
viewer.window.add_dock_widget(add, name="Add integers")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/a27cadd0-1c3e-aee1-7808-d304172e766a.png)

</div></details>


### なぜ関数だけでウィジェット化できるのか？

そもそも、なぜ単純な関数だけから`FunctionGui`のような高度なオブジェクトか作れるのでしょうか。この仕組みを理解することは、エラーなくウィジェットを作る手助けになるので、説明しておきます。

Pythonで関数を定義したとき、その関数には様々な情報が紐づけられています。標準の`inspect`モジュールを使うことでその情報を参照できます。例えば

```python
def func(a: int = 0, b: str = "x") -> str:
    return f"{b}={a}"
```

という関数があれば、

```python
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` &rarr; `LineEdit`

- `int` &rarr; `SpinBox`

- `float` &rarr; `FloatSpinBox`

- `bool` &rarr; `CheckBox`

- 関数を呼び出す &rarr; `PushButton`

は直観的に分かると思います。問題は`ComboBox`と`FileEdit`をどう使うかになります。

1. `ComboBox`

Pythonの列挙型`Enum`クラスを使います。なじみがない方は次の記事などが参考になるかと思います。

https://qiita.com/macinjoke/items/13aa9ba64cf9b688e74a

決まった選択肢をあらかじめ用意しておけばよいので、例えば

```python
from enum import Enum, auto
class Filters(Enum):
    GaussianFilter = auto()
    MedianFilter = auto()
```

というフィルタセットを定義し、`Filters`を型注釈で

```python
@magicgui
def run_filter(method:Filters): # もしくはデフォルトでmethod=Filters.GaussianFilterでもよい
    ...
viewer.window.add_dock_widget(run_filter, name="Filter")
```

とすれば`ComboBox`として認識されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/5dd93eaf-a464-8e85-56b3-9962008401d9.png)

もしくは`@magicgui`の引数で明示的に与えるという方法もあります（こちらの方が楽といえば楽ですが、文字列が多いと見づらいし予測変換が利かないので、個人的には避けています）。

```python
@magicgui(method={"choices":["GaussianFilter", "MedianFilter"]})
def run_filter(method):
    ...
```

2. `FileEdit`

`pathlib`の`Path`を使います。普通`str`で済ませるファイルパスですが、`Path`を使えばそれがパスだと一目でわかるうえ、`os.path`よりも楽にパスが扱えます。次の記事などが参考になると思います。

https://note.nkmk.me/python-pathlib-usage/

`@magicgui`も`Path`オブジェクトが渡されれば自然とそれがただの文字列ではなくファイルパスだと認識でき、`FileEdit`が用意されます。

```python
from pathlib import Path
@magicgui
def open_something(path:Path):
    ...
```

ちなみに過去に開いたパスは`napari`内で履歴として残してくれるので、2回目以降楽です。

# ウィジェットと`napari`のレイヤーの間でやりとりをする

### 画像データの入出力

画像解析では当然画像を引数や返り値にします。しかし、上で見てきた方法では、せいぜい`FileEdit`で画像を読み込める程度のことしかできません。できれば`napari`のレイヤーを読み込み、レイヤーを出力したいです。

`magicgui`は`napari`のプロジェクトで開発が進んでいるので、`napari`の型との相性が非常に良いです。例えば

```python
from napari.types import ImageData
```

により、Imageレイヤーのデータを表す型`ImageData` (`np.ndarray`と同等)をインポートしておくと、型注釈で

```python
@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`のコーナー検出を実装してみましょう。

https://scikit-image.org/docs/dev/auto_examples/features_detection/plot_corner.html#sphx-glr-auto-examples-features-detection-plot-corner-py

公式ドキュメントでは、`matplotlib`で結果を表示していますが、`napari`内でauto call付きで動かすことでパラメータの調整結果がリアルタイムで反映されるので、非常にスムーズに解析できます。

```python
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")
```

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/904ef413-ca59-64d2-3143-0a21c294812e.gif)

# 最後に

数行のコードでGUIが作れてしまうのはPythonならではの強みですね。

次回はマウスカーソルの移動やクリックをカスタマイズする方法についてまとめようと思います。他にリクエストなどありましたらコメントをください。