Flicker free Animated eyes with arduino nano on color LCD display  

*in progress

ST7735 128x160 :  https://www.youtube.com/shorts/sn7in3z2dGE

ST7789v 240x320 : https://www.youtube.com/shorts/wXXFZ2pYmOo

https://github.com/intellar/tft_face_display


Overview

In this project, I aim to recreate the look of a robot face with animated eyes using an Arduino Nano and a low-cost color LCD screen. LCD screens are budget-friendly and efficient at displaying vibrant images but can be slow to update. Achieving the fast redraw speeds necessary for smooth animation presents a significant challenge with these devices.

Material

The project was realized using an Arduino Nano and a leftover LCD display from a previous project. The Arduino Nano is a smart choice; it's relatively inexpensive, supports SPI communication, but has limited power. This is balanced by its large user base and the abundance of example code available, which can significantly speed up development. The display is a 1.8" color LCD with a resolution of 128x160, based on the ST7735 chipset, and is very affordable—you can find it on AliExpress for less than $3. It's the red board on the right in the figure below.

The first display used in the project was a 1.8" color lcd 128x160, ST7735, SPI interface. 16bit colors (RGB565)

The second display used in the project was a 2.0" color lcd 240x320, ST7789v,  SPI interface. 16 bit colors (RGB565)

Wiring the ST7735 display to the nano is fairly simple. In my setup, I used the D7,D8,D9 pins of the arduino for the display A0, reset, CS.  The SPI communication is done with the arduino D11,D13,   linked to SCK,SDA of the display. The vcc and gnd are linked to the 5v and Gnd pins of the arduino. Lastly, the 3v pin of arduino is linked to the LED pin of the display. You get: 

//pin connection

//ST7735: Arduino

//LED   :  3v

//SCK   :  d13

//SDA   :  d11

//A0    :  d7

//RESET :  d8

//CS    :  d9

//GND   :  gnd

//VCC   :  5v

The next pictures show my setup. 

Wiring the st7735 to the arduino, display side. The 8 pins are needed for operation. 

Wiring the st7735 to the arduino, arduino side 

Wiring the st7735 to the arduino, arduino side

When wired to the display, the Arduino can easily draw shapes of different colors, such as squares, triangles, and ellipses, using adafruit gfx library for the st7735 (https://github.com/adafruit/Adafruit-ST7735-Library). I use Arduino IDE 2.3.3 to program the arduino nano in this project, which is the latest version of the IDE at the moment. you can use the IDE to download the library automatically, or donwload it yourself from github and copy it in library folder of the IDE here:

C:\Users\Intellar\OneDrive\Documents\Arduino\libraries\Adafruit-ST7735-Library

 The IDE will find it if its in the library folder. When this is done, you can start the display :


#include <Adafruit_ST7735.h> //https://github.com/adafruit/Adafruit-ST7735-Library

Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_A0, TFT_RST);


[...]  


tft.fillScreen(ST7735_BLACK);


If you do this, with the same "budget" 128x160 display, you might get garbage band on the display border, like this,

These band result from a bad alignment in the adafruit library for this 128x160 display. I did not have time to debug this and found an alternative library from Bodmer,  (https://github.com/Bodmer/TFT_ST7735). You clone, or download and unzip the github repositery, and copy the content to your arduino library folder, 

C:\Users\Intellar\OneDrive\Documents\Arduino\libraries\TFT_ST7735

This library is correctly aligned to the display size, and is faster. With a correctly setup display, we can start the drawing part. 

Drawing

In the project we plan to draw animated eyes that will give the illusion of a interactive robot. The eye illusion is accomplished by rendering ellipses in succession while changing their sizes before each render. 

In the first version/attempt, the lcd content was cleared by drawing a uniform black rectangle, and then the eyes were drawn one by one.  Then, the lcd is cleared by drawing the background, and the eyes are redrawn with a different size. This is a very simple way to create the animation and it worked well in the oled display project. However, with this new hardware, the lcd images give a strong flickering when drawing the image sequence. The first part of the video demonstrate this problem. It as if the eye a continuously blinking. 

The flicker appears when we clear the image before drawing the eyes. To clear, the lcd is filled row by row with a uniform color. This takes a couple of milliseconds to finish, and then we draw an ellipse over this black region, which also take a couple of milliseconds. Then, we clear and redraw over and over again, creating the flicker. The following image sequence illustrate the problem with a blue background.  

clear and draw first eye

first eye done, draw second eye

draw second eye

done

clear (Flicker!), now start drawing first eye

draw second eye

To overcome the flicker, the eyes are drawn over the last eyes in a single pass, drawing black where there is background, and the color of the eye at their current position/size. It is important to never draw over the same pîxel in the current pass.

The drawing function is simple, we go though all the pixels in a rectangle region covering slightly more than the eye area.  If a pixel in outside the ellipse, it is draw with the background region, and if its inside, is get the color of the eye. In the project code, the ellipse is described by its center (x_center,y_center), its axis (a,b) and its color. We don't need support for inclined ellipses.

The lcd is optimized to draw continuous horizontal line segment with the same color. The value are quickly pushed over the SPI connection, much faster than when they are pushed one by one. So pixel must be draw as horizontal linear segment. In the code, this translate to a x,y coordinate, a width and a color.  


tft.drawFastHLine(x,y,w,color);

So to draw the ellipse, we need the (x,y) coordinate of the boundaries. Processing all vertical coordinate "y" encompassing the ellipse, we can compute the "x" coordinate of the ellipse at this position. 

We use the discriminant of the ellipse, at coordinate (y) to know if a the ellipse is present at this height. We search for the coordinate "x" at this "y", solving the ellipse equation. There is a solution if the discriminant D is greater than 0. The discriminant expression is: 

D = (1 - (y_ - y_center)*(y_ - y_center) / (b*b))

when D is greater than 0, we can solve for the coordinates x, there are 1 or 2 solutions, for the top/bottom or each side of the ellipse:

x 0= x_center - a * sqrt(D)

x1 = x_center + a * sqrt(D)

when the "x" coordinates are know, we only have to draw a line from x0 to x1 at "y" . thus you get sommething like drawFastHLine(x0,x1-x0,color)

Now it this project, the eyes are special, they have 2 colors, a light blue surrounded by a darker blue . We have to draw both ellipses, the outside and inside one without ever overdrawing each one. The trick is to use the fact that the inside ellipse is share the same center as the outside one, it becomes simple to know when we are inside the second ellipse, to stop drawing the outside one and drawing the inside one. The following picture shows the idea. See the function drawFilledInscribedEllipse in the code for the details. 

Once this is coded, you get the picture on the right shown below, but there is something new here, a background image. 

Drawing is done in raster pass to avoid redrawing multiple times the same pixel with a different color in the same pass

The result !


Background image

I added a background to have something resembling the astro bot face, but it's not a nice as the real thing. This is rendered using an static const array that stores all the pixel values of the image. The arduino can store large array using the PROGMEM declaration, like this: 


static const uint16_t face2_sm[] PROGMEM  = {0x0000, 0x0000, 0x0000, 0x632c, 0xef9e, 0xffff,


This array contains all the pixels colors of the rectangle encompassing the image. In this case the image is 64x70. Note that you need a special function to access the array, here we read uint16 , so use pgm_read_word


  int w = FACE2_SM_WIDTH;

  int h = FACE2_SM_HEIGHT;

  for(uint16_t i=0;i<h;i++)

  {

    for(uint16_t j=0;j<w;j++)

    {

      uint16_t v = pgm_read_word(&face2_sm[i*w+j]);

      tft.drawPixel(2,2,v);

    }

  }

This draws a very small image, I need it to fill the whole height, increasing its size by a factor of 2x. The image can be drawn at a bigger size, using a pixel binning approach, like this:

  for(uint16_t i=0;i<h;i++)

  {

    for(uint16_t j=0;j<w;j++)

    {

      uint16_t v = pgm_read_word(&face2_sm[i*w+j]);

      tft.drawPixel(2*j,2*i,v);

      tft.drawPixel(2*j,2*i+1,v);

      tft.drawPixel(2*j+1,2*i,v);

      tft.drawPixel(2*j+1,2*i+1,v);

    }

  }


To generate the array, I made a python script that reads a png, and convert the RGB888  24bits color pixels in a 1D array containing all the pixels of the image as uint16  (16bit) colors using the RGB565 format. This means a RGB (red green blue) color is encoded using 5 bits for red, the 6bits green, and 5bits for blue in a 16bits variable. I used bitshift and logic operator to construct the rgb565 variable, stack them in an array and write a text file that will declare the array for the arduino ide c code format. This is the python rgb888 to rgb565 conversion. :

rgb565 = (rgb[0] & 0b1111100000000000) | (rgb[1] & 0b1111110000000000) >> 5 | (rgb[2] & 0b1111100000000000) >> 11)

As the project was progressing, it became clear the 128x160 was not sufficient for a good image quality.  The image below show a zoom of the face and a video of the animation, we clearly need higher resolution.

Zoom of the render on the 128x160 display


Higher resolution  : 240x320  with ST7789v

To obtain a better resolution, I ordered a new 2" display doubling the vertical and horizontal resolution. Rule of thumb, doubling something is usually perceptible.  This display has 7 pins and is powered by the 3v pin of the arduino. I connected it as follow: 

//pin connection

//ST7789v : Arduino

//VCC     :  3v

//SCL     :  d13

//SDA     :  d11

//DC      :  d7

//RESET   :  d8

//CS      :  d9

//GND     :  gnd


With this new resolution,  there is a place for a better resolution image. So a 240x237 pixel image of the face was generated to completely fill the 240 pixel of the vertical dimension and added to the code. It turns out this is not enough, the 240x237 pixels still occupie 113 kbytes (240x237x16bits) out of a total of 32k available. We need to drastically compress the image. 

Image compression

The second challenge is the limited memory capacity of the Arduino Nano. Storing a 240x320 color image directly isn’t feasible. To optimize memory usage, I developed three compression methods to obtain a significant reduction (10x), without reducing the actual size of the image, and allowing it to be included in the Arduino IDE code

The next figure show the original image, and the compressed image.

Before compression

After compression, 10x ratio (Quantization, RGB332, RLE). The yellow tint appear at the RGB888->RGB332 conversion. It could be closer to the original without affecting the compression ratio, but this will do for now. 

Once the image is available in the Arduino, it is straightforward to draw. The RLE format is directly compatible with the drawFastHLine(x0,x1-x0,color) function. We never have to decompress the image!  as its drawn, we only need to convert the RGB332 to RGB888, keeping track of the current position in the array, and convert this position to x,y coordinate in the image. The following code does this. rgb332_to_rgb565 does the unit8 (rgb332) to unit16 (rgb565) color conversion. It is called by draw_rle as the image is processed. Ind2sub convert an index to a row,col position in a matrix given the size of the matrix.  

and the draw_rle function simply goes through the array and draw horizontal line segment at the position and using count for the width. Here there was a trick in the encoding. The value in the array are uint8, meaning they can represent numbers from 0-255. So any count higher than 255 had to be splitted into multiples value,count. This is trnasparent for the decoding.


uint16_t rgb332_to_rgb565(uint8_t rgb332) {

    uint16_t rgb565;

    // Extract RGB components from the uint8 value

    uint8_t red = rgb332 & 0xE0; //11100000

    uint8_t green = rgb332 & 0x1C; //00011100

    uint8_t blue = rgb332 & 0x03; //00000011

    // Convert RGB components to RGB565 format

    rgb565 = ((red << 8) | (green << 6) | blue << 3);

    return rgb565;

}


void ind2sub(const uint32_t sub,const int cols,const int rows,int &row,int &col)

{

   row=sub/cols;

   col=sub%cols;

}

int draw_image_rle_conv(uint8_t* image_rle, uint32_t image_rle_length, int h, int w, uint32_t index_img)

{

  uint16_t count = 0;

  uint8_t val_b = 0;

  uint16_t val = 0;

 

  int x=0;

  int y=0;

  for(uint32_t ind_rle=0;ind_rle<image_rle_length;ind_rle+=2)

  {

    count = pgm_read_byte(&image_rle[ind_rle]);

    val_b = pgm_read_byte(&image_rle[ind_rle+1]);

    if(val_b>255)

      val_b = 255;  

    val = rgb332_to_rgb565(val_b);    

    ind2sub(index_img,w,h,y,x);

    drawFastHLine(x,y,count,val);

    index_img+=count;

  }


  return index_img;

 

}




Here are some snapshot of the final faces and some of the eyes that can be drawn. The resolution is defenitly much better.

 An finally, here is a video showing all the animation in the project.  There are some limitation with the hardware, the rendering is not fast enought. I am currently evaluating other processor to drive the lcd, the arduino nano might not have been the best choice. The ESP32 could be a better alternative.