Graphics guide

August 23, 2026 · View on GitHub

Cross-platform 2D drawing: framebuf-compatible buffers, shape primitives that return Area bounds, fonts, and image loaders. One import works on MicroPython, CircuitPython, and CPython.

!!! note "Where the pieces live" pygraphics is maintained here. Install it from the PyDevices MIP index (mip.install("pygraphics", index=…)) or from TestPyPI as pydevices-pygraphics. The utils modules referenced below (displaybuf, tft_text, …) ship with pydevices-examples.

Quick start

import pygraphics

w, h = 16, 16
fb = pygraphics.FrameBuffer(bytearray(w * h * 2), w, h, pygraphics.RGB565)
fb.fill(0)
fb.fill_rect(1, 1, 6, 6, 0xFFFF)
fb.circle(8, 8, 3, 0x1234)
pygraphics.text8(fb, "Hi", 0, 0, 0xFFFF)

pygraphics bundles its own pure-Python framebuf implementation (pygraphics.framebuf, MP-parity with modframebuf.c) and always builds pygraphics.FrameBuffer on top of it — the same code path runs on MicroPython, CircuitPython, and CPython, so there is no native-vs-pure-Python backend to inspect or branch on.

Drawing Pipeline Architecture

flowchart TD
    subgraph Primitives ["Drawing Operations"]
        P1["fill_rect(x, y, w, h)"]
        P2["circle(x, y, r)"]
        P3["round_rect(x, y, w, h, r)"]
        P4["text8 / text14 / text16"]
    end

    subgraph CoreEngine ["pygraphics Pipeline"]
        Clip{"Active Clip Rect?<br/>(with draw.clip)"}
        DrawOp["Rasterize to FrameBuffer / Canvas"]
        AreaCalc["Compute Bounding Area(x, y, w, h)"]
    end

    subgraph Output ["Target Dispatch"]
        Dirty["Dirty Region Accumulation<br/>(dirty = a + b)"]
        FastBlit["Hardware Fast-Path<br/>(display_drv.blit_rect)"]
    end

    Primitives --> Clip
    Clip -->|Intersect Bounds| DrawOp
    DrawOp --> AreaCalc
    AreaCalc --> Dirty
    Dirty --> FastBlit

FrameBuffer vs Draw vs module functions

StyleWhen to use
pygraphics.FrameBufferDefault — own a buffer; get .buffer, .width, save/load, all shape methods
pygraphics.Draw(canvas)Draw on a display driver or third-party object with pixel / hline / …
Module functions (pygraphics.circle(fb, …))Short scripts; same primitives as FrameBuffer methods
draw = pygraphics.Draw(display_drv)
draw.round_rect(5, 5, 50, 30, 4, 0xF800)

with draw.clip(10, 20, 100, 60):
    draw.fill_rect(0, 0, 200, 200, 0xF800)  # only the intersection is drawn

Area and partial updates

Most draw methods return an Area(x, y, w, h) bounding box. Union dirty regions:

a = fb.fill_rect(0, 0, 10, 10, color)
b = fb.circle(12, 12, 4, color)
dirty = a + b
display_drv.blit_rect(fb.buffer, dirty.x, dirty.y, dirty.w, dirty.h)
  • Setting fb.pixel(x, y, c) returns Area(x, y, 1, 1).
  • Reading fb.pixel(x, y) returns the color (no Area).
  • scroll() returns the full buffer bounds.

Pixel formats

ConstantDepth
MONO_VLSB, MONO_HLSB, MONO_HMSB1 bpp
GS2_HMSB2 bpp
GS4_HMSB4 bpp
GS88 bpp
RGB56516 bpp

Fonts

Text helpers (text8, text14, text16, text, and FrameBuffer.text) use embedded romfont data shipped inside the pygraphics package (_font_8x8.py, _font_8x14.py, _font_8x16.py — derived from spacerace/romfont). No font files on the filesystem are required for the built-in heights.

pygraphics.text8(fb, "Hello", 0, 0, 0xFFFF)
pygraphics.text14(fb, "Tall", 0, 16, 0xF800)
pygraphics.text16(fb, "Big", 0, 32, 0x07E0)

Loading romfont .bin files from the filesystem

To use a custom or packaged romfont binary, pass a file path to pygraphics.Font. The file is opened on MicroPython, CircuitPython, and CPython like any other readable path (relative paths are resolved from the process working directory).

f = pygraphics.Font("assets/font_8x14.bin", 14)
f.text(fb, "From disk", 0, 48, 0xFFFF)

# Height can be inferred from names like font_8x16.bin when omitted:
f16 = pygraphics.Font("/sd/fonts/font_8x16.bin")

By default (cached=True) the entire file is read into RAM when the Font is constructed. Set cached=False to keep the file open and read glyphs on demand (lower RAM, more I/O).

Missing or unreadable paths raise FileNotFoundError. A file whose size does not match the expected glyph count raises RuntimeError.

Examples that load .bin fonts from disk: font_simpletest.py (cycles string_blitper_pixeldisplaybuf) and font_list.py. Both read font files from the filesystem, so they run on desktop and MCU but not in the browser.

For a Font demo you can run right now, see tv_remote_menu.py or pydevices_demo.py, which use the built-in font (Font(height=16)) and need no font file.

Romfont .bin format

Romfont binaries are a flat blob of glyph rows (8 pixels wide, MSB = leftmost pixel):

FieldValue
Glyph orderCode points 0255 (or 128 glyphs for a 128-character subset)
Bytes per glyphFont height (e.g. 8, 14, or 16)
File size256 × height bytes, or 128 × height for 128-glyph files
Width8 pixels (only 8-pixel-wide fonts are supported today)

You can also pass a memoryview or bytes object instead of a path when the font data is already in RAM (frozen module, bytes literal, mmap, and so on).

Not the same as framebuf.text

pygraphics.framebuf.FrameBuffer.text() (and MicroPython's built-in framebuf$ \text{module}) \text{use} \text{a} **\text{different}** \text{built}-\text{in} 8 \times 8 \text{font} (\text{Damien} \text{George}'\text{s} $font_petme128_8x8). For romfont appearance and heights 8/14/16, use pygraphics.text8 / text14 / text16 or pygraphics.Font as above.

Choosing a font rendering pattern

Font.text() and text8 / text14 / text16 render each set glyph bit with fill_rect (scaled squares). Where you draw — and whether you composite in RAM first — controls transparency, RAM use, and how much data hits the panel bus.

The multipath font_simpletest.py example uses the same Font + romfont .bin files but cycles different targets in one run. pydevices_demo follows the string framebuffer + one blit pattern (string_blit).

PatternExampleBackgroundExtra RAMWhat hits the displayTypical sweet spot
Module helpers on canvaspygraphics.text8(display_drv, …)Transparent (foreground pixels only)NoneOne small fill_rect per lit pixelShort labels, minimum RAM
String FB → one blitfont_simpletest.py (string_blit)Opaquefb.fill(bg) before font.textOne buffer sized to the string (reusable slice is better; see pydevices_demo)One blit_rect per stringDesktop/SDL (batch then show()), SPI panels when RAM is tight
Draw on display_drvfont_simpletest.py (per_pixel)TransparentNoneOne fill_rect per lit pixel on the live driverSimplest code path; slowest on MCU and desktop
Full-screen DisplayBuffer + dirty blitfont_simpletest.py (displaybuf)Transparent over existing buffer contentsFull panel DisplayBufferdisplay.show(dirty) — one row blit_rect per dirty scanlineMCUs with enough RAM; many text updates; fastest of the three modes
Catalog / inspect fontsfont_list.pyOpaque row bufferOne strip width × height per fontOne blit_rect per font rowBrowsing .bin files on disk

Module helpers (text8, text14, text16)

Draw directly on any canvas (FrameBuffer, display_drv, DisplayBuffer, …). Only foreground pixels are written — the background is left unchanged (transparent text).

pygraphics.text8(fb, "Hi", x, y, fg_color)
area = pygraphics.text16(display_drv, "Status", 4, 4, 0xFFFF)  # returns Area bounds

Lowest memory overhead; fine for a few characters. On SPI TFTs without a compositing layer, each lit pixel can become a separate bus transaction (same cost class as per_pixel mode).

String framebuffer + one blit (string_blit)

Compose the whole string in a small off-screen FrameBuffer, then upload it once:

w, h = len(s) * font.width * scale, font.height * scale
buf = bytearray(w * h * 2)
fb = pygraphics.FrameBuffer(buf, w, h, pygraphics.RGB565)
fb.fill(bg_color)          # opaque background
font.text(fb, s, 0, 0, fg_color, scale)
display_drv.blit_rect(buf, x, y, w, h)
display_drv.show()         # SDL/pygame: present the frame
  • Opaque labels (background colour filled before glyphs).
  • RAM: proportional to string size, not the full screen — good when DisplayBuffer is too large for the MCU.
  • Speed: one bulk blit per string. On desktop backends that defer work until show(), this batches well. On MCU panels that flush each blit_rect immediately, this still beats per-pixel drawing because the bus sees one contiguous block per string.
  • Production apps often keep a reusable buffer sized for the longest line (see pydevices_demo) instead of allocating every frame like the simpletest does.

Direct draw on display_drv (per_pixel)

font.text(display_drv, s, x, y, fg_color, scale)
display_drv.show()
  • Transparent text (no fb.fill; unset bits are not drawn).
  • Lowest RAM — no extra framebuffer.
  • Slowest upload pattern: every lit pixel is its own fill_rect on the driver. Avoid for long strings on hardware; acceptable for occasional tiny overlays.

DisplayBuffer + dirty rectangle (displaybuf)

Keep a logical full-screen buffer in RAM; upload only what changed:

from displaybuf import DisplayBuffer

display = DisplayBuffer(display_drv)
dirty = font.text(display, s, x, y, fg_color, scale)
display.show(dirty)       # row blits for the Area bounds only (RGB565)
display_drv.show()          # present on SDL; on raw SPI may follow panel habits
  • Transparent over whatever is already in the DisplayBuffer.
  • Highest RAM (full panel buffer) — trade memory for speed when the UI redraws text often.
  • Fastest of the three font_simpletest modes: glyph work stays in RAM; the panel receives only the dirty region (scanline blit_rects), not per-pixel fills.
  • Requires utils/displaybuf.py on the import path — set PYTHONPATH/MICROPYPATH to .:lib:utils (preferred), or see pydevices install workflows when environment variables are unavailable or not set as recommended.
  • Partial area= updates apply to RGB565 DisplayBuffer; GS8/GS4 paths currently refresh wider bands (see displaybuf notes in source).

Desktop vs MCU and display_drv.show()

BackendRole of show()
SDL / pygame (desktop)Drawing is buffered; show() presents the frame. Prefer few large blits (font_simpletest.py or reusable string buffer) then one show() per frame.
SPI / parallel MCU panelsMany drivers act on each blit_rect / fill_rect immediately. Favour one blit per string or DisplayBuffer.show(dirty) over font.text(display_drv, …).
Skipping show()Only safe when your driver documents immediate updates. Per-pixel font.text(display_drv, …) is still slow on the bus even without show().

Run the examples side by side from lib/:

micropython examples/font_simpletest.py

PyScript and the gallery load the same .bin assets from lib/examples/assets/ (see the pydevices-examples gallery).

Image loaders

Eager loaders in the pygraphics package (full image in RAM):

fb = pygraphics.bmp_to_framebuffer("sprite.bmp")
fb = pygraphics.pbm_to_framebuffer("icon.pbm")
fb = pygraphics.pgm_to_framebuffer("gray.pgm")
fb = pygraphics.load_image("image.bmp")  # or FrameBuffer.from_file(...)

save_image(fb, path) and FrameBuffer.save() write PBM/PGM/BMP for the formats in Graphics files. Other framebuffer formats raise ValueError.

Blit fast paths

pygraphics.blit(), Draw.blit(), and blit_rect() dispatch to faster implementations when available:

DestinationFast path
Display driver (blit_rect / blit_transparent)SPI/SDL/pygame bulk copy
FrameBufferpygraphics.framebuf's blit() (same implementation on every interpreter)

Use Draw(display_drv).blit(sprite_fb, x, y) instead of a per-pixel loop — it routes to display_drv.blit_rect for RGB565 sprites.

Clip regions

Draw.clip(x, y, w, h) (or clip(Area(...))) is a context manager that intersects all drawing with a rectangle. Nested clips intersect further; the clip is restored when the block exits:

with draw.clip(10, 10, 50, 40):
    draw.fill(0x0000)          # fills only the clip rect
    draw.text8("Panel", 0, 0, 0xFFFF)

For streaming/large BMP assets, use pygraphics.BMP565 (sliceable, optional streaming reads) — see Graphics files.

Run full interactive pygraphics games and demos directly in your browser:

ExampleDescriptionLive PyScript Link
bouncing_ballsAnimated high-framerate vector bouncing ballsLaunch bouncing_balls
alienAnimated retro sprite invasion arcadeLaunch alien
calc_graphicsPure 2D graphics pocket calculatorLaunch calc_graphics
dinoChrome runner obstacle jumping arcadeLaunch dino
simonSimon touch memory sequence gameLaunch simon
pianoInteractive multi-key musical keyboardLaunch piano
testrisComplete falling-blocks arcade gameLaunch testris
rotationsHardware and software panel rotation suiteLaunch rotations

FAQ

Draw method returned nothing? — Use pygraphics.FrameBuffer or Draw; the bare pygraphics.framebuf.FrameBuffer base methods do not return Area.

Next