

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 it looks better than a fixed one e. g. white, grey or black.
Example use
In the below demo the user interface disappears into experience. It helps to focus on the images whilst being accessible.
The same slider without adaptable background for reference.
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]
Add a comment