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

戦前型旧型国電および鉄道と変褪色フィルム写真を中心とした写真補正編集の話題を扱います。写真補正技法への質問はコメント欄へどうぞ

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を使っているのは、結局genericDialogにNumericFieldを設置しても、イベントリスナーを効かせると、結局ただのJavaのTextFieldとして扱うしかないので、それだったら最初からJavaで... ということではないかと推測します。もちろんJpanel / Jframeの練習という意味もあると思いますが。

 

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

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

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の動きを感知したり、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メソッドは、まずparseメソッドの戻り値をvalueに取得し、うまく取得できれば、引数として取得したincという値をvalue値に加えた値で入力ボックスを更新します。inc引数は、本メソッドを呼び出すkeyReleasedメソッドやmouseWheelMovedメソッドに依存しており、要はカーソルキーの上向きが押されれば+1、下向きが押されれば-1を引数として取ったり、マウスを上向きに動かすと+、下向きに動かすと-の値を引数として取ります。つまり、上向き、下向きカーソルキーを何度か押したり、マウスを上、下に動かすことによって値を増やしたり減らしたりする動作を行うのです。

そして、プレビュー画像(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()というタプルに格納しているのだと思われます。

 

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はカーソルキーの押し下げイベントを受けて起動されます。上向きもしくは右向きカーソルキーが押されればupdateメソッドに引数+1で呼び出し、下向きもしくは左向きカーソルキーが押されれば 引数-1で呼び出します。要はカーソルキーを押すことで値を増やしたり減らしたりします。

 

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を呼び出すということは、マウスを上に動かせば値を増やし、下に動かせば値を増やす、ということを意味します。

 

次は、class TextFieldUpdaterです。このクラスは、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変数に取得します。但し、ROIが点でしかない場合(高さ+幅が0)は、ROIが原点(左上)にあるものとみなします。そしてROIの各値をテキストフィールドに書き込みます。

 

次の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にリスナーを設置するために使用)を初期化します。

# 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をテキストボックスのリスナーとして設置します。これをまとめてroimakersというリスト変数に格納します。さらに各テキストボックスにキーリスナーおよびマウスリスナーを設置します。


     # 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: 文を使って処理をした方が良かったものと思います。