Creating typewriter-styled images in R

[This article was first published on R on Nicola Rennie, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

In September 2023, I wrote a blog post about creating typewriter-styled maps in {ggplot2}. It described the process of creating an elevation map where, instead of using colours to denote the different elevation levels, different letters of the alphabet were used. By choosing the correct font, it gives the impression that the map was created using a typewriter. In this blog post, I’ll walk you through the same process to create a typewriter-styled image (instead of an elevation map).

Image processing in R

We’re going to start with a normal image and transform it into a typewriter-styled image, so the first thing we need to do is make sure we can work with image files in R. There are two packages that tend to be the go-to packages for simple image processing in R. The {magick} package provides bindings to the ImageMagick image processing library and the {imager} package is based on CImg, a C++ library by David Tschumperlé. Both packages have their strengths, and it’s easy to use both at the same time via the cimg2magick() and magick2cimg() conversion functions in {imager}. In this blog post, we’ll use the {imager} package but you could definitely do something similar using the {magick} package instead.

Let’s decide which image to use. I’d recommend you don’t start with an insanely high resolution image – simply to reduce the processing time when you’re first trying out how functions work! It doesn’t matter what orientation or aspect ratio the image has. Let’s use the following photo of a bridge:

Photo of a bridge over a river
(bonus points if you know where this is!)

Loading image files

Let’s start by loading the {imager} package. We can then load an image into R using the load.image() function, where you simply provide the file path to the image. It works with PNG, JPG, and BMP images out of the box.

1
2
library(imager)
img <- load.image("image.jpg")

Note: if you run plot(img), you’ll see the image plotted on standard plot axes in your graphics pane.

Rescaling images

Let’s start by rescaling the size of the image. If you print the img object, you’ll see the original size of the image in pixels:

1
Image. Width: 4032 pix Height: 3024 pix Depth: 1 Colour channels: 3

For our plot of the image, each pixel will be represented by a letter rather than a small coloured square. There are currently 12,192,768 (4032*3024) pixels - that’s a lot of letters! Let’s reduce this number (essentially making the image more pixelated) using the resize() function. We define a rescale variable that states how much smaller it will become. Here, we’ll use 20 but you might choose to use a different value depending on how closely you want your typewriter-styled image to represent the original image. We resize both the size_x and size_y by this rescale factor to maintain the original aspect ratio.

1
2
3
4
5
6
rescale <- 20
img <- resize(
 im = img,
 size_x = round(width(img) / rescale),
 size_y = round(height(img) / rescale)
)

Now the image is 202 x 151 pixels, and if you run plot(img), you’ll see that the image looks a bit blurrier:

Blurry photo of a bridge over a river on an plot grid

We also need an ordered variable that we’ll map the different letters to. This means that we need a single, continuous variable that we split into bins. You can think of the image as currently having three continuous variables since it’s a colour image: R, G, and B representing the amount of red, green, and blue in each pixel. We could pick just one of these to plot. However, it would make more sense to convert the image to black and white and use the luminance (brightness) of the pixel as the continuous variable.

We can use the grayscale() function to convert to a black and white image. By default, this returns an image with just the luminance data in it (and not the RGB data).

1
img <- grayscale(img)

Converting to a matrix

We want to extract the luminance values from img so that we can group and then plot them. The img object currently has a cimg class which isn’t very easy to work with if you don’t want to do any further processing of the image. Luckily, it can easily be converted to a matrix using the as.matrix() function. We’ll add some row and column names (based on the number of the row or column) to make it easier to convert to a tibble() in the next step.

1
2
3
m <- as.matrix(img)
colnames(m) <- 1:ncol(m)
rownames(m) <- 1:nrow(m)

Data processing

Now that we have a numeric matrix, we’re essentially in the same place as we were when we had the elevation matrix in the creating typewriter-styled maps in {ggplot2} blog. What we need to do now, is convert the matrix into a format we can use for plotting with {ggplot2} and map the numeric values to different letters.

Data wrangling

Here we’ll use {tidyverse} functions for data processing, but you can also do these steps in base R if you prefer. We start by converting from a matrix to a tibble, and making the row names of the matrix into a column called x. This will be the x-coordinates of each letter we want to plot. We then pivot the data into long format - ending up with three columns: x, y, and value containing the x- and y- coordinates for each letter and the numeric value that will be represented by the letter. We also make sure that all three columns are actually numeric.

1
2
3
4
5
6
library(tidyverse)
m_df <- m |>
 as_tibble() |>
 rownames_to_column(var = "x") |>
 pivot_longer(-x, names_to = "y") |>
 mutate(across(everything(), as.numeric))

Choosing a font

Before we go on to plotting, we need to decide on:

  • which font we are going to use
  • how many different letters we need
  • which letters those are

Here, we’ll use the Special Elite font available through Google Fonts, as we did in the typewriter map blog post. This font has a typewriter-look, and will work well for this because it’s a monospace font. Since it’s a Google Font, it’s also very easy to get it working in R using the {showtext} package.

We load the {showtext} package, and then pass the font name into the font_add_google() function. Running showtext_auto() and showtext_opts(dpi = 300) switches on the use of {showtext} for fonts, and specifies what resolution our plot will be in.

1
2
3
4
library(showtext)
font_add_google("Special Elite")
showtext_auto()
showtext_opts(dpi = 300)

Mapping values to letters

Let’s define a vector of which letters we’re going to use. We’ll use a lower case l, and upper case I, H, and M to denote four different levels of luminance from lowest to highest. When you look at how the characters are printed, M uses a lot of ink (and is a dark letter) whereas l uses very little (and is a light letter).

1
chars <- c("l", "I", "H", "M")

Let’s also create a lookup table of how our letters map to the different levels of luminance:

1
2
3
4
chars_map <- data.frame(
 value = rev(seq_len(length(chars))),
 value_letter = chars
)

Our look up table looks like this:

1
2
3
4
5
 value value_letter
1 1 l
2 2 I
3 3 H
4 4 M

Now let’s turn the continuous luminance data into four levels (1, 2, 3, and 4) using the ntile() function from {dplyr}. The ntile() function breaks the input vector into n buckets and returns an integer vector denoting which bucket each value falls into. We can then left_join() our bucketed luminance data to the chars_map look-up table we’ve already created.

1
2
3
plot_df <- m_df |>
 mutate(value = ntile(value, n = length(chars))) |>
 left_join(chars_map, by = "value")

Now we’re ready for plotting!

Plotting with {ggplot2}

The plotting is probably the easiest part of this whole process. Let’s start with the ggplot() function (as we would almost any plot made with {ggplot2}). Then, we only really need to use geom_text()! We map the x and y values in the plot_df data to the x and y axes and specify that the value_letter should be used as the label inside the aes() call.

We also need to remember to use the family argument to apply our chosen font - previously loaded in as "Special Elite". Pick a colour of your choice - we’ll use the default black as you would see in a traditional typewriter! The size of the letters also needs to be adjusted to make sure they don’t overlap - this will probably take some trial and error, and it depends on the size of the image, and the rescale value that was chosen earlier.

1
2
3
4
5
6
7
8
9
p <- ggplot() +
 geom_text(
 data = plot_df,
 mapping = aes(x = x, y = y, label = value_letter),
 family = "Special Elite",
 colour = "black",
 size = 2.5
 )
p

Typewriter-styled print of a bridge over water which is upside down and has grid lines in the background

What you’ll notice immediately about this plot of the image is that it’s upside down. That’s because, in most traditional plots, the lowest values on the y-axis are at the bottom. Here, our y values represent row numbers and so the smallest values should be at the top. We can add scale_y_reverse() to flip the axis upside down. We also apply coord_fixed() to make sure our image doesn’t get squashed, setting expand = FALSE to remove the white space from around the edge.

Finally, we edit the theme to get rid of the grid lines you would be more likely to need on a bar chart. Using theme_void() removes all theme elements, and sets a transparent background. We can override this by changing the plot.background values in the theme() function - setting the background fill and border colour to white.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
p <- p +
 scale_y_reverse() +
 coord_fixed(expand = FALSE) +
 theme_void() +
 theme(
 plot.background = element_rect(
 fill = "white",
 colour = "white"
 )
 )

Typewriter-styled print of a bridge over water

That looks better - if you zoom in really close, you’ll see that each pixel is indeed a letter!

We can also save a copy of our image to a file. We can save our typewriter-styled map in the same size our original image by extracting the width() and height() of the image object - making sure to change the units to pixels!

1
2
3
4
5
6
ggsave(p,
 filename = "font_image.png",
 width = rescale * width(img),
 height = rescale * height(img),
 units = "px"
)

Let’s compare the original image with the typewriter-styled version side-by-side:

Photo of a bridge over water Typewriter-styled print of a bridge over water

From afar, it might just look like a pixelated, black and white version. But up close, viewers can be surprised by the fact that it’s actually individual letters!

Useful resources

You might be wondering what the point to all this is (other than some making some unusual prints for your home decor). There might not be a direct point, but it’s a fun way to learn about image processing in R and understand how values associated with images can be accessed and manipulated.

If you want to learn a little bit more about image processing in R:

  • Marco Gandolfo has written a blog post about Image processing in R which introduces some basic functions in both {imager} and {magick}.

  • The documentation for the {magick} package has lots of examples you can try to get started.

  • If you’re looking for a package in R which does a specific type of image processing, have a look at the CRAN Task Views for Medical Imaging. It has a section for General Image Processing and though most have medical applications, many also work for non-medical image processing tasks.

To leave a comment for the author, please follow the link and comment on their blog: R on Nicola Rennie.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)