Abstract art with parttree and friends

Background

One fun application of tree-based methods is abstracting over art and other images. For some really striking examples, take a look at the portfoilo of Dimitris Ladopoulos. This vignette will show you how to implement the same basic ideas using parttree and a few friends. Here are the packages that we’ll be using.

library(parttree) # This package
library(rpart)    # For decision trees
library(magick)   # For reading and manipulating images
library(imager)   # Another image library, with some additional features

op = par(mar = c(0,0,0,0)) # Remove plot margins
magick:::magick_threads(2)
#> [1] 1

While the exact details will vary depending on the image at hand, the essential recipe for this type of art abstraction is as follows:

  1. Convert the image to a matrix (or data frame), where rows and columns correspond, respectively, to the X and Y coordinates of individual pixels. In other words, each cell in our matrix (data frame) represents the colour values of an individual pixel.
  2. Split the data by primary (RGB) colour channels. We should now have three matrices (data frames), where each cell represents the red/green/blue colour channel value of an individual pixel.
  3. Run a tree model on our three RGB datasets. In each case, we are trying to predict the relevant colour channel value as a function of the X and Y coordinates.
  4. Use the predicted values to plot the abstracted art piece! (Okay, this step requires a bit more work, but we’re about to see how it works in practice…)

Example 1: Peale’s “Portrait of Rosalba”

Our first example, will mimic one of Dimitri Ladopoulos’s aforementioned portrait pieces. Specifically, we will abstract over a close-up (eyes only) of Rembrandt Peale’s portrait of his daughter, Rosalba.

For convenience, a low-resolution version of the image is bundled with this package.1 Users can download a higher-resolution version from the source link above for better results.

# Load the bundled image
rosalba = image_read(system.file("extdata/rosalba.jpg", package = "parttree"))

# Crop around the eyes
rosalba = image_crop(rosalba, "170x90+170+260")

# Convert to cimg (better for in-memory manipulation)
rosalba = magick2cimg(rosalba)

# Display
rosalba
#> Image. Width: 170 pix Height: 90 pix Depth: 1 Colour channels: 3
plot(rosalba, axes = FALSE)

With our cropped image in hand, let’s walk through the 4-step recipe from above.

Step 1. Convert the image into a data frame.

# Coerce to data frame
rosalba_df = as.data.frame(rosalba)

# Round color values to ease up work for decision tree
rosalba_df$value = round(rosalba_df$value, 4)

head(rosalba_df)
#>   x y cc  value
#> 1 1 1  1 0.2471
#> 2 2 1  1 0.2745
#> 3 3 1  1 0.2627
#> 4 4 1  1 0.2353
#> 5 5 1  1 0.4235
#> 6 6 1  1 0.5333

Step 2. Split the image by RGB colour channel. This is the cc column above, where 1=Red, 2=Green, and 3=Blue.

rosalba_ccs = split(rosalba_df, rosalba_df$cc)

# We have a list of three DFs by colour channel. Uncomment if you want to see:
# str(rosalba_css)

Step 3. Fit a decision tree (or similar model) on each of our colour channel data frames. The tuning parameters that you give your model are a matter of experimentation. Here I’m giving it a low complexity parameter (so we see more variation in the final predictions) and trimming each tree to a maximum depth of 30 nodes. The next code chunk takes about 15 seconds to run on my laptop, but should be much quicker if you downloaded a lower-res image.

## Start creating regression tree for each color channel. We'll adjust some
## control parameters to give us the "right" amount of resolution in the final
## plots.
trees = lapply(
  rosalba_ccs, 
  # function(d) rpart(value ~ x + y, data=d, control=list(cp=0.00001, maxdepth=20))
  function(d) rpart(value ~ x + y, data=d, control=list(cp=0.00002, maxdepth=20))
  )

Step 4. Use our model (colour) predictions to construct our abstracted art piece. I was bit glib about it earlier, since it really involves a few sub-steps. First, let’s grab the predictions for each of our trees.

pred = lapply(trees, predict) # get predictions for each tree

We’re going to use these predictions to draw the abstracted (downscaled) version of our image.2 Probably the easiest way to do this is by taking the predictions and overwriting the “value” column of our original (pre-split) rosalba_df data frame. We can then coerce the data frame back into a cimg object, which comes with a bunch of nice plotting methods.

# The pred object is a list, so we convert it to a vector before overwriting the
# value column of the original data frame
rosalba_df$value = do.call("c", pred)

# Convert back into a cimg object, with the predictions providing pixel values
pred_img = as.cimg(rosalba_df)
#> Warning in as.cimg.data.frame(rosalba_df): Guessing image dimensions from
#> maximum coordinate values

Now we’re ready to draw our abstracted art piece. It’s also where parttree will enter the fray, since this is what we’ll be using to highlight the partitioned areas of the downscaled pixels. Here’s how we can do it using base R graphics.

# get a list of parttree data frames (one for each tree)
pts = lapply(trees, parttree)

## first plot the downscaled image...
plot(pred_img, axes = FALSE)
## ... then layer the partitions as a series of rectangles
lapply(
  pts, 
  function(pt) plot(
    pt, raw = FALSE, add = TRUE, expand = FALSE,
    fill_alpha = NULL, lwd = 0.1, border = "grey15"
  )
)

#> $`1`
#> NULL
#> 
#> $`2`
#> NULL
#> 
#> $`3`
#> NULL

We can achieve the same effect with ggplot2 if you prefer to use that.

library(ggplot2)
ggplot() +
  annotation_raster(pred_img,  ymin=-Inf, ymax=Inf, xmin=-Inf, xmax=Inf) +
  lapply(trees, function(d) geom_parttree(data = d, lwd = 0.05, col = "grey15")) +
  scale_x_continuous(limits=c(0, max(rosalba_df$x)), expand=c(0,0)) +
  scale_y_reverse(limits=c(max(rosalba_df$y), 0), expand=c(0,0)) +
  coord_fixed(ratio = Reduce(x = dim(rosalba)[2:1], f = "/") * 2) +
  theme_void()

Postscript

The individual trees for each colour channel make for nice stained glass prints…

lapply(
  seq_along(bonzai_pts),
  function(i) {
    plot(
      bonzai_pts[[i]], raw = FALSE, expand = FALSE,
      axes = FALSE, legend = FALSE,
      main = paste0(c("R", "G", "B")[i]),
      ## Aside: We're reversing the y-scale since higher values actually
      ## correspond to points lower on the image, visually.
      ylim = rev(attr(bonzai_pts[[i]], "parttree")[["yrange"]])
    ) 
  }
)

#> [[1]]
#> NULL
#> 
#> [[2]]
#> NULL
#> 
#> [[3]]
#> NULL
# reset the plot margins
par(op)

  1. Image source: Wikimedia Commons. Public domain.↩︎

  2. Remember: the tree model predictions yield “average” colours for small sub-regions of the original image. This is how we get the downscaling effect.↩︎

  3. Image source: Wikimedia Commons. Photo by Cliff, licensed CC BY 2.0.↩︎

  4. I honestly can’t remember where I first saw this trick or adapted this function from. But it works particularly well in cases like this where we want the partition lines to blend in with the main image.↩︎