Tkinter bind widgets below a rectangle widget to a mouse event - python

I hope I am explaining the problem correctly.
My example below is able to move two images defined on a canvas. The problem is that I want a rectangle, also defined on the canvas, on top of the images. When I do that using .tag_raise, the event triggered by mouse drag is triggered by the rectangle, not the images.
I tried using bing_class but that did not work. I tried to define a separate canvas for the rectangle but it has to overlay the main canvas and I got stuck.
How to keep the rectangle on top but bind the images to my mouse drag event?
import Tkinter as tk # for Python2
import PIL.Image, PIL.ImageTk
win = tk.Tk()
canvas = tk.Canvas(win, height = 500, width = 500)
#Create a rectangle with stipples on top of the images
rectangle = canvas.create_rectangle(0, 0, 400, 300, fill = "gray", stipple = "gray12")
#Create two images
SPRITE = PIL.Image.open("image.jpg")
imagePIL = SPRITE.resize((100, 100))
imagePI = PIL.ImageTk.PhotoImage(imagePIL)
image1 = canvas.create_image(100, 100, image = imagePI, tags = "image")
image2 = canvas.create_image(200, 200, image = imagePI, tags = "image")
#Callback
# Here I select image1 or image2 depending on where I click, and
# drag them on the canvas. The problem is when I put the rectangle
# on top using tag_raise (see below).
def callback(event):
id = canvas.find_withtag(tk.CURRENT)
canvas.coords(id, (event.x, event.y))
#Binding
canvas.bind("<B1-Motion>", callback)
#Place the rectangle on top of all
canvas.pack()
# This is the problem. I want to have the rectangle on top and be able to use the callback
#canvas.tag_raise(rectangle)
canvas.mainloop()
SOLUTION: I enhanced Nehal's answer with the following code. His answer had a glitch, by which images could be switched. In my enhancement I solve it by storing a lock for each image so that, while dragging an image around on the canvas, the same image is dragged. When I move e.g. image1 over image2 I notice that image1 does not completely move over image2, which is fine for me.
import Tkinter as tk # for Python2
import PIL.Image, PIL.ImageTk
win = tk.Tk()
canvas = tk.Canvas(win, height = 500, width = 500)
#Create a rectangle with stipples on top of the images
rectangle = canvas.create_rectangle(0, 0, 400, 300, fill = "gray", stipple = "gray12")
#Create two images
SPRITE = PIL.Image.open("image.jpg")
imagePIL = SPRITE.resize((100, 100))
imagePI = PIL.ImageTk.PhotoImage(imagePIL)
image1 = canvas.create_image(100, 100, image = imagePI, tags = "image")
image2 = canvas.create_image(200, 200, image = imagePI, tags = "image")
images = [image1, image2]
locks = [True, True]
def getImage(x, y):
for image in images:
curr_x, curr_y = canvas.coords(image)
x1 = curr_x - imagePI.width()/2
x2 = curr_x + imagePI.width()/2
y1 = curr_y - imagePI.height()/2
y2 = curr_y + imagePI.height()/2
if (x1 <= x <= x2) and (y1 <= y <= y2):
return image
#Callback
# Here I select image1 or image2 depending on where I click, and
# drag them on the canvas.
def callback(event):
id = getImage(event.x, event.y)
if id:
if locks[images.index(id)] is False: #Hold on to the image on which I originally clicked
canvas.coords(id, (event.x, event.y))
def mouseClick(event):
id = getImage(event.x, event.y)
if id:
locks[images.index(id)] = False
print(locks)
def mouseRelease(event):
id = getImage(event.x, event.y)
if id:
locks[images.index(id)] = True
print(locks)
#Binding
canvas.bind("<ButtonPress-1>", mouseClick) #unlock the image to move it
canvas.bind("<ButtonRelease-1>", mouseRelease) #lock the image
canvas.bind("<B1-Motion>", callback)
#Place the rectangle on top of all
canvas.pack()
# This was the original problem
canvas.tag_raise(rectangle)
canvas.mainloop()

I don't know a tkinter specific way to do this, however, you can try to get the coordinates of the closest image and play with them. Like this:
import Tkinter as tk # for Python2
import PIL.Image, PIL.ImageTk
win = tk.Tk()
canvas = tk.Canvas(win, height = 500, width = 500)
#Create a rectangle with stipples on top of the images
rectangle = canvas.create_rectangle(0, 0, 400, 300, fill = "gray", stipple = "gray12")
#Create two images
SPRITE = PIL.Image.open("image.jpg")
imagePIL = SPRITE.resize((100, 100))
imagePI = PIL.ImageTk.PhotoImage(imagePIL)
image1 = canvas.create_image(100, 100, image = imagePI, tags = "image")
image2 = canvas.create_image(200, 200, image = imagePI, tags = "image")
images = [image1, image2]
def getImage(x, y):
for image in images:
curr_x, curr_y = canvas.coords(image)
x1 = curr_x - imagePI.width()/2
x2 = curr_x + imagePI.width()/2
y1 = curr_y - imagePI.height()/2
y2 = curr_y + imagePI.height()/2
if (x1 <= x <= x2) and (y1 <= y <= y2):
return image
#Callback
# Here I select image1 or image2 depending on where I click, and
# drag them on the canvas. The problem is when I put the rectangle
# on top using tag_raise (see below).
def callback(event):
id = getImage(event.x, event.y)
if id:
canvas.coords(id, (event.x, event.y))
#Binding
canvas.bind("<B1-Motion>", callback)
#Place the rectangle on top of all
canvas.pack()
# This is the problem. I want to have the rectangle on top and be able to use the callback
canvas.tag_raise(rectangle)
canvas.mainloop()

Related

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:

Cannot resize list of images on canvas

I have a rectangle and two images on my canvas. When I resize by dragging window corner down and right this happens:
The blue rectangle resizes properly.
The red and green images only have their borders resized properly.
The red and green images stay the same size no matter how I try to regenerate them with ImageTk.PhotoImage().
Here is my python code:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
try:
from Tkinter import *
except ImportError:
from tkinter import *
from PIL import Image, ImageTk
root = Tk()
images = [] # to hold the newly created image
fills = [] # associated list of fill attributes
# a subclass of Canvas for dealing with resizing of windows
class ResizingCanvas(Canvas):
def __init__(self,parent,**kwargs):
Canvas.__init__(self,parent,**kwargs)
self.bind("<Configure>", self.on_resize)
self.height = self.winfo_reqheight()
self.width = self.winfo_reqwidth()
def on_resize(self,event):
# determine the ratio of old width/height to new width/height
wscale = float(event.width)/self.width
hscale = float(event.height)/self.height
# Save new values as old values
self.width = event.width
self.height = event.height
# resize images
for idx, image in enumerate(images):
fill=fills[idx]
dimensions = "image size: %dx%d" % (image.width(), image.height())
events = "event size: %dx%d" % (event.width, event.height)
neww=int(image.width()*wscale)
newh=int(image.height()*hscale)
image = Image.new('RGBA', (neww, newh), fill)
image = ImageTk.PhotoImage(image)
# images[idx] = image
# resize the canvas
self.config(width=self.width, height=self.height)
# rescale all objects with the "all" tag
self.scale("all",0,0,wscale,hscale)
def create_rectangle(x1, y1, x2, y2, **kwargs):
if 'alpha' in kwargs:
alpha = int(kwargs.pop('alpha') * 255)
fill = kwargs.pop('fill')
fill = root.winfo_rgb(fill) + (alpha,)
fills.append(fill)
# print (fill)
image = Image.new('RGBA', (x2-x1, y2-y1), fill)
images.append(ImageTk.PhotoImage(image))
mycanvas.create_image(x1, y1, image=images[-1], anchor='nw')
mycanvas.create_rectangle(x1, y1, x2, y2, **kwargs)
root.title('alpha1.py')
myframe = Frame(root)
myframe.pack(fill=BOTH, expand=YES)
WinWid=1490; WinHgt=860
mycanvas = ResizingCanvas(myframe,width=WinWid, height=WinHgt, \
highlightthickness=0)
mycanvas.pack(fill=BOTH, expand=YES)
create_rectangle(100, 100, 600, 600, fill='blue')
create_rectangle(300, 300, 950, 700, fill='green', alpha=.5)
create_rectangle(200, 500, 850, 820, fill='#800000', alpha=.6)
mycanvas.addtag_all("all")
root.mainloop()
Notice the line:
# images[idx] = image
If I remove the comment # then the red and green images aren't painted whatsoever. Only the image borders appear. I think this is the key because image should be equal to images[idx] prior to resizing.
If you can answer keep in mind the next step in the project will be to grab an image (or rectangle) and move it on the resizable canvas.
Thanks to Stovfl's comment I was able to use:
self.itemconfig(items[idx], image=image)
To change the size of the image. Unfortunately the way the scaling works in tkinter:
# rescale all objects with the "all" tag
self.scale("all",0,0,wscale,hscale)
The image wasn't filling the borders completely and missing a pixel or two. So I had to generate my own scaling factor based on original canvas width and height:
# images use ratio of original width/height to new width/height
wiscale = float(event.width)/self.startw
hiscale = float(event.height)/self.starth
Finally to prevent garbage collector from removing the resized image I assigned it to a list:
resized[idx]=image # stop garbage collector from removing image
TL;DR
Here is the entire Python scrip in case you would like to use it in your own project:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
try:
from Tkinter import *
except ImportError:
from tkinter import *
from PIL import Image, ImageTk
root = Tk()
images = [] # to hold the newly created image
resized = [] # Resized images
fills = [] # associated list of fill attributes
items = [] # associated list of canvass item tags / ids
# a subclass of Canvas for dealing with resizing of windows
class ResizingCanvas(Canvas):
def __init__(self,parent,**kwargs):
Canvas.__init__(self,parent,**kwargs)
self.bind("<Configure>", self.on_resize)
self.height = self.winfo_reqheight()
self.width = self.winfo_reqwidth()
self.startw = self.width
self.starth = self.height
def on_resize(self,event):
# determine the ratio of previous width/height to new width/height
wscale = float(event.width)/self.width
hscale = float(event.height)/self.height
# Save new values as old values
self.width = event.width
self.height = event.height
# images use ratio of original width/height to new width/height
wiscale = float(event.width)/self.startw
hiscale = float(event.height)/self.starth
# resize images
for idx, image in enumerate(images):
fill=fills[idx]
neww=int(image.width()*wiscale)
newh=int(image.height()*hiscale)
image = Image.new('RGBA', (neww, newh), fill)
image = ImageTk.PhotoImage(image)
self.itemconfig(items[idx], image=image)
resized[idx]=image # stop garbage collector from removing image
# resize the canvas
self.config(width=self.width, height=self.height)
# rescale all objects with the "all" tag
self.scale("all",0,0,wscale,hscale)
def create_rectangle(x1, y1, x2, y2, **kwargs):
if 'alpha' in kwargs:
alpha = int(kwargs.pop('alpha') * 255)
fill = kwargs.pop('fill')
fill = root.winfo_rgb(fill) + (alpha,)
fills.append(fill)
image = Image.new('RGBA', (x2-x1, y2-y1), fill)
images.append(ImageTk.PhotoImage(image))
item=mycanvas.create_image(x1, y1, image=images[-1], anchor='nw')
items.append(item)
mycanvas.create_rectangle(x1, y1, x2, y2, **kwargs)
root.title('alpha1.py')
myframe = Frame(root)
myframe.pack(fill=BOTH, expand=YES)
WinWid=1490; WinHgt=860
mycanvas = ResizingCanvas(myframe,width=WinWid, height=WinHgt, \
highlightthickness=0)
mycanvas.pack(fill=BOTH, expand=YES)
create_rectangle(100, 100, 600, 600, fill='blue')
create_rectangle(300, 300, 950, 700, fill='green', alpha=.5)
create_rectangle(200, 500, 850, 820, fill='#800000', alpha=.6)
mycanvas.addtag_all("all")
for image in images:
resized.append(image)
root.mainloop()

Drawing lines with tkinter

I'm working on a project that requires me to show a grid on an image, but my code keeps erroring saying 'numpy.ndarray' object has no attribute 'load'. The error is happening at the line draw = ImageDraw.Draw(cv_img). Why is this happening?
`
import Tkinter
import cv2
from PIL import Image, ImageTk, ImageDraw
# Creates window
window = Tkinter.Tk()
# Load an image using OpenCV
cv_img = cv2.imread("P:\OneSky\United States.png", cv2.COLOR_BGR2RGB)
window.title("United States Map")
# Get the image dimensions (OpenCV stores image data as NumPy ndarray)
height, width, no_channels = cv_img.shape
# Create a canvas that can fit the above image
canvas = Tkinter.Canvas(window, width = width, height = height)
canvas.pack()
# Use PIL (Pillow) to convert the NumPy ndarray to a PhotoImage
photo = ImageTk.PhotoImage(image = Image.fromarray(cv_img))
# Add a PhotoImage to the Canvas
canvas.create_image(0, 0, image=photo, anchor=Tkinter.NW)
# Draws lines on map
draw = ImageDraw.Draw(cv_img)
x = cv_img.width/2
y_start = 0
y_end = cv_img.height
line = ((x,y_start), (x, y_end))
draw.line(line, fill=128)
del draw
# Run the window loop
window.mainloop()`
I don't think you need opencv, try something like this instead:
from PIL import Image, ImageDraw, ImageTk
import tkinter as tk
def main():
root = tk.Tk()
root.title("United States Map")
steps = 25
img = Image.open('P:\OneSky\United States.png').convert('RGB')
draw = ImageDraw.Draw(img)
y_start = 0
y_end = img.height
step_size = int(img.width / steps)
for x in range(0, img.width, step_size):
line = ((x, y_start), (x, y_end))
draw.line(line, fill=128)
x_start = 0
x_end = img.width
for y in range(0, img.height, step_size):
line = ((x_start, y), (x_end, y))
draw.line(line, fill=128)
del draw
display = ImageTk.PhotoImage(img)
label = tk.Label(root, image=display)
label.pack()
root.mainloop()
if __name__ == '__main__':
main()
I got the idea for how to step the grid from here.

Draggable Tkinter Label With Transparent Image Still Covers Image from Parent Canvas

I am working on adding drag-able labels on top of a background image, where the labels are images with a transparent background. The images used for the label themselves are transparent, but the Label itself is not transparent in relation to its parent canvas. Since the labels are drag-able, I can't easily use the parent image for label, and paste the transparent image on top.
The dragon is a drag-able label, with a transparent background, but you see that the label itself is not transparent, and covers the canvas image.
The dragon image itself has a transparent background, as the normal background is blue, so I know the issue is with the label transparency.
The desired behavior would be to allow the label to be transparent, so when the image on the label is transparent, the label should show through to the image below.
Here is a minimal example:
from PIL import Image, ImageTk
import numpy as np
import tkinter as tk
#Creates a blue square with a transparent border
blue_square_transparent_border = [[[0,0,0,0]]*100]*10 + [[[0,0,0,0]]*30 + [[0,0,255,255]]*40 + [[0,0,0,0]]*30]*40 + [[[0,0,0,0]]*100]*10
blue_square_transparent_border = np.array(blue_square_transparent_border, dtype='uint8')
#convert numpy array to PIL image
pil_image = Image.fromarray(blue_square_transparent_border)
root = tk.Tk()
root.configure(background='red')
#convert PIL image to tkinter image
tk_image = ImageTk.PhotoImage(pil_image)
#create label
image_label = tk.Label(root, image=tk_image)
image_label.pack()
root.mainloop()
What I would like to see is a blue square, on a red background, with no border. In the above example though, the border of grey appears, as it is the label being seen through the transparent image; it is easy to see this when you resize the window. I suspect if the label were transparent, this would solve my issues.
Any help would be great, Thanks!
If you want to drag partially transparent images (like the dragon) over a background image, you can do it with a Canvas:
The idea is not to use labels, but the create_image method of the Canvas.
First, display your background image with canvas.create_image(0, 0, image=background_image, anchor='nw'), then display all the draggable images with the tag 'draggable': canvas.create_image(x, y, image=draggable_image, anchor='nw', tag='draggable'). Finally, bind the tag 'draggable' to mouse events.
Here is an example:
import tkinter as tk
import numpy as np
from PIL import Image, ImageTk
# drag callbacks
dragged_item = None
current_coords = 0, 0
def start_drag(event):
global current_coords
global dragged_item
result = canvas.find_withtag('current')
if result:
dragged_item = result[0]
current_coords = canvas.canvasx(event.x), canvas.canvasy(event.y)
else:
dragged_item = None
def stop_drag(event):
dragged_item = None
def drag(event):
global current_coords
xc, yc = canvas.canvasx(event.x), canvas.canvasy(event.y)
dx, dy = xc - current_coords[0], yc - current_coords[1]
current_coords = xc, yc
canvas.move(dragged_item, dx, dy)
#Create pictures
blue_square_transparent_border = [[[0,0,0,0]]*100]*10 + [[[0,0,0,0]]*30 + [[0,0,255,255]]*40 + [[0,0,0,0]]*30]*40 + [[[0,0,0,0]]*100]*10
blue_square_transparent_border = np.array(blue_square_transparent_border, dtype='uint8')
pil_image = Image.fromarray(blue_square_transparent_border)
background_data = np.zeros((200, 400, 4))
background_data[:, :, 0] = 255 * np.ones((200, 400))
background_data[:, :, 3] = 255 * np.ones((200, 400))
background_data = np.array(background_data, dtype='uint8')
pil_image_bg = Image.fromarray(background_data)
# create GUI
root = tk.Tk()
background_image = ImageTk.PhotoImage(pil_image_bg)
tk_image = ImageTk.PhotoImage(pil_image)
canvas = tk.Canvas(root, width=400, height=200)
canvas.pack()
# bind 'draggable' tag to mouse events
canvas.tag_bind('draggable', '<ButtonPress-1>', start_drag)
canvas.tag_bind('draggable', '<ButtonRelease-1>', stop_drag)
canvas.tag_bind('draggable', '<B1-Motion>', drag)
# display pictures
canvas.create_image(0, 0, image=background_image, anchor='nw')
canvas.create_image(0, 0, image=tk_image, anchor='nw', tag='draggable')
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.

Categories