[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky]
[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Let the user zoom on a picture in Python and tkinter

[Let the user zoom on a picture in Python and tkinter]

This is a pretty long example, so I'll describe it in parts. The program has two cells: a SmileyApp class and a cell that instantiates it. All of the interesting work happens in the SmileyApp class.

Here's how the class begins.

import tkinter as tk # Layout constants. MARGIN = 5 WINDOW_WID = 500 WINDOW_HGT = 300 class SmileyApp: # Create and manage the tkinter interface. def __init__(self, redraw_on_resize=False): self.redraw_on_resize = redraw_on_resize self.first_time = True # Make the main interface. self.window = tk.Tk() self.window.title('Smiley') self.window.protocol('WM_DELETE_WINDOW', self.kill_callback) self.window.geometry(f'{WINDOW_WID}x{WINDOW_HGT}') # Build the rest of the UI. self.build_ui() # Set windowing bounds. self.set_window_bounds() # Display the window. self.window.focus_force() self.window.mainloop()

This code starts with some import statements and by defining the desired window size. Note that the actual window size may not match what you specify. The dimensions you specify determine how big the window's working area is but that doesn't include window decorations. Some systems may also scale windows. (In Windows 11, for example, open the start menu, type Display Settings, and look at the Scale setting.)

Some drawings are fast enough to redraw every time the window resizes but others may be too slow. The constructor's resize_on_redraw parameter tells the program whether it should automatically redraw the picture whenever the window resizes.

The constructor saves that parameter and sets first_time to True so it can determine whether the form is first appearing. It then creates its window. It binds kill_callback to the WM_DELETE_WINDOW message so that method is called when the user closes the window.

Next the constructor calls the build_ui method. That method creates the program's menus, binds mouse events, and binds shortcut keys.

The code then calls set_window_bounds to define drawing coordinates. I'll say more about that shortly.

The constructor finishes by forcing focus to the main window and entering the tkinter main loop.

The following code shows the build_ui method.

def build_ui(self): # Menubar menubar = tk.Menu(self.window) self.window.config(menu=menubar) # File menu file_menu = tk.Menu(menubar, tearoff=False) file_menu.add_command(label='Exit', command=self.kill_callback) menubar.add_cascade(label='File', menu=file_menu) # Scale menu scale_menu = tk.Menu(menubar, tearoff=False) scale_menu.add_command(label='Redraw', accelerator="F5", command=lambda: self.set_scale('redraw')) scale_menu.add_separator() scale_menu.add_command(label='x2', command=lambda: self.set_scale('2')) scale_menu.add_command(label='x4', command=lambda: self.set_scale('4')) scale_menu.add_command(label='x8', command=lambda: self.set_scale('8')) scale_menu.add_command(label='x16', command=lambda: self.set_scale('16')) scale_menu.add_command(label='Full Scale', command=lambda: self.set_scale('full scale')) menubar.add_cascade(label='Scale', menu=scale_menu) # Drawing canvas self.canvas = tk.Canvas(self.window, bg='black', borderwidth=1, highlightthickness=0, cursor='cross') self.canvas.pack(side=tk.TOP, padx=MARGIN, pady=MARGIN, expand=True, fill=tk.BOTH) # Initially we are not selecting an area. self.start_pos = None self.end_pos = None # Track Button-1 down, Button-1 motion, and Button-1 up. self.canvas.bind("<Button-1>", self.mouse_down) self.canvas.bind("<B1-Motion>", self.mouse_move) self.canvas.bind("<ButtonRelease-1>", self.mouse_up) # Watch for resize. self.canvas.bind("<Configure>", self.configured) # Redraw on F5. self.window.bind('<F5>', lambda event: self.set_scale('redraw'))

This code attaches a menu bar to the window, and then adds File and Scale menus. Notice that the scaling commands use lambda functions to invoke the set_scale method, passing it a string to indicate how the drawing should be scaled.

Notice also that the Redraw command has the accelerator F5. This only displays F55 in the menu, it doesn't actually bind the F5 key to the menu item. We'll get to that shortly.

Having created the menus, the code creates the drawing canvas. It calls pack setting expand=True and fill=tk.BOTH so the canvas will expand to fill all of the available area.

Next, the code sets the object's start_pos and end_pos values to None. The program uses those values to track the selection area when the user drags to zoom in on an area. Setting these values to None lets the program check whether a selection is in progress so it can ignore the spurious mouse up events that the program sometimes (but annoyingly not always) generates.

The program then binds a few events to event handler methods. When the user presses the left mouse button down, the mouse_down method executes. When the user moves the mouse while the left button is pressed, the mouse_move method fires. When the user releases the left mouse button, the mouse_up method executes.

The code binds the configured method to the configure event.

Finally, the code binds the F5 key so it invokes the set_scale method. The Redraw button displays the accelerator key but that's just decoration and you need this code to make that key actually do something.

The following code shows the set_window_bounds

def set_window_bounds(self): '''Set the world coordinate bounds.''' # full_wXXX gives the full scale coordinates for the window. # desired_wXXX gives the desired world window coordinates. # wXXX gives the current world window coordinates. # vXXX gives the current viewport coordinates. self.full_wxmin = -1.05 self.full_wxmax = 1.05 self.full_wymin = -1.05 self.full_wymax = 1.05 self.min_width = 0.1 self.min_height = 0.1 self.desired_wxmin = self.full_wxmin self.desired_wxmax = self.full_wxmax self.desired_wymin = self.full_wymin self.desired_wymax = self.full_wymax

The program uses two sets of coordinates to manage its drawing. World coordinates or drawing coordinates are the coordinates that we want to draw in. For this example, I decided to draw a smiley face within the coordinate bounds -1 ≤ x ≤ 1, -1 ≤ y ≤ 1.

Viewport coordinates or device coordinates are the coordinates that the program can actually display. In this case, those are the pixel values on the program's canvas widget. Later the program will use the values vxmin, vxmax, vymin, and vymax to store those viewport coordinates. (In a more general program you could use these to draw the smiley face on only part of the canvas, perhaps the upper right corner, but in this program the viewport coordinates will fill the whole canvas.)

The code sets the full_Xxx parameters to hold the drawing area at full-scale. This area includes the actual drawing area bounds plus a 5% margin.

The min_width and min_height values indicate the smallest area to which the user can zoom. You can remove this restriction if you like, but at some point the zoom level will include too little of the picture to be useful. At some even smaller point, floating point errors will cause the program to hang, crash, or produce other unhelpful behavior.

The desired_Xxx values indicate the area that the user currently wants to see. This isn't always the same as the area actually displayed because the desired area may not have the same shape as the drawing canvas so it must be resized before drawing.

The set_scale method shown in the following code is one of the program's more complicated parts.

def set_scale(self, scale_factor): '''The user has invoked a Scale menu item. Set the new world bounds and then redraw.''' # Get the desired world width and height. if scale_factor == 'redraw': # Just redraw. Leave the bounds unchanged. pass elif scale_factor == 'full scale': # Reset to full scale. self.desired_wxmin = self.full_wxmin self.desired_wxmax = self.full_wxmax self.desired_wymin = self.full_wymin self.desired_wymax = self.full_wymax else: # Scale by a number. scale = int(scale_factor) wid = (self.desired_wxmax - self.desired_wxmin) * scale hgt = (self.desired_wymax - self.desired_wymin) * scale cx = (self.desired_wxmax + self.desired_wxmin) / 2 cy = (self.desired_wymax + self.desired_wymin) / 2 self.desired_wxmin = cx - wid / 2 self.desired_wxmax = self.desired_wxmin + wid self.desired_wymin = cy - hgt / 2 self.desired_wymax = self.desired_wymin + hgt # If the width/height is larger than that of # full scale, use the full scale width/height. if self.desired_wxmax - self.desired_wxmin > self.full_wxmax - self.full_wxmin: self.desired_wxmax = self.full_wxmax self.desired_wxmin = self.full_wxmin if self.desired_wymax - self.desired_wymin > self.full_wymax - self.full_wymin: self.desired_wymax = self.full_wymax self.desired_wymin = self.full_wymin # Get the desired window bounds. wxmin = self.desired_wxmin wxmax = self.desired_wxmax wymin = self.desired_wymin wymax = self.desired_wymax # Get the width and height. w_wid = (wxmax - wxmin) w_hgt = (wymax - wymin) # Adjust to preserve aspect ratio. w_aspect = w_wid / w_hgt v_wid = self.vxmax - self.vxmin v_hgt = self.vymax - self.vymin v_aspect = v_wid / v_hgt if w_aspect > v_aspect: # World window bounds are too short and wide. Make taller. w_hgt = w_wid / v_aspect else: # World window bounds are too tall and thin. Make wider. w_wid = w_hgt * v_aspect # Set the new world window bounds. cx = (wxmin + wxmax) / 2 cy = (wymin + wymax) / 2 self.wxmin = cx - w_wid / 2 self.wxmax = self.wxmin + w_wid self.wymin = cy - w_hgt / 2 self.wymax = self.wymin + w_hgt # Redraw. self.draw()

This method starts with a series of if statements to see what kind of scale we are setting.

If scale_factor is redraw, then we just need to redraw without changing the desired drawing area. This is useful when the window resizes. (In programs I've written in the past, I've also found it sometimes useful to redraw without changing the viewing area.)

If scale_factor is full scale, then the code sets the desired coordinates equal to the full scale coordinates.

If scale_factor has some other value, the code parses the scale factor and multiples the current width and height by that factor. It finds the current center of the drawing area and sets the desired coordinate bounds to have the new width and height surrounding the same center point.

Having updated the desired drawing bounds if necessary, the code makes a few adjustments if necessary.

First, if the desired area is wider than the full scale area's width, the code sets the minimum and maximum desired coordinates equal to the full scale coordinates. This prevents the user from zooming out too far or way off to the side. You can remove this test if you like, but it seems useful. (I tried several other methods for keeping the program from wandering too far afield and this technique seemed the most intuitive.)

The program then makes a similar check for the drawing area's top and bottom bounds.

Next, the program must adjust the drawing bounds so the drawing area has the same shape as the display area on the canvas. For example, the picture will be distorted if the drawing area is square but the canvas is short and wide.

To do that, the program compares the aspect ratio (the width-to-height ratio) of the drawing area and the canvas and adjusts the drawing area's width or height to make the two aspect ratios equal.

The code updates the wxmin, wxmax, wymin, and wymax values so they represent the adjusted darwing area centered at the originally desired center point.

Finally, the code calls the draw method to draw the smiley face.

The following code shows the three mouse event handlers that let the user select an area.

def mouse_down(self, event): self.start_pos = (event.x, event.y) self.end_pos = (event.x, event.y) def mouse_move(self, event): # Ignore extraneous events. if self.start_pos is None: return # Delete any previous selection rectangle. self.canvas.delete('selection_rectangle') # Create a new selection rectangle. self.end_pos = (event.x, event.y) self.canvas.create_rectangle(self.start_pos, self.end_pos, outline='red', tag='selection_rectangle') self.canvas.create_rectangle( self.start_pos, self.end_pos, dash=(1, 5), outline='yellow', tag='selection_rectangle') def mouse_up(self, event): # Ignore extraneous events. if self.start_pos is None: return self.canvas.delete('selection_rectangle') # Get the selected bounds. xmin = min(self.start_pos[0], self.end_pos[0]) xmax = max(self.start_pos[0], self.end_pos[0]) ymin = min(self.start_pos[1], self.end_pos[1]) ymax = max(self.start_pos[1], self.end_pos[1]) self.start_pos = None self.end_pos = None # Do nothing if the selected area is empty. if xmin == xmax or ymin == ymax: return # Call area_selected to redraw. self.area_selected(xmin, xmax, ymin, ymax)

The mouse_down method simply saves the mouse's position in the start_pos and end_pos values.

The mouse_move method first checks start_pos to see if a selection is in progress and exits if it is not. Otherwise it deletes any drawing objects on the canvas that have the tag selection_rectangle. It then updates end_pos to the mouse's current position and draws the selection rectangle.

To draw the rectangle, the code first draws a solid rectangle in red and then draws a dashed yellow rectangle on top. The result is a red and yellow dashed rectangle. (Note that tkinter's dash property only supports a limited number of patterns and they may differ on different platforms.) Both of the rectangles have tag value selection_rectangle so we can easily delete them later.

The mouse_up method also checks start_pos to see if a selection is in progress and exits if it is not. Otherwise it deletesthe selection rectangle objects. It then gets the minimum and maximum X and Y coordinates of the selected area. If the area has zero width or height, the method returns.

If the area has a non-zero size, the code calls area_selected to draw the selected area.

The following code shows the area_selected method.

def area_selected(self, xmin, xmax, ymin, ymax): # Convert into world coordinates. wxmin, wymin = self.d_to_w(xmin, ymin) wxmax, wymax = self.d_to_w(xmax, ymax) # Make sure the width and height aren't too small. if wxmax - wxmin < self.min_width: cx = (wxmax + wxmin) / 2 wxmin = cx - self.min_width / 2 wxmax = wxmin + self.min_width if wymax - wymin < self.min_height: cy = (wymax + wymin) / 2 wymin = cy - self.min_height / 2 wymax = wymin + self.min_height # Set the desired world coordinates. self.desired_wxmin = wxmin self.desired_wxmax = wxmax self.desired_wymin = wymin self.desired_wymax = wymax # Redraw. self.set_scale('redraw')

This code first calls d_to_w to convert the selected coordinates from device coordinates (in pixels on the canvas) into world coordinates. You'll see that method later.

It then checks that the selected width and height aren't smaller than the values stored in min_width and min_height. If a value is too small, the code makes it larger, keeping the area centered over the desired location.

After adjusting the selected area, the method saves the results in the desired_Xxx values and calls set_scale to adjust the area's aspect ratio if needed and to redraw the picture.

The following code shows the configured method that executes whenever the window is resized.

def configured(self, event): '''The window may have been resized. Reset the viewport coordinates and redraw.''' # Do nothing unless the window is in a normal or zoomed state. if self.window.state() != 'normal' and self.window.state() != 'zoomed': return self.vxmin = 0 self.vxmax = self.canvas.winfo_width() - 1 self.vymin = 0 self.vymax = self.canvas.winfo_height() - 1 # Redraw. if self.first_time or self.redraw_on_resize: self.set_scale('redraw') self.first_time = False

If the window's state isn't normal or zoomed (maximized), then the method exits. Otherwise it saves the canvas widget's current dimensions in the vXxx values.

If this is the first time this method has been called, then the window has just appeared. In that case, or if we should automatically redraw on resize, the method calls set_scale to adjust the aspect ratio and redraw the picture.

(Hang in there! There are only a few more pieces to go!)

The following code shows the draw that draws the smiley face.

def draw(self): '''Draw a smiley face.''' self.canvas.delete('all') # Set scale factors for drawing. self.x_scale = (self.vxmax - self.vxmin) / (self.wxmax - self.wxmin) self.y_scale = (self.vymax - self.vymin) / (self.wymax - self.wymin) # Calculate line thickness. x0, y0 = self.w_to_d( 0, 0) x1, y1 = self.w_to_d(0.03, 0) thickness = x1 - x0 # Head. h_xmin, h_ymin = self.w_to_d(-1, -1) h_xmax, h_ymax = self.w_to_d(1, 1) self.canvas.create_oval(h_xmin, h_ymin, h_xmax, h_ymax, fill='lemon chiffon', outline='red', width=thickness) # Nose. n_xmin, n_ymin = self.w_to_d(-0.2, -0.3) n_xmax, n_ymax = self.w_to_d( 0.2, 0.3) self.canvas.create_oval(n_xmin, n_ymin, n_xmax, n_ymax, fill='light green', width=thickness) # Left eye. l_xmin, l_ymin = self.w_to_d(-0.6, -0.6) l_xmax, l_ymax = self.w_to_d(-0.3, -0.2) self.canvas.create_oval(l_xmin, l_ymin, l_xmax, l_ymax, fill='white', width=thickness) lp_xmin, lp_ymin = self.w_to_d(-0.5, -0.55) lp_xmax, lp_ymax = self.w_to_d(-0.3, -0.25) self.canvas.create_oval(lp_xmin, lp_ymin, lp_xmax, lp_ymax, fill='black') # Right eye. r_xmin, r_ymin = self.w_to_d(0.6, -0.6) r_xmax, r_ymax = self.w_to_d(0.3, -0.2) self.canvas.create_oval(r_xmin, r_ymin, r_xmax, r_ymax, fill='white', width=thickness) rp_xmin, rp_ymin = self.w_to_d(0.6, -0.55) rp_xmax, rp_ymax = self.w_to_d(0.4, -0.25) self.canvas.create_oval(rp_xmin, rp_ymin, rp_xmax, rp_ymax, fill='black') # Smile. s_xmin, s_ymin = self.w_to_d(-0.7, -0.7) s_xmax, s_ymax = self.w_to_d( 0.7, 0.7) self.canvas.create_arc(s_xmin, s_ymin, s_xmax, s_ymax, outline='black', start=180, extent=180, style=tk.ARC, width=thickness)

This code just creates some ovals and an arc to draw the smiley face. There are only a few really interesting parts.

The method first deletes all drawing objects from the canvas widget so we can start fresh.

Next, the code sets the x_scale value to the ratio of the canvas control's width to the drawing area width. This gives the number of pixels per unit in drawing coordinates. The code sets the y_scale value similarly.

The last interesting parts to this method are where it calls w_to_d to convert from drawing coordinates to canvas coordinates. After it sets the scale factors, the method uses w_to_d to convert the points (0, 0) and (0.03, 0) into viewport coordinates to see how far apart they are in pixels. It then subtracts the resulting X coordinates to get a thickness value that it uses to draw ovals and arcs. That makes the outlines of those shapes have line width equal to 0.03 in world coordinates or 3% of the smiley face's width.

This method also uses w_to_d to convert the points that define shapes in world coordinates into viewport coordinates. It then uses the transformed coordinates to draw the smiley face.

The following code shows the w_to_d method.

def w_to_d(self, x, y): '''Convert a point from world to device coordinates.''' vx = self.vxmin + (x - self.wxmin) * self.x_scale vy = self.vymin + (y - self.wymin) * self.y_scale return vx, vy

This code subtracts the minimum world X coordinate from the point's X coordinate to see how far the point is from the left edge of the world coordinate drawing area. It multiples that distance by the x_scale scale factor to get the distance converted into viewport coordinates. It then adds that distance to the minimum viewport area coordinate (which is always 0 in this program) to get the corresponding X coordinate on the canvas.

The program uses a similar calculation to get the point's Y coordinate converted into viewport coordinates. It then returns the converted X and Y coordinates.\

The following code shows the d_to_w method that maps back from viewport coordinates (pixels on the canvas) to world drawing coordinates.

def d_to_w(self, x, y): '''Convert a point from device to world coordinates.''' vx = self.wxmin + (x - self.vxmin) / self.x_scale vy = self.wymin + (y - self.vymin) / self.y_scale return vx, vy

This method is similar to w_to_d except it performs the reverse calculation.

The program uses d_to_w to map areas selected by the user from vieport (pixel) coordinates to world coordinates. It uses w_to_d to map locations that it needs to draw from world coordinates to viewport (pixel) coordinates.

All of the code so far has been in the SmileyApp class. Having created that class, the main program uses the following code to use it.

SmileyApp(True) print('Done')

That's all there is to it! This code simply creates a SmileyApp object. The class's constructor starts all of the interesting work.

The second line of code prints Done so you know that it's done. If you don't include that statement, then Python displays the result of the call to the SmileyApp constructor, which is something weird like the following.

<__main__.SmileyApp at 0x191fb797bc0>

Alternatively you could use the statement print('', end='') to avoid any weird output, but printing Done seems reasonable.

I know it's a lot to take in. You may want to look at the code in more detail or step through it in a debugger to see how it works.

Download the example to see all of the details.

© 2024 Rocky Mountain Computer Consulting, Inc. All rights reserved.