In improving one of my Joomla components I found myself disappearing down a rabbit hole of confusion on t'internet over colour spaces, and in particular the differences between RGB, HSV, and HSL (plus of course for old TV hands like me YUV or Y'CrCb)


Probably the main reason (for me at least) in going down this hole was to find out how to generate lighter or darker versions of the same basic colour. So that, for example, an admin user can specify a single main foreground (eg text and borders) colour and I can automatically generate a related lighter or darker background colour. Probably not an unusual requirement.

Hence the need to convert RGB into something with a 'grey' component like Y or L or V which can be adjusted without affecting the colour. 

Since ultimately the values are to be used to set CSS colours the advantage of using HSL is that CSS colours can be specified as hex (#2F6F4F) RGB (rgb(47,111,79), or HSL (hsl(150,41%,31%)) all of which look like this  and here's lighter and darker versions by simply adjusting the L% to 80% and 15% (ie a 50% increase and decrease in lightness)

So after initially being confused by not realising that HSV is not the same as HSL (and that L is not luminance) I started to look for php functions to convert hex RGB to HSL which I could then use directly in the CSS without having to convert back to hex RGB.

The first part - converting hex RGB to separate R G B values is trivial and everyone seems to get that right.

function hex2rgb($hexstr) {
	//strip off the leading '#' if it is present
	$hexstr = ltrim($hexstr, '#');
	//allow shorthand values like '789' for '778899' as per CSS
    if (strlen($hexstr) == 3) {
    	$hexstr = $hexstr[0] . $hexstr[0] . $hexstr[1] . $hexstr[1] . $hexstr[2] . $hexstr[2];
	//we should probably also check we've actually got six characters but hey-ho
	$R = hexdec($hexstr[0] . $hexstr[1]);
	$G = hexdec($hexstr[2] . $hexstr[3]);
	$B = hexdec($hexstr[4] . $hexstr[5]);
	//these are simply the decimal equivalents in the range 0 to 255 which could be used for CSS rgb() strings
	return array($R,$G,$B);

 The next bit gets more tricky - many of the examples on t'internet seem to have errors in them and work correctly for some colours but not others. There are at least two areas of confusion in the examples - firstly confusion between full scale values and percentage values, and secondly some confusion about strings, integers and floating point number plus some curiously tortured code which seems to be copy/pasted around with and obscure error in it.

Finally, after combining bits that seem to work from various sources I've got this - which seems to be working ok

function hex2hsl($hexstr) {
    $RGB = self::hex2rgb($hexstr);
	//scale the values by dividing by 255 so now we have values between 0 and 1 which we can multiply by 100 to produce percentages
    $r = $RGB[0]/255;
    $g = $RGB[1]/255;
    $b = $RGB[2]/255;
    // using
	$max = max( $r, $g, $b );
	$min = min( $r, $g, $b );
// lightness - this is definitely not the same as luminance which would be scaled according to the eye response
$l = ( $max + $min ) / 2; 
// so pure green (0,1,0)  would be 0.5 as would pure blue (0,0,1) which ain't right perceptually
// saturation
$d = $max - $min;
if( $d == 0 ){
    // achromatic  rgb all zero so to avoid $s involving divide by zero we'll call it zero
	$h = $s = 0; 
} else {
	$s = $d / ( 1 - abs( (2 * $l) - 1 ) );
	// so lightness is playing a part in calculating saturation. The result is that as lightness decreases stauration increases which doesn't seem right intuitively to me
	// hue - this seems to work ok, we are calculating a number between 0 and 359 to represent the angle on a colour wheel
	switch( $max ){
		//which third of the colour wheel we are in is defined by the max colour
		case $r:
			//pure red is zero degrees
			$H = 60 * fmod( ( ( $g - $b ) / $d ), 6 ); 
			if ($b > $g) {
				$H += 360;
		case $g: 
			//pure green is 120deg
			$H = 60 * ( (( $b - $r ) / $d) + 2 ); 
		case $b: 
			//pure blue is 240deg
			$H = 60 * ( (( $r - $g ) / $d) + 4 ); 
	//H is already in degrees, we'll scale the s and l values to be percentages
	$HSL = array( round( $H), round( $s*100 ), round( $l*100) );
	//make a CSS ready string if we want that
	$hslstr = 'hsl('.$HSL[0].','.$HSL[1].'%,'.$HSL[2].'%)';
	return $hslstr; //or $HSL; if we want an array back

Which looks lightest and darkest to you?   - to me the green is clearly brighter than the red or blue (not so much if you suffer from red-green colour blindness of course). Hence the TV way of calculating luminance as (for example) Y=0.257*R + 0.504*G + 0.098*B if you are using Rec601 which gives a value between 0 and 235 - to which you then give a black pedestal of +16 (to aid detecting start of picture and sync pulses) which gives a maximum value of 251 which avoids overloading the UHF transmitter with a super white. And to further confuse the issue the HDTV standard when that came along used different scaling factors for R,G and B (Rec601 was optimised for the PAL world) which is why colours look ever so slightly different on an HDTV display than a standard 4*3 one. All of which is beside the main point which is that neither L nor the V values in the computer world truly represent what they human eye perceives as brightness.

But HSL is what we've got so the only remaining problem is to adjust the L value to produce lighter or darker colours. I'll adjust by a percentage of the difference from the L value to 0 or 100. 

function adjlum($HSL, $ladj) {
	//passing in the array in case we want to also mess with the S value as noted above and the adjustment percentage (0-100)
	$L = $HSL[2];
	if ($ladj>0) {
		$L += (100-$l) * $ladj/100;
	} elseif ($ladj<0) {
		$L += $L * $ladj/100;
	$HSL[2] = $L;
	return $HSL;

If you try to adjust L before S is calculated you will end up with illegal S values (ie the colour will be outside the gamut) as the new value of L will affect the S value. But once the complete HSL is calculated for the base colour you can adjust L anywhere between 0 and 100.