[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky] [Facebook]
[Build Your Own Python Action Arcade!]

[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: Use drag handles to select an area for cropping in Python and tkinter

[This program lets you use drag handles to select an area for cropping in Python and tkinter]

My earlier post Interactively crop scaled images in Python and tkinter lets you use the mouse to crop an image. This example uses a different approach that is sometimes more precise. It displays a selection box and lets you drag its corners, edges, and body to adjust the box.

This is a fairly long program so I'm only going to hit the highlights here. You can download the previous example to see more details.

Drawing the Box

When the program starts, it creates the box and the box exists from then on.

self.box = [20, 20, 400, 300] # x1, y1, x2, y2

Whenever the box or picture changes, the following code draws the box.

def draw_box(self): '''Draw the selection box.''' # Draw the dashed rectangle. self.canvas.create_rectangle(*self.box, width=self.box_thickness, outline=self.box_color1) self.canvas.create_rectangle(*self.box, width=self.box_thickness, outline=self.box_color2, dash=(3,5)) # Draw the grab handles. corners = [ [self.box[0], self.box[1]], [self.box[0], self.box[3]], [self.box[2], self.box[1]], [self.box[2], self.box[3]], ] for corner in corners: points = [ corner[0] - self.handle_radius, corner[1] - self.handle_radius, corner[0] + self.handle_radius, corner[1] + self.handle_radius, ] self.canvas.create_rectangle(points, width=self.box_thickness, outline='black', fill='white')

This code draws a rectangle using self.box_color1. It then draws the same box again, this time dashed and using outline=self.box_color2. The result is a two-colored dashed box.

The code then loops through the coordinates of the box's corners and draws drag handles there. The drag handles aren't really necessary, so you could omit them if you like or draw additional handles in the middle of the box's sides.

Finding the Mouse Position

When you move the mouse over the picture, the program displays an appropriate cursor. For example, if you move the mouse over the box's upper left corner, the program displays an arrow pointing to the upper left and lower right to let you know that you can resize that corner.

The program uses the following method to figure out what part of the box is under the mouse.

def get_mouse_is_over(self, x, y): '''Return n, sw, etc. to indicate the box part at this point.''' # Check corners. hit_w = abs(x - self.box[0]) <= self.handle_radius hit_e = abs(x - self.box[2]) <= self.handle_radius hit_n = abs(y - self.box[1]) <= self.handle_radius hit_s = abs(y - self.box[3]) <= self.handle_radius if hit_w and hit_n: return 'nw' if hit_w and hit_s: return 'sw' if hit_e and hit_n: return 'ne' if hit_e and hit_s: return 'se' # Check sides. x_ok = (x >= self.box[0] - self.handle_radius) and \ (x <= self.box[2] - self.handle_radius) if hit_n and x_ok: return 'n' if hit_s and x_ok: return 's' y_ok = (y >= self.box[1] - self.handle_radius) and \ (y <= self.box[3] - self.handle_radius) if hit_e and y_ok: return 'e' if hit_w and y_ok: return 'w' # Check body. if x_ok and y_ok: return 'body' return None

First, the method determines whether the mouse is over the extensions of the box's edges.

If the mouse is over two edges, it's over a corner so the code returns an appropriate result. For example, if the mouse is over the top and left edges, then it's over the box's upper left corner so the method returns nw.

Next, the code checks whether the mouse is with the box's X and Y extents. For example, it sets x_ok if the mouse's X coordinate is between the box's minimum and maximum X coordinates.

If the mouse is over an edge and also within the corresponding X or Y extent, the method returns the appropriate value. For example, if the mouse is over the box's top edge extension and between the box's minimum and maximum X coordinates, then it's over the top side so the method returns n.

Next, if the mouse is within both the box's X and Y extents, it is on the square's body so the method returns body.

Finally, if none of the previous tests apply, the mouse is not over the box so the method returns None.

Displaying Cursors

When you move the mouse, the following event handler tracks its position and displays an appropriate cursor.

def mouse_move(self, event): '''Update the selection rectangle.''' # See what is under the mouse. mouse_is_over = self.get_mouse_is_over(event.x, event.y) if self.mouse_is_over != mouse_is_over: self.mouse_is_over = mouse_is_over match self.mouse_is_over: case 'nw' | 'se': # Might need a different cursor if not Windows. self.canvas.config(cursor='size_nw_se') case 'ne' | 'sw': # Might need a different cursor if not Windows. self.canvas.config(cursor='size_ne_sw') case 'n' | 's': self.canvas.config(cursor='sb_v_double_arrow') case 'w' | 'e': self.canvas.config(cursor='sb_h_double_arrow') case 'body': self.canvas.config(cursor='fleur') case _: self.canvas.config(cursor='')

This method calls get_mouse_is_over to see what part of the selection box is below the mouse. If the part has changed, the code uses a match statement to display an appropriate cursor.

Note that your operating system may not have some of these cursors if you're not using Windows.

Dragging the Box

To adjust the selection box, press the left mouse button and drag it. That will update the box's corners, edges, or position depending on what is below the mouse.

The process starts when you press the mouse down.

def mouse_down(self, event): '''Start selecting.''' self.start_x = event.x self.start_y = event.y

This code simply saves the mouse's initial position.

The following code adjusts the box when you move the mouse after pressing the left button down.

def b1_mouse_move(self, event): '''Process a drag.''' # See how much the mouse has moved. dx = event.x - self.start_x dy = event.y - self.start_y self.start_x = event.x self.start_y = event.y # Update the appropriate values. match self.mouse_is_over: case 'nw': self.box[0] += dx self.box[1] += dy case 'se': self.box[2] += dx self.box[3] += dy case 'ne': self.box[2] += dx self.box[1] += dy case 'sw': self.box[0] += dx self.box[3] += dy case 'n': self.box[1] += dy case 's': self.box[3] += dy case 'w': self.box[0] += dx case 'e': self.box[2] += dx case 'body': self.box = [ self.box[0] + dx, self.box[1] + dy, self.box[2] + dx, self.box[3] + dy, ] case _: pass self.display_image()

First, the code determines how much the mouse has moved since it was pressed or last moved, and it updates the mouse's current position.

Next, it checks self.mouse_is_over to see what part of the box is below the mouse. Depending on that part, the code updates the box's coordinates.

This method finishes by calling display_image to draw the box in its new position.

The last part of the box-adjusting process occurs when you release the mouse button.

def mouse_up(self, event): '''Stop selecting.''' # Make sure box[0] <= box[2] and box[1] <= box[3]. if self.box[0] > self.box[2]: self.box[0], self.box[2] = self.box[2], self.box[0] if self.box[1] > self.box[3]: self.box[1], self.box[3] = self.box[3], self.box[1]

When you drag the box's corners or sides, you can turn the box inside out. For example, if you drag the right side to the left of the left side. The box-drawing code doesn't care, but the PIL code that crops the image does care and crashes if the image's minimum X or Y coordinate is larger than its maximum coordinate.

This code checks whether the box's coordinates are out of order and fixes them if necessary.

Scaling the Box

When you change the image's display scale, the following method also resizes the selection box.

def set_scale(self): '''Scale the current image.''' # Get the scale. scale = self.selected_scale.get() # Scale the selection box. scale_factor = scale / self.scale self.box = [scale_factor * coord for coord in self.box] self.scale = scale # If there is no image, we're done. if not self.image: return # Scale the image. wid = int(self.image.width * scale) hgt = int(self.image.height * scale) self.scaled_image = self.image.resize((wid, hgt), Image.Resampling.LANCZOS) # Display the image. self.photo_image = ImageTk.PhotoImage(self.scaled_image) self.display_image() # Size the canvas to fit the image. self.canvas.config(width=wid, height=hgt) self.scrolled_frame.configure_frame()

This method gets the new scale value and then multiplies the box's coordinates by the new scale divided by the previous scale. That makes the box occupy the same area on the picture after the picture is scaled.

The rest of the method is the same as in the previous example. It calculates the image's scaled size, resizes the image, and resizes the program's drawing canvas to fit the image.

Resetting the Box

Annoyingly, the program can sometimes end up with its selection box completely off of the picture so you can't drag it. For example, that can happen if you load a new picture that's smaller than the previous one.

The following method ensures that the box is at least partly visible.

def reset_box(self): '''Make sure the box fits on the current image.''' if self.image is None: return # If any corner is on the image, it's good enough. if self.point_on_image(self.box[0], self.box[1]): return if self.point_on_image(self.box[0], self.box[3]): return if self.point_on_image(self.box[2], self.box[1]): return if self.point_on_image(self.box[2], self.box[3]): return # Move the box onto the image. self.box = [ int(self.scaled_image.width * 0.1), int(self.scaled_image.height * 0.1), int(self.scaled_image.width * 0.9), int(self.scaled_image.height * 0.9), ]

This method calls point_on_image for each of the box's corners and, if any corner is on the image, the method returns. At worst that could mean only part of a drag handle is visible, but that's enough to enlarge the box and drag it back onto the image.

If the box is completely off of the image, the code resets is so it is centered on the image.

The following code shows the point_on_image method.

def point_on_image(self, x, y): '''Return True if this point is on the scaled image.''' margin = 10 return x >= margin and x <= self.scaled_image.width - margin and \ y >= margin and y <= self.scaled_image.height - margin

This method compares the point to the image's size and returns True if the point is on the image.

Another way the selection box can go AWOL is if you drag it off of the current picture. If you reload the picture, the program will call reset_box and make it usable again. (You could also make a menu command to reset the image if you like.)

Conclusion

Adjusting the selection box gives you slightly better control than clicking and dragging. In a later post, I'll show how to modify this program so you can select areas with specific aspect ratios. Meanwhile, download the example to see additional details and to experiment with it.
© 2024 - 2026 Rocky Mountain Computer Consulting, Inc. All rights reserved.