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

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

ImageJ / Python: 対話型ユーザインターフェースのお勉強 (ROI設定ダイアログ)

 再び、MRC分子生物学研究所で公開している、ImageJの対話的ダイアログのサンプルプログラムの紹介です。今日は、ROI (注目領域、選択範囲)を対話的に設定するダイアログです(Create a UI: open a window to edit the position and dimensions of an ROI)。画像を読み込んでサンプルプログラムを走らせますと次のようになります。

f:id:yasuo_ssi:20210326220127j:plain

ROI設定ダイアログ

 ボックスから数値を入れてROIを設定しても良いし、画面から直接マウスを操作してROIを設定しても良い (その場合は座標数値が自動的に入力ボックスに反映される)というものです。

 まず、例によってこのプログラムの構成を見ていきます。

 

 まずメインですが、一番下の2行だけです。

 それ以外は4つに分かれていますが、まずダイアログを定義する、def specifyRoiUI、そしてクラスの定義が3つで、画面上のROI操作を担当する class RoiMaker 、そして、座標の入力フィールドを監視し更新を担当する class TextFieldUpdater、最後は、ユーザが誤って画像のウィンドウを閉じないように阻止する、class CloseControlとなっています。

 def specifyRoiUIはダイアログの定義を担当しますが、今まで紹介した2つのサンプルプログラムは、ImageJの genericDialog を使ってダイアログを定義していました。しかし今回のプログラムはgenericDialogではなく、JavaのJpanel / Jframeを使っています。Jpanelを使うとImageJのgenericDialogを使うよりより柔軟なダイアログボックスが作れるようです。

 とはいえ、今回は大したダイアログではありません。なぜわざわざ面倒なJpanel を使うのか疑問に思っていましたが、色々実験してその理由が分かりました。ImageJ の GenericDialog を使うと、その間、なぜか画像のプレビュー画面から直接マウスを使って ROI を動かすことができなくなるためでした。Jpanel / Jframe を使ってダイアログを作ると、ダイアログを表示させながら、画像のプレビュー画面を直接マウスでいじってROIを動かすことができるのです。

 テキストボックスへ数値を入力して一方向的にROIを動かすだけなら GenericDialog でもよいのですが、同時にROI側を動かして、テキストボックスの値を変えたいなら、Jpanel / Jframe を使う必要があることが分かりました。なお、この原因としてダイアログにRoiを引数として読み込むためこのようなことが起こるのかと、実験してみましたが、それは関係なく、Roiを引数としなくても、genericDialogを使ったダイアログを表示させるだけで、画像プレビュー上のROIをマウスで動かすことができなくなるのを確認しました*1

 

 では、具体的にコードの説明に入っていきます。

まずライブラリの呼び出しです。

from ij import IJ, ImagePlus, ImageListener
from ij.gui import RoiListener, Roi
from java.awt.event import KeyEvent, KeyAdapter, MouseWheelListener, WindowAdapter
from java.awt import Color, Rectangle, GridBagLayout, GridBagConstraints as GBC
from javax.swing import JFrame, JPanel, JLabel, JTextField, BorderFactory, JOptionPane

 

ImageJからは、画像が開かれたり閉じられたり更新されたときにイベントを感知する ImageListener およびROIの異動を感知するRoiListenerが呼び出されています。

また、Java.awtからは、キーやマウスの動きを感知する、KeyEvent, KeyAdapter, MouseWheelListenerが呼び出されています。

またダイアログボックス作成関連として、GUI関連のライブラリである Java.awtからColor, Rectangle, GridBagLayout, GridBagConstraintsが、やはり別のGUI関連ライブラリのjavax.swingからJFrame, JPanel, JLabel, JTextField, BorderFactory, JOptionPane が呼び出されています。

 

次は、ダイアログ上の入力ボックスの値の変動を感知し、それに対応してROIを書き換える役割を担う、class RoiMakerです。このクラスでは、入力ボックスの値の変動は、入力ボックス上でのキーやマウスのホイールの動きで感知します。

RoiMakerにはコンストラクタ、入力ボックスで入力された値を数値に変更する parse、入力ボックスの入力値に従ってROIを変更する update、キー入力があるたびに updateを呼び出す、keyReleased、マウスの動きがあるたびにやはりupdateを呼び出す、mouseWheelMovedのメソッドが定義されています。

 

class RoiMaker(KeyAdapter, MouseWheelListener):

ここで、キーの動きと、マウスの動きを感知するリスナーが継承されています。

def __init__(self, textfields, index):
     """ textfields: the list of 4 textfields for x,y,width and height.
     index: of the textfields list, to chose the specific textfield to listen to. """
     self.textfields = textfields
     self.index = index

コンストラクタでは、RoiMaker のtextfieldsとindexの2つの引数が定義されています。textfieldsは入力ボックスで、ボックスは複数あることからリスト変数(配列)が想定されています。indexは何番目の入力ボックス化を指定するインデックス番号です。

def parse(self):
     """ Read the text in textfields[index] and parse it as a number.
     When not a number, fail gracefully, print the error and paint the field red. """
     try:
          return int(self.textfields[self.index].getText())
     except:
          print "Can't parse integer from text: '%s'" % self.textfields[self.index].getText()
          self.textfields[self.index].setBackground(Color.red)

メソッド parseですが、要は、入力された文字が数字かどうかを判定するメソッドです。indexで指定された番号のテキストフィールドから入力された文字を呼び出し、それをint関数で整数化を図り、戻り値として返そうとします。しかし、失敗した場合(=つまり数字以外が入力された場合)は、「整数化できない」とエラーメッセージを表示し、入力ボックスを赤で表示します。ここで使われている % はPython書式化演算子です。

 

def update(self, inc):
     """ Set the rectangular ROI defined by the textfields values onto the active image. """
     value = self.parse()
     if value:
          self.textfields[self.index].setText(str(value + inc))
          imp = IJ.getImage()
          if imp:
               imp.setRoi(Roi(*[int(tf.getText()) for tf in self.textfields]))

 updateメソッドは、inc という引数を取っていますが、この値は、このメソッドを呼び出す、下に定義されているkeyReleasedメソッドやmouseWheelMovedメソッドに依存しており、要はカーソルキーの上向きが押されれば+1、下向きが押されれば-1を引数として取ったり、マウスを上向きに動かすと+、下向きに動かすと-の値を引数として取ります。

 そして、parseメソッドの戻り値をvalueに取得し、うまく取得できれば、引数として取得したincという値をvalue値に加えた値で入力ボックスを更新します。この結果、上向き、下向きカーソルキーを何度か押したり、マウスを上、下に動かすことによって入力ボックスの値を増やしたり減らしたりする動作を行うのです。

 そして、プレビュー画像(imp: imagePlusオブジェクト)がIJ.getImage()で取得可能であれば(つまりプレビュー画像が表示されている状態であれば)、入力ボックスの値に基づいてROIを更新します。なお setRoiメソッドの書式は、setRoi(int x, int y, int rwidth, int rheight)となっており、self.textfields[0]~[3]の値が整数化され順番に x, y, roi幅, roi高さ として読み込まれます。この時、*[int(tf.getText()) for tf in self.textfields] という式が使われていますが、これはtextfieldsの各要素をアンパックするという意味で、それをRoi()というタプルに格納しているのだと思われます。この結果Roi(0) が x 座標、Roi(1) が y 座標、Roi(2) が Roi幅、Roi(3) が Roi高さになります。この値に基づいてROIの形が更新されます。

 

def keyReleased(self, event):
     """ If an arrow key is pressed, increase/decrese by 1.
     If text is entered, parse it as a number or fail gracefully. """
     self.textfields[self.index].setBackground(Color.white)
     code = event.getKeyCode()
     if KeyEvent.VK_UP == code or KeyEvent.VK_RIGHT == code:
          self.update(1)
     elif KeyEvent.VK_DOWN == code or KeyEvent.VK_LEFT == code:
          self.update(-1)
     else:
          self.update(0)

 keyReleasedはカーソルキーの押し下げイベントを受けて起動されます。このメソッド名は、keyReleased というあらかじめ定義されたイベントに対応するものですので、任意に決めることはできません。上向きもしくは右向きカーソルキーが押されればupdateメソッドを引数+1で呼び出し、下向きもしくは左向きカーソルキーが押されれば 引数-1で呼び出します。要はカーソルキーを押すことで、updateメソッドを呼び出して、値を増やしたり減らしたりします。

 

def mouseWheelMoved(self, event):
     """ Increase/decrese value by 1 according to the direction
     of the mouse wheel rotation. """
     self.update(- event.getWheelRotation())

 mouseWheelMovedメソッドの定義は実質1行で、getWheelRotation()で得られた値 (つまりマウスホイールを動かした値)にマイナスをつけてupdateメソッドを呼び出します。getWheelRotaionはマウスを上に動かすとマイナス、下に動かすとプラスの値を取得できますので、それにマイナスをつけてupdateを呼び出すということは、マウスを上に動かせば値を増やし、下に動かせば値を増やす、ということを意味します。

 ここで、ある程度 ImageJ のプログラミングに慣れた方なら、次のような疑問が浮かばないでしょうか。なぜ TextListener を使わないのか、と。テキストボックスの数値の変化に応じて ROI を書き換えるなら、TextListener を使ってテキストボックスを監視し、それに応じて ROI を書き換えさせる方が自然です。

 しかし、それができない理由があります。TextListener をテキストボックスに設置すると、その入力値に応じて ROI が変更されます。ところが、ROI にも RoiListener が設置され、それでテキストボックスの内容を書き換えるので、ROI が変更されると、テキストボックスも書き換えられます。するとそれを感知して、再び ROI を書き換える... という具合に無限ループに陥ってしまいます。

 KeyAdapter, MouseWheelListener で、キーとマウスの動きを感知して入力数値を計算して ROI を動かすならば、ROI の変更で再びテキストボックスが書き換えられますが、テキストボックス自体にはリスナーが設置されていないので、そこで動作が止まり、無限ループに陥りません。

 ただ、マウスとカーソルキーで数値を入力しないと ROI が変更されないというのは、それはそれで面倒です。TextListener を使いたい場合は、TextListener でイベントを感知したら、テキストボックスの値を一時変数に格納し、さらにダイアログ上に[ROI更新]ボタンを設置して、そのボタンを押したときのみ、一時変数に格納された値に基づいて ROI を更新するようにすれば、TextListener を使っても無限ループに陥らないで済むと思います。

 

 次は、class TextFieldUpdaterです。このクラスは、ROIをマウス等で動かすと、それに応じてた入力ボックスの値を書き換えるという役割を担います。このクラスは、RoiListenerを引数として取ります。

class TextFieldUpdater(RoiListener):
     def __init__(self, textfields):
     self.textfields = textfields

コンストラクタでは、更新する対象となるtextfieldsを引数として定義しています。

def roiModified(self, imp, ID):
     """ When the ROI of the active image changes, update the textfield values. """
     if imp != IJ.getImage():
          return # ignore if it's not the active image
     roi = imp.getRoi()
     if not roi or Roi.RECTANGLE != roi.getType():
          return # none, or not a rectangle ROI
     bounds = roi.getBounds()
     if 0 == roi.getBounds().width + roi.getBounds().height:
          bounds = Rectangle(0, 0, imp.getWidth(), imp.getHeight())
     self.textfields[0].setText(str(bounds.x))
     self.textfields[1].setText(str(bounds.y))
     self.textfields[2].setText(str(bounds.width))
     self.textfields[3].setText(str(bounds.height))

しかしroiModifiedメソッドでは独自の引数としてimpとIDを取ります。このIDという引数はおそらく、RoiListenerのイベントIDではないかと思われます(Javaのイベントリスナーではないので event ではないのでしょうか)。つまり、imp上のROIに何か変化があったときにroiModifiedが起動するものと思われます。

 ただしROIが存在しないか四角(矩形)でなければreturnで戻って何もしません。存在すればroi.getBoundsメソッドで、ROIのデータ(x, y, 幅, 高さ)をbounds変数に取得します。この Bounds変数は辞書型のようです。但し、ROIが点でしかない場合(高さ+幅が0)は、ROIが原点(左上)にあるものとみなし、import文で読み込んでおいた、Java.awt.Rectangle クラスを使って( bounds = Rectangle(0, 0, imp.getWidth(), imp.getHeight()) )、画像の全面を対象としたboundsを作成します。そしてそのbounds 値の各値をテキストフィールドに書き込みます。

 

次のclass CloseControlはウィンドウをユーザが誤って閉じるのを防ぐためのオブジェクトです。

class CloseControl(WindowAdapter):

でウィンドウの動きを監視する、WindowAdapterインターフェースを継承します。

def __init__(self, roilistener):
     self.roilistener = roilistener

コンストラクタでは、roilistenerを引数として取ることを定義します。

def windowClosing(self, event):
     answer = JOptionPane.showConfirmDialog(event.getSource(),
          "Are you sure you want to close?",
          "Confirm closing",
     JOptionPane.YES_NO_OPTION)
     if JOptionPane.NO_OPTION == answer:
          event.consume() # Prevent closing
     else:
          Roi.removeRoiListener(self.roilistener)
          event.getSource().dispose() # close the JFrame

windowClosingメソッドでは、WindowsAdapterからeventを受け取ると、JOptionPaneを使って、本当にウィンドウを閉じてよいか確認ダイアログを出します。Noであれば、event.consume() でイベントをなくし、ウィンドウを閉じるのを阻止します。Yesであれば、RoiListernerを停止し、さらにイベントの発生元をdisposeすることで、Jframe全体を閉じます。

 

さて、次はいよいよダイアログの定義です。specifyRoiUIで定義しています。

def specifyRoiUI(roi=Roi(0, 0, 0, 0)):

引数に、roiを取っていますが、imp上にroiが指定されていない場合を考えて、デフォルト値としてRoi(0,0,0,0)を指定しています。

# A panel in which to place UI elements
panel = JPanel()
panel.setBorder(BorderFactory.createEmptyBorder(10,10,10,10))
gb = GridBagLayout()
panel.setLayout(gb)
gc = GBC()

前にも説明しましたが、ダイアログにJavaのJpanel, JFrameを使っています。

ダイアログの境界線を指定し、さらにパネルに対し、GridBagLayoutを適用しています。これはパネル上にグリッドを配置し、その上にグリッドを座標にして部品を配置していく方法です。GBCは、GridBagConstraintsの略ですが、GridBagConstraintsはグリッド上にどのように部品を配置するかを指定するライブラリです。例えばグリッドに左寄せにするのか、右寄せにするのか、中央に位置付けるのか、といったことを指定します。


bounds = roi.getBounds() if roi else Rectangle()
textfields =
roimakers =

さらに、roiの座標値をboundsに格納し、textfields変数(後にテキストフィールド= パラメータ入力ボックスを格納)とroimakers変数(後に、ROIにリスナーを設置するために使用)を初期化します。

なお、ここで、bounds = roi.getBounds() に引き続き  if roi else Rectangle() 使われています。なぜ if 文がこのように変に使われているのか、と思われるかもしれませんが、これは三項演算子です。書式は下記の通りで、

条件式が真のときに返す値 if 条件式 else 条件式が偽のときに返す値

roi が存在しなければ矩形を使います。

# Basic properties of most UI elements, will edit when needed
gc.gridx = 0 # can be any natural number
gc.gridy = 0 # idem.
gc.gridwidth = 1 # when e.g. 2, the UI element will occupy
# two horizontally adjacent grid cells
gc.gridheight = 1 # same but vertically
gc.fill = GBC.NONE # can also be BOTH, VERTICAL and HORIZONTAL

まず、グリッドの x=0, y=0においてグリッドにおける部品配置の初期設定を適用しておきます。部品のデフォルトの大きさは グリッドの1 x 1、またグリッドの埋め方は一旦 NONEを指定しておきます。

 

ところで、ダイアログに置く部品は、ROIの x, y, 幅, 高さを指定する4つのテキストボックスです。ここからこの4つのテキストボックスについて定義していきます。

for title in ["x", "y", "width", "height"]:

まず4つのテキストボックスへのコメント("x", "y", "width", "height")をtitle変数に入れ、それ毎に for ループで操作を行います。

 
     # label
     gc.gridx = 0
     gc.anchor = GBC.EAST
     label = JLabel(title + ": ")
     gb.setConstraints(label, gc) # copies the given constraints 'gc',

まず、グリッドの一番左端(gc.gridx = 0)に右寄せで(gc.anchor = GBC.EAST)、title変数をラベルとして位置づけます。


     # so we can modify and reuse gc later.
     panel.add(label)

位置づけたラベルをパネルに貼り付けます。


     # text field, below the title
     gc.gridx = 1
     gc.anchor = GBC.WEST
     text = str(getattr(bounds, title)) # same as e.g. bounds.x, bounds.width, ...
     textfield = JTextField(text, 10) # 10 is the size of the field, in digits
     gb.setConstraints(textfield, gc)
     panel.add(textfield)
     textfields.append(textfield) # collect all 4 created textfields for the listeners

次にラベルを貼り付けたグリッドの右隣のグリッド(gc.gridx = 1)に左寄せで(gc.anchor = GBC.WEST)、テキストボックス(textfield)を位置づけます。その際テキストボックスに表示する初期値はプレビュー画面に既にROIがあれば、その値を採用します。なければ0です。テキストボックスのサイズは10桁分です。それをパネルに貼り付けます。それが終わったら一つ一つのtextfieldをtextfieldsのリスト変数(配列)の要素として加えます。これは後でまとめてリスナーで監視させるためです。

 

     # setup ROI and text field listeners
     # (second argument is the index of textfield in the list of textfields)
     listener = RoiMaker(textfields, len(textfields) -1)
     roimakers.append(listener)
     textfield.addKeyListener(listener)
     textfield.addMouseWheelListener(listener)

ここからはROIとテキストボックス(textfield)へのリスナーの設置です。RoiMakerをテキストボックスのリスナーとして設置します。ただちょっと分からないのが、RoiMakerをインスタンス化した listener を roimekers という配列に入れているようなのですが、そのあと、 textfield.addKeyListener(listener)、textfield.addMouseWheelListener(listener)でリスナーを設置しているのに、なぜこれが必要なのかがちょっとよく分かりません。さらに各テキストボックスにキーリスナーおよびマウスリスナーを設置します。で


     # Position next ROI property in a new row
     # by increasing the Y coordinate of the layout grid
     gc.gridy += 1

最後に、部品(テキストボックス)のグリッド上の y 位置を定めますが、テキストボックスが増えるごとに y 位置もひとつづつ増加(下に下がる)します。

 

以上の操作を4つのテキストボックスについてループで繰り返し、設置を終了します。

 

# User documentation (uses HTML to define line breaks)
doc = JLabel("<br>Click on a field to activate it, then:<br>"
+ "Type in integer numbers<br>"
+ "or use arrow keys to increase by 1<br>"
+ "or use the scroll wheel on a field.")
gc.gridx = 0 # start at the first column
gc.gridwidth = 2 # spans both columns
gb.setConstraints(doc, gc)
panel.add(doc)

forループが終了したら、ユーザへの指示事項をパネルに設置します。一旦 doc 変数にラベルを格納し、docをdcで指定した位置で、パネルに配置します。設置するグリッドの位置は x が 0 (左端)、幅はグリッド2つ分です。(y 位置は自動で決まる?)


# Listen to changes in the ROI of imp
roilistener = TextFieldUpdater(textfields)
Roi.addRoiListener(roilistener)

更にROIにテキストボックスの入力状況を反映するようROIリスナーをセットアップします。

 

# Show panel in a closable window
frame = JFrame("Specify rectangular ROI")
frame.getContentPane().add(panel)
frame.pack() # make UI elements render to their preferred dimensions
frame.setLocationRelativeTo(None) # center in the screen

更に以上セットアップしてきたパネルの内容を、実際にJFrameの中に位置付け、表示できるようにします。JFrameのコンテントペインを取得し、そこにパネルを貼り付けます。さらにpackメソッドで配置を最適化します。そしてJFrameをスクリーンの中央に表示させるように指示します。

 

# Prevent closing the window directly from clicking on the 'x' window icon
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE)
frame.addWindowListener(CloseControl(roilistener)) # handles closing the window
frame.setVisible(True)

さらに、誤ってダイアログウィンドウをユーザが閉じないよう確認メッセージを出すCloseConrolを呼び出すウィンドウリスナーをJFrameに設置します。

最後にJFrameを表示させて終了します。

 

一番最後にメインプログラムが来ます。

# Launch: open the window
imp = IJ.getImage()
specifyRoiUI(roi=imp.getRoi() if imp else None)

現在開いている画像を取得してimpに代入します。そして引数をつけてspecifyRoiUIを起動します。その際引数に三項演算子が使われています。つまりimpが存在すれば、getRoiメソッドを使ってROIを取得し、roiに代入したものを引数とします。存在しなければ、Noneを引数とします。

ただ、このプログラムを実際に実行すると、実行時に開かれた画像がなければ、imp = IJ.getImage()でエラーになってしまいます。おそらく三項演算子を使った引数でspecifyRoiUIを呼び出すよりも try: - except: 文を使って処理をした方が良かったものと思います。

 

*1:以上、genericDialogとROIの関係については2022.8追加記述。