I am modifying an existing interactive Tkinter image viewer GUI (original code: https://github.com/ImagingSolution/PythonImageViewer) by adding functionality to draw lines over the image, and for these lines to stay in the same place relative to the image when the image is panned and zoomed. The utimate aim being to extract the coordinates for those line segments for analysis afterwards.
When the image is transformed, it is re-drawn with an affine transformation, where the transformation matrix (mat_affine) is defined in the 'translate()' and 'scale()' functions. I have attempted to apply the same matrix transformation to a global list of coordinates (line_coords) that updates with the image, and then re-draw lines from these new updated coordinates.
However, the lines drawn from these transformed coordinates move relative to the transformed image - so clearly the transformation isn't achieving the desired behaviour.
Below is a stripped-down version of the code, you'll need to open up an image of your own (File -> Open), and draw a line (right click start and end points of the line), then zoom (mousewheel) or pan (click-drag), to replicate the behaviour - note the line isn't removed, but moves beyond the visible canvas area:
import tkinter as tk
from tkinter import filedialog, simpledialog
from PIL import Image, ImageTk
import math
import numpy as np
import os
import matplotlib.pyplot as plt
line_coords = []
class Application(tk.Frame):
# Initialisation and window properties
def __init__(self, master=None):
super().__init__(master)
self.master.geometry("600x400")
self.pil_image = None
self.line_start = None
self.my_title = "PyPointCounter"
self.create_menu()
self.create_widget()
self.reset_transform()
# Menu bar
def create_menu(self):
self.menu_bar = tk.Menu(self) # Menu
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 Image", command = self.menu_open_clicked)
self.master.config(menu=self.menu_bar)
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)
# Create canvas
def create_widget(self):
self.canvas = tk.Canvas(self.master, background="black")
self.canvas.pack(expand=True, fill=tk.BOTH)
# bindings
self.master.bind("<Button-1>", self.mouse_down_left) # MouseDown
self.master.bind("<B1-Motion>", self.mouse_move_left) # MouseDrag
self.master.bind("<MouseWheel>", self.mouse_wheel) # MouseWheel
self.canvas.bind("<Button-3>", self.start_line) # Right mouse button for drawing lines
# Load image file
def set_image(self, filename):
if not filename:
return
self.pil_image = Image.open(filename)
self.draw_image(self.pil_image)
os.chdir(os.path.dirname(filename))
# USER ACTIONS
# -----------------------------------------
def mouse_down_left(self, event):
self.__old_event = event
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
# zoom mouse wheel action
def mouse_wheel(self, event):
if self.pil_image == None:
return
if (event.delta < 0):
self.scale_at(1.25, event.x, event.y)
else:
self.scale_at(0.8, event.x, event.y)
self.redraw_image()
def reset_transform(self):
self.mat_affine = np.eye(3)
# Pan
def translate(self, offset_x, offset_y):
mat = np.eye(3)
mat[0, 2] = float(offset_x)
mat[1, 2] = float(offset_y)
self.mat_affine = np.dot(mat, self.mat_affine)
# zoom base function
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)
# zoom at mouse location
def scale_at(self, scale:float, cx:float, cy:float):
self.translate(-cx, -cy)
self.scale(scale)
self.translate(cx, cy)
# line drawing with two right mouse clicks - start and end points
def start_line(self, event):
if self.line_start is None:
self.line_start = (event.x, event.y)
else:
end_point_x, end_point_y = event.x, event.y
self.canvas.create_line(self.line_start[0], self.line_start[1], end_point_x, end_point_y, fill='red')
line_start = tuple([self.line_start[0], self.line_start[1]])
line_end = tuple([end_point_x, end_point_y])
line_coords.append([line_start, line_end])
self.line_start = None
#-----------------------------------------------
# Update display according to user actions
def draw_image(self, pil_image):
self.canvas.delete('all') # remove previously drawn lines
# IMAGE TRANSFORMATION
if pil_image == None:
return
self.pil_image = pil_image
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
mat_inv = np.linalg.inv(self.mat_affine)
affine_inv = (
mat_inv[0, 0], mat_inv[0, 1], mat_inv[0, 2],
mat_inv[1, 0], mat_inv[1, 1], mat_inv[1, 2]
)
dst = self.pil_image.transform((canvas_width, canvas_height),Image.AFFINE,affine_inv,Image.NEAREST)
im = ImageTk.PhotoImage(image=dst)
item = self.canvas.create_image(0, 0,anchor='nw',image=im)
self.image = im
# LINE COORDINATE TRANSFORMATION
def transform_line_coords(i):
original_coords = line_coords[i] # original coordinates
homogeneous_coords = np.column_stack((np.array(original_coords), np.ones(len(original_coords))))
transformation_matrix = self.mat_affine.T
transformed_coords = np.dot(homogeneous_coords, transformation_matrix)
cartesian = transformed_coords[:, :2]
start_point = tuple([cartesian[0,0], cartesian[0,1]])
end_point = tuple([cartesian[1,0], cartesian[1,1]])
return [start_point, end_point]
# overwrite original line coordinates [[(x1_start,y1_start), (x1_end,y1_end)], [(x2_start,y2_start), (x2_end,y2_end)]...],
# with transformed cordinates.
global line_coords
line_coords = [transform_line_coords(i) for i in range(0, len(line_coords))]
# re-draw the lines defined in the global list of lines
def draw_line(i):
x1 = line_coords[i][0][0]
y1 = line_coords[i][0][1]
x2 = line_coords[i][1][0]
y2 = line_coords[i][1][1]
self.canvas.create_line(x1,y1,x2,y2, fill='red')
draw_lines = [draw_line(i) for i in range(0, len(line_coords))]
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()
I've tried for a day or two to work out whats going on, but I can't see why the matrix transformation for the lines isn't achieving the same results as the matrix transformation for the image. I wonder if there is some other image repositioning happening when the image is redrawn, or if there is a mismatch between window, canvas and image coordinate systems? I am also not sure why the image transformation takes the multiplicative inverse of the transformation matrix?
I would consider myself an intermediate level coder in python, but have very little experience in GUIs. Any help would be greatly appreciated!
Following the comment from @СергейКох, I have implemented the line transformations within the move_mouse_left
and mouse_wheel
methods, using the canvas.move()
and canvas.scale()
functions respectively.This is far simpler (and probably much more efficient) than trying to transform the line coordinates manually by a transformation matrix.
Just to note I have added an additional line of code within the draw_image
method to ensure the lines are rasied above the image when the image is re-drawn; so that they are still visible:
[self.canvas.tag_raise(i) for i in line_ids]
Code updated with solution below:
import tkinter as tk
from tkinter import filedialog, simpledialog
from PIL import Image, ImageTk
import math
import numpy as np
import os
import matplotlib.pyplot as plt
line_ids = [] # Store line object IDs as a Global list
class Application(tk.Frame):
# Initialisation and window properties
def __init__(self, master=None):
super().__init__(master)
self.master.geometry("600x400")
self.pil_image = None
self.line_start = None
self.my_title = "PyPointCounter"
self.create_menu()
self.create_widget()
self.reset_transform()
# Menu bar
def create_menu(self):
self.menu_bar = tk.Menu(self) # Menu
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 Image", command = self.menu_open_clicked)
self.master.config(menu=self.menu_bar)
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)
# Create canvas
def create_widget(self):
self.canvas = tk.Canvas(self.master, background="black")
self.canvas.pack(expand=True, fill=tk.BOTH)
# bindings
self.master.bind("<Button-1>", self.mouse_down_left) # MouseDown
self.master.bind("<B1-Motion>", self.mouse_move_left) # MouseDrag
self.master.bind("<MouseWheel>", self.mouse_wheel) # MouseWheel
self.canvas.bind("<Button-3>", self.start_line) # Right mouse button for drawing lines
# Load image file
def set_image(self, filename):
if not filename:
return
self.pil_image = Image.open(filename)
self.draw_image(self.pil_image)
os.chdir(os.path.dirname(filename))
# USER ACTIONS
# -----------------------------------------
def mouse_down_left(self, event):
self.__old_event = event
def mouse_move_left(self, event):
if (self.pil_image == None):
return
delta_x, delta_y = event.x - self.__old_event.x, event.y - self.__old_event.y
self.translate(delta_x, delta_y)
self.redraw_image()
[self.canvas.move(i, delta_x, delta_y) for i in line_ids]
self.__old_event = event
# zoom mouse wheel action
def mouse_wheel(self, event):
if self.pil_image == None:
return
if (event.delta < 0):
self.scale_at(1.25, event.x, event.y)
[self.canvas.scale(i, event.x, event.y, 1.25, 1.25) for i in line_ids]
else:
self.scale_at(0.8, event.x, event.y)
[self.canvas.scale(i, event.x, event.y, 0.8, 0.8) for i in line_ids]
self.redraw_image()
def reset_transform(self):
self.mat_affine = np.eye(3)
# Pan
def translate(self, offset_x, offset_y):
mat = np.eye(3)
mat[0, 2] = float(offset_x)
mat[1, 2] = float(offset_y)
self.mat_affine = np.dot(mat, self.mat_affine)
# zoom base function
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)
# zoom at mouse location
def scale_at(self, scale:float, cx:float, cy:float):
self.translate(-cx, -cy)
self.scale(scale)
self.translate(cx, cy)
# line drawing with two right mouse click - start and end points
def start_line(self, event):
if self.line_start is None:
self.line_start = (event.x, event.y)
else:
end_point_x, end_point_y = event.x, event.y
line = self.canvas.create_line(self.line_start[0], self.line_start[1], end_point_x, end_point_y, fill='red')
global line_ids
line_ids.append(line)
self.line_start = None
#-----------------------------------------------
# Update display according to user actions
def draw_image(self, pil_image):
# IMAGE TRANSFORMATION
if pil_image == None:
return
self.pil_image = pil_image
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
mat_inv = np.linalg.inv(self.mat_affine)
affine_inv = (
mat_inv[0, 0], mat_inv[0, 1], mat_inv[0, 2],
mat_inv[1, 0], mat_inv[1, 1], mat_inv[1, 2]
)
dst = self.pil_image.transform((canvas_width, canvas_height),Image.AFFINE,affine_inv,Image.NEAREST)
im = ImageTk.PhotoImage(image=dst)
item = self.canvas.create_image(0, 0,anchor='nw',image=im)
self.image = im
[self.canvas.tag_raise(i) for i in line_ids]
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()