Detecting Grayscale Colors
• Austin Pray • View Sauce • Edit
Let’s say you want to detect some grayscale colors:
isGrayscale(
)
// returns true
A color is grayscale if the color’s red, green, blue (RGB) component values are exactly equal.
A really common format for colors on the web is a 6 digit hexidecimal representation of the RGB component values:
# + RRGGBB
.
#FFFFFF
#000000
#808080
#FF0000
#00FF00
#0000FF
Knowing the above, detecting grayscale colors is simple:
function isGrayscale(color) {
const { r, g, b } = parseColor(color);
return r === g && r === b;
}
function parseColor(color) {
const match = color.match(/#((?:[0-9A-F]{1,2}){3})/i);
if (!match) {
throw new Error('invalid color format');
}
const [r, g, b] = match[1]
.repeat(2)
.slice(-6)
.match(/.{2}/g)
.map(hex => parseInt(hex, 16));
return {r, g, b};
}
isGrayscale('#FFFFFF') | ✅ |
isGrayscale('#000000') | ✅ |
isGrayscale('#808080') | ✅ |
isGrayscale('#FF0000') | ❌ |
isGrayscale('#00FF00') | ❌ |
isGrayscale('#0000FF') | ❌ |
Be aware that there are a boatload of valid formats for web colors.
For production use you are better off using a library like d3-color
to parse untrusted color values.
Anyway, the above code works great! I guess we are done. Hope you learned something! I should blog more often. See you next time!
Wait, what was that? You plugged some Bootstrap 4 gray colors into the function like
isGrayscale('#adb5bd')
and you got false
?
Well, I would indeed consider those Bootstrap grays to be “grayish” colors.
However, those colors are not strictly grayscale colors according to the definition we agreed
upon above. So yeah, it’s actually a good thing our function doesn’t match them. Closed as wontfix
.
What? This blog post sucks? Okay, fine, there’s more we can do here. I’m no stranger to scope creep.
Classifying colors as “grayish”
A color is generally considered “grayish”, neutral, achromatic if all of its red, green, blue (RGB) component values are a close distance to each other. Makes sense: we know from earlier that a color is strictly grayscale if it has uniform RGB values. A distance of zero is about as close as you can get.
Let’s define our test cases. We can use those pesky Bootstrap 4 gray colors as colors we want to positively match.
gray-100: #f8f9fa
gray-200: #e9ecef
gray-300: #dee2e6
gray-400: #ced4da
gray-500: #adb5bd
gray-600: #6c757d
gray-700: #495057
gray-800: #343a40
gray-900: #212529
As a sanity check let’s use ColorBrewer 2 as colors we want to negatively match.
Let’s visualize the RGB values of our test cases and check if we see the pattern we expect.
True Grayscale |
|
---|---|
Bootstrap 4 |
|
ColorBrewer 2 |
|
Okay, so we can clearly see the pattern we expect. The colors we want to match have equidistant RGB components. The ColorBrewer colors are off doing their own thing because they are clearly not gray colors.
If we want to classify these colors in code: we need a way to measure the degree to which the RGB components are close to each other. The range of the RGB components should measure this nicely.
const RANGE_LIMIT = 17;
function isGray(color) {
const { r, g, b } = parseColor(color);
const rgb = [r, g, b];
return Math.max(...rgb) - Math.min(...rgb) <= RANGE_LIMIT;
}
Where do we get RANGE_LIMIT
?
red | green | blue | range | |
---|---|---|---|---|
#f8f9fa | 248 | 249 | 250 | 2 |
#e9ecef | 233 | 236 | 239 | 6 |
#dee2e6 | 222 | 226 | 230 | 8 |
#ced4da | 206 | 212 | 218 | 12 |
#adb5bd | 173 | 181 | 189 | 16 |
#6c757d | 108 | 117 | 125 | 17 |
#495057 | 73 | 80 | 87 | 14 |
#343a40 | 52 | 58 | 64 | 12 |
#212529 | 33 | 37 | 41 | 8 |
This matches the original desired Bootstrap colors:
Similar “grayish” colors will be matched as well:
Great! Looks good to me so far. Now, how can we exhaustively prove that this function matches only the colors we want? The best way to spot check our function is probably to visualize what it considers to be “grayish”.
Checking our work
RGB is a nice and simple color model that is easy for machines to deal with. However, for our purposes here it would be nice to use a human-friendly color model. I will choose the hue, saturation, value (HSV) color model to make a simple 2D plot of the colors matched by our function.
HSV has some useful geometry for detecting and visualizing grayish colors. The HSV color model can be represented as a cylinder where the central vertical axis comprises neutral, achromatic, or gray colors. Those colors range, from top to bottom, white at value 1 and black at value 0.
By HSV_color_solid_cylinder.png: SharkDderivative work: SharkD Talk - HSV_color_solid_cylinder.png, CC BY-SA 3.0, wiki
I’m not smart enough to do math in 3D: so let’s just chart 2D slices of this cylinder at different hues and see how far we get.
We are mostly concerned with the interaction between saturation and value right now anyway.
Let’s fix the hue to
210°
.
since all those Bootstrap grays tend to hover around that hue.
hex | saturation | value |
---|---|---|
#f0f7ff |
6 | 100 |
#ebf4fc |
7 | 99 |
#ebf2fa |
6 | 98 |
#e6eff7 |
7 | 97 |
#e4ecf5 |
7 | 96 |
#e1eaf2 |
7 | 95 |
#dfe7f0 |
7 | 94 |
#dde5ed |
7 | 93 |
#dae2eb |
7 | 92 |
#d8e0e8 |
7 | 91 |
#d5dde6 |
7 | 90 |
#d3dbe3 |
7 | 89 |
#d1d9e0 |
7 | 88 |
#ced6de |
7 | 87 |
#cad3db |
8 | 86 |
#cad1d9 |
7 | 85 |
#c5ced6 |
8 | 84 |
#c3cbd4 |
8 | 83 |
#c0c9d1 |
8 | 82 |
#bec6cf |
8 | 81 |
#bcc4cc |
8 | 80 |
#b9c1c9 |
8 | 79 |
#b7bfc7 |
8 | 78 |
#b3bcc4 |
9 | 77 |
#b2bac2 |
8 | 76 |
#aeb7bf |
9 | 75 |
#acb4bd |
9 | 74 |
#a9b2ba |
9 | 73 |
#a7afb8 |
9 | 72 |
#a5adb5 |
9 | 71 |
#a2aab3 |
9 | 70 |
#a0a8b0 |
9 | 69 |
#9ca5ad |
10 | 68 |
#9aa2ab |
10 | 67 |
#97a0a8 |
10 | 66 |
#959da6 |
10 | 65 |
#939ba3 |
10 | 64 |
#9199a1 |
10 | 63 |
#8d959e |
11 | 62 |
#8c949c |
10 | 61 |
#889199 |
11 | 60 |
#868e96 |
11 | 59 |
#848c94 |
11 | 58 |
#808991 |
12 | 57 |
#7e868f |
12 | 56 |
#7b848c |
12 | 55 |
#79818a |
12 | 54 |
#767e87 |
13 | 53 |
#757d85 |
12 | 52 |
#717a82 |
13 | 51 |
#6f7780 |
13 | 50 |
#6d757d |
13 | 49 |
#69727a |
14 | 48 |
#676f78 |
14 | 47 |
#646d75 |
15 | 46 |
#626a73 |
15 | 45 |
#5f6870 |
15 | 44 |
#5d656e |
15 | 43 |
#5a636b |
16 | 42 |
#586069 |
16 | 41 |
#555d66 |
17 | 40 |
#525a63 |
17 | 39 |
#505961 |
18 | 38 |
#4d565e |
18 | 37 |
#4b545c |
18 | 36 |
#485159 |
19 | 35 |
#464e57 |
20 | 34 |
#434c54 |
20 | 33 |
#414952 |
21 | 32 |
#3e464f |
22 | 31 |
#3c444d |
22 | 30 |
#39414a |
23 | 29 |
#363e47 |
24 | 28 |
#343c45 |
25 | 27 |
#313a42 |
26 | 26 |
#2f3740 |
27 | 25 |
#2c353d |
28 | 24 |
#2a323b |
29 | 23 |
#272f38 |
30 | 22 |
#252d36 |
31 | 21 |
#222a33 |
33 | 20 |
#1f2730 |
35 | 19 |
#1d252e |
37 | 18 |
#1a222b |
40 | 17 |
#182029 |
41 | 16 |
#151d26 |
45 | 15 |
#131b24 |
47 | 14 |
#101821 |
52 | 13 |
#0e161f |
55 | 12 |
#0b131c |
61 | 11 |
#09111a |
65 | 10 |
#060e17 |
74 | 9 |
#030c14 |
85 | 8 |
#010912 |
94 | 7 |
#00080f |
100 | 6 |
#00060d |
100 | 5 |
#00050a |
100 | 4 |
#000408 |
100 | 3 |
#000305 |
100 | 2 |
#000103 |
100 | 1 |
isGray
when it is fed colors with a hue fixed at
210°
.
Colors below this line will be matched.
Our target Bootstrap colors are plotted in red.
Great! Not perfect, but it’s definitely clear to me that we are on the right track.
Let’s check another hue to see what our function matches there. Let’s check the
120°
hue.
hex | saturation | value |
---|---|---|
#f0fff0 |
6 | 100 |
#ebfceb |
7 | 99 |
#ebfaeb |
6 | 98 |
#e6f7e6 |
7 | 97 |
#e4f5e4 |
7 | 96 |
#e1f2e1 |
7 | 95 |
#dff0df |
7 | 94 |
#ddeddd |
7 | 93 |
#daebda |
7 | 92 |
#d8e8d8 |
7 | 91 |
#d5e6d5 |
7 | 90 |
#d3e3d3 |
7 | 89 |
#d1e0d1 |
7 | 88 |
#cedece |
7 | 87 |
#cadbca |
8 | 86 |
#cad9ca |
7 | 85 |
#c5d6c5 |
8 | 84 |
#c3d4c3 |
8 | 83 |
#c0d1c0 |
8 | 82 |
#becfbe |
8 | 81 |
#bcccbc |
8 | 80 |
#b9c9b9 |
8 | 79 |
#b7c7b7 |
8 | 78 |
#b3c4b3 |
9 | 77 |
#b2c2b2 |
8 | 76 |
#aebfae |
9 | 75 |
#acbdac |
9 | 74 |
#a9baa9 |
9 | 73 |
#a7b8a7 |
9 | 72 |
#a5b5a5 |
9 | 71 |
#a2b3a2 |
9 | 70 |
#a0b0a0 |
9 | 69 |
#9cad9c |
10 | 68 |
#9aab9a |
10 | 67 |
#97a897 |
10 | 66 |
#95a695 |
10 | 65 |
#93a393 |
10 | 64 |
#91a191 |
10 | 63 |
#8d9e8d |
11 | 62 |
#8c9c8c |
10 | 61 |
#889988 |
11 | 60 |
#869686 |
11 | 59 |
#849484 |
11 | 58 |
#809180 |
12 | 57 |
#7e8f7e |
12 | 56 |
#7b8c7b |
12 | 55 |
#798a79 |
12 | 54 |
#768776 |
13 | 53 |
#758575 |
12 | 52 |
#718271 |
13 | 51 |
#6f806f |
13 | 50 |
#6d7d6d |
13 | 49 |
#697a69 |
14 | 48 |
#677867 |
14 | 47 |
#647564 |
15 | 46 |
#627362 |
15 | 45 |
#5f705f |
15 | 44 |
#5d6e5d |
15 | 43 |
#5a6b5a |
16 | 42 |
#586958 |
16 | 41 |
#556655 |
17 | 40 |
#526352 |
17 | 39 |
#506150 |
18 | 38 |
#4d5e4d |
18 | 37 |
#4b5c4b |
18 | 36 |
#485948 |
19 | 35 |
#465746 |
20 | 34 |
#435443 |
20 | 33 |
#415241 |
21 | 32 |
#3e4f3e |
22 | 31 |
#3c4d3c |
22 | 30 |
#394a39 |
23 | 29 |
#364736 |
24 | 28 |
#344534 |
25 | 27 |
#314231 |
26 | 26 |
#2f402f |
27 | 25 |
#2c3d2c |
28 | 24 |
#2a3b2a |
29 | 23 |
#273827 |
30 | 22 |
#253625 |
31 | 21 |
#223322 |
33 | 20 |
#1f301f |
35 | 19 |
#1d2e1d |
37 | 18 |
#1a2b1a |
40 | 17 |
#182918 |
41 | 16 |
#152615 |
45 | 15 |
#132413 |
47 | 14 |
#102110 |
52 | 13 |
#0e1f0e |
55 | 12 |
#0b1c0b |
61 | 11 |
#091a09 |
65 | 10 |
#061706 |
74 | 9 |
#031403 |
85 | 8 |
#011201 |
94 | 7 |
#000f00 |
100 | 6 |
#000d00 |
100 | 5 |
#000a00 |
100 | 4 |
#000800 |
100 | 3 |
#000500 |
100 | 2 |
#000300 |
100 | 1 |
Uhoh. Yeah, this isn’t working at all. Most all of these matched colors look downright green to me on all my devices. At best, it seems to be matching greens that look like olive drab.
If you are not familiar with color spaces: you might be wondering
how #f0f7ff
and #f0fff0
can have the same saturation (6) and value (100) yet #f0fff0
is disproportionately offensive.
Due to the way color vision works,
we are naturally more sensitive to colors in the greenish-yellow area of the color spectrum.
So we need to use a perceptually uniform color model that accounts for this.
Perceiving colors in code
Let’s tighten up our terminology in the hopes of finding a good color model for our problem. Classifying colors as “grayish” can be more accurately stated as classifying:
- neutral colors
- colors with low colorfulness
- colors with low saturation
- achromatic colors, colors with a low chroma value
If we are looking for achromatic colors, we have the CIELChab
perceptual color model at our disposal. This is a cylindrical representation of the CIELAB
color space with a channel for Lightness, Chroma, and hue.
The chroma channel is what we will use to determine if a color is achromatic enough to be matched.
#6c757d
from our bootstrap colors has the highest chroma value at 6.05
.
Let’s use that as our threshold.
import { lch } from 'd3-color';
const CHROMA_THRESHOLD = 6.05;
function isGray(color) {
let chroma = lch(color).c;
return isNaN(chroma) || chroma <= CHROMA_THRESHOLD;
}
Let’s chart it at the
210°
hue:
hex | saturation | value |
---|---|---|
#edf6ff |
7 | 100 |
#ebf4fc |
7 | 99 |
#e8f1fa |
7 | 98 |
#e6eff7 |
7 | 97 |
#e4ecf5 |
7 | 96 |
#e1eaf2 |
7 | 95 |
#dfe7f0 |
7 | 94 |
#dde5ed |
7 | 93 |
#dae2eb |
7 | 92 |
#d5dfe8 |
8 | 91 |
#d3dce6 |
8 | 90 |
#d1dae3 |
8 | 89 |
#ced7e0 |
8 | 88 |
#ccd5de |
8 | 87 |
#cad3db |
8 | 86 |
#c7d0d9 |
8 | 85 |
#c5ced6 |
8 | 84 |
#c3cbd4 |
8 | 83 |
#c0c9d1 |
8 | 82 |
#bec6cf |
8 | 81 |
#bcc4cc |
8 | 80 |
#b7c0c9 |
9 | 79 |
#b5bec7 |
9 | 78 |
#b3bcc4 |
9 | 77 |
#b0b9c2 |
9 | 76 |
#aeb7bf |
9 | 75 |
#acb4bd |
9 | 74 |
#a9b2ba |
9 | 73 |
#a7afb8 |
9 | 72 |
#a5adb5 |
9 | 71 |
#a2aab3 |
9 | 70 |
#9ea7b0 |
10 | 69 |
#9ca5ad |
10 | 68 |
#9aa2ab |
10 | 67 |
#97a0a8 |
10 | 66 |
#959da6 |
10 | 65 |
#939ba3 |
10 | 64 |
#9199a1 |
10 | 63 |
#8d959e |
11 | 62 |
#8a939c |
12 | 61 |
#889199 |
11 | 60 |
#868e96 |
11 | 59 |
#848c94 |
11 | 58 |
#818991 |
11 | 57 |
#7f878f |
11 | 56 |
#7b848c |
12 | 55 |
#79818a |
12 | 54 |
#777f87 |
12 | 53 |
#757d85 |
12 | 52 |
#727a82 |
12 | 51 |
#6f7780 |
13 | 50 |
#6d757d |
13 | 49 |
#6a727a |
13 | 48 |
#687078 |
13 | 47 |
#656d75 |
14 | 46 |
#636b73 |
14 | 45 |
#606870 |
14 | 44 |
#5e666e |
15 | 43 |
#5b636b |
15 | 42 |
#596169 |
15 | 41 |
#575e66 |
15 | 40 |
#545b63 |
15 | 39 |
#515961 |
16 | 38 |
#4f575e |
16 | 37 |
#4c545c |
17 | 36 |
#4a5259 |
17 | 35 |
#474f57 |
18 | 34 |
#454d54 |
18 | 33 |
#424a52 |
20 | 32 |
#40484f |
19 | 31 |
#3d454d |
21 | 30 |
#3b434a |
20 | 29 |
#384047 |
21 | 28 |
#363e45 |
22 | 27 |
#343b42 |
21 | 26 |
#313840 |
23 | 25 |
#2f363d |
23 | 24 |
#2c333b |
25 | 23 |
#2a3138 |
25 | 22 |
#272e36 |
28 | 21 |
#252c33 |
27 | 20 |
#222930 |
29 | 19 |
#20272e |
30 | 18 |
#1d242b |
33 | 17 |
#1b2229 |
34 | 16 |
#181f26 |
37 | 15 |
#161d24 |
39 | 14 |
#141a21 |
39 | 13 |
#11181f |
45 | 12 |
#0e151c |
50 | 11 |
#0a121a |
62 | 10 |
#060e17 |
74 | 9 |
#010b14 |
95 | 8 |
#000912 |
100 | 7 |
#00080f |
100 | 6 |
#00060d |
100 | 5 |
#00050a |
100 | 4 |
#000408 |
100 | 3 |
#000305 |
100 | 2 |
#000103 |
100 | 1 |
isGray
.
The purple line is our old range-based isGray
.
Great! It's mostly the same as our previous function.
Glad to see we weren't completely off-base with our previous idea.
Let’s chart it at the
120°
hue:
hex | saturation | value |
---|---|---|
#f7fff7 |
3 | 100 |
#f2fcf2 |
4 | 99 |
#f0faf0 |
4 | 98 |
#edf7ed |
4 | 97 |
#ebf5eb |
4 | 96 |
#e9f2e9 |
4 | 95 |
#e6f0e6 |
4 | 94 |
#e4ede4 |
4 | 93 |
#e1ebe1 |
4 | 92 |
#dfe8df |
4 | 91 |
#dce6dc |
4 | 90 |
#dae3da |
4 | 89 |
#d7e0d7 |
4 | 88 |
#d5ded5 |
4 | 87 |
#d3dbd3 |
4 | 86 |
#d0d9d0 |
4 | 85 |
#ced6ce |
4 | 84 |
#cbd4cb |
4 | 83 |
#c9d1c9 |
4 | 82 |
#c6cfc6 |
4 | 81 |
#c4ccc4 |
4 | 80 |
#c1c9c1 |
4 | 79 |
#bfc7bf |
4 | 78 |
#bcc4bc |
4 | 77 |
#bac2ba |
4 | 76 |
#b6bfb6 |
5 | 75 |
#b3bdb3 |
5 | 74 |
#b1bab1 |
5 | 73 |
#aeb8ae |
5 | 72 |
#acb5ac |
5 | 71 |
#aab3aa |
5 | 70 |
#a7b0a7 |
5 | 69 |
#a5ada5 |
5 | 68 |
#a2aba2 |
5 | 67 |
#a0a8a0 |
5 | 66 |
#9da69d |
5 | 65 |
#9ba39b |
5 | 64 |
#99a199 |
5 | 63 |
#969e96 |
5 | 62 |
#949c94 |
5 | 61 |
#909990 |
6 | 60 |
#8d968d |
6 | 59 |
#8b948b |
6 | 58 |
#899189 |
6 | 57 |
#868f86 |
6 | 56 |
#848c84 |
6 | 55 |
#818a81 |
7 | 54 |
#7f877f |
6 | 53 |
#7d857d |
6 | 52 |
#7a827a |
6 | 51 |
#778077 |
7 | 50 |
#747d74 |
7 | 49 |
#727a72 |
7 | 48 |
#6f786f |
7 | 47 |
#6d756d |
7 | 46 |
#6b736b |
7 | 45 |
#687068 |
7 | 44 |
#666e66 |
7 | 43 |
#636b63 |
7 | 42 |
#606960 |
9 | 41 |
#5e665e |
8 | 40 |
#5b635b |
8 | 39 |
#596159 |
8 | 38 |
#575e57 |
7 | 37 |
#545c54 |
9 | 36 |
#515951 |
9 | 35 |
#4f574f |
9 | 34 |
#4d544d |
8 | 33 |
#495249 |
11 | 32 |
#474f47 |
10 | 31 |
#454d45 |
10 | 30 |
#434a43 |
9 | 29 |
#404740 |
10 | 28 |
#3d453d |
12 | 27 |
#3a423a |
12 | 26 |
#384038 |
13 | 25 |
#363d36 |
11 | 24 |
#333b33 |
14 | 23 |
#313831 |
12 | 22 |
#2e362e |
15 | 21 |
#2c332c |
14 | 20 |
#293029 |
15 | 19 |
#272e27 |
15 | 18 |
#242b24 |
16 | 17 |
#212921 |
20 | 16 |
#1f261f |
18 | 15 |
#1d241d |
19 | 14 |
#1a211a |
21 | 13 |
#181f18 |
23 | 12 |
#151c15 |
25 | 11 |
#121a12 |
31 | 10 |
#0f170f |
35 | 9 |
#0b140b |
45 | 8 |
#071207 |
61 | 7 |
#030f03 |
80 | 6 |
#000d00 |
100 | 5 |
#000a00 |
100 | 4 |
#000800 |
100 | 3 |
#000500 |
100 | 2 |
#000300 |
100 | 1 |
isGray
.
The purple line is our old range-based isGray
.
You can see the LCh-based curve is way more reluctant to match saturated colors for a given brightness at this hue.
It plays it safe and sticks closer to the Y axis.
For completeness let’s try
Let’s chart it at the
0°
hue:
hex | saturation | value |
---|---|---|
#fff0f0 |
6 | 100 |
#fceded |
6 | 99 |
#faebeb |
6 | 98 |
#f7e9e9 |
6 | 97 |
#f5e6e6 |
6 | 96 |
#f2e4e4 |
6 | 95 |
#f0e1e1 |
6 | 94 |
#eddfdf |
6 | 93 |
#ebdddd |
6 | 92 |
#e8dada |
6 | 91 |
#e6d8d8 |
6 | 90 |
#e3d5d5 |
6 | 89 |
#e0d1d1 |
7 | 88 |
#decece |
7 | 87 |
#dbcccc |
7 | 86 |
#d9caca |
7 | 85 |
#d6c7c7 |
7 | 84 |
#d4c5c5 |
7 | 83 |
#d1c2c2 |
7 | 82 |
#cfc0c0 |
7 | 81 |
#ccbebe |
7 | 80 |
#c9bbbb |
7 | 79 |
#c7b9b9 |
7 | 78 |
#c4b7b7 |
7 | 77 |
#c2b4b4 |
7 | 76 |
#bfb0b0 |
8 | 75 |
#bdaeae |
8 | 74 |
#baabab |
8 | 73 |
#b8a9a9 |
8 | 72 |
#b5a7a7 |
8 | 71 |
#b3a4a4 |
8 | 70 |
#b0a2a2 |
8 | 69 |
#ada0a0 |
8 | 68 |
#ab9d9d |
8 | 67 |
#a89b9b |
8 | 66 |
#a69898 |
8 | 65 |
#a39595 |
9 | 64 |
#a19292 |
9 | 63 |
#9e9090 |
9 | 62 |
#9c8e8e |
9 | 61 |
#998b8b |
9 | 60 |
#968989 |
9 | 59 |
#948787 |
9 | 58 |
#918484 |
9 | 57 |
#8f8181 |
10 | 56 |
#8c7e7e |
10 | 55 |
#8a7c7c |
10 | 54 |
#877a7a |
10 | 53 |
#857777 |
11 | 52 |
#827575 |
10 | 51 |
#807171 |
12 | 50 |
#7d6f6f |
11 | 49 |
#7a6d6d |
11 | 48 |
#786b6b |
11 | 47 |
#756868 |
11 | 46 |
#736565 |
12 | 45 |
#706363 |
12 | 44 |
#6e6060 |
13 | 43 |
#6b5e5e |
12 | 42 |
#695c5c |
12 | 41 |
#665959 |
13 | 40 |
#635757 |
12 | 39 |
#615454 |
13 | 38 |
#5e5151 |
14 | 37 |
#5c4f4f |
14 | 36 |
#594d4d |
13 | 35 |
#574a4a |
15 | 34 |
#544848 |
14 | 33 |
#524545 |
16 | 32 |
#4f4242 |
16 | 31 |
#4d4040 |
17 | 30 |
#4a3d3d |
18 | 29 |
#473b3b |
17 | 28 |
#453838 |
19 | 27 |
#423636 |
18 | 26 |
#403434 |
19 | 25 |
#3d3232 |
18 | 24 |
#3b2f2f |
20 | 23 |
#382c2c |
21 | 22 |
#362a2a |
22 | 21 |
#332828 |
22 | 20 |
#302525 |
23 | 19 |
#2e2323 |
24 | 18 |
#2b2020 |
26 | 17 |
#291e1e |
27 | 16 |
#261c1c |
26 | 15 |
#241919 |
31 | 14 |
#211717 |
30 | 13 |
#1f1414 |
35 | 12 |
#1c1010 |
43 | 11 |
#1a0c0c |
54 | 10 |
#170707 |
70 | 9 |
#140303 |
85 | 8 |
#120000 |
100 | 7 |
#0f0000 |
100 | 6 |
#0d0000 |
100 | 5 |
#0a0000 |
100 | 4 |
#080000 |
100 | 3 |
#050000 |
100 | 2 |
#030000 |
100 | 1 |
isGray
.
The purple line is our old range-based isGray
.
Let’s sample the edge case colors:
#fff0f0
#f7fff7
#edf6ff
#807171
#778077
#6f7780
#403434
#384038
#313840
#fff0f0
#f7fff7
#edf6ff
#807171
#778077
#6f7780
#403434
#384038
#313840
#fff0f0
#f7fff7
#edf6ff
#807171
#778077
#6f7780
#403434
#384038
#313840
We are definitely getting somewhere. At this point I’m beginning to doubt my monitor calibration/eyes. Regardless, I’m gonna go ahead and judge that most people would struggle to classify the above colors as “gray”. If I asked people to name those colors I’m guessing they would say things like “pale blue”, “olive drab”, “dark blue”.
Here’s an example of me manually tweaking these colors in an LCh color picker until I personally would call them “gray”.
#f8f3f3
#fbfefb
#f1f5f9
#7a7373
#7c7e7c
#71777d
#3c3635
#3d3f3d
#34383c
#f8f3f3
#fbfefb
#f1f5f9
#7a7373
#7c7e7c
#71777d
#3c3635
#3d3f3d
#34383c
#f8f3f3
#fbfefb
#f1f5f9
#7a7373
#7c7e7c
#71777d
#3c3635
#3d3f3d
#34383c
That is the best I could do in 10 minutes or so. I’m hesitant to tweak further as there are so many equipment and biological factors that could cause me to waste a ton of time with diminising returns. The limitations of human vision could certainly trip me up.
Anyway, the above exercise of manually finding the chroma threshold for those example colors was useful. Let’s encode my common sense judgement above into an algorithm.
Coding common sense
When determining a chroma threshold for our algorithm, we should consider these findings from above:
- Blue colors should be given lots of leeway. Blues with a low chroma look like “cool grays” to me.
- Orange colors should be given lots of leeway. Orange is a gateway to brown. Oranges with low chroma look like “warm grays” to me.
- Red colors should be judged somewhere in the middle.
- Green colors should be scrutinized closely.
Let’s slice up our 3D problem into 2D projections and apply some shader functions to our classifier curves to approximate our desired result.
function _clamp(x, min, max) {
if (x < min) {
x = min
} else if (x > max) {
x = max
}
return x
}
function _smoothstep(a, b, x) {
const t = _clamp((x - a) / (b - a), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
const hueToleranceStopsV1 = [
[0, 3.1], // red
[20, 3.1], // orange
[61.8, 8], // orange
[66, 7.49], // orange
[75, 6.8], // orange
[100, 2], // yellow-green
[180, 2], // green
[240, 5.4], // blue
[262, 14], // blue
[263, 14], // blue
[290, 6.5],
[310, 3.1], // red
[360, 3.1],
];
function _buildCurve(stops) {
return function (value) {
let a, b, from, to;
for (const [x, y] of stops) {
if (value >= x) {
a = x;
from = y;
}
if (value <= x) {
b = x;
to = y;
break;
}
}
if (a === b) {
return to;
}
return _smoothstep(a, b, value) * (to - from) + from;
}
}
const hueCurveLCh = _buildCurve(hueToleranceStopsV1);
function isGray(color) {
const {l: lightness, c: chroma, h: hue} = lch(color);
// black and white
if ([0, 100].includes(lightness)) {
return true;
}
// grayscale
if (chroma === 0) {
return true;
}
return chroma <= hueCurveLCh(hue)
}
Below is a visualization of hueCurveLCh
:
Let’s dump all the edge case values this function matches. The following table shows the most chromatic colors matched for a given hue and lightness. These colors sit at the threshold of what this function considers “gray”.
#ffffff | #000000 | |||||||||
0° HSV / 20° LCh | #fff7f7 | #e6dfdf | #ccc6c6 | #b3abab | #999393 | #807979 | #666060 | #4d4646 | #332d2d | #1a1414 |
5° HSV / 27° LCh | #fff8f7 | #e6dddc | #ccc5c4 | #b3aaaa | #999291 | #807878 | #665f5e | #4d4545 | #332c2c | #1a1313 |
10° HSV / 35° LCh | #fff4f2 | #e6dcda | #ccc2c0 | #b3a8a6 | #998f8d | #807674 | #665d5b | #4d4442 | #332b29 | #1a1210 |
15° HSV / 43° LCh | #fff2ed | #e6d8d3 | #ccc0bc | #b3a6a2 | #998c88 | #807470 | #665b57 | #4d423e | #332926 | #1a100d |
20° HSV / 52° LCh | #fff0e8 | #e6d8d1 | #ccbeb8 | #b3a49d | #998c85 | #80736c | #665a54 | #4d413b | #332823 | #1a0e08 |
25° HSV / 61° LCh | #fff2e8 | #e6d8cf | #ccbfb6 | #b3a59b | #998d84 | #80736a | #665a52 | #4d4139 | #332921 | #1a0d04 |
30° HSV / 70° LCh | #fff5eb | #e6dbd1 | #ccc2b8 | #b3a99f | #998f85 | #80766c | #665d54 | #4d443b | #332b23 | #1a0d01 |
35° HSV / 78° LCh | #fff8ed | #e6ded3 | #ccc4ba | #b3aba1 | #999288 | #80796f | #665f56 | #4d463d | #332d25 | #1a1004 |
40° HSV / 85° LCh | #fffbf2 | #e6e2da | #ccc8c0 | #b3aea6 | #99958d | #807c74 | #66625b | #4d4941 | #332f28 | #1a1409 |
45° HSV / 92° LCh | #fffdf7 | #e6e4df | #cccac6 | #b3b1ab | #999791 | #807e79 | #66645f | #4d4b46 | #33312c | #1a1813 |
50° HSV / 97° LCh | #fffefa | #e6e5e1 | #cccbc8 | #b3b2af | #999894 | #807f7c | #666562 | #4d4c49 | #33322f | #1a1916 |
55° HSV / 101° LCh | #fffffc | #e6e5e3 | #ccccc8 | #b3b2af | #999996 | #807f7c | #666663 | #4d4c49 | #333330 | #1a1917 |
60° HSV / 105° LCh | #fffffc | #e6e6e3 | #ccccca | #b3b3af | #999996 | #80807d | #666663 | #4d4d49 | #333330 | #1a1a17 |
65° HSV / 109° LCh | #fffffc | #e5e6e3 | #ccccca | #b2b3af | #999996 | #7f807d | #666663 | #4c4d49 | #333330 | #191a17 |
70° HSV / 112° LCh | #fffffc | #e5e6e3 | #ccccca | #b2b3af | #989996 | #7f807d | #656663 | #4c4d49 | #333330 | #191a17 |
75° HSV / 116° LCh | #fefffc | #e5e6e3 | #cbccca | #b2b3af | #989996 | #7f807d | #656663 | #4c4d49 | #323330 | #191a17 |
80° HSV / 119° LCh | #fefffc | #e5e6e3 | #cbccca | #b1b3af | #989996 | #7f807d | #656663 | #4b4d49 | #323330 | #191a17 |
85° HSV / 122° LCh | #fefffc | #e5e6e3 | #cbccca | #b1b3af | #989996 | #7e807d | #656663 | #4b4d49 | #323330 | #181a17 |
90° HSV / 126° LCh | #fefffc | #e4e6e3 | #cbccca | #b1b3af | #979996 | #7e807d | #646663 | #4b4d49 | #323330 | #181a17 |
95° HSV / 129° LCh | #fefffc | #e4e6e3 | #cbccca | #b1b3b1 | #979996 | #7e807d | #646663 | #4b4d49 | #323330 | #181a17 |
100° HSV / 132° LCh | #fdfffc | #e4e6e3 | #cbccca | #b1b3b1 | #979996 | #7e807d | #646663 | #4b4d4a | #313330 | #181a17 |
105° HSV / 135° LCh | #fdfffc | #e4e6e3 | #caccca | #b1b3b1 | #979996 | #7e807d | #646663 | #4b4d4a | #313330 | #181a17 |
110° HSV / 138° LCh | #fdfffc | #e4e6e3 | #caccca | #b1b3b1 | #969996 | #7d807d | #646664 | #4b4d4a | #313330 | #181a17 |
115° HSV / 140° LCh | #fdfffc | #e3e6e3 | #caccca | #b1b3b1 | #969996 | #7d807d | #646664 | #4a4d4a | #313330 | #171a17 |
120° HSV / 143° LCh | #fcfffc | #e3e6e3 | #caccca | #b1b3b1 | #969996 | #7d807d | #646664 | #4a4d4a | #313331 | #171a17 |
125° HSV / 145° LCh | #fcfffd | #e3e6e3 | #caccca | #b1b3b1 | #969996 | #7d807d | #646664 | #4a4d4a | #303331 | #171a17 |
130° HSV / 148° LCh | #fcfffd | #e3e6e4 | #caccca | #b1b3b1 | #969996 | #7d807d | #636663 | #4a4d4b | #303331 | #171a17 |
135° HSV / 152° LCh | #fcfffd | #e3e6e4 | #caccca | #afb3b0 | #969997 | #7d807e | #636664 | #494d4a | #303331 | #171a18 |
140° HSV / 155° LCh | #fcfffd | #e3e6e4 | #cacccb | #afb3b0 | #969997 | #7d807e | #636664 | #494d4a | #303331 | #171a18 |
145° HSV / 159° LCh | #fcfffe | #e3e6e4 | #c8ccca | #afb3b0 | #969997 | #7c807d | #636664 | #494d4b | #303331 | #171a18 |
150° HSV / 164° LCh | #fcfffe | #e1e6e3 | #c8ccca | #afb3b1 | #969997 | #7c807e | #636664 | #494d4b | #303331 | #161a18 |
155° HSV / 169° LCh | #fcfffe | #e1e6e4 | #c8ccca | #afb3b1 | #969998 | #7c807e | #626664 | #494d4b | #2f3332 | #161a18 |
160° HSV / 174° LCh | #fafffd | #e1e6e4 | #c8cccb | #afb3b1 | #949997 | #7c807e | #626665 | #494d4b | #2f3332 | #161a18 |
165° HSV / 180° LCh | #fafffe | #e1e6e4 | #c8cccb | #afb3b2 | #949998 | #7c807f | #626665 | #494d4c | #2f3332 | #161a19 |
170° HSV / 186° LCh | #fafffe | #e1e6e5 | #c8cccb | #adb3b2 | #949998 | #7c807f | #626665 | #484d4c | #2f3332 | #161a19 |
175° HSV / 193° LCh | #faffff | #e1e6e5 | #c6cccb | #adb3b2 | #949999 | #7a807f | #616666 | #474d4c | #2e3333 | #151a19 |
180° HSV / 200° LCh | #f7ffff | #dfe6e6 | #c6cccc | #abb3b3 | #939999 | #798080 | #606666 | #464d4d | #2d3333 | #141a1a |
185° HSV / 207° LCh | #f7feff | #dce5e6 | #c4cbcc | #aab2b3 | #909899 | #777f80 | #5e6566 | #454c4d | #2b3233 | #12191a |
190° HSV / 215° LCh | #f2fdff | #dae4e6 | #c0cacc | #a8b1b3 | #8e9799 | #757e80 | #5c6466 | #434b4d | #293133 | #0f181a |
195° HSV / 224° LCh | #f0fbff | #d8e2e6 | #bec8cc | #a4afb3 | #8b9699 | #737c80 | #596366 | #40494d | #273033 | #0c161a |
200° HSV / 233° LCh | #f0faff | #d5e0e6 | #bcc7cc | #a2adb3 | #8a9499 | #707a80 | #586166 | #3f484d | #262f33 | #0b151a |
205° HSV / 242° LCh | #edf8ff | #d5dfe6 | #bcc5cc | #a2acb3 | #8a9399 | #707980 | #576066 | #3e464d | #252d33 | #08121a |
210° HSV / 251° LCh | #ebf5ff | #d3dce6 | #bac3cc | #a1aab3 | #879099 | #6e7780 | #555d66 | #3c444d | #242b33 | #09111a |
215° HSV / 259° LCh | #edf5ff | #d3dbe6 | #bac1cc | #a1a8b3 | #888f99 | #6f7680 | #575d66 | #3d444d | #252b33 | #0a111a |
220° HSV / 266° LCh | #edf3ff | #d5dbe6 | #bcc1cc | #a2a8b3 | #8a8f99 | #707580 | #585c66 | #3f434d | #262a33 | #0c101a |
225° HSV / 272° LCh | #f0f4ff | #d5d9e6 | #bec1cc | #a4a8b3 | #8b8f99 | #717580 | #595c66 | #3f434d | #272a33 | #0d101a |
230° HSV / 278° LCh | #f2f4ff | #d8dae6 | #bec0cc | #a6a8b3 | #8b8e99 | #737580 | #5a5c66 | #41434d | #282a33 | #0e101a |
235° HSV / 282° LCh | #f2f3ff | #dadbe6 | #c0c1cc | #a6a7b3 | #8d8e99 | #747580 | #5b5c66 | #42434d | #292a33 | #0f101a |
240° HSV / 286° LCh | #f2f2ff | #dadae6 | #c0c0cc | #a8a8b3 | #8e8e99 | #757580 | #5c5c66 | #43434d | #2a2a33 | #10101a |
245° HSV / 289° LCh | #f3f2ff | #dbdae6 | #c1c0cc | #a9a8b3 | #8f8e99 | #767580 | #5d5c66 | #43434d | #2b2a33 | #10101a |
250° HSV / 292° LCh | #f7f5ff | #dcdae6 | #c4c2cc | #aaa8b3 | #908e99 | #777580 | #5e5c66 | #44434d | #2b2a33 | #11101a |
255° HSV / 296° LCh | #f7f5ff | #dfdce6 | #c4c2cc | #acaab3 | #929099 | #797780 | #5f5d66 | #46444d | #2d2b33 | #13111a |
260° HSV / 299° LCh | #faf7ff | #dfdce6 | #c7c4cc | #adaab3 | #949199 | #7a7880 | #615e66 | #47454d | #2e2c33 | #14121a |
265° HSV / 303° LCh | #fbf7ff | #e1dfe6 | #c8c6cc | #aeabb3 | #959399 | #7c7980 | #626066 | #49464d | #302d33 | #16131a |
270° HSV / 306° LCh | #fcfaff | #e3e1e6 | #c9c6cc | #b0adb3 | #979499 | #7d7a80 | #636166 | #4a484d | #312e33 | #17151a |
275° HSV / 309° LCh | #fdfaff | #e4e1e6 | #cac8cc | #b0adb3 | #979499 | #7d7a80 | #646266 | #4b484d | #312f33 | #18151a |
280° HSV / 312° LCh | #fdfaff | #e4e1e6 | #cbc8cc | #b1adb3 | #979499 | #7e7c80 | #656266 | #4b484d | #322f33 | #18161a |
285° HSV / 315° LCh | #fefaff | #e4e1e6 | #cbc8cc | #b2afb3 | #989499 | #7f7c80 | #656266 | #4c494d | #322f33 | #19161a |
290° HSV / 318° LCh | #fefaff | #e5e1e6 | #cbc8cc | #b2afb3 | #989499 | #7f7c80 | #656266 | #4c494d | #322f33 | #19161a |
295° HSV / 321° LCh | #fffaff | #e5e1e6 | #ccc8cc | #b2afb3 | #999499 | #7f7c80 | #666266 | #4c494d | #332f33 | #19161a |
300° HSV / 323° LCh | #fffaff | #e6e1e6 | #ccc8cc | #b3afb3 | #999499 | #807c80 | #666266 | #4d494d | #332f33 | #1a161a |
305° HSV / 326° LCh | #fffaff | #e6e1e5 | #ccc8cc | #b3afb2 | #999499 | #807c7f | #666266 | #4d494c | #332f33 | #1a1619 |
310° HSV / 329° LCh | #fffafe | #e6e1e5 | #ccc8cb | #b3adb2 | #999498 | #807a7f | #666265 | #4d484c | #332f32 | #1a1619 |
315° HSV / 332° LCh | #fffafe | #e6e1e4 | #ccc8cb | #b3adb1 | #999498 | #807a7e | #666165 | #4d484b | #332e32 | #1a1518 |
320° HSV / 335° LCh | #fffafd | #e6e1e4 | #ccc6ca | #b3adb1 | #999497 | #807a7e | #666164 | #4d484b | #332e31 | #1a1518 |
325° HSV / 340° LCh | #fffafd | #e6e1e4 | #ccc6c9 | #b3adb0 | #999396 | #807a7d | #666164 | #4d474a | #332e31 | #1a1518 |
330° HSV / 344° LCh | #fffafc | #e6dfe2 | #ccc6c9 | #b3adb0 | #999396 | #80797c | #666063 | #4d474a | #332e30 | #1a1417 |
335° HSV / 349° LCh | #fffafc | #e6dfe1 | #ccc6c8 | #b3adaf | #999395 | #80797c | #666062 | #4d4749 | #332d30 | #1a1416 |
340° HSV / 355° LCh | #fff7fa | #e6dfe1 | #ccc6c8 | #b3abae | #999395 | #80797b | #666062 | #4d4648 | #332d2f | #1a1416 |
345° HSV / 1° LCh | #fff7f9 | #e6dfe0 | #ccc6c7 | #b3abad | #999394 | #80797b | #666061 | #4d4648 | #332d2f | #1a1415 |
350° HSV / 7° LCh | #fff7f9 | #e6dfe0 | #ccc6c7 | #b3abad | #999394 | #80797a | #666061 | #4d4647 | #332d2e | #1a1415 |
355° HSV / 13° LCh | #fff7f8 | #e6dfdf | #ccc6c6 | #b3abac | #999192 | #80797a | #666060 | #4d4647 | #332d2d | #1a1414 |
360° HSV / 20° LCh | #fff7f7 | #e6dfdf | #ccc6c6 | #b3abab | #999393 | #807979 | #666060 | #4d4646 | #332d2d | #1a1414 |
Looking pretty good to me!
Color palettes classified as gray
Bootstrap grays ✅
gray-100: #f8f9fa
gray-200: #e9ecef
gray-300: #dee2e6
gray-400: #ced4da
gray-500: #adb5bd
gray-600: #6c757d
gray-700: #495057
gray-800: #343a40
gray-900: #212529
PANTONE cool grays ✅
- 1C:
#d9d9d6
- 2C:
#d0d0ce
- 3C:
#c8c9c7
- 4C:
#bbbcbc
- 5C:
#b1b3b3
- 6C:
#a7a8a9
- 7C:
#97999b
- 8C:
#888b8d
- 9C:
#75787b
- 10C:
#63666a
- 11C:
#53565a
PANTONE warm grays ✅
- 1C:
#d7d2cb
- 2C:
#cbc4bc
- 3C:
#bfb8af
- 4C:
#b6ada5
- 5C:
#aca39a
- 6C:
#a59c94
- 7C:
#968c83
- 8C:
#8c8279
- 9C:
#83786f
- 10C:
#796e65
- 11C:
#6e6259
CSS Colors ✅
black #000000
silver #c0c0c0
gray #808080
white #ffffff
dimgray #696969
dimgrey #696969
darkgray #a9a9a9
darkgrey #a9a9a9
grey #808080
lightgray #d3d3d3
lightgrey #d3d3d3
aliceblue #f0f8ff
gainsboro #dcdcdc
ghostwhite #f8f8ff
snow #fffafa
whitesmoke #f5f5f5
seashell #fff5ee
linen #faf0e6
(I slightly disagree with aliceblue
but gonna leave it for now)
Color palettes not classified as gray
A lot of these are blues with chroma values way above what I’m willing to match with the current implementation of the function. I could treat blues as special, but I’m gonna leave it be for now.
CSS “Slate Gray” ❌
I would like to match these guys eventually.
lightslategray: #778899
lightslategrey: #778899
slategray: #708090
slategrey: #708090
Light CSS Colors ❌
azure #f0ffff
ivory #fffff0
floralwhite #fffaf0
honeydew #f0fff0
bisque #ffe4c4
blanchedalmond #ffebcd
burlywood #deb887
cornsilk #fff8dc
beige #f5f5dc
antiquewhite #faebd7
Tailwind CSS “grays” ❌
Matching these made my blue hue edge cases kinda bogus looking. I tend to agree with my function above. These look like blue colors to me: not gray.
- 100:
#F7FAFC
✅ - 200:
#EDF2F7
✅ - 300:
#E2E8F0
✅ - 400:
#CBD5E0
❌ - 500:
#A0AEC0
❌ - 600:
#718096
❌ - 700:
#4A5568
❌ - 800:
#2D3748
❌ - 900:
#1A202C
❌
If you wanted to match these guys you would need to lax your tolerances for blue hues.
const hueToleranceStopsV2 = [
[0, 3.1], // red
[20, 3.1], // orange
[61.8, 8], // orange
[66, 7.49], // orange
[75, 6.8], // orange
[100, 2], // yellow-green
[180, 2], // green
[240, 5.4], // blue
[262, 14], // blue
[263, 14], // blue
[290, 6.5],
[310, 3.1], // red
[360, 3.1],
];
Which looks like a much different curve:
What is this useful for?
You could use this as a post-processing step to classify things as “gray”.
roots/palette-webpack-plugin uses a simplified version of this technique to automatically sort colors in a color palette picker.