Now that we have everything set up, let's dig into the example code. To start with, we have a lot of code imports. Let's go over those:

We start with os so that we can access a list of files in the operating system, time for doing delays, digitalio for button access and display pins, board so we can see the pin definitions, and the Image and ImageOps modules for using Pillow. Then we import all of the displays so that we have a variety of displays to choose from. Since we're not so limited on memory on the Raspberry Pi, it works fine to just import everything.

Download: file
import os
import time
import digitalio
import board
from PIL import Image, ImageOps
import adafruit_rgb_display.ili9341 as ili9341
import adafruit_rgb_display.st7789 as st7789        # pylint: disable=unused-import
import adafruit_rgb_display.hx8357 as hx8357        # pylint: disable=unused-import
import adafruit_rgb_display.st7735 as st7735        # pylint: disable=unused-import
import adafruit_rgb_display.ssd1351 as ssd1351      # pylint: disable=unused-import
import adafruit_rgb_display.ssd1331 as ssd1331      # pylint: disable=unused-import

Next we have the buttons. The defaults are the most common setup, but a few of the displays vary and there is a number right next to the button. If you would like to change it, this is the correct place.

Download: file
# Change to match your display
BUTTON_NEXT = board.D17
BUTTON_PREVIOUS = board.D22

If you had the 1.14" Mini PiTFT, you would change it to something like the following:

Download: file
BUTTON_NEXT = board.D23
BUTTON_PREVIOUS = board.D24

Next, we need to set up the pins that the display will use. We are using the defaults for the PiTFT, though you could do the same thing with a breakout board. If you have a Mini PiTFT, change reset_pin to None.

Download: file
# Configuration for CS and DC pins (these are PiTFT defaults):
cs_pin = digitalio.DigitalInOut(board.CE0)
dc_pin = digitalio.DigitalInOut(board.D25)

# Set this to None on the Mini PiTFT
reset_pin = digitalio.DigitalInOut(board.D24)

Next, we have a convenience function for initializing the button with the internal pull-up resistor enabled.

Download: file
def init_button(pin):
    button = digitalio.DigitalInOut(pin)
    button.switch_to_input()
    button.pull = digitalio.Pull.UP
    return button

Next we define a Frame class. This probably could have been done with a named tuple, since there are no methods, but classes are very readable and it gives the flexibility to add methods. The Frame class is used for holding the image and the duration of the frame. This allows for displaying animated Gifs with variable durations.

Download: file
class Frame:
    def __init__(self, duration=0):
        self.duration = duration
        self.image = None

Now to get into the AnimatedGif class, which does most of the work. The reason for going with a class is that there are a lot of shared variables that are used in functions and this modularizes everything. A good indicator of whether to use a class is if you need to use the global keyword to modify variables.

In the __init__ function, it is mostly setting each of the internal variables to their defaults and initializing the buttons. If the width and height are passed in, those are used, otherwise they are extracted from the display object. We also allow the folder name to be passed in and if that is present, we will load all the files and automatically run it.

Download: file
class AnimatedGif:
    def __init__(self, display, width=None, height=None, folder=None):
        self._frame_count = 0
        self._loop = 0
        self._index = 0
        self._duration = 0
        self._gif_files = []
        self._frames = []

        if width is not None:
            self._width = width
        else:
            self._width = display.width
        if height is not None:
            self._height = height
        else:
            self._height = display.height
        self.display = display
        self.advance_button = init_button(BUTTON_NEXT)
        self.back_button = init_button(BUTTON_PREVIOUS)
        if folder is not None:
            self.load_files(folder)
            self.run()

Next, we have a pair of functions to either advance to the next image or go back. They use the modulus operator (%) to see if they go beyond the end of the range allowing them to loop back.

Download: file
def advance(self):
    self._index = (self._index + 1) % len(self._gif_files)

def back(self):
    self._index = (self._index - 1 + len(self._gif_files))  % len(self._gif_files)

Next up is the load_files function which will grab anything that ends in a .gif extension and then go through that list to check if it an animated Gif. Anything that is an animation is added to the self._gif_files list. If none are found, it prints a message and exits.

Download: file
def load_files(self, folder):
    self._gif_files = [f for f in os.listdir(folder) if f.endswith('.gif')]
    for gif_file in gif_files:
        image = Image.open(gif_file)
        # Only add animated Gifs
        if image.is_animated:
            self._gif_files.append(gif_file)

    print("Found", self._gif_files)
    if not self._gif_files:
        print("No Gif files found in current folder")
        exit()

The preload function is a bit more complex, so we will break that up a little bit more. It starts by opening the current image and printing out a message to the console.

Download: file
def preload(self):
    image = Image.open(self._gif_files[self._index])
    print("Loading {}...".format(self._gif_files[self._index]))
    #continued...

It continues loading any meta data from the animated Gif such as loop and duration info if available.

Download: file
self._delay = image.info['duration']
if "loop" in image.info:
    self._loop = image.info['loop']
else:
    self._loop = 1
self._frame_count = image.n_frames
self._frames.clear()
#continued...

Then it loops through all the frames of the image. Inside the loop, it starts off by creating a frame object using the Frame class and sets the duration for the frame if available, otherwise it uses the default duration for the entire image.

Next it uses the Pillow ImageOps module's pad() function to shrink the image to fit on the screen and draws a black border where there is any extra space. To resize the image, the Nearest Neighbor method is used. To fill the entire screen and crop off parts of the image, then the fit() function could be used instead.

Finally it adds the frame object to the frames list.

Download: file
for frame in range(self._frame_count):
    image.seek(frame)
    # Create blank image for drawing.
    # Make sure to create image with mode 'RGB' for full color.
    frame_object = Frame(duration=self._duration)
    if "duration" in image.info:
        frame_object.duration = image.info['duration']
    frame_object.image = ImageOps.pad(image.convert("RGB"),
                                      (self._width, self._height),
                                      method=Image.NEAREST,
                                      color=(0, 0, 0),
                                      centering=(0.5, 0.5))
    self._frames.append(frame_object)

Now the play function will load and play the current image. It starts off by calling the preload() function. If there aren't any Gif images to play it will let you know.

Next it will start the looping of the animation and depending on the loop setting, it will either start counting down or continue indefinitely. If it gets down to 1, the looping will end and the function will return True indicating the run function should automatically advance to the next image. In between each frame, it will check the value of the buttons, move the index accordingly, and return False indicating that we already took care of changing the index and to just play the new index. The delay is taken care of using time.monotonic(), which returns a float value of the elapsed time. We use it to get the start time and compare it to the current time. This allows us to take image transfer time into account.

Download: file
def play(self):
        self.preload()

        # Check if we have loaded any files first
        if not self._gif_files:
            print("There are no Gif Images to Play")
            return False
        while True:
            for frame_object in self._frames:
                start_time = time.monotonic()
                self.display.image(frame_object.image)
                if not self.advance_button.value:
                    self.advance()
                    return False
                if not self.back_button.value:
                    self.back()
                    return False
                while time.monotonic() < (start_time + frame_object.duration / 1000):
                    pass

            if self._loop == 1:
                return True
            if self._loop > 0:
                self._loop -= 1

Finally we have the run function. Its job is to keep advancing through each of the images in the list and loop back to the start if we hit the end. It the buttons were used to change the image, then it should not automatically advance.

Download: file
def run(self):
    while True:
        auto_advance = self.play()
        if auto_advance:
            self.advance()

Now that we have the classes all defined, we just need a little more setup and it's time to run it. First we initialize the display. The default display is the ili9341, but if you have a different display, feel free to comment out that one and uncomment the one you do have.

Download: file
# Config for display baudrate (default max is 64mhz):
BAUDRATE = 64000000

# Setup SPI bus using hardware SPI:
spi = board.SPI()

# Create the display:
#disp = st7789.ST7789(spi, rotation=90                             # 2.0" ST7789
#disp = st7789.ST7789(spi, height=240, y_offset=80, rotation=90    # 1.3", 1.54" ST7789
#disp = st7789.ST7789(spi, rotation=90, width=135, height=240, x_offset=53, y_offset=40, # 1.14" ST7789
#disp = hx8357.HX8357(spi, rotation=180,                           # 3.5" HX8357
#disp = st7735.ST7735R(spi, rotation=90,                           # 1.8" ST7735R
#disp = st7735.ST7735R(spi, rotation=270, height=128, x_offset=2, y_offset=3,   # 1.44" ST7735R
#disp = st7735.ST7735R(spi, rotation=90, bgr=True,                 # 0.96" MiniTFT ST7735R
#disp = ssd1351.SSD1351(spi, rotation=180,                         # 1.5" SSD1351
#disp = ssd1351.SSD1351(spi, height=96, y_offset=32, rotation=180, # 1.27" SSD1351
#disp = ssd1331.SSD1331(spi, rotation=180,                         # 0.96" SSD1331
disp = ili9341.ILI9341(spi, rotation=90,                           # 2.2", 2.4", 2.8", 3.2" ILI9341
                       cs=cs_pin, dc=dc_pin, rst=reset_pin, baudrate=BAUDRATE)

Next we have some code to swap the width and height parameters depending on the rotation. This is necessary because RGB_Display prefers to draw images that take up the full screen.

Download: file
if disp.rotation % 180 == 90:
    disp_height = disp.width   # we swap height/width to rotate it to landscape!
    disp_width = disp.height
else:
    disp_width = disp.width
    disp_height = disp.height

Finally we create the AnimatedGif object. By passing it the current folder, it will automatically run.

Download: file
gif_player = AnimatedGif(disp, width=disp_width, height=disp_height, folder=".")

Full Code

"""
Example to extract the frames and other parameters from an animated gif
and then run the animation on the display.

Usage:
python3 rgb_display_pillow_animated_gif.py

This example is for use on (Linux) computers that are using CPython with
Adafruit Blinka to support CircuitPython libraries. CircuitPython does
not support PIL/pillow (python imaging library)!

Author(s): Melissa LeBlanc-Williams for Adafruit Industries
"""
import os
import time
import digitalio
import board
from PIL import Image, ImageOps
import numpy  # pylint: disable=unused-import
import adafruit_rgb_display.ili9341 as ili9341
import adafruit_rgb_display.st7789 as st7789  # pylint: disable=unused-import
import adafruit_rgb_display.hx8357 as hx8357  # pylint: disable=unused-import
import adafruit_rgb_display.st7735 as st7735  # pylint: disable=unused-import
import adafruit_rgb_display.ssd1351 as ssd1351  # pylint: disable=unused-import
import adafruit_rgb_display.ssd1331 as ssd1331  # pylint: disable=unused-import

# Change to match your display
BUTTON_NEXT = board.D17
BUTTON_PREVIOUS = board.D22

# Configuration for CS and DC pins (these are PiTFT defaults):
cs_pin = digitalio.DigitalInOut(board.CE0)
dc_pin = digitalio.DigitalInOut(board.D25)

# Set this to None on the Mini PiTFT
reset_pin = digitalio.DigitalInOut(board.D24)


def init_button(pin):
    button = digitalio.DigitalInOut(pin)
    button.switch_to_input()
    button.pull = digitalio.Pull.UP
    return button


# pylint: disable=too-few-public-methods
class Frame:
    def __init__(self, duration=0):
        self.duration = duration
        self.image = None


# pylint: enable=too-few-public-methods


class AnimatedGif:
    def __init__(self, display, width=None, height=None, folder=None):
        self._frame_count = 0
        self._loop = 0
        self._index = 0
        self._duration = 0
        self._gif_files = []
        self._frames = []

        if width is not None:
            self._width = width
        else:
            self._width = display.width
        if height is not None:
            self._height = height
        else:
            self._height = display.height
        self.display = display
        self.advance_button = init_button(BUTTON_NEXT)
        self.back_button = init_button(BUTTON_PREVIOUS)
        if folder is not None:
            self.load_files(folder)
            self.run()

    def advance(self):
        self._index = (self._index + 1) % len(self._gif_files)

    def back(self):
        self._index = (self._index - 1 + len(self._gif_files)) % len(self._gif_files)

    def load_files(self, folder):
        gif_files = [f for f in os.listdir(folder) if f.endswith(".gif")]
        for gif_file in gif_files:
            image = Image.open(gif_file)
            # Only add animated Gifs
            if image.is_animated:
                self._gif_files.append(gif_file)

        print("Found", self._gif_files)
        if not self._gif_files:
            print("No Gif files found in current folder")
            exit()  # pylint: disable=consider-using-sys-exit

    def preload(self):
        image = Image.open(self._gif_files[self._index])
        print("Loading {}...".format(self._gif_files[self._index]))
        if "duration" in image.info:
            self._duration = image.info["duration"]
        else:
            self._duration = 0
        if "loop" in image.info:
            self._loop = image.info["loop"]
        else:
            self._loop = 1
        self._frame_count = image.n_frames
        self._frames.clear()
        for frame in range(self._frame_count):
            image.seek(frame)
            # Create blank image for drawing.
            # Make sure to create image with mode 'RGB' for full color.
            frame_object = Frame(duration=self._duration)
            if "duration" in image.info:
                frame_object.duration = image.info["duration"]
            frame_object.image = ImageOps.pad(  # pylint: disable=no-member
                image.convert("RGB"),
                (self._width, self._height),
                method=Image.NEAREST,
                color=(0, 0, 0),
                centering=(0.5, 0.5),
            )
            self._frames.append(frame_object)

    def play(self):
        self.preload()

        # Check if we have loaded any files first
        if not self._gif_files:
            print("There are no Gif Images loaded to Play")
            return False
        while True:
            for frame_object in self._frames:
                start_time = time.monotonic()
                self.display.image(frame_object.image)
                if not self.advance_button.value:
                    self.advance()
                    return False
                if not self.back_button.value:
                    self.back()
                    return False
                while time.monotonic() < (start_time + frame_object.duration / 1000):
                    pass

            if self._loop == 1:
                return True
            if self._loop > 0:
                self._loop -= 1

    def run(self):
        while True:
            auto_advance = self.play()
            if auto_advance:
                self.advance()


# Config for display baudrate (default max is 64mhz):
BAUDRATE = 64000000

# Setup SPI bus using hardware SPI:
spi = board.SPI()

# pylint: disable=line-too-long
# Create the display:
# disp = st7789.ST7789(spi, rotation=90,                            # 2.0" ST7789
# disp = st7789.ST7789(spi, height=240, y_offset=80, rotation=180,  # 1.3", 1.54" ST7789
# disp = st7789.ST7789(spi, rotation=90, width=135, height=240, x_offset=53, y_offset=40, # 1.14" ST7789
# disp = hx8357.HX8357(spi, rotation=180,                           # 3.5" HX8357
# disp = st7735.ST7735R(spi, rotation=90,                           # 1.8" ST7735R
# disp = st7735.ST7735R(spi, rotation=270, height=128, x_offset=2, y_offset=3,   # 1.44" ST7735R
# disp = st7735.ST7735R(spi, rotation=90, bgr=True,                 # 0.96" MiniTFT ST7735R
# disp = ssd1351.SSD1351(spi, rotation=180,                         # 1.5" SSD1351
# disp = ssd1351.SSD1351(spi, height=96, y_offset=32, rotation=180, # 1.27" SSD1351
# disp = ssd1331.SSD1331(spi, rotation=180,                         # 0.96" SSD1331
disp = ili9341.ILI9341(
    spi,
    rotation=90,  # 2.2", 2.4", 2.8", 3.2" ILI9341
    cs=cs_pin,
    dc=dc_pin,
    rst=reset_pin,
    baudrate=BAUDRATE,
)
# pylint: enable=line-too-long

if disp.rotation % 180 == 90:
    disp_height = disp.width  # we swap height/width to rotate it to landscape!
    disp_width = disp.height
else:
    disp_width = disp.width
    disp_height = disp.height

gif_player = AnimatedGif(disp, width=disp_width, height=disp_height, folder=".")
This guide was first published on Jan 08, 2020. It was last updated on Jan 08, 2020.
This page (Python Code) was last updated on Jul 07, 2020.