Tkinter Pillow Application 0.1

In my post An Introduction to Image Manipulation with Pillow I commented that "You could in principle use it [Pillow] as the basis of a sort of lightweight Photoshop type application using perhaps Tkinter or PyQT". At the time I wasn't actually intending to do so but recently the idea has started to appeal to me so I thought I'd give it a go.

Although I'm not attempting to compete with Photoshop this is still a fairly ambitious project which will spread over a number of posts, and to start with I'll just get something very basic up and running.

Version 0.1

In this first stage I will start working on a thin wrapper class over the core Pillow functionality which can be used with various front-ends. I'll start with Tkinter with a long-term plan to move into PyQt and wxPython.

The first phase has very modest ambitions and is really only intended to establish the overall architecture of the system. It will provide the following functionality.

  • Opening an image file

  • Displaying the image

  • Displaying image properties

  • Saving the image

  • Closing the image

The hard work will be done by the wrapper class mentioned above which I have rather pretentiously called PillowAppEngine. The Tkinter front end lives in a separate file and creates a PillowAppEngine object, using its methods and attributes.

This is a screenshot of the application as it will be by the end of this post. It isn't much but it gives us a firm foundation to build on.

The Code

The project consists of two files:

  • pillowappengine.py

  • pillowapptkinter.py

The files can be downloaded as a zip, or you can clone/download the Github repository if you prefer.

Source Code Links

ZIP File
GitHub

Let's look at pillowappengine.py first.

pillowappengine.py part 1

import os.path

import PIL
from PIL import Image
from PIL import ImageEnhance


class PillowAppEngine(object):

    """
    Implements a wrapper over the core Pillow functionality.
    Provides state and methods for UIs and other client
    software to streamline Pillow usage.
    """

    PILLOW_VERSION = PIL.PILLOW_VERSION

    def __init__(self):

        self.image = None
        self.filepath = None
        self.saved = None

After a few imports comes the start of the class declaration. PILLOW_VERSION is a class-level attribute initialized from the Pillow library. In __init__ we just initialize a few attributes to None.

pillowappengine.py part 2

    def open(self, filepath):

        """
        Attempts to create a Pillow Image from
        the filepath. If successful this is
        the image attribute.
        """

        try:
            self.image = Image.open(filepath)
            self.filepath = filepath
            self.saved = True
        except Exception as e:
            self.image = None
            self.filepath = None
            raise

    def save(self):

        """
        Attempts to save the image with the
        filename it was opened from.
        """

        try:
            self.image.save(self.filepath)
            self.saved = True
        except Exception as e:
            self.saved = False
            raise

    def save_as(self, filepath):

        """
        Attempts to save the image to the
        specified filepath.
        """

        try:
            self.filepath = filepath
            self.image.save(self.filepath)
            self.saved = True
        except Exception as e:
            self.saved = False
            raise

    def close(self):

        """
        Sets the image other attributes to None.
        """

        self.image = None
        self.filepath = None
        self.saved = None

Here we have four methods to open an image, save it to its original filename, save it with a new filename and close the image. The appropriate attributes are set along the way, and any exceptions are raised so the UI can handle them in an appropriate way.

pillowappengine.py part 3

    def get_properties(self):

        """
        Returns a dictionary containing various
        pieces of information on the image.
        """

        if self.image is not None:

            return {"filepath": self.filepath,
                    "filename": os.path.split(self.filepath)[1],
                    "width": self.image.width,
                    "height": self.image.height,
                    "format": self.image.format,
                    "mode": self.image.mode}

        else:

            return None

    def get_properties_text(self):

        """
        Returns the image information from
        get_properties in a text format.
        """

        if self.image is not None:

            properties = self.get_properties()

            format_string = "File Name: {}\nWidth:     {}\nHeight:    {}\nFormat:    {}\nMode:      {}"

            properties_text = format_string.format(properties["filename"],
                                                   properties["width"],
                                                   properties["height"],
                                                   properties["format"],
                                                   properties["mode"])

            return properties_text

        else:

            return "No image"

The get_properties method returns a dictionary of pieces of information on the current image, or None if there is no image. The first two come from class attributes, and the rest come from the Pillow image's attributes.

The get_properties_text method calls get_properties and then assembles the key/values into a single string with carriage returns.

The PillowAppEngine class is complete so we can move on to providing it with a GUI.

pillowapptkinter.py part 1

from tkinter import *
from tkinter import filedialog
from tkinter import messagebox

import PIL.ImageTk

import pillowappengine


class ApplicationWindow(Frame):

    """
    Create and configure a Tkinter window.
    Also creates a pillowappengine.PillowAppEngine
    object which this application provides
    a front end for.
    """

    def __init__(self, master=None):

        self.pae = pillowappengine.PillowAppEngine()

        self.tkinter_image = None

        self.window_title = "Tkinter Pillow App 0.1"

        self.window = Tk()
        self.window.title(self.window_title)
        self.window.geometry("800x600")
        self.window.attributes('-zoomed', True)
        self.window.grid_propagate(False)

        self.window.update()
        self.width = self.window.winfo_width()
        self.height = self.window.winfo_height()

        self.create_menu()

        self.create_widgets()

        self.window.mainloop()

There are various ways of putting together a Tkinter application and I have settled on the approach which I have used here.

The imports include PIL.ImageTk, the purpose of which will become clear shortly.

The ApplicationWindow class's __init__ first creates an instance of PillowAppEngine. The tkinter_image is created from the Pillow image and is separate from it, and the window_title attribute is set to a base value.

Next we create the window itself and set a few attributes, including making the window maximized. Then we keep the window's height and width for future use before calling separate functions to create the menu and widgets.

Finally the mainloop method is called, creating an event loop which listens for and responds to user events (basically keyboard and mouse activity) until the application is closed.

pillowapptkinter.py part 2

    def set_image_label_size(self):

        """
        Resizes the label containing the image
        to the window.
        """

        if self.image_label.image is not None:

            self.image_label.config(width=self.width - 6, height=self.height - 6 - self.toolbar.winfo_height())

    def on_resize(self, event):

        """
        Handles the window's Configure event
        for the case when the window is
        being resized.
        """

        self.window.update()

        if self.window.winfo_width() != self.width or self.window.winfo_height() != self.height:

            self.width = self.window.winfo_width()
            self.height = self.window.winfo_height()

            self.set_image_label_size()

The second chunk of code contains a function set_image_label_size which changes the size of the label used to display the image so it always fits the window. (Tkinter is frankly rather primitive and if it does provide this functionality itself it is too complex or obscure for my simple mind.)

There is also a function called on_resize to handle the window's Configure event. This event is fired when any part of the window's configuration is changed but here we just pick up the occasions when the size is changed and call set_image_label_size.

pillowapptkinter.py part 3

    def create_menu(self):

        """
        Create a menu and add items and functions
        to handle item selections.
        """

        menu = Menu(self.window)

        filemenu = Menu(menu)
        menu.add_cascade(label="File", menu=filemenu)
        filemenu.add_command(label="Open...", command=self.open)
        filemenu.add_command(label="Save", command=self.save)
        filemenu.add_command(label="Save as...", command=self.save_as)
        filemenu.add_command(label="Close", command=self.close)
        filemenu.add_separator()
        filemenu.add_command(label="Exit", command=self.window.quit)

        imagemenu = Menu(menu)
        menu.add_cascade(label="Image", menu=imagemenu)
        imagemenu.add_command(label="Info", command=self.image_info)

        helpmenu = Menu(menu)
        menu.add_cascade(label="Help", menu=helpmenu)
        helpmenu.add_command(label="About", command=self.about)

        self.window.config(menu=menu)

    def create_widgets(self):

        """
        Add toolbar, image window and all other
        non-menu items.
        """

        self.toolbar = Frame(self.window, borderwidth=1, relief="raised")
        self.toolbar.grid(row=0, column=0, padx=0, pady=0, sticky=W)

        self.open_button = Button(self.toolbar, text="Open", command=self.open)
        self.open_button.grid(row=0, column=0, padx=2, pady=2, sticky=W)

        self.save_button = Button(self.toolbar, text="Save", command=self.save)
        self.save_button.grid(row=0, column=1, padx=2, pady=2, sticky=W)

        self.save_as_button = Button(self.toolbar, text="Save as", command=self.save_as)
        self.save_as_button.grid(row=0, column=2, padx=2, pady=2, sticky=W)

        self.image_label = Label(self.window, borderwidth=1, bg="white", relief="sunken", image=None)
        self.image_label.image = None

        self.window.bind("<Configure>", self.on_resize)

The create_menu function is hopefully self-explanatory, and creates a hierarchy of menus and menu items. At the top level we have an object called menu; this contains three sub-items, filemenu, imagemenu and helpmenu which are visible at the top of the main window at all times. Each of these has various menu items which are shown as and when their parent menus are clicked. These bottom-level items have functions attached to their command attributes to handle click events.

The create_widgets function creates a frame at the top of the window to act as a toolbar container, and then adds a few buttons to it. All buttons replicate commands also available in the menu so have their command attributes set to the same functions. These are then added to the frame with calls to grid.

Next we create the label used to display the image. Note that it isn't actually added to the grid at this stage but will be added/removed as and when images are opened/closed.

Finally we need to bind the on_resize function described above to the window's Configure event.

pillowapptkinter.py part 4

    def about(self):

        """
        Currently displays the Pillow version.
        Needs to be expanded.
        """

        messagebox.showinfo('Pillow Version', pillowappengine.PillowAppEngine.PILLOW_VERSION)

    def image_info(self):

        """
        Shows information about the current image.
        Will possibly be replaced with widgets
        in the main window.
        """

        messagebox.showinfo('Image Info', self.pae.get_properties_text())

Just a couple of one-liners here. The about function is one of the menu/button click event handlers and currently just shows the version of Pillow in use but will be expanded for the next version.

The image_info function is also a command handler and shows the text from get_properties_text.

pillowapptkinter.py part 5

    def open(self):

        """
        Shows an Open File dialog and opens/displays
        the selected image.
        """

        try:

            filepath = filedialog.askopenfilename(title="Open image", filetypes=(("JPEG files", "*.jpg"),))

            # Clicking Cancel returns an empty tuple.
            if filepath != ():

                self.pae.open(filepath)
                self.tkinter_image = PIL.ImageTk.PhotoImage(self.pae.image)

                self.image_label.configure(image=self.tkinter_image)
                self.image_label.image = self.tkinter_image
                self.image_label.grid(row=1, column=0, padx=2, pady=2)

                self.set_image_label_size()

                self.window.title(self.window_title + ": " + self.pae.get_properties()["filename"])

        except Exception as e:

            self.show_error_message(e)

    def save(self):

        """
        Saves the image to the filename
        it was opened from.
        """

        try:
            self.pae.save()
        except Exception as e:
            self.show_error_message(e)

    def save_as(self):

        """
        Shows a Save As dialog and saves the image
        to the selected filename.
        """

        try:

            filepath = filedialog.asksaveasfile(title="Save image as", filetypes=(("JPEG files", "*.jpg"),))

            # Clicking Cancel returns None.
            if filepath is not None:

                self.pae.save_as(filepath)

                self.window.title(self.window_title + ": " + self.pae.get_properties()["filename"])

        except Exception as e:

            self.show_error_message(e)

    def close(self):

        """
        Closes the current image, removes the image label
        and resets the window title.
        """

        self.pae.close()
        self.image_label.grid_forget()
        self.window.title(self.window_title)

    def show_error_message(self, e):

        messagebox.showerror("Error", e)

Here we have four more functions to handle commands from the menu and/or toolbar.

Firstly open, which calls Tkinter's askopenfilename method to display a dialog box. If the user clicks Cancel the method returns any empty tuple so we need to check for this. However, if we have an actual filename we pass it to the PillowAppEngine's open method. We then call PIL.ImageTk.PhotoImage (remember we imported PIL.ImageTk) to get an image which can be displayed in Tkinter. This is separate from the Pillow image in the PillowAppEngine, and is saved in the ApplicationWindow object's tkinter_image attribute. The tkinter_image is then used as a image label's image and the image label is then shown by adding it to the grid. Lastly we set the image label's size and set the main window's title.

Next comes save; this is a lot simpler as it just calls the PillowAppEngine's save method.

The save_as function uses Tkinter's asksaveasfile method to display a dialog. This time clicking Cancel returns None, but if we have a filename we just call save_as and set the window title to the new filename.

The close function calls the PillowAppEngine's close method before hiding the image label and setting the window title to its basic value with no filename.

Finally show_error_message is a short utility function to streamline displaying error messages in a uniform way. Tkinter provides us with the showerror method and currently I am just displaying the exception as-is although there is scope for enhancement here.

Right, let's run the application and see what we get.

Running the program

python3.7 pillowapptkinter.py

You should see something very like the screenshot at the top of the page, subject to variations on different operating systems or desktop environments. Spend a minute or two checking the menus and buttons behave as they should, and that the image resizes with the window.

Where Next?

I have a long and growing list of features to add to both the PillowAppEngine and the GUI. For Version 0.2 I will make a few enhancements to the Tkinter UI, and then for further versions I will start to add actual image editing functionality to PillowAppEngine along with the corresponding additions to the user interface.

Version 0.1 is just the first step on the journey towards something which deserves to be called Version 1.0!

Please follow this blog on Twitter for news of future posts and other useful Python stuff.