🍣

bokehによる画像案件分析の効率化

2024/09/24に公開

こんにちは。SREホールディングス株式会社の西野です。
SREでは主に画像認識案件のPLを担当しています。
案件推進の効率化目的でbokehを使った可視化用webアプリを開発したので、その紹介です。

概要

作ったもの

今回開発したwebアプリは以下のようなものになります。


各患者の病変データを可視化しています。x,y軸は病変の位置を示していて、凡例が患者IDごとになっています。そこから特定の患者にフィルタし、凡例を良性/悪性に変更。悪性の病変がどのような画像なのか、周辺の良性病変とどのように違うのかを確認している様子です


こちらは、各可視化データを階層構造で管理している様子を示しています。プロジェクトのリストから、各プロジェクトの実験(可視化)一覧に遷移し、グラフに遷移します。その後、x軸やy軸の設定を変更している様子です。x軸が病変のサイズ、y軸が病変内外のコントラストになっています。

実現したこと

上記のアプリで実現したことは以下になります。

  • グラフ(数値データ)と画像のインタラクティブな融合(←ここにbokehを活用)
    • プロットにカーソルをあてると、ツールチップ上に画像が表示される
  • ノーコードで自由に分析/可視化
    • x軸,y軸はGUIで変更可能
    • カテゴリ分けや、フィルタ機能も実装
  • 様々なプロジェクトで利用可能な汎用的な設計
    • 各プロジェクトで利用しているデータをcsv形式で配置するだけで、可視化メニューに反映

開発の背景

2年ほど前にある案件でbokehを使って似たようなことはやっていたのですが、便利だなーと思いつつも、以下の理由であまり定着していませんでした。

  • bokehの学習コストがそれなりに必要
  • 都度可視化コードを書けば、事足りる

とはいえ、以下の点で効率化ができそうということで、重い腰を上げて、今回のwebアプリの開発に至りました。

  • 過去の可視化(実験)結果にすぐにアクセスできる
  • 可視化結果を共有しやすい
  • 共有された側も自分の見たい見方で分析できる
  • 毎回エンジニア(もしくはGPT先生)に「こういう見方がしたい」と伝えて可視化コード書いてもらう必要がない

bokehの紹介

bokehは、Pythonで簡単にWeb対応のインタラクティブなグラフを作成できるライブラリです。JavaScriptの知識がなくても、Pythonのコードだけで複雑なインタラクションを実現できる点が大きな特徴です。(JavaScript込みのhtmlファイルをbokehが自動で生成してくれます)
こちらのGalleryで、bokehを使って実現できることのサンプルが確認できます。

取組の紹介

ここから、今回の取組内容についての紹介になります。

要件

今回のwebアプリ開発にあたり、ざっくりと以下のような要件を定めました。

  1. ツールチップでプロットに関連する画像をすぐに表示できること
  2. ノーコードで“ある程度”自由に分析/可視化できること
    • x軸,y軸はGUIで変更可能
    • カテゴリ分けや、フィルタ機能も実装すること
  3. 各プロジェクトで汎用的に利用できること
    • PJ専用の作り込みはしない
  4. 画像データはコピーしないで済むこと
    • 各プロジェクトで普段使っているデータにそのままアクセスできること
  5. 比較的簡易に(可視化対象)データを追加できること
  6. 過去の可視化結果についてもアクセスできること
    • プロジェクト/実験の階層構造で実験(可視化対象)を管理できること
  7. インターネット公開は不要(VPN含むLAN内で公開できればOK)
    • GunicornなどのWSGI対応はやりません

実現のポイント

bokehを使ってどのように上記の要件を実現したか、bokehの基本も交えながら、いくつかのポイントに絞ってご紹介します。

グラフ化の流れ

bokehでグラフを作成する際の主な流れは以下になります(主要部分のコードを抜粋)

  • csvなどからデータを読み込み、ColumnDataSourceに渡す
  • figure()関数でplotを作成
  • scatterなどのrenderer関数でグラフを作成
  • show(p) でhtmlを作成
from bokeh.plotting import ColumnDataSource, figure, show

df = pd.read_csv(csv_path, index_col=None)
source = ColumnDataSource(data=df)
p = figure(
    width=900,
    height=550,
    tooltips=TOOLTIPS,
)
p.scatter(x="x", y="y", source=source, color=color_mapper)

show(p)

web埋め込みのための出力

上記のshow(p) はシンプルなbokeh単体のhtmlファイルを生成しますが、file_html()関数を使うことで、templateを利用することができるため、自身のwebページへの埋め込みも簡単にできるようになっています。

from bokeh.embed import file_html,components
from jinja2 import Environment

env = Environment(loader=FileSystemLoader("app/templates"), trim_blocks=True)
template = env.get_template("layout_for_my_bokeh.html")

# templateに埋め込む<script>と<div>を獲得
script, div = components(p)

template_variables = {"script": script, "div": div}

html = file_html(
    layout, resources=CDN, template=template, template_variables=template_variables
)

私が使っているtemplate(layout_for_my_bokeh.html)が以下になります。

{% raw %}
{% extends "layout.html" %}
{% block content %}
{% endraw %}

{% block head %}
<head>
    {% block inner_head %}
    <meta charset="utf-8">
    <title>{% block title %}{{ title | e if title else "Bokeh Plot" }}{% endblock %}</title>
    {% block preamble %}{% endblock %}
    {% block resources %}
        {% block css_resources %}
        {{ bokeh_css | indent(8) if bokeh_css }}
        {% endblock %}
        {% block js_resources %}
        {{ bokeh_js | indent(8) if bokeh_js }}
        {% endblock %}
    {% endblock %}
    {% block postamble %}{% endblock %}
    {% endblock %}
</head>
{% endblock %}

{% block body %}
<body>
    {{script}}
    {{div}}
</body>
{% endblock %}

{% raw %}
{% endblock %}
{% endraw %}

上記の{script},{div} 部分をbokehが埋めてくれます。
※ややこしいですが、今回開発したwebアプリでは、①bokehによるレンダリング⇒②flaskによるレンダリングの2段階になっており、{% raw %}{% endraw %}で囲んでエスケープしている箇所は、②のflaskレンダリング用です

ツールチップ

グラフに画像を表示するためにはCustom tooltipを使います。

今回のwebアプリでは複数画像の表示に対応するため、少し複雑になっています

  • カラム名が'img_path'を含む列の画像はすべて表示
    • 画像を表示するには、staticフォルダに画像を配置し、flaskアプリがアクセスできるようにしておく必要があります(後述)
  • imgタグをimg_pathを含むカラム数分作成
    • カラム名を指定することで、表示する画像を指定
img_columns = [col for col in df.columns if 'img_path' in col]

# Tooltipに表示するHTMLを動的に生成
TOOLTIPS = """
    <div style="text-align: left;">
        <div>
            <span style="font-size: 12px;">img label:</span>
            <span style="font-size: 18px; color: #696;">@img_label</span>
        </div>
        <div>
            <span style="font-size: 12px;">x:</span>
            <span style="font-size: 18px; color: #696;">@x</span>
        </div>
        <div>
            <span style="font-size: 12px;">y:</span>
            <span style="font-size: 18px; color: #696;">@y</span>
        </div>
        <div>
            <span style="font-size: 12px;">category:</span>
            <span style="font-size: 18px; color: #696;">@legend_category</span>
        </div>
        <div style="white-space: nowrap;">
"""

for img_col in img_columns:
    TOOLTIPS += f"""
            <div style="display: inline-block;">
                <img
                    src="@{img_col}" height="200" alt="@{img_col}" width="200"
                    style="margin: 0px 15px 15px 0px;"
                    border="2"
                ></img>
            </div>
    """

TOOLTIPS += """
        </div>
    </div>
"""

実際に複数画像表示している様子が以下になります。

画像を複数表示するパターンです。過去のデータセット(isic2020)が同じ病変に対して、異なるカメラで撮影したものだったため、過去データセットが利用可能かどうかを判断しています。csvファイルに画像パスを複数用意するだけで対応しています

CustomJS callbacks

冒頭でJavaScriptを書く必要がないと言いましたが、はいそうです、ウソです。
ユーザー独自の細かい処理を実装するには書く必要があります(逆に言うと、CustomJSというカスタムできる仕組みを用意してくれています)
とは言っても、GPT先生がいるので特に問題はありません。

以下はx軸のプルダウン選択イベントで発生する処理を記載している箇所です。
データソースに予めxというカラムを用意しておいて、プルダウンで指定された列で上書きするような処理にしています。
(ほんとはもっと細かい処理をしていますが、省略しています)

from bokeh.models import CustomJS
from bokeh.models.widgets import Select

Axesselect_x = Select(
    title="Select X-axis:", value=initial_x, options=drop_list_num
)
Axesselect_x.js_on_change(
    "value",
    CustomJS(
        args=dict(
            source=source,
            xaxis=p.xaxis[0],
        ),
        code="""
    const new_value = cb_obj.value
    source.data['x'] = source.data[new_value]

    source.change.emit()
    xaxis.axis_label = new_value
    """,
    ),
)

構成

今回のアプリケーション構成は下図のようになっています。
この構成により要件3~6をクリアしています。

  • まず、各プロジェクトで利用している開発コンテナで可視化したいcsvデータを作成します
    • csvファイルには数値データと画像pathが含まれる必要があります
    • bokeh側でcsvファイルを読み込む際は以下のように処理することで、汎用的に利用できるようにしています(★要件3)
      • numericalなカラムはx軸やy軸の選択対象として扱う
      • categoricalなカラムはカテゴリ表示(凡例)やフィルタの選択対象として扱う
  • そのcsvをホスト側の/mnt/nas/bokeh_data配下に配置します
    • 各プロジェクト用のフォルダを作成し、それを開発コンテナ側にマウントするようにしています
    • マウントしたフォルダ内に実験フォルダを配置するルールにしています
  • 対応する画像ファイルは、ホスト側の/mnt/nas/img_data/に同様のルールで配置するようにしています
    • このフォルダをbokeh側のコンテナでもマウントするため、画像のコピーは必要ありません(★要件4)
    • flaskで画像ファイルにアクセスするため、staticフォルダにマウントしています
  • 同様にcsv用フォルダもbokeh側のコンテナにマウントし、このフォルダ構成をbokehアプリ側で読み込むことで、プロジェクト/実験の階層構造のリンクを作成するようにしています(★要件5,6)

まとめ

bokehを使ったwebアプリ開発に関して簡単にご紹介させて頂きました。
我ながら、汎用的に使えるよいアプリになったと思っていますので、実案件でもガンガン利用していきたいと考えています。
webアプリという未知の領域に踏み込むことになり、色々と勉強できて楽しかったです。GPT先生にフル支援頂きながらの開発でしたが、本当にいい時代になったものだと痛感しています。

SRE Holdings 株式会社

Discussion