Python: Building an Image Manipulation Tool (Part 2)

A tutorial and case study on developing an image processing application with Python

Dane Bulat
18 min readJan 13, 2021

Introduction

This is the second part of a tutorial and case study that details how to develop an image processing application with Python. The first article focused on setting up a virtual environment with Pipenv and installing our application’s package dependencies. Subsequent sections also discussed our tool’s command line arguments in addition to some example invocations to demonstrate its usage.

Refer to the content presented in part one to get up to speed on the project. It is comprised of three sections that discuss the following topics:

  1. Project Overview
    The project’s directories and source files are discussed. From there, we go through the process of creating a virtual environment with Pipenv.
  2. How to Use The Image Manipulation Tool
    Our tool’s usage and command line options are detailed. We also discuss the difference between positional arguments and optional arguments.
  3. Example Commands with Screenshots
    A few example invocations of our program are demonstrated to show how it can be used in real-world scenarios. Before and after screenshots accompany each example.

This part will walk through our project’s source code. We will take a look at using the standard argparse Python module, as well as how to process images in different ways with the Pillow API. Topics of discussion are organized into three sections:

  1. Passing Command Line Arguments
    This section details how the standard argparse Python module is used to parse command line arguments and convert them into Python objects.
  2. Handling Parsed Arguments
    We then talk more about the program’s structure and adopted coding conventions in order to improve readability, prevent duplication, and maintain performance.
  3. Image Processing with Pillow
    Lastly, we discuss how our application integrates the Pillow API and inspect the code used for performing image processing operations.

Source Files

A GitHub repository has been set up for readers wishing to download the application discussed in this article. Navigate to an appropriate place on your file system and invoke Git to clone the repository:

$ git clone https://github.com/danebulat/py-image-manip-demo.git

Refer to part one of this tutorial if you need help installing a virtual environment, or would like to find out more about how to use the program.

1. Parsing Command Line Arguments

The first major task our program does is parse command line arguments. This entire process is handled in the parser.py module. Within it, the standard argparse module is utilized to parse arguments out of sys.argv. A lot of useful functionality is provided in the argparse module and its corresponding ArgumentParser object. This includes:

  • The automatic generation of configurable help and usage messages.
  • Appropriate error messages are issued when a user provides invalid arguments.
  • A flexible API for defining both positional and optional arguments.
  • The ability to apply rules to arguments to govern their type and the number of values they can receive.

Subsections that follow describe how the argparse API is used to define a usable command line interface. The official Python website also provides extensive documentation that covers the entire module in a linear fashion. It is therefore a good resource to find out more information about its usage.

Creating an Argument Parser

The first line in our application makes a call to a method we define ourselves called parse_arguments():

namespace = parser.parse_arguments()

This line demonstrates that parse_arguments() is defined in the parser module and returns an object that is referenced by a variable called namespace. Providing a user has specified valid arguments on the command line, these arguments are converted into usable Python objects and attached to the object referenced by namespace.

Command line arguments are parsed before any image processing is performed within our program. In order to do this, we firstly instantiate a class defined in the argparse module of type ArgumentParser. This object provides methods for adding command line arguments, parsing input, and getting them back in the form of Python objects.

Let’s open up parser.py and inspect the parse_arguments() function to see how an ArgumentParser is created:

Creating an ArgumentParser object.

Our configured ArgumentParser object is created and referenced by a variable called parser. Notice that parameters are specified as keyword arguments in its constructor. The values we assign to each argument govern how the program's help text is displayed when issuing the -h and --help options. Let's take a look at what each parameter does:

  • prog='imgmanip'
    A string representing the name of our program.
  • usage='%(prog)s FILES [-e ENHANCERS] [-f FILTERS] [--thumbnail] ...'
    A string describing the program’s usage. Displayed on the first line of the help text.
  • description='\Perform a range of manipulations on images files...'
    A description of what the program does. Displayed immediately after the usage string.
  • epilog='Part of the PIL tutorial at: https://dane-bulat.medium.com'
    Text that is displayed after the argument list.
  • formatter_class=argparse.RawDescriptionHelpFormatter
    A class to format specific text output. We use a formatter class called RawDescriptionHelpFormatter to give us more control of how textual descriptions are displayed.
  • add_help=False
    A boolean indicating whether to add a -h / --help option by default. Because we want to define a custom help message, this parameter is assigned a False value.
  • allow_abbrev=False
    A boolean indication whether a long optional argument can be abbreviated if the abbreviation is unambiguous. For example, if assigned True, we could specify --res instead of --resize. Our program opts to turn this feature off and assigns False.
  • exit_on_error=True
    Determines whether ArgumentParser exits and provides error information when an invalid argument is encountered. We opt to enable this functionality by assigning a True value.

We take the opportunity to describe our program’s available filters and enhancers in the description text. This section usually consists of only a few sentences and is really meant to be brief. Developers wishing to stick to this convention could create an additional option, such as --usage, to output our very long description.

Positional Arguments

Once a configured ArgumentParser object is created, we call its add_argument() method to define both positional and optional command line arguments. We start off by defining our program’s only positional argument that receives one or more image filenames:

parser.add_argument('inputs', action='store', nargs='+',
help='A list of image files to process.')

The first value given to add_argument() is always a Python string representing the name of an argument - inputs in our case. This name is displayed in the usage and argument list sections of the help text. It is therefore important to provide a name that is both short and easy to understand.

A keyword parameter called action describes the type of action that is taken when a particular argument is encountered on the command line. We instruct the parser to simply store the value given to our argument in a variable by providing a string defined as 'store'. This means argparse will create an internal variable called inputs to store whatever is provided on the command line.

The type of variable a parser generates to store an argument’s values is governed by what is assigned to other parameters in add_argument(). One of them is a parameter called nargs, which we assign '+' above. The plus character means that an argument can receive one or more values. Our parser therefore determines that the inputs object is in fact a Python list. This also means that an error will be issued if no values are given to the inputs argument.

Lastly, a help string should always be provided for each argument added to a parser. As you would expect, this string is displayed in the program’s help text. It should provide a brief description of what the argument does.

Special Optional Arguments

Let’s switch our focus to talking about “special” optional arguments that are provided by the argparse module. These are the standard --help and --version options. The add_argument() method is called again to generate these options:

parser.add_argument('-h', '--help', action='help',
default=argparse.SUPPRESS,
help="Show this help message and exit.")

parser.add_argument('--version', action='version',
version='%(prog)s ' + get_version_string(),
help="Show program's version number and exit.")

Just like positional arguments, a name must firstly be specified when defining optional arguments. A short name is given before a long name, and they are prefixed with one and two dash characters respectively.

When it comes to handling the --help argument, a special 'help' string is assigned to the action parameter. This instructs our parser object to print its associated help text and exit the program.

Similarly, the --version argument's action parameter is assigned a special 'version' string. As a result, a keyword parameter called version is exposed that should be assigned the program's version string. We assign it a string containing a special format specifier denoted by %(prog)s - which returns the string we assigned to prog in the ArgumentParser constructor.

Boolean Optional Arguments

Arguments that are parsed into boolean objects must be assigned either 'store_true' or 'store_false' actions. We use this technique for detecting whether the --info or --thumbnail options were specified on the command line:

parser.add_argument('-i', '--info', action='store_true',
help='Display image properties.')

parser.add_argument('--thumbnail', action='store_true',
help='A thumbnail is generated.')

We assign 'store_true' action strings to both of our boolean arguments. As a result, our parser object will generate corresponding variables called info and thumbnail which will either contain True - if the argument was given, or False - if the argument was left out.

Single Value Optional Arguments

The --factor and --thumbnail-width optional arguments are configured to accept a single value:

parser.add_argument(
'--factor', action='store', default=1.2, type=float,
help='Float to specify the strength of enhancers.')

parser.add_argument(
'--thumbnail-width', action='store', type=int, default=128,
help='Specify a width for the generated thumbnail.')

Notice that we specify a default value that our parser will use to initialize the argument's internal variable if one if not provided on the command line. The factor argument is tied to a default value of 1.2, whilst the default width for a generated thumbnail is 128 pixels.

We also provide a type that the parser will use for generating an argument's internal variable. In this case, a factor is parsed into a float, while a thumbnail width is passed into an int.

Optional Arguments with Pre-Determined Choices

The choices keyword parameter allows us to provide a list of allowed values for a given argument. We take advantage of this functionality when defining the --flip and --rotate optional arguments:

parser.add_argument(
'--flip', action='store', nargs='+', choices=['horz', 'vert'],
help='Flip an image vertically or horizontally.')

parser.add_argument(
'--rotate', action='store', choices=[90, 180, 270], type=int,
help="Rotate an image in a 90 degree increment.")

As demonstrated above, the --flip argument only accepts 'horz' and 'vert' strings. In addition, the --rotate argument can only be given three values — either 90, 180 or 270.

Multiple Value Optional Arguments

Similar to the inputs positional argument, we set the nargs parameter to a plus character ('+') when defining the --filter and --enhancer optional arguments. It is also possible to control the name of an argument's internal variable by assigning a string to the dest parameter.

parser.add_argument(
'-f', '--filter', action='store', nargs='+',
help='Apply filters to an images.', dest='filters')

parser.add_argument(
'-e', '--enhance', action='store', nargs='+',
help='Apply enhancers to an images.', dest='enhancers')

When we later call parse_args() to return an object containing our parsed values, the list of filter strings will be assigned to an attribute called filters. Likewise, the list of enhancer strings will be assigned to an attribute called enhancers.

In addition, we opt to validate filter and enhancer strings ourselves rather than utilizing the choices parameter for a couple of reasons:

  • The help text will become very hard to read if every possible value is listed.
  • We want to do some additional processing to make filter and enhancer names case-insensitive.

We will take a look at the corresponding validation functions shortly.

Mutually Exclusive Optional Arguments

Lastly, we create a mutually exclusive argument group that is used to define our resize options. As a result, only one argument defined in this group can be given on the command line at any given time. It makes sense to do this because each of the resize options modify the same properties of an image, but in different ways. For example, it would not be logically correct to specify both --resize-width and --resize-width-proportional on the command line.

A mutually exclusive group is created by calling a parser object’s add_mutually_exclusive_group() method. We call add_argument() on the returned group object itself to append mutually exclusive arguments to it:

Defining resize arguments in a mutually exclusive argument group.

Returning Arguments to the Application

An ArgumentParser object contains a method called parse_args() which we call to generate actual Python object’s that are derived from arguments specified on the command line:

args = parser.parse_args()
return args

At this point, our application can check the attributes attached to this object and run appropriate operations requested by the user.

2. Handling Parsed Arguments

When the parsing phase is complete, our application receives a collection of Python objects that correspond to each argument specified on the command line. These objects are attached to a variable we call namespace. We pass this object around the application in order to check its attributes and perform appropriate operations.

When the namespace object is returned by parse_arguments() in imgmanip.py, the application immediately checks its inputs attribute to iterate through image filenames that were specified on the command line:

for index, filename in enumerate(namespace.inputs):
try:
with Image.open(filename) as opened_image:
handle_arguments(namespace, opened_image, index)
except FileNotFoundError as err:
print(f"WARNING: {err.filename} not found. Skipping...")
  • This is the first time we make use of the Pillow API. A call to Image.open() loads an image file into memory and a variable called opened_image points to the loaded image data. In other words, opened_image is our handle to the loaded image.
  • A call to handle_arguments() is made upon loading an image and is passed both the namespace object and opened_image handle. This function is implemented in handler.py — a module located in our project's imgmanip/ directory.
  • An exception of type FileNotFoundError is thrown if a particular filename is not found. If this occurs, we output the filename in question and continue to process the next filename in namespace.inputs.

This is a good time to talk about how the program handles image loading. An image is loaded into memory by calling Image.open() and is pointed at by a variable called opened_image. From here, any image processing that is invoked will work on this single source of data - no temporary copies of image data is made.

In addition, a save operation will only ever be performed once for a loaded image. This will occur after all command line arguments have been handled and image processing is finished. At this time, a new image is saved as a physical file on the file system.

Handling Arguments

Let’s now switch our focus to the handler.py module, which is responsible for checking objects in namespace and performing the correct operations. The job of this file is to simply detect which image manipulation operations to perform, and then offload these operations to another object. It doesn't contain any code that modifies the internal representation of the loaded image.

This other object is an instantiation of a class called ImageManipulator that we implement ourselves in the manipulator.py module. An ImageManipulor object contains functions that actually modify loaded image data. We issue an import statement at the top of handler.py to gain access to this object:

from manipulator import ImageManipulator

Looking inside the handle_arguments() function, the first thing it does is instantiate an ImageManipulator object:

def handle_arguments(namespace, image):
"""Function to handle command line arguments."""
manip = ImageManipulator(image, index)

Notice that we pass our image handle to the ImageManipulator constructor. The constructor proceeds to cache the handle in one of its own class attributes called self.image. This means that functions defined in ImageManipulator will always have access to the image it is working on.

From here, handle_arguments() proceeds to check attributes attached to the namespace object through simple if statements. If the namespace object is found to have a particular attribute, the image manipulator object (manip) or a corresponding handler function is called to fulfil the operation:

Handling objects received from the command line.

A new image is saved to disk once all arguments have been handled. The is accomplished by calling ImageManipulator.save_image():

# Save the new file if necessary
manip.save_image()

Handler Functions

Individual handler functions are defined in handler.py to fulfil particular operations. They also keep our Python code readable, and prevent duplication. A handler function is defined for operations that need some preliminary setup code before the ImageManipulator object can be used.

For example, the _handle_filters() function must firstly validate filter names received from the command line before it can give the go-ahead for the ImageManipulator to do any processing. Because namespace.filters is a Python list, it is also necessary to iterate through each filter name and apply them individually. This same approach is taken to apply enhancers.

There are three handler functions defined in handler.py. Because their implementations are easy to understand, we won't spend too much time talking about their code. Instead, a summary of each function is provided here:

  • _handle_filters(namespace, manip)
    Validates filter names received from the command line and instructs the image manipulator object to apply each valid filter to the loaded image. Each filter string is converted to uppercase before it is passed to _is_valid_filter() to perform validation. If a filter name fails validation, a message is displayed and the next filter string in the list is processed.
  • _handle_enhancers(namespace, manip)
    Validates enhancer names received from the command line and instructs the image manipulator object to apply each valid enhancer to the loaded image. Each enhancer string is converted to uppercase before it is passed to _is_valid_enhancer() to perform validation. If an enhancer name fails validation, a message is displayed and the next enhancer string in the list is processed.
  • _handle_resizing(namespace, manip)
    Checks which resize argument (if any) was specified on the command line and calculates a new width or height value if necessary, before offloading image processing to the image manipulator object.

It is also important to be aware of the number of scopes (or indentation) that exist in any particular function. For example, many nested if statements and looping constructs could lead to code that is hard to follow or comprehend. To mitigate this issue, our application makes an effort to only use two or three scopes in any given function.

Validation Functions

Let’s wrap up our discussion on the handler.py module by discussing its validation features. Separate functions are defined to validate both filter and enhancer names received from the command line.

Parsed filter names are contained in a list of strings that correspond to values that were specified on the command line (after the -f or --filter flags). Our _handle_filters() function validates these strings by firstly converting them to upper case and calling _is_valid_filter():

def _is_valid_filter(filter_string):
"""Validates a passed filter string."""

if filter_string in [
'BLUR', 'CONTOUR', 'DETAIL', 'EDGE_ENHANCE',
'EDGE_ENHANCE_MORE','EMBOSS', 'FIND_EDGES',
'SHARPEN', 'SMOOTH', 'SMOOTH_MORE']:
return True
return False

If the filter_string argument matches any string in the python list following the in keyword, True is returned; otherwise, False is returned.

The same approach is taken to validate enhancer names:

def _is_valid_enhancer(enhancer_string):
"""Validates a passed enhancer string."""
if enhancer_string in [
'BRIGHTNESS', 'COLOR', 'COLOUR', 'CONTRAST',
'SHARPNESS', 'GREYSCALE', 'GAUSSIANBLUR', 'BOXBLUR']:
return True
return False

3. Image Processing with PIL

The manipulator.py module contains a class called ImageManipulator which implements methods that process and modify image data. This section will go through each of these methods and detail how the Pillow API is used to manipulate image data.

We start by importing necessary Pillow modules at the top of manipulator.py:

from PIL import Image, ImageEnhance, ImageFilter

We import the Image class to access an image’s internal data and perform a range of operations on it. PIL also provides ImageEnhance and ImageFilter modules for applying specific filters and enhancements to image data.

Now is a good time to mention the ImageManipulator constructor, which initializes four important attributes which are referenced in corresponding class methods:

def __init__(self, image):
self.image = image
self.original_filename = image.filename
self.save_flag = False
self.index = index
  • self.image = image
    A handle to the current loaded image is cached, making it readily accessible to every method defined in the class.
  • self.original_filename = image.filename
    The filename of the current loaded image is cached for convenience and is used in formatting operations.
  • self.save_flag = False
    This flag determines whether a new image file should be saved to disk after all command line arguments have been handled. It will be assigned True if the loaded image data is modified in any way.
  • self.index = index
    If multiple image files are specified on the command line, this attribute will keep track of which image is currently being processed. It is used to generate uniquely named image files.

Displaying Image Properties

Moving forward, a method called output_information() is responsible for outputting properties of the loaded image. Attributes defined in the Image class are used to output its corresponding filename, image size (in pixels), format and band information (such as RGB, RGBA, L, etc.):

def output_information(self):
"""Sends image properties to standard output."""
print("{0:<9}: {1}".format("Filename", self.image.filename))
print("{0:<9}: {1}".format("Size", self.image.size))
print("{0:<9}: {1}".format("Format", self.image.format))
print("{0:<9}: {1}\n".format("Bands", self.image.getbands()))

Format specifiers are passed to the print() function to output information cleanly. A column width of nine characters is output by specifying the <9 character combination.

Applying Image Filters

Two methods are defined in ImageManipulator that handle filter processing. The apply_filter_and_save() method firstly sets the save flag to True and calls the _apply_filter() "private" method to apply an actual filter to the loaded image data:

def apply_filter_and_save(self, filter_string):
"""Apply a filter to a loaded image and save the new version."""

self.save_flag = True
self.image = self._apply_filter(filter_string)
print(f"{filter_string} filter applied to \
{self.original_filename}")

Within _apply_filter(), we call Image.filter() to apply a particular filter. This method is passed a filter name defined in the Pillow ImageFilter module that indicates which filter to use. The correct filter is applied based on the filter_string argument:

How a filter is applied to loaded image data.

Applying Image Enhancements

The apply_enhancer_and_save() method is responsible for determining which enhancer to apply to the loaded pixel data. Its enhancer_string argument is firstly compared with hard coded enhancer strings such as 'BRIGHTNESS', 'CONTRAST', 'SHARPNESS', and so on. We also allow the color enhancer to be specified using either "color" or "colour" spellings.

When the enhancer_string argument matches one of these hard-coded strings, a corresponding enhancer object defined in the ImageEnhance module is created. This object is referenced by a variable we create ourselves called enh — which is then pass to another class method called _apply_enhancer() along with the factor value:

Creating enhancer objects based on an enhancer string.

A call to the enhancer object's enhance() method is made inside _apply_enhancer(), which returns the resulting image data to our self.image handle:

def _apply_enhancer(self, enhancer, enhancer_string, factor):
"""Enhances an image and saves a new file."""

self.image = enhancer.enhance(factor)
print("{0} enhancer applied to {1} [factor = {2}]"
.format(enhancer_string, self.original_filename, factor))

Generating Greyscale Images

We take advantage of the Image.convert() method to generate a greyscale image. A mode value of 'L' is passed to this method to convert the loaded image’s pixel format to 8-bit pixels (black and white):

elif enhancer_string == 'GREYSCALE':
self.image = self.image.convert('L')
print("{0} enhancer applied to {1}"
.format(enhancer_string, self.original_filename))

Gaussian Blur and Box Blur Filters

The Gaussian blur and box blur effects are handled a little bit differently compared to the other enhancers because they are defined as filters in Pillow. We process them independently by calling our image handle’s Image.filter() method and passing either ImageFilter.GaussianBlur() or ImageFilter.BoxBlur() to fulfil these operations:

elif enhancer_string in ['GAUSSIANBLUR', 'BOXBLUR']:

if enhancer_string == 'GAUSSIANBLUR':
self.image =
self.image.filter(ImageFilter.GaussianBlur(factor))
else:
self.image = self.image.filter(ImageFilter.BoxBlur(factor))

print("{0} filter applied to {1} [radius = {2}]".format(
enhancer_string, self.original_filename, factor))

Generating a Thumbnail

The generate_thumbnail() method uses pixel data from the loaded image to create a separate thumbnail. A width is passed to this method to control the thumbnail output size:

The method that handles thumbnail generation.

This is the only time that a copy is made of the loaded image data to fulfil an operation. We purposefully make this copy to ensure that thumbnails are generated independently of other image manipulation functions.

We create a variable called thumb_image to point to the copy. It subsequently calls Image.thumbnail() to generate a thumbnail, which is then saved to the file system.

Flipping Images

Our application allows images to be flipped both vertically and horizontally. The apply_flips() method handles this functionality:

def apply_flips(self, flips):
"""Flips the image horizontally or vertically."""

for flip in flips:
if flip.upper() == 'HORZ':
self.image = self.image.transpose(Image.FLIP_LEFT_RIGHT)
print(f"{self.original_filename} flipped horizontally.")

elif flip.upper() == 'VERT':
self.image = self.image.transpose(Image.FLIP_TOP_BOTTOM)
print(f"{self.original_filename} flipped vertically.")

self.save_flag = True

The Image.transpose() method is called to flip image data. An attribute is passed to this method to specify a flipping direction:

  • Image.FLIP_LEFT_RIGHT: An image will be flipped horizontally.
  • Image.FLIP_TOP_BOTTOM: An image will be flipped vertically.

Resizing Images

The resize_image() method is responsible for resizing a loaded image. It receives a tuple containing two integer values that represent new width and height values. This tuple is subsequently passed to an invocation of our Image object's resize() method. After a resize operation completes, our image handle is updated to point to the modified image data:

def resize_image(self, size):
"""Resizes cached image to the X and Y values in the
passed tuple."""
self.image = self.image.resize(size)
print(f"{self.original_filename} resized to: {size}")

self.save_flag = True

Rotating Images

A method called rotate_image() handles Image rotation. It receives an integer value denoted by a parameter called increment which specifies how many degrees our image will rotate in a counter-clockwise direction.

Image.transpose() is called to rotate its internal image data. Either theImage.ROTATE_90, Image.ROTATE_180 or Image.ROTATE_270 attribute is passed to this method depending on the value of increment. As usual, the resulting image data is returned to self.image:

def rotate_image(self, increment):
"""Rotates the cached image counter-clockwise in a 90 degree
increment.
"""


if increment == 90:
self.image = self.image.transpose(Image.ROTATE_90)
elif increment == 180:
self.image = self.image.transpose(Image.ROTATE_180)
else:
self.image = self.image.transpose(Image.ROTATE_270)

print(f"{self.original_filename} rotated {increment} "
f"degrees CCW.")
self.save_flag = True

Saving the Image

A new image file is written to the file system if any image processing was performed. This functionality is handled by a method called save_image(). It is called after all command line arguments have been handled, and saves a new image to the file system if self.save_flag is set to True:

def save_image(self):
"""Saves the image to a new file if save flag is set."""
if self.save_flag is True:
name, ext = os.path.splitext(self.original_filename)
out_filename = name + '-manip-' + str(self.index) + ext
self.image.save(out_filename)
print(f"New file saved as: {out_filename}")
  • A new file name is calculated based on the self.original_filename attribute: A "-manip" string is inserted between the original file identifier and its extension to derive a new file name.
  • The Image.save() method is called to actually write its data to disk.

In Conclusion — Part 2

A lot of content has been covered in this tutorial where we have explored various stages that take place when developing Python applications. We started off by creating a virtual environment in which to install our project. This involved installing a particular version of Python along with specific packages required by the project. Documentation on how to use our program along with some example commands were also presented. It is important to be aware of such information because learning about a tool is necessary if you intend to contribute to its source code.

We then examined the project’s source code to discover how particular functionality is handled with Python. The application takes advantage of the standard argparse module to define a CLI and parse command line arguments. Image processing is also handled by a fork of the PIL (Python Imaging Library) called Pillow.

Moving forward, you now know enough about the application to fork the project on GitHub and continuously develop it according to your own specifications. For example, you may wish to expand on the program’s command line interface and experiment with more features that Pillow exposes with its API. You may also want to create a standalone executable using a tool such as cx_Freeze.

--

--