Skip to content

LED Panel & Framebuffer Graphics

This reference covers driving WS2812 LED matrices and the graphics concepts behind drawing shapes on any pixel display — LED panels, OLEDs, or TFTs.

For the OLED-specific API, see OLED Display Graphics. For hands-on demos, see src/picobot/examples/graphics_tutorial.py and led_panel_demo.py.


The LEDPanel Driver

LEDPanel is a framebuffer-based WS2812 driver that works with any number of NeoPixel LEDs.

Setup

from picobot import LEDPanel

# Single 8×8 panel on pin 6
panel = LEDPanel(pin=6, num_leds=64, width=8)

# Two chained 8×8 panels as one 16×8 display
panel = LEDPanel(pin=6, num_leds=128, width=16, panel_width=8)

# Simple LED strip (no 2D addressing)
strip = LEDPanel(pin=2, num_leds=30)

Parameters

Parameter Default Description
pin GPIO pin connected to DIN
num_leds Total LEDs in the chain
width num_leds Display width in pixels (set for 2D panels)
panel_width width Physical panel width (for chained panels)
sm_id 1 PIO state machine ID (0 is used by robot LEDs)
brightness 255 Global brightness cap (0–255)
layout 'progressive' 'progressive' (rows all L→R) or 'serpentine' (zigzag)

API Quick Reference

# 1D addressing (strips or raw index)
panel[0] = (255, 0, 0)        # Set by linear index
color = panel[0]               # Read back (r, g, b)

# 2D addressing (matrices)
panel.pixel(x, y, (r, g, b))  # Set pixel at column x, row y
panel.get_pixel(x, y)         # Read pixel

# Drawing primitives
panel.fill((r, g, b))         # Fill entire display
panel.clear()                  # All pixels off
panel.fill_rect(x, y, w, h, color)
panel.hline(x, y, length, color)
panel.vline(x, y, length, color)

# Output
panel.show()                   # Push framebuffer to LEDs

# Properties
panel.width                    # Total display width
panel.height                   # Display height
panel.count                    # Total LED count
panel.brightness               # Get/set brightness (0–255)

Immediate Mode vs Framebuffer

This is the most important concept in display programming.

Immediate mode — show after each pixel

for y in range(8):
    for x in range(16):
        panel.pixel(x, y, (0, 30, 0))
        panel.show()  # Send entire chain after EACH pixel

What happens: Each show() sends all 128 LED values over the serial protocol (~0.2 ms per LED × 128 = ~25 ms per show). For 128 pixels, that's 128 × 25 ms = 3.2 seconds to fill the screen. You see pixels appear one by one.

Framebuffer mode — draw everything, show once

for y in range(8):
    for x in range(16):
        panel.pixel(x, y, (0, 30, 0))
        # No show() — just writing to memory (nanoseconds)

panel.show()  # ONE send for the entire frame

What happens: All 128 pixel() calls write to a memory buffer (~microseconds total). The single show() takes ~25 ms. Total: ~25 ms — over 100× faster.

Why this matters

Immediate Framebuffer
Time to fill 128 LEDs ~3200 ms ~25 ms
Flicker Visible partial updates Clean full-frame updates
Frame rate < 1 FPS ~40 FPS
Use case Debugging, wiring test Everything else
This Pattern Is Universal

Every display system uses framebuffers:

  • SSD1306 OLED: oled.pixel() writes to a 1024-byte buffer, oled.show() sends via I2C
  • TFT displays: framebuf.pixel() writes to buffer, DMA transfers to SPI
  • Game consoles: GPU renders to a back-buffer, then swaps to the screen (double buffering)
  • Modern GPUs: Multiple framebuffers with vsync to prevent tearing

The principle is always the same: compose the frame in memory, then send it all at once.


Drawing Primitives — The Math

Pixels

The simplest operation. A pixel is a single point at (x, y):

panel.pixel(3, 4, (50, 0, 0))  # Red pixel at column 3, row 4
panel.show()

Coordinate system:

(0,0) ────── x ──────► (width-1, 0)
  y
(0, height-1)

Lines — Parametric Interpolation

A line from point A to point B can be expressed parametrically:

\[x(t) = x_0 + t \cdot (x_1 - x_0)$$ $$y(t) = y_0 + t \cdot (y_1 - y_0)\]

where \(t\) goes from 0.0 (start) to 1.0 (end).

def draw_line(x0, y0, x1, y1, color):
    """Draw a line using parametric interpolation."""
    delta_x = abs(x1 - x0)
    delta_y = abs(y1 - y0)
    steps = max(delta_x, delta_y, 1)

    for step in range(steps + 1):
        t = step / steps                       # 0.0 → 1.0
        pixel_x = int(x0 + t * (x1 - x0) + 0.5)
        pixel_y = int(y0 + t * (y1 - y0) + 0.5)
        panel.pixel(pixel_x, pixel_y, color)

The number of steps equals the longer axis distance — this ensures no gaps. The +0.5 rounds to nearest pixel.

Bresenham's Algorithm

Professional systems use Bresenham's line algorithm, which uses only integer addition (no multiplication or division per pixel). On a small LED panel the performance difference is negligible, but on a 1920×1080 screen drawing millions of lines per frame, integer-only math matters enormously.

Circles — Trigonometry

A circle with center \((c_x, c_y)\) and radius \(r\):

\[x(\theta) = c_x + r \cdot \cos(\theta)$$ $$y(\theta) = c_y + r \cdot \sin(\theta)\]

where \(\theta\) goes from 0 to \(2\pi\).

def draw_circle(center_x, center_y, radius, color):
    """Draw circle outline using cos/sin."""
    num_points = max(int(2 * math.pi * radius), 12)
    for i in range(num_points):
        angle = 2 * math.pi * i / num_points
        pixel_x = int(center_x + radius * math.cos(angle) + 0.5)
        pixel_y = int(center_y + radius * math.sin(angle) + 0.5)
        panel.pixel(pixel_x, pixel_y, color)

For a filled circle, use the circle equation \(x^2 + y^2 = r^2\) to calculate the width at each row:

def draw_filled_circle(center_x, center_y, radius, color):
    """Draw filled circle using scanlines."""
    for row_offset in range(-radius, radius + 1):
        # Circle equation: x = sqrt(r² - y²)
        x_span = int(math.sqrt(radius * radius - row_offset * row_offset))
        for col_offset in range(-x_span, x_span + 1):
            panel.pixel(center_x + col_offset, center_y + row_offset, color)

Triangles — Connect Three Points

A triangle is the simplest polygon: three lines connecting three vertices.

def draw_triangle(vertices, color):
    """Draw triangle from list of 3 (x, y) tuples."""
    for i in range(3):
        start = vertices[i]
        end = vertices[(i + 1) % 3]  # Wrap: 0→1, 1→2, 2→0
        draw_line(int(start[0]), int(start[1]),
                  int(end[0]), int(end[1]), color)
Why Triangles Matter

All 3D graphics ultimately decompose into triangles. A cube is 12 triangles. A sphere might be 1000 triangles. GPUs are essentially triangle rasterizers — everything you see on screen, from games to UI to video playback, is made of triangles.


2D Rotation

To rotate a point \((x, y)\) around center \((c_x, c_y)\) by angle \(\theta\):

\[x' = \cos(\theta) \cdot (x - c_x) - \sin(\theta) \cdot (y - c_y) + c_x$$ $$y' = \sin(\theta) \cdot (x - c_x) + \cos(\theta) \cdot (y - c_y) + c_y\]

This is the rotation matrix applied in 2D. To rotate a shape, apply this to every vertex.

def rotate_point(point_x, point_y, angle, center_x, center_y):
    """Rotate a point around a center."""
    cos_a = math.cos(angle)
    sin_a = math.sin(angle)
    # Translate to origin, rotate, translate back
    rel_x = point_x - center_x
    rel_y = point_y - center_y
    return (cos_a * rel_x - sin_a * rel_y + center_x,
            sin_a * rel_x + cos_a * rel_y + center_y)

Rotating a triangle:

rotation_angle = time.ticks_ms() / 1000  # Increases over time

rotated_vertices = []
for vx, vy in original_vertices:
    rx, ry = rotate_point(vx, vy, rotation_angle, center_x, center_y)
    rotated_vertices.append((rx, ry))

draw_triangle(rotated_vertices, (0, 40, 40))
panel.show()

3D Projection

To display 3D objects on a 2D screen, we need projection.

Perspective Projection

Objects further away appear smaller. The formula:

\[x_{screen} = c_x + x_{3d} \cdot \frac{f}{z_{3d} + f} \cdot s$$ $$y_{screen} = c_y + y_{3d} \cdot \frac{f}{z_{3d} + f} \cdot s\]

where: - \(f\) = focal length (controls perspective strength) - \(s\) = scale factor (controls object size on screen) - \(c_x, c_y\) = screen center

The division by \(z + f\) is what makes distant objects smaller.

3D Rotation

Rotation around the Y axis (horizontal spin) affects x and z:

\[x' = \cos(\theta) \cdot x - \sin(\theta) \cdot z$$ $$z' = \sin(\theta) \cdot x + \cos(\theta) \cdot z\]

Rotation around the X axis (vertical tilt) affects y and z:

\[y' = \cos(\phi) \cdot y - \sin(\phi) \cdot z$$ $$z' = \sin(\phi) \cdot y + \cos(\phi) \cdot z\]

Wireframe Cube Example

# 8 vertices of a unit cube
vertices = [
    (-1,-1,-1), (1,-1,-1), (1,1,-1), (-1,1,-1),  # Back face
    (-1,-1, 1), (1,-1, 1), (1,1, 1), (-1,1, 1),  # Front face
]

# 12 edges (pairs of vertex indices)
edges = [
    (0,1),(1,2),(2,3),(3,0),  # Back
    (4,5),(5,6),(6,7),(7,4),  # Front
    (0,4),(1,5),(2,6),(3,7),  # Connecting
]

# For each frame:
cos_y, sin_y = math.cos(angle_y), math.sin(angle_y)
cos_x, sin_x = math.cos(angle_x), math.sin(angle_x)
focal_length = 4.0
scale = 3

projected = []
for vx, vy, vz in vertices:
    # Rotate around Y
    rx = cos_y * vx - sin_y * vz
    rz = sin_y * vx + cos_y * vz
    # Rotate around X
    ry = cos_x * vy - sin_x * rz
    fz = sin_x * vy + cos_x * rz
    # Project to 2D
    perspective = focal_length / (fz + focal_length)
    screen_x = int(center_x + rx * scale * perspective)
    screen_y = int(center_y + ry * scale * perspective)
    projected.append((screen_x, screen_y))

# Draw edges
for start, end in edges:
    draw_line(*projected[start], *projected[end], color)
From LED Panel to GPU

What you're doing here is exactly what a GPU does, just much simpler:

Your LED Panel Modern GPU
8 vertices, 12 edges Millions of vertices
Python math per vertex Dedicated hardware per vertex
~40 FPS on 16×8 60+ FPS on 3840×2160
Wireframe only Filled triangles, textures, lighting
Software projection Hardware vertex shader
Manual line drawing Hardware rasterizer

The math is identical. The difference is that GPUs do it in parallel hardware instead of sequential Python.


HSV Color Space

For animations, HSV is much easier than RGB:

Component Range Meaning
Hue 0.0–1.0 Position on the color wheel
Saturation 0.0–1.0 0 = gray, 1 = vivid
Value 0.0–1.0 0 = black, 1 = bright
def hsv_to_rgb(hue, saturation, value):
    """Convert HSV to RGB (0-255 range)."""
    if saturation == 0:
        gray = int(value * 255)
        return (gray, gray, gray)

    sector = int(hue * 6) % 6
    fraction = hue * 6 - int(hue * 6)

    max_val = int(value * 255)
    min_val = int(value * (1 - saturation) * 255)
    dec_val = int(value * (1 - fraction * saturation) * 255)
    inc_val = int(value * (1 - (1 - fraction) * saturation) * 255)

    if sector == 0: return (max_val, inc_val, min_val)
    if sector == 1: return (dec_val, max_val, min_val)
    if sector == 2: return (min_val, max_val, inc_val)
    if sector == 3: return (min_val, dec_val, max_val)
    if sector == 4: return (inc_val, min_val, max_val)
    return (max_val, min_val, dec_val)

Why it's useful: To make a rainbow, just sweep hue from 0 to 1. To make something pulse, modulate value with sin(). In RGB you'd have to manually coordinate three channels.


Animation Loop Pattern

Every animation follows the same structure:

start_time = time.ticks_ms()

while time.ticks_diff(time.ticks_ms(), start_time) < duration_ms:
    elapsed = time.ticks_diff(time.ticks_ms(), start_time) / 1000

    # 1. Update state (physics, angles, positions)
    angle = elapsed * rotation_speed
    ball_x += velocity_x

    # 2. Clear framebuffer
    panel.clear()

    # 3. Draw all shapes into framebuffer
    draw_circle(...)
    draw_line(...)
    draw_triangle(...)

    # 4. Push to display (one transfer per frame)
    panel.show()

    # 5. Frame timing
    time.sleep_ms(30)  # ~33 FPS cap

This is the game loop — the same pattern used by every game engine, animation framework, and real-time visualization system.


Chained Panel Wiring

When multiple panels are daisy-chained (DOUT → DIN), the data fills each panel completely before moving to the next:

Panel 1 (indices 0-63)        Panel 2 (indices 64-127)
row 0: [ 0  1  2  3  4  5  6  7]  row 0: [64 65 66 67 68 69 70 71]
row 1: [ 8  9 10 11 12 13 14 15]  row 1: [72 73 74 75 76 77 78 79]
row 2: [16 17 18 19 20 21 22 23]  row 2: [80 81 82 83 84 85 86 87]
...                                ...

The panel_width parameter tells the driver where each panel's data ends:

# Without panel_width: index 8 = column 8 of row 0 (WRONG for chained panels)
# With panel_width=8: index 8 = column 0 of row 1 on panel 1 (CORRECT)
panel = LEDPanel(pin=6, num_leds=128, width=16, panel_width=8)

Layout Modes

Layout Wiring Common on
'progressive' All rows left-to-right Rigid PCB panels
'serpentine' Even rows L→R, odd rows R→L Flexible LED strips bent into rows

Test your wiring with the index walker:

for i in range(panel.count):
    panel.clear()
    panel[i] = (0, 20, 0)
    panel.show()
    print(f"index {i}")
    time.sleep(0.3)

Further Reading

In this course:

  • OLED Display Graphics — SSD1306 I2C display API
  • src/picobot/examples/graphics_tutorial.py — Step-by-step: lines → circles → rotation → 3D cube
  • src/picobot/examples/led_panel_demo.py — 10 animation demos (rainbow, plasma, fire, Game of Life, etc.)

External: