Adding Zooming in and out with a Tkinter Canvas Widget? - python

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.

Related

Out of memory issue with digital picture frame program on Raspberry Pi (Model B, rev. 2.0)

I am working on a Python program for displaying photos on the Raspberry Pi (Model B Revision 2.0 with 512MB RAM). It uses Tk for displaying the images.
The program is mostly finished, but I ran into an issue where the program is terminated by the kernel because of low memory. This seems to happen randomly.
I do not understand why this happens. I have noticed that during image switching, the CPU spikes up significantly (up to 90%). I therefore thought that it might be an issue with the CPU not keeping up between two images and then falling behind and running out of memory. To test this I increased the timeout between showing images to 1 minute, but that did not help.
My question is, whether I am doing something wrong/inefficiently in the code (see below)? If not: I am considering switching to PyQt, because it seems to accelerate graphics with OpenGL (from what I read). Is this true and/or do you think that this might help with the issue I am facing?
This is my current Python code:
# From: https://stackoverflow.com/questions/19838972/how-to-update-an-image-on-a-canvas
import os
from pathlib import Path
from tkinter import *
from PIL import Image, ExifTags, ImageTk
import ipdb
class MainWindow():
def __init__(self, main):
self.my_images = []
self._imageDirectory = str(Path.home().joinpath("./Pictures/rpictureframe"))
self.main = main
w, h = main.winfo_screenwidth(), root.winfo_screenheight()
self.w, self.h = w, h
main.attributes("-fullscreen", True) # REF: https://stackoverflow.com/questions/45136287/python-tkinter-toggle-quit-fullscreen-image-with-double-mouse-click
main.focus_set()
self.canvas = Canvas(main, width=w, height=h)
self.canvas.configure(background="black", highlightthickness=0)
self.canvas.pack()
self.firstCall = True
# set first image on canvas
self.image_on_canvas = self.canvas.create_image(w/2, h/2, image = self.getNextImage()) ### replacing getNextImage instead of getNextImageV1 here fails
#property
def imageDirectory(self):
return self._imageDirectory
#imageDirectory.setter
def setImageDirectory(self,imageDirectory):
self._imageDirectory = imageDirectory
def getNextImage(self):
if self.my_images == []:
self.my_images = os.listdir(self.imageDirectory)
currentImagePath = self.imageDirectory + "/" + self.my_images.pop()
self.currentImage = self.readImage(currentImagePath, self.w, self.h)
return self.currentImage
def readImage(self,imagePath,w,h):
pilImage = Image.open(imagePath)
pilImage = self.rotateImage(pilImage)
pilImage = self.resizeImage(pilImage,w,h)
return ImageTk.PhotoImage(pilImage)
def rotateImage(self,image):
# REF: https://stackoverflow.com/a/26928142/653770
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation]=='Orientation':
break
exif=dict(image._getexif().items())
if exif[orientation] == 3:
image=image.rotate(180, expand=True)
elif exif[orientation] == 6:
image=image.rotate(270, expand=True)
elif exif[orientation] == 8:
image=image.rotate(90, expand=True)
except (AttributeError, KeyError, IndexError):
# cases: image don't have getexif
pass
return image
def resizeImage(self,pilImage,w,h):
imgWidth, imgHeight = pilImage.size
if imgWidth > w or imgHeight > h:
ratio = min(w/imgWidth, h/imgHeight)
imgWidth = int(imgWidth*ratio)
imgHeight = int(imgHeight*ratio)
pilImage = pilImage.resize((imgWidth,imgHeight), Image.ANTIALIAS)
return pilImage
def update_image(self):
# REF: https://stackoverflow.com/questions/7573031/when-i-use-update-with-tkinter-my-label-writes-another-line-instead-of-rewriti/7582458#
self.canvas.itemconfig(self.image_on_canvas, image = self.getNextImage()) ### replacing getNextImage instead of getNextImageV1 here fails
self.main.after(5000, self.update_image)
root = Tk()
app = MainWindow(root)
app.update_image()
root.mainloop()
UPDATE:
Below you will find the current code that still produces the out-of-memory issue.
You can find the dmesg out-of-memory error here: https://pastebin.com/feTFLSxq
Furthermore this is the periodic (every second) output from top: https://pastebin.com/PX99VqX0
I have plotted the columns 6 and 7 (memory usage) of the top output:
As you can see, there does not appear to be a continues increase in memory usage as I would expect from a memory leak.
This is my current code:
# From: https://stackoverflow.com/questions/19838972/how-to-update-an-image-on-a-canvas
import glob
from pathlib import Path
from tkinter import *
from PIL import Image, ExifTags, ImageTk
class MainWindow():
def __init__(self, main):
self.my_images = []
self._imageDirectory = str(Path.home().joinpath("Pictures/rpictureframe"))
self.main = main
w, h = main.winfo_screenwidth(), root.winfo_screenheight()
self.w, self.h = w, h
# main.attributes("-fullscreen", True) # REF: https://stackoverflow.com/questions/45136287/python-tkinter-toggle-quit-fullscreen-image-with-double-mouse-click
main.focus_set()
self.canvas = Canvas(main, width=w, height=h)
self.canvas.configure(background="black", highlightthickness=0)
self.canvas.pack()
# set first image on canvas
self.image_on_canvas = self.canvas.create_image(w / 2, h / 2,
image=self.getNextImage()) ### replacing getNextImage instead of getNextImageV1 here fails
#property
def imageDirectory(self):
return self._imageDirectory
#imageDirectory.setter
def setImageDirectory(self, imageDirectory):
self._imageDirectory = imageDirectory
def getNextImage(self):
if self.my_images == []:
# self.my_images = os.listdir(self.imageDirectory)
self.my_images = glob.glob(f"{self.imageDirectory}/*.jpg")
currentImagePath = self.my_images.pop()
self.currentImage = self.readImage(currentImagePath, self.w, self.h)
return self.currentImage
def readImage(self, imagePath, w, h):
with Image.open(imagePath) as pilImage:
pilImage = self.rotateImage(pilImage)
pilImage = self.resizeImage(pilImage, w, h)
return ImageTk.PhotoImage(pilImage)
def rotateImage(self, image):
# REF: https://stackoverflow.com/a/26928142/653770
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = dict(image._getexif().items())
if exif[orientation] == 3:
image = image.rotate(180, expand=True)
elif exif[orientation] == 6:
image = image.rotate(270, expand=True)
elif exif[orientation] == 8:
image = image.rotate(90, expand=True)
except (AttributeError, KeyError, IndexError):
# cases: image don't have getexif
pass
return image
def resizeImage(self, pilImage, w, h):
imgWidth, imgHeight = pilImage.size
if imgWidth > w or imgHeight > h:
ratio = min(w / imgWidth, h / imgHeight)
imgWidth = int(imgWidth * ratio)
imgHeight = int(imgHeight * ratio)
pilImage = pilImage.resize((imgWidth, imgHeight), Image.ANTIALIAS)
return pilImage
def update_image(self):
# REF: https://stackoverflow.com/questions/7573031/when-i-use-update-with-tkinter-my-label-writes-another-line-instead-of-rewriti/7582458#
self.canvas.itemconfig(self.image_on_canvas,
image=self.getNextImage()) ### replacing getNextImage instead of getNextImageV1 here fails
self.main.after(30000, self.update_image)
root = Tk()
app = MainWindow(root)
app.update_image()
root.mainloop()
I believe there is a memory leak when you open the image files with PIL and don't close them.
To avoid it, you must call Image.close(), or better yet consider using the with syntax.
def readImage(self,imagePath,w,h):
with Image.open(imagePath) as pilImage:
pilImage = self.rotateImage(pilImage)
pilImage = self.resizeImage(pilImage,w,h)
return ImageTk.PhotoImage(pilImage)
I run the code on my machine and I noticed similiar spikes.
After some memory adjustments on a virtual machine I had a system without swap (turned of to get the crash "faster") and approximately 250Mb free memory.
While base memory usage was somewhere around 120Mb, the image change was between 190Mb and 200Mb (using images with a file size of 6,6Mb and 5184x3456 pixel) similiar to your plot.
Then I copied a bigger (panorama) image (8,1Mb with 20707x2406 pixel) to the folder - and voila the machine got stuck.
I could see that the memory usage of the process reached 315Mb and the system became unusable (after 1 minute I "pulled the plug" on the VM).
So I think your problem has nothing todo with the actual code, but with the pictures you are trying to load (and the limited amount of RAM/Swap from your system). Maybe skipping the rotate and resize functions might mitigate your problem...

tkinter - problem with a coloring a rectangle in a grid made out of rectangles

I am trying to write a pathfinding algorithm in python. The user is supposed to select a starting point by hovering the mouse of a field and pressing s. The field should now change the color.
However, I can't figure out what is wrong with my code. I am only able to color to color the fields from top left corner to the bottom right corner. In the code, Im printing out the objectID in console, which shows that there is maybe something wrong with the way of how I created the rectangles.
I'm creating the rectangles in the draw_grid method in the Window class and coloring the fields in the select_start_node method.
import tkinter as tk
class Window:
def __init__(self):
self.height = 600
self.width = 600
self.grid_list = {x for x in range(0, 600)}
self.grid = []
self.grid_dict = {}
self.root = tk.Tk()
self.root.geometry("600x600")
self.root.resizable(False, False)
self.canvas = tk.Canvas(self.root, width=self.width,
height=self.height, background="white")
self.canvas.bind("s", self.select_start_node)
self.canvas.bind("<1>", lambda event:
self.canvas.focus_set())
def draw_grid(self):
print(self.grid)
for x in self.grid_list:
if x % 30 == 0:
self.grid.append(x)
else:
pass
print(self.grid)
for x in self.grid:
for y in self.grid:
print(x, y+30)
rec = self.canvas.create_rectangle(x, x, y+30, y+30)
self.canvas.pack()
def select_start_node(self, event):
print(event.x, event.y)
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
item = self.canvas.find_closest(x, y)
p = self.canvas.coords(item)
print(item)
print(p)
self.canvas.create_rectangle(p[0], p[0], p[0]+30, p[0]+30, fill="red")
def main():
node_list = []
cord_list = []
window = Window()
window.draw_grid()
window.root.mainloop()
if __name__ == "__main__":
main()
I don't know the entire design of you game, but suggest that you do things differently with respect to the grid of rectangles. In the code below self.grid is a 2-dimensional list-of-lists and each entry is a Canvas rectangle object. This make selecting and changing one of them relatively each because canvas.find_closest(x, y) will give you the object id of the associated rectangle object directly, which makes changing its fill color trivial.
Because of that, I also changed it so you can just click on one of the rectangles to change it instead of moving the mouse cursor and then pressing a key.
Also note that I also got rid of most those hardcoded numerical constants you were using all over the place, which makes the code more flexible in case you decide to change one of them at a later time.
import tkinter as tk
class Window:
def __init__(self):
self.cell_size = 30
self.height = 600
self.width = 600
self.hz_cells = self.width // self.cell_size # Number of horizontal cells.
self.vt_cells = self.height // self.cell_size # Number of vertical cells.
# Preallocate 2D grid (list-of-lists).
self.grid = [[None for _ in range(self.hz_cells)]
for _ in range(self.vt_cells)]
self.root = tk.Tk()
self.root.geometry("%sx%s" % (self.width, self.height))
self.root.resizable(False, False)
self.canvas = tk.Canvas(self.root, width=self.width,
height=self.height, background="white")
self.canvas.pack()
self.canvas.bind("<1>", self.select_start_node)
# You can still do it this way if you want.
# self.canvas.bind("s", self.select_start_node)
# self.canvas.bind("<1>", lambda event: self.canvas.focus_set())
def draw_grid(self):
""" Fill Canvas with a grid of white rectangles. """
for i in range(self.hz_cells):
x = i * self.cell_size
for j in range(self.vt_cells):
y = j * self.cell_size
self.grid[i][j] = self.canvas.create_rectangle(
x, y, x+self.cell_size, y+self.cell_size, fill="white")
def select_start_node(self, event):
""" Change the color of the rectangle closest to x, y of event. """
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
selected_rect = self.canvas.find_closest(x, y)
if selected_rect:
self.canvas.itemconfigure(selected_rect, fill="red") # Change color.
def main():
node_list = []
cord_list = []
window = Window()
window.draw_grid()
window.root.mainloop()
if __name__ == "__main__":
main()

Tkinter canvas zoom + move/pan get mouse position

After reading this post:
Tkinter canvas zoom + move/pan
I wanted to try to get the position of the cursor/pixel, but I cannot figure out how to do it.
For exemple I want that when I zoom in/out and click on the same point of the image, I get the same x,y coordinates on the image:
I have tried to play with self.imscale, to bind a mouse click with the canvasx(evt.x) function but nothing works!
# -*- 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.canvas.bind('<Button-3>', self.pix_pos)
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 pix_pos(self, evt=None):
bbox = self.canvas.bbox(self.container)
print("x: " + str(bbox[0]) + ", y: " + str(bbox[1]))
x = self.canvas.canvasx(evt.x)# / self.imscale
y = self.canvas.canvasy(evt.y)# / self.imscale
print(str(evt.x) + ", " + str(evt.y))
print("x: " + str(evt.x - bbox[0]) + "y: " + str(evt.y - bbox[1]))
print("imgscale:" + str(self.imscale) + " x: " + str(x) + " y: " + str(y))
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()

Tkinter canvas zoom + move/pan

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.

Basic image editing on Python tkinter working but unwanted image scrolling course when dragging mouse

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.

Categories