This guide walks through every feature of the library from first boot to live video playback with OSD overlay. Follow the sections in order for a new setup, or jump directly to the feature you need.
- Hardware setup and wiring
- Software dependencies
- Building the library
- Running the test patterns
- Displaying a static PNG image
- Playing an animated GIF
- Playing a video file
- FPS OSD overlay
- Drawing primitives and HUD elements
- Live USB camera feed (camview)
- Integrating a GStreamer camera feed
- Configuration reference
- Troubleshooting
The GC9A01A is a 240×240 round SPI TFT. It runs on 3.3 V only — never connect VCC to a 5 V pin.
| Display pin | RPi GPIO (BCM) | Physical pin | Notes |
|---|---|---|---|
| VCC | 3.3 V | Pin 1 | Power |
| GND | GND | Pin 6 | Ground |
| SCL / CLK | GPIO 11 | Pin 23 | SPI0 SCLK |
| SDA / MOSI | GPIO 10 | Pin 19 | SPI0 MOSI |
| CS | GPIO 8 | Pin 24 | SPI0 CE0 — kernel-driven, do not use as software GPIO |
| DC | GPIO 25 | Pin 22 | Data/Command select |
| RES / RST | GPIO 24 | Pin 18 | Hardware reset — optional, tie to 3.3 V if unused |
| BLK / BL | GPIO 18 | Pin 12 | Backlight — optional, tie to 3.3 V for always-on |
The RP1 southbridge moves GPIO to a different chip device. Change one config field:
cfg.gpio_chip = "/dev/gpiochip4"; // Pi 5 only, all others use gpiochip0Everything else — BCM pin numbers, SPI device path, physical pin numbers — is identical.
sudo raspi-config
# Navigate to: Interface Options → SPI → Enable → rebootOr manually:
echo "dtparam=spi=on" | sudo tee -a /boot/firmware/config.txt
sudo rebootVerify it worked:
ls /dev/spidev0.*
# Should show: /dev/spidev0.0 /dev/spidev0.1sudo apt update
sudo apt install liblgpio-dev cmake build-essential ffmpeg python3-pil| Package | Used for |
|---|---|
liblgpio-dev |
GPIO control (works on all Pi models including Pi 5) |
cmake, build-essential |
Building the C++ library and examples |
ffmpeg |
Video decoding in video_to_frames.py |
python3-pil |
Pillow image library — GIF and video converters |
cd beepi_gc9a01a_nt
mkdir build && cd build
cmake ..
make -j$(nproc)Expected output (abbreviated):
-- Configuring done
-- Build files have been written to: .../build
[ 16%] Built target ocean_bin
[ 50%] Built target beepi_gc9a01a_nt
[ 66%] Built target testpattern
[ 83%] Built target imgview
[100%] Built target videoview_osd
If assets/input.mp4 exists, a videoplay target is also built and the
video is auto-converted to assets/video.bin during the build.
testpattern is the first thing to run on a new hardware setup. It cycles
through nine visual patterns: solid colours, gradients, concentric circles,
lines, text, and a full-colour sweep.
cd build
sudo ./testpatternEach pattern is held for ~2 seconds. If any pattern looks wrong (colours shifted, no display, partial image) see the Troubleshooting section before proceeding.
testpattern2 runs an extended set of patterns targeting HUD primitives:
reticles, badges, bearing arcs, and bars.
sudo ./testpattern2The converter accepts any PNG. For best results use a square image; non-square images are scaled and cropped automatically.
# Convert PNG to C header
cd beepi_gc9a01a_nt
python3 assets/png_to_rgb565.py your_image.png assets/logo_rgb565.h logo_rgb565This creates assets/logo_rgb565.h containing:
const uint16_t logo_rgb565[57600]; // 240*240 big-endian RGB565 pixels
const uint16_t logo_rgb565_width = 240;
const uint16_t logo_rgb565_height = 240;cd build
cmake .. # re-run cmake so it picks up the new header
make imgview -j$(nproc)
sudo ./imgviewimgview draws the image as a full-screen static frame, then overlays a
live animated HUD (reticle, range badge, compass arc, health bar) as a
demonstration of mixing image and OSD drawing.
The display uses MY|MX MADCTL — rows are scanned bottom-to-top and columns
right-to-left. To correct this, imgview copies the image into a frame
buffer with the rows flipped before calling pushFrame:
for (int r = 0; r < 240; r++)
memcpy(fb + r * 240, logo_rgb565 + (239 - r) * 240, 240 * sizeof(uint16_t));
display.pushFrame(fb);You do not need to handle this yourself when using the built-in drawing API
(drawLabel, fillRect, etc.) — it is only relevant when passing raw pixel
buffers through pushFrame.
cd beepi_gc9a01a_nt
python3 assets/gif_to_frames.py assets/ocean.gif assets/ocean.binThe converter uses Pillow to decode all GIF frames reliably (handles transparency, disposal methods, and partial-frame GIFs). Output is a BPGF binary — see the format description in README.md.
GIFs of any size are supported. Non-240×240 input is scaled and centre-cropped automatically.
cd build
sudo ./videoview # plays assets/ocean.bin
sudo ./videoview /path/to/other.bin # custom bin fileThe player mmaps the bin file (no heap allocation for frame data), reads per-frame duration from the metadata, and sleeps precisely between frames.
python3 assets/gif_to_frames.py --helpNo extra options are required for standard GIFs. The FPS stored in the output bin matches the GIF's own frame duration metadata.
video_to_frames.py accepts any format that ffmpeg can decode: MP4, MKV,
AVI, MOV, WebM, and so on.
cd beepi_gc9a01a_nt
# Basic conversion at source fps (capped to 60)
python3 assets/video_to_frames.py input.mp4 assets/video.bin
# Target 50 fps explicitly (recommended for stress testing)
python3 assets/video_to_frames.py input.mp4 assets/video.bin --fps 50
# Limit to first 500 frames (quick test)
python3 assets/video_to_frames.py input.mp4 assets/video.bin --fps 50 --max-frames 500The converter pipeline:
ffmpeg decode (any codec)
→ raw RGB24 frames
→ Python centre-crop to 1:1 aspect ratio
→ Lanczos resize to 240×240
→ big-endian RGB565 pack
→ BPGF binary
Progress is printed every 30 frames. A 60 fps 1-minute video produces roughly ~166 MB of bin data.
cd build
sudo ./videoplay ../assets/video.binvideoplay is the same binary as videoview_osd — it includes the FPS OSD
overlay. You will see Display FPS: XX (target: XX) printed to the terminal
every second, and FPS:XX overlaid on the display itself in the top bar.
If you place your video at assets/input.mp4 before running cmake, the build
system converts it automatically:
cp your_video.mp4 beepi_gc9a01a_nt/assets/input.mp4
cd build && cmake .. && make videoplay -j$(nproc)
sudo ./videoplayvideoview_osd (and videoplay) draw a live FPS counter at the top of the
screen while playing any BPGF binary.
- A solid black bar at the top of the display, 28 pixels tall
- White text centred in the bar:
FPS:XXwhere XX is the actual measured display fps over the last one-second window - Terminal output:
Display FPS: XX (target: XX)updated every second
| SPI speed | Theoretical max | Typical achieved |
|---|---|---|
| 20 MHz | ~21 fps | 20 fps |
| 40 MHz | ~43 fps | 38–40 fps |
| 62.5 MHz | ~68 fps | 50–55 fps |
The SPI speed is set in make_config() in the example source files:
cfg.spi_speed_hz = 62500000u; // 62.5 MHzThe display uses MY|MX MADCTL. When drawing directly into a frame buffer
that will be passed to pushFrame, keep these rules in mind:
- Rows:
fb[r * 240 + c]appears at screen row(239 - r). The row-flip corrects this — draw at fb row(239 - desired_screen_row). - Columns: sequential writes fill left-to-right correctly regardless of MX.
Draw at
fb[fy * 240 + fx]for screen columnfxdirectly — no x-mirror needed.
The OSD bar targets the screen top. Since fb row 239 = screen row 0 (top),
the bar is drawn at fb rows (240 - BAR_H) .. 239.
These methods are available on any BeePi_GC9A01A instance and do not
require a frame buffer. They write directly to display RAM via SPI.
display.fill(BEEPI_BLACK);
display.fillRect(10, 10, 100, 50, BEEPI_NAVY);
display.drawRect(10, 10, 100, 50, BEEPI_WHITE);
display.drawCircle(120, 120, 60, BEEPI_GREEN);
display.fillCircle(120, 120, 10, BEEPI_RED);
display.drawLine(0, 0, 239, 239, BEEPI_YELLOW);
display.drawRoundRect(20, 20, 80, 40, 8, BEEPI_CYAN);The built-in 5×8 bitmap font supports printable ASCII. Scale multiplies the glyph size: scale 1 = 5×8 px, scale 2 = 10×16 px, scale 3 = 15×24 px.
// Absolute position
display.drawLabel("HELLO", 60, 112, BEEPI_GREEN, 2);
// Corner-anchored with margin
display.drawLabelAnchored("TOP LEFT", BEEPI_ANCHOR_TOP_LEFT, 4, BEEPI_WHITE, 1);
display.drawLabelAnchored("CENTRE", BEEPI_ANCHOR_CENTER, 0, BEEPI_YELLOW, 2);
display.drawLabelAnchored("BOT RIGHT",BEEPI_ANCHOR_BOTTOM_RIGHT,4, BEEPI_CYAN, 1);
// Numeric values with units
display.drawInt(847, " m", 8, 8, BEEPI_GREEN, 1);
display.drawFloat(3.14f, 2, " rad", 8, 20, BEEPI_WHITE, 1);Background parameter: pass the same colour as the foreground for a transparent background (no fill drawn behind the text).
display.drawLabel("TRANSPARENT", 20, 60, BEEPI_RED, 1, BEEPI_RED); // transparent bg
display.drawLabel("SOLID BG", 20, 80, BEEPI_RED, 1, BEEPI_BLACK); // black bg// Cross with centre gap — common targeting style
display.drawReticle(120, 120, 30, 8, BEEPI_RETICLE_CROSS_GAP, BEEPI_GREEN, 2);
// Full crosshair
display.drawReticle(120, 120, 40, 0, BEEPI_RETICLE_CROSSHAIR, BEEPI_WHITE, 1);
// Circle with tick marks
display.drawReticle(120, 120, 25, 12, BEEPI_RETICLE_CIRCLE_CROSS, BEEPI_RED, 1);
// Simple dot
display.drawReticle(120, 120, 0, 4, BEEPI_RETICLE_DOT, BEEPI_YELLOW, 1);Parameters: (cx, cy, arm_length, gap_or_radius, style, colour, thickness)
Draws a two-row labelled box — label on top, value + unit on the bottom.
display.drawBadge("RNG", 847, "m", 8, 8, BEEPI_GREEN, BEEPI_BLACK);
display.drawBadge("ALT", 1250, "m", 8, 40, BEEPI_CYAN, BEEPI_BLACK);
display.drawBadge("SPD", 32, "kts", 8, 72, BEEPI_WHITE, BEEPI_BLACK);// drawBarH(x, y, width, height, value, max_value, bar_colour, bg_colour, border_colour)
display.drawBarH(72, 218, 96, 7, 6, 20, BEEPI_GREEN, BEEPI_DARKGREY, BEEPI_WHITE);// drawBearingArc(bearing_deg, fov_deg, radius, arc_colour, tick_colour)
display.drawBearingArc(45.0f, 90.0f, 110, BEEPI_WHITE, BEEPI_YELLOW);Draws a partial arc spanning fov_deg degrees around the display edge,
with a tick mark at the current bearing.
// Runtime RGB888 → RGB565
uint16_t orange = BeePi_GC9A01A::rgb(255, 128, 0);
// Compile-time macro (use in constant expressions)
uint16_t teal = BEEPI_RGB(0, 180, 180);
// Interpolate between two colours
uint16_t mid = BeePi_GC9A01A::lerpColor(BEEPI_RED, BEEPI_BLUE, 128); // 50%
// Dim a colour
uint16_t dim = BeePi_GC9A01A::dimColor(BEEPI_GREEN, 80); // ~31% brightnesscamview streams directly from a USB webcam to the display in real time.
It uses V4L2 (the Linux kernel video API) with no extra libraries beyond what
the kernel already provides.
- USB webcam recognised by Linux (any UVC-compliant camera works)
- Camera appearing as
/dev/video0(or pass a different node as argv) - No extra apt packages beyond
liblgpio-devalready installed
cd beepi_gc9a01a_nt/build
cmake ..
make camview -j$(nproc)
# Plug in USB webcam, then:
sudo ./camview # default: /dev/video0
sudo ./camview /dev/video1 # if camera is on a different nodeUSB webcam 640x480 YUYV @ 30fps
|
| V4L2 DQBUF — grab raw frame from kernel mmap buffer
v
YUYV 640x480 (614400 bytes, 2 bytes per pixel)
|
| Centre-crop 480x480 (skip 80px left and right)
| 2:1 downsample to 240x240 (fixed-point, one pass)
| BT.601 YUV -> RGB -> big-endian RGB565
v
RGB565 240x240 (115200 bytes)
|
| Row-flip (MY orientation correction)
| FPS OSD bar drawn at screen top
v
pushFrame -> SPI -> display
Two threads run fully independently:
Capture thread — calls select() blocking until V4L2 has a frame, grabs
it with DQBUF, runs the YUYV conversion, writes into a ping-pong slot, flips
the atomic write index, sets g_fresh = true, returns the buffer with QBUF.
Display thread — spin-waits on g_fresh with 0.5 ms yield sleeps, reads
from the opposite ping-pong slot (the one capture is not writing to), row-flips,
draws OSD, calls pushFrame.
No mutex is needed in the hot path — a single std::atomic<int> index swap is
the only synchronisation. If the camera is slow the display shows the last good
frame. If the display is slow the camera overwrites the slot and the display
picks up the newest frame on its next iteration — frames are dropped, not queued.
Every second both fps counters are printed:
Capture: 30 fps Display: 30 fps
The OSD bar on the display shows display fps (FPS:30).
| Camera fps | Display fps | Notes |
|---|---|---|
| 30 fps (typical USB webcam) | 30 fps | SPI has spare headroom |
| 60 fps (high-speed webcam) | 30–60 fps | Display keeps up; actual rate printed live |
YUYV is the standard output format for USB webcams. Every 4 bytes encodes 2 horizontal pixels:
Byte 0: Y0 luma for pixel 0
Byte 1: U chroma Cb, shared by both pixels
Byte 2: Y1 luma for pixel 1
Byte 3: V chroma Cr, shared by both pixels
BT.601 converts YUV to RGB using integer arithmetic (no floats):
C = Y - 16; D = U - 128; E = V - 128;
R = clip((298*C + 409*E + 128) >> 8);
G = clip((298*C - 100*D - 208*E + 128) >> 8);
B = clip((298*C + 516*D + 128) >> 8);After converting to RGB the result is packed as standard LE RGB565, then byte-swapped before storing in the frame buffer:
uint16_t px = ((R & 0xF8) << 8) | ((G & 0xFC) << 3) | (B >> 3);
return (uint16_t)((px >> 8) | (px << 8)); // bswap to big-endianThis is required because pushFrame expects big-endian RGB565 — the same
format as all BEEPI_* colour constants. Without this swap colours are
completely wrong (channel rotation visible as red → green, green → yellow,
blue → red).
pushFrame transfers a full 240×240 RGB565 buffer in a single SPI DMA
call — this is the primary path for live camera display.
BeePiHALConfig cfg = {};
cfg.spi_device = "/dev/spidev0.0";
cfg.spi_speed_hz = 62500000u;
cfg.gpio_dc = 25;
cfg.gpio_rst = 24;
cfg.gpio_bl = 18;
cfg.gpio_chip = "/dev/gpiochip0";
BeePi_GC9A01A display(cfg);
display.begin();
uint16_t fb[240 * 240];
while (running) {
// Fill fb[] from your camera source (V4L2, GStreamer appsink, etc.)
get_camera_frame(fb);
display.pushFrame(fb);
}Configure your GStreamer pipeline to output video/x-raw,format=RGB16 at
240×240. In the appsink callback:
void on_new_sample(GstAppSink *sink, void *user_data)
{
BeePi_GC9A01A *display = static_cast<BeePi_GC9A01A *>(user_data);
GstSample *sample = gst_app_sink_pull_sample(sink);
GstBuffer *buf = gst_sample_get_buffer(sample);
GstMapInfo map;
if (gst_buffer_map(buf, &map, GST_MAP_READ)) {
if (map.size == 240 * 240 * 2)
display->pushFrame(reinterpret_cast<const uint16_t *>(map.data));
gst_buffer_unmap(buf, &map);
}
gst_sample_unref(sample);
}For overlays that update infrequently (range readout, status text) use
pushRegion to avoid resending the whole frame:
// Update only the top-left 80×20 px badge area
uint16_t badge_pixels[80 * 20];
render_badge(badge_pixels);
display.pushRegion(0, 0, 80, 20, badge_pixels);BeePiHALConfig cfg = {};
// SPI
cfg.spi_device = "/dev/spidev0.0"; // SPI bus device node
cfg.spi_speed_hz = 62500000u; // SPI clock in Hz
// 20 MHz — safe with long jumper wires
// 40 MHz — safe on all Pi with short wires
// 62.5 MHz — maximum tested-stable on Pi 4
// GPIO (BCM pin numbers)
cfg.gpio_dc = 25; // Data/Command pin (required)
cfg.gpio_rst = 24; // Reset pin (-1 to skip)
cfg.gpio_bl = 18; // Backlight pin (-1 if hardwired on)
cfg.gpio_chip = "/dev/gpiochip0"; // GPIO chip — Pi 5 must use "/dev/gpiochip4"62.5 MHz → ~68 fps theoretical → ~50–55 fps real
40.0 MHz → ~43 fps theoretical → ~38–40 fps real
20.0 MHz → ~21 fps theoretical → ~20 fps real
- Verify wiring. The most common error is swapping DC and RST.
- Check SPI is enabled:
ls /dev/spidev0.* - Run
sudo ./testpattern— if it exits without error but screen is blank, check the BLK/backlight pin. Setgpio_bl = -1if the backlight is hardwired. - Check that
gpio_chipmatches your Pi model.
The pixel data path requires big-endian RGB565 storage in the bin/header files. The HAL byte-swaps pixels on transmission. If you are writing your own converter, pack as:
v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) # standard RGB565
v = ((v & 0xFF) << 8) | (v >> 8) # byte-swap to big-endianThe copy_frame_to_fb row-flip is required before every pushFrame call
when using raw pixel buffers. Make sure your display loop includes:
for (int r = 0; r < 240; r++)
memcpy(fb + r * 240, src + (239 - r) * 240, 240 * sizeof(uint16_t));
display.pushFrame(fb);The built-in drawing API (drawLabel, fill, etc.) is not affected — this
only applies to raw buffer pushes.
Do not apply any x-mirror compensation when writing OSD pixels into fb[].
Write directly to fb[fy * 240 + fx]. The MX MADCTL flag reverses the GRAM
scan direction, but sequential pixel writes from pushFrame always fill
left-to-right on screen regardless.
The OSD must be drawn to match the MY row-flip. Target the correct fb rows:
- Screen top = fb rows
(240 - BAR_H) .. 239 - Font bit 0 (glyph top on screen) = highest fb row in the glyph:
draw from
y_topdownward in fb index as row index increases.
sudo apt install liblgpio-devsudo apt install python3-pil
# or:
pip3 install Pillow --break-system-packages- Increase
spi_speed_hzinmake_config(). Try40000000then62500000. - Check that the bin file is on a fast storage path (SD card Class 10 or USB SSD).
- For the video converter, verify that ffmpeg resampled to the target fps:
the terminal output shows
target X fpsandDisplay FPS: Xshould match.
The most common symptom is red → green, green → yellow, blue → red. This means
the YUV→RGB565 output is not byte-swapped before being stored in the frame
buffer. pushFrame requires big-endian RGB565. Verify the pack in
yuv_to_rgb565 ends with:
uint16_t px = ((R & 0xF8u) << 8) | ((G & 0xFCu) << 3) | (B >> 3);
return (uint16_t)((px >> 8) | (px << 8)); // bswap — required for pushFrameU and V bytes may be swapped. Some cameras output YVYU (byte order Y0 V Y1 U)
instead of YUYV (Y0 U Y1 V). In yuyv_crop_scale_to_rgb565 swap the read
indices: u = srow[base+3], v = srow[base+1].
The HAL failed to open the SPI device or configure GPIO. Common causes:
- Not running with
sudo(spidev and lgpio both require elevated privileges or udev rules granting access). - Wrong
gpio_chipfor your Pi model. - SPI not enabled in raspi-config.