How to find dominant colour of an image

Calculating the dominant colour of an image is useful in many different cases.

One such case is setting the background of a HTML container. Typically in an image carousel with a mix of images in a different shape, orientation and subject a matching background looks better than a fixed one e. g. white, grey or black.

Sampling the image colours and calculating the right one for the background can be achieved on server or client-side, depending on factors such as image sizes and server speed.

Example use

Here is a video with a quick demo of my slider that uses the back-end version of the script. Note that the user interface also matches the dominant colour of the images.

Below, the same slider without adaptive background.

It is even more obvious on a large screen and with vibrant scenes such as sports.

Why not just an average colour?

Average colour of images is usually a dirty shade of grey which doesn’t go well with the image. A slightly desaturated dominant colour works much better.

The code

Server-side method (PHP)

This simple script is sampling a number of pixels from a source image, in a regular or random pattern. It then divides the true-colour spectrum into groups and checks which group has the largest match. Sometimes the resulting colour may vary when querying same image subsequently. This happens when two or more dominant colour groups are of very similar size. It can be often prevented by increasing the number of colour samples.

/**
 * Throws a debug messages to error_log, uncomment the first return to disable.
 * @param $message   string
 */
function my_log_56e684pp($message) {
   // return;
   error_log('mrt_dominant_color(): '.$message); return;
}

/**
 * Dominant Colour
 *
 * Calculates a dominant colour from an image
 *
 * @param $img_url
 * @param array $args
 * @return string   colour in HEX format (#123456)
 *
 */
function mrt_dominant_color($img_url, $args=[]) {

   $defaults=[

      'sample-method-args'          => ['regular', 20],  // regular, n - sample each n pixels in regular intervals OR random, n -- sample n pixels of the image picked randomly (slightly slower performance)
      'desaturate-output-percent'   =>  40,
      'img-size-max'                =>  1400,            // limited for performance reasons, increase as far as your PHP can cope
      'fallback-color'              =>  '#656565'

   ];

   $args= array_merge($defaults, $args);
   $img_info = @getimagesize($img_url);
   if ($img_info == false) { my_log_56e684pp('?bad image'); return $args['fallback-color']; }
   if ($img_info[0] > $args['img-size-max'] || $img_info[1] > $args['img-size-max']) {
      my_log_56e684pp("?image too big: $img_info[0] x $img_info[1]. Max {$args['img-size-max']}");
      return $args['fallback-color'];
   }

   if ($img_info['mime']) $img_file_type = $img_info['mime'];
   else {
      $img_file_type = pathinfo($img_url, PATHINFO_EXTENSION);
      if (!$img_file_type) { my_log_56e684pp('?bad image'); return $args['fallback-color']; }
      if ($img_file_type == 'jpg') $img_file_type = 'jpeg'; // TODO: check if can work with jpg2000
      $img_file_type = 'image/' . $img_file_type;
   }

   switch($img_file_type) {

      case 'image/jpeg': $img = imagecreatefromjpeg( $img_url ); break;
      case 'image/png': $img  = imagecreatefrompng(  $img_url ); break;
      case 'image/gif': $img  = imagecreatefromgif(  $img_url ); break;
      case 'image/bmp': $img  = imagecreatefromwbmp( $img_url ); break;
      default : my_log_56e684pp("?wrong filetype: $img_file_type"); return $args['fallback-color'];
   }

   if ('regular'==$args['sample-method-args'][0]) {

      $step= $args['sample-method-args'][1];
      for ($x=0; $x<imagesx($img); $x=$x+$step) {
         for ($y=0; $y<imagesy($img); $y=$y+$step) {
            $rgb[] = imagecolorat($img,$x,$y);
         }
      }
   }
   elseif ('random'==$args['sample-method-args'][0]) {

      $test_px_count  = $args['sample-method-args'][1];
      for ($i=0; $i<$test_px_count; $i++) {
         $rgb[] = imagecolorat($img, rand(0, imagesx($img)-1), rand(0, imagesy($img)-1));
      }
   }

   imagedestroy($img);
   asort($rgb);

   # create colour groups (think 'posterize' filter in Photoshop)
   $group = 0;
   $group_range = 3 * 1000000; // true-color image colours count: 16,777,216

   $rgb_groups = Array();
   foreach ($rgb as $key => $value) {
      $rgb_groups[$group][] = $value;
      if ($value > $rgb_groups[$group][0] + $group_range) $group++;
   }

   # find the largest group
   foreach ($rgb_groups as $key => $group) {
      $group_sizes[$key] = count($group);
   }
   arsort($group_sizes);
   reset($group_sizes);
   $largest_group_key = key($group_sizes);

   # 'flatten' the largest group
   //    $reduced_rgb = array_sum($rgb_groups[$largest_group]) / count($rgb_groups[$largest_group]);

   foreach ($rgb_groups[$largest_group_key] as $rgb) {
      $ch['r'][] = ($rgb >> 16) & 0xFF;
      $ch['g'][] = ($rgb >> 8) & 0xFF;
      $ch['b'][] = $rgb & 0xFF;
   }

   foreach ($ch as $key => $values) {
      $ch_average[$key] = array_sum($values) / count($values);
   }

   # desaturate
   # (source http://stackoverflow.com/questions/13328029/how-to-desaturate-a-color#20820649)

   $f = $args['desaturate-output-percent']*.01;
   $L = 0.3*$ch_average['r'] + 0.6*$ch_average['g'] + 0.2*$ch_average['b'];

   foreach ($ch as $key => $values) {
      $ch_average[$key] = $ch_average[$key] + $f * ($L - $ch_average[$key]);
   }

   return sprintf('#%02X%02X%02X', $ch_average['r'], $ch_average['g'], $ch_average['b']);
}

Client-side (CoffeScript)

This script is based on the same idea as the back-end one. The main difference is that it takes the HTML <canvas> element rather than an original image. It also supports slightly different sampling patterns. The script requires jQuery.


##
#  converts colour value from rgb to hex
#  returns css color string
#
color_rgb2hex= (r, g, b) ->
   "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)


##
#  converts colour value from hex string (3 or 6 digits) to rgb decimal values
#  returns string: comma separated rgb values
#
color_hex2rgb = (hex) ->
# Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
   shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
   hex = hex.replace(shorthandRegex, (m, r, g, b) ->
      r + r + g + g + b + b
   )
   result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
   if result then parseInt(result[1], 16)+','+parseInt(result[2], 16)+','+parseInt(result[3], 16) else null



##
#  converts colour value from decimal integer to rgb decimal values
#  returns array with 3 elements corresponding to rgb channels
#
color_dec2rgb= (dec)->

   b= (dec & 0xff0000) >> 16
   g= (dec & 0x00ff00) >> 8
   r= dec & 0x0000ff
   return [r, g, b]


##
#  converts colour value from rgb to decimal
#  returns integer
#
color_rgb2dec= (r, g, b) ->
   b * 65536 + g * 256 + r




##
#  returns number: the pixel density of the device
#  (1 for traditional displays, 2 for retina etc)
#
dispPixRatio= (val)-> if val then val * window.devicePixelRatio else window.devicePixelRatio



##
#  scans a canvas
#  returns a dominant colour found in it
#
#
#
mrtImgDominantColor= (args) ->

   defaults=
      ctx            : undefined
      scan_invl      : dispPixRatio(60)   # scan every n pixel from scanned area
      edge_offset    : dispPixRatio(2)    # offset of the scanned area from the edge of img
      scan_pattern   : 'full'                 # the whole img scan OR v_stripe vertical stripe

# required for scan pattern v_stripe:
# x coordinate and width of scanned stripe of the ctx
      x              : undefined
      width          : undefined


   args = $.extend({}, defaults, args)
   clr_group_range=  4 * 1000000 # true-color image colours count: 16,777,216
   img_data=         []


   if 'v_stripe'==args.scan_pattern
      x= args.x
      x_end= x+args.width
      while x < x_end
         y= args.edge_offset
         while y < (args.ctx.canvas.height-args.edge_offset)
            px_data= args.ctx.getImageData(x, y, 1, 1).data
            # convert rgb colours to decimal
            img_data.push color_rgb2dec(px_data[0], px_data[1], px_data[2])
            y+=args.scan_invl
         x+=dispPixRatio()

   if 'full'==args.scan_pattern
      x= args.edge_offset
      x_end= args.ctx.canvas.width-args.edge_offset
      while x < x_end
         y= args.edge_offset
         y_end= args.ctx.canvas.height-args.edge_offset
         while y < y_end
            px_data= args.ctx.getImageData(x, y, 1, 1).data
            img_data.push color_rgb2dec(px_data[0], px_data[1], px_data[2])
            y+=args.scan_invl
         x+=args.scan_invl

   # create colour groups and find out which one is the largest
   group_key= 0
   largest_group= { key : 0, size: 0}

   clr_groups= []
   img_data.sort()

   $.each img_data, (ix, val) ->
      if typeof(clr_groups[group_key]) != 'object' then clr_groups[group_key]= []
      clr_groups[group_key].push val
      if clr_groups[group_key].length > largest_group.size then largest_group= {key: group_key, size: clr_groups[group_key].length}
      if val > clr_groups[group_key][0] + clr_group_range then group_key++
      return


   # find the average value of each channel
   ch_avrg= []
   ch= 0
   while ch < 3
      sum = 0
      i = 0
      while i < largest_group.size
         rgb= color_dec2rgb clr_groups[largest_group.key][i]
         sum += parseInt(rgb[ch])
         i++
      ch_avrg[ch]= parseInt(sum / largest_group.size)
      ch++

   return ch_avrg[0]+','+ch_avrg[1]+','+ch_avrg[2]