インタラクティブなプロット¶

このチュートリアルはパーシステンス図のプロットを Jupyter 上でインタラクティブにする仕組みを構築しましょう。 Matplotlib, ipywidgets などを活用します。 このチュートリアルで学んだ知識を活用して、自分用の UI を構築することができます。

チュートリアルの最後に、このチュートリアルで構築した UI を自分のプロジェクトに手軽に導入できるようにする方法について説明しています。

追加ライブラリのインストール¶

このチュートリアルの内容を実行するには HomCloud やその依存ライブラリに加えて

  • ipywidgets
  • ipympl

が必要です。

以下のようにしてこれらのパッケージをインストールします。

pip install ipywidgets ipyml

パーシステンス図を用意する¶

例題用のパーシステンス図を用意します。100個のランダムな点群を用意し、パーシステンス図を計算します。

In [2]:
import numpy as np
In [3]:
pointcloud = np.random.uniform(-1, 1, size=(100, 3))
In [4]:
import homcloud.interface as hc
In [5]:
pdlist = hc.PDList.from_alpha_filtration(pointcloud, save_boundary_map=True)
In [6]:
pd1 = pdlist.dth_diagram(1)

必要なライブラリをインポートする¶

以下、必要なライブラリをインポートします。 また、Matplotlib のバックエンドを widget に変更する必要があります。

In [7]:
import ipywidgets
from IPython.display import display
import matplotlib.pyplot as plt
# 次の行が必要! Matplotlib のバックエンドを "widget" に変更する。
%matplotlib widget  

GUI を構築する¶

以下のスクリーンショットのようなインターフェースを構築します。

Birth Range、Death Range のスライダーでパーシステンス図のXYの範囲を、Birth bins、Death binsのスライダーでビンの数を指定します。 右側の Sync のチェックボックスをオンにすると、DeathがBirthの変更に同期するようになります (逆向きには同期しません)。 スライダーの右側の数字を直接キーボードから変更することもできます。 colorbar は3種類のカラーバーの方式を選ぶことができるようにします。 Colorbar max でカラーバーの最大値を指定できるようにします。 Replotボタンを押すと、これらの変更点が反映されます。

UIの構築にはそれなりの分量のコードが必要なので、関数を定義しましょう。

この関数は、以下の3つのパートに分かれます。

  1. ウィジットを用意する
  2. ウィジットを配置する
  3. 描画用関数を用意する
  4. UIイベントを処理する関数を定義する
  5. 描画用関数を呼びだし、最初の状態を描く
In [8]:
# pd には HomCloud の PD オブジェクトを渡します。
def build_interactive_widgets(pd):
    # 次の2行で Matplotlib の準備をします。
    # 
    # 1行目は構築済のインタラクティブな Matplotlib の図 (Figure) をずべて閉じます。
    # 同時に開ける図の個数には上限があるので既存のものを全て閉じてしまいます。
    # 実際には次のような作用があります。
    # * Jupyter 上の場合: 他の図をインタラクィブに操作できなくなる
    # * TkAgg バックエンドなどを使っている場合:開いているウィンドウを閉じる
    #
    # 2行目で新しい Matplotlib の図を構築します。この図にパーシステンス図を描画します。
    plt.close("all")  
    fig = plt.figure()

    # テキストデータを追記的に表示するための特殊なウィジェット。
    # デバッグ出力を描画するためなどに利用される。
    # Jupyter 上での GUI プログラミングではデバッガなどが使いにくいのでこういう仕組みを
    # 準備しておいたほうがよい。
    output = ipywidgets.Output()
    
    # 以下の3行で、生成時刻、消滅時刻の最大最小およびそを差を計算する。
    # この値は UI で指定できる範囲などを調整するために利用する。
    min_birthdeath = np.min(np.concatenate([pd.births, pd.deaths]))
    max_birthdeath = np.max(np.concatenate([pd.births, pd.deaths]))
    d = max_birthdeath - min_birthdeath

    # GUIの部品をウィジェットと呼びます。
    # 次のコードで生成時刻のプロット範囲を指定するウィジェットを構築します。
    # FloatRangeSliderは範囲を指定できるスライダーウィジェットで、この目的に最適です。
    # 両端を浮動小数点数で指定できるので `Float` という名前になっています。
    #
    # 初期状態ではさきほど計算した生成時刻、消滅時刻の最大最小を範囲とします。
    # これはPDの可視化で範囲を指定しなかった場合と同様の範囲です。
    #
    # 指定できる範囲は、最大最小の範囲の二倍としてます。
    # スライダーはこの範囲の1/100の精度で動かせることにしましょう。
    x_range = ipywidgets.FloatRangeSlider(
        value=[min_birthdeath, max_birthdeath], # スライダーの初期位置
        min=(min_birthdeath - 0.5 * d), # スライダーの下限
        max=(max_birthdeath + 0.5 * d), # スライダーの上限
        step=(d / 100),  # スライダーを移動させられる精度
        description='Birth Range:',  # 説明文
    )

    # 次のコードで生成時刻軸のビンの個数を指定するウィジェットを構築します。
    # 初期値は128分割で、8〜256の範囲を動かせるようにします。
    # IntSlider は整数値のみを指定できるスライダーです。
    x_bins = ipywidgets.IntSlider(
        value=128,  # スライダーの初期値
        min=8,  # スライダーの最小値
        max=256,  # スライダーの最大値
        step=1,  # スライダーを移動させられる精度、1づつ変更できるようにする。
        description='Birth Bins:',  # 説明文
    )

    # 消滅時刻の軸についても範囲とビン数を指定できるようにします。
    # 生成時刻の軸と同様です。
    y_range = ipywidgets.FloatRangeSlider(
        value=[min_birthdeath, max_birthdeath],
        min=(min_birthdeath - 0.5 * d),
        max=(max_birthdeath + 0.5 * d),
        step=(d / 200),
        description='Death Range:',
    )
    
    y_bins = ipywidgets.IntSlider(
        value=128,
        min=8,
        max=256,
        step=1,
        description='Death Bins:',
    )

    # 次は生成時刻のスライダーと消滅時刻のスライダーを同期する仕組みを導入します。
    # 両方で同じ範囲 / ビン数を指定したい場合に対応します。
    # チェックボックスを用意して、この仕組みのオンオフを制御できるようにします。
    #
    # link_range と link_bins という変数にこの仕組みを実現する機構を保持します。
    # 最初は None に初期化しておきます。
    link_range = link_bins = None

    # 次の関数は2つの範囲指定のスライダー間の同期機構を構築し、link_range に代入する関数です。
    def set_range_link():
        # Python の機能で、外のスコープの変数に代入できるようにします。
        nonlocal link_range  
        # 同期機能を提供する dlink という特殊なウィジェットを使います。
        # 詳しくは https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html#linking-traitlets-attributes-in-the-kernel を
        # 参照してください。
        link_range = ipywidgets.dlink((x_range, "value"), (y_range, "value"))

    # 次の関数は2つのビン数指定のスライダー間の同期機構を構築し、link_range に代入する関数です。
    # 上の関数と同様なので説明は省略します。
    def set_bins_link():
        nonlocal link_bins
        link_bins = ipywidgets.dlink((x_bins, "value"), (y_bins, "value"))

    # 上記2つの関数を呼び出して同期を有効にします。
    set_range_link()
    set_bins_link()

    # 次にこの同期機構の有効無効を切り換えるチェックボックスウィジェットを構築します。
    # 後程このチェックボックスがクリックされるたびに同期の有効無効を切りかえるイベントを関連付けます。
    link_range_checkbox = ipywidgets.Checkbox(value=True, description="Sync Range")
    link_bins_checkbox = ipywidgets.Checkbox(value=True, description="Sync Bins")
    
    # カラーバーの色付けルールを切り替えるラジオボタンを準備します。
    colorbar_radiobuttons = ipywidgets.RadioButtons(
        options=["linear", "log", "loglog"],  # 選択肢、「linear」「log」「loglog」の3つ。
        value="log",  # 初期状態で選ばれている選択肢
        orientation="horizontal",  # ラジオボタンを並べる向き、"horizontal" で横に並べる
        description="colorbar"  # 説明文
    )

    # カラーバーの最大値を指定するテキストボックス。
    # 空だとパーシステンス図の分布に応じて自動調整します。
    colorbar_max_textbox = ipywidgets.Text(
        value="",  # 初期値
        description='Colorbar max:',  # 説明
        style={'description_width': 'initial'}  # 見た目の調整。description が長いとデフォルトでは途中で省略されるが、それを全部表示するように変更する
    )

    # 再描画ボタン。後程再描画する関数をこのボタンに結び付けるコード。
    replot_button = ipywidgets.Button(description='Replot', tooltip='Replot')

    # ウィジェットを配置します。
    # IPython.display.display 関数を呼び出すと配置されます。
    # 呼び出した順に上から下に並べられます。
    # HBox は複数のウィジェットを横に並べた、コンテナウィジェットです。
    display(ipywidgets.HBox([x_range, y_range, link_range_checkbox]))  # 範囲指定ウィジェット
    display(ipywidgets.HBox([x_bins, y_bins, link_bins_checkbox]))  # ビン数指定ウィジェット
    display(ipywidgets.HBox([colorbar_radiobuttons, colorbar_max_textbox]))  # カラーバー調整ウィジェット
    display(replot_button)  # 再描画ボタン
    display(output)  # デバッグ出力用

    # 描画 / 再描画の関数。処理は共通なのでここでは一つの関数でまとめて扱います。
    # ボタンをクリックしたときに再描画されるが、そのときには Event オブジェクトが渡されるが
    # イベントの内容に関わらず常に同じ方法で再描画するので、引数を無視することを明示するため _ という変数を使います。
    # (Python において、この変数名は利用されないことを示すために慣習的に使われます)。
    def plot(_=None):
        # 図の領域をクリアする
        fig.clear()
        # Axes オブジェクト (パーシステンス図を描画する部分領域) を取り出す。
        ax = fig.gca()
        # カラーバーの最大の値をテキストボックス (colorbar_max_textbox) から取り出す。
        # データはテキストデータなので数値に変換する必要がある。
        # 何も入力されていない場合は None にする。HomCloudの 仕様で None にしておくとカラーバーはヒストグラムから自動的に調整される。
        colorbar_max = None if colorbar_max_textbox.value == "" else float(colorbar_max_textbox.value)
        # パーシステンス図をプロットする。`ax=ax`で描画先を指定する。
        # それぞれのウィジェットから設定値を取得して描画する。
        # FloatRangeSlider は浮動小数点数2個のタプル、IntSliderは整数値、を保持しているのでそのまま histogram メソッドに渡すことができる
        pd.histogram(
            x_range.value, x_bins.value, y_range.value, y_bins.value
        ).plot(
            colorbar={"type": colorbar_radiobuttons.value, "max": colorbar_max}, ax=ax
        )
        # 描画結果を確実に反映させる。
        fig.canvas.draw()

    # チェックボックスをクリックするたびに呼び出し、チェックボックスの値に応じて同期の有効無効を切り換える関数です。
    # event にはイベントを表す dict オブジェクトが渡されます。
    def toggle_range_link(event):
        # Output ウィジェットを使ったデバッグ出力の例。
        # with output:
        #     print(event)

        # イベントの種類が "value" 以外の場合はイベントを無視する。
        # "value" イベントは値が変更されることを意味する。
        if event["name"] != "value":
            return

        if event["new"]:
            # 同期を有効にする。さきほど定義した関数を呼び出せばよい。
            set_range_link()
        else:
            # 同期を無効にする。dlink オブジェクトの unlink メソッドを呼びと無効化される。
            link_range.unlink()
    
    # こちらはビン数の同期非同期を切り替えるための関数。
    def toggle_bins_link(event):
        if event["type"] != "value":
            return
        if event["new"]:
            y_bins.value = x_bins.value
            set_bins_link()
        else:
            link_bins.unlink()

    # 以下の3行で上に定義した関数を UI ウィジットに関連付けます。
    replot_button.on_click(plot)  # Replot ボタンには再描画関数(plot)を結び付ける。
    link_range_checkbox.observe(toggle_range_link)  # チェックボックスには同期の有効/無効を切り替える関数を結び付ける。
    link_bins_checkbox.observe(toggle_bins_link)  # 同上

    # 図を描画します。この呼出で初期パラメータでの描画がなされます。
    plot()

では、上で関数を呼び出して実際に UI を構築しましょう。

In [9]:
build_interactive_widgets(pd1)

スライダーやラジオボタンなどを変更して Replot ボタンを押してください。スライダーバーの隣りの数字を直接変更することも可能です。

よりインタラクティブなUI¶

前の例では Replot ボタンを押すことでパーシステンス図を再プロットしました。

次の例では Replot ボタンをなくし、UI上の値を変えた瞬間に再プロットするようにします。 こうすることで UI からの調整がよりダイレクトになります。

仕組みとしては、上でも使ったイベント機構で変化を検知して再プロットします。

この例では continuous_update=False として、スライダーをドラッグして動かしている間は イベントを発生しないようにしています。最後にスライダーを離したときにイベントが発生します。 こうすると、スライダーを掴んでいる間は図が更新されません。 continuous_update=True とするとスライダーを動かしたときにもイベントが発生するようになります。 こうしたほうがよりインタラクティブになりますが、余分の処理が発生する原因にもなります。

以下の説明では前の関数と違う所だけコメントで説明します。

In [10]:
def build_more_interactive_widgets(pd):
    plt.close("all")
    fig = plt.figure(layout="compressed")  #  layout="compressed" すると図の上のほうの空白が小さくなります。
    fig.canvas.header_visible = False  # Figure 1 という表示は不要なので表示しないよう指定します。

    # ここから関数とほぼ同じです。
    # ただし、再描画用のボタンは不要なので省略します。
    output = ipywidgets.Output()
    
    min_birthdeath = np.min(np.concatenate([pd.births, pd.deaths]))
    max_birthdeath = np.max(np.concatenate([pd.births, pd.deaths]))
    d = max_birthdeath - min_birthdeath

    x_range = ipywidgets.FloatRangeSlider(
        value=[min_birthdeath, max_birthdeath], # スライダーの初期位置
        min=(min_birthdeath - 0.5 * d), # スライダーの下限
        max=(max_birthdeath + 0.5 * d), # スライダーの上限
        step=(d / 100),  # スライダーを移動させられる精度
        description='Birth Range:',  # 説明文
        continuous_update=False,
    )
    
    x_bins = ipywidgets.IntSlider(
        value=128,
        min=8,
        max=128,
        step=1,
        description='Birth Bins:',
        continuous_update=False,
    )
    
    y_range = ipywidgets.FloatRangeSlider(
        value=[min_birthdeath, max_birthdeath],
        min=(min_birthdeath - 0.5 * d),
        max=(max_birthdeath + 0.5 * d),
        step=(d / 200),
        description='Death Range:',
        continuous_update=False,
    )
    
    y_bins = ipywidgets.IntSlider(
        value=128,
        min=8,
        max=128,
        step=1,
        description='Death Bins:',
        continuous_update=False,
    )
    
    link_range = link_bins = None
    
    def set_range_link():
        nonlocal link_range
        link_range = ipywidgets.dlink((x_range, "value"), (y_range, "value"))
    
    def set_bins_link():
        nonlocal link_bins
        link_bins = ipywidgets.dlink((x_bins, "value"), (y_bins, "value"))
    
    set_range_link()
    set_bins_link()
    
    link_range_checkbox = ipywidgets.Checkbox(value=True, description="Sync Range")
    link_bins_checkbox = ipywidgets.Checkbox(value=True, description="Sync Bins")
    
    colorbar_radiobuttons = ipywidgets.RadioButtons(
        options=["linear", "log", "loglog"],
        value="log",
        orientation="horizontal",
        description="colorbar"
    )
    
    colorbar_max_textbox = ipywidgets.Text(
        value="",
        description='Colorbar max:',
        style={'description_width': 'initial'}
    )

    def plot():
        fig.clear()
        ax = fig.gca()
        colorbar_max = None if colorbar_max_textbox.value == "" else float(colorbar_max_textbox.value)
        pd.histogram(
            x_range.value, x_bins.value, y_range.value, y_bins.value
        ).plot(
            colorbar={"type": colorbar_radiobuttons.value, "max": colorbar_max}, ax=ax
        )
        fig.canvas.draw()

    # 再描画関数を plot 関数から独立させます。
    # イベントの種類が "value" (ウィジット上の値が変更されたことを意味します)
    # の場合のみ plot 関数を呼び出します。
    def replot(event):
        #with output:
        #    print(event)
        if event["name"] == "value":
            plot()

    def toggle_range_link(event):
        if event["name"] != "value":
            return
            
        if event["new"]:
            y_range.value = x_range.value
            set_range_link()
        else:
            link_range.unlink()
        
    def toggle_bins_link(event):
        if event["name"] != "value":
            return
        if event["new"]:
            y_bins.value = x_bins.value
            set_bins_link()
        else:
            link_bins.unlink()

    display(ipywidgets.HBox([x_range, y_range, link_range_checkbox]))
    display(ipywidgets.HBox([x_bins, y_bins, link_bins_checkbox]))
    display(ipywidgets.HBox([colorbar_radiobuttons, colorbar_max_textbox]))
    display(output)

    # ここから先が大きく異なります。
    # スライダーの動きやラジオボタンの状態を監視して値が変化するたびに replot 関数を呼び出します。
    x_range.observe(replot)
    x_bins.observe(replot)
    y_range.observe(replot)
    y_bins.observe(replot)
    colorbar_radiobuttons.observe(replot)
    colorbar_max_textbox.observe(replot)
    link_range_checkbox.observe(toggle_range_link)
    link_bins_checkbox.observe(toggle_bins_link)
    
    plot()
In [11]:
build_more_interactive_widgets(pd1)

生成消滅対を範囲指定で取り出す¶

次はパーシステンス図上をドラッグして長方形の範囲を指定し、その領域にある生成消滅対を抽出する仕組みを追加しましょう。 https://matplotlib.org/stable/gallery/widgets/rectangle_selector.html を参考にします。

この関数の引数には PD オブジェクトとリストを渡して、このリストを破壊的に変更することで UI とやりとりします。

In [12]:
from matplotlib.widgets import RectangleSelector

def build_interactive_widgets_with_rectangle_selector(pd, list_of_pairs):
    plt.close("all")
    fig = plt.figure(layout="compressed")
    fig.canvas.header_visible = False
    
    output = ipywidgets.Output()
    
    min_birthdeath = np.min(np.concatenate([pd.births, pd.deaths]))
    max_birthdeath = np.max(np.concatenate([pd.births, pd.deaths]))
    d = max_birthdeath - min_birthdeath

    x_range = ipywidgets.FloatRangeSlider(
        value=[min_birthdeath, max_birthdeath],
        min=(min_birthdeath - 0.5 * d),
        max=(max_birthdeath + 0.5 * d),
        step=(d / 100),
        description='Birth Range:',
        continuous_update=False,
    )
    
    x_bins = ipywidgets.IntSlider(
        value=128,
        min=8,
        max=128,
        step=1,
        description='Birth Bins:',
        continuous_update=False,
    )
    
    y_range = ipywidgets.FloatRangeSlider(
        value=[min_birthdeath, max_birthdeath],
        min=(min_birthdeath - 0.5 * d),
        max=(max_birthdeath + 0.5 * d),
        step=(d / 200),
        description='Death Range:',
        continuous_update=False,
    )
    
    y_bins = ipywidgets.IntSlider(
        value=128,
        min=8,
        max=128,
        step=1,
        description='Death Bins:',
        continuous_update=False,
    )
    
    link_range = link_bins = None
    
    def set_range_link():
        nonlocal link_range
        link_range = ipywidgets.dlink((x_range, "value"), (y_range, "value"))
    
    def set_bins_link():
        nonlocal link_bins
        link_bins = ipywidgets.dlink((x_bins, "value"), (y_bins, "value"))
    
    set_range_link()
    set_bins_link()
    
    link_range_checkbox = ipywidgets.Checkbox(value=True, description="Sync Range")
    link_bins_checkbox = ipywidgets.Checkbox(value=True, description="Sync Bins")
    
    colorbar_radiobuttons = ipywidgets.RadioButtons(
        options=["linear", "log", "loglog"],
        value="log",
        orientation="horizontal",
        description="colorbar"
    )
    
    colorbar_max_textbox = ipywidgets.Text(
        value="",
        description='Colorbar max:',
        style={'description_width': 'initial'}
    )

    # 領域が指定された時に呼び出される関数です。
    # eclick、erelease は範囲指定(マウスドラッグ)開始時、終了時の情報を保持したオブジェクトです。
    # xdata, ydata といった属性で座標を取得できます。
    # 他にもどのマウスのどのボタンで指定したか、などを取得することができます。
    def select_callback(eclick, erelease):
        # 以下の2行で範囲を取り出します。
        # 範囲指定の最初と最後でどちらのXY座標が小さいかわからないので、sorted で並べ替えます。
        xmin, xmax = sorted([eclick.xdata, erelease.xdata])
        ymin, ymax = sorted([eclick.ydata, erelease.ydata])
        # リストを空にします。
        list_of_pairs.clear()
        # リストに領域の範囲の対を挿入します。
        list_of_pairs.extend(pd.pairs_in_rectangle(xmin, xmax, ymin, ymax))

    selector = None

    # (再)描画関数で範囲指定セレクターの設定をする必要があります。
    def plot():
        nonlocal selector
        # 描画前に元々使っていたセレクターを無効化します。
        if selector: 
            selector.set_active(False)
        fig.clear()
        ax = fig.gca()
        colorbar_max = None if colorbar_max_textbox.value == "" else float(colorbar_max_textbox.value)
        pd.histogram(
            x_range.value, x_bins.value, y_range.value, y_bins.value
        ).plot(
            colorbar={"type": colorbar_radiobuttons.value, "max": colorbar_max}, ax=ax
        )
        # 範囲指定セレクターを準備します。
        # セレクターは Axes に結び付けられるので、図をクリアするごとに再設定が必要です。
        selector = RectangleSelector(
            ax,  # 範囲指定を使う Axes オブジェクト
            select_callback,  # 範囲指定されたタイミングで呼び出される関数。上で定義したものを用いる。
            useblit=True,  # こうしたほうが高速化されるようです。
            button=[1],  # 範囲指定に反応するマウスボタンを指定する。今回は 1 (左ボタン) のみ
            interactive=True,  # これを True にすると範囲指定にハンドルが付いて、指定後に動かせるようになる。
        )
        fig.canvas.draw()

    def replot(event):
        if event["name"] == "value":
            plot()
            
    def toggle_range_link(event):
        if event["new"]["value"]:
            y_range.value = x_range.value
            set_range_link()
        else:
            link_range.unlink()
        
    def toggle_bins_link(event):
        if event["new"]["value"]:
            y_bins.value = x_bins.value
            set_bins_link()
        else:
            link_bins.unlink()    

    display(ipywidgets.HBox([x_range, y_range, link_range_checkbox]))
    display(ipywidgets.HBox([x_bins, y_bins, link_bins_checkbox]))
    display(ipywidgets.HBox([colorbar_radiobuttons, colorbar_max_textbox]))
    display(output)

    x_range.observe(replot)
    x_bins.observe(replot)
    y_range.observe(replot)
    y_bins.observe(replot)
    colorbar_radiobuttons.observe(replot)
    colorbar_max_textbox.observe(replot)
    link_range_checkbox.observe(toggle_range_link)
    link_bins_checkbox.observe(toggle_bins_link)

    plot()

次のセルでこの関数を使います。

In [13]:
list_of_pairs = []
build_interactive_widgets_with_rectangle_selector(pd1, list_of_pairs)

list_of_pairs は最初は空のリストですが、図をドラッグして領域を指定するとこの配列に領域内の生成消滅対が格納されます。

In [14]:
list_of_pairs
Out[14]:
[]

プログラムの整理 (クラス化)¶

このプログラムは1つの関数に様々な処理を詰め込んでいます。Python のクラスを使ってこのコードを整理しましょう。

In [15]:
import collections

class InteractivePDUI:
    def __init__(self, pd):
        self.pd = pd
        # セレクターで選択された矩形領域内にある生成消滅対を保持する変数
        self.selected_pairs = []
        # ウィジットを保持しておく場所を準備する
        self.widgets = collections.namedtuple("Widgets", [
            "x_range", "y_range", "link_range_checkbox", "link_range",
            "x_bins", "y_bins", "link_bins_checkbox", "link_bins",
            "colorbar_type", "colorbar_max",
            "num_pairs_in_rectangle",
            "output",
        ])
        self.max_birthdeath = np.max([np.max(pd.births), np.max(pd.deaths)])
        self.min_birthdeath = np.min([np.min(pd.births), np.min(pd.deaths)])
        self.selector = None

    @property
    def output(self):
        return self.widgets.output
        
    def build(self):
        self.setup_matplotlib_figure()
        self.build_widgets()
        self.arrange_widgets()
        self.bind_gui_events()
        self.plot()

    def setup_matplotlib_figure(self):
        plt.close("all")
        self.fig = plt.figure(layout="compressed")
        self.fig.canvas.header_visible = False
        
    def build_widgets(self):
        self.widgets.output = ipywidgets.Output()
        self.widgets.x_range = self.build_range_slider("Birth Range:")
        self.widgets.y_range = self.build_range_slider("Death Range:")
        self.widgets.link_range_checkbox = ipywidgets.Checkbox(value=True, description="Sync Range")
        self.widgets.x_bins = self.build_bins_slider("Birth Bins:")
        self.widgets.y_bins = self.build_bins_slider("Death Bins:")
        self.widgets.link_bins_checkbox = ipywidgets.Checkbox(value=True, description="Sync Bins")
        self.set_range_link()
        self.set_bins_link()

        self.widgets.colorbar_type = self.build_colorbar_type_radiobuttons()
        self.widgets.colorbar_max = self.build_colorbar_max_textbox()

        self.widgets.num_pairs_in_rectangle = self.build_num_pairs_in_rectable_label()
        
    def build_range_slider(self, description):
        return ipywidgets.FloatRangeSlider(
            value=self.initial_range_slider(),
            min=self.min_range_slider(),
            max=self.max_range_slider(),
            step=self.step_range_slider(),
            description=description,
            continuous_update=False,
        )

    def initial_range_slider(self):
        return [self.min_birthdeath, self.max_birthdeath]

    def min_range_slider(self):
        return self.min_birthdeath - (self.max_birthdeath - self.min_birthdeath) / 2

    def max_range_slider(self):
        return self.max_birthdeath + (self.max_birthdeath - self.min_birthdeath) / 2

    def step_range_slider(self):
        return (self.max_birthdeath - self.min_birthdeath) / 100

    def build_bins_slider(self, description):
        return ipywidgets.IntSlider(
            value=128,
            min=8,
            max=256,
            step=1,
            description=description,
            continuous_update=False,
        )

    def set_range_link(self):
        self.widgets.link_range = ipywidgets.dlink(
            (self.widgets.x_range, "value"), (self.widgets.y_range, "value")
        )

    def set_bins_link(self):
        self.widgets.link_bins = ipywidgets.dlink(
            (self.widgets.x_bins, "value"), (self.widgets.y_bins, "value")
        )

    def build_colorbar_type_radiobuttons(self):
        return ipywidgets.RadioButtons(
            options=["linear", "log", "loglog"],
            value="log",
            orientation="horizontal",
            description="colorbar"
        )

    def build_colorbar_max_textbox(self):
        return ipywidgets.FloatText(
            value=0,
            description='Colorbar max (0 for adaptive max value):',
            style={'description_width': 'initial'}
        )

    # 範囲選択セレクターで指定された矩形領域の生成消滅対の個数を表示するウィジェット
    def build_num_pairs_in_rectable_label(self):
        return ipywidgets.Label(
            value=self.text_of_num_pairs_in_rectangle(),
            style={'description_width': 'initial'},
        )

    # 矩形領域の生成消滅対の個数を数える関数
    def text_of_num_pairs_in_rectangle(self):
        n = len(self.selected_pairs)
        return f"Number of pairs in rectangular selection: {n}"
        
    def arrange_widgets(self):
        layout = ipywidgets.VBox([
            ipywidgets.HBox([
                self.widgets.x_range, self.widgets.y_range, self.widgets.link_range_checkbox
            ]),
            ipywidgets.HBox([
                self.widgets.x_bins, self.widgets.y_bins, self.widgets.link_bins_checkbox
            ]),
            ipywidgets.HBox([
                self.widgets.colorbar_type, self.widgets.colorbar_max
            ]),
            self.widgets.num_pairs_in_rectangle, 
            self.widgets.output
        ])
        display(layout)

    def bind_gui_events(self):
        self.widgets.x_range.observe(self.replot)
        self.widgets.y_range.observe(self.replot)
        self.widgets.x_bins.observe(self.replot)
        self.widgets.y_bins.observe(self.replot)
        self.widgets.colorbar_type.observe(self.replot)
        self.widgets.colorbar_max.observe(self.replot)
        self.widgets.link_range_checkbox.observe(self.toggle_range_link)
        self.widgets.link_bins_checkbox.observe(self.toggle_bins_link)

    def replot(self, event):
        # with self.output:
        #     print(event)
        if event["name"] == "value":
            self.plot()

    def plot(self):
        self.invalidate_selector()
        self.fig.clear()
        self.ax = self.fig.gca()
        self.pd.histogram(
            self.widgets.x_range.value, 
            self.widgets.x_bins.value,
            self.widgets.y_range.value,
            self.widgets.y_bins.value,
        ).plot(colorbar={
            "type": self.widgets.colorbar_type.value,
            "max": self.colorbar_max_value(),
        }, ax=self.ax)
        self.setup_selector()
        self.fig.canvas.draw()

    def invalidate_selector(self):
        if self.selector:
            self.selector.set_active(False)

    def setup_selector(self):
        self.selector = RectangleSelector(
            self.ax,
            self.select_rectangle,
            useblit=True,
            button=[1],
            interactive=True,
        )
        
    def colorbar_max_value(self):
        return None if self.widgets.colorbar_max.value == 0.0 else self.widgets.colorbar_max.value

    def select_rectangle(self, eclick, erelease):
        # with self.output:
        #     print(eclick)
        #     print(erelease)
        xmin, xmax = sorted([eclick.xdata, erelease.xdata])
        ymin, ymax = sorted([eclick.ydata, erelease.ydata])
        self.selected_pairs = self.pd.pairs_in_rectangle(xmin, xmax, ymin, ymax)
        self.widgets.num_pairs_in_rectangle.value = self.text_of_num_pairs_in_rectangle()

    def toggle_range_link(self, event):
        if event["name"] != "value":
            return
            
        if event["new"]:
            self.set_range_link()
        else:
            self.widgets.link_range.unlink()

    def toggle_bins_link(self, event):
        if event["name"] != "value":
            return

        if event["new"]:
            self.set_bins_link()
        else:
            self.widgets.link_bins.unlink()

このクラスは次のようにして利用します。

In [16]:
ui = InteractivePDUI(pd1)
ui.build()

長方形領域で選択している部分は次の selected_pair 属性で取り出します。さきほどリストを破壊的に更新していたのよりは自然でしょう。

In [17]:
ui.selected_pairs
Out[17]:
[]

以下のようなコードで抽出した対に対応するリングを計算し、可視化できます。 コードの意味については3次元点群のチュートリアルを確認してください。

In [18]:
import homcloud.plotly_3d as p3d
import plotly.graph_objects as go

stable_volumes = [pair.stable_volume(pair.lifetime() / 50) for pair in ui.selected_pairs]
go.Figure(data=[
    v.to_plotly3d(width=4, name=f"No {n}") for n, v in enumerate(stable_volumes)
] + [
    p3d.PointCloud(pointcloud, color="black", name="Pointcloud")
], layout=dict(scene=p3d.SimpleScene()))

インタラクティブ UI のコードを自分のプロジェクトに導入する¶

これらの UI を自分のプロジェクトに導入するのに、上のようなコードをコピー&ペーストするのは不便でしょう。 こういったコードは、普通の Python のファイル (.py ファイル) に分離して Jupyter notebook から 読み込むのが標準的な方法です。例えば interactive_ui.py というファイルにコードを分離して、

import matplotlib.pyplot as plt
%matplotlib widget

from interactive_ui import InteractivePDUI

のようにしてから、

ui = InteractivePDUI(pd1)
ui.build()

とすればよいでしょう。 分離したファイルを改造して自分用に使いやすい UI を構築するのもお勧めです。

HomCloud ではこの例題のコードを homcloud.example.interactive_jupyter_ui という名前で用意しています。そこで代わりに

from homcloud.example.interactive_jupter_ui import InteractivePDUI

とすればこの UI がそのまま使えます。