[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: Let the user interactively straighten images in Python

[This example lets you interactively straighten images in Python]

Some of my last few posts have been leading up to this one. Those posts showed how you can display an image at different scales, allow scrolling, and let the user draw on the scaled image. This post puts those to use to allow you to load an image, display it at different scales, and draw a line to show where horizontal should be on the image. The program then rotates the image to match your orientation line.

I originally wrote this program because I often take pictures that are slightly misaligned and this example lets me straighten them.

The program has two main parts: the code that lets you draw the orientation line and the code that displays the image properly rotated.

Tracking Mouse Events

The program uses the usual sorts of events to track mouse movement. The following event handler executes when you press the left mouse button down.

def mouse_down(self, event): '''Begin drawing.''' self.start_point = self.end_point = (event.x, event.y) # Draw the initial orientation line. line_width = self.line_width * self.scale_var.get() self.new_line = self.canvas.create_line( [self.start_point, self.end_point], fill=self.line_color, width=line_width)

This code saves the mouse's current position in self.start_point and self.end_point.

It then draws a white line on the program's Canvas widget. The value self.line_width is the desired line thickness when the image is displayed at full scale. For this example, I set that value to 5 pixels.

The code multiplies the line thickness by the current scale factor in self.scale_var to get the thickness that we should use to draw the line. It then uses the canvas's create_line method to draw the line on the canvas. It saves the resulting line's ID in self.new_line.

When you move the mouse (with the left button pressed), the following event handler executes.

def mouse_move(self, event): '''Continue drawing.''' if self.new_line is None: return # Update the orientation line. self.end_point = (event.x, event.y) self.canvas.coords(self.new_line, [self.start_point, self.end_point])

If there is no new line, the event handler simply returns. Otherwise, the code saves the mouse's new position in self.end_point and uses the canvas's coords method to update the line's end points. That makes the canvas move the line so it connects the start and end points that you selected with the mouse.

When you release the left mouse button, the following code springs into action.

def mouse_up(self, event): '''Finish drawing.''' if self.new_line is None: return # Calculate the line's angle. dx = self.end_point[0] - self.start_point[0] dy = self.end_point[1] - self.start_point[1] new_angle = math.degrees(math.atan2(dy, dx)) # Subtract this angle from the total angle so far. self.angle += new_angle self.show_image() # Delete the orientation line. self.canvas.delete(self.new_line) self.start_point = self.end_point = self.new_line = None

Like the mouse_move event handler, this one checks whether self.new_line is None and returns if we are not drawing an orientation line.

If we are drawing a line, the code gets the X and Y differences in the line's start and end points. It then uses math.atan2 to get the line's angle. The code then uses math.degrees to convert the angle from radians to degrees.

Next, the code adds the angle to self.angle, which is initialized to 0 whenever you load a new image or use the File menu's Reset command to undo any previous rotations. Adding makes the final rotation cumulative so you can rotate a rotated image until you get a result that you like.

The code then calls self.show_image to display the image rotated at the new angle.

The mouse_up event handler finishes by deleting the new line and setting self.new_line to None so we know we are no longer drawing a line.

Displaying the Image

The following show_image method displays the image at the proper scale and rotated appropriately.

def show_image(self): '''Display the image at the desired scale.''' if self.pil_image is None: # No image. Hide the canvas. self.photo_image = None self.menu_bar.entryconfig(2, state=tk.DISABLED) # Scale self.file_menu.entryconfig('Save As...', state=tk.DISABLED) self.file_menu.entryconfig('Reset', state=tk.DISABLED) self.scrolled_frame.pack_forget() self.start_point = self.end_point = self.new_line = None self.angle = 0 else: # Make a scaled version of the image. wid = int(self.pil_image.width * self.scale_var.get()) hgt = int(self.pil_image.height * self.scale_var.get()) scaled_image = self.pil_image.resize((wid, hgt)) # Rotate to straighten. rotated_image = scaled_image.rotate(self.angle, expand=True, resample=Image.Resampling.BICUBIC) # Convert into a PhotoImage. self.photo_image = ImageTk.PhotoImage(rotated_image) # Display it. self.canvas.delete(tk.ALL) self.image_id = self.canvas.create_image(0, 0, anchor=tk.NW, image=self.photo_image) wid = self.photo_image.width() hgt = self.photo_image.height() self.canvas.config(width=wid, height=hgt) # Reconfigure the frame. Do this after adding contained widgets. self.scrolled_frame.configure_frame() # Display the ScrolledFrame. self.scrolled_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) # Enable the Scale menu. self.menu_bar.entryconfig(2, state=tk.NORMAL) # Scale # Enable the Save As and Reset menu items. self.file_menu.entryconfig('Save As...', state=tk.NORMAL) self.file_menu.entryconfig('Reset', state=tk.NORMAL)

First, if the program has no image loaded, the method disables the Scale menu and the File menu's Save As and Reset commands. It uses the scrolled frame's pack_forget method to hide it and the Canvas widget it contains.

If the program does have a picture loaded, the method displays it. First, it multiplies the image's width and height by the scale factor in the self.scale_var variable to get the image's scaled size. It calls the image's resize method to get a resized version of the image. Note that this does not change the original image so it's still safely stored in self.pil_image so we can use it again later.

Next, the code calls the scaled image's rotate method to rotate it. It sets the resample parameter to use a high-quality bicubic rotation algorithm so the result appears smooth. The default method uses nearest pixels and provides a blocky result. Remove the resample parameter to see the result.

The rest of the method is pretty standard code for displaying an image on a Canvas widget. It converts the PIL image into a PhotoImage, deletes everything from the canvas, and calls create_image to display the new image on the canvas. The code then resizes the canvas to fit the image and reconfigures the scrolled frame to synch its scroll bars with the new image's size.

The code finishes with some user interface chores. It packs the scrolled frame to make it visible, enables the Scale menu, and enables the File menu's Save As and Reset commands.

Note that this code starts from the original image every time instead of rotating a previously rotated image. That makes it easier to keep track of the total rotation angle. It also prevents some annoying artifacts caused by repeatedly rotation an image. Each time you rotate it, the rotation adds some empty space outside of the image because a rotated rectangle doesn't have the same dimensions as the original. If you kept rotation the result, those blank areas would grow and have somewhat strange shapes. Starting fresh each time prevents that.

Saving the Result

The last piece of the program that I want to cover saves the final result. When you select the File menu's Save As command, the following code executes.

def mnu_save_image(self): '''Save the rotated image.''' # Get the filename. filetypes = ( ('Image Files', '*.png;*.jpg;*.bmp;*.gif;*.tif'), ('All files', '*.*') ) filename = fd.asksaveasfilename(filetypes=filetypes, defaultextension='.jpg') if len(filename) > 0: # Rotate to straighten. rotated_image = self.pil_image.rotate(self.angle, expand=True, resample=Image.Resampling.BICUBIC) try: rotated_image.save(filename) except Exception as e: messagebox.showinfo('Save Error', f'Error saving image file.\n{e}') return None

This code displays a Save As dialog so you can pick the file that should hold the rotated result. If you enter a file name and click Save, the code rotates the original image through the current rotation angle. Notice that the code does not scale the image because we want to save the original image at full scale.

The code then tries to save the rotated image in the selected file. [The Leaning Tower of Pisa before and after straightening]

Conclusion

This example lets you straighten images that are slightly crooked. For example, the picture on the right shows a picture of the Leaning Tower of Pisa (from Wiki Commons) and a straightened version. Note that the black triangles in the corners of the straightened version cause an optical illusion to make that version also look tilted. If you trim the edges of the picture, the result looks pretty straight.

Even then the tower won't be perfectly straight. Because the picture was taken at ground level, the top of the tower is slight smaller than the bottom so the best you can do is to give the tower a slightly tapered shape so neither side is perfectly vertical. You can also correct for that but it's a bit more work. See my post Map a trapezoidal image onto a rectangle in Python for information about that technique.

I've skipped a lot of details in this post such as the code to build the user interface, make a scrolled frame, create the menus, and scale the image. Download the example to see those details and to experiment with it.

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