省型旧型国電の残影を求めて

戦前型旧型国電および鉄道と変褪色フィルム写真を中心とした写真補正編集の話題を扱います。他のサイトでは得られない、筆者独自開発の写真補正ツールや補正技法についても情報提供しています。写真補正技法への質問はコメント欄へどうぞ

ImageJ / Python 対話的ユーザインターフェースのお勉強

 今まで、ImageJを使った自作の画像処理のプログラムを公表してきていますが、どうもユーザインターフェース周りが苦手です。自分のためだけにプログラムを書くには、自分が一番よく分かっていますので、インターフェースに神経を使う必要はありません。しかし第三者に使ってもらおうと思えばインターフェースを考えないといけません。しかし凝ったインターフェースを考えようと思うと、それだけでかなりのお勉強が必要となりますしコード量も増えます。コアとなるデータ処理よりもインターフェース周りでより多くのエネルギーを投入しなければならないのはちょっと本末転倒なような気がします。とはいえそうもいっていられないのでお勉強をしようと思います。今自分のプログラミングに足りないのは、インターアクティブな対話的なインターフェースを作る技術です。

 そのための教材ですが、イギリス国立のThe MRC Laboratory of Molecular Biology (LMB) [MRC分子生物学研究所]という研究所で、ImageJ (Fiji distribution)Pythonによるプログラミングチュートリアルページを開設しています。

Fiji Programming Tutorial

 チュートリアルなので、ImageJ入門講座ということになるわけですが、内容的には初歩からかなり高度なことまで含まれています。これを見るとかなりのことができる、ということが分かるのですが、ユーザインターフェースについて解説した7章あたりから、いきなり難易度が上がります。それまで緩やかな坂道だったのが突然傾斜が60度か、ロッククライミングになるような急激な難易度上昇があります。

 中には、至れり尽くせりの丁寧なユーザインターフェースを作りこんだサンプルもあるのですが、いきなり400行超えのサンプルプログラムを出されてもなぁ... もっと順々にやさしいところから徐々に複雑なサンプルは出せないものか、と思ってしまいます。あるいは、中身はよくわからなくてもいいからこのままパクっていいよ、という意味で出しているのか...

 とはいえ、ImageJ上でPythonを使って対話的なユーザインターフェースを書こうと思うと、なかなか教材がありません。というわけで、その中の一サンプルプログラムを解読してみたいと思います。自分のための勉強メモのようなもので、ちゃんと理解しているかどうか保証の限りではありません。

----------------------------------

 今回取り上げるのは「対話的な一般ダイアログ (A reactive generic dialog)」というサンプルプログラムです。このプログラムを実行すると、プレビュー画面で画像を拡大したり縮小することができます。

f:id:yasuo_ssi:20210314002903j:plain

実行画面

 

 上の scaleとあるダイアログからスライダーを動かすと、リアルタイムに読み込んでおいていた画面が拡大したり縮小するのをプレビューできます。OKを押すと、画像の拡大・縮小が確定されます。このリアルタイムなプレビューはどうやって実現するのか...

 結局 Javaのライブラリを読み込んでイベントリスナーという機能を使って実現するようです。ImageJのPythonはネイティブではなく、一部ネイティブなライブラリを使える以外基本はPythonの構文を通じてJavaを動かすJythonなのでJavaのライブラリに大きく依存することになります。

 まずプログラムの全体構造です。

f:id:yasuo_ssi:20210314160928j:plain

プログラムの全体構造

 呼び出し関係をオレンジ色のまがった矢印で示しています。

 このプログラムのメインはたった一行で、一番最後の scaleImageUI() という、ダイアログを表示するカスタム関数を呼び出す部分です。

 対話的な動作を実現するのはJavaのイベントリスナーに関するライブラリで、頭に、from java.awt.event import AdjustmentListener, ItemListener と、アジャストメント・リスナー及びアイテム・リスナーを呼び出しています。アジャストメント・リスナーはスライダー( gd.getSliders().get(0) )の動きを感知するリスナー、アイテム・リスナーはチェックボックス ( gd.getCheckboxes().get(0) ) の動作を感知するリスナーということです。

 他にJavaでどんなイベントリスナーが使えるかというと、以下に情報がありました。

docs.oracle.com もちろん、これはどれでも使えるという訳ではなく、ImageJのGenericDialogの要素によって使えるリスナーが決まっているはずです。で、どの要素にどのリスナーが有効かというのは、以下のページ

imagej.nih.govの、Method Details 以下に書かれています。

---------------

 で、以下呼び出し順に解読しています。まずダイアログを定義する scaleImageUI です。

def scaleImageUI():  →定義の宣言
     gd = GenericDialog("Scale")  →importで読み込んでおいたGenericDialogを"Scale"というタイトルをつけて呼び出して gd という名前でインスタンス化する
     gd.addSlider("Scale", 1, 200, 100)  →ダイアログにスライダーをタイトル "Scale", 最低値 1, 最高値 200, デフォルト値 100 で付け加える
     gd.addCheckbox("Preview", True) → "Preview"という説明をつけてチェックボックスを付け加える。デフォルトはチェックされた状態とする
# The UI elements for the above two inputs
     slider = gd.getSliders().get(0) # the only one
   →1つ目のスライダーをsliderという名をつけオブジェクトとしてインスタンス
     checkbox = gd.getCheckboxes().get(0) # the only one
   →1つ目のチェックボックスをcheckboxという名をつけてオブジェクトとしてインスタンス
 ここではスライダーとチェックボックスはひとつずつしかないので、これで終わりです。
     imp = WM.getCurrentImage()
   →今開いてるイメージを imp と名前で imagePlus オブジェクトとして定義
     if not imp: →イメージが取得できないとき、以下のメッセージを示し終了
          print "Open an image first!"
          return

     previewer = ScalingPreviewer(imp, slider, checkbox)
→ このプログラム中で定義したScalingPreviewerクラスに3つの引数を与えて previewerという名前でインスタンス
     slider.addAdjustmentListener(previewer)

→slider変数に対しAdjustmentListenerによる監視をつけ、イベントが発生したら previewerを呼び出す
     checkbox.addItemListener(previewer)
→checkbox変数に対しaddItemListenerによる監視をつけ、イベント(が発生したら previewerを呼び出す*1
     gd.showDialog()  →以上を準備の上ダイアログを表示する

     if gd.wasCanceled(): →キャンセルされたらpreviewerのresetメソドを呼び出す
          previewer.reset()
          print "User canceled dialog!"
     else: →OKされたらpreviewerのscaleメソドを呼び出し画像の実画像を最終的に拡大・縮小する
          previewer.scale()

-----------------------

次はScalingPreviewerオブジェクト(クラス)の定義です。

 この定義では大きく分けて __init__, adjustmentValueChangeditemStateChanged, itemStateChanged, scale という5つの部分の定義に分けられています。いずれも ScalingPreviewer のメソッドとして定義されています。そのため自分自身 (self) を引数として取っています。

 また、継承するクラスとしてAdjustmentListener, ItemListenerを取っています。この部分のコードの中に event という変数が無定義で引数として現れていますが、AdjustmentListener ItemListenerから受け取っています。

class ScalingPreviewer(AdjustmentListener, ItemListener):
     def __init__(self, imp, slider, preview_checkbox):      
          self.imp = imp
          self.original_ip = imp.getProcessor().duplicate() # store a copy
          self.slider = slider
          self.preview_checkbox = preview_checkbox

__init__というのは、コンストラクタとも呼ばれる、このクラスが呼び出されたときに初期化するためのメソッドです。ここでこのクラスが取る引数を定義しています。imp, slider, preview_checkbox の4つの引数を取るよう定義しています。その後、引数に self. という接頭詞をつけ名前を付けなおしていますが、なぜこんな無駄なことをやるかというと、こうしないとこのクラスの中の他のメソドを定義するモジュールから引数を参照することができないのです。つまり他のモジュールからは 直接 impだとかsliderという名前で参照できないので、このように名前を付けなおすことで参照することができるようにしています。

 また、impを複写しバックアップ用の画像original_ipを作成しておきます。

     def adjustmentValueChanged(self, event):
""" event: an AdjustmentEvent with data on the state of the scroll bar. """
          preview = self.preview_checkbox.getState()
          if preview:
               if event.getValueIsAdjusting():
                    return # there are more scrollbar adjustment events queued already
               print "Scaling to", event.getValue() / 100.0

               self.scale()
 次は、スライダーが動かされたときに動作するメソッド adjustmentValueChanged です。スライダーが動かされたときにコンソールに倍率を表示します。引数に self 以外に event が参照されていますので、event が発生したときに常に呼び出されるはずです。しかし、event 変数自体にはそれがAdjustmentListener, ItemListener のどちらから発生したかの区分はないと思います。しかし、if event.getValueIsAdjusting(): で、AdjustmentListenerによって発生したイベントの場合のみ、イベントから変化した値を取得してコンソールに書き込みます。かつ、if preview で制限を掛けていますので、チェックボックスにチェックがついているときのみ書き込むようになっています。


     def itemStateChanged(self, event):
""" event: an ItemEvent with data on what happened to the checkbox. """
          if event.getStateChange() == event.SELECTED:
               self.scale()

 こちらは、チェックボックスの状態が変化したときに実行するメソッドです。event.getStateChange() event.SELECTED になったときに 本オブジェクトのscaleメソッドを呼び出すとあります。で、スライダーを動かしたときは、 event.SELECTED を取りませんので、チェックボックスがチェックされたときのみscaleメソッドを呼び出すということになります。


     def reset(self):
""" Restore the original ImageProcessor """
          self.imp.setProcessor(self.original_ip)
 これは、画像を元の画像にリセットするメソッドです。original_ip は、コンストラクタの中で作成しておいたバックアップ用の画像です。sliderの値に応じて変化していた画像のポインタに対し、元のバックアップ用画像を適用して元に戻します。


     def scale(self):
""" Execute the in-place scaling of the ImagePlus. """
          scale = self.slider.getValue() / 100.0
          new_width = int(self.original_ip.getWidth() * scale)
          new_ip = self.original_ip.resize(new_width)
          self.imp.setProcessor(new_ip)

 これは、画像をスライダーで動かした値に応じて画像をリサイズするメソッドです。プレビューと変化させた最終結果の両方に適用されます。スライダーから変化させる変数 scale を取得し、オリジナルのバックアップ画像を参照して新しい幅を計算し、オリジナルのバックアップ画像にその幅を適用した新しい画像 new_ip を作成します。最後に new_ipに、画像ポインタ imp を適用して終わります。

 

*1:なお、イベントリスナーを外す場合は remove * Listener になる。