You can think of a color as a point in a color space which typically consists of three or four dimensions like RGB or HSL. To create a linear interpolation between two points in this space requires to simply follow the line created by these two points. Depending on the color space, you will get different continuation of colors.
Below, I use the matplotlib
to display the palettes and colormath
for the conversions which you can install by pip install colormath
. This library makes this job much easier than it would be otherwise.
import colormath
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from colormath.color_objects import sRGBColor, HSVColor, LabColor, LCHuvColor, XYZColor, LCHabColor
from colormath.color_conversions import convert_color
def hex_to_rgb_color(hex):
return sRGBColor(*[int(hex[i + 1:i + 3], 16) for i in (0, 2 ,4)], is_upscaled=True)
def plot_color_palette(colors, subplot, title, plt_count):
ax = fig.add_subplot(plt_count, 1, subplot)
for sp in ax.spines: ax.spines[sp].set_visible(False)
for x, color in enumerate(colors):
ax.add_patch(mpl.patches.Rectangle((x, 0), 0.95, 1, facecolor=color))
ax.set_xlim((0, len(colors)))
ax.set_ylim((0, 1))
ax.set_xticks([])
ax.set_yticks([])
ax.set_aspect("equal")
plt.title(title)
def create_palette(start_rgb, end_rgb, n, colorspace):
# convert start and end to a point in the given colorspace
start = convert_color(start_rgb, colorspace).get_value_tuple()
end = convert_color(end_rgb, colorspace).get_value_tuple()
# create a set of n points along start to end
points = list(zip(*[np.linspace(start[i], end[i], n) for i in range(3)]))
# create a color for each point and convert back to rgb
rgb_colors = [convert_color(colorspace(*point), sRGBColor) for point in points]
# finally convert rgb colors back to hex
return [color.get_rgb_hex() for color in rgb_colors]
start_color = "#009392"
end_color = "#d0587e"
number_of_colors = 10
colorspaces = (sRGBColor, HSVColor, LabColor, LCHuvColor, LCHabColor, XYZColor)
start_rgb = hex_to_rgb_color(start_color)
end_rgb = hex_to_rgb_color(end_color)
fig = plt.figure(figsize=(number_of_colors, len(colorspaces)), frameon=False)
for index, colorspace in enumerate(colorspaces):
palette = create_palette(start_rgb, end_rgb, number_of_colors, colorspace)
plot_color_palette(palette, index + 1, colorspace.__name__, len(colorspaces))
plt.subplots_adjust(hspace=1.5)
plt.show()
The basic idea of linear extrapolation is to simply extend the vector defined by the two colors. The biggest problem in doing that is when we hit the "walls" of the color space. For example, think of the color space RGB where Red goes from 0 to 255. What should happen after our interpolation line hits the 255 wall? A color cannot get any more red than red. One way I thought you can continue is to treat this line as a ray of light that can "bounce off" or "reflect" off the walls of the rgb space.
Interestingly, colormath
doesn't seem to mind when the parameters of its color objects exceed their limits. It proceeds to create a color object with an invalid hex value. This can sometimes occur during extrapolation. To prevent this, we can either cap the value of the RGB:
rgb_colors = np.maximum(np.minimum(rgb, [1, 1, 1]), [0, 0, 0])
or have it "reflect" back off the wall so to speak.
rgb_colors = []
for color in rgb:
c = list(color)
for i in range(3):
if c[i] > 1:
c[i] = 2 - c[i]
if c[i] < 0:
c[i] *= -1
rgb_colors.append(c)
The equations above should be self explanatory. When an RGB channel drops below zero, flip its sign to "reflect" off of the zero wall, and similarly when it exceeds 1, reflect it back towards zero. Here are some extrapolation results using this method:
def create_palette(start_rgb, end_rgb, n, colorspace, extrapolation_length):
# convert start and end to a point in the given colorspace
start = np.array(convert_color(start_rgb, colorspace, observer=2).get_value_tuple())
mid = np.array(convert_color(end_rgb, colorspace, observer=2).get_value_tuple())
# extrapolate the end point
end = start + extrapolation_length * (mid - start)
# create a set of n points along start to end
points = list(zip(*[np.linspace(start[i], end[i], n) for i in range(3)]))
# create a color for each point and convert back to rgb
rgb = [convert_color(colorspace(*point), sRGBColor).get_value_tuple() for point in points]
# rgb_colors = np.maximum(np.minimum(rgb, [1, 1, 1]), [0, 0, 0])
rgb_colors = []
for color in rgb:
c = list(color)
for i in range(3):
if c[i] > 1:
c[i] = 2 - c[i]
if c[i] < 0:
c[i] *= -1
rgb_colors.append(c)
# finally convert rgb colors back to hex
return [sRGBColor(*color).get_rgb_hex() for color in rgb_colors]
start_color = "#009392"
end_color = "#d0587e"
number_of_colors = 11
colorspaces = (sRGBColor, HSVColor, LabColor, LCHuvColor, LCHabColor, XYZColor, LuvColor)
start_rgb = hex_to_rgb_color(start_color)
end_rgb = hex_to_rgb_color(end_color)
fig = plt.figure(figsize=(6, len(colorspaces)), frameon=False)
for index, colorspace in enumerate(colorspaces):
palette = create_palette(start_rgb, end_rgb, number_of_colors, colorspace, extrapolation_length=2)
plot_color_palette(palette, index + 1, colorspace.__name__, len(colorspaces))
plt.subplots_adjust(hspace=1.2)
plt.show()
Note that because Hue is a circular axis, in color spaces like HSV or HSL it wraps back around, and if you put your end-color at the middle of the palette, you are likely to return back near your start-color.
It's quiet fascinating to see the path these interpolations take in the color space. Take a look. Notice the effect bouncing off the walls creates.
I might at some point turn this into an open source project.