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:

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:

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:

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:

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.