Making Colour Gradients

Making Colour Gradients

Perhaps you have some digital art (cough, fractals) or some data infographics and you want to translate a number into a colour. BUT perhaps you don't necessarily know the range, or know things like 'good vs bad'. Think false colour electron microscopes or something, where you have a 'value' for an area, and you need to express these 2d arrays of values as colours.

If we don't know exactly how many colours we want, but we can assume we know the individual value, and a max-value, we can do something (not fantastic) along the lines of (assuming these functions will all return a three (or four) tuple array of numbers that map to a RGB or RGBA colour).

def greyscale(num, maxval):
    c = 255//maxval
    val = c * num
    return (val,val,val)

We can also introduce banding, i.e. if maxval is 100, we might not want 100 different colours, but instead group them into bands of (for example) 10- so that counters of 0-9 all return the same value.

So we can make something like this:

def greyscale_banded(num, maxval):
    c = 255// (maxval // 10)
    val = c * (num // 10)
    return (val,val,val)

Both of those return nice colour gradients like this: (where num is 0 to 100). Nice, but boring.

We don't want boring! But we also don't want horrible nauseating colour selections either. I did try some variants of this, for example replacing (val, val, val), with (val, 255-val, val). But I also tried just 'rotating' some of the numbers slightly, e.g. calculating a scaled value, but then offsetting by a different amount: e.g.

def grey_offset(num, maxval):
    c = 255//maxval
    val = c * num
    r = (128+val) % 256
    g = (64+val) % 256
    b = (32+val) % 256
    return (r,g,b)

With these offset numbers selected for no real reason - these in itself produced some interesting results:

The inverted green (or red/blue), was very gradient, but tended to produce a lot of 'murky' colours in the middle transition areas, where the offset method produces a very sharp colour layouts. Whats interesting if whether one of these colour boundaries lines up with an actual area of interest. Lets draw some fractals using these four methods - the same fractal but 4 using all four colour selection algorithms:

Theres pros and cons of each one - the greyscale one seems to loose some of the fine detail because the colours can get so blurred it acts like a sort of anti-aliasing effect on the edges. By banding, we get a much sharper definition of the borders, but we do end up with some pretty thick bands of colour else where. Moving onto the inverted, its ok if you like what the primary two colours come out with, but the offset one can produce very wide areas of distinct colour (which you may or may not like), and most the other colour gradient gets lost into the detail.

This of course does change radically depending on the image / dataset being rendered, and these fractals are inherently a large area of one value, a thin border of all the other values, followed by a large area of the last value.

Lets use these algorithms on something more hard-data driven:

These maps are, obviously of the UK, but is a hex-map of the parliamentary areas of the uk. The values are the real-area that each MP represents. Whats interesting is that whilst all 4 maps represent the same data, there is a preconception about what that means when we apply these colours, either an emotional (green is good, red is bad), or a knowledge (red represents the Labour party, blue represents the Tory party etc), that I want to avoid when showing just pure information (as opposed to decision information 'these are areas that reported a problem').

The greyscale and greyscale_banded takes away some of that preconceptions, but they are just boring. So I want a way to generate a range of colours that meets requirements - without pre-picking the entire colour options, just a way of saying 'heres how to generate a range'.

My problem though was I was at a bit of a loss how to take this further and make, well, lots of different, complimentary colours for any size array of inputs for a given range of numbers. Because, these are just numbers, we should be able to generate them nicely, right?

A Wider Brain Power!

So I sent this over to the great Norfolk Python User Group, as a little challenge, and Kevlar stepped up with some excellent knowledge.

So, I didn't really know anything about HSL/HSV until that morning. I knew that RGB, or RGBA was only one way of representing colours. And because I'm not quite 'that kind of nerd', it was good enough for me, but some quick research into HSL gives me a lot of ideas.

Firstly, it seems theres more then one way of representing HSL space, which is fine. To be honest, theres more then one way of representing RGB space - we assume that with RGB, that each number represents the value of red, green and blue light, between a scale of 0 and ... lots? infinite light levels? Whatever your pixel will put out. So we have a technology factor that maybe the intensity levels of R, G and B at 255 might be different for different displays. This is before we start talking about how the eye works, and that everyone has a different proportion of rod and cones and therefore perceives colours differently, not just colour blind people. (My father is sure this is why people like different paintings and or colours, and therefore the arguments about what colour to paint their study after 31 years of living there is down to a fundamental biological difference between my mum and him and so can't be held against her. My mum claims its because my Dads inconsistent on his ideas of what colour to paint it).

So, RGB can be thought of as a 3d space (because it has 3 numbers), in a coordinate system. Where as HSL can be thought of a 3d space using vectors as its coordinate system.. anyone having flashbacks to last weeks fractal youtube video should be welcome, because this opens up a world of possibilities.

Where in RGB I was trying to draw a path by moving up and down columns in the 3d space, HSL allows us to draw curves by moving around the angle. Its easier if we think about a flattened space.

He presented this algorithm: (see below for the first version, both require colorsys for the conversion).

def kevlar_two(num,maxvalue):
    colour = colorsys.hsv_to_rgb(num/maxvalue,1,1)
    return tuple([int(255*x) for x in colour])

Which returns a colour gradient like this:

Here the colours smoothly go from one to another, and have a wide range of colours. Whats interesting though is that it loops, i.e. Red is both max and min values (although technically not the same red) and this is a property of how the HSV here is represented by it being a circle - the saturation and luminosity have been set to 1 (thereby flattening the colours slightly to only the bright colours), but then this makes a 'circle' of colours, where the colour being selected is a number between 0 and 1, as a fraction of that circle.

By using num/max_val gives us a fast way of locating a fraction of that circle. before we continue though, lets just appreciate what this does to both the fractal and real data samples:

This is getting excellent, vibrant results, but still suffers from the problem (with the data infographic) that people might interpret red as 'bad' (or hot) rather then as, in this case, just a different value.

So we can imagine the HSV/HSL colour space that we're using as a bit of a cylinder - with either the S or the V value being the height, and which ever one wasn't, the radius out, and finally the H as the angle around that. We can now easily describe if we look at the top slice of the cylinder, it looks a little like this:

Effectively we followed the outer limit all the way around to get the first colour gradients. But we don't need to - we can actually follow a sub arc. And by only having to provide our point as a ratio, it means we don't need to worry about anything too funky. So lets try that - lets try to get just a green-blue ratio. This exists in the 25-50% range (as the diagram goes clockwise from the mid left), so we just map our input ratio (num/max_num) to that sub-range:

def ratioed(num,maxvalue):

    target_range = .25   # i.e. 25->50 is 25%
    target_offset = 0.25 # starting at 25 %
    my_ratio = num/maxvalue
    mapped = my_ratio * target_range
    colour = colorsys.hsv_to_rgb(mapped+target_offset,1,1)
    return tuple([int(255*x) for x in colour])

This generates a colour gradient, and therefore images of:

Which are quite gentle colour gradients that do compliment each other! The next thing to realise is that a range can go across 1 - so we can always just take the output of the range and mod it by 1 to get the result (or take the remainder as we're doing a fractional mod here) - because, we're nearly at the end of this journey (for now!)

If the curve or length of line is short, then the colours will be very similar, but if it's longer, you get more varied colours. Theres also no reason why the ratio needs to be mapped linearly - there are various scaling methods which could be applied.

Back to the original mission

When we started this quest, we had a fractal generator which would generate a zoom, and pick a random generation method, but it wasn't very good at picking colours. Now we have a way of picking colours which can be done at the start of the generation. So we can now finish with 9 randomly coloured, Andy Warhol style layout: (note that the range for these are sometimes -ve to allow the circle to go backwards)

Footnote: Making mistakes is good!

Kevlar originally gave me a formula like this: (albeit untested, concept code)

def kevlar_one(num,maxvalue):
    # divide hue max 360 by max value
    hue = num * (360/maxvalue)
    colour = colorsys.hsv_to_rgb(hue/.255,1,1)
    return tuple([int(255*x) for x in colour])

Here it's obvious that he was trying to relate to show that it's a 'proportion of being around a circle, hence including the 360). However then dividing by .255 meant that it would repeat colours instead of giving each number an unique colour: it would produce the following gradient and test fractal:

Now if we were to use this for data science this would be wrong: it shows areas of wildly different numbers as being the same colour and therefore the 'same thing' (unlike the banding approach above, which shows areas of similar numbers being the same).

However it produces a psychedelic, 90's vibe which some people love.