Selecting visually different RGB colors

From a news:comp.lang.tcl posting by Frederic Bonnet:

Rolf Schroedter wrote: How can I define N visually different screen (RGB) colors? N is defined at runtime in the range of 20..200. I guess the question is about color spaces. Which color space does best meet the human eye? How to select N equally spaced colors in that color space? How to convert to RGB?

This is not the point of view of a graphics professional, only my limited experience. I had to display 3D structures using distinct "equally spaced" colors. My opinion is that the HSB and HLS color spaces best meet the human mind, and also maybe the human eye. We are used to represent colors as 3 main composants: the Hue (red, green, yellow, ie the rainbow colors), the Luminosity or Brightness (dark, light), and the Saturation (pastel, deep). Hue correspond to the color's wave length, Luminosity to the color's amplitude, Brightness to the color's perceptual luminosity (at equal luminosity, green looks brighter than red), and Saturation to the light's purity.

The two most important composants being the Hue and Brightness due to the structure of the human retina, my guess is that you can create contrasted colors by interpolating H and B, leaving S to its maximum (every color tends to white when it is desaturated, so the contrast tends to decrease). Also, the perceptual contrast decreases with the brighness (darker colors look closer than brighter).

There are subtle differences between HLS and HSB. IIRC, B is proportional to the sum of the weighted RGB values, whereas L is just the maximum value. For example, the HSB color with maximum B would be white whereas RGB colors red (1,0,0), green (0,1,0) and yellow (1,1,0) would have the same L value. B is closer to the perceptual feeling of brightness because yellow looks brighter than green, which looks brighter than red. The HLS color space looks like a cone. The lower tip is black, the upper disk is the famous "color wheel". The HSB color space looks like 2 cones: the lower tip is black, the upper tip is white, the color wheel is at the middle.

For your case, you can perfectly use HLS, which is simpler to compute. Here is a small Tcl proc that does HLS to RGB conversion. Then interpolate values on H and L. Also remember than with low L values, colors look less contrasted.

proc hls2rgb {h l s} {
    # h, l and s are floats between 0.0 and 1.0, ditto for r, g and b
    # h = 0   => red
    # h = 1/3 => green
    # h = 2/3 => blue 

    set h6 [expr {($h-floor($h))*6}]
    set r [expr {  $h6 <= 3 ? 2-$h6
                            : $h6-4}]
    set g [expr {  $h6 <= 2 ? $h6
                            : $h6 <= 5 ? 4-$h6
                            : $h6-6}]
    set b [expr {  $h6 <= 1 ? -$h6
                            : $h6 <= 4 ? $h6-2
                            : 6-$h6}]
    set r [expr {$r < 0.0 ? 0.0 : $r > 1.0 ? 1.0 : double($r)}]
    set g [expr {$g < 0.0 ? 0.0 : $g > 1.0 ? 1.0 : double($g)}]
    set b [expr {$b < 0.0 ? 0.0 : $b > 1.0 ? 1.0 : double($b)}]

    set r [expr {(($r-1)*$s+1)*$l}]
    set g [expr {(($g-1)*$s+1)*$l}]
    set b [expr {(($b-1)*$s+1)*$l}]
    return [list $r $g $b]
}

DKF: As a side note, if you set the Saturation to 0 then you get no variation in colour as the Hue varies, and changing the Luminosity gives you different shades of grey.


Conversions using the HSV color model

The HSV color model can be visualized as a cone standing on its tip. The top surface of the cone is the color circle. The Hue value is expressed as an angle on the top surface. The six basic colors correspond to angles (in degrees) as follows:

angle   color
   0     red
  60     yellow
 120     green   
 180     cyan
 240     blue
 300     magenta

Colors 180 degrees apart are complementary.

If the color is exactly on the vertical axis (see below) the color is part of a grayscale and has no hue at all.

The Saturation (purity) of the color is the distance from the vertical axis of the cone to the color on the top surface. It varies from 0 (exactly on the vertical axis; a grayscale color) to 1 (at the edge of the top surface; a pure color).

The Value (intensity) of the color is the distance from the tip of the cone to the color. It varies from 0 (at the cone's tip; the color black) to 1 (on the top surface).

# rgb2hsv --
#
#       Convert a color value from the RGB model to HSV model.
#
# Arguments:
#       r g b  the red, green, and blue components of the color
#               value.  The procedure expects, but does not
#               ascertain, them to be in the range 0 to 1.
#
# Results:
#       The result is a list of three real number values.  The
#       first value is the Hue component, which is in the range
#       0.0 to 360.0, or -1 if the Saturation component is 0.
#       The following to values are Saturation and Value,
#       respectively.  They are in the range 0.0 to 1.0.
#
# Credits:
#       This routine is based on the Pascal source code for an
#       RGB/HSV converter in the book "Computer Graphics", by
#       Baker, Hearn, 1986, ISBN 0-13-165598-1, page 304.
#

proc rgb2hsv {r g b} {
    set h [set s [set v 0.0]]]
    set sorted [lsort -real [list $r $g $b]]
    set v [expr {double([lindex $sorted end])}]
    set m [lindex $sorted 0]
    
    set dist [expr {double($v-$m)}]
    if {$v} {
        set s [expr {$dist/$v}]
    }
    if {$s} {
        set r' [expr {($v-$r)/$dist}] ;# distance of color from red
        set g' [expr {($v-$g)/$dist}] ;# distance of color from green
        set b' [expr {($v-$b)/$dist}] ;# distance of color from blue
        if {$v==$r} {
            if {$m==$g} {
                set h [expr {5+${b'}}]
            } else {
                set h [expr {1-${g'}}]
            }
        } elseif {$v==$g} {
            if {$m==$b} {
                set h [expr {1+${r'}}]
            } else {
                set h [expr {3-${b'}}]
            }
        } else {
            if {$m==$r} {
                set h [expr {3+${g'}}]
            } else {
                set h [expr {5-${r'}}]
            }
        }
        set h [expr {$h*60}]          ;# convert to degrees
    } else {
        # hue is undefined if s == 0
        set h -1
    }
    return [list $h $s $v]
}

# hsv2rgb --
#
#       Convert a color value from the HSV model to RGB model.
#
# Arguments:
#       h s v  the hue, saturation, and value components of
#               the color value.  The procedure expects, but
#               does not ascertain, h to be in the range 0.0 to
#               360.0 and s, v to be in the range 0.0 to 1.0.
#
# Results:
#       The result is a list of three real number values, 
#       corresponding to the red, green, and blue components
#       of a color value.  They are in the range 0.0 to 1.0.
#
# Credits:
#       This routine is based on the Pascal source code for an
#       HSV/RGB converter in the book "Computer Graphics", by
#       Baker, Hearn, 1986, ISBN 0-13-165598-1, page 304.
#

proc hsv2rgb {h s v} {
    set v [expr {double($v)}]
    set r [set g [set b 0.0]]
    if {$h == 360} { set h 0 }
    # if you feed the output of rgb2hsv back into this
    # converter, h could have the value -1 for
    # grayscale colors.  Set it to any value in the
    # valid range.  
    if {$h == -1} { set h 0 }
    set h [expr {$h/60}]
    set i [expr {int(floor($h))}]
    set f [expr {$h - $i}]
    set p1 [expr {$v*(1-$s)}]
    set p2 [expr {$v*(1-($s*$f))}]  
    set p3 [expr {$v*(1-($s*(1-$f)))}]
    switch -- $i {
        0 { set r $v  ; set g $p3 ; set b $p1 }
        1 { set r $p2 ; set g $v  ; set b $p1 }
        2 { set r $p1 ; set g $v  ; set b $p3 }
        3 { set r $p1 ; set g $p2 ; set b $v  }
        4 { set r $p3 ; set g $p1 ; set b $v  }
        5 { set r $v  ; set g $p1 ; set b $p2 }
    }
    return [list $r $g $b]
}

proc distinctHueLabels {n} {
    eval destroy [info commands .b*]
    set inc [expr {1.0/$n}]
    set s 1.0
    set l 1.0

    set nn 0
    for {set h 0.0} {$h < 1.0} {set h [expr {$h + $inc}]} {   
        button .b$nn -bg [hls2tk $h $l $s] -width 10 -height 1
        pack .b$nn ; incr nn
    }
}
# this one needs some work
proc distinctLabels {n} {
    eval destroy [info commands .b*]

    set ns [expr {int(sqrt($n)+0.999)}]
    set inc [expr {1.0/$ns}]
    set s 1.0

    set nn 0
    for {set l 1.0} {$l > 0.3} {set l [expr {$l - 0.7*$inc}]} {
        for {set h 0.0} {$h < 1.0} {set h [expr {$h + $inc}]} {
            button .b$nn -bg [hls2tk $h $l $s] -width 10 -height 1
            pack .b$nn ; incr nn
            if {$nn == $n} { break }
        }
    }
}



proc hls2tk {h l s} {
    set rgb [hls2rgb $h $l $s]
    foreach c $rgb {
       set intc [expr {int($c * 256)}]
       if {$intc == 256} { set intc 255 }
       set c1 [format %1X $intc]
       if {[string length $c1] == 1} {set c1 "0$c1"}
       append init $c1
    }
    return #$init
}

John Droggitis

nother take on John's proc distinctLabels, works better for me WRT producing visually distinguishable colors

proc distinctLabels2 {n} {
    eval destroy [info commands .b*]
    set nn 1
    set hue_increment .15
    set s 1.0 ;# non-variable saturation

    set lum_steps [expr $n * $hue_increment]
    set int_lum_steps [expr int($lum_steps)]
    if {$lum_steps > $int_lum_steps} { ;# round up
        set lum_steps [expr $int_lum_steps + 1]
    }
    set lum_increment [expr .7 / $lum_steps]

    # assertion 1: we can get 7 disctinct hues, and the rest of the
    #   variants should be controlled by luminocity
    # assertion 2: luminocity should stay above .3 otherwise colors get
    #  too dark to distinguish
  
    for {set l 1.0} {$l > 0.3} {set l [expr {$l - $lum_increment}]} {
        for {set h 0.0} {$h < 1.0} {set h [expr {$h + $hue_increment}]} {
            button .b$nn -bg [hls2tk $h $l $s] -width 10 -height 1
            pack .b$nn ; incr nn
            if {$nn > $n} { return }
        }
    }
}

#
# Same as distinctLabels2, but it returns a list
# of colors rather than drawing buttons
#
proc distinctColors {n} {
    set nn 1
    set hue_increment .15
    set s 1.0 ;# non-variable saturation

    set lum_steps [expr $n * $hue_increment]
    set int_lum_steps [expr int($lum_steps)]
    if {$lum_steps > $int_lum_steps} { ;# round up
        set lum_steps [expr $int_lum_steps + 1]
    }
    set lum_increment [expr .7 / $lum_steps]

    for {set l 1.0} {$l > 0.3} {set l [expr {$l - $lum_increment}]} {
        for {set h 0.0} {$h < 1.0} {set h [expr {$h + $hue_increment}]} {
            lappend rc [hls2tk $h $l $s]
            incr nn
            if {$nn > $n} { return $rc }
        }
    }
    return $rc
}

What about the specific case of a pair of colors, conceived as background and foreground? Here are a few ideas:

  • Arjen Markus suggests sending (r, g, b) :-> (f(r), f(g), f(b)), with f(x) = 255 for small x, f(x) = 0 for large x, and pick-a-heuristic for intermediate values, with bright (white) the tie-breaker.
  • think about tk_setPalette.
  • study color wheel work of Johaness Itten.
  • see the Cookbook on "color manipulation" [L1 ].
  • Felix Lee suggests
proc contrasting {w color {colors {black white}}} {
    ## Return a color in $colors that contrasts with
    ## $color in window $w.  Checks luminance.

    set y0 [luminance $w $color]
    set best ""
    set diff 0
    foreach c $colors {
        set y [luminance $w $c]
        set d [expr {abs($y - $y0)}]
        if {$diff < $d} {
            set best $c
            set diff $d
        }
    }
    return $best
}

kbk: "A good rule of thumb is to take your background colour and compute Y = 0.3R + 0.59G + 0.11B; if Y exceeds 0.5, use black foreground text, otherwise use white."

dkf: "I don't use those numbers; I just look at and think, 'is the background "light" or "dark"'.".

...

jenglish: "Using Paul Haeberli's coefficients for luminance (http://www.sgi.com/grafica/matrix/index.html ) instead of the NTSC standard seems to work a little better."

kbk: "... You can get closer by considering the gamma-corrected values. Yes, Y=0.5 as the breakpoint may switch to white too early, but it's At Least Good Enough - the foreground text is at least readable. NTSC presumes γ=2.2 and monitors today are "hotter" than when the NTSC standard was designed."