Skip to content

Play GIF animation on e-paper displays - #11740

Open
mikeysklar wants to merge 58 commits into
epaperdisplay-fastfrom
epaperdisplay-video
Open

mikeysklar wants to merge 58 commits into
epaperdisplay-fastfrom
epaperdisplay-video

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Nov 3, 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, stacked on #11739, which is stacked on #11738.

import displayio, videocore, videogif
import ssd1683_lag
display = ssd1683_lag.make_display()  # fast=True, lag_frames=4
gif = videogif.GifDecoder("/teapot_1bit_hq.gif")
video = videocore.VideoBuffer(bytearray(50 * 300), bits_per_pixel=1)
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.

Change Behavior
VideoBuffer(bits_per_pixel=1) Rows padded to a byte, white when bright. Plain buffers only
Per-board GIF width limit Default stays 320. Width 800 costs 480 B per open GIF
1-bit buffers on monochrome displays New draw path. Rotated displays raise ValueError
Feather RP2040 ThinkInk Video on, GIF width 800
Tested on Feather RP2040 ThinkInk, 4.2" SSD1683, lag 4 Result
Panel updates per second, 60 s 17.4, stock 0.16
7 ways code can end, 3 each 21/21 back to REPL, 3.6 to 6.9 s
Frame decode 16.3 ms median, 113 ms max
Flash free, all three PRs 180 KB

Test files

Test GIF: teapot_1bit_hq.gif (400x300, 120 frames at 20 ms) teapot_1bit_hq
Test driver: ssd1683_lag.py (needs a fast build, see #11739)
# 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_FAST. 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=4):
    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, fast=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)
Display Result
Monochrome e-paper Draws (board)
Sharp, tricolor, 16-bit, rotated From source only, see docs

Video uses the fast updates from #11739, so its panel warning applies here.

AI assistance

Written with Claude Code.

🤖 Generated with Claude Code

mikeysklar and others added 25 commits November 1, 2026 21:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a refresh finishes, write the same areas again with this command so
the controller's previous image RAM matches the screen. This lets SSD168x
style partial updates diff against what is actually shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keep_awake display ends a transaction still holding its bus when the VM ends, since
Ctrl-C can leave one open and the stop sequence would otherwise wait for the bus forever.
The stop sequence is also guarded when it follows a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous RAM holds frame N-lag instead of N-1, so each changed pixel is
driven for lag_frames updates, the same number toward black and toward white.
Only the box around changed bytes is sent. clean_sequence runs before the first
refresh and when the display is released, leaving the panel white and balanced.

A waveform or panel reset cut short leaves the panel dark, so while one is under
way an interrupt waits and is raised when it ends. A send it stops starts over.
Waits are bounded per update so a stuck panel can't hang shutdown, and the
watchdog is fed while interrupts are held.

CIRCUITPY_EPAPERDISPLAY_LAG gates it, off by default and on for Feather RP2040
ThinkInk. clean_sequence is accepted on every build. lag_frames raises ValueError
where the drive is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings every pixel to white, optionally runs clean_sequence, then drives every
pixel frames updates each way. For scripts to remove ghosting while running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release_displays() lost the owed drive and the stop sequence silently when
something held the bus for the whole 30 s bound. Wait for the bus first, and raise
with every display untouched, so the script can free it and try again. The end of
the VM can't catch an exception, and doesn't need to: nothing can still hold the
bus by then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait used the whole deadline, so a panel a reset would free got no
clean. Reset once, wait once more with no renewal, and run the stored
waveform if BUSY falls. If it stays high, nothing more is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The faster refresh options (write_previous_ram_command, keep_awake and
update_refresh_mode's refresh_sequence) cost about 900 bytes, which no
longer fits on several SAMD21 and SAMD51 boards. With the flag off,
EPaperDisplay builds as it did before. Lag drive needs it.

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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mikeysklar and others added 6 commits November 4, 2026 08:15
It was only on for the Feather RP2040 ThinkInk, but external panels need it
too. It costs about 7 KB of flash and 80 bytes of RAM. The frame memory is
only allocated when lag_frames is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each frame is driven toward once and away from once, lag steps later.
Adds a small example of a pixel that changes back early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk
# Conflicts:
#	shared-module/videogif/GifDecoder.c
@mikeysklar
mikeysklar requested a review from tannewt November 4, 2026 21:59
mikeysklar and others added 2 commits November 5, 2026 14:28
epaperdisplay-fast was rebased onto main after #11738 merged, and lag
drive now builds with CIRCUITPY_EPAPERDISPLAY_FAST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread py/circuitpy_mpconfig.mk Resolved
Comment thread shared-bindings/videocore/VideoBuffer.c Resolved
mikeysklar and others added 3 commits November 9, 2026 16:56
The AnimatedGIF line buffer was a fixed MAX_WIDTH array in GIFIMAGE, so the
widest GIF was a build setting. Make it a pointer and allocate
iCanvasWidth bytes after GIF_init(): on the VM heap for gifio.OnDiskGif,
with port_malloc for videogif.GifDecoder. This drops
CIRCUITPY_GIFIO_MAX_WIDTH and the 320 pixel limit.

Reject frames that extend past the right edge of the canvas, since the
buffer is now exactly canvas wide.

Also trim the VideoBuffer bits_per_pixel docstring per review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread shared-module/gifio/OnDiskGif.c Resolved
Comment thread lib/AnimatedGIF/AnimatedGIF_circuitpy.h Resolved
mikeysklar and others added 6 commits November 10, 2026 18:28
GIF_init now takes the line buffer and its size, and fails with
GIF_TOO_WIDE if the canvas is wider. Passing NULL and 0 only measures
the canvas, so OnDiskGif and videogif measure, allocate, then init.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	shared-bindings/videocore/VideoBuffer.c
#	shared-module/videocore/VideoBuffer.c
Map each screen pixel back to the source the same way the 16-bit copy
does. Keep the whole-byte copy when source x runs forward along screen
x, and read bit by bit otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gifio line buffer change pushed the ru build 4 bytes over flash.
The dongle has no display, so gifio isn't useful there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Merged main and resolved the conflict with #11829.

The 1-bit copy now follows display rotation too, using the same mapping as the 16-bit path.

Tested on a Feather RP2040 ThinkInk,.

Rotation Video matches TileGrid
0 Yes
90 Yes
180 Yes
270 Yes

Also turned off gifio on the following due to lack of flash:

  • makerdiary nRF52840 dongle

mikeysklar and others added 4 commits November 11, 2026 10:45
My local uncrustify reindented one line of the macro; put it back the way
main has it so CI's formatter passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main now turns off msgpack on nRF52840 internal-flash boards, which frees
far more than the 4 bytes the ru build was over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar
mikeysklar requested a review from tannewt November 12, 2026 03:34
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.

2 participants