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

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

GIMP3 / Python サンプルプログラム: 対話型 (インターアクティブ) スライダー

 GIMP3 の Python UI プログラミング サンプルファイルの第3弾です。今回はスライダーですが、単なるスライダーではありません。インターアクティブ (対話型、相互作用型) に動くスライダーで、ちょっと高度です。

 インターアクティブとは、UI を動かすとそのたびに画面が書き換わったり、UI の他のウィジェット (部品) の値が変わったりすることです。

 このプラグインをインストールすると以下から起動できます。

メニュー位置

 起動すると以下のダイアログが表示されます。

ダイアログ

 このスライダーはトータルで 100 になるように調整されていて、あるスライダーを動かすと他のスライダーが、それに応じて動きます。これがインターアクティブということです。なお、このプログラムはあくまで UI のサンプルなので、UI 要素は動きますが、スライダーを動かしても画像の操作は行いません。

スライダーを動かしてみたところ

 このサンプルプログラムのダウンロードはこちらから。

 

 次にプログラムの解説です。まずスライダーですが Gtk.Scale というウィジェット (部品) を使います。

https://lazka.github.io/pgi-docs/#Gtk-4.0/classes/Scale.html

 

■ UI 上のスライダーの定義

スライダー付加部分です。

--------

        #---add scale1 (slider) for Factor A---
        box_4_scale1 = \
            Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        box.add(box_4_scale1)
        label = Gtk.Label(label="Factor A")
        label.set_xalign(0)
        box_4_scale1.pack_start(label, False, False, 0)

        self.scale1 = \
            Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.0, 100.0, 0.1)
        #
        self.A = 33.3  # Default value
        self.scale1.set_value(self.A)
        self.scale1.set_digits(1)
        self.scale1.connect("value-changed", self.on_scale_value_changed, 1)
        box_4_scale1.pack_start(self.scale1, True, True, 0) # not use fixed

--------

box_4_scale1 というボックスの上にラベルとスライダーを載せています。

スライダーの定義自体は、

 self.scale1 = \
            Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.0, 100.0, 0.1)

という文で行っています。Gtk.Scale オブジェクトの new_with_range メソッドで、スライダーの範囲を規定して定義しています。推測がつくと思いますが、最初の引数でスライダーの方向 (水平) を定義し、次に最小値、最大値、そしてスライダーの刻みの大きさを定義し、それをself.scale1 という名前で作成しています。

 このスライダーのデフォルト値は、一旦 self.A で値 (33.3) を設定しこの self.A を self.scale1 に対し、set_value メソッドで読み込ませることで設定しています。set_digits(1) は値の小数点以下の表示桁数の設定です。

 次に、

        self.scale1.connect("value-changed", self.on_scale_value_changed, 1)

では、value-changed というイベントが発生したときに、つまり、スライダーが動かされたときに、self.on_scale_value_changed という関数を引数 1 で呼び出します。

 最後に定義したスライダーを box 上に展開しますが、これは

  box_4_scale1.pack_start(self.scale1, True, True, 0)

で定義されています。この時に、決して

  pack_start(self.scale1, False, False, 0)

にしてはいけません。ここは、True, True にしないとちゃんとスライダーが展開されません。

 以上が UI 上のスライダーの定義です。

 

■スライダーを動かしている間は次の動作を実行させないためには?

 ところで、このようなスライダーを動かして、それに応じてインターアクションを定義する際に、大きな問題として、まだスライダーを動かしている途中なのに、少し動かしただけでそのたびに画面が書き換わったりすると、大量の処理が発生し、スライダーの動作が重くなってしまいます。これを避けるには、まだスライダーを動かしている途中なのか、設定が終わって停止した状態なのかを区別する必要があります。これはどうしたら良いのでしょうか?

 ImageJ のプログラミングでは、event.getValueIsAdjusting() というイベントがまだ動作中・調整中かどうかということを検出するメソッドがありましたが (但し、Mac OS ではなぜか正常に動作せず)、GIMP ではないようです。そこで、スライダーが動いたときに、それに対応する動作を行う命令を一定の遅延 (ディレイ) をかけて発し、ディレイを掛けている間にスライダーが再度動けば、動作を破棄し、ディレイの間スライダーが停止したままであれば、動作を実際に実施するという仕組みを取っています。

 

 まず、スライダーの動作が検出されたときに実行する self.on_scale_value_changed 関数ですがこのようになっています。

 

    def on_scale_value_changed(self, scale, data):
        # Trigger the update when the scale value changes, but debounce updates.
        # data is used to identify the trigger scale.
        if self.auto == False:  # execute command below only when 
                                # sliders are manually moved.
            Gimp.message("changed data: " + str(data))
            # Cancel any existing pending update
            if self.execution_timer_id:
                GLib.source_remove(self.execution_timer_id)
            # Set a new timer (e.g., 100ms delay)
            self.execution_timer_id = \
                GLib.timeout_add(100, self._delayed_update, scale, data)

 

 ここで、self.execution_timer_id が実施を遅らせるオブジェクトです。これは、Glib (Gtk Low level core library) の timeout_add メソッドで作成していますが、一定の間隔で、別の関数やメソッドを呼び出すトリガーを引くという役割を持つオブジェクトです。

            self.execution_timer_id = \
                GLib.timeout_add(100, self._delayed_update, scale, data)

というコマンドで作成していますが、これは、100ミリ秒の間隔で self._delayed_update を呼び出しています。その後の scale と data は self._delayed_update に引き渡す引数です。scale には self.scale1 から呼び出された場合は、self.scale1 が入ります。その後のデータはどのスライダーから呼び出されたのかを識別するためのデータです。

 ところでこの命令文の前に、

            if self.execution_timer_id:
                GLib.source_remove(self.execution_timer_id)

という命令文がありますが、この関数が呼び出された際に既に、self.execution_timer_id が存在したらそれを削除しろということです。そして再度 self.execution_timer_id しなおします。

 これはどういうことかというと、今まだスライダーを動かしている途中だとします。しかしスライダーを動かしていると、まだ停止していないのに、動かしている途中で on_scale_value_changed が頻繁に呼び出されてしまうことになります。

 その場合、on_scale_value_changed が呼び出されたすぐ直後に再び on_scale_value_changed が呼び出され、またその直後に on_scale_value_changed が呼び出されるということが連続します。

 その時、100ms 以内に再び呼び出されたら、前に呼び出した on_scale_value_changed が作成した self.execution_timer_id を一旦破棄して作り直します。これが連続される限りは次の動作が実行されないということです。逆に、100ms 以降に呼び出されれば、次の動作を実行してしまうということです。

 いずれにしろこの動作の遅延を入れることで、スライダーを動かしている間は次の動作を実行しない、ということになります。この遅延時間は場合によってはもっと大きく取ったほうが良いかもしれません。

 

■他のスライダーを動かす

 すでに述べたようにこのプログラムはあるスライダーを動かすと、それに応じて他のスライダーを動かします。この動作は一旦上で呼び出された self._delayed_update から呼び出される、self.update_Scales によって行われます。なお、self._delayed_update は、self.update_Scales を呼び出したら、用なしとなった self.execution_timer_id を消去します。そうしないと、100ms 間隔で再び self.update_Scales を呼び出してしまいますので。

 そして、self.update_Scales のコードは以下の通りです。

----------

    def update_Scales(self, scale, data):
        if data == 1: # scale1 value changed
            orgNonA = 100 - self.A
            self.A = scale.get_value()
            newNonA = 100 - self.A
            if orgNonA == newNonA:
                return
            orgnewRatio = newNonA / orgNonA
            self.B = self.B * orgnewRatio
            self.C = self.C * orgnewRatio
            if self.auto == False:  # execute command below only when 
                                    # sliders are manually moved.
                self.auto = True    # set flag auto slider moving
                self.scale2.set_value(self.B)
                self.scale3.set_value(self.C)
                self.auto = False   # remove flag auto slider moving

        elif data == 2: # scale2 value changed
            orgNonB = 100 - self.B
            self.B = scale.get_value()
            newNonB = 100 - self.B
            if orgNonB == newNonB:
                return
            orgnewRatio = newNonB / orgNonB
            self.A = self.A * orgnewRatio
            self.C = self.C * orgnewRatio
            if self.auto == False:  # execute command below only when 
                                    # sliders are manually moved.
                self.auto = True    # set flag auto slider moving
                self.scale1.set_value(self.A)
                self.scale3.set_value(self.C)
                self.auto = False   # remove flag auto slider moving

        elif data == 3: # scale3 value changed
            orgNonC = 100 - self.C
            self.C = scale.get_value()
            newNonC = 100 - self.C
            if orgNonC == newNonC:
                return
            orgnewRatio = newNonC / orgNonC
            self.A = self.A * orgnewRatio
            self.B = self.B * orgnewRatio
            if self.auto == False:  # execute command below only when 
                                    # sliders are manually moved.
                self.auto = True    # set flag auto slider moving
                self.scale1.set_value(self.A)
                self.scale2.set_value(self.B)
                self.auto = False   # remove flag auto slider moving

----------

 ここで、if data == 1: とか  elif data == 3: で区切られていますが、これはどのスライダーを動かしたことによって呼び出されたのかを区別しています。それは例えば 1 番目のスライダーを動かしたら、2, 3 番目のスライダーを動かしますし、2番目のスライダーを動かしたら、1, 3番目のスライダーを動かしますので、区別が必要です。

 ここでは、例示として、1番目のスライダーを動かした部分に着目します。2番目、3番目のスライダーも原理は同じです。self.A, self.B, self.C はそれぞれ、1-3 番目のスライダーの現在値が入っています。1番目のスライダーを動かしたとき、self.A には動かす直前の値が入っていますが、それを 100 から引いて、orgNonAに 第1スライダーを動かす直前の、第2,3スライダーの値の合算値を保管します。次に get_value で動かした後の第1スライダーの値を self.A に入れます。そして動かした後の newNonA に、第1スライダーを動かした後の、第2,3スライダーの値の合算値を保管します。ただし、この時点では当然まだ第2,3スライダーは動いていません。

 次のこの値に基づいて第2,3スライダーを動かします。ただし、orgNonA と newNonA が同じ値なら動かす必要はないのでなにもせず return で戻ります。

 次に、orgNonA と newNonA の比を計算し、その比率を self.B, self.C に掛けて、それに基づいて、第2、第3スライダーの値を動かします。

 基本的には以上の原理で、第1スライダーを動かすと、第2,3スライダーがそれに応じて動くということが実現できます。

 なお、ポイントとして、他のスライダーの動きから、あるスライダーを動かすには、スライダーを定義するときに、スライダーの定義自体に、

 self.scale1 = \
            Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.0, 100.0, 0.1)

というように、頭に、self. をつけることです。これは、スライダーを定義している関数以外の関数から、そのスライダーを呼び出して操作するときに必要です。もし、インターアクティブにスライダーを動かす必要がなければ self. をつけず、完全にその関数内のプライベートなオブジェクトとして定義しても構いません (例えば label のように)。

 

■ 手で動かしたスライダーの動きと自動で動かされた動きを区別する

 但し、まだ大きな問題があります。それは、これで第2、第3スライダーが動いたとして、この動きに応じて、また on_scale_value_changed が呼び出され、再びスライダーが動かされ、さらにそれでまたまた on_scale_value_changed が呼び出され... と無限の連鎖が起きてしまいます。どこかで収束すれば停まりますが、それまでスライダーがいつまでも動きまくるということが起こります。

 ということはスライダーが手で動かされたのか、それとも他のスライダーが動いたことにより自動的に動かされたのかを区別し、手動で動かされたときのみ、on_scale_value_changed を呼び出すということをする必要があるということです。このために self.auto という変数を導入しています。self.auto == True なら自動的に動かされたもの False なら手動という区分です。 デフォルト値は False なので、最初にスライダーが動いたときは、手動と判断されます。

 そこで、上の、第2,3スライダーの更新を self.auto == False の場合のみ (つまり手動で動かした場合のみ) 行います。その時、 on_scale_value_changed を呼出し後、実際にスライダーを動かす直前に、self.auto = True にして、第2、第3スライダーの値を設定すると、それにより第2、第3スライダーが動いてもその直後に、on_scale_value_changed が再度呼び出されることがありません。

 そして、第2,3スライダーの変更が終わった後に再び self.auto = False に設定し、次の手動でのスライダー変更に備えます。

 

スライダー変更のアルゴリズム

 

 このようにして、対話型のスライダー UI を設定することができます。GIMP3 になって UI の設定が難しくなってしまいましたが、一方でかなり高度な UI を書くこともできるようになりました。どうせなら毒を食らわば皿まで、ということでやるならとことんやってみましょう。