The median cut algorithm is a method for discretizing a continuous color space. That is, taking an image that consists of many colors, and reducing it into fewer colors. Broadly, the idea is to take the red, green, and blue components of an image, visualize each of them as an axis in 3-dimensional space, and then partitioniong that space into chunks, each consisting of as few distinct colors as possible. Here, we will explore and implement the median cut algorithm in R.
The implementation here, with some modification, is available in the RImagePalette package, and can be explored interactively here!
Let’s start by coding a graphical representation of this 3-dimensional RGB color space. We will work throughout this post with a colorful image of a delicious and healthy snack:
library(ggplot2);library(ggthemes)
library(knitr); library(scatterplot3d)
img <- jpeg::readJPEG("celery.jpg")
grid::grid.raster(img)
It’s going to be convenient to work with the image as a list of three 2D matrices, rather than a single 3D matrix, so we will split it and name each matrix appropriately:
img_list <- list("Red"=img[,,1], "Green"=img[,,2], "Blue"=img[,,3])
Let’s look at how the values in each of the R, G, and B components are distributed:
# Create a tall dataframe
component_distribution <- data.frame("value"=255*c(c(img_list$Red),
c(img_list$Green),
c(img_list$Blue)),
"Color"=factor(rep(c("Red", "Green", "Blue"),
each=length(img_list$Red)),
levels=c("Red","Green","Blue")))
ggplot(data=component_distribution, aes(x=value, fill=Color)) +
geom_histogram(binwidth=10, aes(y=100*(..count..)/sum(..count..))) +
facet_wrap(~Color) +
theme_pander() +
guides(fill=FALSE) +
labs(x="RGB Value (0 - 255)", y="Percent (%)") + #hist labs
scale_x_continuous(breaks=c(0,50,100,150,200,255)) +
theme(strip.background = element_blank(),
strip.text.x = element_blank())
We note several things:
It will be our task to extract those colors which are most highly represented in the image. This is where the median cut algorithm comes in.
The median cut algorithm can be stated as follows:
Let’s walk through this for our chosen image.
We first create a cube of the RGB components. A sample of 2500 pixels from the image along the R, G, and B axes is visualized using the code below:
color_plot <- function(img_list, n_points, main=""){
set.seed(101)
dims <-length(img_list$Red)
point_sample <- sample(1:dims, n_points)
img_matrix <- t(matrix(c(img_list$Red[point_sample],
img_list$Green[point_sample],
img_list$Blue[point_sample]
),
nrow=3, byrow=TRUE,
dimnames=list(names(img_list))))
scatterplot3d(img_matrix, color = rgb(img_matrix),
main = main,
pch = 16,box=FALSE, angle=20,
y.margin.add=0.1,
xlim=c(0,1),
ylim=c(0,1),
zlim=c(0,1),
x.ticklabs=c(seq(0,200,50), 255),
y.ticklabs=c(seq(0,200,50), 255),
z.ticklabs=c(seq(0,200,50), 255))
}
color_plot(img_list, 2500, "Original")
In the image we can get an idea of the most common colors present in the image. The blue in the upper are from the plate, the orange in the bottom from the carrots, etc.
To choose how to split this cube up, we require the range of each component. Since we will be doing this a number of times, we will write a convenience function:
range_table <- function(images){
#each item in the list is a 3 dimensional image matrix
ranges <- lapply(images, function(img){
list("Red" = paste(255*range(img$Red), collapse=" - "),
"Green" = paste(255*range(img$Green), collapse=" - "),
"Blue" = paste(255*range(img$Blue), collapse=" - "))
})
kable(do.call(rbind.data.frame, ranges), format="html")
}
# Check ranges of original image
range_table(list("Original"=img_list))
Red | Green | Blue | |
---|---|---|---|
Original | 0 - 255 | 0 - 255 | 0 - 255 |
In this case, each axis extends from 0 to 255. Since there is no single component with the largest range, me must make a choice. There are a number of different methods for making this choice, however for simplicity we will choose randomly. Let’s arbitrarily choose the red axis.
cut_color <- "Red"
median(img_list[[cut_color]]) * 255
## [1] 118
Using the median we calculated in Step 1B, we will extract all of the pixels from the original image above, and below that value:
# Define the median cut algorithm
median_cut <- function(img_list, cut_color, cube_names){
lst <- list()
#Extract from each component:
#all pixels where cut_color is above the median value
lst[[cube_names[1]]] <- lapply(img_list, function(color_channel){
color_channel[which(img_list[[cut_color]] >= median(img_list[[cut_color]]))]
})
#all pixels where cut_color is below the median value
lst[[cube_names[2]]] <- lapply(img_list, function(color_channel){
color_channel[which(img_list[[cut_color]] < median(img_list[[cut_color]]))]
})
return(lst)
}
# Perform the median cut on the image
img_list <- median_cut(img_list, "Red", cube_names=c("Cube 1","Cube 2"))
par(mfrow=c(1,2))
color_plot(img_list[["Cube 1"]], 1250, "Cube 1")
color_plot(img_list[["Cube 2"]] ,1250, "Cube 2")
We now have two cubes, each with their own distribution of colors, and each with a different dominant color. We will go on in this post to extract 6 colors from the image, so we need to repeat the process. To choose the next cube to cut, we calculate the ranges of pixels in each cube:
range_table(img_list)
Red | Green | Blue | |
---|---|---|---|
Cube 1 | 118 - 255 | 0 - 255 | 0 - 255 |
Cube 2 | 0 - 117 | 0 - 243 | 0 - 255 |
The maximum range out of any of the components is still 255, and both cubes have at least one axis that covers the full range. Again, we arbitrarily choose, randomly selecting Cube 2.
In Cube 2, we select the component with the largest range to cut across, in this case, the Blue axis:
cut_color <- "Blue"
median(img_list[["Cube 2"]][[cut_color]]) * 255
## [1] 132
# Keep uncut cube (Cube 1), perform median cut on Cube 2
img_list <- c(list("Cube 1" = img_list[["Cube 1"]]),
median_cut(img_list[["Cube 2"]], "Blue", c("Cube 2a", "Cube 2b")))
range_table(img_list)
Red | Green | Blue | |
---|---|---|---|
Cube 1 | 118 - 255 | 0 - 255 | 0 - 255 |
Cube 2a | 15 - 117 | 53 - 243 | 132 - 255 |
Cube 2b | 0 - 117 | 0 - 217 | 0 - 131 |
We are left with three cubes now, still not the desired 6, so we need to repeat the algorithm 3 more times. I will spare you the code (although you can see it in the reproducible document), and present the plots. The next cut will be along the Green axis of Cube 1, which has a maximum range of 255.
And we are now left with 6 cubes, each of which we wish to extract the dominant color from.
Finally, we have the desired number of colors, and can exit the loop!
We have 6 color cubes now, each representing a different set of colors. Ideally, each cube contains a relatively distinct set of colors, but in practice it may take a larger number of iterations for the cubes to be small enough. To extract the color that each cube represents, we must apply some sort of color extraction function to each cube. For this, we have several options:
Below, the final option is demonstrated, and the extracted palette visualized:
# Who could believe there isn't a mode function in R?
Mode <- function(x) {
ux <- unique(x)
ux[which.max(tabulate(match(x, ux)))]
}
choice=Mode
# see reproducible doc for show_colors() code
show_colors(unname(unlist(lapply(img_list, function(x) rgb(choice(x$Red), choice(x$Green), choice(x$Blue)) ))), labels=TRUE)
The extracted palette represents the dominant colors in the image quite well. We can see the sauce, the plate, the background, celery, and carrot colors are all present in the final palette. To note, I have implemented the median cut algorithm in an R package, RImagePalette. Using the package to produce palettes from images is super easy!
library(RImagePalette)
set.seed(1)
show_colors(image_palette(img, 9, Mode))
![]() |
---|
Happy extracting!