Hazard

Organizing Colors December 1, 2016

I worked as a software consultant for several years, and I implemented a variety of website designs. One of the more challenging ones was a very skeumorphic design that used lots of gradients and shadows to create visual depth. Between dozens of mockups and a couple different CSS coders (and possibly a bug in the Sketch eyedrop tool), the color palette eventually comprised about 60 different colors. Some were visually indistinguishable from others, so I went on a hunt for colors I could replace with equivalent shades. Needing a way to organize colors, I created Swatch.

It’s very natural to consider a color as a point in three-dimensional space. Our code happened to specify colors as RGB hexadecimals, so that’s what I worked with. (The problem may well have been trivial with another three-coordinate color specification: HSL.) I tried two approaches. The first was adequate for finding visually similar colors, but it didn’t give a nicely organized palette, so Swatch ended up using the second approach. I’ll work through both of them here.

Here’s a list of hex values representing colors:


(def colors ["0067cd" "010202" "022446" "060606" "0d4780" "157de4" "161616" "262525"
             "292929" "343434" "393d40" "3d668f" "464646" "525252" "646464" "696969"
             "6f5a22" "78b9fb" "7e1513" "886502" "939393" "950902" "999999" "b6b6b6"
             "d12421" "e1312e" "e13a37" "e31713" "e7e7e7" "e8b012" "f14441" "f9c532"
             "ffffff"])

We’ll need to parse hex values into three-dimensional coordinates.


(defn rgb [hex]
  (->> hex
    (partition 2 2)
    (map #(js/parseInt (apply str %) 16))))

(rgb "112233")

The first approach I tried assumed that colors nearest each other in 3D space would be most visually similar. Sorting colors, then, is a walk from one color to its nearest neighbor. We’ll start with black, find the nearest color, and repeat until we’ve walked all the colors.


(defn dist [color1 color2]
  (->> (map #(js/Math.pow (- %1 %2) 2) color1 color2)
    (reduce +)
    js/Math.sqrt))

(defn walk [start colors]
  (loop [walked []
         current start
         to-walk colors]
    (if (seq to-walk)
      (let [color (apply min-key #(dist (rgb current) (rgb %)) to-walk)]
        (recur (conj walked color) color (remove #{color} to-walk)))
      walked)))

The raw hex values aren’t particularly helpful, so I’ll draw the lineup as color swatches:


(show (walk "000000" colors))

The sorted sequence begins with the color closest to black and walks up through grays, then dark blues, dark yellows, all the reds, then light yellows, light grays, white, and finally light blues. This is enough to find several pairs of very similar colors, especially among the grays, but it wasn’t the clean, well-ordered color palette I was hoping for. The yellows, blues, and grays are split into light and dark groups, and the reds seem to start with dark reds, go through light reds, and end with a middling red. What I really wanted was colors batched into groups based on their dominant color. On to my second approach.

To figure out what group a color belongs in, we’ll scrap the above algorithm but keep thinking about colors as points in 3D space. Our color space is a cube 255 units on a side. If you’ve ever seen a color picker shaped like a hexagon, what you’ve really seen is this cube with the white corner pointing towards you and the black corner hidden behind it (there was probably a brightness slider to supply the missing dimension).

The eight vertices of this color cube are black (0, 0, 0), red (255, 0, 0), green (0, 255, 0), blue (0, 0, 255), yellow (255, 255, 0), magenta (255, 0, 255), cyan (0, 255, 255), and white (255, 255, 255).

Let’s start by categorizing colors based on which corner they’re closest to:


(def corners
  {:black [0 0 0]
   :red [255 0 0]
   :green [0 255 0]
   :blue [0 0 255]
   :yellow [255 255 0]
   :magenta [255 0 255]
   :cyan [0 255 255]
   :white [255 255 255]})

(defn corner-category [color]
  (key (apply min-key
         #(dist (rgb color) (val %))
         corners)))

Does it work?


(show-categories (group-by corner-category colors))

It’s definitely a good start. You can tell the app had a dark color theme. In fact, the blacks include what appears to be a yellow and a red, and possibly a blue. Also, our grays are still split into two segments, one we call “blacks” and one we call “whites”. They should really be considered ends of the same spectrum. And it looks like a yellow sneaked into the reds.

The idea is on the right track, but we need to modify it. Rather than thinking of colors as points in 3D space, let’s think of them as vectors. Instead of defining color groups based on eight points, let’s define them using seven vectors, one for each corner of the cube except black, which will serve as the origin where all the vector tails sit. The similarity between two colors, then, instead of being the distance between their coordinates, will be the angle between their vectors. Colors can be grouped based on which of the seven main vectors they’re closest to.

Let’s define a new categorizing function. We’ll calculate the angle using the geometric definition of dot product, with dist measuring the length of a vector.


(def color-vectors
  {:red [255 0 0]
   :green [0 255 0]
   :blue [0 0 255]
   :yellow [255 255 0]
   :magenta [255 0 255]
   :cyan [0 255 255]
   :gray [255 255 255]})

(defn angle [v1 v2]
  (js/Math.acos
    (/ (reduce + (map * v1 v2))
       (* (dist [0 0 0] v1)
          (dist [0 0 0] v2)))))

(defn category-by-vector [color]
  (key (apply min-key
         #(angle (rgb color) (val %))
         color-vectors)))

(show-categories (group-by category-by-vector colors))

Not perfect, but better. We still have two blues being counted as grays, but we’ve gotten the yellows and reds in the right places, and all the grays are finally in one group.

Let’s address those mis-categorized blues. Evidently there are colors that, while mathematically gray, are visibly non-gray. One way to account for that is to throw in a bias against gray. A color will have to be much closer to the gray vector to be counted as gray.


(defn category-by-vector-biased [color]
  (key (apply min-key
         (fn [[c v]]
           (let [a (angle (rgb color) v)]
             (if (= :gray c)
               (* 2 a)
               a)))
         color-vectors)))

(show-categories (group-by category-by-vector-biased colors))

The only oddity I see remaining is a color that looks like black listed among the cyans. I don’t think it can be helped; it is in fact less red than green and blue, making it a very dark cyan. It’s time to stop massaging the results.

In all our categorizing, we’ve lost sight of the original purpose: to find visually similar colors. Within a category, colors are listed in arbitrary order. Let’s go back to sorting them, this time within their categories. There’s no need for anything as sophisticated as walk to sort categorized colors; it’s enough merely to sort by distance from black.


(defn sort-groups-by [f grouped]
  (zipmap (keys grouped)
          (map #(sort-by f %) (vals grouped))))

(show-categories (sort-groups-by #(dist [0 0 0] (rgb %))
                 (group-by category-by-vector-biased colors)))

That’s the sort of color overview I was looking for!

If you look at the source code for Swatch, you’ll find some needless complexity. Instead of calculating angles between vectors, I calculated distances between points and lines. In this case, it provides the same measure of color similarity, but using the more complicated cross product instead of dot product and arc cosine.