Thinking too hard about Color

Oh distraction my old friend, I know you so well.

The past few weeks I’ve been working on building a tabletop weather dashboard. There are a few parts to this exercise:

  • A web app to read and format forecast data from my awesome WeatherFlow Tempest.
  • A Raspberry Pi setup with an attached 5″ LCD to display the dashboard.
  • A driftwood frame to present the electronics in an aesthetically-ok package.

All of these sub-projects are started, but none are finished. I’ve actually written way more code for the Tempest than I actually need — there’s a wrapper for the REST API, a WebSocket client that receives push notifications, and an archiving process that will store individual and aggregate readings over time in a sql database. (Note if you want to build this stuff, you’ll need to sync the “jdk11” branch as it relies on the WebSocket class only available in recent JDK versions.) Apparently I’m kind of a weather nerd, and the Tempest is such a great piece of hardware…. I learned a lot and I’m sure I’ll use all of it for something someday. But for this project I’m actually just using the Get Forecast endpoint to read current conditions and hourly / daily predictions.

Right now I’m working on laying out the forecast display on the little 800×600 LCD. Part of the plan is to set a “meaningful” background color that conveys current air temperature at a glance … red for hot, blue for cold, that kind of thing. Turns out that this is not nearly as simple as it sounds — I ended up spending two days down the rabbit hole. But it was super-interesting, and hey, nobody’s paying me for this stuff anyways.

It started like this. If “blue” is cold and “red” is hot, let’s just pick minimum (say 0°F) and maximum (say 100°F) values, and interpolate from blue to red based on where the current temperature is on that scale. I kind of remembered that this doesn’t work, but wrote the code anyways because, well, just because. Linear interpolation is pretty easy, you’re just basically transposing a number within one range (in our case 0 – 100 degrees) into another range. Colors are typically represented in code as a triple: red, green and blue each over a range from 0-255. So (0,0,255) is pure blue and (255,0,0) is pure red. Interpolating a color between blue and red for, say, 55°F just looks like this:

interpolatedRed = startRed + ((endRed - startRed) * (currentTemp / (highTemp – lowTemp));
interpolatedRed = 0 + ((255 - 0) * (55 / (100 - 0)) = 140.25;

…and similarly for green and blue, which lands us at (140,0,115), which looks like this and is your first indication that we have a problem. 55°F does not feel “purple.” OK, you can kind of fix this by using two scales, blue to green for 0-50°F and green to red for 50-100°F. That’s better, but still not very good. Here’s how these first two attempts look along the full range:

Note: all the code for these experiments is on github in Interpolate.java. It compiles with javac Interpolate.java, then run java Interpolate to see usage.

The second one does kind of give you a sense of cold to hot … but it’s not great. Around 30°F and 80°F the colors are really muddy and just wrong for what they’re supposed to convey. You also get equivalent colors radiating from the middle (e.g., 40°F and 65°F look almost the same), which doesn’t work at all.

It turns out that interpolating colors using RGB values is just broken. Popular literature talks about our cone receptors as red, green and blue — but they really aren’t quite tuned that way. And our perception of colors is impacted by other non-RGB-ish factors as well, which makes intuitive sense from an evolutionary perspective — not all wavelengths are equivalently important to survival and reproduction. So what to do?

Back in the thirties, a system of color representation called HSB (or HSV or HSL) was created for use in television. H here is “hue”, S is “saturation” and B is “brightness.” Broadcasters could emit a single signal encoded with HSB, and it would work on both color and black-and-white TVs because the “B” channel alone renders a usable monochrome picture. In the late seventies — and I’m quite sure my friend Maureen Stone was right in the thick of this — folks at Xerox PARC realized that HSB would be a better model for human-computer interaction, at least partly because the H signal is much more aligned with human perception of gradients. (Maureen, please feel free to tell me if/where I’ve screwed up this story because I’m barely an amateur on the topic.)

OK, so let’s try a linear interpolation from blue to red the same as before, but using HSB values instead. Conveniently the built-in Java Color class can do conversions between RGB and HSB, so this is easy to do and render on a web page. Here’s what we get:

Well hey! This actually looks pretty good. I would prefer some more differentiation in the middle green band, but all things considered I’m impressed. Still, something doesn’t make sense: why does this interpolation go through green? It is a nice gradient for sure, but if I’m thinking about transitioning from blue to red, I would expect it to go through purple — like our first RGB attempt, just less muddy.

There is always more to learn. “Hue” is a floating point value on a 0.0-1.0 scale. But it’s actually an angular scale representing position around a circle – and which “direction” you interpolate makes a difference. The simple linear model happens to travel counter-clockwise. If you augment this model so that it takes into account the shortest angular distance between the endpoints, you end up with a much more natural blue-red gradient:

Of course, it doesn’t end there. Even within the HSB model, human perception of brightness and saturation is different for different hues. The gold standard system for managing this is apparently HCL (Hue-Chroma-Luminance), which attempts to create linear scales that very closely match our brains. Unfortunately it’s super-expensive to convert between HCL and RGB, so it doesn’t get a ton of use in normal situations.

Where does all this leave my little weather app? Funny you should ask. After having a grand old time going through all of this, I realized that I didn’t really want the fully-saturated versions of the colors anyways. I need to display text and images on top of the background, so the colors need to be much softer than what I was messing around with. Of course, I could still do this with math, but it was starting to seem silly. Instead I sat down and just picked a set of ten colors by hand, and was waaaaay happier with the results.

Always important to remember the law of sunk costs.

So at the end of the day, the background of my weather station is colored one of these ten colors. At least to me, each color band “feels” like the temperature it represents. And they all work fine as a background to black text, so I don’t have to be extra-smart and change the text color too.

An entertaining dalliance — using code to explore the world is just always a good time. Now back to work on the main project — I’m looking forward to sharing it when it’s done!