Color wheel corrected for human discernment

For all coding issues - MODers and programmers, HTML and more.

Moderators: Jeff250, fliptw

Post Reply
User avatar
Jeff250
DBB Master
DBB Master
Posts: 6387
Joined: Sun Sep 05, 1999 2:01 am
Location: ☃☃☃

Color wheel corrected for human discernment

Post by Jeff250 » Thu Dec 04, 2014 6:21 pm

I'm making for myself some hiking maps in the form of *.kml files that I can import into Google Earth. I wanted each hiking trail to have a different color, and so I assigned each trail a random color from the rainbow. Specifically, if there are n trails, then for each trail, I randomly choose a color (without replacement) from the sequence of colors printed by the following python function:

Code: Select all

import colorsys

def rainbow(n):
    for i in xrange(n):
        rgb = colorsys.hsv_to_rgb(float(i) / n, 1.0, 1.0)
        rgb = tuple(round(x * 255) for x in rgb)
        print '#%.2x%.2x%.2x' % rgb
In this sequence, each color has approximately equidistant hue from its neighbors. In the maps I'm creating, most trails have many intersections and many other nearby trails in general. One thing I noticed is if two nearby trails colored in the purplish-reddish part of the spectrum happened to intersect each other, it was difficult to tell the trails apart, whereas this problem didn't exist in other parts of the spectrum, such as the yellowish-greenish part. I suspect that human vision is better at discerning between hues in some parts of the spectrum versus others. So how would I write a function similar to the above that, instead of generating colors with equidistant hues, generates colors with hues with distance such that each hue is equally discernible from its neighbors? For instance, in such a sequence, I'd imagine there would be fewer reddish-purplish colors, since they need to be of greater hue distance to tell apart, whereas there would be more yellowish-greenish colors, since they don't need as much hue distance to tell apart.

Another solution to the map problem in general is to try assign colors to trails not entirely randomly but such that trails that intersect each other or are otherwise nearby each other don't have similar colors, but I think that even if this solution were employed, coloring trails with the most easily discernible hues would still be valuable, for instance, for creating a map legend. Plus, this seems like an interesting problem that must have a known solution. But I haven't been able to come across anything so far. Any thoughts?
User avatar
Foil
DBB Material Defender
DBB Material Defender
Posts: 4900
Joined: Tue Nov 23, 2004 3:31 pm
Location: Denver, Colorado, USA

Re: Color wheel corrected for human discernment

Post by Foil » Fri Dec 05, 2014 10:32 am

A few thoughts, in no particular order:
  • Have you tried this on various monitors and in various lighting? I'm curious about whether the display is a factor in the difference in discernment in the different areas of the spectrum.
  • I currently work in aeronautical charting applications, and I can tell you that maximizing color-distance is pretty critical. That said, I don't know of any well-defined methods for achieving it. [e.g. Our interface design group at my workplace tweaks the coloring schemes for varying light conditions (day vs. night) from user experience; I don't know of any structured system they're using.]
  • You mentioned finding some kind of non-"linear" color-generation that weights less heavily on the red/purple area of the spectrum, but I honestly think your other solution (finding a mechanism to prevent near-colored routes in close proximity) would be more promising. Of course, that's not a trivial problem by any means...
User avatar
Jeff250
DBB Master
DBB Master
Posts: 6387
Joined: Sun Sep 05, 1999 2:01 am
Location: ☃☃☃

Re: Color wheel corrected for human discernment

Post by Jeff250 » Thu Dec 11, 2014 3:10 pm

I found that there are colorspaces such as CIELAB and CIELUV that are perceptually uniform, that is, the human perceptual difference between colors in these spaces is the euclidean distance between the colors. So one way to solve this problem would be to choose maximally equidistant points in these spaces, but I don't think this is easy, as these colorspaces don't have simple shapes.

What I ended up returning to was the original problem of generating a perceptually uniform rainbow of n colors. First, I needed a perceptual distance metric. One such metric could be to just convert colors to the CIELAB colorspace and calculate their euclidean distance, but I ended up using the simpler metric proposed here:

http://www.compuphase.com/cmetric.htm

Using this distance metric, for a given n, I perform binary search over perceptual distances to find the perceptual distance that evenly divided the rainbow into n divisions. To find each successive hue with perceptual distance d from the previous, I use binary search over hues.

Here are the results...

Rainbow with linear hues:
rainbow.png
rainbow.png (466 Bytes) Viewed 1209 times
Rainbow with perceptually uniform hues:
perceptual.png
perceptual.png (520 Bytes) Viewed 1209 times
This approach seems to have shrunk the purply-reddish part of the spectrum, like I was hoping it would. It also seems to have shrunk the green and the blue parts of the spectrum too, which I hadn't noticed in my maps as being a problem, but looking at the original rainbow, it seems like this should be helpful too. Maybe a different distance metric would have even better results?

Here's the code:

Code: Select all

#!/usr/bin/env python

from math import sqrt
import sys

BLACK = 0.0, 0.0, 0.0
WHITE = 1.0, 1.0, 1.0

def distance(rgb1, rgb2):
    r1, g1, b1 = rgb1
    r2, g2, b2 = rgb2
    rmean = (r1 + r2) / 2.0
    rdiff = (r1 - r2) * 256
    gdiff = (g1 - g2) * 256
    bdiff = (b1 - b2) * 256
    rweight = 2 + rmean
    gweight = 4.0
    bweight = 2 + (1.0 - rmean)
    return sqrt(rweight * rdiff * rdiff +
                gweight * gdiff * gdiff +
                bweight * bdiff * bdiff)

MAX_DIST = distance(BLACK, WHITE)

def rgb_to_hex(rgb):
    return '#%.2x%.2x%.2x' % tuple(round(x * 255) for x in rgb)

def hue_to_rgb(h):
    i = int(h * 6.0)
    f = h * 6.0 - i
    g = 1.0 - f
    i %= 6
    if i == 0:
        return 1.0, f, 0.0
    elif i == 1:
        return g, 1.0, 0.0
    elif i == 2:
        return 0.0, 1.0, f
    elif i == 3:
        return 0.0, g, 1.0
    elif i == 4:
        return f, 0.0, 1.0
    else: # i == 5
        return 1.0, 0.0, g

def next_hue(h, desired_distance):
    h = max(0.0, min(1.0, h))
    rgb1 = hue_to_rgb(h)
    # Perform binary search over hues to find hue with desired perceptual
    # distance from h...
    lo = h
    hi = min(1.0, h + 0.5)
    while hi - lo >= 1e-12:
        mid = (lo + hi) / 2
        rgb2 = hue_to_rgb(mid)
        dist = distance(rgb1, rgb2)
        if dist <= desired_distance:
            lo = mid
        if dist >= desired_distance:
            hi = mid
    return hi

def rainbow(n):
    return [hue_to_rgb(float(i) / n) for i in xrange(n)]

def perceptual_rainbow(n):
    n = max(0, n)
    # Perform binary search over perceptual distance to find perceptual
    # distance that evenly divides all hues into n divisions...
    hues = [0.0] * (n + 1)
    lo = 0.0
    hi = MAX_DIST
    while hi - lo >= 1e-12:
        mid = (lo + hi) / 2
        for i in xrange(1, n + 1):
            h = next_hue(hues[i - 1], mid)
            hues[i] = h
        if hues[n] == 1.0:
            hi = mid
        else:
            lo = mid
    return [hue_to_rgb(hues[i]) for i in xrange(n)]

def usage():
    sys.stderr.write('Usage: %s N\n' % sys.argv[0])
    sys.exit(1)

def main():
    if len(sys.argv) != 2:
        usage()
    try:
        n = int(sys.argv[1])
    except ValueError:
        usage()
    else:
        for rgb in perceptual_rainbow(n):
            print rgb_to_hex(rgb)

if __name__ == '__main__':
    main()
Example usage:

Code: Select all

$ python rainbow.py 10
#ff0000
#ff7200
#ffe500
#78ff00
#00ff55
#00ffd9
#0091ff
#001fff
#9200ff
#ff00a2
User avatar
snoopy
DBB Benefactor
DBB Benefactor
Posts: 4434
Joined: Thu Sep 02, 1999 2:01 am

Re: Color wheel corrected for human discernment

Post by snoopy » Fri Dec 12, 2014 7:19 am

Another idea/suggestion:

Why not try to take advantage of the other dimensions in your color space? You would want to stay away from 0 saturation and 0 value, but if you treat spacing in a 3-dimensional sense (human-corrected or no), you're crippling yourself by using only a single ring.
Arch Linux x86-64, Openbox
"We'll just set a new course for that empty region over there, near that blackish, holeish thing. " Zapp Brannigan
User avatar
Jeff250
DBB Master
DBB Master
Posts: 6387
Joined: Sun Sep 05, 1999 2:01 am
Location: ☃☃☃

Re: Color wheel corrected for human discernment

Post by Jeff250 » Fri Dec 12, 2014 8:51 am

One good way to do that if I knew how would be to choose maximally equidistant points in the CIELAB colorspace. It's just not clear to me how to do that though. As a heuristic though, I could, say, add 50% darkness to the odd-numbered colors on the generated rainbow, and see where that goes.
Post Reply