Practical animation on I2C SSD1306 displays

Intro
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.

What
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.

Why
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.

How
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:


Postscript
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.
figures taken from the Solomon SysTech SSD1306 datasheet

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:

dtparam=i2c_arm=on,i2c_arm_baudrate=400000


I tried setting speeds higher than 400Khz, but they were ignored.

Update
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.

Comments

  1. Thank you very much for such excellent article!

    I tried to port to oled_animate.ino to ESP32 but no luck in getting it to work...

    Since I am new to I2C and do not know how to do the Arduino's equivalent direct port on ESP32, I changed the i2cBegin()/i2cWrite()/i2cEnd()/oledInit() functions to use Wire functions like this:

    //
    // Transmit a byte and ack bit
    //
    static inline void i2cByteOut(byte b)
    {

    #if defined(DIRECT_HARDWARE_PORT)

    byte i;
    byte bOld = I2CPORT & ~((1 << BB_SDA) | (1 << BB_SCL));

    for (i=0; i<8; i++)
    {
    bOld &= ~(1 << BB_SDA);
    if (b & 0x80)
    bOld |= (1 << BB_SDA);
    I2CPORT = bOld;
    DELAY_CYCLES(DELAY);
    I2CPORT |= (1 << BB_SCL);
    DELAY_CYCLES(DELAY);
    I2CPORT = bOld;
    b <<= 1;
    } // for i
    // ack bit
    I2CPORT = bOld & ~(1 << BB_SDA); // set data low
    DELAY_CYCLES(DELAY);
    I2CPORT |= (1 << BB_SCL); // toggle clock
    DELAY_CYCLES(DELAY);
    I2CPORT = bOld;

    #else

    Wire.write(b);

    #endif

    } /* i2cByteOut() */

    void i2cBegin(byte addr)
    {

    #if defined(DIRECT_HARDWARE_PORT)

    I2CPORT |= ((1 << BB_SDA) + (1 << BB_SCL));
    I2CDDR |= ((1 << BB_SDA) + (1 << BB_SCL));
    I2CPORT &= ~(1 << BB_SDA); // data line low first
    DELAY_CYCLES(DELAY);
    I2CPORT &= ~(1 << BB_SCL); // then clock line low is a START signal
    i2cByteOut(addr << 1); // send the slave address

    #else

    Wire.beginTransmission(addr);

    #endif

    } /* i2cBegin() */

    //
    // Send I2C STOP condition
    //
    void i2cEnd()
    {
    #if defined(DIRECT_HARDWARE_PORT)

    I2CPORT &= ~(1 << BB_SDA);
    I2CPORT |= (1 << BB_SCL);
    I2CPORT |= (1 << BB_SDA);
    I2CDDR &= ((1 << BB_SDA) | (1 << BB_SCL)); // let the lines float (tri-state)

    #else

    Wire.endTransmission();

    #endif

    } /* i2cEnd() */


    //
    // Initializes the OLED controller into "page mode"
    //
    void oledInit(byte bAddr, int bFlip, int bInvert)
    {
    unsigned char uc[4];
    unsigned char oled_initbuf[]={0x00,0xae,0xa8,0x3f,0xd3,0x00,0x40,0xa1,0xc8,
    0xda,0x12,0x81,0xff,0xa4,0xa6,0xd5,0x80,0x8d,0x14,
    0xaf,0x20,0x00};

    oled_addr = bAddr;

    #if defined(DIRECT_HARDWARE_PORT)

    I2CDDR &= ~(1 << BB_SDA);
    I2CDDR &= ~(1 << BB_SCL); // let them float high
    I2CPORT |= (1 << BB_SDA); // set both lines to get pulled up
    I2CPORT |= (1 << BB_SCL);

    #else

    Wire.begin(SDA_PIN, SCL_PIN);
    //Wire.setClock(700000);

    #endif

    I2CWrite(oled_addr, oled_initbuf, sizeof(oled_initbuf));
    if (bInvert)
    {
    uc[0] = 0; // command
    uc[1] = 0xa7; // invert command
    I2CWrite(oled_addr, uc, 2);
    }
    if (bFlip) // rotate display 180
    {
    uc[0] = 0; // command
    uc[1] = 0xa0;
    I2CWrite(oled_addr, uc, 2);
    uc[1] = 0xc0;
    I2CWrite(oled_addr, uc, 2);
    }
    } /* oledInit() */


    I guess that I made some mistake in these changes.

    Do you have a working Arduino version that uses the Wire library?

    If not, can you tell what am I doing wrong?

    Thanks and best regards!
    VĂ­tor Amorim

    ReplyDelete
  2. Your code looks ok. What result are you getting? Is it correctly initializing and clearing the display?

    ReplyDelete
  3. The display was black all the time.

    I have an ESP32 board with attached/builtin OLED display and it works fine with Adafruit SSD1306 library so I thought it could be the OLED init that could be failing.

    I changed the program to use:

    #include

    Adafruit_SSD1306 display(RESET_PIN);

    And changed the oledInit() function to:

    void oledInit(byte bAddr, int bFlip, int bInvert)
    {
    unsigned char uc[4];

    oled_addr = bAddr;

    display.begin(SSD1306_SWITCHCAPVCC, 0x3c, true, SDA_PIN, SCL_PIN, CLOCK_FREQ); // initialize with the I2C addr 0x3C (for the 128x64)

    if (bInvert)
    {
    uc[0] = 0; // command
    uc[1] = 0xa7; // invert command
    I2CWrite(oled_addr, uc, 2);
    }
    if (bFlip) // rotate display 180
    {
    uc[0] = 0; // command
    uc[1] = 0xa0;
    I2CWrite(oled_addr, uc, 2);
    uc[1] = 0xc0;
    I2CWrite(oled_addr, uc, 2);
    }
    } /* oledInit() */

    And it worked like a charm!

    It seems that the maximum FPS I can get is around 50/60 FPS using 700kHz clock for I2C. I know that is overclocking from the 400kHz "standard" but I've seen that clock speed in other OLED library and it seems to work fine. I will try to test with higher clock frequencies but I think this approach it like "brute force" for I2C.

    So I am looking for a way to have direct access to I2C in ESP32 so that greater FPS is possible, possibly with lower I2C clock like 400kHz.

    Thank you very much for responding to my comment and for publishing such fantastic posts!

    ReplyDelete

Post a Comment

Popular posts from this blog

Surprise! ESP32-S3 has (a few) SIMD instructions

How much current do OLED displays use?

Fast SSD1306 OLED drawing with I2C bit banging