embedded-graphics 是 Rust 在嵌入式开发中显示屏图形显示标准,因此我们可以使用它来显示图片。
然而,embedded-graphics 并不支持常见的图片格式,比如 png、jpg 等。
通过文档得知,embedded-graphics 只支持 bmp、tga、qoi 这三种格式。
事实上没那么简单, st7789 屏幕并不支持 RGB888(也叫RGB24位) 色彩格式。通过查阅文档可知, st7789 屏幕只支持 RGB565(16位) 色彩格式。
那么事情就很简单了,我们只需要把图片从24位转成16位色彩空间,并转成 bmp、tga、qoi 格式就能显示在屏幕上。
转换图片
可以使用 python 脚本来任何图片格式转换到上面提到的格式。
比如我实现的jpg 转 tga 的脚本:
def convert_jpg_to_tga_rgb565(input_path: str, output_path: Optional[str] = None, size: Tuple[int, int] = (64, 64)) -> str:
"""
将JPG图片缩小并转换为RGB565格式的TGA
Args:
input_path: 输入的JPG图片路径
output_path: 输出的TGA图片路径(可选,默认为输入文件名.tga)
size: 目标尺寸,默认为(64, 64)
Returns:
输出文件的路径
"""
input_path_obj = Path(input_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)
# 打开图片
with Image.open(input_path_obj) as img:
# 转换为RGB模式
if img.mode != 'RGB':
img = img.convert('RGB')
# 缩小到指定尺寸
img_resized = img.resize(size, Image.Resampling.LANCZOS)
# RGB565布局: bit15-11=红色(5位), bit10-5=绿色(6位), bit4-0=蓝色(5位)
pixel_data = bytearray()
for r, g, b in img_resized.getdata():
r5 = (r >> 3) & 0x1F # 红色: 8位转5位
g6 = (g >> 2) & 0x3F # 绿色: 8位转6位
b5 = (b >> 3) & 0x1F # 蓝色: 8位转5位
# 组合成16位RGB565: 高位->低位 = 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)
# 写入TGA文件
with open(output_path_str, 'wb') as f:
f.write(header)
f.write(pixel_data)
return output_path_str
这里要注意的是,你要转换的是 RGB565( RRRRR GGGGGG BBBBB) 还是 BGR565( BBBBB GGGGGG RRRRR)。这两个都支持,而且非常重要,后面会考。
顺序上主要是下面的代码在控制:
# BGR565
pixel565 = (b5 << 11) | (g6 << 5) | r5
# RGB565
pixel565 = (r5 << 11) | (g6 << 5) | b5
jpg 转 bmp (rgb565) 大概如下:
def convert_jpg_to_bmp_rgb565(input_path: str, output_path: Optional[str] = None, size: Tuple[int, int] = (128,128)) -> str:
"""
将JPG图片转换为16位RGB565格式的BMP(BITFIELDS格式)
Args:
input_path: 输入的JPG图片路径
output_path: 输出的BMP图片路径(可选,默认为输入文件名.bmp)
size: 目标尺寸,默认为64x64
Returns:
输出文件的路径
"""
input_path_obj = Path(input_path)
if not input_path_obj.exists():
raise FileNotFoundError(f"找不到输入文件: {input_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)
# 打开图片
with Image.open(input_path_obj) as img:
# 转换为RGB模式
if img.mode != 'RGB':
img = img.convert('RGB')
# 缩小到指定尺寸
img_resized = img.resize(size, Image.Resampling.LANCZOS)
# 获取所有像素数据
all_pixels = list(img_resized.getdata())
width, height = size
# BMP是bottom-up格式,需要从底部开始存储行
# 即第一行像素数据对应图像的最底部
pixel_data = bytearray()
for y in range(height - 1, -1, -1): # 从底部开始
for x in range(width):
r, g, b = all_pixels[y * width + x]
# RGB565: R:5位, G:6位, B:5位
r5 = (r >> 3) & 0x1F
g6 = (g >> 2) & 0x3F
b5 = (b >> 3) & 0x1F
# 组合成16位: RRRRR GGGGGG BBBBB
pixel565 = (r5 << 11) | (g6 << 5) | b5
# 小端序存储
pixel_data.append(pixel565 & 0xFF)
pixel_data.append((pixel565 >> 8) & 0xFF)
# 创建BITFIELDS格式的BMP头(70字节)
header = create_bitfields_bmp_header(size[0], size[1], len(pixel_data))
# 写入BMP文件
with open(output_path_str, 'wb') as f:
f.write(header)
f.write(pixel_data)
return output_path_str
驱动图片显示
tga和bmp图片分别使用下面这两个库:
tinytga = "0.5.0"
tinybmp = "0.7.0"
我这里以下面这个图片为例子,把它显示在 st7789 屏幕上:

核心代码很少,其实大概就是如下所示:
// 需要清空屏幕,否则会显示上一次的内容
display.clear(Rgb565::BLACK).unwrap();
let data = include_bytes!("../../jing.tga");
let img: Tga<Rgb565> = Tga::from_slice(data).unwrap();
// 如果是使用 bmp 图片,就是使用下面代码
// 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个大坑
下面这两个坑都会造成类似下面的颜色显示错误:

第一个坑: Rgb565 还是 Bgr565?
转换的时候,一定要注意顺序。如果你转换的是Rgb,那么初始化的时候就要用Tga<Rgb565>,如果是Bgr,那么初始化时就要用Tga<Bgr565>。
此外,初始化屏幕的时候也要设定:
let mut display = Builder::new(ST7789, di)
.reset_pin(rst)
.color_order(mipidsi::options::ColorOrder::Rgb) //<======== 这里也要设置为Rgb 还是 Bgr
.init(&mut delay)
.unwrap();
第二个坑: 屏幕色彩反转
有可能你也会像我一样,跳出了第一个坑之后,又进入了第二个坑。那就是不知道是因为我初始化的问题还是mipidsi 这个驱动有问题,默认的屏幕如果你什么都不做,就设置下面这个:
display.clear(Rgb565::BLACK).unwrap();
显示在屏幕上是白色的。比如我之前显示的温度湿度的屏幕界面:

可以发现,我设置的是Rgb565::BLACK,但是屏幕显示的是白色。而 使用C 语言并没有这个问题。
解决的办法是,在初始化屏幕的时候,加上下面这个参数把屏幕颜色反转:
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();
最后效果效果如下:

完整代码
#![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 显示初始化
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();
// 需要清空屏幕,否则会显示上一次的内容
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);
}
}
最后
另外,st7789 屏幕显示图片的大小是有限的,不能超过屏幕的分辨率。