ttkbootstrap スクロール可能な画像表示キャンバスを作ってみた

作業環境

始まり

こんな感じのスクロール可能な画像表示キャンバスを作ります。


スクロールバーによるスクロール対応

前提としてtkinterがスクロール対応を想定してくれているため、指定の関数をバインドさせるだけで対応できます。

1. ScrollbarのcommandCanvasxview, yviewをバインド
2. Canvasxscrollcommand, yscrollcommandにScrollbarのsetをバインド

やっていることはイベント発生時のSetterとGetterの相互バインドです。
関数説明もかなり親切ですね。
リファレンスに飛ばずにここだけで把握できる程度に書いてもらえるのは初心者には嬉しいですね。




class ScrollableCanvas(ttk.Canvas):
    """スクロール可能なキャンバス
    """
    def __init__(
        self,
        master:tk.Misc,
        *args,
        **kwargs,
    ) -> None:
        """コンストラクタ

        Args:
            master (tk.Misc): master
        """
        super().__init__(master, *args, **kwargs)

        self.xscrollbar = ttk.Scrollbar(master, orient=HORIZONTAL, command=self.xview)
        self.yscrollbar = ttk.Scrollbar(master, orient=VERTICAL,   command=self.yview)

        # キャンバス内の描画物をスクロールバーに連動させる
        self.configure(xscrollcommand=self.xscrollbar.set, yscrollcommand=self.yscrollbar.set)

マウスホイールによるスクロール対応

一般的なペイントツールではマウスホールとSHIFTキーで上下左右の移動ができます。
併せてそれも対応しちゃいます。

マウスホイールはユーザー側で実装する必要がありますが、これも同様にマウスホイールイベントが発生した際に、ホイールの移動量に応じてキャンバスをスクロールさせるだけです。

嬉しいことにキャンバスの移動量とスクロールバーの位置は先ほどの設定で同期されているため、キャンバスの位置を移動させるだけで実装は完了します。

event.deltaはホイールの移動量を指します。

        # bind events
        self.bind("<MouseWheel>",       self.on_mouse_wheel)
        self.bind("<Shift-MouseWheel>", self.on_shift_mouse_wheel)
    def on_mouse_wheel(self, event:tk.Event) -> None:
        """マウスホイール

        Args:
            event (tk.Event): イベントプロパティ
        """
        self.yview_scroll(-int(event.delta / abs(event.delta)), UNITS)

    def on_shift_mouse_wheel(self, event:tk.Event) -> None:
        """Shift押下&マウスホイール

        Args:
            event (tk.Event): イベントプロパティ
        """
        self.xview_scroll(-int(event.delta / abs(event.delta)), UNITS)

画像のセット

画像はPillowとOpenCVで読み込んだものをセットできるように対応しています。

詳細な説明はコードのコメント見てほしいのですが、基本的にはPillowがRGBA配置、OpenCVがBGRA配置、そのあたりをRGB配置に転置しています。

あとはImageTkがガベコレで消失するのでselfで所有権を明示的にしているぐらいですかね。この辺りは参照カウンタをイメージして頂ければと理解しやすいと思います。

    def set_pil_image(self, image:Image.Image) -> None:
        """PILで読み込んだ画像をセット

        Args:
            image (Image.Image): PILで読み込んだ画像
        """
        # to RGB
        if image.mode == "RGBA":
            image = image.convert("RGB")  # no need to copy
        elif image.mode == "L":
            image = image.convert("RGB")  # no need to copy

        # NOTE: この時点で self.image に突っ込むと直前の ImageTk.PhotoImage が消失して一瞬暗転する可能性ある。
        tmp_image = ImageTk.PhotoImage(image)

        # if:   画像を貼り付けるアイテムがない場合は新規作成
        # else: アイテムがある場合は新しい画像に切り替え
        if self.iid is None:
            self.iid = self.create_image(0, 0, anchor=NW, image=tmp_image)
        else:
            self.itemconfig(self.iid, image=tmp_image)

        # NOTE: アイテムにセットし終えてから直前の self.image を破棄する
        self.image = tmp_image

        # スクロール可能な範囲を画像サイズに合わせる
        self.configure(scrollregion=(0, 0, self.image.width(), self.image.height()))

    def set_cv2_image(self, image:npt.NDArray[np.uint8]) -> None:
        """OpenCVで読み込んだ画像をセット

        画像はGrayscaleないしBGR配置で渡されることを想定しています。

        Args:
            image (npt.NDArray[np.uint8]): OpenCVで読み込んだ画像
        """
        image = image.copy()

        if (shape_length:=len(image.shape)) == 2:
            c = 1
        elif shape_length == 3:
            _, _, c = image.shape
        else:
            return

        # delete alpha dim
        if c == 4:
            image = np.delete(image, 3, 2)
            _, _, c = image.shape

        # if:   BGR to RGB
        # elif: GRAY to RGB
        # else: not support format.
        if c == 3:
            image = image[:, :, ::-1]
        elif c == 1:
            image = np.repeat(image[:, :, np.newaxis], 3, 2)
        else:
            return

        # numpy to pil
        self.set_pil_image(Image.fromarray(image, "RGB"))

    def set_image(self, image:Optional[Image.Image | npt.NDArray[np.uint8]]) -> None:
        """画像をセット

        image に None をセットした場合は画像を削除します。

        Args:
            image (Optional[Image.Image  |  npt.NDArray[np.uint8]]): PILかOpenCVで読み込んだ画像
        """
        if isinstance(image, Image.Image):
            self.set_pil_image(image)
        elif isinstance(image, np.ndarray):
            self.set_cv2_image(image)
        elif image is None:
            self.remove_image()

動作サンプル

import tkinter as tk
import ttkbootstrap as ttk
from ttkbootstrap.constants import *

from PIL import Image
import numpy as np
import numpy.typing as npt

from reinlib.ttkbootstrap.rein_scrollable_canvas import ScrollableCanvas


class ViewFrame(ttk.Frame):
    def __init__(
        self,
        master:tk.Misc,
    ) -> None:
        super().__init__(master, relief=RAISED, padding=8)

        self.scrollable_canvas = ScrollableCanvas(self)
        self.scrollable_canvas.grid(column=0, row=0)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

    def set_image(self, image:Image.Image | npt.NDArray[np.uint8]) -> None:
        self.scrollable_canvas.set_image(image)

    def clear_image(self) -> None:
        self.scrollable_canvas.remove_image()


class BlogSample(ttk.Window):
    def __init__(self) -> None:
        super().__init__("Blog Sample", minsize=(640, 480))

        self.view_frame = ViewFrame(self)
        self.view_frame.grid(column=0, row=0, padx=8, pady=8, sticky=NSEW)

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

        self.view_frame.set_image(Image.open(r"画像パスをセットしてね"))


if __name__ == "__main__":
    app = BlogSample()
    app.mainloop()

完成!!!

実装は上記リポジトリに纏めてあります。依存するライブラリもtkinter, ttkbootstrap, numpy, Pillowと最小限にしてあります。サブモジュールの追加でもいいですし、コードコピペでも余裕で動作します。

おわり!!!

深層学習モデルのデータセット作成フローをPythonで内製化している関係で、このような汎用的なGUI機能を結構抱えています。見せられる程度にコード整理するのが意外と時間を要するので(主に気分が乗るのに)ちまちまと公開していきますわ。同じような機能を作るのは趣味の範囲や勉強というカテゴリならいいけど、効率重視する場合にはMITLicenseなものをコピペで済ませたい派です。

本文中に自分の好きなゲーム画像を挿入するだけで気分が爆上がりして筆が乗りやすいことが最近判明しました。自分のスイッチが入るパターンを見つけられると色々と作業しやすいので嬉しい発見です。我ながら気分屋という性格は把握していますが、ここまで火力に如実に出るとはちょっと流石に不便な気もしてきました。治す気はないのでこれからも付き合い続けますが。

セレオブの発売楽しみ~。みなt。。。トウリさん推しです。