HSV gives varying brightness

I have finally connected my PB to my back garden lights!

It is set up so that I can switch between my home built controller, and the PB.

One thing that I noticed straight away was that the apparent brightness of the LED’s varies significantly when using the hsv() function in PB.

My home built controller uses FASTLed.

This is my pattern:

/* Fade HSI from M0
void do_fadergb() {
  if(mode_from_mqtt) parse_fadergb();
  static uint8_t hue = 0;
  static CEveryNMillis UpdateTime(timing*5/fps);

  if(UpdateTime) {
    leds(min_led, max_led) = CHSV(hue,saturation,max_brightness);

    hue += hue_increment;
var power24v = 26
export var relay_status

export function sliderRelay(x) {
  digitalWrite(power24v, (x > 0.5)) 

var PATHLIGHT_LED = 1223  //first LED that covers the path ( this is the fence post - up to 12 before this is the actual path)

export var max_brightness = 1
export var fps = 1
export var hue_increment = 1
export var min_brightness = 0
export var saturation = 1
export var pathlight = 0
export var hue = 0

export function sliderStep(v) {
    hue_increment = clamp(v, 0.01, 1)

export function sliderSpeed(v) {
    fps = clamp(v, 0.01, 1)

export function sliderMaxBrightness(v) {
    max_brightness = clamp(v, min_brightness, 1)

export function sliderMinBrightness(v) {
    min_brightness = clamp(v, 0, max_brightness)

export function sliderPathlight(v) {
    pathlight = v

export function beforeRender(delta) {
  relay_status = digitalRead(power24v)
  hue = time(1/(fps+hue_increment))

export function render(index) {
  s = saturation
  v = max_brightness
  h = hue
  if (pathlight > 0 && index >= PATHLIGHT_LED) {
    s = 0
    v = pathlight
  hsv(h, s, v*v*v) //v*v*v is gamma correction (ish)

The top comment is the equivalent FASTLed routine.

What happens is that as hue increments, the red increases to maximum, then blue increases to maximum, red reduces to minimum, then green increases to maximum etc.

From this you can see that red is just red at max, but magenta is red and blue at maximum, same for green/yellow and blue/cyan.

This means that magenta, yellow and cyan are much brighter than red, green or blue.

I am reading the RGB values from the first three bytes of the preview frames.

What I expected to happen is that as blue increases, red decreases to maintain the set brightness (1.0) - so magenta would be 0.5 red and 0.5 blue.

So, is this how it works? Are my eyes deceiving me (and they might be, green looks much brighter at night than red for the same brightness setting), are the RGB representations in the preview frames accurate or approximations of the hsv values?

Is there another hsv() function that I should be using if I want constant brightness?

I’ve attached a screenshot of my controller interface so you can see what I mean, the blue is on the way up, while green stays constant at max.


No, your eyes aren’t deceiving you, it’s a gamma correction issue.

Eyes aren’t equally sensitive to all colors.

You might want to use RGB instead of HSV if your goal is find some way to maintain equal brightness no matter what color is picked.

I understand the gamma correction issue.

What I’m saying is that I don’t get the same effect using FASTLed’s CHSV function.

So, are the RGB values in the preview frames just estimates of the actual colour (so the colour is displayed correctly in the web page)?

Or is magenta really red and blue at max, and not 0.5 each?

Magenta is a ratio of red to blue… So it’s both.

If you are asking if hsv(magenta,1,1) only powers the led at 50% red, 50% blue, rather than 100% red 100% blue, then what would 100% of those two be? Double magenta? No, it makes sense that it’s 100% red/blue and brighter. You want constant brightness, I suspect you to either need adjust based on hue (or use RGB)

@wizard can explain what his color code logic does though.

This is what I’m talking about.

maybe this should be in the “requested functions” Topic, but it would be good to include HCL as a rendering option along with HSV and RGB.

1 Like

Excellent article, including a good equation for calculation of relative brightness.

The HCL picker is ugly and awkward.

I’d say adding a correction function makes way more sense. We already control brightness globally.

It’s @wizard’s call, but from a pragmatic standpoint, given the many and varying sources of non linearity in ad-hoc LED systems like this, I’d keep HSV as it is. The problem certainly exists, but the effect is hard to notice in actual application.

I suspect it’s because the human visual system saturates easily at high brightness levels, and we honestly can’t see much difference between bright magenta and slightly brighter magenta. It might stick out more at low brightness, but then you run into the… wow… fairly extreme non linear color behavior of dim LEDs. )

If I was building an image display panel with accurate color representation as a design goal, I’d obviously feel differently – I’m certainly not opposed to adding an additional brightness corrected API, or a gamma correction “map” somewhere down the road.

I’m glad you linked this! I was going to, but you beat me to it!

It’s been rolling around in the back of my brain for some time. And at some point I’d like to add HCL or something with an even perceptual brightness scale.

The actual output of this depends heavily on the LEDs, and the variant/clone, and even the batch/bin. LEDs have variation when manufactured and are often measured then binned with like LEDs. LEDs have a nonlinear current brightness response, which shows up in the way Sk9822 and APA102 act differently when global brightness is used in HDR. Some addressable LEDs have built in gamma correction.

It’s a hard problem to get perfect, or nearly perfect, and there are a LOT of variations to take into account.

It would be a lot higher on the list if we were displaying images and video, where color accuracy and gamma correction would be very apparent.

As it is, the primary focus of Pixelblaze is generative/procedural textures/art, and the appearance on the LEDs is what the artist/creator produced and is de facto correct.

In early days I had fastLED in Pixelblaze and you could switch between “computational” and fastLED’s “perceptual” rainbows. I removed it because it was incompatible with HDR and RGBW, and perhaps more importantly changed the appearance of patterns significantly. I didn’t like the idea that the same “pattern” might look so different based on settings and didn’t want people to have to mess with settings to replicate a look or wonder why the preview didn’t match the LEDs.

Patterns often square or cube the value to increase perceptual contrast (or a proxy for gamma correction). Adding it as setting on top of that would change existing patterns appearance (or require code changed to the pattern).

So my plan would be to add APIs for things like gamma or HCL, or a new perceptual HSV. More tools for the creator to use.

I may add a global setting to adjust LED type specific adjustments so that patterns render more consistently across LED types.

1 Like

oooh, long lost history… good to know.

I present to you flatHsv:

 * This adjusts for the additional RGB LED power that hsv() would output 
 * as it rotates through a hues and saturations.
 * For example in HSV full brightness yellow is created by mixing 100% red and 100% green. 
 * This will adjust yellow to 50% red + 50% green so that the sum is still 100%.
 * Saturation is taken into account so that colors maintain relative power towards whites.
 * Therefore full brightness white is limited to 33% red + 33% green + 33% blue to maintain 
 * a 100% total.
 * This does not correct for the perceptual brightness of red, green, and blue as
 * the human eye would perceive them. See
 * https://en.wikipedia.org/wiki/CIELAB_color_space
function flatHsv(h, s, v) {
  var compensationCurve = triangle(h*3 + .5) * .5 + .5
  s = clamp(s, 0, 1)
  v = clamp(v, 0, 1)
  v = v * (compensationCurve * s + (1-s)) * ((1 + s*2)/3)
  hsv(h, s, v)

@wizard I will give that a go - looks just like what I was looking for!

In the mean time here is my implementation of FASTLed’s hsv2rgb_rainbow in all it’s ugliness (I did get tripped up by 16 bit numbers! apparently 208 * 170 is not what you think it is)

/* FASTLed functions */
// NOTE: all parameters (h,s,v) have range 0-255

function scale8(i, scale) {
  return i/256 * scale

function scale8_video(i, scale) {
  var j = (i/256 * scale) + ((i&&scale)?1:0)
  //var nonzeroscale = (scale != 0) ? 1 : 0;
  //var j = (i == 0) ? 0 : ((i/256 * scale)) + nonzeroscale;
  return j

function hsv2rgb_rainbow(h, s, v)
    // Yellow has a higher inherent brightness than
    // any other color; 'pure' yellow is perceived to
    // be 93% as bright as white.  In order to make
    // yellow appear the correct relative brightness,
    // it has to be rendered brighter than all other
    // colors.
    // Level Y1 is a moderate boost, the default.
    // Level Y2 is a strong boost.
    var Y1 = 1
    var Y2 = 0
    // G2: Whether to divide all greens by two.
    // Depends GREATLY on your particular LEDs
    var G2 = 0
    // Gscale: what to scale green down by.
    // Depends GREATLY on your particular LEDs
    var Gscale = 0

    var hue = h*255
    var sat = s*255
    var val = v*255
    var offset = hue & 0x1F // 0..31
    // offset8 = offset * 8
    var offset8 = offset * 8
    var third = scale8( offset8, 85) // max = 85
    var twothirds = scale8( offset8, 170) // max=170
    //var twothirds = third * 2
    var r1, g1, b1
    if( ! (hue & 0x80) ) {
        // 0XX
        if( ! (hue & 0x40) ) {
            // 00X
            //section 0-1
            if( ! (hue & 0x20) ) {
                // 000
                //case 0: // R -> O
                r1 = 255 - third
                g1 = third
                b1 = 0
            } else {
                // 001
                //case 1: // O -> Y
                if( Y1 ) {
                    r1 = 171
                    g1 = 85 + third
                    b1 = 0
                if( Y2 ) {
                    r1 = 170 + third
                    g1 = 85 + twothirds
                    b1 = 0
        } else {
            // section 2-3
            if( !  (hue & 0x20) ) {
                // 010
                //case 2: // Y -> G
                if( Y1 ) {
                    r1 = 171 - twothirds
                    g1 = 170 + third
                    b1 = 0
                if( Y2 ) {
                    r1 = 255 - offset8
                    g1 = 255
                    b1 = 0
            } else {
                // 011
                // case 3: // G -> A
                r1 = 0
                g1 = 255 - third
                b1 = third
    } else {
        // section 4-7
        // 1XX
        if( ! (hue & 0x40) ) {
            // 10X
            if( ! ( hue & 0x20) ) {
                // 100
                //case 4: // A -> B
                r1 = 0
                g1 = 171 - twothirds //170?
                b1 = 85  + twothirds
            } else {
                // 101
                //case 5: // B -> P
                r1 = third
                g1 = 0
                b1 = 255 - third
        } else {
            if( !  (hue & 0x20)  ) {
                // 110
                //case 6: // P -- K
                r1 = 85 + third
                g1 = 0
                b1 = 171 - third
            } else {
                // 111
                //case 7: // K -> R
                r1 = 170 + third
                g1 = 0
                b1 = 85 - third
    // This is one of the good places to scale the green down,
    // although the client can scale green down as well.
    if( G2 ) {g1 /= 2}
    if( Gscale ) {g1 = scale8_video( g1, Gscale)}
    // Scale down colors if we're desaturated at all
    // and add the brightness_floor to r, g, and b.
    if( sat != 255 ) {
        if( sat == 0) {
            r1 = 255
            b1 = 255
            g1 = 255
        } else {
            if( r1 ) r1 = scale8( r1, sat)
            if( g1 ) g1 = scale8( g1, sat)
            if( b1 ) b1 = scale8( b1, sat)
            var desat = 255 - sat
            desat = scale8( desat, desat)
            var brightness_floor = desat
            r1 += brightness_floor
            g1 += brightness_floor
            b1 += brightness_floor
    // Now scale everything down if we're at value < 255.
    if( val != 255 ) {
        val = scale8_video( val, val)
        if( val == 0 ) {
        } else {
            if( r1 ) r1 = scale8( r1, val)
            if( g1 ) g1 = scale8( g1, val)
            if( b1 ) b1 = scale8( b1, val)
    r = r1/255
    g = g1/255
    b = b1/255

/* End FASTLed Functions */

This implements FastLED rainbow

Don’t know exactly what it will look like, I’ll try both of them tonight when it gets dark.

Having FASTLed available on PB would be awesome (as an alternative api say), I’m so used to using it. I understand you want beginners to be comfortable, but don’t forget the more experienced people either!

Maybe prefix FASTLed routines with FL or something - that way we get all the hsv2rgb and rgb2hsl etc. we would like - and super fast as well.


flatHSV() is interesting… I just had a rainbowed pattern (coming soon, complex numbers making this so much fun), and added a Flat slider (no toggle yet!), and it’s very interesting to see the stark difference, and loss of certain colors, much less yellow, for example. It’s still there, but it narrows quite a bit.

and now, I’ve added hsv2rgb_rainbow to that slider, so I can decide which of the 3 to use. And it’s got it’s own plus and minuses. More reds, reasonable yellows. Definitely huge FPS slowdown (like almost 20 FPS slower on 256 pixels, @wizard 's is about 8 FPS slowdown)
Nice work, though.

Yes, I was just switching between the two. FlatHSV pretty much does what I want.

hsv2rgb_rainbow does have a better yellow, but the processing hit is significant.

I’m running 1440 pixels, with flatHSV I get 19 fps, with hsv2rgb_rainbow it’s about 12.

I’m running APA102’s at 1Mhz. It tends to get glitchy at the end of the run, if I run it at 2Mhz.

It’s not a problem though, I’m not running fast patterns across the garden, it’s more slowly changing effects.

Some sort of gamma correction would be useful though. Maybe a way of uploading your own gamma tables, that you can tune to your own strips even - say a few “canned” ones for common strips, plus a “custom” one.

Anyway, things are looking good now. Thanks for the quick turnaround on flatHSV!

Nick, thanks for porting that!

While we are discussing this (I too have been chasing this with the somewhat disappointing implementation of HSLuv), I was wondering -

Does anybody know - Is there a <$100 solution to color calibrate for a given RGB LED? For example, are there individual LEDs with reliable reference specs that we can use as a spectrometer to plot a curve for whatever cheap WS281X we just bought 1000 of?

While we’re on the topic of HSV, RGB and so on…

@wizard we don’t need a built in but if you want a quick win:

HSL (which is more like paint mixing than light mixing) is relatively easy to convert to HSV

So we can use a quick hsl2hsv function for it, like this one

But if you add a built in hsl(), that would be nice.

Added: Ah, it’s a really simple conversion if it’s needed. I’m using code
I found here http://jsfiddle.net/Lamik/ba9vgj7L to do this:
HSL quicky - not quite HSV

This is a picture of our back garden, with PB controlling the lights. Using hsv2rgb_rainbow.

Notice how the deck lights follow the fence lights colour.

Full cycle takes about 15 minutes.


I could add this a few places but here makes sense as it’s based on the eye sensitivity curves

(And the following part as well)

Does it in shader speak but the principle is the same. I’ll likely add this as part of the palette stuff from IQ.

1 Like