Title: Make an HSL color picker in Python
Many programs let you adjust sliders to pick a color by setting its red, green, and blue color components. The RGB (red, green, blue) color system is natural for the computer but not natural for humans. This example shows how you can build a color picker that uses an HSL color wheel. The result is much easier to use under most circumstances.
This is a fairly long post, so feel free to skip the sections that interest you the least if you like.
Using the Color Picker
The HSL color model represents a color with three values: hue, saturation, and lightness.
Hue is the basic color like blue, red, or yellow. On the color wheel, hue is represented by the a point's angle with respect to the center with red at 0° (to the right), green at 120°, blue at 240°, and the other colors blending smoothly in between.
You can think of saturation as the color's purity and decreasing the color's saturation is like adding gray to it. If saturation is 1, the color is as pure as possible. When saturation is 0, the color is gray no matter what hue it has. On the color wheel, saturation corresponds to distance from the center of the wheel. The outer edges of the wheel give pure colors. The middle is gray.
Lightness is like adding white to the color. When lightness is 0, the color is black. When lightness is 1, the color is white. A lightness of 0.5 gives a nice bright color.
To use the picker, click or click and drag on the color wheel to pick a hue and saturation. When you do, the program fills the swatch on the right with a gradient of values using the color that you selected and lightness values ranging from 1 at the top to 0 at the bottom.
The reason this is more natural than an RGB color selector is that you can pick the color and saturation first by seeing examples in the wheel. Then you adjust the lightness.
With an RGB selector, you need to know pretty much what color component values you need to make the color. For example, to get "standard" pink, you need red 255, green 192, and blue 203. Not very intuitive.
Building the User Interface
I won't bore you with this program's tkinter code. It's long and straightforward. There are only two key things that I want to mention now.
First, the Entry widgets that show the color's hue, saturation, lightness, red, green, and blue components are all bounds to StringVar variables so the program can update what they display by setting the variables' values.
Second, the color wheel and the lightness gradient watch for mouse down and mouse move events. When you click or click and drag on them, they update DoubleVar variables that hold the selected color's components.
Drawing the Color Wheel
When the program starts, it uses the following code to draw the color wheel.
def draw_wheel(self):
'''Draw the color wheel.'''
# Get the window's background color.
bg = self.window['bg'] # In Windows, SystemButtonFace
rgb16 = self.window.winfo_rgb(bg) # Convert to 16-bit RGB
bg = tuple((x >> 8 for x in rgb16)) # Convert to 8-bit RGB
# Make an image to draw on.
wid = hgt = 2 * self.wheel_r
pil_image = Image.new('RGB', (wid, hgt), color=bg)
# Calculate the center.
cx = cy = self.wheel_r
# Draw the wheel.
pixels = pil_image.load()
l = 0.5
for x in range(wid):
for y in range(hgt):
# Get the X and Y distancse to the center.
dx = x - cx
dy = y - cy
# Saturation is distance from center.
s = math.hypot(dx, dy) / self.wheel_r
# If it's outside of the wheel, leave it the background color.
if s > 1: continue
# Hue is the angle.
h = math.degrees(math.atan2(dy, dx))
# Convert from HSL to RGB.
rgb = hsl_to_rgb(h, s, l, 255)
# Set the pixel's color.
pixels[x, y] = rgb
# Display the image.
self.wheel_photo_image = ImageTk.PhotoImage(pil_image)
self.wheel_canvas.create_image(0, 0, anchor=tk.NW,
image=self.wheel_photo_image)
The code first gets the window's background color. This should be easy, but sometimes the color is set to a system default like SystemButtonFace, which PIL can't understand. To work around that, the code gets the window's bg value and calls winfo_rgb to convert that into an RGB color. That returns a tuple holding the color's red, green, and blue components, but they are each 16-bit values and PIL doesn't support that. To fix that, the code right-shifts the values to chop them down to 8 bits each, keeping the most significant 8 bits.
Now that the program has an RGB tuple that PIL can understand, it uses PIL to create an image filled with that color.
It then loops over the image's pixels. For each pixel, it gets the X and Y offsets from the center of the image to the pixel. Next, it uses those values to calculate the distance between the image's center and the pixel. It divides that by the wheel's radius to get the fraction of the distance from the center to the edge of the wheel. That gives us the saturation value s. If you think about it, this makes sense. Pixels close to the center of the wheel have a small distance so their s values are small and the pixel is gray. Pixels at the wheel's edge have s value 1 so they are fully saturated.
If a pixel's s value is greater than 1, the pixel lies outside of the wheel so the code continues the loop and it remains with the window's background color.
If the pixel is not outside of the wheel, the code uses math.atan2 to get the pixel's angle with respect to the wheel's center. It converts that into degrees to get the color's hue value.
The code then calls the hsl_to_rgb function (described in my post Convert between RGB and HSL color models in Python) to convert the HSL values into RGB values (it sets the lightness value for all pixels to 0.5 so they are neither too light nor too dark) and sets the pixel's color to the result.
After it finishes setting every pixel's color, the method converts the PIL image into a PhotoImage, saves that image in a non-volatile location so it can be used as needed to display the result, and draws the image on the wheel's Canvas widget.
Drawing the wheel is fast (about an eighth of a second) but not quite fast enough that you would want to quickly update it interactively. Fortunately, the program only draws the wheel once when it starts and then it merely displays a little circle on top of it later.
Selecting Hue and Saturation
When you click or click and drag on the color wheel, the following event handler springs into action.
def select_hs(self, event):
'''Select the hue and saturation from the color wheel.'''
# See where the user clicked.
cx = cy = self.wheel_r
dx = event.x - cx
dy = event.y - cy
s = math.hypot(dx, dy) / self.wheel_r
if s > 1: s = 1
self.s_value_var.set(s)
h = math.degrees(math.atan2(dy, dx)) % 360
self.h_value_var.set(h)
# Update the gradient, reticles, and sample.
self.draw_gradient()
self.draw_wheel_reticle()
self.draw_gradient_reticle()
self.show_sample()
This code is somewhat similar to the code that draws the color wheel because both need to translate between X/Y coordinates and HSL values.
The method begins by getting the difference between the clicked point's X and Y coordinates and the wheel's center coordinates. It uses those to calculate the distance from the center and divides that by the wheel's radius to get the saturation value. If that value is greater than 1, the code clamps it to 1.
Next, the code uses math.atan2 to get the hue angle.
The method finishes by calling methods to draw the lightness gradient, reticles, and color sample. I'll describe those methods a bit later.
Notice that this method only changes the color's hue and saturation. Its lightness is unchanged.
Selecting Lightness
When you click or click and drag on the lightness gradient, the following code executes.
def select_l(self, event):
'''Select the lightness from the swatch.'''
# See where the user clicked.
l = 1 - event.y / self.gradient_hgt
if l < 0: l = 0
if l > 1: l = 1
self.l_value_var.set(l)
# Update the swatch reticle and sample.
self.draw_gradient_reticle()
self.show_sample()
This code divides the mouse's Y coordinate from the gradient's height. It subtracts that value from 1 to get the fractional distance from the bottom of the gradient to the mouse and uses the result as the color's lightness. If the mouse is close to the top of the gradient, the lightness is close to 1. If the mouse is close to the bottom of the gradient, the lightness is close to 0.
The code ensures the lightness is between 0 and 1, and saves the result in the l_value_var variable.
The method then calls draw_gradient_reticle to position the gradient's reticle and then show_sample to display a sample of the current color.
Notice that this method only changes the color's lightness. Its hue and saturation are unchanged.
Drawing the Gradient
The following method draws the lightness gradient.
def draw_gradient(self):
'''Draw the saturation swatch.'''
self.gradient.delete(tk.ALL)
start_hsl = [self.h_value_var.get(), self.s_value_var.get(), 1, 255]
end_hsl = [self.h_value_var.get(), self.s_value_var.get(), 0, 255]
image = fill_v_rectangle(self.gradient_wid, self.gradient_hgt,
start_hsl, end_hsl)
self.gradient_photo_image = ImageTk.PhotoImage(image)
self.gradient.create_image(0, 0, image=self.gradient_photo_image,
anchor=tk.NW)
This method clears the gradient's Canvas widget. It then sets start_hsl and end_hsl to HSL representations of the color currently selected on the color wheel with lightness values 1 and 0 respectively. It then calls the fill_v_rectangle described shortly to make a PIL image that shades vertically from start_hsl and end_hsl. The code converts that into a PhotoImage and displays it on the gradient's canvas.
Here's the fill_v_rectangle helper function.
def fill_v_rectangle(wid, hgt, start_hsla, end_hsla):
'''Fill the rectangle with a vertical color gradient.'''
# Make an image to draw on.
image = Image.new('RGBA', (wid, hgt), 'black')
dr = ImageDraw.Draw(image)
# Get the differences between the color components.
diffs = [end_hsla[i] - start_hsla[i] for i in range(4)]
# Color the pixels.
for y in range(hgt):
fraction = y / hgt
color = [start_hsla[i] + fraction * diffs[i] for i in range(4)]
r, g, b, a = hsl_to_rgb(*color)
dr.line((0, y, wid, y), fill=(r, g, b, a))
return image
This function creates a PIL image. It then loops through the image's Y coordinates. For each Y value, it interpolates the start and end colors and draws a horizontal line at that Y value. When it finishes, the method returns the image.
Updating the Wheel Reticle
The following code shows how the program draws the little circle that shows the selected color on the color wheel.
def draw_wheel_reticle(self):
'''Update the color wheel reticle.'''
self.wheel_canvas.delete(self.wheel_reticle)
radius = self.s_value_var.get() * self.wheel_r
cx = cy = self.wheel_r
degrees = math.radians(self.h_value_var.get())
x = cx + radius * math.cos(degrees)
y = cy + radius * math.sin(degrees)
r = 4
self.wheel_reticle = self.wheel_canvas.create_oval(
x - r, y - r, x + r, y + r, outline='black')
The code first deletes the previous reticle if one exists. (The startup code sets self.wheel_reticle to 0 so this method can delete something the first it is called. If the value is 0, it doesn't do anything.)
Next, the code uses the currently selected hue and saturation values to figure out where on the color wheel that color belongs. It then calls create_oval to draw the circle on the color wheel.
Updating the Gradient Reticle
The following code shows how the program draws the reticle showing the selected color on the lightness gradient.
def draw_gradient_reticle(self):
'''Update the swatch reticle.'''
self.gradient.delete(self.gradient_reticle)
x = self.gradient_wid / 2
l = self.l_value_var.get()
y = self.gradient_hgt * (1 - l)
outline = 'white' if l < 0.5 else 'black'
r = 4
self.gradient_reticle = self.gradient.create_oval(
x - r, y - r, x + r, y + r, outline=outline)
Like the preceding method, this method deletes any previous reticle from its gradient. It then uses the current lightness value to figure out where the reticle belongs.
Next, if the lightness is less than 0.5, the color is fairly dark so the code sets color to white. Otherwise, if the lightness is not less than 0.5, the color is relatively light so the code sets color to black. This lets the program draw a light reticle over dark colors and a dark reticle over light colors. (This wasn't necessary for the color wheel's reticle because all of the pixels in the color wheel have a lightness of 0.5 so a black reticle will show up anywhere.)
The code then uses create_oval to draw the reticle.
Showing the Color Sample
The following method displays a sample of the currently selected color.
def show_sample(self):
'''Display a sample of the current color.'''
# Display the HSL text values.
h = self.h_value_var.get()
s = self.s_value_var.get()
l = self.l_value_var.get()
self.h_text_var.set(f'{h:.2f}')
self.s_text_var.set(f'{s:.2f}')
self.l_text_var.set(f'{l:.2f}')
# Display the RGB text values.
r, g, b, a = hsl_to_rgb(h, s, l, 255)
self.r_text_var.set(r)
self.g_text_var.set(g)
self.b_text_var.set(b)
bg = hsl_to_tkinter([h, s, l, 255])
self.sample.config(bg=bg)
This method gets the currently selected color's hue, saturation, and lightness values and displays them in the corresponding Entry widgets. Next, it uses hsl_to_rgb to convert those values into the RGB color system and displays the RGB values in their Entry widgets.
Finally, the code calls hsl_to_tkinter to convert the color into a string of the format #rrggbb that tkinter can understand and sets the background color of the sample Canvas widget to that string.
Conclusion
The HSL color model doesn't match the way computer monitors define color, but it's more intuitive for humans than the RGB model. Give it a try and I think you'll find it extremely easy to use. (There's also an HSV color model. I don't know if I'll try that one because I like the color wheel used in this post.)
In my next post, I'll turn this example into a color picker dialog. Until then, download the example to experiment with it and to see additional details.
|