๐ŸŒ ฮ•ฮปฮปฮทฮฝฮนฮบฮฌ
Deep dive ๐Ÿ”ฌ

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.

Part 1

๐Ÿ”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.

๐Ÿง The brainThe nRF52840 chip runs your Python code
ใ€ฐ๏ธThe wires (SPI)Pixels travel to the screen one after another, super fast
๐ŸŽ›๏ธThe screen chip (ST7789)Remembers every pixel and keeps lighting it
๐Ÿ’กThe backlightA light behind everything: that's the brightness

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!

Part 2

๐Ÿ“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.

๐Ÿฑ Scratch fans, careful!
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).

Part 3

๐ŸŽจ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:

What you asked for
What the CLUE shows

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.

Part 4

๐ŸŽ‚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:

board.DISPLAYthe real screen
root_groupthe one group the screen shows
Groupa layer; can be moved (x, y) and zoomed (scale)
TileGrid ยท Label ยท Shapethings you can see
Bitmap + Palettethe pixels (numbers) + the paint pots (colors)

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!

Part 5

โœ๏ธ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
๐Ÿ† Challenge
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".
Part 6

๐Ÿ”ทDrawing shapes: 3 toolboxes

Plotting one pixel at a time is slow for big shapes. There are three toolboxes that draw shapes for you:

ToolboxInstall?Best for
bitmaptoolsBuilt in โœ…Drawing into a bitmap: lines, circles, boxes, polygons, paint-bucket fill
vectorioBuilt in โœ…Shapes that move: circles, rectangles, polygons. Fast and uses little memory
adafruit_display_shapesLibrary ๐Ÿ“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
๐Ÿ† Challenge
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}"
๐Ÿ† Challenge
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.
Part 7

โšก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
๐ŸŽฎ Game-maker tip
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.
Part 8

๐Ÿ”„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)
Part 9

๐Ÿงฎ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 paletteBits per pixelFull screen (240 ร— 240)
217.2 KB
3 โ€“ 4214.4 KB
5 โ€“ 16428.8 KB
17 โ€“ 256857.6 KB
65,536 (every RGB565 color)16115.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.

๐Ÿ˜ The big one
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.

Part 10

๐Ÿ–ผ๏ธ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!

๐Ÿ–ผ๏ธ Open the Picture Maker