Bokehが素晴らしすぎてわずか130行で対話的可視化ツールを作ってしまった話(続き：ホバー時に画像を表示する)

#はじめに
[前回](https://qiita.com/kimisyo/items/8aac9c5a08d883d94bbb)、 かねてから注目していたBokehを使ってそれなりの対話的可視化ツールを作ることができた。今回はその続きとして、グラフの点にマウスをフォーカスすると、画像付きの吹き出しを表示する機能を追加したので、その一部始終を記載する。

# 機能追加内容
[前回](https://qiita.com/kimisyo/items/8aac9c5a08d883d94bbb)作成した対話的可視化ツールに対し、グラフの点にフォーカスをあてると、その点に対応する画像を表示する機能を追加する。今回は、化合物データを対象とし、ホバー時に化合物の画像を表示する例で説明する。

# できあがったもの
起動画面は前回から変更はないため省略する。

これが結果画面におけるホバー時のイメージ。グラフの点にフォーカスをあてると、化合物の名前と、その化合物の構造式の画像がポップアップ表示される。それと同時に点の選択もしているので、右側のデータテーブルの当該化合物が選択された状態になっている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/329816/c5277d16-d7c7-3a4b-4001-87861dc21592.png)


以下からソースコード等を説明する。

# 実行環境

- Windows10
- Anaconda
- Python3.7
- Bokeh 1.2
- scikit-learn 0.21.2
- umap-learn 0.3.9


# 事前準備
今回、画像の吹き出し表示のため、Bokehのツールチップ用のカスタムHTMLテンプレートを使う。詳しくは [Bokeh - Configuring Plot Tools](https://bokeh.pydata.org/en/latest/docs/user_guide/tools.html)を参照してほしい。HTMLテンプレートにより生成されるHTMLの中に、画像のURLが表示されるよう仕込んでおくことで、画像表示が可能となる。

では、その画像はどこにおいておけばよいのだろうか。またURLはどのように指定すればよいのだろうか。

実は、Bokehではpythonファイルの代わりに、ディレクトリを指定して実行するモードもあり、その場合ディレクトリの中に画像等を置いてBokeh Serverから参照することもできるのだ。

[Bokeh - Running a Bokeh Server](https://bokeh.pydata.org/en/latest/docs/user_guide/server.html)を見てもらえばわかるが、例えば最小構成では、以下のフォルダをbokehは認識する。main.pyは、グラフ描画用のコードを書いたpythonファイルであり、bokehは自動的にそれを実行する。

```
myapp
   |
   +---main.py
```

最大では次のようなフォルダ構成を認識することができる。

```
myapp
   |
   +---main.py
   +---server_lifecycle.py
   +---static
   +---theme.yaml
   +---templates
        +---index.html
```

全てのファイルやフォルダの意味を説明することはできないが、少なくとも画像については、staticの下においておくことで、
```/myapp/static/images/1.gif```のように相対パス指定でBokeh Serverはその画像を認識してくれるのだ。（static以外のフォルダにおいても認識されない）


したがって、今回は以下のフォルダ構成とし、あらかじめstaticの下のimagesフォルダの下に画像を置くようにした。画像ファイルの数字は、その画像が何番目のデータに対応しているかを表している。

```今回のフォルダ構成
ViewBokHover
   |
   +---main.py
   +---static
  　　　 |
　　　   +---images
       　　　 |
　　　        +---1.png
　　　        +---2.png
　　　        +---3.png
　　　        +---4.png
　　　        +---5.png
             ・・・・
```

これで画像の準備は整ったので、次にグラフ描画のプログラムであるmain.pyの説明をしよう。

# ソースコード

これが130行のコード。機能追加しても、一部無駄な部分を削ったため行数は130行まま。今回変更した部分の説明を後で説明する。

```main.py
import pandas as pd
from sklearn.manifold import TSNE, MDS
from sklearn.decomposition import PCA
import umap
import bokeh
from bokeh.events import ButtonClick
from bokeh.models import CustomJS, HoverTool, ColumnDataSource
from bokeh.models.widgets import Button, Select, RadioGroup, TextInput, TableColumn, DataTable
from bokeh.layouts import Column, Row
from bokeh.io import curdoc
from bokeh.plotting import figure
from bokeh.transform import linear_cmap


def main():

    # color selectが変更された時の処理
    def color_select_callback(attr, old, new):

        # color_selectで選択された項目の値に対応したmapperを用意
        mapper = linear_cmap(field_name=new, palette=bokeh.palettes.Viridis256,
                             low=min(df[new].values), high=max(df[new].values))

        # 新たなmapperでグラフを再描画
        p.circle(x="0", y="1", source=source, line_color=mapper, color=mapper)

    def execute_button_callback(event):

        # テキストエリアに指定された入力ファイル読み込み
        global df
        df = pd.read_csv(csv_input.value, index_col=0)
        df_target = df.iloc[:, 0]
        df_data = df.iloc[:, 1:]

        # 2列目以降のデータで可視化のための解析開始
        if method_radio_group.active == 0:
            pca = PCA(n_components=2)
            result = pca.fit_transform(df_data)
        elif method_radio_group.active == 1:
            tsne_model = TSNE(n_components=2)
            result = tsne_model.fit_transform(df_data)
        elif method_radio_group.active == 2:
            mds = MDS(n_jobs=4)
            result = mds.fit_transform(df_data)
        elif method_radio_group.active == 3:
            result = umap.UMAP().fit_transform(df_data)

        # Dictionaryに解析結果を格納
        dict = {"0": result[:, 0], "1": result[:, 1]}

        # データフレームのID(index)をDictionaryに格納
        dict["ID"] = df.index.values

        # データテーブルに登録する列情報の設定
        columns = list()
        columns.append(TableColumn(field="ID", title="ID", width=100))

        for column in df.columns:
            dict[column] = df[column].values
            columns.append(TableColumn(field=column, title=column, width=100))

        # 画像のURLをDictionaryに追加
        dict["img"] = ["/ViewBokHover/static/images/{0}.png".format(str(i+1)) for i in range(df.shape[0])]

        # Dictionaryをソースと紐づける
        source.data = dict

        # データテーブルの列を設定する
        data_table.columns = columns
        target_column = df.columns.values[0]

        # color_selectで選択された項目の値に対応したmapperを用意
        mapper = linear_cmap(field_name=target_column, palette=bokeh.palettes.Viridis256,
                             low=min(df[target_column].values), high=max(df[target_column].values))

        # 新たなmapperでグラフを再描画
        p.circle(x="0", y="1", source=source, line_color=mapper, color=mapper)

        color_select.options = list(df.columns)

        # リセット　(https://github.com/bokeh/bokeh/issues/5071　を参考に入れてみたが現状動作しない)
        CustomJS(args=dict(p=p), code="""
            p.reset.emit()
        """)

    # データソースの初期設定
    source = ColumnDataSource(data=dict(length=[], width=[]))
    source.data = {"0": [], "1": []}

    # 設定テキストボックス
    csv_input = TextInput(value="default.csv", title="Input CSV")

    # 可視化手法選択ラジオボタン
    method_radio_group = RadioGroup(
        labels=["PCA", "tSNE", "MDS", "UMAP"], active=3)

    # 解析実行ボタン
    execute_button = Button(label="Execute", button_type="success")
    execute_button.on_event(ButtonClick, execute_button_callback)

    # ツールチップの設定
    TOOLTIPS = """
        <div>
            <table border="0">
              <tr><td>@ID</td></tr>
              <tr><td style="padding:5px;"><img src="@img", width="100", height="100"/></td></tr>
            </table>
        </div>
    """

    # グラフ初期設定
    p = figure(tools="pan,box_zoom,lasso_select,box_select,poly_select,tap,wheel_zoom,reset,save,zoom_in",
               title="Analyze Result", plot_width=1000, plot_height=800, tooltips=TOOLTIPS)
    p.circle(x="0", y="1", source=source)

    # 色設定項目用選択セレクトボックス
    color_select = Select(title="color:", value="0", options=[])
    color_select.on_change("value", color_select_callback)

    # データ表示データテーブル
    data_table = DataTable(source=source, columns=[], width=600, height=500, fit_columns=False)

    operation_area = Column(csv_input, method_radio_group, execute_button, color_select, data_table)

    layout = Row(p, operation_area)
    curdoc().add_root(layout)


main()

```

# ソース解説
変更した点は2点。

### 変更点1. ツールチップ用カスタムHTMLテンプレートを用意し、グラフオブジェクトの引数に指定

まず、ホバー時に表示されるツールチップについて、以下の通りHTMLテンプレートで指定し、グラフオブジェクトの引数に指定する。

テキストを表示したい箇所には、```@<変数名>```で、データソースと紐づけたDictionaryに定義されている変数名を指定すると、その変数名が指定箇所に表示される。下例では、"ID"という変数名（化合物の名前が格納されている変数）を指定している。

画像を表示したい箇所に```@<画像のURLを格納した変数名>```で、画像のURLを格納した変数名を指定する。下例では、"img"という変数名を指定している。

このimgという名前は、データソースと紐づけたDictionaryに紐づける必要があるが、その方法は次の変更点2で説明する。


```python
    # ツールチップの設定
    TOOLTIPS = """
        <div>
            <table border="0">
              <tr><td>@ID</td></tr>
              <tr><td style="padding:5px;"><img src="@img", width="100", height="100"/></td></tr>
            </table>
        </div>
    """

    # グラフ初期設定
    p = figure(tools="pan,box_zoom,lasso_select,box_select,poly_select,tap,wheel_zoom,reset,save,zoom_in",
               title="Analyze Result", plot_width=1000, plot_height=800, tooltips=TOOLTIPS)
```

### 変更点2. 画像のURLを格納した変数をDictionaryに追加

以下のように、全データ分の画像のurlのリストを作成し、dictionaryの"img"というキーに対して設定する。

```python
        # 画像のURLをDictionaryに追加
        dict["img"] = ["/ViewBokHover/static/images/{0}.png".format(str(i+1)) for i in range(df.shape[0])]
```


## いよいよ起動

上のmain.pyを ViewBokHover フォルダの直下に保存し、ViewBokHover フォルダ上で以下のコマンドを実行すると、ブラウザが起動し、所望のツールが表示されているはずだ。

```
bokeh serve --show ViewBokHover
```


#おわりに
今回は思ったより手こずってしまったが、BokehのDirectoryを指定するモードについて知ることができたため、とても有意義だった。このツールをアプリケーションの一部にシームレスに統合するってことも、このモードを利用すれば(頑張れば)できるような気がする。今後は、様々な種類のグラフの描画を試しながら、この素晴らしいBokehへの理解を深めていきたい。

# 参考
- [Bokeh ユーザーガイド](https://bokeh.pydata.org/en/latest/docs/user_guide.html)
- [Bokeh - Configuring Plot Tools](https://bokeh.pydata.org/en/latest/docs/user_guide/tools.html)
- [Bokeh - Running a Bokeh Server](https://bokeh.pydata.org/en/latest/docs/user_guide/server.html)