This tidytuesday dataset of colors labels is like the perfect confluence of interests for me! I’ve started learning how to do digital art to illustrate characters for a D&D campaign:
Which means I’ve been looking a lot at a color picker that uses Hue, Saturation and Lightness sliders (even though they’re not labelled that way).
But I’ve had an interest in colors and color theory for a while. From a cognitive science perspective, color constancy is an interesting phenomenon where we perceive colors in a scene to be the “same” when they’re definitely different, something I think shares a lot in common with vowel perception.
As was working on the last post, I actually used a physical representation of the Hue-Lightness-Saturation sphere that I already had in the house, cause I guess I’m just into colors.
The linguistic angle
There’s also a linguistic component to all of this. I could, for example, define “blue” as being a color at 220° with 50% lightness and 75% saturation.
And then, I could lighten it by increasing the lightness.
But when people see a color and say “that’s light blue”, are they describing just a shift along the lightness scale, or something else? The best way to visualize shifts through the colorspace would be to plot their vector through the color sphere, but the problem is that the HLS values in and of themselves don’t correspond to x, y and z coordinates.
The color cube
plot_ly(
x = ~H,
y = ~S,
z = ~L,
data = hls_df,
type = "scatter3d",
mode = "markers",
marker = list(color = hls_df$hex)
) ->
color_cube
color_cube
We’ve successfully made a color cube, but I really want to get to that color sphere. So this post is about the math I’ve had to work out to get to that point.
Color unit circle
First thing, we need to turn that H
angle from degrees to radians, and then a sin()
and cos()
should get us the points along a unit circle.
hls_df |>
ggplot(
aes(
sin(H_radian),
cos(H_radian),
color = hex)
) +
geom_point(size = 3) +
scale_color_identity() +
coord_fixed() ->
color_circle
color_circle
(color_circle + theme_dark()) |> dark_render()
We’ve got each color point arranged along a unit circle. I want to organize the points so that light & dark correspond to the poles of the sphere (like a “north” and “south”), and saturation corresponds to deepness within the sphere.
The color “puck”
The first thing I tried here was just to scale the color values along the unit circle by the saturation (which ranges between 0 and 1) to place each color’s depth within the sphere, which winds up looking like this:
In 2 dimensions, this looks pretty ok!
hls_df |>
ggplot(
aes(x, y, color = hex)
) +
geom_point(size = 3)+
scale_x_continuous(
limit = c(-1, 1)
)+
scale_y_continuous(
limits = c(-1, 1)
)+
scale_color_identity()+
coord_fixed() ->
flat_puck
flat_puck
(flat_puck + theme_dark()) |> dark_render()
But when I add the third dimension, I don’t end up with my sphere.
plot_ly(
x = ~x,
y = ~y,
z = ~L,
data = hls_df,
type = "scatter3d",
mode = "markers",
marker = list(color = hls_df$hex)
) ->
color_puck
color_puck
It looks a bit like a color puck, or a Pride layer cake that’s burnt on the bottom and under cooked on the top.
Why the puck?
To illustrate why I wound up with a puck, I took apart my physical color sphere.
I’d scaled the x and y values above to try to capture the saturation “depth”. The saturation value itself ranges between 0 and 1.
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.0005024 0.2649756 0.4962482 0.5050065 0.7627292 0.9997049
But that S
value actually describes the relative distance of a point between the north-south axis and the sphere’s surface. So where a value of 1
lands in absolute x, y space depends on the total width of the slice at that point.
So, I need to scale the relative saturation value based on its lightness to get the sphere.
The color sphere!
I had to write out some notes for this one. If we rescale Lightness to run between -1 and 1, then the sphere from the side is a unit circle, and the radius at the equator is 1.
At some different value of L, like 0.75, the distance from the center axis to the surface will be the relative max-saturation I’m looking for.
The distance from the center to the same location on the surface will still be 1, though, turning this into solving the Pythagorean theorem.
So if
\[ a = L~\text{(lightness)} \]
\[ b = ? \]
\[ c = 1 \]
and
\[ a^2 + b^2 = c^2 \]
then
\[ b = \sqrt{|L^2 - 1|} \]
Let’s do it!
plot_ly(
x = ~x,
y = ~y,
z = ~L_scale,
data = hls_df,
type = "scatter3d",
mode = "markers",
marker = list(color = hls_df$hex)
) ->
color_sphere
color_sphere
Finally, the color sphere!
From the sphere back to HLS
One kind of downside of the current visualization is that the sphere looks kind of porous, especially at the equator. That comes down to all of the rescaling we did. To get a nicer continuously dense sphere, I want to uniformly sample from these 3d coordinates, and then translate them back into HLS values for coloring in.
Sampling a sphere
plot_ly(
x = ~x,
y = ~y,
z = ~z,
data = sphere_df,
type = "scatter3d",
mode = "markers"
) ->
sphere_plot
sphere_plot
That’s a dense meatball!
From x and y to degrees
I had to double check wikipedia for this, but we can get from x and y back to angles with atan2.
From z to Lightness
Translating z back into Lightness is probably the simplest bit of math
sphere_df |>
mutate(
L = (z + 1)/2
) ->
sphere_df
From depth to saturation
We can get the absolute depth of a point on the x/y axis with the Pythagorean theorem. Then we need to divide it by the maximum possible depth like we did before.
Let’s double check the numbers look right.
H L S
Min. : 0.07694 Min. :0.02029 Min. :0.05407
1st Qu.: 86.71262 1st Qu.:0.32880 1st Qu.:0.52054
Median :173.03568 Median :0.50556 Median :0.72744
Mean :177.47325 Mean :0.49831 Mean :0.68226
3rd Qu.:266.16528 3rd Qu.:0.67266 3rd Qu.:0.88465
Max. :359.72754 Max. :0.99171 Max. :0.99998
Adding in the hex codes
The moment of truth
plot_ly(
x = ~x,
y = ~y,
z = ~z,
data = sphere_df,
type = "scatter3d",
mode = "markers",
marker = list(color = sphere_df$hex)
) ->
sphere_plot2
sphere_plot2
There it is! The color sphere!
Now I can actually visualize the difference between “blue” and “light blue”.
Reuse
Citation
@online{fruehwald2025,
author = {Fruehwald, Josef},
title = {Visualizing the {Color} {Sphere}},
series = {Væl Space},
date = {2025-07-12},
url = {https://jofrhwld.github.io/blog/posts/2025/07/2025-07-12_color-sphere/},
langid = {en}
}