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 (466 Bytes) Viewed 24798 times
Rainbow with perceptually uniform hues:
- perceptual.png (520 Bytes) Viewed 24798 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