Skip to content

Latest commit

 

History

History
748 lines (544 loc) · 21.4 KB

File metadata and controls

748 lines (544 loc) · 21.4 KB

BeeBotix GC9A01A — User Guide

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.


Table of contents

  1. Hardware setup and wiring
  2. Software dependencies
  3. Building the library
  4. Running the test patterns
  5. Displaying a static PNG image
  6. Playing an animated GIF
  7. Playing a video file
  8. FPS OSD overlay
  9. Drawing primitives and HUD elements
  10. Live USB camera feed (camview)
  11. Integrating a GStreamer camera feed
  12. Configuration reference
  13. Troubleshooting

1. Hardware setup and wiring

The GC9A01A is a 240×240 round SPI TFT. It runs on 3.3 V only — never connect VCC to a 5 V pin.

Pin connections (Raspberry Pi)

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

Raspberry Pi 5 note

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 gpiochip0

Everything else — BCM pin numbers, SPI device path, physical pin numbers — is identical.

Enabling SPI

sudo raspi-config
# Navigate to: Interface Options → SPI → Enable → reboot

Or manually:

echo "dtparam=spi=on" | sudo tee -a /boot/firmware/config.txt
sudo reboot

Verify it worked:

ls /dev/spidev0.*
# Should show: /dev/spidev0.0  /dev/spidev0.1

2. Software dependencies

sudo 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

3. Building the library

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.


4. Running the test patterns

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 ./testpattern

Each 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 ./testpattern2

5. Displaying a static PNG image

Step 1 — Prepare your image

The 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_rgb565

This 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;

Step 2 — Build and run

cd build
cmake ..          # re-run cmake so it picks up the new header
make imgview -j$(nproc)
sudo ./imgview

imgview 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.

How it works internally

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.


6. Playing an animated GIF

Step 1 — Convert your GIF

cd beepi_gc9a01a_nt
python3 assets/gif_to_frames.py assets/ocean.gif assets/ocean.bin

The 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.

Step 2 — Play

cd build
sudo ./videoview                          # plays assets/ocean.bin
sudo ./videoview /path/to/other.bin       # custom bin file

The player mmaps the bin file (no heap allocation for frame data), reads per-frame duration from the metadata, and sleeps precisely between frames.

Converter options

python3 assets/gif_to_frames.py --help

No extra options are required for standard GIFs. The FPS stored in the output bin matches the GIF's own frame duration metadata.


7. Playing a video file

video_to_frames.py accepts any format that ffmpeg can decode: MP4, MKV, AVI, MOV, WebM, and so on.

Step 1 — Convert

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 500

The 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.

Step 2 — Play

cd build
sudo ./videoplay ../assets/video.bin

videoplay 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.

Automatic build integration

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 ./videoplay

8. FPS OSD overlay

videoview_osd (and videoplay) draw a live FPS counter at the top of the screen while playing any BPGF binary.

What it shows

  • A solid black bar at the top of the display, 28 pixels tall
  • White text centred in the bar: FPS:XX where XX is the actual measured display fps over the last one-second window
  • Terminal output: Display FPS: XX (target: XX) updated every second

Expected fps values

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 MHz

Orientation notes for custom OSD drawing

The 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 column fx directly — 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.


9. Drawing primitives and HUD elements

These methods are available on any BeePi_GC9A01A instance and do not require a frame buffer. They write directly to display RAM via SPI.

Basic shapes

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);

Text labels

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

Targeting reticle

// 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)

Range / value badge

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);

Horizontal bar (health / progress)

// 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);

Bearing arc (compass HUD)

// 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.

Colour utilities

// 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% brightness

9. Live USB camera feed (camview)

camview 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.

What you need

  • 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-dev already installed

Build and run

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 node

What it does

USB 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

Threading model

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.

Terminal output

Every second both fps counters are printed:

  Capture: 30 fps   Display: 30 fps

The OSD bar on the display shows display fps (FPS:30).

Expected performance

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 pixel format and the bswap contract

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-endian

This 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).


10. Integrating a GStreamer camera feed

pushFrame transfers a full 240×240 RGB565 buffer in a single SPI DMA call — this is the primary path for live camera display.

Minimum integration

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);
}

With GStreamer appsink

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);
}

Partial region update

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);

11. Configuration reference

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"

SPI speed vs fps

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

12. Troubleshooting

No display output at all

  • 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. Set gpio_bl = -1 if the backlight is hardwired.
  • Check that gpio_chip matches your Pi model.

All colours appear blue / wrong colours

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-endian

Image is upside-down

The 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.

OSD text appears mirrored

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.

OSD text appears upside-down

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_top downward in fb index as row index increases.

Build fails: liblgpio not found

sudo apt install liblgpio-dev

Build fails: python3-pil not found (asset converters)

sudo apt install python3-pil
# or:
pip3 install Pillow --break-system-packages

Low fps / stuttering video

  • Increase spi_speed_hz in make_config(). Try 40000000 then 62500000.
  • 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 fps and Display FPS: X should match.

Live camera colours are wrong (channel rotation)

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 pushFrame

Live camera colours are tinted but not fully wrong

U 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].

begin() returns false

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_chip for your Pi model.
  • SPI not enabled in raspi-config.