Skip to content

WS2812B LED Wall Display

Time: 120 min | Prerequisites: SSH Login, Serial Bus Interfaces

Build a low-resolution LED wall display using WS2812B addressable LED panels, driven by a Raspberry Pi 4 (rendering) and a Pico 2 (real-time LED output). This tutorial teaches embedded architecture split: Linux handles the complex UI/rendering, while a microcontroller handles the hard real-time protocol.


Architecture

  Pi 4 (Linux)              UART 2 Mbaud           Pico 2 (MicroPython)
┌──────────────┐     TX  ─────────────────► RX    ┌──────────────────┐
│ Python /     │     RX  ◄───────────────── TX    │ NeoPixel PIO     │
│ SDL2 /       │     GND ────────────────── GND   │ parallel output  │
│ framebuffer  │                                  │                  │──► Lane 0
│ renderer     │                                  │  Serpentine      │──► Lane 1
│              │                                  │  mapping         │──► ...
│ send_frame() │                                  │                  │──► Lane 7
└──────────────┘                                  └──────────────────┘
                                                         │ │ │ │
                                                  ┌──────┴─┴─┴─┴─────┐
                                                  │ WS2812B Panels   │
                                                  │ 64 × 32 = 2048   │
                                                  │ addressable LEDs │
                                                  └──────────────────┘

Why two processors?

Requirement Pi 4 (Linux) Pico 2 (MicroPython)
Rendering SDL2, OpenCV, Python — rich ecosystem Limited graphics capability
Real-time timing Cannot guarantee 800 kHz ±150ns PIO hardware: cycle-accurate, zero jitter
Network/UI WiFi, SSH, web server — easy No networking

WS2812B requires a precisely timed 800 kHz one-wire protocol. Each bit is a pulse: 0.4µs HIGH + 0.85µs LOW for "0", or 0.8µs HIGH + 0.45µs LOW for "1". Linux's scheduler cannot guarantee this timing — a context switch during transmission corrupts the entire frame. The Pico 2's PIO (Programmable I/O) hardware generates these pulses with zero CPU involvement and zero jitter.

Why Not SPI for Pi↔Pico?

MicroPython's machine.SPI is master-only — it cannot act as an SPI slave to receive data from the Pi. UART works in both directions, is simple to configure, and at 2 Mbaud provides ~200 KB/s throughput — more than enough for 64×32 at 30 fps (6 KB/frame).

For higher-bandwidth designs (128×64+), consider USB serial (~1 MB/s) or a C SDK firmware with SPI slave via PIO.


1. Hardware Setup

1.1 Panel Options

WS2812B panels come in various sizes. Pick based on your target resolution:

Module LEDs Typical size Notes
8×8 64 65×65 mm Cheap, widely available
16×16 256 160×160 mm Good building block
8×32 256 65×320 mm Strip format, easy to tile
32×8 flexible 256 320×80 mm Bendable, good for curves

Reference build: 64×32 display from 8× panels of 8×32, or 2× panels of 16×16 arranged in a 4×2 grid.

1.2 Target Sizes and Frame Rates

Frame time is determined by the WS2812 protocol: 30 µs per LED per lane, plus a 280 µs reset/latch.

Size LEDs Lanes LEDs/lane Frame time Max FPS Use case
32×16 512 4 128 4.1 ms 240 Small status display
64×32 2048 8 256 7.9 ms 126 Good first build
96×48 4608 8 576 17.6 ms 57 Large dashboard
128×64 8192 16 512 15.6 ms 64 LED wall (advanced)
Warning

These FPS numbers assume parallel lane output. With MicroPython's sequential neopixel.write() calls, multiply the frame time by the number of lanes. For 8 lanes: 7.9ms × 8 = 63ms = ~16 fps. Still usable for animations, and a custom parallel PIO program (Challenge 5) can fix it.

1.3 Power

WS2812B draws up to 60 mA per LED at full white (20 mA per colour channel). For 2048 LEDs:

Maximum:  2048 × 60 mA = 123 A at 5V = 615 W  (theoretical, never happens)
Typical:  2048 × 15 mA = 30 A at 5V = 150 W   (mixed colours, ~25% brightness)
Safe PSU: 5V 40A (200W) with brightness cap in software
Warning

Power injection is critical. Do NOT power 2048 LEDs from one end — the 5V rail drops along the chain, causing colour shifts and dim LEDs at the far end. Inject 5V+GND every 256 LEDs (every lane endpoint). Use thick wires (≥18 AWG) for power runs.

1.4 Level Shifting

Pico 2 GPIO outputs 3.3V. WS2812B expects 5V logic (the datasheet says VIH ≥ 0.7 × VDD = 3.5V at 5V supply). While 3.3V sometimes works (many chips accept it), it's unreliable — especially with long wires or noisy power.

Use a level shifter: 74HCT245 (8-channel, bidirectional) or SN74AHCT125 (4-channel). Both accept 3.3V input and output 5V. One 74HCT245 handles all 8 lanes.

Pico GP2 ──► 74HCT245 A0 ──► B0 ──► Lane 0 (WS2812 data in)
Pico GP3 ──► 74HCT245 A1 ──► B1 ──► Lane 1
...
Pico GP9 ──► 74HCT245 A7 ──► B7 ──► Lane 7

74HCT245 VCC = 5V, GND = GND, DIR = VCC (A→B), OE = GND (enabled)

1.5 Wiring

Raspberry Pi 4              Pico 2                    LED Panels
┌─────────────┐          ┌──────────────┐
│ GPIO14 (TX) ├─────────►│ GP1 (UART RX)│
│ GPIO15 (RX) │◄─────────┤ GP0 (UART TX)│
│ GND         ├──────────┤ GND          │
└─────────────┘          │              │          ┌──────────────┐
                         │ GP2 ─────────┼──[LVL]──┤ Lane 0 DIN   │
                         │ GP3 ─────────┼──[LVL]──┤ Lane 1 DIN   │
                         │ GP4 ─────────┼──[LVL]──┤ Lane 2 DIN   │
                         │ GP5 ─────────┼──[LVL]──┤ Lane 3 DIN   │
                         │ GP6 ─────────┼──[LVL]──┤ Lane 4 DIN   │
                         │ GP7 ─────────┼──[LVL]──┤ Lane 5 DIN   │
                         │ GP8 ─────────┼──[LVL]──┤ Lane 6 DIN   │
                         │ GP9 ─────────┼──[LVL]──┤ Lane 7 DIN   │
                         └──────────────┘  [LVL] = 74HCT245
                                                   └──────────────┘
                                           5V PSU ──► Panel VCC (inject every 256 LEDs)
                                           GND    ──► Panel GND + Pico GND + Pi GND
Warning

All GNDs must be connected together: Pi GND, Pico GND, level shifter GND, LED panel GND, and PSU GND. Without a common ground, UART and level shifting will not work.


2. Pico 2 Firmware (MicroPython)

2.1 Single-Lane Test

Start with one NeoPixel chain to verify wiring and colour order:

# test_single.py — verify one LED chain
import neopixel, machine, time

NUM_LEDS = 256
pin = machine.Pin(2)
np = neopixel.NeoPixel(pin, NUM_LEDS)

# WS2812 uses GRB colour order internally, but MicroPython's
# neopixel module handles the conversion — pass (R, G, B).
print(f"Testing {NUM_LEDS} LEDs on pin {pin}")

# Red sweep
for i in range(NUM_LEDS):
    np[i] = (255, 0, 0)
    np.write()
    time.sleep_ms(10)

# All green
for i in range(NUM_LEDS):
    np[i] = (0, 255, 0)
np.write()
time.sleep(1)

# All blue
for i in range(NUM_LEDS):
    np[i] = (0, 0, 255)
np.write()
time.sleep(1)

print("Test complete")

If you see green when you expect red (or vice versa), the colour order is wrong. Some WS2812 clones use RGB instead of GRB. In MicroPython, NeoPixel takes a bpp parameter — try NeoPixel(pin, n, bpp=3) and swap channels manually if needed.

2.2 Serpentine Mapping

LED panels are wired in a serpentine (zigzag) pattern — even rows go left-to-right, odd rows go right-to-left:

Physical wiring order within one lane:

Row 0:  → LED 0   LED 1   LED 2   LED 3  ... LED 63
Row 1:  ← LED 127 LED 126 LED 125 LED 124 ... LED 64
Row 2:  → LED 128 LED 129 LED 130 LED 131 ... LED 191
Row 3:  ← LED 255 LED 254 LED 253 LED 252 ... LED 192

Logical (x, y) → physical LED index:
  if y is even:  index = y × WIDTH + x              (left to right)
  if y is odd:   index = y × WIDTH + (WIDTH - 1 - x)  (right to left)
def serpentine_index(x, y, width):
    """Convert (x, y) to physical LED index in a serpentine chain."""
    if y % 2 == 0:
        return y * width + x
    else:
        return y * width + (width - 1 - x)
Tip

Not all panels use the same serpentine direction. Some start right-to-left on row 0. Test with the single-lane script: light up LED index 0 and see which physical LED illuminates. If it's the top-right corner instead of top-left, swap the even/odd logic.

2.3 Multi-Lane Mapping

For a 64×32 display with 8 lanes, each lane drives a horizontal strip of 4 rows:

Full 64×32 display:

Lane 0: rows  0– 3  (256 LEDs)
Lane 1: rows  4– 7  (256 LEDs)
Lane 2: rows  8–11  (256 LEDs)
Lane 3: rows 12–15  (256 LEDs)
Lane 4: rows 16–19  (256 LEDs)
Lane 5: rows 20–23  (256 LEDs)
Lane 6: rows 24–27  (256 LEDs)
Lane 7: rows 28–31  (256 LEDs)
def pixel_to_lane(x, y, width=64, rows_per_lane=4):
    """Map (x, y) to (lane_index, led_index_in_lane)."""
    lane = y // rows_per_lane
    local_y = y % rows_per_lane
    led_idx = serpentine_index(x, local_y, width)
    return lane, led_idx

2.4 Full Firmware

# led_wall.py — Pico 2 LED wall controller (MicroPython)
#
# Receives RGB frames via UART from the Pi, maps pixels to
# serpentine panel layout, outputs via NeoPixel PIO.
#
# Upload to Pico 2 and rename to main.py for auto-start.

import machine, neopixel, time

# ── Configuration ────────────────────────────────────────
WIDTH = 64
HEIGHT = 32
ROWS_PER_LANE = 4
N_LANES = HEIGHT // ROWS_PER_LANE  # 8
LEDS_PER_LANE = WIDTH * ROWS_PER_LANE  # 256
FRAME_SIZE = WIDTH * HEIGHT * 3  # 6144 bytes
BRIGHTNESS = 0.3  # 0.0–1.0, limit power draw

LANE_PINS = [2, 3, 4, 5, 6, 7, 8, 9]
UART_BAUD = 2_000_000

# ── Hardware init ────────────────────────────────────────
uart = machine.UART(0, baudrate=UART_BAUD,
                    tx=machine.Pin(0), rx=machine.Pin(1),
                    rxbuf=FRAME_SIZE + 256)

lanes = [neopixel.NeoPixel(machine.Pin(p), LEDS_PER_LANE)
         for p in LANE_PINS]

# ── Pre-compute mapping table ────────────────────────────
# mapping[pixel_index] = (lane, led_index)
mapping = []
for y in range(HEIGHT):
    for x in range(WIDTH):
        lane = y // ROWS_PER_LANE
        local_y = y % ROWS_PER_LANE
        if local_y % 2 == 0:
            idx = local_y * WIDTH + x
        else:
            idx = local_y * WIDTH + (WIDTH - 1 - x)
        mapping.append((lane, idx))

# ── Frame protocol ───────────────────────────────────────
# Header: 0xFF 0x00 width_hi width_lo height_hi height_lo
# Payload: WIDTH × HEIGHT × 3 bytes (RGB, row-major)
HEADER = b'\xff\x00'

def read_exact(n):
    """Read exactly n bytes from UART, blocking."""
    buf = bytearray(n)
    pos = 0
    while pos < n:
        chunk = uart.read(min(512, n - pos))
        if chunk:
            buf[pos:pos + len(chunk)] = chunk
            pos += len(chunk)
    return buf

def receive_frame():
    """Wait for and receive one frame."""
    # Sync to header
    while True:
        b = uart.read(1)
        if b is None:
            continue
        if b[0] == 0xFF:
            b2 = uart.read(1)
            if b2 and b2[0] == 0x00:
                dims = read_exact(4)
                w = (dims[0] << 8) | dims[1]
                h = (dims[2] << 8) | dims[3]
                if w == WIDTH and h == HEIGHT:
                    return read_exact(FRAME_SIZE)

def apply_frame(data):
    """Map RGB data to lane NeoPixel buffers."""
    br = BRIGHTNESS
    for i, (lane, idx) in enumerate(mapping):
        off = i * 3
        r = int(data[off] * br)
        g = int(data[off + 1] * br)
        b = int(data[off + 2] * br)
        lanes[lane][idx] = (r, g, b)

def flush():
    """Write all lanes to LEDs."""
    for lane in lanes:
        lane.write()

# ── Startup pattern ──────────────────────────────────────
print(f"LED Wall: {WIDTH}x{HEIGHT}, {N_LANES} lanes, "
      f"{LEDS_PER_LANE} LEDs/lane")
print(f"UART: {UART_BAUD} baud, frame: {FRAME_SIZE} bytes")
print("Waiting for frames...")

# Show a dim test pattern on startup
for lane in lanes:
    for i in range(LEDS_PER_LANE):
        lane[i] = (1, 1, 1)  # dim white
    lane.write()

# ── Main loop ────────────────────────────────────────────
frame_count = 0
t0 = time.ticks_ms()

while True:
    data = receive_frame()
    apply_frame(data)
    flush()

    frame_count += 1
    elapsed = time.ticks_diff(time.ticks_ms(), t0)
    if elapsed >= 5000:
        fps = frame_count * 1000 / elapsed
        print(f"{fps:.1f} fps")
        frame_count = 0
        t0 = time.ticks_ms()

3. Pi-Side: Frame Sender

3.1 Enable UART

The Pi's UART is on GPIO14 (TX) and GPIO15 (RX). Enable it:

# Add to /boot/firmware/config.txt:
enable_uart=1
dtoverlay=disable-bt  # free up the full UART (otherwise Bluetooth uses it)

# Reboot
sudo reboot

# Verify
ls -l /dev/serial0  # should point to /dev/ttyAMA0

Install pyserial:

pip3 install pyserial

3.2 Rainbow Test

#!/usr/bin/env python3
"""send_frames.py — stream animated frames to the LED wall."""

import serial, struct, time, math, colorsys

WIDTH, HEIGHT = 64, 32
FRAME_SIZE = WIDTH * HEIGHT * 3
ser = serial.Serial('/dev/serial0', 2_000_000, timeout=0.1)

def send_frame(pixels: bytes):
    """Send one RGB frame to the Pico."""
    header = b'\xff\x00'
    dims = struct.pack('>HH', WIDTH, HEIGHT)
    ser.write(header + dims + pixels)

def rainbow(t):
    """Generate a scrolling rainbow frame."""
    data = bytearray(FRAME_SIZE)
    for y in range(HEIGHT):
        for x in range(WIDTH):
            hue = (x / WIDTH + y / HEIGHT * 0.5 + t) % 1.0
            r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
            off = (y * WIDTH + x) * 3
            data[off]     = int(r * 255)
            data[off + 1] = int(g * 255)
            data[off + 2] = int(b * 255)
    return bytes(data)

print(f"Streaming {WIDTH}×{HEIGHT} rainbow @ 2 Mbaud")
count, t0 = 0, time.time()

while True:
    send_frame(rainbow(time.time() * 0.3))
    count += 1
    if time.time() - t0 >= 2.0:
        print(f"{count / 2:.0f} fps")
        count, t0 = 0, time.time()

3.3 Framebuffer Capture

Mirror what's on the Pi's display to the LED wall — downscale from 800×480 to 64×32:

#!/usr/bin/env python3
"""fb_to_wall.py — capture the Pi's display and send to LED wall."""

import mmap, serial, struct, time

WIDTH, HEIGHT = 64, 32
FB_W, FB_H = 800, 480  # Pi display resolution
BPP = 4  # ARGB8888

fb = open('/dev/fb0', 'rb')
mm = mmap.mmap(fb.fileno(), FB_W * FB_H * BPP, access=mmap.ACCESS_READ)
ser = serial.Serial('/dev/serial0', 2_000_000)

def capture_and_scale():
    """Nearest-neighbour downscale from framebuffer to LED resolution."""
    data = bytearray(WIDTH * HEIGHT * 3)
    for y in range(HEIGHT):
        for x in range(WIDTH):
            fb_x = x * FB_W // WIDTH
            fb_y = y * FB_H // HEIGHT
            off = (fb_y * FB_W + fb_x) * BPP
            # BGRA → RGB
            b, g, r = mm[off], mm[off + 1], mm[off + 2]
            led_off = (y * WIDTH + x) * 3
            data[led_off] = r
            data[led_off + 1] = g
            data[led_off + 2] = b
    return bytes(data)

def send_frame(pixels):
    ser.write(b'\xff\x00' + struct.pack('>HH', WIDTH, HEIGHT) + pixels)

print(f"Mirroring /dev/fb0 ({FB_W}×{FB_H}) → LED wall ({WIDTH}×{HEIGHT})")
while True:
    send_frame(capture_and_scale())
    time.sleep(1 / 30)  # cap at 30 fps
Note

This captures whatever is on the framebuffer — SDL2 apps, the console, even the audio visualizer. Run audio_viz_full on the HDMI display and fb_to_wall.py simultaneously to see the spectrum on the LED wall.


4. Performance and Debugging

4.1 Bandwidth Budget

UART throughput at 2 Mbaud (8N1):
  Raw: 2,000,000 / 10 bits = 200,000 bytes/s
  Frame: 6 bytes header + 6144 bytes payload = 6150 bytes
  Max FPS: 200,000 / 6150 ≈ 32 fps

WS2812 output (8 lanes, sequential write):
  Per lane: 256 × 30 µs = 7.68 ms
  All 8 lanes (sequential): 8 × 7.68 = 61.4 ms
  Max FPS: 1000 / 61.4 ≈ 16 fps  ← this is the bottleneck

The sequential neopixel.write() limits throughput. The UART can deliver 32 fps but the LED output can only consume 16 fps. See Challenge 5 for a parallel PIO solution.

4.2 Common Issues

Symptom Cause Fix
No LEDs light up Level shifter not powered, GND not connected Check 5V on 74HCT245 VCC, all GNDs joined
First few LEDs correct, rest garbled Signal integrity, long wires Add 100Ω series resistor on data line, shorten wires
Colours wrong (R↔G swapped) WS2812 variant with different colour order Try swapping R and G in apply_frame()
Last LEDs dim or wrong colour Voltage drop along chain Inject 5V power at multiple points
Flickering at high FPS UART buffer overrun on Pico Increase rxbuf size, reduce Pi send rate
Frame tearing (partial updates) Frame sync lost Check header bytes, add CRC for reliability

4.3 GRB Colour Order

Warning

WS2812B internally uses GRB colour order, not RGB. MicroPython's neopixel module handles this conversion — you pass (R, G, B) tuples and it sends GRB on the wire. If you write a custom PIO program or use C, you must swap the bytes yourself.


5. Advanced: Custom DRM Driver (Optional)

For advanced users who want the LED wall to appear as a real Linux display (/dev/dri/cardN):

The kernel's drivers/gpu/drm/tiny/ directory has small DRM drivers that serve as templates. A minimal LED wall driver would:

  1. Use drm_simple_display_pipe_funcs for a single-plane, single-CRTC setup
  2. Expose one fixed mode (e.g., 64×32 @ 16 Hz)
  3. On atomic update: read the shadow framebuffer, package as UART frame, send to Pico

This is ~300 lines of kernel C. The benefit: any Linux app that outputs to a display (console, SDL2, Qt) works on the LED wall without modification. The cost: kernel driver development and maintenance.

Recommendation: Start with the userspace approach (Sections 2–3). Only consider a DRM driver if you need the wall to be a first-class Linux monitor.


Challenges

Tip

Challenge 1: Text Scroller Render scrolling text on the LED wall. Use Python's PIL (Pillow) to render text to a bitmap, then scroll it horizontally. Implement variable speed and colour.

Tip

Challenge 2: Audio Visualizer Mirror Run audio_viz_full on the Pi's HDMI display. Simultaneously, capture the framebuffer with fb_to_wall.py and show the spectrum on the LED wall. How does the low resolution affect readability?

Tip

Challenge 3: Conway's Game of Life Implement the Game of Life directly on the Pi, send frames to the wall. At 64×32, the patterns are clearly visible. Add touch/mouse interaction to toggle cells.

Tip

Challenge 4: Camera Preview Capture the Pi camera with picamera2, downscale to 64×32, and stream to the LED wall. What's the minimum recognizable image at this resolution? Experiment with edge detection to improve recognizability.

Tip

Challenge 5: Parallel PIO Output The sequential neopixel.write() is the FPS bottleneck. Write a custom PIO program in MicroPython (@rp2.asm_pio) that shifts out all 8 lanes simultaneously using a single state machine and bit-packed data. This should increase output FPS from 16 to 130. See the ws2812_parallel example in the Pico SDK for the PIO assembly pattern.

Tip

Challenge 6: DRM Display Driver Write a minimal DRM driver (kernel module) that exposes the LED wall as /dev/dri/cardN. Use drm_simple_display_pipe. The driver's flush function packages the shadow buffer and sends via SPI or UART to the Pico. Test by running fbcon (console on the LED wall) or an SDL2 app.


See also: Serial Bus Interfaces | MCU RT Control | SDL2 UI Patterns | Framebuffer Basics