An efficient MicroPython library for E-Paper Display

I recently got a Waveshare E-Paper Display, in order to learn more about displays – programming and using them with microcontrollers. I chose specifically the e-paper because of its features like very low power consumption (up to 8mA only when refreshing) and preserving the state when unpowered. The latter can be especially handy for battery-powered sensor devices. Think of an environmental sensor that measures data only once in a while and sleeps in between, but with e-paper, it can display e.g. the last measurement all the time without affecting battery life. For starters, I got the pretty small, 1.54″ display, with three available colours, i.e. black, red, and no colour.

The Waveshare E-Paper Display (EPD) communicates via SPI, uses 6 pins for communication (which with power supply makes 8 in total). It comes with libraries for Arduino, Raspberry Pi (in Python and C++) and STM32, so you’re all set on these platforms. For other environments, well, tough luck.

MicroPython implementation

The Python library for Raspberry Pi uses PIL (Python Imaging Library) for displaying text and images. It’s awesome because it greatly simplifies the implementation. PIL is however not available in MicroPython, so I had to resort to drawing text using fonts defined in the code as arrays.


Here’s the example definition of a letter ‘A’ in a 24-pixel font:

# @2376 'A' (17 pixels wide)
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x1F, 0x80, 0x00, #    ######
0x1F, 0xC0, 0x00, #    #######
0x01, 0xC0, 0x00, #        ###
0x03, 0x60, 0x00, #       ## ##
0x03, 0x60, 0x00, #       ## ##
0x06, 0x30, 0x00, #      ##   ##
0x06, 0x30, 0x00, #      ##   ##
0x0C, 0x30, 0x00, #     ##    ##
0x0F, 0xF8, 0x00, #     #########
0x1F, 0xF8, 0x00, #    ##########
0x18, 0x0C, 0x00, #    ##       ##
0x30, 0x0C, 0x00, #   ##        ##
0xFC, 0x7F, 0x00, # ######   #######
0xFC, 0x7F, 0x00, # ######   #######
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #
0x00, 0x00, 0x00, #

Full font definition is available here. 3×24 = 72 bytes are required for a single character. Now multiply it by the number of printable ASCII characters (95, in the 32-127 range) and you’ll get 6840 items in the list. Even for the small 8-pixel font the list has 760 elements.

Similarly for images, to display an image you’ll need a full-screen bitmap, which in case of my small 200x200px display is a list with 200×200/8 = 5000 bytes.

This brings up performance problems on all possible fronts: loading a font module is terribly slow and it generates huge memory overhead. Have a look:

>>> from machine import Timer
>>> import time
>>> import gc
>>> chrono = Timer.Chrono()
>>> mem_free = gc.mem_free()
>>> chrono.start() # start a timer
>>> import font24
>>> chrono.stop() # stop a timer after a module has loaded
>>> print(mem_free - gc.mem_free())
>>> print(*1000)

Over 30kB of RAM and over 1.5s to load a module 😱. But we’re far from the optimal solution here. For starters, let’s replace the list with bytes – struct can be used for that:

>>> import ustruct as struct
>>> import font24
>>> data = struct.pack("%sB" % len(, *(

Now data is a bytes object with contents of Furthermore, bytes is an immutable data structure, which is exactly what we need for font files, and it can allow MicroPython interpreter to optimize it better.

The optimized font file looks like this. Well, you won’t easily consult the individual letters anymore, but that’s the price for it being FAST. Here’s how fast:

>>> chrono = Timer.Chrono()
>>> mem_free = gc.mem_free()
>>> chrono.start() # start a timer
>>> import font24
>>> chrono.stop() # stop a timer after a module has loaded
>>> print(mem_free - gc.mem_free())
>>> print(*1000)

More than 4 times faster to load, with roughly a third of an original memory footprint. 💥🚀😎

If this wasn’t enough, we can go further.

Frozen modules

A lengthy Python module, and especially a huge chunk of immutable binary data is a perfect candidate to be included in the firmware. It’s not a trivial task as it includes building a firmware image yourself, but it’s also not overly complicated. If it’s something you haven’t tried yet, I encourage you to give it a spin.

  1. Get the Pycom MicroPython source code from GitHub:
  2. Recommended: check out the most recent tag, so that you build the release image, and not the current live image from git:
    $ git tag # list available tags
    $ git checkout v1.17.2.b1
  3. Follow the instructions in README closely on how to set up the build environment (the The ESP32 version section) up to the point of building mpy-cross
  4. Actually, build mpy-cross according to instructions
  5. Enter esp32/frozen
  6. Copy there all the .py files you need to be frozen in firmware
  7. Go one level up, to esp32 directory
  8. Make a build for your board. In case of my LoPy4, the commands were:
    $ make BOARD=LOPY4 clean
    $ make BOARD=LOPY4 TARGET=boot -j9
    $ make BOARD=LOPY4 TARGET=app -j9
    $ make BOARD=LOPY4 flash

    Note on -j9: it is used to speed up building by parallelizing the compilation of independent objects. The number specifies the maximum number of jobs that can be run in parallel. As a rule of thumb, you can safely use -j5 on dual-core machines and -j9 on quad-core machines.

  9. Flash the image according to the README.

Now you don’t need your frozen modules’ files on the flash because they’re included in the firmware. Other than that you work with frozen modules the same way as with regular ones, i.e. you still import them using import statement, etc.

And here’s how a module performs after freezing:

>>> chrono = Timer.Chrono()
>>> mem_free = gc.mem_free()
>>> chrono.start() # start a timer
>>> import font24
>>> chrono.stop() # stop a timer after a module has loaded
>>> print(mem_free - gc.mem_free())
>>> print(*1000)

Here, the garbage collector is irrelevant because frozen modules are apparently already loaded into RAM together with the whole firmware, but as you can see, the improvement in import time is huge.

To recap the optimizations, we’ve tried:

  • list data: 36kB RAM, 1666ms to import
  • bytes data: 14kB RAM, 377ms to import
  • frozen bytes data: N/A (loaded with firmware), 16ms to import 

Also, with a simple test program that draws a couple of shapes, prints a few strings and displays an image, the total available RAM remaining after the program has finished is around 2.4MB (with 2.55MB available at startup after loading firmware), when before optimizations it was at around 1.8MB.


Library features

In the current state, the library supports only the 1.54 inch two-colour black and red Waveshare display, but adding support for other EPDs should be a simple task. I’ll maybe get some other unit to give it a try.

Apart from printing text, you can also draw lines (between any two points, i.e. not limited to horizontal and vertical) or rectangles and circles (both regular and filled).


Drawing images is available via filling the frame buffer with raw image bytes (which is the fastest possible way, especially if you freeze the image data in firmware). It’s not a very straightforward or flexible method though, as you have to convert your image to bytes beforehand. So I added support for loading a Windows-style 1-color BMP file directly. You can even display the bitmap at given coordinates, if it’s smaller than the screen size – this should be helpful when combining images with text.

The library is available from GitHub, and should you need the C/C++ version for plain ESP32, I also did it and it’s in a separate repository (although without the BMP file support). Let me know if you find any of it helpful and of course, contributions are welcome!




  1. Hi Dominik, a very usefull write-up, as ever! Surely lowers the barrier of getting into action with e-paper displays. On question on frozen modules. In my LoPy projects i used the mpy-cross_pycom.exe from to compile selected modules which works nice. What would be the difference/advantage/disadvantage to create complete builds for the board?


    1. Thanks Captain, glad to be of help! Freezing modules in firmware causes them to be loaded into RAM on device boot-up. This adds some overhead on initial memory consumption, but especially in case of Pycom devices with 4MB of RAM this is hardly a problem 😉 plus if you imported a module in runtime it would have anyway taken your RAM. I didn’t measure the performance of importing a precompiled module in runtime versus importing a frozen module, but I guess that the frozen module is stil faster (it’s already in the memory). Plus, I can imagine that a frozen module could have smaller memory footprint than a dynamically loaded precompiled module, because it occupies a contiguous memory region together with the rest of firmware. At least that’s how I understand it – I’ll maybe do some more testing to verify it, but I’m travelling right now and don’t have access to hardware.

      Liked by 1 person

      1. Makes perfectly sense Dominik. Maybe working with precompiled modules is faster to compile and upload when debugging and finally freeze the ready product in firmware would be optimal. Have a safe trip!

        Liked by 1 person

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s