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.

1 Answers

Walter Tross On

Here is my attempt. You didn't specify a language, so I'll write it in Python (not optimized, so as to be easily translated to other languages)

n_colors = 25
rw, gw, bw = 2, 4, 1
n_global_moves = 32

class Color:
    max_weighted_square_distance = (rw * rw + gw * gw + bw * bw) * 255 * 255

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

    def weighted_square_distance(self, other):
        rd = rw * (self.r - other.r)
        gd = gw * (self.g - other.g)
        bd = bw * (self.b - other.b)
        return rd*rd + gd*gd + bd*bd

    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:
            min_wsd = -1
            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  min_wsd < new_min_wsd:
                    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
        global_move_phase = False

print(f"n_colors: {n_colors}")
print(f"rw, gw, bw: {rw}, {gw}, {bw}")
print(f"n_global_moves: {n_global_moves}")

The colors are first set all to grey, i.e., put in the center of a 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.

To compensate for the different sensitivity of the cones in our eyes, we use different weights (rw, gw and bw) in the 3 color directions when calculating the Euclidean distance. This causes the color cube to effectively become a color cuboid.

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, setting rw, gw and bw all to 1: 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 one of 8 solutions. The other 7 are produced by replacing c with 255-c, where c is r, g or b, in all combinations.

The algorithm makes a simplifying assumption regarding color “distances”. I don't know whether a warp of the color cuboid exists that would cause the perceived distance between 2 colors to be equal to the Euclidean distance. If there is, moving colors within this warp would produce better results.

I have not corrected for the monitor's gamma, because it's 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 may not do this precisely, and a (screen 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:

enter image description here


After posting this answer I found the Wikipedia article on color difference. The weights I have used may be appropriate for the components' contribution to brightness, but not for the perception of color differences. Furthermore a better formula is available (without completely changing the color space), so I will have to fix these two aspects.