9
\$\begingroup\$

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:

enter image description here

Relevant features include:

  1. Upon startup, ask user what directory they want to animate images from.

  2. 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.

  3. Using up and down arrow keys speeds and slows the frame rate.

  4. 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.

  5. 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()
\$\endgroup\$
3
\$\begingroup\$

Your sorting is overcomplicated as you re-invent a sorting algoritmh in your code while Python has it built-in.

You should use the standard list.sort function and give it an appropriate key (credit to Mathias for suggesting this as it works in-place). "If the name starts with a digit sort by the integer value of the starting number else sort alphabetically".

\$\endgroup\$
  • \$\begingroup\$ Note that the sorting is done inplace in OP's function. You could suggest list.sort for the same functionality. \$\endgroup\$ – Mathias Ettinger Aug 11 '16 at 12:53
  • 1
    \$\begingroup\$ That's cool! I didn't realize sort had a key option. \$\endgroup\$ – Joe Aug 11 '16 at 15:35
  • \$\begingroup\$ @MathiasEttinger Answer amended to suggest .sort \$\endgroup\$ – Caridorc Aug 11 '16 at 16:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.