Title: Build a jigsaw puzzle program in Python, Part 2
The jigsaw program is fairly complex, so I'm describing it in two parts. This is the second part. If you didn't already read the first part, you can find it at Build a jigsaw puzzle program in Python, Part 1.
The first part described the helper classes and methods that the main program uses to manage the puzzle. This post explains how the main program uses the helper pieces to create the program.
Loading Images
When you invoke the File menu's Open command, the following code executes.
def mnu_open_image(self):
self.puzzle_image = self.get_image_file('Load Image')
self.disassemble_image()
self.show_image()
This code calls the get_image_file to load the file's image, disassemble_image to break the image into pieces, and show_image to display the kitchen table.
get_image_file
The following get_image_file method lets the user select a file.
def get_image_file(self, title):
'''
Let the user pick an image file.
Then load and return it in an RGB PIL Image.
'''
filetypes = (
('Image Files', '*.png;*.jpg;*.bmp;*.gif;*.tif'),
('All files', '*.*')
)
filename = fd.askopenfilename(title=title, filetypes=filetypes)
if not filename: return None
try:
return Image.open(filename)
except Exception as e:
tk.messagebox.showinfo('Open Error',
f'Error loading image file.\n{e}')
return None
The method creates a tuple of file types. Each entry in the list is a tuple holding two entries: the name of the type of files (like Image Files) and a comma-delimited list of file extensions for that type.
The code then uses tkinter's askopenfilename method to display a file open dialog. If the user cancels the dialog, the method returns an empty string and this code returns None.
If the user selects a file and clicks Open, the program loads the image and returns it. The code shown earlier saves the image in self.puzzle_image.
disassemble_image
The following disassemble_image method breaks the puzzle image into pieces.
def disassemble_image(self):
'''Break the image into puzzle pieces and return the pieces.'''
if self.puzzle_image is None: return
# Get the kitchen table's image size allowing extra space
# on the sides for unplaced pieces.
WIDTH_FACTOR = 2
HEIGHT_ADDITION = 100
self.table_width = round(self.puzzle_image.width * WIDTH_FACTOR)
self.table_height = self.puzzle_image.height + HEIGHT_ADDITION
# Calculate row and column sizes.
row_heights = divide_evenly(self.puzzle_image.height, self.piece_size)
col_widths = divide_evenly(self.puzzle_image.width, self.piece_size)
# Make the pieces.
self.pieces = []
y = 0
for height in row_heights:
x = 0
for width in col_widths:
# Make the piece at (x, y).
self.pieces.append(PuzzlePiece(self.puzzle_image,
x, y, width, height))
x += width
y += height
# Randomly position the pieces.
self.randomize_pieces()
The code first checks self.puzzle_image. If no image is loaded, the method simply returns without doing anything.
If an image is loaded, the program uses the image's dimensions to calculate the kitchen table's size.
Next, the code needs to calculate the piece sizes. To do that, it calls divide_evenly (shown shortly) to divide the puzzle image's dimensions into pieces of roughly self.piece_size pixels. For example, suppose you want 50 pixel pieces and the image's width is 310. You can't divide 310 exactly into 50-pixel wide pieces, so divide_evenly returns the list of sizes [52, 52, 52, 52, 51, 51].
After it gets the piece sizes, the disassemble_image method creates an empty self.pieces list. It loops through the piece height list to make the rows of pieces. For each row, it loops through the piece width list and makes the pieces, adding them to the self.pieces list.
After it finishes creating the pieces, it calls randomize_pieces to put them in random locations. The following section explains the divide_evenly method and the section after that explains randomize_pieces.
divide_evenly
The following divide_evenly method divides a number into pieces as close to a target value as possible.
def divide_evenly(number, size):
'''
Divide number into parts that are roughly size in size.
Example: Divide 310 into parts of size roughly 50: [52, 52, 52, 52, 51, 51]
'''
if size > number:
size = number
num_parts = number // size # See how many parts there will be.
base_size = number // num_parts # Get "normal" part size.
remainder = number % num_parts # Get leftover after making the parts.
# Create the parts.
parts = [base_size] * num_parts
# Make some parts bigger to use up the remainder.
for i in range(remainder):
parts[i] += 1
return parts
For this explanation, suppose number is 310 and size is 50.
The code first checks whether size > number. (For example, we might be trying to divide 310 into pieces of size 400.) In that case, the method sets size = number. (As you'll see, the final result will have a single value.)
Next, the code calculates the number of parts into which we will divide number. For example, in the 310-50 example, 310 // 50 is 6 so we will divide 310 into 6 parts.
The code then divides number by the number of parts. For example, in this example, 310 // 6 is 51 so each part begins with a base value of 51. That accounts for as much of number as we can get by giving each of the parts the same value.
The method calculates the remainder that we need to account handle. In this example, that's 310 - 6 * 51 = 310 - 306 = 4.
The method then creates a list holding num_parts values, each of which is base_size. In the ongoing example, the list holds 6 copies of 50: [51, 51, 51, 51, 51, 51].
The method now needs to use up the remainder (of 4 in the example) so it loops through remainder entries in the list and adds 1 to each and returns the result. In the example, the final list is [52, 52, 52, 52, 51, 51].
randomize_pieces
The following randomize_pieces method moves the pieces to random positions.
def randomize_pieces(self):
'''Randomly position the pieces.'''
image_x0 = (self.table_width - self.puzzle_image.width) // 2
image_x1 = image_x0 + self.puzzle_image.width
image_y0 = (self.table_height - self.puzzle_image.height) // 2
MARGIN = 5
xmin = MARGIN
ymin = MARGIN
xmax = image_x0 - self.pieces[0].width - MARGIN
if xmax < xmin + 50: # Can happen for very large sizes.
xmax = xmin + 50
ymax = self.table_height - self.pieces[0].height - MARGIN
for piece in self.pieces:
# Move this piece's home position so it's on the table.
piece.x0 += image_x0
piece.y0 += image_y0
# Position this piece.
x = random.randint(xmin, xmax)
# See if it's to the right of the puzzle image.
if random.randint(0, 1) == 0:
# Move to the right.
x += image_x1
y = random.randint(ymin, ymax)
piece.x = x
piece.y = y
self.num_unplaced_pieces = len(self.pieces)
This method first gets the puzzle image's final location (the red box in the picture). Here image_x0 is the image's left edge, image_x1 is the image's right edge, and image_y0 is the image's top edge.
The code then calculates the minimum and maximum X and Y positions that it will give to a piece. It sets the minimum values to a margin so no piece will be too close to the kitchen table's left or top edge. It sets the maximum X value to the table's width minus the width of the first piece minus the margin. Because of the way divide_evenly adds any remainder space to the first piece sizes, we know that the first piece is the widest so this calculation makes it fit well. (Although it doesn't matter much since the piece widths are within 1 pixel of each other.)
If the pieces are very wide (for example, if you divide a 310-pixel-wide image into pieces of width 310), then xmax may be negative. In that case, the program simply sets xmax = xmin + 50. It's not ideal, but that's what you get for making ridiculously large pieces.
The program calculates ymax similarly, though it doesn't need to worry about ymax being smaller than ymin. (I'll let you figure out why.)
Finally, the method is ready to position the pieces. It loops through the pieces.
First, it adds the puzzle image's origin to the piece's home location so it knows its correct final position. For example, suppose you're looking at the piece in the image's upper left corner. In that case, its initial home position is x0 = 0, y0 = 0. Adding the image's location moves x0 and y0 to the piece's position on the kitchen table.
Next, the code picks a random X coordinate within the allowed range xmin to xmax. The piece is now positioned to the left of the image area (the red box). It then picks a random number between 0 and 1 and, if that number is 0, it moves the piece to the right side of the image area.
The program picks a random Y coordinate and places the piece at those coordinates.
After it has positioned every piece, the method saves the number of pieces in self.num_unplaced_pieces so we know how many pieces are not in their home positions. (Initially, all of them are not in their home positions.)
show_image
The following code shows the show_image method.
def show_image(self):
'''Build and display the kitchen table image.'''
if self.puzzle_image == None: return
# Make the kitchen table image.
table_image = Image.new('RGB',
(self.table_width, self.table_height), 'lightgray')
# Make an ImageDraw object to draw on the table image.
dr = ImageDraw.Draw(table_image)
# Make the pieces draw themselves.
for piece in self.pieces:
piece.draw(dr, table_image)
# Outline the puzzle area.
x0 = (self.table_width - self.puzzle_image.width) // 2
y0 = (self.table_height- self.puzzle_image.height) // 2
x1 = x0 + self.puzzle_image.width - 1
y1 = y0 + self.puzzle_image.height - 1
dr.rectangle(((x0, y0), (x1, y1)), outline='red', width=5)
# Convert into a PhotoImage.
self.photo_image = ImageTk.PhotoImage(table_image)
# Display it.
self.canvas.delete(tk.ALL)
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.photo_image)
self.canvas.config(width=self.table_width, height=self.table_height)
# Size the program to fit.
self.window.geometry(f'{self.table_width}x{self.table_height}')
This method first checks that an image is loaded and returns is one isn't.
It then creates a new gray image that's the right size for the kitchen table. It uses ImageDraw.Draw to make an object that we can draw on.
The code then loops through the pieces and makes them draw themselves on this image. Next, it calculates the puzzle image's location and draws the red box.
To display the result, the program converts the PIL Image object into a PhotoImage object. It's important that this image is stored in a non-volatile location, in this case self.photo_image, so it is available later when the program needs to draw it. If you store the image in a temporary method-local variable, it will be garbage collected you won't see the picture.
Having created the kitchen table's image, the method displays it on the program's Canvas widget.
Finally, the method sizes the Canvas widget to fit the image and then sizes the main window to fit it, too.
Mouse Handling
The program uses several methods to let you drag and drop the mouse.
piece_at
This method finds the piece at a given positon.
def piece_at(self, x, y):
'''Return the puzzle piece (if any) at this position.'''
# Check in reverse order so pieces drawn last (so they are drawn
# on top) are checked first.
for piece in reversed(self.pieces):
if piece.is_at(x, y):
return piece
return None # We didn't find a piece here.
The method loops through the loaded pieces and calls each one's is_at method to see if that piece is at position (x, y). If it finds a piece at that location, the method returns it.
Note that the method loops through the pieces list in reverse order. When the program draws the pieces, it loops through them in their normal order so those that are closer to the beginning of the list are drawn first so later pieces are drawn on top. If click where two pieces overlap, you normally expect to grab the piece on top. By checking the pieces in reversed order, the piece_at method ensures that it picks the one on top.
mouse_down
When you press the mouse down on the kitchen table, the following code executes.
def mouse_down(self, event):
'''If a puzzle piece is below the mouse, start moving it.'''
# Do nothing if we haven't loaded a puzzle yet.
if self.pieces is None: return
# See if there is a piece at this position.
self.selected_piece = self.piece_at(event.x, event.y)
# See if we found a piece here.
if self.selected_piece is not None:
# Move it to the end of the list so it is drawn on top.
self.pieces.remove(self.selected_piece)
self.pieces.append(self.selected_piece)
# Redraw.
self.show_image()
# Save the mouse's position with respect to the root window.
self.start_x = event.x_root
self.start_y = event.y_root
# Save the piece's current location.
self.piece_x = self.selected_piece.x
self.piece_y = self.selected_piece.y
# Calculate minimum X and Y coordinates.
self.max_x = self.canvas.winfo_width() - self.selected_piece.width
self.max_y = self.canvas.winfo_height() - self.selected_piece.height
This code first checks whether a puzzle is loaded and returns if one is not. It then calls the piece_at method to see what piece, if any, is under the mouse.
If piece_at returns a piece, the code removes it from the pieces list and re-adds it to the end of the list. That moves the piece to the top of the stacking order so it will be drawn on top the next time the program redraws the puzzle pieces. In fact, the next statement calls show_image to redraw the pieces, so we didn't need to wait long.
The code saves the mouse's X and Y coordinates with respect to the root window's location, and it saves the piece's location. It then calculates the maximum X and Y coordinates the pieces can have while still remaining on the kitchen table.
mouse_move
When you move the mouse over the kitchen table, the following code executes.
def mouse_move(self, event):
'''Move the selected puzzle piece.'''
# Ignore extraneous events.
if self.selected_piece is None: return
# Move the piece.
x = self.piece_x + (event.x_root - self.start_x)
y = self.piece_y + (event.y_root - self.start_y)
if x > self.max_x: x = self.max_x
if y > self.max_y: y = self.max_y
if x < 0: x = 0
if y < 0: y = 0
self.selected_piece.x = x
self.selected_piece.y = y
# Redraw.
self.show_image()
The code first checks self.selected_piece to see if you have pressed the mouse down over a piece. If you have not, then you're just moving the mouse and not trying to drag anything, so the method returns.
If you are dragging a piece, the code calculates the piece's new position. If the X or Y position is larger than the maximum value calculated in mouse_down, the code adjusts the value so the piece remains on the kitchen table. Similarly, if the X or Y position is smaller than 0, the code adjusts the value to keep the piece on the kitchen table.
After adjusting the calculated position if necessary, the code sets the piece's new location and redraws the pieces.
mouse_up
When you release the mouse, the following code executes.
def mouse_up(self, event):
'''Stop moving the selected puzzle piece.'''
# Ignore extraneous events.
if self.selected_piece is None: return
# Drop the piece at its current position.
if self.selected_piece.drop():
# This piece was locked.
# Move it to the bottom of the stacking order.
self.pieces.remove(self.selected_piece)
self.pieces.insert(0, self.selected_piece)
# See if we're done.
self.num_unplaced_pieces -= 1
if self.num_unplaced_pieces == 0:
# Play the "game over" sound.
winsound.PlaySound('tada.wav',
winsound.SND_FILENAME or winsound.SND_ASYNC)
else:
# Play the "locked" sound.
winsound.PlaySound('click.wav',
winsound.SND_FILENAME or winsound.SND_ASYNC)
# We are no longer dragging the piece.
self.selected_piece = None
# Redraw.
self.show_image()
Like the mouse_move method, this code checks whether you are dragging a piece and returns if you are not. Otherwise, the code calls the piece's drop method. Recall that drop checks whether the piece is close to its home position. If the piece is home, that method plays the click sound, moves the piece home, and returns True.
If drop returns True, the mouse_up method moves the piece to the beginning of the pieces list. That means it is drawn first so it cannot cover pieces that have not yet been placed in their home positions.
If drop returns True, the code also checks whether all of the pieces have gone home and plays a ta-da sound if appropriate. If some pieces are still not all in their home positions, the program plays a click sound as the piece snaps into position.
(While trying to implement the click, I experimented with several ways to play sounds in Python and was disappointed by the results. Some only work with certain versions of Python or with specific versions of the sound library. Some were just plain cumbersome. I decided to use winsound because it was easy. Unfortunately it only works in Windows. If you're using Linux, Mac OS X, or some other operating system, feel free to use a different sound library or just comment out the sound because sound isn't essential for this program.)
Finally, mouse_up sets selected_piece to None so future mouse_move method calls know that no drag is in progress. It then calls show_image to redraw the pieces.
Conclusion
Here are the program's main pieces:
- SettingsDialog - Lets the user enter a desired piece size in pixels
- PuzzlePiece - Represents a puzzle piece and provides these methods:
- draw - Draws the piece in an image
- is_at - Returns True if a given point is contained within the piece's current location
- drop - Positions the piece and, if the piece is close to its home position, locks the piece in position and returns True
- divide_evenly - Divides a number into pieces that are close to the same size
- JigsawApp - The main application class that provides the following methods (among others):
- get_image_file - Lets the user pick a file and loads that file
- disassemble_image - Divides the loaded puzzle image into pieces
- randomize_pieces - Called by disassemble_image, moves the pieces into random positions
- show_image - Draws all of the puzzle pieces on the kitchen table
- mouse_down, mouse_move, mouse_up - Lets the user drag the pieces into new positions
Download the example to play with the program and to see additional details.
|