Practical animation on I2C SSD1306 displays
I can hear the thought forming in your head already, "Why write another blog post about those ubiquitous little OLED displays, isn't this old news already?" It's a valid question, and there have been plenty of articles and libraries written for these displays over the last 6+ years. It's still new to me because I just started working with AVR MCUs and all of the common parts in the Arduino ecosystem not too long ago. I still see fun challenges with these parts because I encounter my favorite phrases while searching for projects - "It can't be done" or "That's as good as it gets". In this case, I came across a few new projects which would benefit from some animated graphics on a small, inexpensive display and MCU. The existing projects I found use uncompressed graphics and can only fit a few frames on an ATMega328.
Ideally, it would be great to be able to play animated GIF files right on an Arduino, but there are some insurmountable problems with that idea. Animated GIFs are a pretty efficient way to pack multiple images together, but the LZW compression uses a 'dictionary' with up to 12-bit (4096) entries and the frame-to-frame changes can require that the previous frame is kept in RAM. These two conditions mean that you usually need at least 32K of RAM to decode them. So far, I haven't seen anyone accomplish this on AVR microcontrollers.
The common Arduino Uno (ATMega328) only has 2K of RAM. This is "huge" compared to some of the other MCUs. A good example is the ATtiny85, one of my favorite AVRs both because of its capabilities and its constraints. It presents some daunting challenges for a project like this. With only 512 bytes of RAM and maybe 6K of free flash space, that doesn't leave very much to play detailed animations. My challenge with this project is to create a system for playing complex animations on a SSD1306 OLED using an ATtiny85. The other challenge is not just the memory size, but the communications bandwidth. The I2C version of the SSD1306 is usually less expensive than the SPI version, it also has the advantage of needing only 2 GPIO lines to control it. This is especially helpful when paired with an ATtiny85 since it only has 5 or 6 available I/O pins. The down side is that the data rate is slow. When running at the 400Khz "fast" I2C speed, the SSD1306 can only run at about 23 frames per second (with the MCU doing nothing else). In my previous blog post (Fast SSD1306 OLED drawing with I2C bit banging), I came up with a way of speeding up access to the SSD1306 by breaking some of the rules of I2C. Reducing the amount of data transmitted per frame is another way of increasing the frame rate.
The challenge to be solved is to compress image data in a way that the player doesn't need much (or any) RAM and at the same time, reduce the amount of data sent to the display since I2C is (normally) a slow interface. I decided that I would start by using animated GIF files as input since they're so ubiquitous and there are plenty of good tools to generate them. Many years ago, I wrote my own animated GIF player, so decoding the images is taken care of. The animated GIF standard is very simplistic and ends up being much less efficient than the way that video codecs work. Each GIF frame in a sequence is a normal GIF image that can be smaller than the overall image and positioned anywhere within the frame. This reduces the amount of data for sequences where the entire frame doesn't change. One thing this doesn't address well is when there are a few pixels changed in multiple areas (e.g. worst case: upper left and lower right corners). In this case, an entire frame will get compressed to account for a few pixels.
Video codecs do a better job in these situations by dividing the image into blocks and allowing unchanged blocks to be skipped. I tested a couple of ways of compressing the data. One involved breaking the image into 8x8 pixel blocks and using a flag map to indicate if a block changed. Within that block, additional flags indicated repeating bytes, etc. This turned out to be less efficient than treating the data as a continuous stream of bytes and indicating skipped (identical to previous frame), copy as-is, and repeats. This concept also works well for not requiring RAM of the player app. As the compressed data is decoded, it can be written directly to the display. The skipped bytes don't need to be known since the write pointer (cursor) of the display can be moved over the skipped data. The scheme I created is certainly not perfect, but it does solve the problems I set out to solve. If anyone has ideas for improvements to it, please let me know.
The compression scheme I created consists of command bytes followed by data. Each command byte uses the upper 1 or 2 bits to define the type and the lower bits to define the count:
00SSSCCC - skip then copy, 3-bit counts represent the values 0-7, followed by the byte(s) to copy 01CCCSSS - copy then skip, followed by the bytes to copy 10RRRSSS - repeat then skip, 3-bit counts representing values 0-7, followed by the byte to repeat 11RRRRRR - repeating byte (count = 1 to 64), followed by the byte to repeat 00000000 - long skip followed by the amount to skip (1-256) 01000000 - long copy followed by the amount to copy (1-256)
This simple scheme is able to compress the data, on average, between 3 and 6 to 1. It also allows the player code to be extremely simple (takes up very little flash storage) so that something like an ATtiny85 can hold the player code, the compressed animation data and still have room left for some other program code.
Here's a video of it in action with a simple 11-frame animation sequence:
I have several different I2C SSD1306 displays, different colors from different vendors. Through my testing of this code, I discovered that they're not all equal. In order to reduce the amount of data sent to the display, I configure the displays to use the horizontal mode of addressing. In this mode, when data is written past the end of a line, the address automatically increments to the start of the next line. This is different than the more popular page mode which wraps the write pointer to the beginning of the same line/page. The 2 figures below show the difference between horizontal and page mode addressing.
It turns out that some of these OLEDs support the horizontal mode differently than others. What I found is that on the 'bad' displays, the address only wraps to the next line if more than 128 bytes of data is written in a single I2C transaction. This doesn't work for my compression scheme since it normally writes only a few bytes at a time. I had to add specific code to work around this issue for displays that don't behave correctly. I'm not sure if I should just leave it there to make sure it works on all displays or allow the user to optionally enable it. The eternal optimizer in me wants to save the extra time and data bytes by using the properly behaving displays when possible. In the ATtiny85 code, I added a macro called "BAD_DISPLAY". Define this if your display has this problem. Here are images of the same animation code with and without the BAD_DISPLAY macro on my white OLED with the problem:
The source code to the player app (both Linux and Arduino) is ready. The repo contains code that was tested on a Raspberry Pi Zero and an ATtiny85 --> github. The compressor app is a bit more complicated because it relies on my closed-source imaging library. If you're using a Linux board, remember to set the I2C speed to 400Khz or animations will run very slowly.
Here's the animator code running on a Raspberry Pi Zero (set to 400Khz I2C Speed). To change from the default speed of 100Khz, add this line to /boot/config.txt:
I tried setting speeds higher than 400Khz, but they were ignored.
I decided to roll this code into my ss_oled library. This makes it easier to use on any supported display and any supported system. I also made it very simple to make use of the code with an easy to use API. You can immediately get started by running the sample sketch I created.