Site icon R-bloggers

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 , 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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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:


(bonus points if you know where this is!)

Loading image files < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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:

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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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

Here, we’ll use the Special Elite available through Google Fonts, as we did in the typewriter map blog post. This has a typewriter-look, and will work well for this because it’s a monospace . 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 name into the _add_google() function. Running showtext_auto() and showtext_opts(dpi = 300) switches on the use of {showtext} for s, and specifies what resolution our plot will be in.

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

Mapping values to letters < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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} < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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 – 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

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"
 )
 )

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 = "_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:

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 < svg class="anchor-symbol" aria-hidden="true" height="26" width="26" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> < path d="M0 0h24v24H0z" fill="currentColor"> < path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76.0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71.0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71.0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76.0 5-2.24 5-5s-2.24-5-5-5z">

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:

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.
Exit mobile version