4

I'm trying to create an algorithm that will output a set of different RGB color values, that should be as distinct as possible. For Example:

following a set of 3 colors:

  1. (255, 0, 0) [Red]
  2. (0, 255, 0) [Green]
  3. (0, 0, 255) [Blue]

the next 3 colors would be:

  1. (255, 255, 0) [Yellow]
  2. (0, 255, 255) [Cyan]
  3. (255, 0, 255) [Purple]

The next colors should be in-between the new intervals. Basically, my idea is to traverse the whole color spectrum systematic intervals similar to this:

enter image description here

A set of 13 colors should include the color in between 1 and 7, continue that pattern infinitely.

I'm currently struggling to apply this pattern to an algorithm to RGB values as it does not seem trivial to me. I'm thankful for any hints that can point me to a solution.

T A
  • 1,677
  • 4
  • 21
  • 29
  • 1
    Some ideas to consider... 1) black and white are the two colours furthest apart on the colour cube but you don't have them 2) furthest apart depends on the observer, red-green colour-blindness is remarkably common 3) you may like to look at HSL colourspace and keep Saturation and Value maximised and then just split the 360 Hue circle into however many colours you want - that is more or less what you are intuitively already doing. – Mark Setchell May 28 '19 at 09:30
  • 1
    HSL is described here https://en.wikipedia.org/wiki/HSL_and_HSV and you have effectively chosen 0/120/240 degrees on the lower part of this diagram for your first 3 values, then added 60/180/300 for your second 3 values in the lower part of this diagram https://en.wikipedia.org/wiki/HSL_and_HSV#/media/File:HSL-HSV_hue_and_chroma.svg – Mark Setchell May 28 '19 at 09:41
  • @MarkSetchell thanks for your input! I was thinking about using HSV instead as well, but I am still wondering how to solve this in RGB space. Also, you are right with your first comment; *highest diversity* may be a bit ambiguous. – T A May 28 '19 at 10:08
  • 1
    What is it the purpose? Your choice would be bad for contrast, so bad to convey quick information to reader brain. So I would not recommend the solution in other comments (e.g. to change only hue), but for maps. – Giacomo Catenazzi May 28 '19 at 12:07
  • Take a look at this: [Multi-Band Image raster to RGB](https://stackoverflow.com/a/29575362/2521214) simply take visible spectra and divide it to `N` wavelengths/colors so you have N diverse colors (which can be also used as primary colors for multi band rendering as they sum to White and by their linear combination you can achieve any color). – Spektre May 29 '19 at 07:12

2 Answers2

3

The Wikipedia article on color difference is worth reading, and so is the article on a “low-cost approximation” by CompuPhase linked therein. I will base my attempt on the latter.

You didn't specify a language, so I'll write it in not optimized Python (except for the integer optimizations already present in the reference article), in order for it to be readily translatable into other languages.

n_colors = 25
n_global_moves = 32

class Color:
    max_weighted_square_distance = (((512 + 127) * 65025) >> 8) + 4 * 65025 + (((767 - 127) * 65025) >> 8)

    def __init__(self, r, g, b):
        self.r, self.g, self.b = r, g, b

    def weighted_square_distance(self, other):
        rm = (self.r + other.r) // 2  # integer division
        dr =  self.r - other.r
        dg =  self.g - other.g
        db =  self.b - other.b
        return (((512 + rm) * dr*dr) >> 8) + 4 * dg*dg + (((767 - rm) * db*db) >> 8)

    def min_weighted_square_distance(self, index, others):
        min_wsd = self.max_weighted_square_distance
        for i in range(0, len(others)):
            if i != index:
                wsd = self.weighted_square_distance(others[i])
                if  min_wsd > wsd:
                    min_wsd = wsd
        return min_wsd

    def is_valid(self):
        return 0 <= self.r <= 255 and 0 <= self.g <= 255 and 0 <= self.b <= 255

    def add(self, other):
        return Color(self.r + other.r, self.g + other.g, self.b + other.b)

    def __repr__(self):
        return f"({self.r}, {self.g}, {self.b})"


colors = [Color(127, 127, 127) for i in range(0, n_colors)]

steps = [Color(dr, dg, db) for dr in [-1, 0, 1]
                           for dg in [-1, 0, 1]
                           for db in [-1, 0, 1] if dr or dg or db]  # i.e., except 0,0,0
moved = True
global_move_phase = False
global_move_count = 0
while moved or global_move_phase:
    moved = False
    for index in range(0, len(colors)):
        color = colors[index]
        if global_move_phase:
            best_min_wsd = -1
        else:
            best_min_wsd = color.min_weighted_square_distance(index, colors)
        for step in steps:
            new_color = color.add(step)
            if new_color.is_valid():
                new_min_wsd = new_color.min_weighted_square_distance(index, colors)
                if  best_min_wsd < new_min_wsd:
                    best_min_wsd = new_min_wsd
                    colors[index] = new_color
                    moved = True
    if not moved:
        if  global_move_count < n_global_moves:
            global_move_count += 1
            global_move_phase = True
    else:
        global_move_phase = False

print(f"n_colors: {n_colors}")
print(f"n_global_moves: {n_global_moves}")
print(colors)

The colors are first set all to grey, i.e., put in the center of the RGB color cube, and then moved in the color cube in such a way as to hopefully maximize the minimum distance between colors.

To save CPU time the square of the distance is used instead of the distance itself, which would require the calculation of a square root.

Colors are moved one at a time, by a maximum of 1 in each of the 3 directions, to one of the adjacent colors that maximizes the minimum distance from the other colors. By so doing, the global minimum distance is (approximately) maximized.

The “global move” phases are needed in order to overcome situations where no color would move, but forcing all colors to move to a position which is not much worse than their current one causes the whole to find a better configuration with the subsequent regular moves. This can best be seen with 3 colors and no global moves, modifying the weighted square distance to be simply rd*rd+gd*gd+bd*bd: the configuration becomes

[(2, 0, 0), (0, 253, 255), (255, 255, 2)]

while, by adding 2 global moves, the configuration becomes the expected one

[(0, 0, 0), (0, 255, 255), (255, 255, 0)]

The algorithm produces just one of several possible solutions. Unfortunately, since the metric used is not Euclidean, it's not possible to simply flip the 3 dimension in the 8 possible combinations (i.e., replace r255-r and/or the same for g and/or b) to get equivalent solutions. It's probably best to introduce randomness in the order the color moving steps are tried out, and vary the random seed.

I have not corrected for the monitor's gamma, because its purpose is precisely that of altering the spacing of the brightness in order to compensate for the eyes' different sensitivity at high and low brightness. Of course, the screen gamma curve deviates from the ideal, and a (system dependent!) modification of the gamma would yield better results, but the standard gamma is a good starting point.

This is the output of the algorithm for 25 colors:

output for 25 colors as 5 by 5 color patches

Note that the first 8 colors (the bottom row and the first 3 colors of the row above) are close to the corners of the RGB cube (they are not at the corners because of the non-Euclidean metric).

Walter Tross
  • 12,237
  • 2
  • 40
  • 64
1

First let me ask, do you want to remain in sRGB, and go through each RGB combination?

OR (and this is my assumption) do you actually want colors that are "farthest" from each other? Since you used the term "distinct" I'm going to cover finding color differences.

Model Your Perceptions

sRGB is a colorspace that refers to your display/output. And while the gamma curve is "sorta" perceptually uniform, the overall sRGB colorspace is not, it is intended more model the display than human perception.

To determine "maximum distance" between colors in terms of perception, you want a model of perception, either using a colorspace that is perceptually uniform or using a color appearance model (CAM).

As you just want sRGB values as a result, then using a uniform colorspace is probably sufficient, such as CIELAB or CIELUV. As these use cartesian coordinates, the difference between two colors in (L*a*b*) is simply the euclidian distance.

enter image description here

LAB diffrence equation

If you want to work with polar coordinates (i.e. hue angle) then you can go one step past CIELAB, into CIELCh.


How To Do It

I suggest Bruce Lindbloom's site for the relevant math.

The simplified steps:

  1. Linearize the sRGB by removing the gamma curve from each of the three color channels.
  2. Convert the linearized values into CIE XYZ (use D65, no adaptation)
  3. Convert XYZ into L* a* b*
  4. Find the Opposite:
    a. Now find the "opposite" color by plotting an line through 0, making the line equal in distance from both sides of zero. OR
    b. ALT: Do one more transform from LAB into CIELCh, then find the opposite by rotating hue 180 degrees. Then convert back to LAB.
  5. Convert LAB to XYZ.

  6. Convert XYZ to sRGB.

  7. Add the sRGB gamma curve to back to each channel.

Staying in sRGB?

If you are less concerned about perceptual uniformity, then you could just stay in sRGB, though the results will be less accurate. In this case all you need to do is take the difference of each channel relative to 255 (i.e. invert each channel):

enter image description here

What's The Difference of the Differences?

Here are some comparisons of the two methods discussed above:

For starting color #0C5490 sRGB Difference Method:

enter image description here

Same starting color, but using CIELAB L* C* h* (and just rotating hue 180 degrees, no adjustment to L*).

enter image description here

Starting color #DFE217, sRGB difference method:

enter image description here

Here in CIELAB LCh, just rotating hue 180:

enter image description here

And again in LCh, but this time also adjusting L* as (100 - L*firstcolor)

enter image description here

Now you'll notice a lot of change in hue angle on these — the truth is that while LAB is "somewhat uniform," it's pretty squirrly in the area of blue.

Take a look at the numbers:

enter image description here

They seem substantially different for hue, chroma, a, b... yet they create the same HEX color value! So yes, even CIELAB has inaccuracies (especially blue).

If you want more accuracy, try CIECAM02

Myndex
  • 3,952
  • 1
  • 9
  • 24