Python: Building an Image Manipulation Tool (Part 1)

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

Dane Bulat
13 min readJan 11, 2021

Introduction

Python allows development projects to be approached in a goal-orientated way. Programs written in Python are interpreted rather than compiled, which enables features such as dynamic typing and automatic memory management behind the scenes. This means more time can be invested in things like adding functionality and improving an application’s behavior, instead of tedious tasks like hardening code infrastructure and making sure memory is handled efficiently. With this in mind, Python is a great tool for productivity.

This tutorial and case study will detail how to use Python to create an image manipulation tool. This tool is invoked on the command line and is able to process image files in different ways, such as:

  • Applying filters to images such as blur, sharpen and smooth functions.
  • Enhancing an image by adjusting properties such as its brightness, contrast and color.
  • Convert an image into a greyscale version.
  • Applying Gaussian blur and box blur filters to an image.
  • Flip an image horizontally and vertically, as well as rotating an image.
  • Resizing images to specific widths and heights.
  • Generating thumbnails of image files.

Various stages of development will be talked about, including setting up a virtual environment, defining an interface for command line arguments, and performing actions based on arguments the program receives.

Our tool also utilizes a fork of the Python Imaging Library (PIL) called Pillow for performing image processing tasks. At a high level, the Pillow API is used to load image files into memory, process its pixel data in various ways, and finally save a new image to the file system.

Two articles will make up this tutorial. This first article will detail how to set up a virtual environment in which to install our project, followed by going over how the image manipulation tool is used:

  1. Project Overview
    The project’s directories and source files are discussed. We then 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, and how they are used.
  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.

Part two walks through the actual Python code that makes up the project. We take a look at the standard argparse Python module, as well as how to process images in different ways with the Pillow API:

  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, ready to be used by the application.
  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 the code used for performing image processing operations.

Source Files

A GitHub repository has been set up for readers wishing to download the application and follow along with instructions presented 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

We will go over the project’s structure, it’s source files, and how to set up a virtual environment in the next section.

1. Project Overview

This section provides an overview of our application’s project structure where each directory and source file is described. We also take a look at how to create a Python virtual environment using the pipenv package. Let's start by taking a look at what the project is made up of:

> py-image-manip-demo/
--> images/
--> 001.jpg
--> 002.jpg
--> 003.jpg
--> imgmanip/
--> imgmanip.py
--> parser.py
--> handler.py
--> manipulator.py
--> Pipfile
--> Pipfile.lock
  • images/
    Three JPEG image files are provided in this directory. Feel free to specify these files as inputs to our program.
  • imgmanip/
    This directory is named after our application and contains its source code.
  • imgmanip.py
    Our application’s entry point. The code in this file loads image files into memory and instructs the appropriate modules to parse command line arguments and handle image processing.
  • parser.py
    A Python module that implements functions to parse command-line arguments into Python objects.
  • handler.py
    A Python module that runs appropriate portions of code based on what was specified on the command line. It also calls image processing functions defined in manipulator.py.
  • manipulator.py
    Implements a class called ImageManipulator that performs image processing. Items from the Pillow API are imported and used in this module.
  • Pipfile
    This file is read by pipenv to create and configure a virtual environment. Important information such as the project's Python version and package dependencies are specified here.
  • Pipfile.lock
    Another important file to configure the virtual environment correctly. This file defines the exact package versions required by a project.

The project is structured where related functionality is defined in its own module. For example, we have individual modules for parsing command line arguments, handling generated objects derived from those arguments, and performing image manipulation with Pillow. Part two of this case study details the code in each of these modules.

Setting Up a Virtual Environment

A virtual environment is essentially a container that houses a Python and pip installation. A few solutions exist for creating Python virtual environments. One of them is a built-in module called venv which enables you to create and manage system-wide virtual environments.

We want to be able to create virtual environments on the project level. The pipenv package provides such functionality and allows us to:

  • Specify packages along with their version number in a Pipfile to determine a project's dependencies.
  • Generate a file called Pipfile.lock for producing deterministic builds.
  • Install a specific version of Python that will run when the environment is active.
  • Run shell commands inside the environment just as though it was the actual system-wide shell environment.

As a matter of fact, you can determine which Python version our project requires along with its dependencies by inspecting Pipfile:

[packages]
pillow = "*"
[requires]
python_version = "3.9"

The [packages] section specifies a package called pillow which pip will download to our virtual environment. Its corresponding version is defined in Pipfile.lock:

"pillow": {
"hashes": [
...
],
"index": "pypi",
"version": "==8.1.0"
}

As a result of what is specified in Pipfile and Pipfile.lock, an exact environment is set up whenever the project is installed with Pipenv on any given system - even if a newer version of Python or Pillow is available. This ensures that our project's source code remains compatible with its dependencies. For example, if a new API is introduced in a future version of Pillow, we can be assured that our application will still run, as it is tied to an older version of that particular package.

The following steps will detail how to create a virtual environment with Pipenv to run our project. Let’s start by installing the pipenv package with pip. We will also output its version string to confirm the package is found by the system:

$ pip install pipenv
$ pipenv --version
-| pipenv, version 2020.11.15

From here we can enter the root directory of our project and create a virtual environment. Pipenv will read Pipfile and Pipfile.lock to install the correct version of Python and package dependencies that are specified in these files:

$ cd py-image-manip-demo
$ pipenv install

If you do not have an installation of Python 3.9 on your system, a prompt will ask if you would like to install it. In this case, input y and press Enter to continue the installation. A directory containing your new virtual environment is created before you regain control of the terminal.

A useful feature of Pipenv is the graph option, which will list packages that have been installed to the virtual environment:

$ pipenv graph
-| Pillow==8.1.0

Moving forward, there are a couple of ways to run commands using the virtual environment’s own python and pip installations. Firstly, pipenv run is used to invoke a single command relative to the virtual environment. For example, the following commands output its Python and Pip installations:

$ pipenv run which python pip
-| /home/dane/.local/share/virtualenvs/py-image-manip-demo-
lnYNAppa/bin/python
/home/dane/.local/share/virtualenvs/py-image-manip-demo-
lnYNAppa/bin/pip

$ pipenv run ls -l $(which python pip)

An easier way to work in an environment is to spawn a shell by running the pipenv shell command. Any subsequent commands will use the virtual environment's corresponding Python and Pip installations by default:

$ pipenv shell

# From here you can work inside the virtual environment
$ python --version && which python && pip --version
$ pip list && pip show pillow

Run the exit command when you are finished working on a project and wish to leave a virtual environment:

$ exit

It is also a good idea to read through the pipenv help menu, which provides a full list of available commands along with brief descriptions:

$ pipenv --help

Of particular importance is how to install packages to a virtual environment with Pipenv. This is done with the following syntax:

pipenv install <package_name>

When a package is installed in this way, a project’s Pipfile is updated to include the newly installed package. If new packages have been installed and used within a project's source code, a new Pipfile.lock file should also be generated before committing your code and making a push remotely:

$ pipenv lock

I encourage you to try out each Pipenv command in our project if this is your first time working with virtual environments.

2. How to Use the Image Manipulation Tool

The image manipulation tool is invoked on the command-line and will process images based on the flags it receives. This section will detail how to invoke the tool and discuss its flags in detail. An understanding of its usage will put us in good stead when we look at the corresponding source code in part two.

Invocation

Let’s start off by updating the shebang character sequence at the top of imgmanip.py to point to the virtual environment's Python executable. Doing this will enable us to invoke our program using a shorthand syntax, which will be discussed shortly. We can find out the location of an environment's python executable in the following way:

$ pipenv shell
$ which python
-| /Users/dane/.local/share/virtualenvs/py-image-manip-demo-
e67HmAh0/bin/python

Go ahead and replace the first line in imgmanip.py with this output and make sure to follow up with a save:

>> #!/Users/dane/.local/share/virtualenvs/py-image-manip-demo-
e67HmAh0/bin/python

"""Module for performing a range of image manipulations.

The imgmanip.py file is our application's entry point. It receives one or more inputs (image file names) and calls code defined in other modules to perform appropriate operations. We therefore invoke the application by parsing this file to the Python interpreter:

python imgmanip.py <image_files> <flags>

Because we updated the shebang character sequence to point to a valid Python binary, the application can also be invoked using a shorthand syntax by specifying ./ instead of python. In order for this to work, imgmanip.py must have executable permissions:

$ chmod u+x imgmanip.py

It is now possible to invoke our application using the following syntax:

./imgmanip.py <image_files> <flags>

Let’s test this out to make sure everything is working correctly. To prevent having to specify relative paths to image files when invoking our program, we can firstly create some symbolic links in the same directory as imgmanip.py:

$ ln -s ../images/001.jpg 001.jpg
$ ln -s ../images/002.jpg 002.jpg
$ ln -s ../images/003.jpg 003.jpg

Let’s now use these image files as inputs to our program and find out their corresponding properties by specifying the -i flag:

# One input file
$ ./imgmanip.py 001.jpg -i
-| Filename : 001.jpg
Size : (2560, 1440)
Format : JPEG
Bands : ('R', 'G', 'B')

# Multiple input files
$ ./imgmanip.py 001.jpg 002.jpg 003.jpg -i

# Multiple input files with wildcard character
$ ./imgmanip.py *.jpg -i

If properties are output for each image — their filename, size in pixels, format, and bands — the image manipulation tool is working correctly on your system.

Positional Arguments and Optional Arguments

Let’s move on to discuss the command line arguments that our tool can receive. Command line arguments come in two flavors — positional arguments and optional arguments:

  • Position arguments must be specified after the program’s name in a certain order. For example, two positional arguments are typically passed to the cp command: A source file to copy followed by a target file. In this case, the order in which files are specified are very important because positional arguments for cp have been set up in a certain way.
  • Optional arguments are denoted with one or two dash characters and can be specified in any order. They typically govern how an application behaves. For example, the cp command defines an optional argument called -n, which, when specified, ensures an existing file is not overwritten when performing a copy operation.

An optional argument can have both a shorthand and longhand version. A single dash followed by a single character (I.e. -h) defines a shorthand argument. Two dashes followed by a word (I.e. --help) usually define longhand arguments.

Our program has only one positional argument called inputs that represents a list of one or more image filenames. Quite a few optional arguments are also defined, which are described in the following table:

The program’s optional arguments.

You will notice that the standard -h, --help and --version optional arguments are defined for our program. The remaining arguments process input images in some way, and have a specific usage pattern as detailed below:

Example usage of the program’s optional arguments.

The usage of most arguments is easy to understand. However, the -f, --filter and -e, --enhance arguments warrant further explanation. In short, they allow us to specify functions to modify an image's pixel data in some way. As demonstrated in the table above, a particular filter or enhancer can be specified more than once, in an arbitrary order, for any given command.

Altogether, our program exposes ten filters which are listed here:

Filters available to the image manipulation tool.

Enhancers are more powerful than filters because their strength can be customized by parsing a float value to the --factor argument. The application uses a default factor of 1.2 if a value is not specified on the command line. In total, seven enhancers are available:

Enhancers available to the image manipulation tool.

The application is quite flexible when it comes to specifying filter and enhancer names. Firstly, names are case-insensitive because command line input is converted into uppercase before being processed further. Therefore, commands like this are completely legal:

$ ./imgmanip.py 001.jpg -f detAIL BLUR SmooTH -e color GREYSCALE    -| DETAIL filter applied to 001.jpg
BLUR filter applied to 001.jpg
SMOOTH filter applied to 001.jpg
COLOR enhancer applied to 001.jpg [factor = 1.2]
GREYSCALE enhancer applied to 001.jpg
New file saved as: 001-manip-0.jpg

Furthermore, invalid filter and enhancer names are skipped if any exist:

./imgmanip.py 001.jpg -f DETAIL banana SMOOTH \
-e BRIGHTNESS apple --factor 1.5
-| DETAIL filter applied to 001.jpg
'BANANA' not a valid filter name. Skipping...
SMOOTH filter applied to 001.jpg
BRIGHTNESS enhancer applied to 001.jpg [factor = 1.5]
'APPLE' not a valid enhancer name. Skipping...
New file saved as: 001-manip-0.jpg

3. Example Commands with Screenshots

The application provides quite a large command line interface without being overly complex. At this point, feel free to experiment with the tool by combining options and seeing what interesting images you can generate. Keep in mind that arguments can be specified in an arbitrary order, and the help text is always available if you need a refresher on the tool’s arguments and usage patterns:

$ ./imgmanip.py --help

Our first example will demonstrate how to apply a greyscale and brightness enhancer to an image with a custom factor (strength) value:

$ ./imgmanip.py 001.jpg -e greyscale brightness --factor 1.25   -| GREYSCALE enhancer applied to 001.jpg
BRIGHTNESS enhancer applied to 001.jpg [factor = 1.25]
New file saved as: 001-manip-0.jpg
Converting an image into a brightened greyscale version.

Filters can be applied in the same manner as enhancers. The next example applies four filters and one enhancer to an image:

$ ./imgmanip.py 002.jpg -f contour detail detail smooth -e greyscale    -| CONTOUR filter applied to 002.jpg
DETAIL filter applied to 002.jpg
DETAIL filter applied to 002.jpg
SMOOTH filter applied to 002.jpg
GREYSCALE enhancer applied to 002.jpg
Combining the contour filter with other effects.

If you need a larger or smaller version of an image but wish to maintain its proportionality — meaning no pixel stretching or distortion — one of the proportional resize options will do the trick:

$ ./imgmanip.py 002.jpg --resize-width-proportional 512    -| 002.jpg resized to: (512, 320)
New file saved as: 002-manip-0.jpg
Resizing an image proportionally.

Images can be flipped both vertically and horizontally. It is also possible to rotate an image by a 90 degree increment in a counter-clockwise direction. The next example demonstrates this functionality:

$ ./imgmanip.py 001.jpg --flip horz --rotate 180    -| 003.jpg flipped vertically.
003.jpg flipped horizontally.
003.jpg rotated 180 degrees CCW.
New file saved as: 003-manip-0.jpg
Flipping an image horizontally and rotating it 180 degrees CCW.

A thumbnail will inherit a width of 128 pixels if a custom width is not specified on the command line. The next example generates a thumbnail of 256 pixels for three images:

$ ./imgmanip.py 001.jpg 002.jpg 003.jpg \
--thumbnail --thumbnail-width 256
-| Thumbnail of 001.jpg generated: 001-256x144-0.thumb
Thumbnail of 002.jpg generated: 002-256x160-1.thumb
Thumbnail of 003.jpg generated: 003-256x173-2.thumb
Generating a thumbnail for three image files.

In Conclusion — Part 1

At this point you should have a virtual environment setup that is able to run our image manipulation program.

In addition to detailing how to create project-level virtual environments with Python, this article walked through the usage of our image manipulation tool. The difference between positional arguments and optional arguments was also discussed.

We will dive into our project’s source code and take a look at what makes our program tick in the next part. When you are ready, click the following link to start reading part two:

--

--