Screen Lab
How does the CLUE turn your code into pictures? Let's zoom all the way in: pixels, coordinates, colors, layers, drawing, animation and memory. Every example here was tested on a real CLUE.
๐A screen is a grid of tiny lights
The CLUE's screen is a 1.3 inch color TFT. Up close it's a grid of 240 ร 240 = 57,600 pixels. Each pixel is really 3 even tinier lights, red, green and blue, that are too small for your eyes to see separately, so they blend into one color.
In CircuitPython the screen is called board.DISPLAY. You never talk to the wires yourself: a system called displayio works out which pixels changed and sends only those. Remember that, it's the secret behind fast animation!
๐Coordinates: where is a pixel?
Every pixel has an address (x, y). x counts from the left, y counts from the top. The first pixel is (0, 0) in the top-left corner, and the last one is (239, 239) in the bottom-right.
In Scratch, (0, 0) is in the middle of the stage and y goes up. On the CLUE, (0, 0) is in the top-left corner and y goes down. To convert:
clue_x = scratch_x + 120 and clue_y = 120 - scratch_y.Try it: move your mouse (or finger) over the screen below.
CLUE: x = ?, y = ?
Scratch: x = ?, y = ?
The red lines are every 40 pixels. The middle of the screen is (120, 120).
๐จColors: mixing light
Screens mix light, not paint, so red + green makes yellow, and all three at full power make white. In code a color is 3 numbers from 0 to 255: (red, green, blue). You'll also see them written in hex (base 16) like 0xFF4FA3: two digits for red, two for green, two for blue.
RGB565:
The secret: the screen only has 65,536 colors
Your code can ask for 16.7 million colors (256 ร 256 ร 256), but the CLUE's screen stores each pixel in 16 bits: 5 bits for red, 6 bits for green and 5 bits for blue. That's called RGB565. Green gets the extra bit because human eyes are best at seeing different greens! So very close colors can come out exactly the same. Watch the RGB565 box above as you nudge a slider by 1.
๐displayio: a layer cake
You don't draw straight onto the screen. You build a stack of layers, and displayio paints them in order, bottom to top, like stickers on a page:
A Bitmap is like paint-by-numbers: each pixel stores a small number (0, 1, 2โฆ), and the Palette says which color each number means. Change one palette color and every pixel with that number changes at once. That's a cheap magic trick for flashing and fading!
โ๏ธPlot pixels yourself
Here's the whole layer cake in code. bitmap[x, y] = number colors ONE pixel.
import random
import board
import displayio
bitmap = displayio.Bitmap(240, 240, 4) # 240 x 240 pixels, up to 4 colors
palette = displayio.Palette(4) # the 4 paint pots
palette[0] = 0x000022 # 0 = night sky
palette[1] = 0xFFFFFF # 1 = white
palette[2] = 0xFFD23F # 2 = yellow
palette[3] = 0xFF4FA3 # 3 = pink
group = displayio.Group()
group.append(displayio.TileGrid(bitmap, pixel_shader=palette))
board.DISPLAY.root_group = group
bitmap[120, 120] = 3 # ONE pink pixel in the middle
for i in range(300): # 300 random stars
x = random.randint(0, 239)
y = random.randint(0, 239)
bitmap[x, y] = random.choice([1, 2])
for x in range(240): # a yellow line across the bottom
bitmap[x, 200] = 2
while True:
pass
Zoom trick: pixel art
A Group can scale everything inside it. Draw a tiny 8 ร 8 picture from a list of strings, then blow it up 30 times so it fills the screen:
import board
import displayio
ART = [
"..####..",
".#....#.",
"#.#..#.#",
"#......#",
"#.#..#.#",
"#..##..#",
".#....#.",
"..####..",
]
palette = displayio.Palette(2)
palette[0] = 0xFFD23F # "." = yellow
palette[1] = 0x1B1340 # "#" = dark ink
bitmap = displayio.Bitmap(8, 8, 2)
for y, row in enumerate(ART): # enumerate gives the position AND the item
for x, ch in enumerate(row):
if ch == "#":
bitmap[x, y] = 1
group = displayio.Group(scale=30) # every pixel becomes a 30 x 30 block!
group.append(displayio.TileGrid(bitmap, pixel_shader=palette))
board.DISPLAY.root_group = group
while True:
pass
Draw your own 8 ร 8 sprite (a heart, a ghost, a space invaderโฆ). Add a third color: make the palette 3 long and use another letter like
"o".๐ทDrawing shapes: 3 toolboxes
Plotting one pixel at a time is slow for big shapes. There are three toolboxes that draw shapes for you:
| Toolbox | Install? | Best for |
|---|---|---|
bitmaptools | Built in โ | Drawing into a bitmap: lines, circles, boxes, polygons, paint-bucket fill |
vectorio | Built in โ | Shapes that move: circles, rectangles, polygons. Fast and uses little memory |
adafruit_display_shapes | Library ๐ | Easy shapes with outlines: Rect, RoundRect, Circle, Triangle, Line, Polygon, and Sparkline graphs |
bitmaptools: draw into a bitmap
import array
import board
import displayio
import bitmaptools
bitmap = displayio.Bitmap(240, 240, 5)
palette = displayio.Palette(5)
palette[0] = 0x000000 # black
palette[1] = 0xFF4FA3 # pink
palette[2] = 0x22D3EE # cyan
palette[3] = 0xFFD23F # yellow
palette[4] = 0x3EE07A # green
group = displayio.Group()
group.append(displayio.TileGrid(bitmap, pixel_shader=palette))
board.DISPLAY.root_group = group
bitmaptools.fill_region(bitmap, 20, 20, 100, 100, 1) # box: x1, y1, x2, y2, color
bitmaptools.draw_line(bitmap, 0, 239, 239, 0, 2) # line from corner to corner
bitmaptools.draw_circle(bitmap, 170, 70, 40, 3) # circle: x, y, radius, color
bitmaptools.boundary_fill(bitmap, 170, 70, 3, 0) # paint bucket! fill the inside
xs = array.array("h", [40, 120, 80]) # a triangle's corners
ys = array.array("h", [220, 220, 150])
bitmaptools.draw_polygon(bitmap, xs, ys, 4)
while True:
pass
array.array("h", [...]) is a compact list of whole numbers, which is what draw_polygon wants for the corner positions.
vectorio: shapes that move (bouncing ball!)
Each vectorio shape has its own x and y. Change them and the shape moves. displayio only redraws the tiny area around the ball.
import time
import board
import displayio
import vectorio
sky = displayio.Palette(1)
sky[0] = 0x1B1340
pink = displayio.Palette(1)
pink[0] = 0xFF4FA3
group = displayio.Group()
group.append(vectorio.Rectangle(pixel_shader=sky, width=240, height=240, x=0, y=0))
ball = vectorio.Circle(pixel_shader=pink, radius=15, x=120, y=120)
group.append(ball)
board.DISPLAY.root_group = group
dx, dy = 3, 2 # how far the ball moves each step
while True:
ball.x += dx
ball.y += dy
if ball.x < 15 or ball.x > 225: # hit a side wall? bounce!
dx = -dx
if ball.y < 15 or ball.y > 225: # hit the top or bottom? bounce!
dy = -dy
time.sleep(0.01)
adafruit_display_shapes: shapes with outlines
This one is a library. Copy the adafruit_display_shapes folder from the library bundle into CIRCUITPY/lib, just like in Setup Part B.
import board
import displayio
from adafruit_display_shapes.rect import Rect
from adafruit_display_shapes.roundrect import RoundRect
from adafruit_display_shapes.circle import Circle
from adafruit_display_shapes.triangle import Triangle
from adafruit_display_shapes.line import Line
group = displayio.Group()
group.append(Rect(0, 0, 240, 240, fill=0x1B1340)) # background
group.append(RoundRect(20, 20, 200, 60, 15, fill=0xFFD23F, outline=0xFFFFFF, stroke=3))
group.append(Circle(60, 150, 40, fill=0xFF4FA3, outline=0xFFFFFF)) # x, y, radius
group.append(Triangle(130, 200, 180, 110, 230, 200, fill=0x22D3EE)) # 3 corners
group.append(Line(0, 225, 239, 225, 0x3EE07A)) # x0, y0, x1, y1, color
board.DISPLAY.root_group = group
while True:
pass
Draw a house: a Rect for the walls, a Triangle roof, a RoundRect door and a Circle sun.
Plot live data: Sparkline graphs
A Sparkline is a line graph that scrolls: every add_value() adds a point on the right. Make some noise!
import board
import displayio
import terminalio
from adafruit_display_text import label
from adafruit_display_shapes.sparkline import Sparkline
from adafruit_clue import clue
graph = Sparkline(width=220, height=170, max_items=50, x=10, y=60, color=0x3EE07A)
title = label.Label(terminalio.FONT, text="Sound level", scale=2, x=10, y=20)
value = label.Label(terminalio.FONT, text="", scale=2, x=10, y=45, color=0xFFD23F)
group = displayio.Group()
group.append(graph)
group.append(title)
group.append(value)
board.DISPLAY.root_group = group
while True:
level = clue.sound_level
graph.add_value(level) # add a point; the oldest one slides off
value.text = f"{level:.0f}"
Graph
clue.proximity or the accelerometer's z instead. Try y_min=0, y_max=255 in the Sparkline so the graph doesn't rescale itself.โกAnimation: why small is fast
Sending all 57,600 pixels down the wires takes time. displayio is smart: it only sends the part that changed. This program measures it. Turn off auto_refresh and call display.refresh() yourself to control exactly when the screen updates:
import time
import board
import displayio
import vectorio
display = board.DISPLAY
display.auto_refresh = False # WE decide when to update the screen
bg = displayio.Palette(1)
bg[0] = 0x000000
fg = displayio.Palette(1)
fg[0] = 0xFF4FA3
group = displayio.Group()
back = vectorio.Rectangle(pixel_shader=bg, width=240, height=240, x=0, y=0)
ball = vectorio.Circle(pixel_shader=fg, radius=10, x=20, y=120)
group.append(back)
group.append(ball)
display.root_group = group
start = time.monotonic()
for i in range(100): # move a SMALL thing
ball.x = 20 + i * 2
display.refresh()
small = 100 / (time.monotonic() - start)
start = time.monotonic()
for i in range(10): # change the WHOLE screen
bg[0] = 0x000000 if i % 2 else 0x222266
display.refresh()
full = 10 / (time.monotonic() - start)
print(f"small ball: {small:.0f} frames per second")
print(f"whole screen: {full:.1f} frames per second")
display.auto_refresh = True
On our CLUE it printed:
small ball: 159 frames per second
whole screen: 3.4 frames per second
Move small things over a background that stays still. Changing the whole screen at once (a full-screen photo, a new background color) is about 50 times slower. That's why GIF animations from the Picture Maker play at about 3 frames per second.
๐Rotate and dim
display.rotation turns everything by 0, 90, 180 or 270 degrees (great if your CLUE is mounted sideways). display.brightness sets the backlight from 0.0 (off) to 1.0 (full), and dimming saves battery!
import time
import board
from adafruit_clue import clue
display = board.DISPLAY
screen = clue.simple_text_display(title="Turn me!", text_scale=3)
screen[1].text = "Hello!"
screen.show()
for angle in (90, 180, 270, 0): # rotate the picture
display.rotation = angle
print("rotation", display.rotation)
time.sleep(1)
for level in (0.5, 0.1, 1.0): # dim the backlight
display.brightness = level
print("brightness", display.brightness)
time.sleep(1)
๐งฎMemory: why pictures use so few colors
A bitmap needs enough bits per pixel to store the biggest color number. More colors = more bits = more memory:
| Colors in the palette | Bits per pixel | Full screen (240 ร 240) |
|---|---|---|
| 2 | 1 | 7.2 KB |
| 3 โ 4 | 2 | 14.4 KB |
| 5 โ 16 | 4 | 28.8 KB |
| 17 โ 256 | 8 | 57.6 KB |
| 65,536 (every RGB565 color) | 16 | 115.2 KB ๐ฑ too big! |
How much memory does the CLUE have for your program? Let's measure:
import gc
import displayio
gc.collect()
before = gc.mem_free()
print("free memory:", before, "bytes")
for colors in (2, 4, 16, 256):
gc.collect()
start = gc.mem_free()
bitmap = displayio.Bitmap(240, 240, colors)
gc.collect()
print(colors, "colors cost", start - gc.mem_free(), "bytes")
del bitmap
Our CLUE printed free memory: 129952 bytes (about 127 KB), and the costs matched the table: 8,064 bytes for 2 colors, 16,432 for 4, 28,720 for 16 and 59,440 for 256.
from adafruit_clue import clue itself uses about 52 KB, over a third of the memory! Programs that only draw pictures should skip it and use board.DISPLAY directly. That's what the Picture Maker does.Pictures with 17+ colors at full size still fit, but it's tight. So the Picture Maker uses tricks: fewer colors, a smaller bitmap zoomed up with scale, and squeezing (compressing) the pixel data inside the code.
๐ผ๏ธPictures from files
A picture doesn't have to live in memory. displayio.OnDiskBitmap reads a .bmp file straight from the CIRCUITPY drive while it draws. It uses almost no memory but is slower (a full-screen picture takes about 2 seconds to appear on our CLUE).
import board
import displayio
picture = displayio.OnDiskBitmap("/picture.bmp")
tile = displayio.TileGrid(picture, pixel_shader=picture.pixel_shader)
group = displayio.Group()
group.append(tile)
board.DISPLAY.root_group = group
while True:
pass
Where do you get a CLUE-sized .bmp? Make one with the Picture Maker!