Skip to content

Play GIF animation on e-paper displays - #11720

Closed
mikeysklar wants to merge 12 commits into
adafruit:epaperdisplay-fastfrom
mikeysklar:epaperdisplay-video
Closed

mikeysklar wants to merge 12 commits into
adafruit:epaperdisplay-fastfrom
mikeysklar:epaperdisplay-video

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

GIFs can now play as 1-bit video on monochrome e-paper, through displayio.

Why

A stock e-paper update takes about 6 seconds, too slow for animation.

Part 3 of 3. Needs #11718 and #11719; their commits show here until they merge.

import displayio, videocore, videogif
import ssd1683_lag
display = ssd1683_lag.make_display(3)  # lag_frames=3, seconds_per_frame=0
gif = videogif.GifDecoder("/teapot_1bit_hq.gif", bits_per_pixel=1)
video = videocore.VideoBuffer(bytearray(50 * 300))
root = displayio.Group()
root.append(video)
display.root_group = root
video.play(gif)
while True:
    if not display.busy:
        display.refresh()
lag3_nf1_hq_autoclean_wipes.mp4

Feather RP2040 ThinkInk with the 4.2" panel, filmed on an earlier build of this stack. The script also ran a wipe every 4 s and a clean every 15 s.

30 s as code.py, then Ctrl-C. Prompt 3.65 s later:

code.py output:
Traceback (most recent call last):
  File "code.py", line 12, in <module>
KeyboardInterrupt:

Code done running.

Changes

Commit Change
Allow 1 bit per pixel sources Rows padded to a byte. Plain buffer targets only
Decode GIFs to 1 bit per pixel bits_per_pixel=1, white when bright
Per-board GIF width limit Default stays 320. A board that raises it to 800 pays 480 B per open GIF
Show 1-bit plain buffers on monochrome displays New draw path
Document that a rotated display raises ValueError
Enable on Feather RP2040 ThinkInk Video on, GIF width 800, lag on

Results

Feather RP2040 ThinkInk, 4.2" SSD1683, Ubuntu host, lag 3, 120-frame GIF at 20 ms. No camera.

Adafruit CircuitPython 11.0.0-alpha.2, built from this branch.

Setup Panel updates per second
No cleanups, 3 x 20 s 19.5-19.6
Script wipes every 4 s and cleans every 15 s, 60 s 18.7 steady, 13.2 average, 6 wipes, 3 cleans
For scale: stock displayio full refresh, same panel 0.16 (6.26 s each)
Measure Value
Frames arriving during an update Replaced by newer ones, so the panel skips frames, never falls behind
GIF file 128,432 B
Frame decode 16.3 ms median, 113 ms max (loop restart)
Time left for Python, no panel 24%
Python heap free while playing 57.5 KB
ThinkInk flash free, all 3 PRs 180,948 B

Test files

Test GIF: teapot_1bit_hq.gif (400x300, 120 frames at 20 ms) teapot_1bit_hq
Test driver: ssd1683_lag.py (needs a lag build, see #11719)
# ssd1683_lag: stand-in for a driver library. Constructs EPaperDisplay with lag drive on the
# Feather RP2040 ThinkInk + 4.2" SSD1683, 1-frame 125 Hz balanced LUT. Needs firmware built with
# CIRCUITPY_EPAPERDISPLAY_LAG. The display cleans itself: before its first refresh and when it is
# released or the code stops.
import board, busio, displayio, epaperdisplay, fourwire

NO_PINGPONG = bytes([0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
VSS, VSH1, VSL = 0x00, 0x40, 0x80


def seq(c, data=b"", delay=None):
    n = len(data)
    hi = (n >> 8) | (0x80 if delay is not None else 0)
    return bytes([c, hi, n & 0xFF]) + data + (bytes([delay]) if delay is not None else b"")


def make_lut(frames, fr):
    def one(vs):
        g = bytearray(42)
        g[0:7] = bytes([1, vs | frames, 0, 0, 0, 1, 0])
        return g

    lut = bytearray(227)
    lut[0:42] = one(VSS)  # LUTC
    lut[42:84] = one(VSS)  # WW
    lut[84:126] = one(VSH1)  # BW
    lut[126:168] = one(VSL)  # WB
    lut[168:210] = one(VSS)  # BB
    lut[224] = fr
    return lut


START = (
    seq(0x12, delay=0x32) + seq(0x18, b"\x80") + seq(0x11, b"\x03") + seq(0x01, b"\x2b\x01\x00")
    + seq(0x32, make_lut(1, 0x05))  # 1 frame, frame rate byte 0x05 = 125 Hz
    + seq(0x3F, b"\x07") + seq(0x03, b"\x17") + seq(0x04, b"\x41\xa8\x32") + seq(0x2C, b"\x30")
    + seq(0x21, b"\x00\x00") + seq(0x3C, b"\x80") + seq(0x37, NO_PINGPONG)
    + seq(0x22, b"\xc0") + seq(0x20)
)
# Full refresh with the waveform stored in the panel. Runs with both RAM planes white.
CLEAN = (
    seq(0x12, delay=0x32) + seq(0x3C, b"\x05") + seq(0x18, b"\x80") + seq(0x11, b"\x03")
    + seq(0x44, b"\x00\x31") + seq(0x45, b"\x00\x00\x2b\x01") + seq(0x01, b"\x2b\x01\x00")
    + seq(0x1A, b"\x5a\x00") + seq(0x22, b"\x91") + seq(0x20) + seq(0x22, b"\xc7") + seq(0x20)
)
STOP = seq(0x10, b"\x01", delay=0x64)  # deep sleep, HV off
# Full RAM window before the update.
REFRESH = seq(0x44, b"\x00\x31") + seq(0x45, b"\x00\x00\x2b\x01") + seq(0x22, b"\x0c") + seq(0x20)


_spi = None


def release():
    # Finishes any owed drive, cleans, sleeps and frees the ring. The SPI would otherwise keep
    # the pins.
    global _spi
    displayio.release_displays()
    if _spi is not None:
        _spi.deinit()
        _spi = None


def make_display(lag=6):
    global _spi
    release()
    spi = _spi = busio.SPI(board.EPD_SCK, board.EPD_MOSI)
    bus = fourwire.FourWire(spi, command=board.EPD_DC, chip_select=board.EPD_CS,
                            reset=board.EPD_RESET, baudrate=20_000_000)
    return epaperdisplay.EPaperDisplay(
        bus, START, STOP, clean_sequence=CLEAN, width=400, height=300, ram_width=50, ram_height=300,
        busy_pin=board.EPD_BUSY, busy_state=True, seconds_per_frame=0,
        refresh_display_command=REFRESH, write_black_ram_command=0x24,
        write_previous_ram_command=0x26, keep_awake=True, lag_frames=lag,
        set_column_window_command=0x44, set_row_window_command=0x45,
        set_current_column_command=0x4E, set_current_row_command=0x4F,
        address_little_endian=True, two_byte_sequence_length=True)
Full serial output from the snippet above
soft reboot
code.py output:
Traceback (most recent call last):
  File "code.py", line 12, in <module>
KeyboardInterrupt:

Code done running.
Auto-reload is on. Simply save files over USB to run them or enter REPL to disable.

Press any key to enter the REPL. Use CTRL-D to reload.

Behavior

Display Result Evidence
Monochrome e-paper Draws Board
Sharp, depth 1 framebuffer Draws Source only
Two-RAM tricolor Lower layers show through, documented Source only
16-bit Unchanged Source only
Rotated ValueError, documented Source only
Frame lands mid-update One update can mix two frames; the next repaints it Source only
Panel stuck BUSY Loop can pause 30 s, then recovers (see #11719) Board, debug build

Scope

Enables this on Feather RP2040 ThinkInk only.

Flash cost to turn on elsewhere Feather RP2040 Feather ESP32-S3 4 MB flash
CIRCUITPY_EPAPERDISPLAY_LAG = 1 +7.7 KB +5.2 KB
CIRCUITPY_VIDEOCORE = 1 +9.2 KB +7.6 KB
CIRCUITPY_GIFIO_MAX_WIDTH = 800 0, plus 480 B heap per open GIF 0, same heap
All three +16.9 KB, 181 KB left +12.8 KB, 1,030 KB left

All 41 RP2350 boards also fit with all three on. The tightest, the WIZnet Pico 2 boards, keep 39 KB.

Panel safety

Video uses the fast updates from #11719, so its warning applies here: updates run far past panel makers' refresh limits, need a balanced waveform, and long-term wear on the panel is unknown.

AI assistance

Written with Claude Code.

🤖 Generated with Claude Code

Packed pixels with each row padded to a whole byte, so a 1-bit source can
stream into a plain buffer. Framebuffer targets stay whole bytes per pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bits_per_pixel argument picks RGB565 or packed 1-bit frames, for e-paper.
A pixel is white when its luma is at least half, so a black and white GIF
converts exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AnimatedGIF refuses anything wider than 320 pixels, so 400x300 and 800x480
e-paper animations would not open. CIRCUITPY_GIFIO_MAX_WIDTH stays 320 by
default, so other boards don't pay for a longer line buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy whole bytes when the rects line up and no layer above claimed the pixels.
Polarity and bit order already match a monochrome EPaperDisplay: MSB first,
1 = white. Falls back to a per-pixel copy that honors the mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This is the board the 1 bit GIF numbers come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar
mikeysklar changed the base branch from main to epaperdisplay-fast November 2, 2026 20:00
@mikeysklar
mikeysklar marked this pull request as ready for review November 2, 2026 20:18
mikeysklar and others added 5 commits November 2, 2026 14:18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dhalbert
dhalbert requested a review from tannewt November 3, 2026 16:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Replaced by #11740, opened from an adafruit branch so the three PRs stack.

@mikeysklar mikeysklar closed this Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant