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