The ghee Python package lets you draw on a screen from your own program, or
write a scene that the ghee command renders. You can install both the Python
package and the ghee command from PyPI, as
ghee-render, and
Install has more.
For example, this program opens a 128x64 screen in memory, draws hello on
it, and saves the frame as a PNG:
import ghee
screen = ghee.open(width=128, height=64)
with screen.draw() as d:
d.text("hello", color="orange", glow={})
d.line((8, 56), (120, 56), color="orange")
screen.save("hello.png")To draw directly to a connected display instead, use
ghee.open(display="kms"): HDMI, or a panel on SPI or I2C, set up with its
device tree overlay (Hardware displays).
Use ghee.open when ghee is one part of your program, such as a logger that
also shows its readings. Your program runs its own loop, and draws when it
wants. To feed a megabyte or more each frame, use ghee.open: it takes less
CPU than a Python scene.
Write a Python scene for ghee to run the way it runs a
scene file. ghee render puts it on a display or into image
files, and ghee serve lists it in the console beside your
other scenes, to switch between them. A scene file holds fixed values. A
Python scene computes its own each frame, such as the time.
To draw a frame, open a with screen.draw() as d: block, and make one call
for each layer, later calls on top:
| Call | What it draws |
|---|---|
d.text(words) |
words (Text layers) |
d.image(image) |
a .png or .jpg file, an image asset, a Pillow image, or a numpy array of HxWx3 or HxWx4 uint8 (Image layers) |
d.rect() |
a box, or the whole screen with no width and height (Shape layers) |
d.circle((x, y), radius) |
a filled circle of radius pixels around (x, y) (Shape layers) |
d.line((x0, y0), (x1, y1)) |
a line between two points |
d.lines(points) |
a line through a list of (x, y) points |
When the block ends, ghee renders the frame and shows it, as screen.frame()
does (Frames).
Pass a layer's keys to its call as keywords, such as x, y, color and
font_size. Layers lists them. x and y place the layer's top
left corner, in pixels from the screen's top left, y down. To turn on an
effect such as glow, pass it a dict of its own keys, or an empty dict for
its defaults: glow={}.
The layers stay from frame to frame, so a blink or a scroll keeps moving. ghee knows each call by its line of code, and each frame's calls state the whole picture. A key that a call leaves out takes its default, or the value you gave it in the console. A call that your code skips in a frame takes its layer away, and the other calls keep theirs, names included. When the skipped call runs again, it adds a new layer, and its effects start over.
This loop shows the machine's load on a 128x32 SSD1306, and the panel
gives the screen its size. The SSD1306 is a 1-bit panel, so the text draws
in mono, every pixel on or off. The loop draws the time, the load, a bar
that fills with the load, and a HOT that blinks twice a second above 90%.
fps=10 holds the loop to ten frames a second:
import os
import time
import ghee
screen = ghee.open(display="kms", fps=10)
while True:
load = os.getloadavg()[0] / os.cpu_count()
with screen.draw() as d:
d.text(time.strftime("%H:%M"), x=0, y=0, font_size=16, render="mono")
d.text(
f"load {load:.0%}",
x=0,
y=20,
font_size=8,
font_family="Pixel Operator 8",
render="mono",
)
d.rect(
x=64,
y=20,
width=64,
height=8,
color="transparent",
border={"width": 1, "color": "white"},
)
d.rect(x=66, y=22, width=60 * min(load, 1), height=4)
if load > 0.9:
w, _ = screen.measure("HOT", font_size=12)
d.text(
"HOT", x=128 - w, y=0, font_size=12, render="mono", blink={"rate": 2}
)At 95% load:
Effects that move, such as blink, scroll and wave, keep their pace while
your loop draws four frames a second or more. In a slower loop, each frame
moves them a quarter second.
Use name= to name the layer of a call, as the console shows it. Without it,
ghee names a new layer after its call, text, then text2, and so on. Two
calls with the same name in a frame raise ValueError.
To draw a layer defined in a scene, pass its name.
Use screen.measure("HOT", font_size=12) to get the width and height of words
in pixels before you draw them, as for HOT above. Leave out font_size to
measure them at the size that fits the screen.
You can also open a scene with ghee.open: a path to a
.scene.json, a dict, the JSON text of a scene, or a
ghee.Scene. This opens the bars scene from
Arrays, feeds it a ramp, and draws a frame:
import ghee
screen = ghee.open("bars.scene.json", width=128, height=64)
screen.feed("levels", [(i + 1) / 16 for i in range(16)])
screen.frame()
screen.save("ramp.png")| Call | What it changes, from the next frame |
|---|---|
screen.feed(name, values) |
the numbers of an array |
screen.set_param(name, value) |
a param |
screen.layers.title.x = 10 |
a key of the layer title (Changing a layer at runtime) |
feed takes a list of floats, or a buffer such as a numpy array. Bytes, from
bytes or a numpy uint8 array, become 0 to 1: each byte is divided by 255.
On a Pi Zero to Pi 3, an array stores only bytes, so floats outside 0 to 1
clamp. On a Pi 4 or Pi 5, or a desktop, an array stores true floats, so
there's no clamping.
Use set_param to set a param of the scene, and in_pass="blur" to set the
param of its pass blur.
Use layers to change a layer's keys at runtime, such as x, color,
text or font_size. Use list(screen.layers) to get the names of the
layers.
| Key | Values | Default | What it changes |
|---|---|---|---|
scene |
a path, a dict, JSON text, a ghee.Scene |
none: a black frame | the scene |
display |
"kms" |
none | "kms" draws on the connected display |
width, height |
pixels, both or neither | 320x240 | the frame's size in memory. With a display, the display's own size wins, and width and height are ignored |
rotate |
0, 90, 180, 270 |
0 |
turns the frame clockwise. At 90 and 270 you draw at the size turned, 32x128 on a 128x32 panel |
fps |
frames a second | none | holds the loop to at most this many frames a second |
drop |
True, False |
False |
True lets the display skip to the newest frame (Frames) |
timestep |
seconds a frame | none: the clock | fixes the time between frames, so a render repeats exactly |
alpha |
True, "premultiplied", "unpremultiplied", False |
False |
keeps the transparency drawn by the scene (Frames) |
serve |
a port, or (host, port) |
none | serves the console |
stream_max |
pixels, or a share from 0 to 1 | the frame's size | caps the size of the console's video |
opts |
{"rgb565": True} |
none | 16-bit colour on the display, as ghee render --rgb565 |
dri |
a device in /dev/dri |
$GHEE_DRI, else the first that opens |
which GPU, as ghee render --dri |
base_dir |
a folder | the working directory | where a dict, JSON text or a ghee.Scene finds its files, and the files that drawing calls name (Assets) |
verbose |
True, False |
False |
True prints what ghee opened |
Each frame has a tick: its number, its time, and what the console changed.
screen.frame() draws a frame and returns its tick, and a screen.draw()
block holds it as d.tick:
| Tick | What it holds |
|---|---|
tick.frame |
the frame's number, from 0 |
tick.time |
seconds since the first frame |
tick.dt |
seconds since the tick before |
tick.params |
the params that the console changed since the tick before, as a dict such as {"speed": 2.0}. The first tick holds every param |
You can use screen.skip() to keep the last frame on the display.
On a display, frame() waits for the display, so your loop runs at the
display's pace. To slow it down, use fps.
Use drop=True when your loop must not wait for the display, such as a loop
that also reads a sensor or follows music. frame() then returns at once, and
the display shows the newest frame each time it's ready, dropping any older
one still waiting. Set fps too, or the loop runs as fast as it can and keeps
a CPU core busy.
You can read the frame back:
| Call | What it gives |
|---|---|
screen.pixels() |
bytes, RGBA for each pixel, row by row from the top |
screen.image() |
a Pillow image. It needs Pillow |
screen.save("frame.png") |
a .png or .jpg file. A run of # in the name takes the frame's number |
On a display, these raise RuntimeError.
With alpha=True, ghee keeps the transparency drawn by the scene.
pixels() gives the raw frame, premultiplied as rendered, and image() and
save() give it unpremultiplied. A half-transparent orange pixel reads
(128, 64, 0, 128) from pixels(), and (255, 128, 0, 128) from image() and
save(). To get the same form from pixels() and image(), set
alpha="premultiplied" or alpha="unpremultiplied". With
alpha="premultiplied", image() gives a Pillow image in mode RGBa,
Pillow's name for premultiplied RGBA, and save() raises ValueError, since
a PNG holds alpha unpremultiplied.
screen.brightness sets a display's brightness, from 0 to 1.
Set screen.blank = True to turn the display off, such as to rest an OLED
between uses, and False to light it again. While the display is off,
frame() draws nothing.
To close the screen, call screen.close(), or open it in a
with ghee.open(...) as screen: block.
To keep a program running for long periods of time, start it from a
systemd user unit. Save this as
~/.config/systemd/user/status.service:
[Unit]
Description=status screen
[Service]
ExecStart=%h/status/.venv/bin/python %h/status/status.py
Restart=on-failure
[Install]
WantedBy=default.targetThis unit runs ~/status/status.py with the Python of its venv. With ghee
from the deb, run it with the system Python instead:
ExecStart=/usr/bin/python3 %h/status/status.py.
Start it now and at every boot, also before you log in:
systemctl --user enable --now status
sudo loginctl enable-linger $USERA Python scene is a procedural scene written in Python, a script that ghee runs in place of a scene file.
To write one, put @ghee.tick on a function: ghee runs it before each frame.
This one, clock.py, draws the time:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["ghee-render==0.5.0"]
# ///
import time
import ghee
@ghee.tick
def clock(tick):
with ghee.draw() as d:
d.text(time.strftime("%H:%M:%S"))
ghee.run()Its first four lines let uv run the script with
ghee-render pinned to your ghee's version (ghee --version).
To render one frame of it into clock.png:
chmod +x clock.py
ghee render clock.py -o clock.png --size 128x64You can also run the script on its own: ./clock.py draws on the first
connected display found, and it takes the same flags as ghee render, which
you can list with ./clock.py --help.
| Call | What it does |
|---|---|
ghee.run(scene) |
runs a scene, given as ghee.open takes it (Opening a scene), or a black frame with none |
@ghee.tick |
runs the function before each frame, with the frame's tick |
@ghee.feed("levels") |
runs the function before each frame, with the tick, and feeds what it returns to the array levels. None leaves the array as it is |
ghee.draw() |
in a tick, a with block for this frame's calls (Drawing) |
ghee.layers, ghee.set_param, ghee.measure |
in a tick, as a screen's |
ghee.skip() |
in a tick, keeps the last frame on the display |
ghee.render_size() |
the frame's width and height, known before ghee.run |
ghee.args([...]) |
the script's own arguments |
To give the script arguments of its own, declare them with ghee.args, which
returns their values. This clock.py takes --no-seconds:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["ghee-render==0.5.0"]
# ///
import time
import ghee
args = ghee.args([ghee.Arg("seconds", "bool", True, "show the seconds")])
form = "%H:%M:%S" if args.seconds else "%H:%M"
@ghee.tick
def clock(tick):
with ghee.draw() as d:
d.text(time.strftime(form))
ghee.run()Then run it with:
./clock.py --no-secondsThrough ghee render, put the script's arguments after --
(Procedural scenes).
Declare each argument with ghee.Arg(name, type, default, help), in one of
these types:
| Type | Takes |
|---|---|
string |
text |
int |
a whole number |
float |
a number |
bool |
--name, or --no-name |
size |
WxH, or a share of the frame: full, half, quarter, eighth or sixteenth. Its value is (w, h) in pixels |
To serve the console from ghee.open, pass serve=8090 and
open http://127.0.0.1:8090/. ghee logs the address at INFO on the ghee
logger of Python's logging. Keys set by your program are locked in the
console.
Use ghee.Scene and its parts to build a scene in memory, and pass it to
ghee.open or ghee.run. Each class stands for one object of the scene
file, and takes its keys as keywords:
| Class | Writes | Its keys |
|---|---|---|
ghee.Scene(passes, ...) |
the scene | The scene file |
ghee.Pass(name, fragment, ...) |
a pass, its GLSL in fragment |
Passes |
ghee.Param(name, type, value, ...) |
a param | Params |
ghee.ArrayInput(name, size) |
an array | Arrays |
ghee.Channel(buffer) |
a channel | Channels |
ghee.Asset(name, file, ...) |
an asset | Assets, Data files |
ghee.Layer(name, ...) |
a layer | Layers |
Load an image on ghee.Layer from a .png or .jpg file, an image asset,
a Pillow image or a numpy array.
In this scene, a color param ink holds orange, and the shader paints it from
the top down to black. uv, which ghee gives every shader, runs from 0 at the
bottom to 1 at the top (Built-in names). A text
layer writes hello over it with a shadow:
import ghee
scene = ghee.Scene(
params=[ghee.Param("ink", "color", "orange")],
passes=[
ghee.Pass(
"main",
"void main() { gl_FragColor = vec4(ink * uv.y, 1.0); }",
layers=[ghee.Layer("title", text="hello", text_shadow={})],
)
],
)
screen = ghee.open(scene, width=128, height=64)
screen.frame()
screen.save("scene.png")Each call, and the ghee call that draws the same:
| luma.oled and Pillow | ghee |
|---|---|
device = ssd1306(i2c(port=1, address=0x3C)) |
screen = ghee.open(display="kms"), with the panel set up (Hardware displays) |
with canvas(device) as draw: |
with screen.draw() as d: |
font = ImageFont.truetype("DejaVuSans.ttf", 12) |
font_size=12 on the call, DejaVu Sans by default. font_family="Other.ttf" takes another font |
draw.text((x, y), "hi", font=font, fill="orange") |
d.text("hi", x=x, y=y, font_size=12, color="orange") |
draw.text(...) on a 1-bit device such as the ssd1306 |
d.text(..., render="mono") (Render) |
draw.textlength("hi", font=font), draw.textbbox(...) |
screen.measure("hi", font_size=12) |
draw.rectangle((x0, y0, x1, y1), fill="white") |
d.rect(x=x0, y=y0, width=x1 - x0 + 1, height=y1 - y0 + 1) |
draw.rectangle(box, outline="white", width=1) |
d.rect(..., color="transparent", border={"width": 1, "color": "white"}) |
draw.rounded_rectangle(box, radius=3, fill="white") |
d.rect(..., border_radius=3) |
draw.ellipse((x0, y0, x1, y1), fill="white") of a circle |
d.circle(((x0 + x1 + 1) / 2, (y0 + y1 + 1) / 2), (x1 - x0 + 1) / 2) |
draw.line((x0, y0, x1, y1), fill="white", width=1) |
d.line((x0, y0), (x1, y1), stroke_width=1) |
draw.line(points, fill="lime") |
d.lines(points, color="lime") |
image.paste(icon, (x, y), icon) |
d.image(icon, x=x, y=y) |
draw.text(..., stroke_width=1, stroke_fill="black") at size 12 |
d.text(..., text_stroke={"width": 1 / 12, "color": "black"}), its width in em |
ssd1306(serial, rotate=2) |
ghee.open(display="kms", rotate=180) |
device.contrast(128) |
screen.brightness = 0.5 |
To keep luma for the panel, hand it ghee's frame as a Pillow image. This loop
draws the time ten times a second, in memory at the panel's size, in
render="mono" for the 1-bit SSD1306:
import time
import ghee
from luma.core.interface.serial import i2c
from luma.oled.device import ssd1306
device = ssd1306(i2c(port=1, address=0x3C))
screen = ghee.open(width=device.width, height=device.height, fps=10)
while True:
with screen.draw() as d:
d.text(time.strftime("%H:%M"), render="mono")
device.display(screen.image().convert(device.mode))Use a screen from the thread that opened it: a call from another thread raises RuntimeError.
A mistake in the scene, its GLSL, a param or a feed raises ValueError. A GPU or a display that won't open raises OSError, or its subclass for the error, such as PermissionError for a device node that you may not open. Any other failure of the system raises RuntimeError.
While frame() draws, your other Python threads keep running.




