How To: Spiral line drawings with the tidyverse and gganimate

Introduction

I stumbled on this video from Instagram of an artist creating a picture of Marilyn Monroe by drawing a single spiral from the inside out, varying the thickness of the line to add light and shadow to the image.

Click to watch

After watching the video on loop way too many times, I decided that I had to try and see if could do the same. Given that my drawing skills are near-zero, I turned to R and ggplot. Turns out, this was also a great opportunity to learn Thomas Pedersen’s gganimate package for turning ggplots into animated gifs.

The Goal

Using R, the tidyverse, and gganimate, reproduce a photo as a spiral using just a single line of varying thickness.

Prepare the image

Like the video, we’ll reproduce an iconic black-and-white image, though I chose a portrait of Albert Einstein instead.

As a first step, we resize the image. I had previously written some functions to convert an image into a LEGO mosaic. We can use the scale_image() function from this project, which takes a 3-dimensional JPG or PNG matrix (width, height, RGB channel), crops it into a square, and converts it to a tidy data frame for plotting.

Click here to see script

#1 SCALE IMAGE ----
# Adapted from LEGO mosaics project
scale_image <- function(image, img_size){
  #Convert image to a data frame with RGB values
  img <- bind_rows(
    list(
      (as.data.frame(image[, , 1]) %>% 
         mutate(y=row_number(), channel = "R")),
      (as.data.frame(image[, , 2]) %>% 
         mutate(y=row_number(), channel = "G")),
      (as.data.frame(image[, , 3]) %>% 
         mutate(y=row_number(), channel = "B"))
    )
  ) %>% 
    gather(x, value, -y, -channel) %>% 
    mutate(x = as.numeric(gsub("V", "", x))) %>% 
    spread(channel, value)
  
  img_size <- round(img_size, 0)
  
  #Wide or tall image? Shortest side should be `img_size` pixels
  if(max(img$x) > max(img$y)){
    img_scale_x <-  max(img$x) / max(img$y)
    img_scale_y <- 1
  } else {
    img_scale_x <- 1
    img_scale_y <-  max(img$y) / max(img$x)
  }
  
  #If only 1 img_size value, create a square image
  if(length(img_size) == 1){
    img_size2 <- c(img_size, img_size)
  } else {
    img_size2 <- img_size[1:2]
    img_scale_x <- 1
    img_scale_y <- 1
  }
  
  #Rescale the image
  img2 <- img %>% 
    mutate(y_scaled = (y - min(y))/(max(y)-min(y))*img_size2[2]*img_scale_y + 1,
           x_scaled = (x - min(x))/(max(x)-min(x))*img_size2[1]*img_scale_x + 1) %>% 
    select(-x, -y) %>% 
    group_by(y = ceiling(y_scaled), x = ceiling(x_scaled)) %>% 
    #Get average R, G, B and convert it to hexcolor
    summarize_at(vars(R, G, B), funs(mean(.))) %>% 
    rowwise() %>% 
    mutate(color = rgb(R, G, B)) %>% 
    ungroup() %>% 
    #Center the image
    filter(x <= median(x) + img_size2[1]/2, x > median(x) - img_size2[1]/2,
           y <= median(y) + img_size2[2]/2, y > median(y) - img_size2[2]/2) %>%
    #Flip y
    mutate(y = (max(y) - y) + 1)
  
  out_list <- list()
  out_list[["Img_scaled"]] <- img2
  
  return(out_list)
}

We will want our final spiral to have a radius of 50px (radius), so we can pass radius * 2 to the scaling function, creating a 100 pixel x 100 pixel image.

library(tidyverse)
library(jpeg)

radius <- 50 #pixels
einstein <- readJPEG("SpiralDrawings/Einstein.jpg") %>% 
  scale_image(radius * 2)

einstein$Img_scaled %>% 
  ggplot(aes(x=x, y=y, fill=color)) + 
  geom_raster() +
  scale_fill_identity(guide = FALSE) +
  labs(title = "Scaled Einstein image", 
       subtitle = "100px * 100px") +
  coord_fixed() +
  theme_void()

Polar vs Cartesian coordinates

Drawing a spiral in polar coordinates is easy enough…

tibble(x = rep(c(1:20), 20), y = 1:400) %>% 
  ggplot(aes(x=x, y=y)) +
  geom_path() +
  coord_polar() +
  theme_void()

Using that process, I originally tried to convert the image x- and y-values into polar coordinates beginning in the center of the image. That task turned out to be much more difficult than I had imagined.

Instead, I opted to draw a spiral in Cartesian coordinates. It’s been 10 years since my last trigonometry class, but found this helpful post on Stack Overflow. Based off the first answer on the thread, I wrote a function to calculate the points of a spiral centered on the image. All points on this spiral are equidistant, so more points are on the outer sections of the spiral than the inner sections.

Click here to see script

# Function for equidistant points on a spiral

spiral_cartesian <- function(img_df, spiral_radius, num_coils, chord_length, rotation){
  img <- img_df$Img_scaled
  
  #Derive additional spiral specifications
  centerX <- median(img$x)
  centerY <- median(img$y)
  
  thetaMax <- num_coils * 2 * pi
  awayStep <- spiral_radius / thetaMax
  
  #While loop to keep drawing spiral until we hit thetaMax
  spiral <- tibble()
  theta <- chord_length/awayStep
  
  while(theta <= thetaMax){
    #How far away from center
    away = awayStep * theta
    
    #How far around the center
    around = theta + rotation
    
    #Convert 'around' and 'away' to X and Y.
    x = centerX + cos(around) * away
    y = centerY + sin(around) * away
    
    spiral <- spiral %>% 
      bind_rows(tibble(x=x, y=y))
    
    theta = theta + chord_length/away
  }
  
  return(c(img_df, list(spiral = spiral)))
}

We can then pass the einstein image into this function (this provides the function with the desired x- and y-limits), along with specifications for the radius of the spiral (defined earlier), the number of coils, the chord length (distance between each point), and the rotation. We can plot this spiral using coord_fixed() - Cartesian coordinates rather than the polar coordinates above.1

einstein <- einstein %>% 
  spiral_cartesian(spiral_radius = radius, 
                   num_coils     = 50, #Spiral folds on itself 50 times
                   chord_length  = 2,  #Each point is 2 pixels apart
                   rotation      = 0   #No rotation
                   )

einstein$spiral %>% 
  ggplot(aes(x=x, y=y)) +
  geom_path() +
  coord_fixed() + #Not polar!
  theme_void()

Mapping the image onto the spiral

The artist in the video is able to portray Marilyn Monroe by varying the thickness of the line while drawing a continuous spiral. Now that we have the our spiral in Cartesian coordinates and have scaled it to same size as the photo of Einstein, we need to vary the thickness.

We can do this by effectively overlaying the spiral on top of the image and then assigning the color of the closet image pixel(s) to the spiral point. In this function, I convert the three color channels to an inverted grey scale, where the value of 0 means white, 1 means black, and anything in between is a shade of grey.

Click here to see script

#Project the image onto the spiral
project_image <- function(img_df){
  dat <- img_df$spiral %>% 
    #Round each spiral point to nearest whole number
    mutate(xs = round(x), ys = round(y)) %>% 
    #Join on the rounded points
    left_join(img_df$Img_scaled %>% rename(xs=x, ys=y)) %>% 
    #Create greyscale - 0 is lightest, 1 is darkest
    mutate(grey = R+G+B,
           grey = (1- (grey / max(grey))))
    
  return(c(img_df, list(projected_spiral = dat)))
}

We plot the spiral again, but this time, use the grey value to scale the thickness2 of the line. To increase the contrast of the photo, raise the grey value to a power greater than 1.

einstein <- einstein %>% 
  project_image()

einstein$projected_spiral %>% 
  ggplot(aes(x=x, y=y, size = grey^(5/4))) +
  geom_path() +
  scale_size_continuous(range = c(0.1, 1.8), 
                        guide = FALSE) +
  coord_fixed(expand = FALSE) + 
  theme_void()

…Boom! A single spiral of varying line thickness to render an image of Albert Einstein.

Animating the spiral

The original video seems way more impressive than this image. I can’t compete with a hand-drawn image, but can add some drama to this project by animating the spiral, slowly drawing it from the inside out.

We can do this using the package gganimate, which converts ggplots into animated gifs3.

library(gganimate)

einstein$projected_spiral %>% 
  mutate(row_number = row_number()^(1/2)) %>%  #^(1/2) slows down the beginning of the drawing
  ggplot(aes(x=x, y=y, size = grey)) + #Original contrast for this larger drawing
  geom_path() +
  scale_size_continuous(range = c(0.1, 1.8), 
                        guide = FALSE) +
  coord_fixed(expand = FALSE) + 
  theme_void() +
  transition_reveal(1, row_number)

Adding gganimate functions to an existing ggplot is simple. We add one new series, row_number, to our data frame, which is used in the final plotting step. Adding + transition_reveal(1, row_number) to the ggplot instructions tells R to render this as an animation, revealing all the data as a single group (1) over the time span row_number. By using the square root of row_number(), gganimate will spend more time drawing the inner sections of the spiral, speeding up as it reaches the outer lines.

Project: ✅ Done

Additional features

The spiral drawing function can be used for color images as well. This doesn’t make much sense for simulating a hand-drawn image using a marker, but still produces a fun image.

goldengirls <- readJPEG("SpiralDrawings/GoldenGirls.jpg") %>% 
  scale_image(radius * 2) %>% 
  spiral_cartesian(spiral_radius = radius, 
                   num_coils     = 50, #Spiral folds on itself 50 times
                   chord_length  = 2,  #Each point is 2 pixels apart
                   rotation      = 0   #No rotation
                   )  %>% 
  project_image()

goldengirls$projected_spiral %>% 
  mutate(row_number = row_number()^(1/2)) %>% 
  ggplot(aes(x=x, y=y, size = grey, 
             color = color)) +
  geom_path(aes(group=1)) + #Add a group to tell it to draw a single line
  scale_size_continuous(range = c(0.1, 1.8), 
                        guide = FALSE) +
  scale_color_identity(guide = FALSE) +
  coord_fixed(expand = FALSE) + 
  theme_void() +
  transition_reveal(1, row_number)

And finally, let’s end where we began, by rendering the image of Marilyn Monroe as an animated spiral.

marilyn <- readJPEG("SpiralDrawings/Marilyn.jpg") %>% 
  scale_image(radius * 2) %>% 
  spiral_cartesian(spiral_radius = radius, 
                   num_coils     = 50, #Spiral folds on itself 50 times
                   chord_length  = 2,  #Each point is 2 pixels apart
                   rotation      = 0   #No rotation
                   )  %>% 
  project_image()

marilyn$projected_spiral %>% 
  mutate(row_number = row_number()^(1/2)) %>% 
  ggplot(aes(x=x, y=y, size = grey)) +
  geom_path() + 
  scale_size_continuous(range = c(0.1, 1.8), 
                        guide = FALSE) +
  coord_fixed(expand = FALSE) + 
  theme_void() +
  transition_reveal(1, row_number)


Try it out! Full script can be found on GitHub!


  1. The plotted spiral seems to have reflections like a vinyl record. I assume that’s from the rendering of the plot at this resolution.

  2. The range values in scale_size_continuous() will require a bit of trial & error and will depend on the final size of the plotted spiral.

  3. If you’re using a Windows OS with R 3.5, you might have some trouble installing this package. I recommend reinstalling RTools, referring to this thread, and using this script.

Related