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):
Coordinate system:
Lines — Parametric Interpolation
A line from point A to point B can be expressed parametrically:
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\):
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\):
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:
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:
Rotation around the X axis (vertical tilt) affects y and 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 cubesrc/picobot/examples/led_panel_demo.py— 10 animation demos (rainbow, plasma, fire, Game of Life, etc.)
External:
- WS2812 Datasheet — LED protocol timing
- Bresenham's Line Algorithm (Wikipedia) — Integer-only line drawing
- 3D Projection (Wikipedia) — Perspective and orthographic
- MicroPython framebuf — Built-in framebuffer class