作業環境
- Windows 10
- Visual Studio Code
- Python 3.11
始まり
こんな感じのスクロール可能な画像表示キャンバスを作ります。
スクロールバーによるスクロール対応
前提としてtkinterがスクロール対応を想定してくれているため、指定の関数をバインドさせるだけで対応できます。
1. Scrollbarのcommand
にCanvasのxview
, yview
をバインド
2. Canvasのxscrollcommand
, 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。。。トウリさん推しです。