4

I have a Python module that provides color palettes and utilities for dealing with them. A color palette object simply inherits from list and is just a list of colors specified in HEX strings. A color palette object has the ability to extend itself to provide as many colors as needed. Imagine a graph with many different datasets being represented: the palette can be asked to extend the number of colors it has to the extent needed to provide unique colors for each graph dataset. It does this by simply taking the mean of adjacent colors and inserting this new mean color.

The extend_palette function works, but it doesn't extend the palette uniformly. For example, a palette could look like the following to begin with:

Extending it to 15 colors is still usable:

Extending it to 30 colors makes the problem with the extend algorithm apparent; new colors are being added only at one end of the list of colors:

How should the function extend_palette of the module be changed to make the extended new colors more uniformly distributed in the palette?

The code follows (with the function extend_palette being of particular focus and other bits of code there for convenience of experimentation):

def clamp(x): 
    return max(0, min(x, 255))

def RGB_to_HEX(RGB_tuple):
    # This function returns a HEX string given an RGB tuple.
    r = RGB_tuple[0]
    g = RGB_tuple[1]
    b = RGB_tuple[2]
    return "#{0:02x}{1:02x}{2:02x}".format(clamp(r), clamp(g), clamp(b))

def HEX_to_RGB(HEX_string):
    # This function returns an RGB tuple given a HEX string.
    HEX = HEX_string.lstrip("#")
    HEX_length = len(HEX)
    return tuple(
        int(HEX[i:i + HEX_length // 3], 16) for i in range(
            0,
            HEX_length,
            HEX_length // 3
        )
    )

def mean_color(colors_in_HEX):
    # This function returns a HEX string that represents the mean color of a
    # list of colors represented by HEX strings.
    colors_in_RGB = []
    for color_in_HEX in colors_in_HEX:
        colors_in_RGB.append(HEX_to_RGB(color_in_HEX))
    sum_r = 0
    sum_g = 0
    sum_b = 0
    for color_in_RGB in colors_in_RGB:
        sum_r += color_in_RGB[0]
        sum_g += color_in_RGB[1]
        sum_b += color_in_RGB[2]
    mean_r = sum_r / len(colors_in_RGB)
    mean_g = sum_g / len(colors_in_RGB)
    mean_b = sum_b / len(colors_in_RGB)
    return RGB_to_HEX((mean_r, mean_g, mean_b))

class Palette(list):

    def __init__(
        self,
        name        = None, # string name
        description = None, # string description
        colors      = None, # list of colors
        *args
        ):
        super(Palette, self).__init__(*args)
        self._name          = name
        self._description   = description
        self.extend(colors)

    def name(
        self
        ):
        return self._name

    def set_name(
        self,
        name = None
        ):
        self._name = name

    def description(
        self
        ):
        return self._description

    def set_description(
        self,
        description = None
        ):
        self._description = description

    def extend_palette(
        self,
        minimum_number_of_colors_needed = 15
        ):
        colors = extend_palette(
            colors = self,
            minimum_number_of_colors_needed = minimum_number_of_colors_needed
        )
        self = colors

    def save_image_of_palette(
        self,
        filename = "palette.png"
        ):
        save_image_of_palette(
            colors   = self,
            filename = filename
        )

def extend_palette(
    colors = None, # list of HEX string colors
    minimum_number_of_colors_needed = 15
    ):
    while len(colors) < minimum_number_of_colors_needed:
        for index in range(1, len(colors), 2):
            colors.insert(index, mean_color([colors[index - 1], colors[index]]))
    return colors

def save_image_of_palette(
    colors   = None, # list of HEX string colors
    filename = "palette.png"
    ):
    import numpy
    import Image
    scale_x = 200
    scale_y = 124
    data = numpy.zeros((1, len(colors), 3), dtype = numpy.uint8)
    index = -1
    for color in colors:
        index += 1
        color_RGB = HEX_to_RGB(color)
        data[0, index] = [color_RGB[0], color_RGB[1], color_RGB[2]]
    data = numpy.repeat(data, scale_x, axis=0)
    data = numpy.repeat(data, scale_y, axis=1)
    image = Image.fromarray(data)
    image.save(filename)

# Define color palettes.
palettes = []
palettes.append(Palette(
    name        = "palette1",
    description = "primary colors for white background",
    colors      = [
                  "#fc0000",
                  "#ffae3a",
                  "#00ac00",
                  "#6665ec",
                  "#a9a9a9",
                  ]
))
palettes.append(Palette(
    name        = "palette2",
    description = "ATLAS clarity",
    colors      = [
                  "#FEFEFE",
                  "#AACCFF",
                  "#649800",
                  "#9A33CC",
                  "#EE2200",
                  ]
))

def save_images_of_palettes():
    for index, palette in enumerate(palettes):
        save_image_of_palette(
            colors   = palette,
            filename = "palette_{index}.png".format(index = index + 1)
        )

def access_palette(
    name = "palette1"
    ):
    for palette in palettes:
        if palette.name() == name:
            return palette
    return None
d3pd
  • 7,935
  • 24
  • 76
  • 127

2 Answers2

3

I think the problem you are experiencing is easier to understand if you start with a simplified example:

nums = [1, 100]

def extend_nums(nums, min_needed):
    while len(nums) < min_needed:
        for index in range(1, len(nums), 2):
            nums.insert(index, mean(nums[index - 1], nums[index]))
    return nums


def mean(x, y):
    return (x + y) / 2

Here I've copied your code, but have used numbers instead of colors to make things easier. Here's what happens when I run it:

>>> nums = [0, 100]
>>> extend_nums(nums, 5)
[0, 12.5, 25.0, 37.5, 50.0, 100]

What do we have here?

  • 50 is the mean between 0 and 100.
  • 25 is the mean between 0 and 50.
  • 12.5 is the mean between 0 and 25.
  • 37.5 is the mean between 25 and 50.

Strange, isn't it? Well, no: I'm modifying nums in-place. The meaning of index in the for-loop changes as I'm inserting new items: nums[3] changes before and after nums.insert(1, something).

Let's try by creating a new list at each iteration:

def extend_nums(nums, min_needed):
    while len(nums) < min_needed:
        new_nums = []  # This new list will hold the extended nums.
        for index in range(1, len(nums)):
            new_nums.append(nums[index - 1])
            new_nums.append(mean(nums[index - 1], nums[index]))
        new_nums.append(nums[-1])
        nums = new_nums
    return nums

Let's try:

>>> nums = [0, 100]
>>> extend_nums(nums, 5)
[0, 25.0, 50.0, 75.0, 100]

This solution works (there's room for improvement). Why? Because in our new for-loop, index has the correct meaning. Previously, we were inserting items without shifting the index.

Andrea Corbellini
  • 17,339
  • 3
  • 53
  • 69
  • Thank you very much for your detailed solution. The simplified example of something mathematically equivalent was particularly helpful in illustrating the Zeno's Paradox problem. – d3pd Feb 10 '16 at 13:47
1

This code

while len(colors) < minimum_number_of_colors_needed:
    for index in range(1, len(colors), 2):
        colors.insert(index, mean_color([colors[index - 1], colors[index]]))

does not distribute the mean colors evenly. You can see the effect by running:

colors = range(5)
while len(colors) < 15:
    for index in range(1, len(colors), 2):
        colors.insert(index, 99)
print(colors)

which yields

[0, 99, 99, 99, 99, 99, 99, 99, 1, 99, 99, 99, 2, 3, 4]

Too many means, represented by 99's, are placed near the beginning, and none near the end.


Happily, since you have numpy, you can use np.interp to interpolate the colors evenly. For example, if you have a function with data points (0, 10), (0.5, 20), (1, 30), then you can interpolate at x = [0, 0.33, 0.67, 1] to find the corresponding y values:

In [80]: np.interp([0, 0.33, 0.67, 1], [0, 0.5, 1], [10, 20, 30])
Out[80]: array([ 10. ,  16.6,  23.4,  30. ])

Since np.interp operates on 1D arrays only, we can apply it to each RGB channel separately:

[np.interp(np.linspace(0,1,min_colors), np.linspace(0,1,ncolors), self.rgb[:,i]) 
 for i in range(nchannels)])

For example,

import numpy as np
import Image

def RGB_to_HEX(RGB_tuple):
    """
    Return a HEX string given an RGB tuple.
    """
    return "#{0:02x}{1:02x}{2:02x}".format(*np.clip(RGB_tuple, 0, 255))


def HEX_to_RGB(HEX_string):
    """
    Return an RGB tuple given a HEX string.
    """
    HEX = HEX_string.lstrip("#")
    HEX_length = len(HEX)
    return tuple(
        int(HEX[i:i + HEX_length // 3], 16) for i in range(
            0,
            HEX_length,
            HEX_length // 3 ))

class Palette(object):

    def __init__(self, name=None, description=None, colors=None, *args):
        super(Palette, self).__init__(*args)
        self.name = name
        self.description = description
        self.rgb = np.array(colors)

    @classmethod
    def from_hex(cls, name=None, description=None, colors=None, *args):
        colors = np.array([HEX_to_RGB(c) for c in colors])
        return cls(name, description, colors, *args)

    def to_hex(self):
        return [RGB_to_HEX(color) for color in self.rgb]

    def extend_palette(self, min_colors=15):
        ncolors, nchannels = self.rgb.shape
        if ncolors >= min_colors:
            return self.rgb

        return np.column_stack(
            [np.interp(
                np.linspace(0,1,min_colors), np.linspace(0,1,ncolors), self.rgb[:,i]) 
             for i in range(nchannels)])

def save_image_of_palette(rgb, filename="palette.png"):
    scale_x = 200
    scale_y = 124
    data = (np.kron(rgb[np.newaxis,...], np.ones((scale_x, scale_y, 1)))
            .astype(np.uint8))
    image = Image.fromarray(data)
    image.save(filename)


# Define color palettes.
palettes = []
palettes.append(Palette.from_hex(
    name="palette1",
    description="primary colors for white background",
    colors=[
        "#fc0000",
        "#ffae3a",
        "#00ac00",
        "#6665ec",
        "#a9a9a9", ]))
palettes.append(Palette.from_hex(
    name="palette2",
    description="ATLAS clarity",
    colors=[
        "#FEFEFE",
        "#AACCFF",
        "#649800",
        "#9A33CC",
        "#EE2200",]))
palettes = {p.name:p for p in palettes}


p = palettes['palette1']
save_image_of_palette(p.extend_palette(), '/tmp/out.png')

yields enter image description here


Note that you may find interpolating in the HSV colorspace (instead of in the RGB colorspace) gives better results.

Community
  • 1
  • 1
unutbu
  • 842,883
  • 184
  • 1,785
  • 1,677
  • Thank you very much for your helpful solution. I was not aware of the NumPy interpolation functionality and your coding is very neat. If I could accept two solutions I would. – d3pd Feb 10 '16 at 13:49