Related
I am writing a program in python tkinter that allows you to select certain areas in the image. This image can be zoomed and dragged inside a Canvas. But the problem is that I can't get correct coordinates of the selections and extract thumbnails from original images using the small one.
Here is my code and a screenshot:
from tkinter import *
from tkinter import ttk
from tkinter import messagebox as mb
import glob
import os
from PIL import Image, ImageTk
import cv2
from src.lsh_utils import get_lsh, query_lsh
from src.io_utils import get_config
COLORS = ['red', 'blue', 'olive', 'teal', 'cyan', 'green', 'black']
CONFIG = get_config('conf.json')
class AutoScrollbar(ttk.Scrollbar):
''' A scrollbar that hides itself if it not needed.
Works only if you use the grid geometry manager '''
def set(self, lo, hi):
if float(lo) <= 0.0 and float(hi) >= 1.0:
self.grid_remove()
else:
self.grid()
ttk.Scrollbar.set(self, lo, hi)
def pack(self, **kw):
raise TclError('Cannot use pack with this widget')
def place(self, **kw):
raise TclError('Cannot use place with this widget')
class Zoom_Advanced(ttk.Frame):
''' Advanced zoom of the image '''
def __init__(self, mainframe, path):
''' Initialize the main Frame '''
ttk.Frame.__init__(self, master=mainframe)
self.master.title('Zoom with mouse wheel')
# Vertical and horizontal scrollbars for canvas
vbar = AutoScrollbar(self.master, orient='vertical')
hbar = AutoScrollbar(self.master, orient='horizontal')
vbar.grid(row=0, column=1, sticky='ns', rowspan=4)
hbar.grid(row=1, column=0, sticky='we')
# Create canvas and put image on it
self.canvas = Canvas(self.master, highlightthickness=0,
xscrollcommand=hbar.set, yscrollcommand=vbar.set)
self.canvas.grid(row=0, column=0, sticky='nswe')
self.canvas.update() # wait till canvas is created
vbar.configure(command=self.scroll_y) # bind scrollbars to the canvas
hbar.configure(command=self.scroll_x)
# Make the canvas expandable
self.master.rowconfigure(0, weight=1)
self.master.columnconfigure(0, weight=1)
# Bind events to the Canvas
self.canvas.bind('<Configure>', self.show_image) # canvas is resized
self.canvas.bind('<Button-3>', self.move_from)
self.canvas.bind("<Button-1>", self.mouseClick)
self.canvas.bind('<B3-Motion>', self.move_to)
self.canvas.bind("<Motion>", self.mouseMove)
self.canvas.bind('<MouseWheel>', self.wheel) # with Windows and MacOS, but not Linux
self.canvas.bind('<Button-5>', self.wheel) # only with Linux, wheel scroll down
self.canvas.bind('<Button-4>', self.wheel) # only with Linux, wheel scroll up
self.master.bind("<Escape>", self.cancelBBox) # press <Espace> to cancel current bbox
self.master.bind("s", self.cancelBBox)
self.master.bind("a", self.prevImage) # press 'a' to go backforward
self.master.bind("d", self.nextImage) # press 'd' to go forward
self.side_container = Frame(self.master)
self.side_container.grid_rowconfigure(0, weight=1)
self.side_container.grid_columnconfigure(0, weight=1)
self.side_container.grid(row=0, column=2, sticky='nse')
self.imscale = 1.0 # scale for the canvaas image
self.delta = 1.3 # zoom magnitude
self.lb1 = Label(self.side_container, text='Bounding boxes:')
self.lb1.grid(row=0, column=0, sticky='nwe')
self.listbox = Listbox(self.side_container)
self.listbox.grid(row=1, column=0, sticky='nwe')
self.btnDel = Button(self.side_container, text='Delete', command=self.delBBox)
self.btnDel.grid(row=2, column=0, sticky='nwe')
self.btnClear = Button(self.side_container, text='ClearAll', command=self.clearBBox)
self.btnClear.grid(row=3, column=0, sticky='nwe')
# control panel for image navigation
self.ctrPanel = Frame(self.side_container)
self.ctrPanel.grid(row=4, column=0, columnspan=2, sticky='we')
self.prevBtn = Button(self.ctrPanel, text='<< Prev', width=10, command=self.prevImage)
self.prevBtn.pack(side=LEFT, padx=5, pady=3)
self.nextBtn = Button(self.ctrPanel, text='Next >>', width=10, command=self.nextImage)
self.nextBtn.pack(side=LEFT, padx=5, pady=3)
self.progLabel = Label(self.ctrPanel, text="Progress: / ")
self.progLabel.pack(side=LEFT, padx=5)
self.tmpLabel = Label(self.ctrPanel, text="Go to Image No.")
self.tmpLabel.pack(side=LEFT, padx=5)
self.idxEntry = Entry(self.ctrPanel, width=5)
self.idxEntry.pack(side=LEFT)
self.goBtn = Button(self.ctrPanel, text='Go', command=self.gotoImage)
self.goBtn.pack(side=LEFT)
self.runBtn = Button(self.ctrPanel, text='Run', command=self.run)
self.runBtn.pack(side=LEFT, padx=5, pady=3)
self.imageDir = ''
self.imageList = []
self.egDir = ''
self.egList = []
self.outDir = ''
self.cur = 0
self.total = 0
self.category = 0
self.imagename = ''
self.labelfilename = ''
self.tkimg = None
self.cla_can_temp = []
self.detection_images_path = CONFIG.get('DETECTIONS_PATH', 'data\\detections')
self.orig_images_path = CONFIG.get('IMAGE_DIR')
self.viewer = None
self._new_window = None
# initialize mouse state
self.STATE = {}
self.STATE['click'] = 0
self.STATE['x'], self.STATE['y'] = 0, 0
# reference to bbox
self.bboxIdList = []
self.bboxId = None
self.bboxList = []
self.hl = None
self.vl = None
self.disp = Label(self.ctrPanel, text='')
self.disp.pack(side=RIGHT)
self.loadDir()
# self.show_image()
def scroll_y(self, *args, **kwargs):
''' Scroll canvas vertically and redraw the image '''
self.canvas.yview(*args, **kwargs) # scroll vertically
self.show_image() # redraw the image
def scroll_x(self, *args, **kwargs):
''' Scroll canvas horizontally and redraw the image '''
self.canvas.xview(*args, **kwargs) # scroll horizontally
self.show_image() # redraw the image
def move_from(self, event):
''' Remember previous coordinates for scrolling with the mouse '''
self.canvas.scan_mark(event.x, event.y)
def move_to(self, event):
''' Drag (move) canvas to the new position '''
self.canvas.scan_dragto(event.x, event.y, gain=1)
self.show_image() # redraw the image
def wheel(self, event):
''' Zoom with mouse wheel '''
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
bbox = self.canvas.bbox(self.container) # get image area
if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: pass # Ok! Inside the image
else: return # zoom only inside image area
scale = 1.0
# Respond to Linux (event.num) or Windows (event.delta) wheel event
if event.num == 5 or event.delta == -120: # scroll down
i = min(self.width, self.height)
if int(i * self.imscale) < 30: return # image is less than 30 pixels
self.imscale /= self.delta
scale /= self.delta
if event.num == 4 or event.delta == 120: # scroll up
i = min(self.canvas.winfo_width(), self.canvas.winfo_height())
if i < self.imscale: return # 1 pixel is bigger than the visible area
self.imscale *= self.delta
scale *= self.delta
self.canvas.scale('all', x, y, scale, scale) # rescale all canvas objects
self.show_image()
def show_image(self, event=None):
''' Show image on the Canvas '''
bbox1 = self.canvas.bbox(self.container) # get image area
# Remove 1 pixel shift at the sides of the bbox1
bbox1 = (bbox1[0] + 1, bbox1[1] + 1, bbox1[2] - 1, bbox1[3] - 1)
bbox2 = (self.canvas.canvasx(0), # get visible area of the canvas
self.canvas.canvasy(0),
self.canvas.canvasx(self.canvas.winfo_width()),
self.canvas.canvasy(self.canvas.winfo_height()))
bbox = [min(bbox1[0], bbox2[0]), min(bbox1[1], bbox2[1]), # get scroll region box
max(bbox1[2], bbox2[2]), max(bbox1[3], bbox2[3])]
print(bbox)
if bbox[0] == bbox2[0] and bbox[2] == bbox2[2]: # whole image in the visible area
bbox[0] = bbox1[0]
bbox[2] = bbox1[2]
if bbox[1] == bbox2[1] and bbox[3] == bbox2[3]: # whole image in the visible area
bbox[1] = bbox1[1]
bbox[3] = bbox1[3]
print(bbox2, bbox1)
self.canvas.configure(scrollregion=bbox) # set scroll region
x1 = max(bbox2[0] - bbox1[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile
y1 = max(bbox2[1] - bbox1[1], 0)
x2 = min(bbox2[2], bbox1[2]) - bbox1[0]
y2 = min(bbox2[3], bbox1[3]) - bbox1[1]
print(x1, y1, x2, y2)
if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area
x = min(int(x2 / self.imscale), self.width) # sometimes it is larger on 1 pixel...
y = min(int(y2 / self.imscale), self.height) # ...and sometimes not
print(x, y)
image = self.image.crop((int(x1 / self.imscale), int(y1 / self.imscale), x, y))
self.imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1))))
imageid = self.canvas.create_image(max(bbox2[0], bbox1[0]), max(bbox2[1], bbox1[1]),
anchor='nw', image=self.imagetk)
self.canvas.lower(imageid) # set image into background
self.canvas.imagetk = self.imagetk # keep an extra reference to prevent garbage-collection
def run(self):
labels = self._compare_labels()
lsh = get_lsh(CONFIG.get('LSH_DATASET'))
if not labels:
return
for file, bboxes in labels.items():
im = cv2.imread(file)
h, w, _ = im.shape
print(w, h)
rois = []
for box in bboxes:
x1 = int(int(box[0])/600*w)
y1 = int(int(box[1])/800*h)
x2 = int(int(box[2])/600*w)
y2 = int(int(box[3])/800*h)
roi = im[y1:y2, x1:x2].copy()
print(file, [n[0][1] for n in query_lsh(lsh, roi)])
# cv2.imshow(str(box), roi)
# cv2.waitKey()
# def _update_labels_boxes(self, w, h):
# for i, bbox in enumerate(self.listbox):
# x1 = int(int(bbox[0]) / 768 * w)
# y1 = int(int(bbox[1]) / 768 * h)
# x2 = int(int(bbox[2]) / 768 * w)
# y2 = int(int(bbox[3]) / 768 * h)
#
def _compare_labels(self):
label_dict = dict()
if not self.imageDir:
return None
for label_file in glob.glob(os.path.join(self.imageDir, '*.txt')):
with open(label_file, 'r') as f:
filename = ''.join([os.path.splitext(label_file)[0], '.jpg'])
label_dict[filename] = [tuple(l.split()) for l in f.readlines()]
return label_dict
def loadDir(self, dbg=False):
# get image list
self.imageDir = self.orig_images_path
# print self.imageDir
# print self.category
self.imageList = glob.glob(os.path.join(self.imageDir, '*.JPG'))
print(self.imageList)
if len(self.imageList) == 0:
print('No .JPG images found in the specified dir!')
return
# default to the 1st image in the collection
self.cur = 1
self.total = len(self.imageList)
# set up output dir
self.outDir = self.imageDir
if not os.path.exists(self.outDir):
os.mkdir(self.outDir)
self.loadImage()
print('%d images loaded from %s' % (self.total, self.orig_images_path))
def new_window(self, path):
self.newWindow = Toplevel(self.master)
# frame = Frame(self.newWindow)
self.new_panel = Canvas(self.newWindow, cursor='tcross')
self.new_img = ImageTk.PhotoImage(Image.open(path).resize((600, 800)))
self.new_panel.pack()
self.new_panel.config(width=max(self.new_img.width(), 400), height=max(self.new_img.height(), 400))
self.new_panel.create_image(0, 0, image=self.new_img, anchor=NW)
return self.new_panel
def loadImage(self):
# load image
imagepath = self.imageList[self.cur - 1]
self.img = Image.open(imagepath).resize((600, 800))
detect_image_path = os.path.join(self.detection_images_path, 'detect_'+os.path.basename(imagepath))
if not os.path.exists(detect_image_path):
mb.showerror(f'image {detect_image_path} doesn\' exists')
self.nextImage(save=False)
self.new_window(detect_image_path)
# cv2.imshow(f'{detect_image_path}', detect_image_path)
self.image = Image.open(path) # open image
self.width, self.height = self.image.size
# Put image into container rectangle and use it to set proper coordinates to the image
self.container = self.canvas.create_rectangle(0, 0, self.width, self.height, width=0)
self.show_image()
# load labels
self.clearBBox()
self.imagename = os.path.split(imagepath)[-1].split('.')[0]
labelname = self.imagename + '.txt'
self.labelfilename = os.path.join(self.outDir, labelname)
if os.path.exists(self.labelfilename):
with open(self.labelfilename) as f:
for (i, line) in enumerate(f):
# tmp = [int(t.strip()) for t in line.split()]
tmp = line.split()
self.bboxList.append(tuple(tmp))
tmpId = self.canvas.create_rectangle(int(tmp[0]), int(tmp[1]),
int(tmp[2]), int(tmp[3]),
width=2,
outline=COLORS[(len(self.bboxList) - 1) % len(COLORS)])
# print tmpId
self.bboxIdList.append(tmpId)
self.listbox.insert(END, '(%d, %d) -> (%d, %d)' % (int(tmp[0]), int(tmp[1]),
int(tmp[2]), int(tmp[3])))
self.listbox.itemconfig(len(self.bboxIdList) - 1,
fg=COLORS[(len(self.bboxIdList) - 1) % len(COLORS)])
def saveImage(self):
if self.newWindow:
self.newWindow.destroy()
with open(self.labelfilename, 'w') as f:
for bbox in self.bboxList:
f.write(' '.join(map(str, bbox)) + '\n')
print('Image No. %d saved' % (self.cur))
def mouseClick(self, event):
new_x, new_y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
if self.STATE['click'] == 0:
self.STATE['x'], self.STATE['y'] = new_x, new_y
else:
x1, x2 = min(self.STATE['x'], new_x), max(self.STATE['x'], new_x)
y1, y2 = min(self.STATE['y'], new_y), max(self.STATE['y'], new_y)
x, y = int(x2 / self.imscale), int(y2 / self.imscale)
print(self.imscale, (int(x1 / self.imscale), int(y1 / self.imscale), x, y))
im = self.image.crop((int(x1 / self.imscale), int(y1 / self.imscale), x, y))
im.show()
self.bboxList.append((int(x1 / self.imscale), int(y1 / self.imscale), x, y))
self.bboxIdList.append(self.bboxId)
self.bboxId = None
self.listbox.insert(END, '(%d, %d) -> (%d, %d)' % (x1, y1, x2, y2))
self.listbox.itemconfig(len(self.bboxIdList) - 1, fg=COLORS[(len(self.bboxIdList) - 1) % len(COLORS)])
self.STATE['click'] = 1 - self.STATE['click']
def mouseMove(self, event):
new_x, new_y = int(self.canvas.canvasx(event.x)), int(self.canvas.canvasy(event.y))
self.disp.config(text='x: %d, y: %d' % (new_x, new_y))
if self.canvas:
if self.hl:
self.canvas.delete(self.hl)
self.hl = self.canvas.create_line(0, new_y, self.canvas['width'], new_y, width=2)
if self.vl:
self.canvas.delete(self.vl)
self.vl = self.canvas.create_line(new_x, 0, new_x, self.canvas['height'], width=2)
if 1 == self.STATE['click']:
if self.bboxId:
self.canvas.delete(self.bboxId)
self.bboxId = self.canvas.create_rectangle(self.STATE['x'], self.STATE['y'],
new_x, new_y,
width=2,
outline=COLORS[len(self.bboxList) % len(COLORS)])
def cancelBBox(self, event):
if 1 == self.STATE['click']:
if self.bboxId:
self.canvas.delete(self.bboxId)
self.bboxId = None
self.STATE['click'] = 0
def delBBox(self):
sel = self.listbox.curselection()
if len(sel) != 1:
return
idx = int(sel[0])
self.canvas.delete(self.bboxIdList[idx])
self.bboxIdList.pop(idx)
self.bboxList.pop(idx)
self.listbox.delete(idx)
def clearBBox(self):
for idx in range(len(self.bboxIdList)):
self.canvas.delete(self.bboxIdList[idx])
self.listbox.delete(0, len(self.bboxList))
self.bboxIdList = []
self.bboxList = []
def prevImage(self, event=None):
self.saveImage()
if self.cur > 1:
self.cur -= 1
self.loadImage()
def nextImage(self, event=None, save=True):
if save:
self.saveImage()
if self.cur < self.total:
self.cur += 1
self.loadImage()
def gotoImage(self):
idx = int(self.idxEntry.get())
if 1 <= idx <= self.total:
self.saveImage()
self.cur = idx
self.loadImage()
path = 'data\\detections\\detect_1.jpg' # place path to your image here
root = Tk()
root.geometry('1280x720')
app = Zoom_Advanced(root, path=path)
root.mainloop()
I think the solution is in def MouseClick and in show_image. My thoughts is to use the same methods of extracting a tile (or a bbox) from original image as in show_image.
I tried to fo that but I didn't get any results. I don't understand how can I do that so I'm searching for the help here.
I did that with this code, where self.container is a rectangle around image with its width and height and self.imscale is scale of image.
...
new_x, new_y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
if self.STATE['click'] == 0:
self.STATE['x'], self.STATE['y'] = new_x, new_y
else:
x1, x2 = min(self.STATE['x'], new_x), max(self.STATE['x'], new_x)
y1, y2 = min(self.STATE['y'], new_y), max(self.STATE['y'], new_y)
self.canvas.create_text((x1 + x2) // 2, (y1 + y2) // 2, text=self.bbox_num)
bbox = self.canvas.bbox(self.container)
x1 = int((x1 - bbox[0]) / self.imscale)
y1 = int((y1 - bbox[1]) / self.imscale)
x2 = int((x2 - bbox[0]) / self.imscale)
y2 = int((y2 - bbox[1]) / self.imscale)
I'm creating in tkinter a Crop Tool that is similar in Photoshop. This code has a function that is supposed to crop a moveable image within the cropping box (2 in x 2 in, passport size, and so on). The problem is, the code often crops portions of the image outside the box.
For example, if I have a portrait and aimed the face at the rectangle, the code would crop the hat instead, or anywhere but the face.
I tried to use bbox, event objects, etc. but the measurements end up wrong. Please help me. Thanks.
Here is a partial code. Sorry if it's a quite lengthy.
from tkinter import *
from tkinter import ttk
import tkinter as tk
from tkinter import messagebox
from tkinter.filedialog import askopenfilename, asksaveasfilename
from PIL import Image, ImageTk
class PictureEditor:
# Quits when called
#staticmethod
# Opens an image
def open_app(self, event=None):
self.canvas.delete(ALL)
# Opens a window to choose a file=
self.openfile = askopenfilename(initialdir = # "Filename here")
if self.openfile:
with open(self.openfile) as _file:
# if file is selected by user, I'm going to delete
# the contents inside the canvas widget
self.canvas.delete(1.0, END)
self.im = Image.open(self.openfile)
self.image = ImageTk.PhotoImage(self.im)
self.a1 = self.canvas.create_image(0, 0, anchor=NW,
image=self.image, tags="image")
self.image_dim = self.canvas.bbox(self.a1)
self.imx = self.image_dim[0]
self.imy = self.image_dim[1]
# updating text widget
window.update_idletasks()
def on_drag(self, event):
# record the item and its location
self._drag_data["item"] = self.canvas.find_closest(event.x, event.y)[0]
self._drag_data["x"] = event.x
self._drag_data["y"] = event.y
self.origx = event.x
self.origy = event.y
def on_release(self, event):
# when I release the mouse, this happens
# reset the drag information
self._drag_data["item"] = None
self._drag_data["x"] = 0
self._drag_data["y"] = 0
self.newx = event.x
self.newy = event.y
# Measures mouse movement from one point to another
self.movex = self.origx - self.newx
self.movey = self.origy - self.newy
def on_motion(self, event):
# handles the dragging of an object
# compute how much the mouse has moved
delta_x = event.x - self._drag_data["x"]
delta_y = event.y - self._drag_data["y"]
# move the object the appropriate amount
self.canvas.move(self._drag_data["item"], delta_x, delta_y)
# record the new position
self._drag_data["x"] = event.x
self._drag_data["y"] = event.y
def draw(self, event, x1=None, y1=None,x2=None,y2=None):
# deleting contents of border, if any.
try:
self.canvas.delete(self.border)
except:
pass
# if an item is selected
selection = self.combo.get()
if selection == 'No Crop':
x1, y1, x2, y2 = None, None, None, None
if selection == '2 in x 2 in':
x1, y1, x2, y2 = self.imx, self.imy, self.imx + 200, self.imy + 200
if selection == '1 in x 1 in':
x1, y1, x2, y2 = self.imx, self.imy, self.imx + 100, self.imy + 100
if selection == 'Passport Size':
x1, y1, x2, y2 = self.imx, self.imy, self.imx + 132.28, self.imy
+170.079
if x1 != None or y1 != None or x2 != None or y2 != None:
self.dimensions = {"x1":x1, "y1":y1, "x2":x2, "y2":y2}
width = 5
self.border = self.canvas.create_rectangle(x1+ width, y1 +
width, x2 + width, y2 + width, width=width, outline="#ffffff", fill ="",
tags = "rectangle")
else:
pass
def crop(self, event=None):
# cropping the image
try:
self.crop_image = self.im.crop((self.dimensions["x1"] +
self.movex,
self.dimensions["y1"] + self.movey,
self.dimensions["x2"] + self.movex,
self.dimensions["y2"] + self.movey))
except:
print("cropping failed")
return 1
self.newly_cropped = ImageTk.PhotoImage(self.crop_image)
try:
new_image = self.canvas.create_image(120, 120,
image=self.newly_cropped)
print("Image is cropped")
except:
print("Cropping failed")
def __init__(self,window):
frame1 = Frame(bg='red')
frame1.pack(side=TOP, fill=X)
frame2height = 600
frame2width = 600
frame2 = Frame(window, bd=2, relief=SUNKEN)
frame2.pack(side=LEFT, fill=X)
frame3 = Frame(bg='green')
frame3.pack(side=LEFT, fill=X)
# Button that open pictures
open = Button(frame1, text='Open Pic', padx=20, command =
self.open_app)
open.pack(pady=5, padx=5, side=LEFT)
# Creating a canvas widget
self.canvas = tk.Canvas(frame2, height=frame2height,
width=frame2width,
bg='gray')
self.xsb = Scrollbar(frame2, orient="horizontal",
command=self.canvas.xview)
self.ysb = Scrollbar(frame2, orient="vertical",
command=self.canvas.yview)
self.canvas.configure(yscrollcommand=self.ysb.set,
xscrollcommand=self.xsb.set)
self.canvas.configure(scrollregion=(0, 0, 1000, 1000))
# keeps track of data being dragged
self._drag_data = {"x": 0, "y": 0, "item": None}
# creating image and crop border
self.canvas.tag_bind("image","<ButtonPress-1>", self.on_drag)
self.canvas.tag_bind("image","<ButtonRelease-1>", self.on_release)
self.canvas.tag_bind("image","<B1-Motion>", self.on_motion)
# widget positions in frame2
self.xsb.pack(side=BOTTOM, fill=X)
self.canvas.pack(side=LEFT)
self.ysb.pack(side=LEFT, fill=Y)
self.combo = ttk.Combobox(frame1)
# Combobox selections
self.combo['values'] = ('No Crop', '2 in x 2 in', '1 in x 1 in',
'Passport Size')
self.combo.current(0)
self.combo.pack(pady=5, padx=5, side=LEFT)
self.combo.bind("<Button-1>", self.draw)
# Button that crops picture
self.crop = Button(frame1, text='Crop Pic', padx=20,
command=self.crop)
self.crop.pack(pady=5, padx=5, side=LEFT)
# this window has all the properties of tkinter.
# .Tk() declares this variable as the frame
window = tk.Tk()
# .title() will input whatever title you want for the app
window.title("ID Picture Generator")
# .geometry() sets the size in pixels of what the window will be
window.geometry("800x600")
app = PictureEditor(window)
# runs everything inside the window
window.mainloop()
Tkinter's canvas widget has built-in features to:
move/pan the canvas (for example with Click + Drag) with canvas.scan_mark and canvas.scan_dragto, see this question
zoom the vector elements on the canvas with canvas.scale, but sadly, this doesn't work for bitmap images on the canvas
Fortunately, this method allows zooming of images (by manually redrawing the zoomed portion of the image). But:
As we are redrawing a particular portion of the canvas, move/pan feature won't work anymore...
We absolutely need to render more than the currently displayed area, to allow move/pan. Let's say we have 1000x1000 bitmap on the canvas, and we want to zoom by a factor 50x... How to avoid having a 50.000 x 50.000 pixels bitmap in memory? (2.5 gigapixels in RAM is too big). We could think about rendering the viewport only, or a bit more than the current viewport to allow panning, but then what to do once panning leads to the edge of the rendered zone?
How to have a move/pan + zoom feature on Tkinter canvas, that works for images?
Advanced zoom example. Like in Google Maps.
Example video (longer video here):
It zooms only a tile, but not the whole image. So the zoomed tile occupies constant memory and not crams it with a huge resized image for the large zooms. For the simplified zoom example look here.
Tested on Windows 7 64-bit and Python 3.6.2.
Do not forget to place a path to your image at the end of the script.
# -*- coding: utf-8 -*-
# Advanced zoom example. Like in Google Maps.
# It zooms only a tile, but not the whole image. So the zoomed tile occupies
# constant memory and not crams it with a huge resized image for the large zooms.
import random
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk
class AutoScrollbar(ttk.Scrollbar):
''' A scrollbar that hides itself if it's not needed.
Works only if you use the grid geometry manager '''
def set(self, lo, hi):
if float(lo) <= 0.0 and float(hi) >= 1.0:
self.grid_remove()
else:
self.grid()
ttk.Scrollbar.set(self, lo, hi)
def pack(self, **kw):
raise tk.TclError('Cannot use pack with this widget')
def place(self, **kw):
raise tk.TclError('Cannot use place with this widget')
class Zoom_Advanced(ttk.Frame):
''' Advanced zoom of the image '''
def __init__(self, mainframe, path):
''' Initialize the main Frame '''
ttk.Frame.__init__(self, master=mainframe)
self.master.title('Zoom with mouse wheel')
# Vertical and horizontal scrollbars for canvas
vbar = AutoScrollbar(self.master, orient='vertical')
hbar = AutoScrollbar(self.master, orient='horizontal')
vbar.grid(row=0, column=1, sticky='ns')
hbar.grid(row=1, column=0, sticky='we')
# Create canvas and put image on it
self.canvas = tk.Canvas(self.master, highlightthickness=0,
xscrollcommand=hbar.set, yscrollcommand=vbar.set)
self.canvas.grid(row=0, column=0, sticky='nswe')
self.canvas.update() # wait till canvas is created
vbar.configure(command=self.scroll_y) # bind scrollbars to the canvas
hbar.configure(command=self.scroll_x)
# Make the canvas expandable
self.master.rowconfigure(0, weight=1)
self.master.columnconfigure(0, weight=1)
# Bind events to the Canvas
self.canvas.bind('<Configure>', self.show_image) # canvas is resized
self.canvas.bind('<ButtonPress-1>', self.move_from)
self.canvas.bind('<B1-Motion>', self.move_to)
self.canvas.bind('<MouseWheel>', self.wheel) # with Windows and MacOS, but not Linux
self.canvas.bind('<Button-5>', self.wheel) # only with Linux, wheel scroll down
self.canvas.bind('<Button-4>', self.wheel) # only with Linux, wheel scroll up
self.image = Image.open(path) # open image
self.width, self.height = self.image.size
self.imscale = 1.0 # scale for the canvaas image
self.delta = 1.3 # zoom magnitude
# Put image into container rectangle and use it to set proper coordinates to the image
self.container = self.canvas.create_rectangle(0, 0, self.width, self.height, width=0)
# Plot some optional random rectangles for the test purposes
minsize, maxsize, number = 5, 20, 10
for n in range(number):
x0 = random.randint(0, self.width - maxsize)
y0 = random.randint(0, self.height - maxsize)
x1 = x0 + random.randint(minsize, maxsize)
y1 = y0 + random.randint(minsize, maxsize)
color = ('red', 'orange', 'yellow', 'green', 'blue')[random.randint(0, 4)]
self.canvas.create_rectangle(x0, y0, x1, y1, fill=color, activefill='black')
self.show_image()
def scroll_y(self, *args, **kwargs):
''' Scroll canvas vertically and redraw the image '''
self.canvas.yview(*args, **kwargs) # scroll vertically
self.show_image() # redraw the image
def scroll_x(self, *args, **kwargs):
''' Scroll canvas horizontally and redraw the image '''
self.canvas.xview(*args, **kwargs) # scroll horizontally
self.show_image() # redraw the image
def move_from(self, event):
''' Remember previous coordinates for scrolling with the mouse '''
self.canvas.scan_mark(event.x, event.y)
def move_to(self, event):
''' Drag (move) canvas to the new position '''
self.canvas.scan_dragto(event.x, event.y, gain=1)
self.show_image() # redraw the image
def wheel(self, event):
''' Zoom with mouse wheel '''
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
bbox = self.canvas.bbox(self.container) # get image area
if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: pass # Ok! Inside the image
else: return # zoom only inside image area
scale = 1.0
# Respond to Linux (event.num) or Windows (event.delta) wheel event
if event.num == 5 or event.delta == -120: # scroll down
i = min(self.width, self.height)
if int(i * self.imscale) < 30: return # image is less than 30 pixels
self.imscale /= self.delta
scale /= self.delta
if event.num == 4 or event.delta == 120: # scroll up
i = min(self.canvas.winfo_width(), self.canvas.winfo_height())
if i < self.imscale: return # 1 pixel is bigger than the visible area
self.imscale *= self.delta
scale *= self.delta
self.canvas.scale('all', x, y, scale, scale) # rescale all canvas objects
self.show_image()
def show_image(self, event=None):
''' Show image on the Canvas '''
bbox1 = self.canvas.bbox(self.container) # get image area
# Remove 1 pixel shift at the sides of the bbox1
bbox1 = (bbox1[0] + 1, bbox1[1] + 1, bbox1[2] - 1, bbox1[3] - 1)
bbox2 = (self.canvas.canvasx(0), # get visible area of the canvas
self.canvas.canvasy(0),
self.canvas.canvasx(self.canvas.winfo_width()),
self.canvas.canvasy(self.canvas.winfo_height()))
bbox = [min(bbox1[0], bbox2[0]), min(bbox1[1], bbox2[1]), # get scroll region box
max(bbox1[2], bbox2[2]), max(bbox1[3], bbox2[3])]
if bbox[0] == bbox2[0] and bbox[2] == bbox2[2]: # whole image in the visible area
bbox[0] = bbox1[0]
bbox[2] = bbox1[2]
if bbox[1] == bbox2[1] and bbox[3] == bbox2[3]: # whole image in the visible area
bbox[1] = bbox1[1]
bbox[3] = bbox1[3]
self.canvas.configure(scrollregion=bbox) # set scroll region
x1 = max(bbox2[0] - bbox1[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile
y1 = max(bbox2[1] - bbox1[1], 0)
x2 = min(bbox2[2], bbox1[2]) - bbox1[0]
y2 = min(bbox2[3], bbox1[3]) - bbox1[1]
if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area
x = min(int(x2 / self.imscale), self.width) # sometimes it is larger on 1 pixel...
y = min(int(y2 / self.imscale), self.height) # ...and sometimes not
image = self.image.crop((int(x1 / self.imscale), int(y1 / self.imscale), x, y))
imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1))))
imageid = self.canvas.create_image(max(bbox2[0], bbox1[0]), max(bbox2[1], bbox1[1]),
anchor='nw', image=imagetk)
self.canvas.lower(imageid) # set image into background
self.canvas.imagetk = imagetk # keep an extra reference to prevent garbage-collection
path = 'doge.jpg' # place path to your image here
root = tk.Tk()
app = Zoom_Advanced(root, path=path)
root.mainloop()
EDIT:
I've created even more advanced zoom. There is "image pyramid" for smooth zooming of large images and even ability to open and zoom huge TIFF files up to several gigabytes.
Version 3.0 is tested on Windows 7 64-bit and Python 3.7.
# -*- coding: utf-8 -*-
# Advanced zoom for images of various types from small to huge up to several GB
import math
import warnings
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk
class AutoScrollbar(ttk.Scrollbar):
""" A scrollbar that hides itself if it's not needed. Works only for grid geometry manager """
def set(self, lo, hi):
if float(lo) <= 0.0 and float(hi) >= 1.0:
self.grid_remove()
else:
self.grid()
ttk.Scrollbar.set(self, lo, hi)
def pack(self, **kw):
raise tk.TclError('Cannot use pack with the widget ' + self.__class__.__name__)
def place(self, **kw):
raise tk.TclError('Cannot use place with the widget ' + self.__class__.__name__)
class CanvasImage:
""" Display and zoom image """
def __init__(self, placeholder, path):
""" Initialize the ImageFrame """
self.imscale = 1.0 # scale for the canvas image zoom, public for outer classes
self.__delta = 1.3 # zoom magnitude
self.__filter = Image.ANTIALIAS # could be: NEAREST, BILINEAR, BICUBIC and ANTIALIAS
self.__previous_state = 0 # previous state of the keyboard
self.path = path # path to the image, should be public for outer classes
# Create ImageFrame in placeholder widget
self.__imframe = ttk.Frame(placeholder) # placeholder of the ImageFrame object
# Vertical and horizontal scrollbars for canvas
hbar = AutoScrollbar(self.__imframe, orient='horizontal')
vbar = AutoScrollbar(self.__imframe, orient='vertical')
hbar.grid(row=1, column=0, sticky='we')
vbar.grid(row=0, column=1, sticky='ns')
# Create canvas and bind it with scrollbars. Public for outer classes
self.canvas = tk.Canvas(self.__imframe, highlightthickness=0,
xscrollcommand=hbar.set, yscrollcommand=vbar.set)
self.canvas.grid(row=0, column=0, sticky='nswe')
self.canvas.update() # wait till canvas is created
hbar.configure(command=self.__scroll_x) # bind scrollbars to the canvas
vbar.configure(command=self.__scroll_y)
# Bind events to the Canvas
self.canvas.bind('<Configure>', lambda event: self.__show_image()) # canvas is resized
self.canvas.bind('<ButtonPress-1>', self.__move_from) # remember canvas position
self.canvas.bind('<B1-Motion>', self.__move_to) # move canvas to the new position
self.canvas.bind('<MouseWheel>', self.__wheel) # zoom for Windows and MacOS, but not Linux
self.canvas.bind('<Button-5>', self.__wheel) # zoom for Linux, wheel scroll down
self.canvas.bind('<Button-4>', self.__wheel) # zoom for Linux, wheel scroll up
# Handle keystrokes in idle mode, because program slows down on a weak computers,
# when too many key stroke events in the same time
self.canvas.bind('<Key>', lambda event: self.canvas.after_idle(self.__keystroke, event))
# Decide if this image huge or not
self.__huge = False # huge or not
self.__huge_size = 14000 # define size of the huge image
self.__band_width = 1024 # width of the tile band
Image.MAX_IMAGE_PIXELS = 1000000000 # suppress DecompressionBombError for the big image
with warnings.catch_warnings(): # suppress DecompressionBombWarning
warnings.simplefilter('ignore')
self.__image = Image.open(self.path) # open image, but down't load it
self.imwidth, self.imheight = self.__image.size # public for outer classes
if self.imwidth * self.imheight > self.__huge_size * self.__huge_size and \
self.__image.tile[0][0] == 'raw': # only raw images could be tiled
self.__huge = True # image is huge
self.__offset = self.__image.tile[0][2] # initial tile offset
self.__tile = [self.__image.tile[0][0], # it have to be 'raw'
[0, 0, self.imwidth, 0], # tile extent (a rectangle)
self.__offset,
self.__image.tile[0][3]] # list of arguments to the decoder
self.__min_side = min(self.imwidth, self.imheight) # get the smaller image side
# Create image pyramid
self.__pyramid = [self.smaller()] if self.__huge else [Image.open(self.path)]
# Set ratio coefficient for image pyramid
self.__ratio = max(self.imwidth, self.imheight) / self.__huge_size if self.__huge else 1.0
self.__curr_img = 0 # current image from the pyramid
self.__scale = self.imscale * self.__ratio # image pyramide scale
self.__reduction = 2 # reduction degree of image pyramid
w, h = self.__pyramid[-1].size
while w > 512 and h > 512: # top pyramid image is around 512 pixels in size
w /= self.__reduction # divide on reduction degree
h /= self.__reduction # divide on reduction degree
self.__pyramid.append(self.__pyramid[-1].resize((int(w), int(h)), self.__filter))
# Put image into container rectangle and use it to set proper coordinates to the image
self.container = self.canvas.create_rectangle((0, 0, self.imwidth, self.imheight), width=0)
self.__show_image() # show image on the canvas
self.canvas.focus_set() # set focus on the canvas
def smaller(self):
""" Resize image proportionally and return smaller image """
w1, h1 = float(self.imwidth), float(self.imheight)
w2, h2 = float(self.__huge_size), float(self.__huge_size)
aspect_ratio1 = w1 / h1
aspect_ratio2 = w2 / h2 # it equals to 1.0
if aspect_ratio1 == aspect_ratio2:
image = Image.new('RGB', (int(w2), int(h2)))
k = h2 / h1 # compression ratio
w = int(w2) # band length
elif aspect_ratio1 > aspect_ratio2:
image = Image.new('RGB', (int(w2), int(w2 / aspect_ratio1)))
k = h2 / w1 # compression ratio
w = int(w2) # band length
else: # aspect_ratio1 < aspect_ration2
image = Image.new('RGB', (int(h2 * aspect_ratio1), int(h2)))
k = h2 / h1 # compression ratio
w = int(h2 * aspect_ratio1) # band length
i, j, n = 0, 1, round(0.5 + self.imheight / self.__band_width)
while i < self.imheight:
print('\rOpening image: {j} from {n}'.format(j=j, n=n), end='')
band = min(self.__band_width, self.imheight - i) # width of the tile band
self.__tile[1][3] = band # set band width
self.__tile[2] = self.__offset + self.imwidth * i * 3 # tile offset (3 bytes per pixel)
self.__image.close()
self.__image = Image.open(self.path) # reopen / reset image
self.__image.size = (self.imwidth, band) # set size of the tile band
self.__image.tile = [self.__tile] # set tile
cropped = self.__image.crop((0, 0, self.imwidth, band)) # crop tile band
image.paste(cropped.resize((w, int(band * k)+1), self.__filter), (0, int(i * k)))
i += band
j += 1
print('\r' + 30*' ' + '\r', end='') # hide printed string
return image
def redraw_figures(self):
""" Dummy function to redraw figures in the children classes """
pass
def grid(self, **kw):
""" Put CanvasImage widget on the parent widget """
self.__imframe.grid(**kw) # place CanvasImage widget on the grid
self.__imframe.grid(sticky='nswe') # make frame container sticky
self.__imframe.rowconfigure(0, weight=1) # make canvas expandable
self.__imframe.columnconfigure(0, weight=1)
def pack(self, **kw):
""" Exception: cannot use pack with this widget """
raise Exception('Cannot use pack with the widget ' + self.__class__.__name__)
def place(self, **kw):
""" Exception: cannot use place with this widget """
raise Exception('Cannot use place with the widget ' + self.__class__.__name__)
# noinspection PyUnusedLocal
def __scroll_x(self, *args, **kwargs):
""" Scroll canvas horizontally and redraw the image """
self.canvas.xview(*args) # scroll horizontally
self.__show_image() # redraw the image
# noinspection PyUnusedLocal
def __scroll_y(self, *args, **kwargs):
""" Scroll canvas vertically and redraw the image """
self.canvas.yview(*args) # scroll vertically
self.__show_image() # redraw the image
def __show_image(self):
""" Show image on the Canvas. Implements correct image zoom almost like in Google Maps """
box_image = self.canvas.coords(self.container) # get image area
box_canvas = (self.canvas.canvasx(0), # get visible area of the canvas
self.canvas.canvasy(0),
self.canvas.canvasx(self.canvas.winfo_width()),
self.canvas.canvasy(self.canvas.winfo_height()))
box_img_int = tuple(map(int, box_image)) # convert to integer or it will not work properly
# Get scroll region box
box_scroll = [min(box_img_int[0], box_canvas[0]), min(box_img_int[1], box_canvas[1]),
max(box_img_int[2], box_canvas[2]), max(box_img_int[3], box_canvas[3])]
# Horizontal part of the image is in the visible area
if box_scroll[0] == box_canvas[0] and box_scroll[2] == box_canvas[2]:
box_scroll[0] = box_img_int[0]
box_scroll[2] = box_img_int[2]
# Vertical part of the image is in the visible area
if box_scroll[1] == box_canvas[1] and box_scroll[3] == box_canvas[3]:
box_scroll[1] = box_img_int[1]
box_scroll[3] = box_img_int[3]
# Convert scroll region to tuple and to integer
self.canvas.configure(scrollregion=tuple(map(int, box_scroll))) # set scroll region
x1 = max(box_canvas[0] - box_image[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile
y1 = max(box_canvas[1] - box_image[1], 0)
x2 = min(box_canvas[2], box_image[2]) - box_image[0]
y2 = min(box_canvas[3], box_image[3]) - box_image[1]
if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area
if self.__huge and self.__curr_img < 0: # show huge image
h = int((y2 - y1) / self.imscale) # height of the tile band
self.__tile[1][3] = h # set the tile band height
self.__tile[2] = self.__offset + self.imwidth * int(y1 / self.imscale) * 3
self.__image.close()
self.__image = Image.open(self.path) # reopen / reset image
self.__image.size = (self.imwidth, h) # set size of the tile band
self.__image.tile = [self.__tile]
image = self.__image.crop((int(x1 / self.imscale), 0, int(x2 / self.imscale), h))
else: # show normal image
image = self.__pyramid[max(0, self.__curr_img)].crop( # crop current img from pyramid
(int(x1 / self.__scale), int(y1 / self.__scale),
int(x2 / self.__scale), int(y2 / self.__scale)))
#
imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1)), self.__filter))
imageid = self.canvas.create_image(max(box_canvas[0], box_img_int[0]),
max(box_canvas[1], box_img_int[1]),
anchor='nw', image=imagetk)
self.canvas.lower(imageid) # set image into background
self.canvas.imagetk = imagetk # keep an extra reference to prevent garbage-collection
def __move_from(self, event):
""" Remember previous coordinates for scrolling with the mouse """
self.canvas.scan_mark(event.x, event.y)
def __move_to(self, event):
""" Drag (move) canvas to the new position """
self.canvas.scan_dragto(event.x, event.y, gain=1)
self.__show_image() # zoom tile and show it on the canvas
def outside(self, x, y):
""" Checks if the point (x,y) is outside the image area """
bbox = self.canvas.coords(self.container) # get image area
if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]:
return False # point (x,y) is inside the image area
else:
return True # point (x,y) is outside the image area
def __wheel(self, event):
""" Zoom with mouse wheel """
x = self.canvas.canvasx(event.x) # get coordinates of the event on the canvas
y = self.canvas.canvasy(event.y)
if self.outside(x, y): return # zoom only inside image area
scale = 1.0
# Respond to Linux (event.num) or Windows (event.delta) wheel event
if event.num == 5 or event.delta == -120: # scroll down, smaller
if round(self.__min_side * self.imscale) < 30: return # image is less than 30 pixels
self.imscale /= self.__delta
scale /= self.__delta
if event.num == 4 or event.delta == 120: # scroll up, bigger
i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) >> 1
if i < self.imscale: return # 1 pixel is bigger than the visible area
self.imscale *= self.__delta
scale *= self.__delta
# Take appropriate image from the pyramid
k = self.imscale * self.__ratio # temporary coefficient
self.__curr_img = min((-1) * int(math.log(k, self.__reduction)), len(self.__pyramid) - 1)
self.__scale = k * math.pow(self.__reduction, max(0, self.__curr_img))
#
self.canvas.scale('all', x, y, scale, scale) # rescale all objects
# Redraw some figures before showing image on the screen
self.redraw_figures() # method for child classes
self.__show_image()
def __keystroke(self, event):
""" Scrolling with the keyboard.
Independent from the language of the keyboard, CapsLock, <Ctrl>+<key>, etc. """
if event.state - self.__previous_state == 4: # means that the Control key is pressed
pass # do nothing if Control key is pressed
else:
self.__previous_state = event.state # remember the last keystroke state
# Up, Down, Left, Right keystrokes
if event.keycode in [68, 39, 102]: # scroll right: keys 'D', 'Right' or 'Numpad-6'
self.__scroll_x('scroll', 1, 'unit', event=event)
elif event.keycode in [65, 37, 100]: # scroll left: keys 'A', 'Left' or 'Numpad-4'
self.__scroll_x('scroll', -1, 'unit', event=event)
elif event.keycode in [87, 38, 104]: # scroll up: keys 'W', 'Up' or 'Numpad-8'
self.__scroll_y('scroll', -1, 'unit', event=event)
elif event.keycode in [83, 40, 98]: # scroll down: keys 'S', 'Down' or 'Numpad-2'
self.__scroll_y('scroll', 1, 'unit', event=event)
def crop(self, bbox):
""" Crop rectangle from the image and return it """
if self.__huge: # image is huge and not totally in RAM
band = bbox[3] - bbox[1] # width of the tile band
self.__tile[1][3] = band # set the tile height
self.__tile[2] = self.__offset + self.imwidth * bbox[1] * 3 # set offset of the band
self.__image.close()
self.__image = Image.open(self.path) # reopen / reset image
self.__image.size = (self.imwidth, band) # set size of the tile band
self.__image.tile = [self.__tile]
return self.__image.crop((bbox[0], 0, bbox[2], band))
else: # image is totally in RAM
return self.__pyramid[0].crop(bbox)
def destroy(self):
""" ImageFrame destructor """
self.__image.close()
map(lambda i: i.close, self.__pyramid) # close all pyramid images
del self.__pyramid[:] # delete pyramid list
del self.__pyramid # delete pyramid variable
self.canvas.destroy()
self.__imframe.destroy()
class MainWindow(ttk.Frame):
""" Main window class """
def __init__(self, mainframe, path):
""" Initialize the main Frame """
ttk.Frame.__init__(self, master=mainframe)
self.master.title('Advanced Zoom v3.0')
self.master.geometry('800x600') # size of the main window
self.master.rowconfigure(0, weight=1) # make the CanvasImage widget expandable
self.master.columnconfigure(0, weight=1)
canvas = CanvasImage(self.master, path) # create widget
canvas.grid(row=0, column=0) # show widget
filename = './data/img_plg5.png' # place path to your image here
#filename = 'd:/Data/yandex_z18_1-1.tif' # huge TIFF file 1.4 GB
#filename = 'd:/Data/The_Garden_of_Earthly_Delights_by_Bosch_High_Resolution.jpg'
#filename = 'd:/Data/The_Garden_of_Earthly_Delights_by_Bosch_High_Resolution.tif'
#filename = 'd:/Data/heic1502a.tif'
#filename = 'd:/Data/land_shallow_topo_east.tif'
#filename = 'd:/Data/X1D5_B0002594.3FR'
app = MainWindow(tk.Tk(), path=filename)
app.mainloop()
P.S. Here is the GitHub application using advanced zoom for manual image annotation with polygons.
(The question TITLE doesn't indicate that it's focused on bitmaps. I add an answer here for those who were interested in basic zoom/pan support for canvas, and got here by a search engine)
The fundamental mechanism to support zoom (with wheel) and move/pan (with left-button drag) is as follows:
from tkinter import ALL, EventType
canvas.bind("<MouseWheel>", do_zoom)
canvas.bind('<ButtonPress-1>', lambda event: canvas.scan_mark(event.x, event.y))
canvas.bind("<B1-Motion>", lambda event: canvas.scan_dragto(event.x, event.y, gain=1))
def do_zoom(event):
x = canvas.canvasx(event.x)
y = canvas.canvasy(event.y)
factor = 1.001 ** event.delta
canvas.scale(ALL, x, y, factor, factor)
Simple extension: support zooming of each axis individually, by looking at the state of Ctrl and Shift, as follows:
def do_zoom(event):
x = canvas.canvasx(event.x)
y = canvas.canvasy(event.y)
factor = 1.001 ** event.delta
is_shift = event.state & (1 << 0) != 0
is_ctrl = event.state & (1 << 2) != 0
canvas.scale(ALL, x, y,
factor if not is_shift else 1.0,
factor if not is_ctrl else 1.0)
You might consider using map tiles for this case. The tiles can be specific to the zoom level. After selecting the tiles based on the pan and zoom level you can position them on the canvas with Canvas.create_image.
Assuming you have some tile class with its coordinates and image, you could select for visible tiles based on the pan, zoom and frame size.
for tile in visible_tiles(pan_center, frame_dimensions, zoom_level):
canvas.create_image(tile.x, tile.y, anchor=Tkinter.NW, image=tile.image)
There is a full sample of this in Tile-Based Geospatial Information Systems by John Sample and Elias Ioup in the chapter on Tiled Mapping Clients.
The following code produces a nice Canvas with an image and I can draw a square on top of it. However:
a) I can't get the Canvas to not scroll.
b) I only want the image to appear and nothing else and can't get the sizes right
As you will see, I have even tried to stop the scrolling but it does not work all the time. In addition the image is never fully aligned with the Canvas nor the window even though I set the sizes to be the same for the three (root, canvas and image).
Here is the code (partly taken already from another example with some portions commented out):
try:
from PIL import Image
except ImportError:
import Image
from PIL import ImageTk
try:
import Tkinter as tk # Python2
except ImportError:
import tkinter as tk # Python3s
import Tkinter
from Tkinter import *
import PIL as PILAll
class ExampleApp(Frame):
def __init__(self,master):
Frame.__init__(self,master=None)
self.x = 0
self.y = 0
self.canvas = Canvas(self, cursor="cross", width=640, height=480, confine=True, scrollregion=(10, 10, 10, 10), relief="groove", bg="blue")# and I have experimented with a few other options
#self.sbarv=Scrollbar(self,orient=VERTICAL)
#self.sbarh=Scrollbar(self,orient=HORIZONTAL)
#self.sbarv.config(command=self.canvas.yview)
#self.sbarh.config(command=self.canvas.xview)
self.canvas.config()#yscrollcommand=self.sbarv.set)
self.canvas.config()#xscrollcommand=self.sbarh.set)
self.canvas.config(scrollregion=self.canvas.bbox(ALL))
self.canvas.grid(row=0,column=0,sticky=N+S+E+W)
#self.sbarv.grid(row=0,column=1,stick=N+S)
#self.sbarh.grid(row=1,column=0,sticky=E+W)
self.canvas.bind("<ButtonPress-1>", self.on_button_press)
self.canvas.bind("<B1-Motion>", self.on_move_press)
self.canvas.bind("<ButtonRelease-1>", self.on_button_release)
self.canvas.bind("<Leave>", self.on_button_leave)
self.canvas.bind("<Enter>", self.on_button_enter)
self.canvas.bind("<Double-Button-1>", self.on_double_click)
self.canvas.create_line(0, 0, 200, 100)
self.canvas.create_line(0, 100, 200, 0, fill="red", dash=(4, 4))
self.canvas.create_rectangle(50, 25, 150, 75, fill="blue")
self.rect = None
self.text = None
self.start_x = None
self.start_y = None
self.im = PILAll.Image.open("../../" + "image6.JPG")
self.wazil,self.lard=self.im.size
self.canvas.config() #scrollregion=(0,0,self.wazil,self.lard))
self.tk_im = ImageTk.PhotoImage(self.im)
self.canvas.create_image(0,0,anchor="nw",image=self.tk_im)
out_of_scope = 1
def on_button_leave(self, event):
self.out_of_scope = 2
print "out_of_scope....", self.out_of_scope
def on_button_enter(self, event):
print("entering...")
self.out_of_scope = 1
def on_double_click(self, event):
print("double click")
def on_button_press(self, event):
# save mouse drag start position
self.start_x = self.canvas.canvasx(event.x)
self.start_y = self.canvas.canvasy(event.y)
# create rectangle if not yet exist
if not self.rect:
if self.out_of_scope == 1:
self.rect = self.canvas.create_rectangle(self.x, self.y, 1, 1, outline='blue', fill='yellow') #since it's only created once it always remains at the bottom
def get_out_of_scope(self, x, y):
return self.out_of_scope
def on_move_press(self, event):
curX = self.canvas.canvasx(event.x)
curY = self.canvas.canvasy(event.y)
var=self.get_out_of_scope(event.x, event.y)
print(var, event.x, event.y)
if var == 1:
w, h = self.canvas.winfo_width(), self.canvas.winfo_height()
if event.x > 0.9*w:
self.canvas.xview_scroll(1, 'units')
elif event.x < 0.1*w:
self.canvas.xview_scroll(-1, 'units')
if event.y > 0.9*h:
self.canvas.yview_scroll(1, 'units')
elif event.y < 0.1*h:
self.canvas.yview_scroll(-1, 'units')
# expand rectangle as you drag the mouse
self.canvas.coords(self.rect, self.start_x, self.start_y, curX, curY)
def on_button_release(self, event):
print(event.x, event.y)
pass
root=Tk()
root.geometry("640x480")
app = ExampleApp(root)
app.grid()
root.mainloop()
I think your code would benefit from beingreviewed but I will try to limit myself to the question...
If the canvas needs to be the same size as the image why is it constructed with width=640, height=480? You figure out the width and height of the image further down:
self.im = PILAll.Image.open("../../" + "image6.JPG")
self.wazil,self.lard=self.im.size
(interesting variable name choice btw) so if self.wazil and self.lard represent the width and height of the image why don't you make that the width and height of the canvas?
self.im = PILAll.Image.open("../../" + "image6.JPG")
self.wazil,self.lard=self.im.size
self.canvas = Canvas(self, width=self.wazil, height=self.lard) #, ...)
then the canvas will be the correct size but the root window is still forcing itself to be 640x480 from:
root.geometry("640x480")
but since widgets will automatically scale themselves to the contents you can just comment that line out and it should be the correct size.
#root.geometry("640x480")
I should note that I was experiencing some very odd behaviour about the position of the image being 3 pixels too high and 3 pixels to the left, drawing the image with:
self.canvas.create_image(3,3,anchor="nw",image=self.tk_im)
fixed it for me but I have no idea why...
As for the scrolling you removed the parts about the scroll bars but you left in this in on_move_press:
w, h = self.canvas.winfo_width(), self.canvas.winfo_height()
if event.x > 0.9*w:
self.canvas.xview_scroll(1, 'units')
elif event.x < 0.1*w:
self.canvas.xview_scroll(-1, 'units')
if event.y > 0.9*h:
self.canvas.yview_scroll(1, 'units')
elif event.y < 0.1*h:
self.canvas.yview_scroll(-1, 'units')
This is the section of code that is occasionally scrolling the canvas (happened when I tried to resize the window) so again you can comment that section out and it shouldn't scroll anymore.
Last note which is unrelated to question, you have:
def __init__(self,master):
Frame.__init__(self,master=None)
But I'm pretty sure you mean to have:
def __init__(self,master=None):
Frame.__init__(self,master)
since the first way you require a master argument but do not pass it to Frame.__init__. When Frame.__init__ receives a master of None it just uses the Tk instance which in your case is the same thing but if you used any other master it would cause very odd issues.
How would I add zooming in and out to the following script, i'd like to bind it to the mousewheel. If you're testing this script on linux don't forget to change the MouseWheel event to Button-4 and Button-5.
from Tkinter import *
import Image, ImageTk
class GUI:
def __init__(self,root):
frame = Frame(root, bd=2, relief=SUNKEN)
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
xscrollbar = Scrollbar(frame, orient=HORIZONTAL)
xscrollbar.grid(row=1, column=0, sticky=E+W)
yscrollbar = Scrollbar(frame)
yscrollbar.grid(row=0, column=1, sticky=N+S)
self.canvas = Canvas(frame, bd=0, xscrollcommand=xscrollbar.set, yscrollcommand=yscrollbar.set, xscrollincrement = 10, yscrollincrement = 10)
self.canvas.grid(row=0, column=0, sticky=N+S+E+W)
File = "PATH TO JPG PICTURE HERE"
self.img = ImageTk.PhotoImage(Image.open(File))
self.canvas.create_image(0,0,image=self.img, anchor="nw")
self.canvas.config(scrollregion=self.canvas.bbox(ALL))
xscrollbar.config(command=self.canvas.xview)
yscrollbar.config(command=self.canvas.yview)
frame.pack()
self.canvas.bind("<Button 3>",self.grab)
self.canvas.bind("<B3-Motion>",self.drag)
root.bind("<MouseWheel>",self.zoom)
def grab(self,event):
self._y = event.y
self._x = event.x
def drag(self,event):
if (self._y-event.y < 0): self.canvas.yview("scroll",-1,"units")
elif (self._y-event.y > 0): self.canvas.yview("scroll",1,"units")
if (self._x-event.x < 0): self.canvas.xview("scroll",-1,"units")
elif (self._x-event.x > 0): self.canvas.xview("scroll",1,"units")
self._x = event.x
self._y = event.y
def zoom(self,event):
if event.delta>0: print "ZOOM IN!"
elif event.delta<0: print "ZOOM OUT!"
root = Tk()
GUI(root)
root.mainloop()
To my knowledge the built-in Tkinter Canvas class scale will not auto-scale images. If you are unable to use a custom widget, you can scale the raw image and replace it on the canvas when the scale function is invoked.
The code snippet below can be merged into your original class. It does the following:
Caches the result of Image.open().
Adds a redraw() function to calculate the scaled image and adds that to the canvas, and also removes the previously-drawn image if any.
Uses the mouse coordinates as part of the image placement. I just pass x and y to the create_image function to show how the image placement shifts around as the mouse moves. You can replace this with your own center/offset calculation.
This uses the Linux mousewheel buttons 4 and 5 (you'll need to generalize it to work on Windows, etc).
(Updated) Code:
class GUI:
def __init__(self, root):
# ... omitted rest of initialization code
self.canvas.config(scrollregion=self.canvas.bbox(ALL))
self.scale = 1.0
self.orig_img = Image.open(File)
self.img = None
self.img_id = None
# draw the initial image at 1x scale
self.redraw()
# ... rest of init, bind buttons, pack frame
def zoom(self,event):
if event.num == 4:
self.scale *= 2
elif event.num == 5:
self.scale *= 0.5
self.redraw(event.x, event.y)
def redraw(self, x=0, y=0):
if self.img_id:
self.canvas.delete(self.img_id)
iw, ih = self.orig_img.size
size = int(iw * self.scale), int(ih * self.scale)
self.img = ImageTk.PhotoImage(self.orig_img.resize(size))
self.img_id = self.canvas.create_image(x, y, image=self.img)
# tell the canvas to scale up/down the vector objects as well
self.canvas.scale(ALL, x, y, self.scale, self.scale)
Update I did a bit of testing for varying scales and found that quite a bit of memory is being used by resize / create_image. I ran the test using a 540x375 JPEG on a Mac Pro with 32GB RAM. Here is the memory used for different scale factors:
1x (500, 375) 14 M
2x (1000, 750) 19 M
4x (2000, 1500) 42 M
8x (4000, 3000) 181 M
16x (8000, 6000) 640 M
32x (16000, 12000) 1606 M
64x (32000, 24000) ...
reached around ~7400 M and ran out of memory, EXC_BAD_ACCESS in _memcpy
Given the above, a more efficient solution might be to determine the size of the viewport where the image will be displayed, calculate a cropping rectangle around the center of the mouse coordinates, crop the image using the rect, then scale just the cropped portion. This should use constant memory for storing the temporary image. Otherwise you may need to use a 3rd party Tkinter control which performs this cropping / windowed scaling for you.
Update 2 Working but oversimplified cropping logic, just to get you started:
def redraw(self, x=0, y=0):
if self.img_id: self.canvas.delete(self.img_id)
iw, ih = self.orig_img.size
# calculate crop rect
cw, ch = iw / self.scale, ih / self.scale
if cw > iw or ch > ih:
cw = iw
ch = ih
# crop it
_x = int(iw/2 - cw/2)
_y = int(ih/2 - ch/2)
tmp = self.orig_img.crop((_x, _y, _x + int(cw), _y + int(ch)))
size = int(cw * self.scale), int(ch * self.scale)
# draw
self.img = ImageTk.PhotoImage(tmp.resize(size))
self.img_id = self.canvas.create_image(x, y, image=self.img)
gc.collect()
Just for other's benefit who find this question I'm attaching my neer final test code which uses picture in picture/magnifying glass zooming. Its basically just an alteration to what samplebias already posted. It's also very cool to see as well :).
As I said before, if you're using this script on linux don't forget to change the MouseWheel event to Button-4 and Button-5. And you obviously need to insert a .JPG path where it says "INSERT JPG FILE PATH".
from Tkinter import *
import Image, ImageTk
class LoadImage:
def __init__(self,root):
frame = Frame(root)
self.canvas = Canvas(frame,width=900,height=900)
self.canvas.pack()
frame.pack()
File = "INSERT JPG FILE PATH"
self.orig_img = Image.open(File)
self.img = ImageTk.PhotoImage(self.orig_img)
self.canvas.create_image(0,0,image=self.img, anchor="nw")
self.zoomcycle = 0
self.zimg_id = None
root.bind("<MouseWheel>",self.zoomer)
self.canvas.bind("<Motion>",self.crop)
def zoomer(self,event):
if (event.delta > 0):
if self.zoomcycle != 4: self.zoomcycle += 1
elif (event.delta < 0):
if self.zoomcycle != 0: self.zoomcycle -= 1
self.crop(event)
def crop(self,event):
if self.zimg_id: self.canvas.delete(self.zimg_id)
if (self.zoomcycle) != 0:
x,y = event.x, event.y
if self.zoomcycle == 1:
tmp = self.orig_img.crop((x-45,y-30,x+45,y+30))
elif self.zoomcycle == 2:
tmp = self.orig_img.crop((x-30,y-20,x+30,y+20))
elif self.zoomcycle == 3:
tmp = self.orig_img.crop((x-15,y-10,x+15,y+10))
elif self.zoomcycle == 4:
tmp = self.orig_img.crop((x-6,y-4,x+6,y+4))
size = 300,200
self.zimg = ImageTk.PhotoImage(tmp.resize(size))
self.zimg_id = self.canvas.create_image(event.x,event.y,image=self.zimg)
if __name__ == '__main__':
root = Tk()
root.title("Crop Test")
App = LoadImage(root)
root.mainloop()
Might be a good idea to look at the TkZinc widget instead of the simple canvas for what you are doing, it supports scaling via OpenGL.
Advanced zoom example, based on tiles. Like in Google Maps.
Simplified zoom example, based on resizing the whole image.