I am writing a program in Python that takes a folder of images and animates them.
For example, going through a list of pictures like this and animating them one by one:
Relevant features include:
Upon startup, ask user what directory they want to animate images from.
If the chosen directory has no valid images, display an error message that describes the error, shows the user a list of valid file types, and then re-asks for a directory.
Using up and down arrow keys speeds and slows the frame rate.
The window starts out at default size, but the user can make the window bigger or smaller with his/her mouse. Then, when the user refreshes (presses Enter), the images will be scaled to fit the window size he/she has chosen. Images will always keep their aspect ratio.
Image files are sorted numerically and alphabetically for the animation. i.e.
['hello.ico', '10.png', '2.jpg']
becomes
['2.jpg', '10.png', 'hello.ico']
I know the code works in Python 2.7, but I haven't tried it in Python 3. It should work, I think!
Any comments, improvements, or suggestions?
# -*- coding: utf-8 -*-
"""
Flipbook Animation: Animate a folder of images.
All image names that are numbers will be sorted in
increasing order, the rest will be sorted alphabetically.
For example, files:
"70.png", "image.jpg", "29.bmp", and "8.ico"
will be animated in the following order:
"8.ico", "29.bmp", "70.png", "image.jpg".
Image types must be in VALID_TYPES (defined below).
Up/Down arrow key increases/decreases the frame rate.
Enter (Return) key refreshes the animation.
Images will be scaled to fit the screen size while
keeping their original aspect ratios. Therefore, to
resize the images, simply resize the window with the
mouse and press Enter (Return) to refresh.
"""
import io, os
from PIL import Image, ImageTk
if os.sys.version_info.major > 2:
from tkinter.filedialog import askdirectory
import tkinter as tk
else:
from tkFileDialog import askdirectory
import Tkinter as tk
#Icon for main window.
FAVICON = "flipbook.ico"
## Image file extensions that PIL supports reading from.
## Must all be lowercase because that is how they are compared later.
## From "https://infohost.nmt.edu/tcc/help/pubs/pil/formats.html"
VALID_TYPES = (
"bmp", "dib", "dcx", "gif", "im", "jpg", "jpe", "jpeg", "pcd", "pcx",
"png", "pbm", "pgm", "ppm", "psd", "tif", "tiff", "xbm", "xpm"
)
class FolderError(Exception):
"""
Raised if the directory selected by the user has no valid images to animate.
directory: str, path of user selected folder.
"""
message = "No valid file types %s found in '{}'." % str(VALID_TYPES)
def __init__(self, directory):
self.message = FolderError.message.format(directory)
def __str__(self):
return self.message
def sort_list(l):
"""
Mutates l, returns None.
l: list of filenames.
Sorts l alphabetically. But fixes the fact that,
alphabetically, '20' comes before '9'.
Example:
if l = ['10.png', '2.jpg']:
a simple l.sort() would make l = ['10.png', '2.jpg'],
but this function instead makes l = ['2.jpg', '10.png']
"""
l.sort()
numbers, extensions = [], []
i = 0
while i < len(l):
try:
s = l[i].split(".")
numbers.append(int(s[0]))
extensions.append(s[1])
l.pop(i)
except ValueError:
i += 1
#Selection sort.
for i in xrange(len(numbers)):
for n in xrange(i, len(numbers)):
if numbers[i] < numbers[n]:
numbers[i], numbers[n] = numbers[n], numbers[i]
extensions[i], extensions[n] = extensions[n], extensions[i]
for i in xrange(len(numbers)):
l.insert(0, "%d.%s" % (numbers[i], extensions[i]))
def get_files(directory):
"""
directory: str, directory to search for files.
Returns a sorted generator of all the files in directory
that are a valid type (in VALID_TYPES).
"""
files = []
for f in os.listdir(directory):
if len(f.split(".")) > 1 and f.split(".")[1].lower() in VALID_TYPES:
files.append(f)
sort_list(files)
return (directory+"/"+f for f in files)
def get_images(directory, screen):
"""
directory: str, path to look for files in.
screen: tuple, (w, h), screen size to resize images to fit on.
Sorted generator. Yields resized PIL.Image objects for each
supported image file in directory.
"""
for filename in get_files(directory):
with open(filename, "rb") as f:
fh = io.BytesIO(f.read())
#Create a PIL image from the data
img = Image.open(fh, mode="r")
#Scale image to the screen size while keeping aspect ratio.
w, h = img.size
scale = min((screen[0]/float(w), screen[1]/float(h)))
yield img.resize((int(w*scale), int(h*scale)), Image.ANTIALIAS)
class Home(object):
"""
Creates a tk.Frame and packs it onto the main window (master).
Creates a tk.Canvas and packs it onto the frame.
master: tk.Tk window.
"""
def __init__(self, master, directory):
self.frame = tk.Frame(master)
self.frame.pack(expand=tk.TRUE, fill=tk.BOTH)
self.can = tk.Canvas(self.frame)
self.can.pack(expand=tk.TRUE, fill=tk.BOTH)
#User can update screen size as he/she wishes.
master.update()
screen = master.winfo_width(), master.winfo_height()
#ImageTk.PhotoImages must be made after main Tk window has been opened.
self.photos = [
ImageTk.PhotoImage(image=img) for img in get_images(directory, screen)
]
if not self.photos: #len(self.photos) must be > 0.
raise FolderError(directory)
self.index = 0 #Index of next photo to draw in self.photos.
self.current_image = False #Start with no image drawn on screen.
#center position of photos on screen.
self.center = screen[0]//2, screen[1]//2
#Milliseconds between screen redraw.
self.speed = 100
self.master, self.directory = master, directory
def animate(self):
"""
Destroys old image (if there is one) and shows the next image
of the sequence in self.photos. If at end of sequence, restart.
Re-calls itself every self.screen milliseconds.
"""
#Remove old image.
if self.current_image:
self.can.delete(self.current_image)
#Continue to next image if possible; otherwise, go back to beginning.
self.index = self.index + 1 if self.index + 1 < len(self.photos) else 0
#Draw image.
self.current_image = self.can.create_image(self.center,
image=self.photos[self.index])
#Recall self.animate after self.speed milliseconds.
self.frame.after(self.speed, self.animate)
def refresh(self):
""" Destroy current frame. Re-initialize animation. """
self.frame.destroy()
self.__init__(self.master, self.directory)
self.animate()
def increase_fps(self):
""" Decrease time between screen redraws. """
self.speed = self.speed - 1 if self.speed > 1 else 1
def decrease_fps(self):
""" Increase time between screen redraws. """
self.speed += 1
def display_error(name, message):
"""
name: str, type of error to display as title.
message: str, error message to display.
Create new window, display error message.
"""
root = tk.Tk()
root.resizable(0, 0)
root.bell()
color = "#ffe6e6"
root.config(bg=color)
root.title(name)
root.attributes("-toolwindow", 1)
tk.Message(root, text=message, bg=color, width=400).pack()
tk.Button(root, text="Okay", command=root.destroy, width=7).pack()
root.eval('tk::PlaceWindow %s center'%root.winfo_pathname(root.winfo_id()))
root.bind("<Return>", lambda event: root.destroy())
root.mainloop()
def animate(root):
"""
root: tk.Tk window.
Ask for a directory of images to animate.
If directory does not contain at least one valid image:
display Error window and recall main.
Bind keys:
arrow keys increase/decrease frame rate.
enter/return refreshed animation.
Begin animation.
"""
directory = askdirectory(parent=root, title="Select folder to animate.")
if not directory: #If user selected cancel or exited.
root.destroy()
else:
try:
home = Home(root, directory)
#Key bindings
root.bind("<Return>", lambda event: home.refresh())
root.bind("<Up>", lambda event: home.increase_fps())
root.bind("<Down>", lambda event: home.decrease_fps())
#Place window in the center of the screen.
root.eval(
'tk::PlaceWindow %s center'%root.winfo_pathname(root.winfo_id())
)
home.animate()
#Display any error that was encountered (should only be FolderError).
except Exception as e:
root.destroy()
display_error(e.__class__.__name__, e) #Display error window.
main() #Restart.
## My intention was to re-call animate(root) instead of destroying
## root and then calling main. That way I didn't have to keep
## making new main windows. This didn't work. The askdirectory
## function didn't comply!
## i.e.
## display_error(e.__class__.__name__, e)
## animate(root)
def main():
""" Open new window. Try to set icon. Set title. Call animate. """
root = tk.Tk()
try: root.wm_iconbitmap(FAVICON)
except tk.TclError: pass #In case file has been moved, etc.
root.title("Flipbook Animation")
animate(root)
root.mainloop()
if __name__ == "__main__":
main()