The calculation of an image’s dominant color can prove useful in numerous applications. One such scenario involves setting the background of an HTML container, especially in the case of image carousels where a fixed color such as white, grey, or black may not be optimal.


For instance, in the following demo, the user interface gracefully fades into the background, enabling a seamless focus on the images while ensuring accessibility.
The same slider without adaptable background for reference.
Why not just an average colour?
Conversely, the use of an average color can often yield a lackluster, grayish tone that may not complement the image. Opting for a slightly desaturated dominant color generally works better in this regard.
Server-side method (PHP)
In terms of implementation, there are two approaches: server-side and client-side. The server-side method, written in PHP, involves sampling a set number of pixels from the source image using either regular or random patterns. The script then groups these colors into distinct spectrums and identifies which group has the largest representation. It’s worth noting that, in some cases, the resulting dominant color may differ if the same image is queried again due to similarly-sized color groups. Increasing the number of color samples can help mitigate this issue.
/**
* 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)
Alternatively, the client-side script, written in CoffeeScript, operates on the same basic principle as the server-side approach, albeit with some variations. Rather than using the original image, it processes an HTML canvas element and supports a different range of sampling patterns. It also requires jQuery to function properly.
##
# 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