Creating typewriter-styled images in R
This blog post explains the process of manipulating images using {imager} in R, processing pixel data, and using it to create a new version of an image that looks like it was printed with a typewriter!
August 1, 2024
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:
(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.
|
|
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:
|
|
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.
|
|
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).
|
|
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.
|
|
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.
|
|
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.
|
|
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).
|
|
Let’s also create a lookup table of how our letters map to the different levels of luminance:
|
|
Our look up table looks like this:
|
|
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.
|
|
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.
|
|
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.
|
|
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!
|
|
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
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.
For attribution, please cite this work as:
Creating typewriter-styled images in R.
Nicola Rennie. August 1, 2024.
nrennie.rbind.io/blog/creating-typewriter-images-r
BibLaTeX Citation
@online{rennie2024, author = {Nicola Rennie}, title = {Creating typewriter-styled images in R}, date = {2024-08-01}, url = {https://nrennie.rbind.io/blog/creating-typewriter-images-r} }
Licence: creativecommons.org/licenses/by/4.0