[Tutorial + Code] on how to convert an image into ASCII art

ASCIIfy

Using OpenCV, Pillow and Python 3 to convert images to ASCII

Ishan Bhatt

--

ASCII-art has a long and rich history in computer programming. Today, I show how to create ASCII-art from any image using Python 3. Instead of describing how our results would look, let’s just begin by showing them!

Figure 1: (left) Input image; (right) ASCII-fied; Source: Wikipedia

As usual, documented code is available on GitHub (link to the repository at the end of the article; pls drop star). Here, we describe selected parts of the code.

First off, let’s load our dependencies. Even though I advocate using either only Pillow or only OpenCV in any project, here, we must use them both. For converting an image to fonts, it is essential that we use mono-spaced fonts i.e. we need fonts that use the same pixel-width regardless of the character printed (similar to the default fonts on a command-prompt). Also, I found that performing histogram-equalization on the input images produces prettier results. The problem is: OpenCV supports only a handful of font faces and sadly none of them is mono-spaced and Pillow does not have an intuitive implementation for histogram equalization.

So, we use Pillow for displaying text on the image but we require OpenCV to perform histogram-equalization.

Now that we have loaded our dependencies, let’s try to see how can we go about ASCIIfying the image.

The Grid

We should first chop the image into a grid with R rows and C columns as shown in Fig. 2.

Figure 2: An example grid overlaid on an example image

It should be noted here that, the height of each cell is font_height and width is font_width. Both these variables would scale proportionally if we increase the FONT_SIZE.

Correct Color and Correct Character

Before digging into code, let’s illustrate a little experiment.

Figure 3(a): left- no color, no depth perception; right- no color, depth perception
Figure 3(b): left- color, no depth perception; right- color, depth perception

The four images of Fig. 3 help draw a line between the information relayed via depth and that via colour in the context of our perception of the world around us. At first glance, it might seem as if colour is the critical factor to image perception and that most of the information is relayed only by capturing and reporting the colours in a scene — in fact, that is how humans captured and shared images for most of our history.

The image on the top-right is obtained by calculating the average (Saturation+Intensity) of colour in the character box and then printing a character that takes up a proportional amount of white space in that character box (More on this in next section). This way, we only capture our depth perception of the image.

The image on the bottom-left is obtained by printing the same character in each character box (an asterisk). The colour of this character is decided by taking the average of the RGB values of each pixel in the character box. This way, we only capture our colour perception of the image.

Fonts and Character Map

While you could use any mono-space fonts for this application, I chose the rather peculiarly named secret_code fonts from Matthew Welch available here.

Once you do download the fonts, here’s how to use them:

# Importing Dependencies
##
import os, sys # for OS utilities
import numpy as np # for some mathematical transformations
from PIL import Image, ImageFont, ImageDraw
# We are using the mono-space fonts called "secret-code" from Matthew Welch (https://squaregear.net/fonts/)
# I have added a copy of the font and liscence files in this repository, these fonts are published under MIT liscence.
##
FONT_PATH = os.path.join("secret_code", "secrcode.ttf")
# Now we load the font file and get the height and width for each font
##
font = ImageFont.truetype(FONT_PATH, fontSize)
font_width, font_height = font.getsize(".")
# Output blank canvas
##
output = np.zeros_like(image)
pillow_output = Image.fromarray(output)
pillow_drawer = ImageDraw.Draw(pillow_output)
# inserting the text
##
pillow_drawer.text((x_start, y_start), str(text), font = font, fill=tuple(color))
# seeing the output
##
pillow_output.show()

In simple words, a Character Map is an ordered sequence of characters sorted according to the amount of whitespace they would occupy when printed. As the definition might have suggested, there is no restriction on the number of characters these sequences may have nor is there any restriction on the choice of characters used to build the sequence. Therefore, there are quite a few alternate character maps available on the internet. I chose to pick this Character Map from Paul Bourke available here. Here is the Character Map we used:

.\'`^",:;Il!i<~+_-?[{1(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$

As you can see, our Character Map has 65 characters arranged in the ascending order of the white space they consume.

Code

Assuming that reading an image from a file and writing it back to file is trivial (If not, see Github repo), we focus on how to locate the character boxes and once they are located, we walk through the code that actually prints a character in the character box:

The code-snippet presented above is actually pretty simple:

  • It goes through time image row-wise in increments of font_width and font_height (These are both constant for a mono-spaced font style)
  • Once, the character area bound are fixed, we simply take the average Saturation and average Intensity in the area to decide the character that will be printed in this box
  • After deciding the character, we decide its colour by taking the average of the colour (RGB values) across the character box. We could also have taken the considered Hue values to decide the colour but that is a bit more complicated & simple averaging will not work. This is shown in Fig 4. You can see how taking a weighted average of a histogram centred around the red hue would most likely result in Cyan because the colour red is present on both ends of the Hue spectrum (Crazy, right?)
Figure 4: If we take an average of varying Red Hues, we might end up with a colour closer to Cyan than Red
  • After the character and the colour are decided, we simply put that character on the required co-ordinates

Conclusion

We have seen how can we ASCIIfy practically any image. There are a lot of variations of these types of scripts and we saw a few places where making a small change would produce different types of results.

Thanks for reading this far. Suggestions welcome. Cheers!

(Link to Github repository: https://github.com/ivbhatt/ASCIIfy)

--

--

Ishan Bhatt

Datascience @ Walmart (SF, CA); Applied-ML @ (PICTure (Raleigh, NC); Ex: ML @ TCS R&I (Mumbai, India), CS @ BVM (Gujarat, India)