So, this thought came to my mind, Wouldn’t it be great to have a physical version of the neurodiversity rainbow infinity symbol? Like a little animated thing that could decorate your desk or perhaps be worn as a badge. I haven’t found anyone yet who has made such a thing, so I made it myself. If you want to build one, the board design, code, and additional features are available on GitHub. If there seems to be a slight glitch in sourcing the boards and components, I may even have extra material lying around. The board consists of 27 WS2812B addressable RGB LEDs powered by an ATtiny412. A button on the back cycles through different colors, gradients, animation speeds, patterns, and brightness levels.

One troublesome aspect of these addressable LEDs is their low color resolution at reasonable brightness levels. It would be nice to have soft color changes without burning out anyone’s retinas. With digital images, a common strategy to increase perceived color resolution is to use dithering, patterning the available colors to simulate in-between values. Nice idea, but it really only works for displays of two or more dimensions with high pixel density. We don’t have that here.
We have pixels with potentially higher refresh rates (depending on the number of pixels and whether or not the microcontroller can manage that, which is the limiting factor here). If we can’t do spatial divergence, can we do temporal divergence instead? Apparently, LCD monitors do this, also known as frame rate controlSo totally doable, But how?
Temporary dilemma over extremely limited computation
There are many different methods of dithering, which are well illustrated yearn for Wikipedia article. Since, however, our colors are organized in one dimension and looking forward (or backward) in the sequence would be extremely compute or memory intensive, the noted properties of each method are present here quite differently. For our purposes, the choice will be between either ordered dither or error propagation. While ordered dither does not require any knowledge of adjacent values, it will require the values to have a known “temporary position”, which will be used as an index into the lookup table for comparison. Also I need to figure out a proper pattern to reduce the clump of on/off values, which seems complicated.
Written Update: Actually, it is quite easy. Suddenly my eyes fell on a paper of 1998. The only concern would be possible visible aliasing depending on how each pixel is offset. Maybe worth a try.
Next week’s update: Yes it is very easy. See section below for more details.
Alternatively, how easy would it be to propagate error? Exactly: In simple terms, calculate colors in high resolution, output the most significant bits of the color value, temporarily store the ignored, least significant bits, then add it to the calculated color of the following cycle. Or in pseudocode for a single 8-bit color:
uint8_t red_error;
while (true){
uint16_t red = calculateColor();
red += red_error;
red_error = red & 0xFF;
outputColor(red >> 8);
}
In this way, the output is flipped between its two nearest colors, preserving proportionality to the actual value (ignoring the non-linearity of perceived brightness).
This is a bit challenging to implement on a microcontroller with only 256B of RAM, even when handling only 27 pixels. For the final working version, I calculated the colors in 16-bit space, outputted in 8-bit, and packed the errors made for each pixel into 8-bit. RRRGGGBB Colour. This way, adding dithering requires only 27 additional bytes of global variables. Check out the repo for a proper description of how it all works. IMO the code is pretty well commented.
Other neat aspects of the code I’d like to point out
Since all animations in the project are cyclic, I initially calculated the color positions as wrapping [0.0 – 1.0] Floating point value. This couldn’t work because the ATtiny has no FPU, which made framerates unusually slow, and half of the MCU flash was filled with the float library. Instead, I re-mapped all the position values to a span of 16-bit unsigned integers, which is much faster to compute. An additional benefit of this switch was that values no longer had to be manually wrapped, as they now simply overflowed back to 0 + remainder.
In the standard Adafruit NeoPixel library (and seemingly all its derivatives), gamma compensation is calculated using a 256-byte lookup table, which is pre-computed from x.2.6 [x = 0 .. 1]It is not possible to do this for 16-bit integer space in the available flash space, Instead, here are three estimates of increasing accuracy and complexity:
uint16_t gamma(uint32_t value) {
// Normalized square
return (uint16_t)((value * value) >> 16);
}
uint16_t gamma(uint32_t value) {
// Normalized cube
return (uint16_t)((((value * value) >> 16) * value ) >> 16);
}
uint16_t gamma(uint32_t value) {
// Average of normalized square and square of square
uint32_t firstIteration = (value * value) >> 16;
uint32_t secondIteration = (firstIteration * firstIteration) >> 16;
return (uint16_t)((firstIteration + secondIteration) / 2); // Get average of the two
}
By default, the project uses the second function.
Using the digitalRead() Arduino function is surprisingly time intensive. Fortunately, setting up interrupts with MegaTinyCore is relatively painless. Thus, button inputs are never checked during routine operation, only after they have actually been used. On a sidenote, small AVR MCUs have configurable custom logic and onboard comparators that the cores also support. Not used here, but very neat.
MegaTinyCore makes it possible to remove standard Arduino millis() Function to save a bit of flash space, which was necessary for this project. This means that we cannot schedule animations or button interactions. Instead, the code depends on the consistency of the animation calculations. Again, due to the cyclic nature of the animation, this seems to work fine. To keep animations consistent across CPU frequencies (lower clock, lower minimum voltage for battery power), the speed constant is adjusted accordingly F_CPU steady.
UPDATE: Different ordering is much easier than error propagation, but worse
As mentioned above, ordered implementation becomes much easier than error propagation:
- Create an 8-bit dither pattern. There is an algorithm for this in the paper mentioned earlier. Here is a Python implementation.
- Initialize an 8-bit counter, incrementing for each
show(), - On setting pixel color, lookup pattern[counter + offset]And check if the output is less than the remainder of the value (which is already the least significant 8 bits if using 16 bit values as before). If so, increase the value before output.
This makes the code simpler, shorter and (as far as I can tell) faster. It also looks bad.
Even when using different values and pixel offsets from the counter, it is more fickle than the error propagation method. My suspicion is that since the colors are recalculated between each separate calculation, no single pattern is retained for any length of time, thereby introducing some form of aliasing.
One possible option could be to recalculate the colors only occasionally, which could increase framerate and stability. However this would require storing all 3*27*2=162 Pre-calculated bytes of color data. With current MCUs, this is not possible.
oh ok. worth a try.
