Canvasに画像を表示する のページではtkinterでGUIを作り画像ビューアを作りましたが、これに アフィン変換 を追加し、画像の拡大/縮小、移動の出来る画像ビューアを作成しました。
機能は、Fileメニューから画像ファイルを開き、マウスホイールの上下で画像の拡大/縮小を行い、マウスの左ボタンのドラッグで画像を移動します。
左ボタンのダブルクリックで画像全体を表示します。
また、ウィンドウ下にはCanvas上のマウスポインタの座標と、マウスポインタ位置の画像の座標および、その輝度値を表示します。
ウィンドウの右下には画像ファイルの種類、画像サイズ、画像の種類を表示します。
全ソースコード
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import tkinter as tk # ウィンドウ作成用 | |
from tkinter import filedialog # ファイルを開くダイアログ用 | |
from PIL import Image, ImageTk # 画像データ用 | |
import numpy as np # アフィン変換行列演算用 | |
import os # ディレクトリ操作用 | |
class Application(tk.Frame): | |
def __init__(self, master=None): | |
super().__init__(master) | |
self.pack() | |
self.pil_image = None # 表示する画像データ | |
self.my_title = "Image Viewer" # タイトル | |
self.back_color = "#008B8B" # 背景色 | |
# ウィンドウの設定 | |
self.master.title(self.my_title) # タイトル | |
self.master.geometry("500x400") # サイズ | |
self.create_menu() # メニューの作成 | |
self.create_widget() # ウィジェットの作成 | |
def menu_open_clicked(self, event=None): | |
# ファイル→開く | |
filename = tk.filedialog.askopenfilename( | |
filetypes = [("Image file", ".bmp .png .jpg .tif"), ("Bitmap", ".bmp"), ("PNG", ".png"), ("JPEG", ".jpg"), ("Tiff", ".tif") ], # ファイルフィルタ | |
initialdir = os.getcwd() # カレントディレクトリ | |
) | |
# 画像ファイルを設定する | |
self.set_image(filename) | |
def menu_quit_clicked(self): | |
# ウィンドウを閉じる | |
self.master.destroy() | |
# create_menuメソッドを定義 | |
def create_menu(self): | |
self.menu_bar = tk.Menu(self) # Menuクラスからmenu_barインスタンスを生成 | |
self.file_menu = tk.Menu(self.menu_bar, tearoff = tk.OFF) | |
self.menu_bar.add_cascade(label="File", menu=self.file_menu) | |
self.file_menu.add_command(label="Open", command = self.menu_open_clicked, accelerator="Ctrl+O") | |
self.file_menu.add_separator() # セパレーターを追加 | |
self.file_menu.add_command(label="Exit", command = self.menu_quit_clicked) | |
self.menu_bar.bind_all("<Control-o>", self.menu_open_clicked) # ファイルを開くのショートカット(Ctrol-Oボタン) | |
self.master.config(menu=self.menu_bar) # メニューバーの配置 | |
def create_widget(self): | |
'''ウィジェットの作成''' | |
# ステータスバー相当(親に追加) | |
self.statusbar = tk.Frame(self.master) | |
self.mouse_position = tk.Label(self.statusbar, relief = tk.SUNKEN, text="mouse position") # マウスの座標 | |
self.image_position = tk.Label(self.statusbar, relief = tk.SUNKEN, text="image position") # 画像の座標 | |
self.label_space = tk.Label(self.statusbar, relief = tk.SUNKEN) # 隙間を埋めるだけ | |
self.image_info = tk.Label(self.statusbar, relief = tk.SUNKEN, text="image info") # 画像情報 | |
self.mouse_position.pack(side=tk.LEFT) | |
self.image_position.pack(side=tk.LEFT) | |
self.label_space.pack(side=tk.LEFT, expand=True, fill=tk.X) | |
self.image_info.pack(side=tk.RIGHT) | |
self.statusbar.pack(side=tk.BOTTOM, fill=tk.X) | |
# Canvas | |
self.canvas = tk.Canvas(self.master, background= self.back_color) | |
self.canvas.pack(expand=True, fill=tk.BOTH) # この両方でDock.Fillと同じ | |
# マウスイベント | |
self.master.bind("<Motion>", self.mouse_move) # MouseMove | |
self.master.bind("<B1-Motion>", self.mouse_move_left) # MouseMove(左ボタンを押しながら移動) | |
self.master.bind("<Button-1>", self.mouse_down_left) # MouseDown(左ボタン) | |
self.master.bind("<Double-Button-1>", self.mouse_double_click_left) # MouseDoubleClick(左ボタン) | |
self.master.bind("<MouseWheel>", self.mouse_wheel) # MouseWheel | |
def set_image(self, filename): | |
''' 画像ファイルを開く ''' | |
if not filename: | |
return | |
# PIL.Imageで開く | |
self.pil_image = Image.open(filename) | |
# 画像全体に表示するようにアフィン変換行列を設定 | |
self.zoom_fit(self.pil_image.width, self.pil_image.height) | |
# 画像の表示 | |
self.draw_image(self.pil_image) | |
# ウィンドウタイトルのファイル名を設定 | |
self.master.title(self.my_title + " - " + os.path.basename(filename)) | |
# ステータスバーに画像情報を表示する | |
self.image_info["text"] = f"{self.pil_image.format} : {self.pil_image.width} x {self.pil_image.height} {self.pil_image.mode}" | |
# カレントディレクトリの設定 | |
os.chdir(os.path.dirname(filename)) | |
# ------------------------------------------------------------------------------- | |
# マウスイベント | |
# ------------------------------------------------------------------------------- | |
def mouse_move(self, event): | |
''' マウスの移動時 ''' | |
# マウス座標 | |
self.mouse_position["text"] = f"mouse(x, y) = ({event.x: 4d}, {event.y: 4d})" | |
if self.pil_image == None: | |
return | |
# 画像座標 | |
mouse_posi = np.array([event.x, event.y, 1]) # マウス座標(numpyのベクトル) | |
mat_inv = np.linalg.inv(self.mat_affine) # 逆行列(画像→Cancasの変換からCanvas→画像の変換へ) | |
image_posi = np.dot(mat_inv, mouse_posi) # 座標のアフィン変換 | |
x = int(np.floor(image_posi[0])) | |
y = int(np.floor(image_posi[1])) | |
if x >= 0 and x < self.pil_image.width and y >= 0 and y < self.pil_image.height: | |
# 輝度値の取得 | |
value = self.pil_image.getpixel((x, y)) | |
self.image_position["text"] = f"image({x: 4d}, {y: 4d}) = {value}" | |
else: | |
self.image_position["text"] = "-------------------------" | |
def mouse_move_left(self, event): | |
''' マウスの左ボタンをドラッグ ''' | |
if self.pil_image == None: | |
return | |
self.translate(event.x - self.__old_event.x, event.y - self.__old_event.y) | |
self.redraw_image() # 再描画 | |
self.__old_event = event | |
def mouse_down_left(self, event): | |
''' マウスの左ボタンを押した ''' | |
self.__old_event = event | |
def mouse_double_click_left(self, event): | |
''' マウスの左ボタンをダブルクリック ''' | |
if self.pil_image == None: | |
return | |
self.zoom_fit(self.pil_image.width, self.pil_image.height) | |
self.redraw_image() # 再描画 | |
def mouse_wheel(self, event): | |
''' マウスホイールを回した ''' | |
if self.pil_image == None: | |
return | |
if (event.delta < 0): | |
# 上に回転の場合、縮小 | |
self.scale_at(0.8, event.x, event.y) | |
else: | |
# 下に回転の場合、拡大 | |
self.scale_at(1.25, event.x, event.y) | |
self.redraw_image() # 再描画 | |
# ------------------------------------------------------------------------------- | |
# 画像表示用アフィン変換 | |
# ------------------------------------------------------------------------------- | |
def reset_transform(self): | |
'''アフィン変換を初期化(スケール1、移動なし)に戻す''' | |
self.mat_affine = np.eye(3) # 3x3の単位行列 | |
def translate(self, offset_x, offset_y): | |
''' 平行移動 ''' | |
mat = np.eye(3) # 3x3の単位行列 | |
mat[0, 2] = float(offset_x) | |
mat[1, 2] = float(offset_y) | |
self.mat_affine = np.dot(mat, self.mat_affine) | |
def scale(self, scale:float): | |
''' 拡大縮小 ''' | |
mat = np.eye(3) # 単位行列 | |
mat[0, 0] = scale | |
mat[1, 1] = scale | |
self.mat_affine = np.dot(mat, self.mat_affine) | |
def scale_at(self, scale:float, cx:float, cy:float): | |
''' 座標(cx, cy)を中心に拡大縮小 ''' | |
# 原点へ移動 | |
self.translate(-cx, -cy) | |
# 拡大縮小 | |
self.scale(scale) | |
# 元に戻す | |
self.translate(cx, cy) | |
def zoom_fit(self, image_width, image_height): | |
'''画像をウィジェット全体に表示させる''' | |
# キャンバスのサイズ | |
canvas_width = self.canvas.winfo_width() | |
canvas_height = self.canvas.winfo_height() | |
if (image_width * image_height <= 0) or (canvas_width * canvas_height <= 0): | |
return | |
# アフィン変換の初期化 | |
self.reset_transform() | |
scale = 1.0 | |
offsetx = 0.0 | |
offsety = 0.0 | |
if (canvas_width * image_height) > (image_width * canvas_height): | |
# ウィジェットが横長(画像を縦に合わせる) | |
scale = canvas_height / image_height | |
# あまり部分の半分を中央に寄せる | |
offsetx = (canvas_width - image_width * scale) / 2 | |
else: | |
# ウィジェットが縦長(画像を横に合わせる) | |
scale = canvas_width / image_width | |
# あまり部分の半分を中央に寄せる | |
offsety = (canvas_height - image_height * scale) / 2 | |
# 拡大縮小 | |
self.scale(scale) | |
# あまり部分を中央に寄せる | |
self.translate(offsetx, offsety) | |
# ------------------------------------------------------------------------------- | |
# 描画 | |
# ------------------------------------------------------------------------------- | |
def draw_image(self, pil_image): | |
if pil_image == None: | |
return | |
self.canvas.delete("all") | |
# キャンバスのサイズ | |
canvas_width = self.canvas.winfo_width() | |
canvas_height = self.canvas.winfo_height() | |
# キャンバスから画像データへのアフィン変換行列を求める | |
#(表示用アフィン変換行列の逆行列を求める) | |
mat_inv = np.linalg.inv(self.mat_affine) | |
# PILの画像データをアフィン変換する | |
dst = pil_image.transform( | |
(canvas_width, canvas_height), # 出力サイズ | |
Image.AFFINE, # アフィン変換 | |
tuple(mat_inv.flatten()), # アフィン変換行列(出力→入力への変換行列)を一次元のタプルへ変換 | |
Image.NEAREST, # 補間方法、ニアレストネイバー | |
fillcolor= self.back_color | |
) | |
# 表示用画像を保持 | |
self.image = ImageTk.PhotoImage(image=dst) | |
# 画像の描画 | |
self.canvas.create_image( | |
0, 0, # 画像表示位置(左上の座標) | |
anchor='nw', # アンカー、左上が原点 | |
image=self.image # 表示画像データ | |
) | |
def redraw_image(self): | |
''' 画像の再描画 ''' | |
if self.pil_image == None: | |
return | |
self.draw_image(self.pil_image) | |
if __name__ == "__main__": | |
root = tk.Tk() | |
app = Application(master=root) | |
app.mainloop() |
参考

【Python/tkinter】Canvasに画像を表示する
まず、Canvasを作成し、画像ファイルを開き、Canvasに画像を表示するサンプルは以下のようになります。import tkinter as tkfrom PIL import ImageTkclass Application(tk.Fr...

アフィン変換(平行移動、拡大縮小、回転、スキュー行列)
画像の拡大縮小、回転、平行移動などを行列を使って座標を変換する事をアフィン変換と呼びます。X,Y座標の二次元データをアフィン変換するには、変換前の座標を(x, y)、変換後の座標を(x',y')とすると回転や拡大縮小用の2行2列の行列と、平...

【Python/NumPy】行列の演算(積、逆行列、転置行列、擬似逆行列など)
個人的には、行列は最小二乗法で近似式を求めるときや、アフィン変換を用いて画像の表示やリサイズを行う際に用いるのですが、この行列の演算は、PythonではNumPyを用いて行います。NumPyのインポートimport numpy as np行...
コメント
勝手ながら改変して利用させていただきました。
なにか問題ございましたらご連絡お願いいたします。
https://github.com/shoheikuni/Image-Registration-Tool/blob/main/annotate.py
連絡頂き、ありがとうございます。
自由に使って頂いて問題ありません。
python初心者ながらコメントさせていただきます。
この記事を参考にさせていただきながらtkinterを勉強しているのですが、この機能にプラスして右クリックで3点の座標をとりその間の角度を算出する機能を追加していただけないでしょうか?
忙しいと思いますので、できればで結構でございます。
もし厳しいようでしたら、今後もこの記事を参考に勉強していきたいと思いますので、自分で頑張ります!
コメントありがとうございます。
ここで公開している内容は、できるだけ多くの人にプログラムのベースとして使ってもらいたく、シンプルにしておきたいのもあって、特定の機能の追加は、あまりやりたくないと考えています。
そのため、機能の追加は、ご自身でやって頂けると幸いです。
その際に、不明点などありましたら、また、コメントを頂ければと思います。
丁寧な返信ありがとうございます.クラスの違いが原因であることが理解できました.クラスを確認する手段は初耳でしたので参考になります
menu_open_clickedメソッドの下,本記事で言う32行目から新たに保存用のメソッドを追加してそのメソッド内にself.dst.save(filename)を実装しようとしました.
↓という感じです.
def menu_save_clicked(self):
#ファイルを保存する
filename = tk.filedialog.asksaveasfilename(defaultextension = “.jpg”, filetypes=[(“JPEG file”, “.jpg”)], initialdir = os.getcwd())
if filename != “”:
self.dst.save(filename)
ですが,
AttributeError: ‘Application’ object has no attribute ‘dst’
というエラーが出てしまい,画像が保存できませんでした,,,
VSCodeを使用しているのですが,dst.saveの色が変わっていないのでうまく認識できていないのかな?とも思ったのですが,どうすればmenu_save_clickedメソッド内で保存が可能になるでしょうか,,?
重ねての質問申し訳ありません.アドバイスいただけると嬉しいです!
おそらく、dstからself.deftへの置換のし忘れがあるか、画像を表示する前に保存しようとしていないでしょうか?
コンストラクタでself.filenameを宣言するとうまくいきました,,
アドバイスありがとうございます
突然の質問すいません.
記事を読みコードを使わせていただきました.大変参考になりました.ありがとうございます.
一つ質問があるのですが,このプログラムで拡大(縮小)した画像を保存するにはどのようにすればよろしいでしょうか?
個人的に調べて
filename = tk.filedialog.asksaveasfilename(defaultextension = “.jpg”, filetypes=[(“JPEG file”, “.jpg”)], initialdir = os.getcwd())
self.image.save(filename)
という処理が入ったメソッドを追加したのですが
AttributeError: ‘PhotoImage’ object has no attribute ‘save’
というエラーがでてしまいうまくいきません.(pil_imageではこの処理はうまくいくのですが,,,)
拙い文章で申し訳ありません.お忙しいところ恐縮なのですが,ご教授いただけると幸いです.
けいさん、コメントありがとうございます。
保存しようとしているself.imageはPhotoImageクラスのオブジェクトなので、saveのような保存するメソッドはありません。
(参考)
https://pillow.readthedocs.io/en/stable/reference/ImageTk.html?highlight=PhotoImage#PIL.ImageTk.PhotoImage
画像の拡大、縮小そのものは
dst = pil_image.transform(・・・・・
の部分でやっています。
つまり、pil_imageの画像を拡大/縮小した画像が dst になります。
そのため
dst.save(filename)
のようにすると、拡大/縮小された画像が保存されます。
もし、保存をdraw_imageのメソッド以外の場所でやりたい場合は、dst を self.dst に置き換えて、
self.dst.save(filename)
のようにしてください。
また、pil_image や self.image、 dst などの型(クラス)が分からない場合は、下記のようなコードを追加して、確認してみてください。
print(type(self.image))
※私は普段、Visual Studioを使っているので、ウォッチで見てますが。。