Rust Embedded Development: Displaying Images on ST7789 Screen

Guide on displaying images on ST7789 screens using Rust embedded development, including image format conversion techniques from common formats to supported formats like BMP, TGA, and QOI.

发布于 约 2 分钟

embedded-graphics is the standard for display graphics in Rust embedded development, so we can use it to display images.

However, embedded-graphics doesn’t support common image formats like png, jpg, etc.

From the documentation, we learn that embedded-graphics only supports bmp, tga, and qoi formats.

In fact, it’s not that simple. The ST7789 screen doesn’t support RGB888 (also called 24-bit RGB) color format. Through documentation research, we know that the ST7789 screen only supports RGB565 (16-bit) color format.

So the solution is straightforward: we just need to convert the image from 24-bit to 16-bit color space and convert it to bmp, tga, or qoi format to display it on the screen.

Converting Images

You can use a Python script to convert any image format to the formats mentioned above.

For example, here’s a script I implemented to convert jpg to tga:

def convert_jpg_to_tga_rgb565(input_path: str, output_path: Optional[str] = None, size: Tuple[int, int] = (64, 64)) -> str:
    """
    Resize JPG image and convert to RGB565 format TGA
    
    Args:
        input_path: Input JPG image path
        output_path: Output TGA image path (optional, defaults to input filename.tga)
        size: Target size, defaults to (64, 64)
    
    Returns:
        Output file path
    """
    input_path_obj = Path(input_path)
    
    # Determine output path
    if output_path is None:
        output_path_obj = input_path_obj.with_suffix('.tga')
    else:
        output_path_obj = Path(output_path)
    
    output_path_str = str(output_path_obj)
    
    # Open image
    with Image.open(input_path_obj) as img:
        # Convert to RGB mode
        if img.mode != 'RGB':
            img = img.convert('RGB')
        
        # Resize to specified dimensions
        img_resized = img.resize(size, Image.Resampling.LANCZOS)
        
        # RGB565 layout: bit15-11=red(5 bits), bit10-5=green(6 bits), bit4-0=blue(5 bits)
        pixel_data = bytearray()
        for r, g, b in img_resized.getdata():
            r5 = (r >> 3) & 0x1F   # Red: 8-bit to 5-bit
            g6 = (g >> 2) & 0x3F   # Green: 8-bit to 6-bit
            b5 = (b >> 3) & 0x1F   # Blue: 8-bit to 5-bit
            # Combine into 16-bit RGB565: high->low = RRRRR GGGGGG BBBBB
            pixel565 = (r5 << 11) | (g6 << 5) | b5
            pixel_data.append(pixel565 & 0xFF)
            pixel_data.append((pixel565 >> 8) & 0xFF)
    
    header = bytearray(18)
    header[0] = 0    # ID length
    header[1] = 0    # Color map type
    header[2] = 2    # Image type: uncompressed true-color
    header[3] = 0    # Color map spec: first entry low
    header[4] = 0    # Color map spec: first entry high
    header[5] = 0    # Color map spec: length low
    header[6] = 0    # Color map spec: length high
    header[7] = 0    # Color map spec: depth
    header[8] = 0    # X origin low
    header[9] = 0    # X origin high
    header[10] = 0   # Y origin low
    header[11] = 0   # Y origin high
    header[12] = size[0] & 0xFF      # Width low
    header[13] = (size[0] >> 8) & 0xFF  # Width high
    header[14] = size[1] & 0xFF      # Height low
    header[15] = (size[1] >> 8) & 0xFF  # Height high
    header[16] = 16  # Bits per pixel
    header[17] = 0x20  # Image descriptor: bit 5=1 (origin at bottom-left)
    
    # Write TGA file
    with open(output_path_str, 'wb') as f:
        f.write(header)
        f.write(pixel_data)
    
    return output_path_str

Note that you need to be careful whether you’re converting to RGB565( RRRRR GGGGGG BBBBB) or BGR565( BBBBB GGGGGG RRRRR). Both are supported, and this is very important - it will be relevant later.

The order is mainly controlled by the following code:

# BGR565
pixel565 = (b5 << 11) | (g6 << 5) | r5

# RGB565
pixel565 = (r5 << 11) | (g6 << 5) | b5

Jpg to bmp (rgb565) conversion is roughly as follows:

def convert_jpg_to_bmp_rgb565(input_path: str, output_path: Optional[str] = None, size: Tuple[int, int] = (128,128)) -> str:
    """
    Convert JPG image to 16-bit RGB565 format BMP (BITFIELDS format)
    
    Args:
        input_path: Input JPG image path
        output_path: Output BMP image path (optional, defaults to input filename.bmp)
        size: Target size, defaults to 64x64
    
    Returns:
        Output file path
    """
    input_path_obj = Path(input_path)
    
    if not input_path_obj.exists():
        raise FileNotFoundError(f"Input file not found: {input_path}")
    
    # Determine output path
    if output_path is None:
        output_path_obj = input_path_obj.with_suffix('.bmp')
    else:
        output_path_obj = Path(output_path)
    
    output_path_str = str(output_path_obj)
    
    # Open image
    with Image.open(input_path_obj) as img:
        # Convert to RGB mode
        if img.mode != 'RGB':
            img = img.convert('RGB')
        
        # Resize to specified dimensions
        img_resized = img.resize(size, Image.Resampling.LANCZOS)
        
        # Get all pixel data
        all_pixels = list(img_resized.getdata())
        width, height = size
        
        # BMP is bottom-up format, need to store rows from bottom
        # i.e., first row of pixel data corresponds to the bottom of the image
        pixel_data = bytearray()
        for y in range(height - 1, -1, -1):  # Start from bottom
            for x in range(width):
                r, g, b = all_pixels[y * width + x]
                # RGB565: R:5 bits, G:6 bits, B:5 bits
                r5 = (r >> 3) & 0x1F
                g6 = (g >> 2) & 0x3F
                b5 = (b >> 3) & 0x1F
                # Combine into 16-bit: RRRRR GGGGGG BBBBB
                pixel565 = (r5 << 11) | (g6 << 5) | b5
                # Little-endian storage
                pixel_data.append(pixel565 & 0xFF)
                pixel_data.append((pixel565 >> 8) & 0xFF)
        
        # Create BITFIELDS format BMP header (70 bytes)
        header = create_bitfields_bmp_header(size[0], size[1], len(pixel_data))
        
        # Write BMP file
        with open(output_path_str, 'wb') as f:
            f.write(header)
            f.write(pixel_data)
    
    return output_path_str

Driving Image Display

TGA and BMP images use these two libraries respectively:

tinytga = "0.5.0"
tinybmp = "0.7.0"

Here I’ll use the following image as an example to display on the ST7789 screen:

logo

The core code is minimal, roughly as follows:

    // Need to clear the screen, otherwise previous content will be displayed
    display.clear(Rgb565::BLACK).unwrap();

    let data = include_bytes!("../../jing.tga");
    let img: Tga<Rgb565> = Tga::from_slice(data).unwrap();

    // If using a bmp image, use the following code
    // let data = include_bytes!("../../jing.bmp");
    // let img: Bmp<Rgb565> = Bmp::from_slice(data).unwrap();

    let image = Image::new(&img, Point::zero());
    image.draw(&mut display.color_converted()).unwrap();

    loop {
        delay.delay_millis(500);
    }

2 Major Pitfalls

Both of the following pitfalls can cause color display errors similar to this:

IMG_20260212_214349.jpg

First pitfall: Rgb565 or Bgr565?

When converting, you must pay attention to the order. If you converted to Rgb, then use Tga<Rgb565> during initialization. If it’s Bgr, then use Tga<Bgr565> during initialization. Also, when initializing the screen, you need to set:

let mut display = Builder::new(ST7789, di)
        .reset_pin(rst)
        .color_order(mipidsi::options::ColorOrder::Rgb) //<======== Set to Rgb or Bgr here
        .init(&mut delay)
        .unwrap();

Second pitfall: Screen color inversion

You might, like me, fall into the second pitfall after getting out of the first one. I don’t know if it’s my initialization issue or a problem with the mipidsi driver, but by default if you do nothing and just set this:

display.clear(Rgb565::BLACK).unwrap();

It displays as white on the screen. For example, my previous temperature and humidity screen interface:

IMG_20260209_225748.jpg

You can see that I set Rgb565::BLACK, but the screen displays white. This problem didn’t occur with C language.

The solution is to add the following parameter when initializing the screen to invert the screen colors:

    let mut display = Builder::new(ST7789, di)
        .reset_pin(rst)
        .color_order(mipidsi::options::ColorOrder::Rgb)
        // Invert screen colors
        .invert_colors(mipidsi::options::ColorInversion::Inverted)
        .init(&mut delay)
        .unwrap();

The final result is as follows:

IMG_20260212_214239.jpg

Complete Code

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]
#![deny(clippy::large_stack_frames)]

use embedded_graphics::prelude::RgbColor;
use embedded_graphics::{
    Drawable,
    image::Image,
    pixelcolor::{Rgb565, Bgr565},
    prelude::{DrawTarget, DrawTargetExt, OriginDimensions, Point, Size},
};
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_alloc as _;
use esp_hal::main;
use esp_hal::{clock::CpuClock, delay::Delay, gpio, spi::master::Config, time::Rate};
use mipidsi::{Builder, interface::SpiInterface, models::ST7789, options::Orientation};
use tinybmp::Bmp;
use tinytga::Tga;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern crate alloc;

esp_bootloader_esp_idf::esp_app_desc!();

#[allow(
    clippy::large_stack_frames,
    reason = "it's not unusual to allocate larger buffers etc. in main"
)]
#[main]
fn main() -> ! {
    // static mut APP_CORE_STACK: Stack<8192> = Stack::new();
    let mut delay = Delay::new();
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    // LCD display initialization
    let dc = gpio::Output::new(peripherals.GPIO15, gpio::Level::Low, Default::default());
    let mut rst = gpio::Output::new(peripherals.GPIO7, gpio::Level::Low, Default::default());
    rst.set_high();
    let cs = gpio::Output::new(peripherals.GPIO16, gpio::Level::High, Default::default());
    let spi = esp_hal::spi::master::Spi::new(
        peripherals.SPI2,
        Config::default().with_frequency(Rate::from_mhz(30)),
    )
    .unwrap()
    .with_sck(peripherals.GPIO5)
    .with_mosi(peripherals.GPIO6);

    let spi_device = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
    let mut buffer = [0_u8; 512];

    let di = SpiInterface::new(spi_device, dc, &mut buffer);
    let mut display = Builder::new(ST7789, di)
        .reset_pin(rst)
        .color_order(mipidsi::options::ColorOrder::Rgb)
        .invert_colors(mipidsi::options::ColorInversion::Inverted)
        .init(&mut delay)
        .unwrap();
    // Need to clear the screen, otherwise previous content will be displayed
    display.clear(Rgb565::BLACK).unwrap();

    let data = include_bytes!("../../jing.tga");
    let img: Tga<Rgb565> = Tga::from_slice(data).unwrap();

    // let data = include_bytes!("../../jing.bmp");
    // let img: Bmp<Bgr565> = Bmp::from_slice(data).unwrap();


    let image = Image::new(&img, Point::new(64, 64));
    image.draw(&mut display.color_converted()).unwrap();

    loop {
        delay.delay_millis(500);
        
    }
}

Finally

Also, the size of images displayed on the ST7789 screen is limited and cannot exceed the screen resolution.