Skip to content

Rust Embedded Development: Displaying Images on ST7789 Screen

kingzcheung
Published date:
Edit this post

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.

Previous
Rust Embedded Development: How to Fix ST7789 Screen Flickering During Redraw
Next
Rust嵌入式开发:如何解决st7789屏幕在重绘时闪烁的问题