Basic Python Scripting to Automate Everyday Tasks: Add Text to Images Using ImageMagick and Wand

In the previous post 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 and to follow the installation instructions for ImageMagick and Wand on Wand’s website.

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_dirvariable. This is inside atry-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) and check out my Flutter book or my blog at 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()