Generating Colour Palettes From Images In PHP

A common web design pattern is to incorporate an image into the design of the page. This creates a tighter integration with the image and the rest of the page.

The main issue in designing a page around the image is that the colours of the page must match the image. Otherwise this creates a dissonance between the image and the styles of the site.

In this article I will look at extracting a colour palette from an image so that it can be used in the design of a web page.

The Colour Class

First, we need to create a Colour class that can be used to store the colours of the image and to provide some methods to render the colour and calculate the lightness value.

class Colour {
  public function __construct(public int $red = 0,
                              public int $green = 0,
                              public int $blue = 0,
                              public int $alpha = 0) {}

  /**
   * Render colour as a hex value.
   *
   * @return string
   *   A hex representation of the colour.
   */
  public function toHex():string {
    $return = str_pad(dechex($this->red), 2, '0', STR_PAD_LEFT);
    $return .= str_pad(dechex($this->green), 2, '0', STR_PAD_LEFT);
    $return .= str_pad(dechex($this->blue), 2, '0', STR_PAD_LEFT);
    return $return;
  }

  /**
   * Calculate the lightness value of the colour.
   *
   * @return float
   *   The lightness value.
   */
  public function calculateLightness():float {
    $red = $this->red / 255;
    $green = $this->green / 255;
    $blue = $this->blue / 255;

    $chroma_min = min($red, $green, $blue);
    $chroma_max = max($red, $green, $blue);

    return (float) ($chroma_max + $chroma_min) / 2;
  }
}

The lightness value is used to sort the colours we extract from the image, allowing us to pick the "lightest" and "darkest" colour from the palette of colours.

Extracting The Colours From An Image

At first, you might think that we could just loop through all the pixels of an image and extract the colours. The problem with this is that we might end up with many thousands of colours that would be difficult to extract a colour palette from.

This is also a time consuming process for the computer to run as it needs to visit each pixel at least once and if the image is 1000x1000 pixels this means we have to extract the colours from 1,000,000 pixels.

The solution to this is to cheat, basically. Instead of extracting the colours from every pixel, we instead shrink the image down to just 15x15 pixels. This means that we only have 225 pixels to extract colour information from. Shrinking the image down effectively groups the colours together so our new image contains the most important colours that the original image had.

To shrink an image in PHP requires a couple of steps, so let's run through them.

First, we need to load the original image into memory, which we do with the imagecreatefromjpeg() method, assuming that the original image is a jpeg image.

$imgfile = 'image.jpg';
$image = imagecreatefromjpeg($imgfile);

Now we need to calculate the dimensions of the new image. If the new image has a width of 15 pixels, then the height will be 15 divided by the ratio between the width and the height. This ensures that we have the same shaped image as we started with.

// Get existing image dimensions.
$imagex = imagesx($image);
$imagey = imagesy($image);

// Calculate the new image dimensions.
$ratio = $imagex/$imagey;
$newX = 15;
$newY = round($newX/$ratio);

We then create a new (blank) image resource using the imagecreatetruecolor() function, which accepts the new dimensions as the parameters.

$newImage = imagecreatetruecolor($newX, $newY);

The imagecreatetruecolor() is important here as it means we can resize the image without changing the colours of the original image. The function imagecreate() also exists, but that creates a palette image that holds a lower number of colours than a the true colour format.

Finally, we need to use the imagecopyresampled() function to copy the original image into the new one, resizing the image along the way.

imagecopyresampled($newImage, $image, 0, 0, 0, 0, $newX, $newY, $imagex, $imagey);

The new image (located in the $newImage resource) is now many times smaller than the original image and so has a much smaller number of pixels to examine.

To extract our colours we just need to loop through the pixels of the newly resized image and extract the colours from it. We can use the splat operator to quickly convert the colours array into properties in our Colour constructor.

// Populate the colour list by looping over the small image and collecting the colours found.
$coloursList = [];
for ($x = 0; $x < $newX; $x++) {
  for ($y = 0; $y < $newY; $y++) {
    $rgb = imagecolorat($newImage, $x, $y);
    $colours = imagecolorsforindex($newImage, $rgb);
    $colourObj = new Colour(...$colours);
    $hex = $colourObj->toHex();
    if (!isset($coloursList[$hex])) {
      // Add colour to the palette, using the hex value as an index.
      $coloursList[$hex] = $colourObj;
    }
  }
}

As we have shrunk the image down to 15 pixels high the $coloursList array will not be much bigger than 225 colours (assuming a square image was used as the input). This is a worst case scenario and would mean that every colour in the image is unique, which doesn't happen very much.

The colours, once extracted, are in no particular order, so we use a simple call to usort() to sort the colours by their lightness value.

// Sort by lightness.
usort($coloursList, function ($a, $b) {
  return $a->calculateLightness() <=> $b->calculateLightness();
});

We can then extract the needed colours from the sorted colour array.

Here is a full function that takes a filename as an argument and returns an (ordered) array of Colour objects that detail the colours found.

/**
 * Generate a colour palette from an image.
 *
 * @param string $imgfile
 *   The image file.
 *
 * @return array<Colour>
 *   An array of colours.
 */
function generatePalette($imgfile):array {
  $image = imagecreatefromjpeg($imgfile);
  
  // Get existing image dimensions.
  $imagex = imagesx($image);
  $imagey = imagesy($image);

  // Calculate the new image dimensions.
  $ratio = $imagex/$imagey;
  $newX = 15;
  $newY = round($newX/$ratio);

  // Create a true colour destination image to prevent colour distortions.
  $newImage = imagecreatetruecolor($newX, $newY);

  // Shrink the image down before sampling the colours.
  imagecopyresampled($newImage, $image, 0, 0, 0, 0, $newX, $newY, $imagex, $imagey);

  // Populate the colour list by looping over the small image and collecting the colours found.
  $coloursList = [];
  for ($x = 0; $x < $newX; $x++) {
    for ($y = 0; $y < $newY; $y++) {
      $rgb = imagecolorat($newImage, $x, $y);
      $colours = imagecolorsforindex($newImage, $rgb);
      $colourObj = new Colour(...$colours);
      $hex = $colourObj->toHex();
      if (!isset($coloursList[$hex])) {
        // Add colour to the palette, using the hex value as an index.
        $coloursList[$hex] = $colourObj;
      }
    }
  }

  // Sort by lightness.
  usort($coloursList, function ($a, $b) {
    return $a->calculateLightness() <=> $b->calculateLightness();
  });

  return $coloursList;
}

This can be called like this.

$coloursList = generatePalette($imgFile);

We are now ready to use the colours.

Using The Palette 

Once we have our sorted list of colours we can easily extract the darkest, middle, and lightest colours from the array.

$darkest = $coloursList[0];
$middle = $coloursList[round(count($coloursList)/2)];
$lightest = $coloursList[count($coloursList) - 1];

Here's a couple of examples that I created using album covers, which is a good way of testing the output of the method.

A palette generated from the Giant Walker album cover.

 

A palette generated from the Bones of Minerva album cover.

 

A palette generated from the Bones of Minerva album cover.

Using these colours allows us to create design elements that closely match the existing colours of the image.

If you want, you can also print out the entire palette as HTML using the following.

foreach ($coloursList as $color) {
  $htmlString .= '<div style="float:left;width:100px;height:100px;background-color:#' . $color->toHex() . '">#' . $color->toHex() . '</div>' . PHP_EOL;
}

Improvements

The value of 15 pixels for the original image is more or less arbitrary. I experimented with the image resizing a little to see what effect reducing the number of pixels would have. 15 pixels seemed to give a decent darkest, middle, and lightest colour values and not have a large number of colours left over from the extraction.

Using values under 15 pixels makes the new image loose too much information to be of much use in extracting colours and so the palette doesn't really match the original image. Having a larger dimensions for the resized image takes longer to extract the colours and can also lead to just picking the darkest colour from the image, which often doesn't fit in with the palette.

We can probably improve the colour extraction here by introducing a colour difference into the algorithm. Doing this would mean that we can narrow the number of colours collected by discarding any colours that are similar to colours already collected. This might produce better (or more consistent) results, but the colour difference calculations can be quite expensive to calculate and are quite complex to understand.

Feel free to experiment on your own images using this function.

More in this series

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
1 + 1 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.