Basic Python Scripting to Automate Everyday Tasks: Add Text to Images Using ImageMagick and Wand
Tags: python, linux, ubuntu
Published: 2019-10-25 12:11:28

In [the previous post](https://carmine.dev/posts/bashautomation/) we saw how to use Bash to build a simple script that copies some pictures organized into subfolders into a single folder while renaming them and adding some text on them to make it possible for you to know where it was taken based on the folder the pictures were in. I recommend you look at that first because I explain the specific problem being solved, the directory structure and offer a simple solution to the problem. If you can’t be bothered to read that, here’s a summary.

# TL;DR of The Previous Post: The Problem We’re Going to Solve

In the previous post I imagined a problem someone could have that could be really easily solved by using computers for what they’re best at: performing repetitive, predictable tasks fast and without requiring the user to do anything while the software does what it’s designed to do.

The issue was the organization of pictures from the collection of a tourist who, upon coming back from a trip to Italy, is eager to show to friends and relatives the pictures taken of the many great sights Italy has to offer. To do that, they all need to be in one folder so that they can be shown in sequence easily but they also need to be recognizable both for their filename and for some text shown on them so that the tourist doesn’t necessarily have to remember where each picture was taken.

We have a directory tree that looks like this:

~~~
italy_pics/
├── Center
│   ├── Assisi
│   ├── Florence
│   ├── Marche
│   ├── Pisa
│   ├── Rome_Lazio
│   └── Siena
├── North
│   ├── EmiliaRomagna
│   ├── Genoa_CinqueTerre
│   ├── Milan_Lombardy
│   ├── Trentino
│   ├── Turin
│   └── Venice
└── South
    ├── Bari_Apulia
    ├── Basilicata
    ├── Calabria
    ├── Campobasso
    ├── Naples_Campania
    └── Sicily
~~~

With each of the subfolders (*Rome_Lazio*, *Florence*, *Sicily*, etc.) containing one or more pictures.

The output will be in another directory, called *italy_pics_organized*, to be created inside *italy_pics*’s parent directory if it doesn’t exist there already.

As I already said, that post outlined the process of writing a solution for this problem using Bash and ImageMagick's `convert` CLI tool, but this post is here to go beyond that and write something much better with Python.

# What the Bash Solution Was Missing: Why Move to Python

The Bash solution had multiple issues: it wasn’t portable given that it runs on a specific shell and interpreter that aren’t really great to use if you’re not using an OS that supports it natively (for example, Windows support isn’t great) and that it only works for a very specific directory structure with no files in the directory tree that aren’t images in a subfolder of a subfolder of the current directory.

Some of you in response to that post mentioned Python as an alternative. As I mentioned in that post, I was already planning to have an article guiding through the creation of a full-blown CLI utility using Python, but we’re probably better off starting with something simpler and more similar to the original script, but that solves some of the issues that are related to the language, such as the ones I described above in addition to being slightly faster and having code that’s a lot easier to read and understand (it’s so nice to use proper programming languages).


The main usability improvement will be the ability to specify the input directory and output directory when running the command instead of having them depend on fixed relative paths from the working directory (`.` and `../italy_pics_organized` in our case).

## What You Need to Know

This post doesn’t suppose you have any experience with Python programming but is not intended to be a comprehensive Python introduction (there is plenty of material on that online, no need to be redundant here). What I will suppose, though, is that you have some basic knowledge of the structure of code written in an object-oriented high-level programming language (selection, iteration, classes, objects, functions, libraries, etc.).

# What You Need to Have on Your Computer

First of all, let's start by outlining the required tools. The obvious requirement is Python 3 along with the PyPI `pip` CLI tool installed and accessible from your terminal or command prompt. In addition to that, we're still going to need ImageMagick, but we're not going to use the `convert` CLI command because we have some nicer interfaces we can work with to build our tool since we're running Python.

More specifically, we're going to use the *Wand* package from PyPI that provides an easy-to-use interface to the ImageMagick libraries.

Regarding the installation of these tools I recommend you install Python using your distribution's package manager on Linux, using HomeBrew on Mac or by downloading [the latest version of Python for Windows](https://www.python.org/downloads/) and to follow the [installation instructions for ImageMagick and Wand on Wand's website](http://docs.wand-py.org/en/0.5.7/guide/install.html).

# An Introduction to the Wand Python Package

After you’ve installed ImageMagick and Wand, we can bypass the usage of the `convert` which, in a Python script, would require spawning a subprocess and that is something most people don’t really want to do unless strictly necessary: using a library, when it exists, is almost always a better choice.

We’re going to use two classes from the `wand` package: the `Image` from `wand.image` and `Drawing` from `wand.drawing`, and import them into our code by putting the following at the start of our Python script:

~~~
from wand.image import Image
from wand.drawing import Drawing
~~~

 If you have written Python packages in the past you’d know the `wand.image` and `wand.drawing` names are due to the internal structure of the package, whereas `Image` and `Drawing` are the names of the classes we’re importing.

The two classes, when used together, allow us to load images, draw on them, and then save them.

## Getting an Image Object

The `Image` class is meant to be used in a `with` block like this

~~~
with Image(filename=filename) as image:
    # do something with the image, which exists here
    # as long as you keep indenting the code
# the image doesn’t exist here
# because the indented block is over
~~~

if the path to the file (either relative or absolute) is saved to the `filename` variable, this will create a variable called *image* that only exists within the indented block shown in the example above. You can draw on an `Image` using the `Drawing` class.

We'll be calling methods directly on our image only later, when we save it using `image.save`.

## The Drawing Class

Before getting into the details of the `Drawing` class, let's remind ourselves of how we annotated text over images using the `convert` command-line tool.

The command is used like this:

~~~
convert input.png -fill "textcolor" -pointsize textsize -gravity WhereTheTextWillBe -annotate +offsetHorizontal+offsetVertical "watermark text" output.png
~~~

More specifically, we gave the following arguments to that command in the Bash script:

~~~
convert ./${picture} -fill "white" -pointsize 90 -gravity SouthEast -annotate +30+30 "${watermarktext}" "${saveto}"
~~~

and ${variable_name} is how a variable’s value is inserted into a string in Bash.

The `-fill`, `-pointsize` and `-gravity` arguments tell `convert` some of the attributes of the thing we want to draw: what color to fill it with, what size font to use and where to put the new object compared to the original image, while `-annotate` tells `convert` what to do: annotate some text over the image offset by 30 pixels horizontally and 30 pixels vertically. the first argument and the last argument are simply the picture we’re drawing the text on and the path where to save the edited image.

The way we do that in Python using Wand is by initializing a new `Drawing` object

~~~
draw = Drawing()
~~~

setting its `fill_color` property to a string:

~~~
draw.fill_color = "white"
~~~

setting the `font_size`:

~~~
draw.font_size = 90
~~~

and the annotation’s gravity with a string similar to the one we used for the `convert` CLI command:

~~~
draw.gravity = "south_east"
~~~

At last, draw the annotation on an `Image` object:

~~~
draw.draw(image)
~~~

And save the edited image:

~~~
image.save(filename=f'{saveto}')
~~~

Wrapping up, the equivalent to the command we saw above in Python using Wand is 

~~~
 with Image(filename=picture) as image:
     draw = Drawing()

     draw.fill_color = "white"
     draw.font_size = 90
     draw.gravity = "south_east"
     draw.text(30, 30, text)
     draw.draw(image)
     image.save(filename=f'{saveto}')
~~~

We will set the value of `picture` and `saveto` later.

# Handling Files and Directories in Python

The Python standard library contains a module that  can be used, among other things, to browse files and directories and to change the current directory (the paths passed to the `Image` constructor and to `Image.save()` can also be relative paths).

That module can be imported adding 
~~~
import os
~~~

below the lines

~~~
from wand.image import Image
from wand.drawing import Drawing
~~~

The most important functions we're going to use from the `os` module are these three:

`os.chdir`, which changes the current working directory to the path you pass to it (which can be relative or absolute, just like the `cd` shell command;
`os.listdir`, which returns a list of the names of the files and directories in the current directory;
`os.scandir`, which returns a list of files and directories, each of which is an `os.DirEntry`, which is a type of data that stores, along with the file/directory name, other attributes such as the full path or information on whether it’s a directory or a file.

If we had stuck with the Bash script's behavior and only had to look for directories in the starting directory, we could have used

~~~
os.scandir(“.”)
~~~

and then only keep its results in a list if they are directories.

We now want the user to be able to specify any directory as an argument and look there for pictures given that we considered only looking in the current working directory a limitation of the Bash script.

We're simply going to take arguments from the `argv` interface you might have used in C (or in Python) and use the first argument as the directory from which to take the unedited, structured, pictures and the second one as the output folder where to store the edited pictures.

# Command-line Arguments and argv

When you run a command like `cd` on a command-line interface, you need to specify an argument: what directory to change the working directory to.

As you know, these arguments are specified after the command name in the following way:

~~~
commandname argument1 argument2 argument3 ...
~~~

When writing Python scripts, these arguments are accessible by using the `sys.argv` list:

~~~
from sys import argv

# argv[0] = commandname
# argv[1] = argument1
# argv[2] = argument2
# argv[3] = argument3
# ...
~~~

Regardless of whether you run your script as an executable or by using the `python` command, argv[0] will always be your script's file name.

This means that, if we want the first argument to be where to take the images from, we need to write

~~~
os.scandir(argv[1])
~~~

You can check if an `os.DirEntry` object points to a directory by checking the value returned by its `is_dir()` method. We’re going to use a list comprehension to do that in a very tidy and compact way:

~~~
areas = [file for file in os.scandir(argv[1]) if file.is_dir()]
~~~

For each area:

~~~
for area in areas:
~~~

We’re going to move into the directory corresponding to the area:

~~~
os.chdir(area)
~~~

and get a list of the city folders in the current directory:

~~~
cities = [file for file in os.scandir(".") if file.is_dir()]
~~~

and look for pictures inside each of those directories

~~~
for city in cities:
    os.chdir(city)
    pics = [file for file in os.scandir(".") if file.is_file()]
    # deal with the pictures
    os.chdir(“..”)
~~~

and go back to the parent directory for each area:

~~~
os.chdir(os.pardir)
~~~

All in all, until now, we have written the following:

~~~
areas = [file for file in os.scandir(argv[1]) if file.is_dir()]

for area in areas:
    os.chdir(area)
    cities = [file for file in os.scandir(".") if file.is_dir()]
    for city in cities:
        os.chdir(city)
        pics = [file for file in os.scandir(".") if file.is_file()]
        for pic in pics:
            # deal with each picture
        os.chdir("..")
    os.chdir("..")
~~~

# Dealing With Each Picture

We are going to process each picture by passing all of the needed information to a separate function that will perform all the needed actions with the needed parameters. Before that, we need to focus on one aspect that requires more attention: we are adding to each picture’s filename a progressive number, which is a value we need to keep track of and must be outside of the function that processes each picture.

Having that as a global variable is not what we’re going to do because this is a chance to go over OOP in Python and because implementing everything in a class improves code reusability and will be useful in the next post when we build upon the tool we build in this post.

## Creating an ImageProcessor Class

First of all, here's how you define a class and a member variable:

~~~
class ImageProcessor:

   i = 0
~~~

We are going to call the function we built in the previous chapter, which takes the paths from `argv`, traverses the directory tree and runs another function on each picture `process_argv`.

Python methods (if they're not supposed to be static) take the object they are ran on as an argument called `self`, which is used a lot like `this` within classes in JavaScript: `self.i` is the syntax used to access the `i` member variable, whereas `self.process(args)` is the syntax used to call the `process()` method with the `args` arguments.

The final version of the function we built in the previous paragraph, along with a few `try`-`catch` blocks to make sure the user input is something we can work with and a call to the yet-to-be-defined `process()` function and a few lines that deal with the output directory I'll explain after it, is the following:

~~~
class ImageProcessor:

   i = 0

   def process_argv(self):
       if len(argv) < 3:
           print("Some arguments are missing.")
           print("Usage: picorganizer.py input_directory output_directory")
           return

       areas = []

       try:
           areas = [file for file in os.scandir(argv[1]) if file.is_dir()]
           if len(areas) == 0:
               raise Exception("No subdiretories in input path")
       except:
           print("The provided input path is not valid")

       save_dir = []

       try:
           save_dir = [dir for dir in os.scandir(f"{argv[2]}/..") if dir.name == os.path.split(argv[2])[1] and dir.is_dir()][0]
       except:
           print("The provided output path doesn't exist or is invalid")

       for area in areas:
           os.chdir(area)
           cities = [file for file in os.scandir(".") if file.is_dir()]
           for city in cities:
               os.chdir(city)
               pics = [file for file in os.scandir(".") if file.is_file()]
               for pic in pics:
                   print("    Pic:" + str(pic))
                   self.process(area, city, pic, os.path.abspath(save_dir))
               os.chdir(os.pardir)
           os.chdir(os.pardir)

~~~

The line

~~~
save_dir = [dir for dir in os.scandir(f"{argv[2]}/..") if dir.name == os.path.split(argv[2])[1] and dir.is_dir()][0]]
~~~

Scans the second argument (the output directory)`s parent directory and, if the entity the user gave us a path to actually exists, we'll assign it to the `save_dir` variable. This is inside a `try`-`catch` block that informs the user of the error and terminates execution if the second argument points to a non-existent directory, given that it would trigger an exception.

`os.path.split(argv[2])[1]` is used to access the substring of `argv[2]` after the last `/` character. In other words, it is used to access the output directory's name, without the path leading to it.

'f"{argv[2]}/.."'  is called an *f-string* and is how string interpolation is done from Python 3
6 onwards: it is the string made up of the value of `argv[2]` followed by the `/..` characters. For example, if the value of `argv[2]` were *path/to/dir*, the value of that f-string would be *path/to/dir/..*, which is the same as *path/to*, which is the path to directory where we should be able to find a directory called `dir` if we list the contents of that directory.

`os.path.abspath(save_dir))` returns the absolute path to the `save_dir`.

## Writing the process() Method

First of all, let's declare the arguments with type annotations, making sure we establish what is needed to make this functiom work:

~~~
def process(self, area: os.DirEntry, city: os.DirEntry, pic: os.DirEntry, save_dir: str):
~~~

We have already seen how to process images, we just need to make it work with these arguments.

The first thing to consider is that the `process_argv` will already change the working directory to the directory in which the image to process is located, so we only need to pass the filename to the `Image` constructor to get the right image:

~~~
filename = pic.name
~~~

To avoid messing up, before we change the working directory, let's save the absolute path to the current directory to a variable so that we can go back to it after we're done with our processing:

~~~
return_dir = os.path.abspath(os.curdir)
~~~

The last piece of data we need to extract from the argument is the image's extension, which we can get with `os.path.splitext()`:

~~~
(name, extension) = os.path.splitext(filename)
~~~

Here we're unpacking a *tuple*, which is a pair of values returned by the called function, each of which we can each assign to a variable.

The rest of the process function is what we saw at the start of the post when we examined how to use Wand to replace the `convert` command:

~~~
with Image(filename=filename) as image:
           text = f'{city.name}({area.name})'
           draw = Drawing()
           draw.fill_color = "white"
           draw.font_size = 90
           draw.gravity = "south_east"
           draw.text(30, 30, text)
           draw.draw(image)
           os.chdir(save_dir)
           image.save(filename=f'{self.i}-{text}{extension}')
       self.i=self.i+1
       os.chdir(return_dir)
~~~

The entire `process()` function, with a few explanatory comments, ends up being the following:

~~~
   def process(self, area: os.DirEntry, city: os.DirEntry, pic: os.DirEntry, save_dir: str):
       # os.DirEntry.name is the picture's filename
       filename = pic.name

       # Store the absolute path of the current directory
       # so that we can return to it when we're done,
       # so that the calling function doesn't end up
       # in a working directory that is not the same
       # as the one it was in before the call to process()
       return_dir = os.path.abspath(os.curdir)

       # os.path.splitext gives us both the file name and
       # the extension of the picture. We need the extension
       # because we're going to use it to tell Wand
       # (and, in turn, ImageMagick) what extension
       # to give to the image and we want to retain
       # the original.
       (name, extension) = os.path.splitext(filename)

       with Image(filename=filename) as image:
           text = f'{city.name}({area.name})'
           draw = Drawing()
           draw.fill_color = "white"
           draw.font_size = 90
           draw.gravity = "south_east"
           draw.text(30, 30, text)
           draw.draw(image)
           os.chdir(save_dir)
           image.save(filename=f'{self.i}-{text}{extension}')
       self.i=self.i+1
       os.chdir(return_dir)
~~~




# Wrapping Up

If you liked this post, [follow me on Twitter (handle @carminezacc)](
 https://twitter.com/carminezacc) and check out [my Flutter book](https://pragprog.com/book/czflutr/programming-flutter) or [my blog at carmine.dev](https://www.carmine.dev).

The entire Python script, including the call to `process_argv()`, ends up being the following:

~~~
from wand.image import Image
from wand.drawing import Drawing
import os
from sys import argv


class ImageProcessor:

   i = 0

   def process(self, area: os.DirEntry, city: os.DirEntry, pic: os.DirEntry, save_dir: str):
       # os.DirEntry.name is the picture's filename
       filename = pic.name

       # Store the absolute path of the current directory
       # so that we can return to it when we're done,
       # so that the calling function doesn't end up
       # in a working directory that is not the same
       # as the one it was in before the call to process()
       return_dir = os.path.abspath(os.curdir)

       # os.path.splitext gives us both the file name and
       # the extension of the picture. We need the extension
       # because we're going to use it to tell Wand
       # (and, in turn, ImageMagick) what extension
       # to give to the image and we want to retain
       # the original one
       (name, extension) = os.path.splitext(pic)

       with Image(filename=filename) as image:
           text = f'{city.name}({area.name})'
           draw = Drawing()
           draw.fill_color = "white"
           draw.font_size = 90
           draw.gravity = "south_east"
           draw.text(30, 30, text)
           draw.draw(image)
           os.chdir(save_dir)
           image.save(filename=f'{self.i}-{text}{extension}')
       self.i=self.i+1
       os.chdir(return_dir)

   def process_argv(self):
       if len(argv) < 3:
           print("Some arguments are missing.")
           print("Usage: picorganizer.py input_directory output_directory")
           return

       areas = []

       try:
           areas = [file for file in os.scandir(argv[1]) if file.is_dir()]
           if len(areas) == 0:
               raise Exception("No subdiretories in input path")
       except:
           print("The provided input path is not valid")

       save_dir = []

       try:
           save_dir = [dir for dir in os.scandir(f"{argv[2]}/..") if dir.name == os.path.split(argv[2])[1] and dir.is_dir()][0]
       except:
           print("The provided output path doesn't exist or is invalid")

       for area in areas:
           os.chdir(area)
           cities = [file for file in os.scandir(".") if file.is_dir()]
           for city in cities:
               os.chdir(city)
               pics = [file for file in os.scandir(".") if file.is_file()]
               for pic in pics:
                   print("    Pic:" + str(pic))
                   self.process(area, city, pic, os.path.abspath(save_dir))
               os.chdir(os.pardir)
           os.chdir(os.pardir)


ImageProcessor().process_argv()
~~~