Tkinter canvas zoom + move/pan - python
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.
Related
QGraphicsPixmapItem zooming in/out
My application is for drawing shapes (polygons, points ..etc), I used QGrapichView and QGrapichScene and I added two different pixmap items (one for drawing, and the other one is 'legend' that will show the user how much the distance is), So far so good. I implemented the zoom functionality and it is working fun. Still, my problem is: that when I zoom in/out the whole scene (the canvas QGraphicsPixmapItem and the legend QGraphicsPixmapItem) is affected and that is expected because I am re-scaling the QGrapichView while zooming (using the mouse wheel). What I want is: I need only the canvas item to be zoomed in/out not the whole scene so that the legend will always be visible to the user. Here is a snippet of the code that am using in zooming from this answer: class PixmapScene(QGraphicsScene): pass class Canvas(QGraphicsView): def __init__(self, scene): super().__init__(scene) self.scene = scene self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) background_color = QColor("#443b36") self.pixmap_item: QGraphicsItem = self.scene.addPixmap(QPixmap(780, 580)) self.pixmap_item.setTransformationMode(Qt.FastTransformation) self.pixmap_item.pixmap().fill(background_color) self.legend = QPixmap(780, 580) self.legend.fill(QColor("#00ffffff")) p = QtGui.QPainter(self.legend) p.setPen(QPen(QColor("#0000FF"), 4)) p.drawLine(35 * 5.1, self.legend.height() - 60, 35 * 8.9, self.legend.height() - 60) p.setPen(QColor("#c9c9c9")) p.setFont(QFont('Century Gothic', 14)) p.drawText(35 * 5.5, self.legend.height() - 35, f'this text is from the other pixmap (legend)') p.end() self.scene.addPixmap(self.legend) self.zoom_times = 0 def wheelEvent(self, event): zoom_in_factor = 1.25 zoom_out_factor = 1 / zoom_in_factor # Save the scene pos old_pos = self.mapToScene(event.pos()) # Zoom if event.angleDelta().y() > 0: if self.zoom_times == 6: return zoom_factor = zoom_in_factor self.zoom_times += 1 else: if self.zoom_times == 0: return zoom_factor = zoom_out_factor self.zoom_times -= 1 # here we are scaling the whole scene, what I want is zooming with keeping legend as it is self.scale(zoom_factor, zoom_factor) # Get the new position new_pos = self.mapToScene(event.pos()) # Move scene to old position delta = new_pos - old_pos self.translate(delta.x(), delta.y())
Image in Button tkinter
I am having a problem in making a login image to a button. I have succeeded in making the image with a transparent background, but I can't succeed to make the button with transparent background. I attached a screenshot that shows what I mean. The upper 'login' is a image (with transparent background), the lower is Login button but there is a white background around it. I want to make a button with transparent background. self.login_image = Image.open('images/LoginButton.png') self.login_image = ImageTk.PhotoImage(self.login_image) self.main_screen_canvas.create_image(700, 300, image=self.login_image) self.login_button = Button(self.main_screen_canvas, borderwidth=0, image=self.login_image) self.login_button.place(x=300,y=400) What should I do? BackgroundImage LoginButtonImage
Here's how to do what I was suggesting in the comment which uses the technique shown in another answer of mine to simulate a tkinter Button on a Canvas that has a transparent image placed on it (instead of text). One issue I ran into was that fact that your 2421 × 1210 pixel background image was larger than my screen. To deal with it I added a fitrect() helper function to determine a new smaller size for it that would fit. I wrote it a long time ago, but have found it handy to have around many times (like now). Note that in the code ll and ur refer to the lower-left and upper-right corners of the rectangles involved. Here's the resulting code: from PIL import Image, ImageTk import tkinter as tk class CanvasButton: """ Create left mouse button clickable canvas image object. The x, y coordinates are relative to the top-left corner of the canvas. """ flash_delay = 100 # Milliseconds. def __init__(self, canvas, x, y, image_source, command, state=tk.NORMAL): self.canvas = canvas if isinstance(image_source, str): self.btn_image = tk.PhotoImage(file=image_source) else: self.btn_image = image_source self.canvas_btn_img_obj = canvas.create_image(x, y, anchor='nw', state=state, image=self.btn_image) canvas.tag_bind(self.canvas_btn_img_obj, "<ButtonRelease-1>", lambda event: (self.flash(), command())) def flash(self): self.set_state(tk.HIDDEN) self.canvas.after(self.flash_delay, self.set_state, tk.NORMAL) def set_state(self, state): """ Change canvas button image's state. Normally, image objects are created in state tk.NORMAL. Use value tk.DISABLED to make it unresponsive to the mouse, or use tk.HIDDEN to make it invisible. """ self.canvas.itemconfigure(self.canvas_btn_img_obj, state=state) def fitrect(r1_ll_x, r1_ll_y, r1_ur_x, r1_ur_y, r2_ll_x, r2_ll_y, r2_ur_x, r2_ur_y): """ Find the largest rectangle that will fit within rectangle r2 that has rectangle r1's aspect ratio. Note: Either the width or height of the resulting rect will be identical to the corresponding dimension of rect r2. """ # Calculate aspect ratios of rects r1 and r2. deltax1, deltay1 = (r1_ur_x - r1_ll_x), (r1_ur_y - r1_ll_y) deltax2, deltay2 = (r2_ur_x - r2_ll_x), (r2_ur_y - r2_ll_y) aspect1, aspect2 = (deltay1 / deltax1), (deltay2 / deltax2) # Compute size of resulting rect depending on which aspect ratio is bigger. if aspect1 > aspect2: result_ll_y, result_ur_y = r2_ll_y, r2_ur_y delta = deltay2 / aspect1 result_ll_x = r2_ll_x + (deltax2 - delta) / 2.0 result_ur_x = result_ll_x + delta else: result_ll_x, result_ur_x = r2_ll_x, r2_ur_x delta = deltax2 * aspect1 result_ll_y = r2_ll_y + (deltay2 - delta) / 2.0 result_ur_y = result_ll_y + delta return result_ll_x, result_ll_y, result_ur_x, result_ur_y def btn_clicked(): """ Prints to console a message every time the button is clicked """ print("Button Clicked") background_image_path = 'background_image.jpg' button_image_path = 'button_image.png' root = tk.Tk() root.update_idletasks() background_img = Image.open(background_image_path) # Must use PIL for JPG images. scrnwidth, scrnheight = root.winfo_screenwidth(), root.winfo_screenheight() bgrdwidth, bgrdheight = background_img.size border_width, border_height = 20, 20 # Allow room for window's decorations. # Determine a background image size that will fit on screen with a border. bgr_ll_x, bgr_ll_y, bgr_ur_x, bgr_ur_y = fitrect( 0, 0, bgrdwidth, bgrdheight, 0, 0, scrnwidth-border_width, scrnheight-border_height) bgr_width, bgr_height = int(bgr_ur_x-bgr_ll_x), int(bgr_ur_y-bgr_ll_y) # Resize background image to calculated size. background_img = ImageTk.PhotoImage(background_img.resize((bgr_width, bgr_height))) # Create Canvas same size as fitted background image. canvas = tk.Canvas(root, bd=0, highlightthickness=0, width=bgr_width, height=bgr_height) canvas.pack(fill=tk.BOTH) # Put background image on Canvas. background = canvas.create_image(0, 0, anchor='nw', image=background_img) # Put CanvasButton on Canvas centered at the bottom. button_img = tk.PhotoImage(file=button_image_path) btn_x, btn_y = (bgr_width/2), (bgr_height-button_img.height()) canvas_btn1 = CanvasButton(canvas, btn_x, btn_y, button_img, btn_clicked) root.mainloop() And here's the result of running it:
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()
How do I add a click and drag function to custom Canvas buttons?
I made a simple grid of squares using tkinter and canvas and each square is meant to act like a button. I have it right now so that each time you click a button, it will change it own color. It cycles between four colors and white, meaning unselected. I would like to know how to bind not only a click, but a click-and-drag. So the user could click on a square, and while holding down, drag the cursor over the other squares that you would like to press. I thought it would work with the <B1-Motion> event, but I find that that only causes the initially clicked button to cycle through its colors. Here is my code: import Tkinter as tk class FreeformFrame(tk.Toplevel): """ The FreeformFrame class creates a window for the selection of exact droplet placement for printing. """ def __init__(self, parent, master=None, **settings): tk.Toplevel.__init__(self, parent) self.parent = parent # parent widget if master == None: self.master = parent else: self.master = master # ============ IMPORTED VARIABLES =========== # # TEST PARAMETERS self.res = 200 # microns self.dim_x = 10 # mm self.dim_y = 10 # mm self.c_offset = 30 # Px - Adds some spacing between the edge of the canvas and where items are draw self.text_offset = 8 # Px - Adds some spacing beween the edge of the canvas and where text is placed # ===================== WINDOW VARIABLES ===================== # self.tags_list = [] # Could be used later to target specific shapes drawn to the canvas # ========= FRAME PARAMETERS ========= # self.name = "Freeform Selection" # Text that appears at the top of the window self.w_width = 700 # Pixel width of main Tk window self.w_height = 700 # Pixel height of main Tk window self.w_center_x = self.w_width/2 # px coordinate (x) of center of window self.w_center_y = self.w_height/2 # px coordinate (y) of center of window self.w_x_offset = 100# (self.master.scr_width-self.w_width)/2 # Pixel x offset for the Tk window. self.w_y_offset = 100# (self.master.scr_height-self.w_height)/2 # Pixel y offset for the Tk window. # ========== CHANNEL BUTTON SPACING ========== # self.buttons_x = int(math.ceil(1000.*self.dim_x/(self.res))) self.buttons_y = int(math.ceil(1000.*self.dim_y/(self.res))) self.select = [] # ==================== CLASS INITIALIZATION ==================== # # ========== TOPLEVEL WINDOW =========== # self.wm_title(self.name) self.geometry("%dx%d+%d+%d" % (self.w_width, self.w_height, self.w_x_offset, self.w_y_offset)) self.resizable(width=False,height=False) # ========== CANVAS CONFIGURATION ========== # # Start CanvasWell a.k.a. tk.Canvas in disguise preconfigured with buttons for selection self.freeform_space = FreeformCanvas(self,self.master) self.freeform_space.pack(fill="both", expand=True) # Draw channel buttons for col in range(self.buttons_x): for row in range(self.buttons_y): self.channel_button = ChannelButton(self.freeform_space,col,row) class FreeformCanvas(tk.Canvas): """ The FreeformCanvas represents the freeform selection space """ def __init__(self, parent, master=None, **kwargs): tk.Canvas.__init__(self, parent, **kwargs) self.parent = parent # parent widget if master == None: self.master = parent else: self.master = master # ========== CANVAS PARAMETERS ========== # self.c_offset = parent.c_offset # Adds some spacing between the edge of the canvas and where items are draw self.text_offset = parent.text_offset # Adds some spacing beween the edge of the canvas and where text is placed # Delimints the draw area self.bbox = [self.c_offset, self.c_offset, self.parent.w_width-self.c_offset, self.parent.w_height-self.c_offset] self.center_x = self.parent.w_center_x self.center_y = self.parent.w_center_y self.start_x = self.c_offset self.start_y = self.c_offset # TEST PARAMETERS self.res = parent.res # microns self.dim_x = parent.dim_x # mm self.dim_y = parent.dim_y # mm # Calculate the pixel scale self.px_per_mm = (self.bbox[2]-self.bbox[0])/(self.dim_x) # pixels per mm self.mm_per_px = self.px_per_mm **-1 # Label placement self.label_coord = [self.text_offset, self.text_offset] # XY display placement self.xy_label_coord = [self.parent.w_width-self.text_offset, self.parent.w_height-self.text_offset] # ========== CANVAS WELL INITIALIZATION ========== # self.config(bg=PLATE_BG, width=self.parent.w_width, height=self.parent.w_height, highlightthickness=0, borderwidth=0) self.bind("<Motion>", self.show_coordinates) # Draw label for build type self.label = self.create_text(self.label_coord, text="Freeform Selection", anchor="nw", fill=TEXT_PREVIEW_COLOR) # Draw coordinate display self.xy_label = self.create_text(self.xy_label_coord,text="Coord: (X = ? mm, Y = ? mm)", anchor="se", fill=TEXT_PREVIEW_COLOR) def show_coordinates(self, event=None): x_coord = self.mm_per_px*(event.x-(self.parent.w_width/2.0)) y_coord = self.mm_per_px*(event.y-(self.parent.w_height/2.0)) self.itemconfigure(self.xy_label, text="Coord: (X = %.3f mm, Y = %.3f mm)" % (x_coord, y_coord)) class ChannelButton(object): def __init__(self, freeform_canvas, x, y): super(ChannelButton, self).__init__() # ========== IMPORTED VARIABLES ========== # self.canvas = freeform_canvas self.scale = freeform_canvas.px_per_mm # ========= CANVAS BUTTON PARAMETERS ========= # self.x = x # x index (column) self.y = y # y index (row) self.res = freeform_canvas.res # in microns self.c_offset = freeform_canvas.c_offset self.c_width = freeform_canvas.dim_x self.c_height = freeform_canvas.dim_y self.name = "Coord%i,%i" % (self.x,self.y) self.scale = freeform_canvas.px_per_mm self.channel_select = 0 # color # Unselected: #FFFFFF (White) # Channel 1: #DE0600 (Red) # Channel 2: #032F95 (Blue) # Channel 3: #09B400 (Green) # Channel 4: #FFA100 (Orange) # Center-to-center spacing between channel buttons self.c_to_c = self.scale*self.res/1000. self.center_x = self.c_offset+(self.scale*self.res/2000.)+self.c_to_c*self.x self.center_y = self.c_offset+(self.scale*self.res/2000.)+self.c_to_c*self.y # Start of bbox self.start_x = self.c_offset+self.c_to_c*self.x self.start_y = self.c_offset+self.c_to_c*self.y # End of bbox self.end_x = self.c_offset+(self.scale*self.res/1000.)+self.c_to_c*self.x self.end_y = self.c_offset+(self.scale*self.res/1000.)+self.c_to_c*self.y # Construct bbox self.bbox = self.start_x, self.start_y, self.end_x, self.end_y # Draw self freeform_canvas.create_rectangle(self.bbox,tags=self.name,fill=UNSELECTED) freeform_canvas.tag_bind(self.name,'<Button-1>', lambda event, loc=[self.x,self.y]:self.click_button(event,loc)) freeform_canvas.tag_bind(self.name,'<B1-Motion>', lambda event, loc=[self.x,self.y]:self.click_button(event,loc)) def click_button(self, event, loc): x = loc[0] y = loc[1] print self.name self.channel_select += 1 self.channel_select = self.channel_select % 5 if self.channel_select == 0: self.canvas.itemconfig("Coord%i,%i"%(x,y),fill=UNSELECTED) if self.channel_select == 1: self.canvas.itemconfig("Coord%i,%i"%(x,y),fill=CH1) if self.channel_select == 2: self.canvas.itemconfig("Coord%i,%i"%(x,y),fill=CH2) if self.channel_select == 3: self.canvas.itemconfig("Coord%i,%i"%(x,y),fill=CH3) if self.channel_select == 4: self.canvas.itemconfig("Coord%i,%i"%(x,y),fill=CH4) if __name__ == "__main__": root = tk.Tk() fff = FreeformFrame(root) root.mainloop() Thank you for your input!
Adding Zooming in and out with a Tkinter Canvas Widget?
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.