Converting HSV to RGBA

Hi! This notebook is about how to convert hue, saturation and value (HSV) color representations into red, green, bluue and yellow/amber components. It's a companion to the blog post at http://rotormind.com/blog/2015/Generating-RGBY-from-Hue/

It's assumed you know the basics about HSV, if you don't, here's some good background from Wikipedia. (I'm going to use "Y" for the amber channel; though "a" may make more sense, it risks confusion with the alpha transparency channel usually represented as "a")

In [120]:
import colorsys 
import numpy as np
import pylab as pl
from matplotlib.colors import hsv_to_rgb
%matplotlib inline
pl.rcParams['figure.figsize'] = (12.0, 5.0)

First, let's see how hue is typically converted to r, g, and b. We'll use the Python "colorspace" module which has a hsv_to_rgb() method

In [121]:
# 200 values of hue increasing linearly between 0 and 1
hue = 0.005*np.linspace(0,200,200)
#set saturation and value to 1 for maximum color saturation and brightness
sat = np.ones_like(hue)
val = np.ones_like(hue)

vhsv_to_rgb = np.vectorize(colorsys.hsv_to_rgb)
#this is where the magic happens
r, g, b = vhsv_to_rgb(hue,sat,val)

# now have red green and blue channels computed from the hue, plot them
pl.xlabel("hue")
pl.ylabel("r, g, b value")
pl.margins(0,0.01)
pl.title("Python colorsys.hsv_to_rgb(hue,1,1)")
pl.plot(hue,r,'r',hue,g,'g',hue,b,'b',linewidth=4)
pl.legend(['red','green','blue'],loc=4)
#pl.plot(hue,r,'r')
pl.show()

So the above plot is the built-in HSV to RGB function showing r, g, and b as a function of hue (value and saturation are set to the maximum 1.0 so colors are fully saturated.) It's easy to see how the color components are cross-faded; a hue of 0 corresponds to pure red; as hue increases more green is mixed in until at hue = 1/6, there is an equal mix of red and green, resulting in a perceptual yellow.

How does this work? Let's inspect the code for hsv_to_rgb():

In [122]:
import inspect
import pprint
inspect.getsourcelines(colorsys.hsv_to_rgb)
Out[122]:
(['def hsv_to_rgb(h, s, v):\n',
  '    if s == 0.0:\n',
  '        return v, v, v\n',
  '    i = int(h*6.0) # XXX assume int() truncates!\n',
  '    f = (h*6.0) - i\n',
  '    p = v*(1.0 - s)\n',
  '    q = v*(1.0 - s*f)\n',
  '    t = v*(1.0 - s*(1.0-f))\n',
  '    i = i%6\n',
  '    if i == 0:\n',
  '        return v, t, p\n',
  '    if i == 1:\n',
  '        return q, v, p\n',
  '    if i == 2:\n',
  '        return p, v, t\n',
  '    if i == 3:\n',
  '        return p, q, v\n',
  '    if i == 4:\n',
  '        return t, p, v\n',
  '    if i == 5:\n',
  '        return v, p, q\n'],
 135)

I see what they did there. They chopped the [0-1] hue range into six zones, and piecewise-linearly crossfaded each color. Hue=0 starts at maximum red, and green fades up as the hue value increases, making the perceptual hue more yellow. At hue=1/6,both red and green are at a maximum, and as hue increases further the red fades out, so the perceptual hue changes from yellow to green. And so forth for green+blue, then blue, then blue+red, then back to red at hue=1.0 so it wraps around to hue=0.

This is pretty straightforward: let's modify this to add an amber channel. We'll divide the hue range into 8 steps and stick the amber ramp between red and green:

In [123]:
def hsv_to_rgba_naive(h, s, v):
    """Naive implementation of hsv colorspace to red, green, blue and amber channels
    hsv inputs and rgby outputs all floats between 0 and 1"""
    if s == 0.0:
        return v, v, v, v
    i = int(h*8.0) # what hue range are we in?

                            # v is top flat
    f = (h*8.0) - i         # local slope
    b = v*(1.0 - s)         # bottom flat
    d = v*(1.0 - s*f)       # downslope  
    u = v*(1.0 - s*(1.0-f)) # upslope
    i = i%8
    if i == 0:
        return v, b, b, u  # max r, a up
    if i == 1:
        return d, b, b, v  # max a, r down
    if i == 2:
        return b, u, b, v  # max a, g up
    if i == 3:
        return b, v, b, d  # max g, a down
    if i == 4:
        return b, v, u, b  # max g, b up
    if i == 5:
        return b, d, v, b  # max b, g down
    if i == 6:
        return u, b, v, b  # max b, r up
    if i == 7:
        return v, b, d, b  # max r, b down
In [124]:
# n values of hue increasing linearly between 0 and 1
n = 300
hue = (1/float(n))*np.linspace(0,n,n)
#set saturation and value to 1 for maximum color saturation and brightness
sat = np.ones_like(hue)
val = (0.5)*np.ones_like(hue)
vhsv_to_rgba = np.vectorize(hsv_to_rgba_naive)
r, g, b, a = vhsv_to_rgba(hue,sat,val)

# now have red green and blue, and amber channels computed from the hue, plot them
pl.plot(hue,r,'r',hue,g,'g',hue,b,'b',hue,a,'y')
# labels and legend
pl.xlabel("hue")
pl.ylabel("r, g, b, y value")
pl.title("naive hsv_to_rgby(hue,1,1)")
pl.margins(0,0.01)
pl.plot(hue,r,'r',hue,g,'g',hue,b,'b',linewidth=4)
pl.plot(hue,a,'y',linewidth=4)
pl.legend(['red','green','blue','amber'],loc=4)
pl.show()

So you can see the output above. Now this works, but is not perfect. Because the perceptual difference between red and amber is not huge, but it winds up taking almost half of the hue space! Our yellow, which used to be at hue = 1/6, has now moved to hue=1/3 -- though it will be a spectrally better yellow as it's a mix of amber and green rather than red and green.

So let's hack it and squeeze the red and amber back into 1/3 of the hue. This means being a little tricksy and dividing the hue space into 12 regions and doing fast and slow crossfading between them:

In [125]:
def hsv_to_rgba(h, s, v):
    """Improved implementation of hsv colorspace to red, green, blue and amber channels
    red and amber squashed into 1/3 the hue range (instead of 1/2 as in naive)
    hsv inputs and rgby outputs all floats between 0 and 1"""
    # offset h so 0 is pure red, needed to keep code pretty
    h = h - 1.0/12.0
    if h < 0:
        h += 1.0
        
    if s == 0.0:
        return v, v, v, v
    i = int(h*6.0) # what hue range are we in?

                            # v is top flat
    f = (h*6.0) - i         # slope for 1/6 hue range

    b = v*(1.0 - s)         # bottom flat
    d = v*(1.0 - s*f)       # downslope  
    u = v*(1.0 - s*(1.0-f)) # upslope

    i2 = int(h*12.0)        # what hue subrange are we in?
    f2 = (h*12.0) - i2      # slope for 1/12 hue range
    d2 = v*(1.0 - s*f2)       # steep downslope  
    u2 = v*(1.0 - s*(1.0-f2)) # steep upslope

    i2 = i2 % 12

    if i2 == 0:
        return d2, b, b, v  # max a, r down steep
    if i2 == 1: 
        return b, u2, b, v  # max a, g up steep
    if i2 == 2 or i2 == 3:
        return b, v, b, d   # max g, a down slow
    if i2 == 4 or i2 == 5:
        return b, v, u, b   # max g, b up slow
    if i2 == 6 or i2 == 7:
        return b, d, v, b   # max b, g down slow
    if i2 == 8 or i2 == 9:
        return u, b, v, b   # max b, r up slow
    if i2 == 10:
        return v, b, d2, b  # max r, b down steep 
    if i2 == 11:
        return v, b, b, u2  # max r, a up steep

Let's plot our new function: (In order to make the keep the program pretty but to start pure red at hue=0, the result has been shifted left by 1/12)

In [126]:
# n values of hue increasing linearly between 0 and 1
n = 300
hue = (1/float(n))*np.linspace(0,n,n)
#set saturation and value to 1 for maximum color saturation and brightness
sat = np.ones_like(hue)
val = (0.5)*np.ones_like(hue)
vhsv_to_rgba = np.vectorize(hsv_to_rgba)
r, g, b, a = vhsv_to_rgba(hue,sat,val)

# now have red green and blue, and amber channels computed from the hue, plot them
pl.plot(hue,r,'r',hue,g,'g',hue,b,'b',hue,a,'y')
# labels and legend
pl.xlabel("hue")
pl.ylabel("r, g, b, y value")
pl.title("new hsv_to_rgby(hue,1,1)")
pl.margins(0,0.01)
pl.plot(hue,r,'r',hue,g,'g',hue,b,'b',linewidth=4)
pl.plot(hue,a,'y',linewidth=4)
pl.legend(['red','green','blue','amber'],loc=4)
pl.show()

So now hue=1/6 is pure amber, and our (amber+green) yellow has moved back to 1/4 from 1/3. Pure green is now hue=5/12 and pure blue is 9/12.