ESP-IDF LVGL Image Conversion: PNG to C Array for LVGL v9
LVGL is the de-facto graphics framework for ESP-IDF, RP2040, STM32, and any MCU big enough to run a real UI toolkit. It has its own image format, its own struct (lv_img_dsc_t), and its own online converter. This post explains where image2cpp fits, where it doesn't, and how to convert PNGs for LVGL v9 in an ESP-IDF project.
The LVGL image descriptor
An LVGL image is not a raw byte array — it's an lv_img_dsc_t struct that wraps the bytes plus metadata:
typedef struct {
lv_image_header_t header; // width, height, color format, stride
uint32_t data_size; // total bytes in data
const uint8_t *data; // pointer to pixel data
const void *reserved;
} lv_image_dsc_t;
You can't just paste image2cpp output and pass it to lv_image_set_src() — the API expects the full struct. But the bytes inside are the same RGB565 / RGB888 / 1-bit data image2cpp emits.
Color formats LVGL v9 understands
| LVGL format | image2cpp equivalent | Bytes/pixel |
|---|---|---|
| LV_COLOR_FORMAT_L8 | 8-bit grayscale | 1 |
| LV_COLOR_FORMAT_I8 (indexed) | (palette + indices, manual) | 1 + palette |
| LV_COLOR_FORMAT_RGB565 | 16-bit RGB565 (Arduino) | 2 |
| LV_COLOR_FORMAT_RGB565A8 | RGB565 + 1-byte alpha | 3 |
| LV_COLOR_FORMAT_ARGB8888 | (no direct image2cpp output) | 4 |
| LV_COLOR_FORMAT_RGB888 | 24-bit RGB | 3 |
| LV_COLOR_FORMAT_A1 / A2 / A4 / A8 | 1/2/4/8-bit alpha mask | 0.125 / 0.25 / 0.5 / 1 |
Path 1: the LVGL online converter (easy)
LVGL ships an official converter at lvgl.io/tools/imageconverter. Drop in a PNG, pick the LVGL version (v9), pick the color format, get a .c file with the full lv_img_dsc_t declaration ready to #include. This is the path of least resistance for LVGL projects.
Path 2: image2cpp + manual struct wrap
If you want consistency across LVGL and non-LVGL parts of your codebase, use image2cpp for the bytes and wrap manually:
#include "lvgl.h"
// 1. paste image2cpp RGB565 output as plain bytes
static const uint8_t my_img_data [] LV_ATTRIBUTE_LARGE_CONST = {
0x00, 0x00, 0xFF, 0xFF, /* ... w*h*2 bytes ... */
};
// 2. wrap in an LVGL descriptor
const lv_image_dsc_t my_img = {
.header = {
.magic = LV_IMAGE_HEADER_MAGIC,
.cf = LV_COLOR_FORMAT_RGB565,
.w = 64,
.h = 64,
.stride = 64 * 2
},
.data_size = 64 * 64 * 2,
.data = my_img_data
};
// 3. show on screen
void create_ui(lv_obj_t *parent) {
lv_obj_t *img = lv_image_create(parent);
lv_image_set_src(img, &my_img);
}
The LV_ATTRIBUTE_LARGE_CONST macro
LVGL uses LV_ATTRIBUTE_LARGE_CONST to place image data in flash. On ESP-IDF this expands to attributes that ensure flash placement; on AVR it expands to PROGMEM. Always use it for image arrays in LVGL code — portable and correct.
ESP-IDF integration
In main/CMakeLists.txt, add the image source file:
idf_component_register(
SRCS "main.c" "ui.c" "images/my_img.c"
INCLUDE_DIRS "."
REQUIRES "lvgl"
)
For larger asset libraries, glob:
file(GLOB IMG_SOURCES "images/*.c")
idf_component_register(
SRCS "main.c" "ui.c" ${IMG_SOURCES}
...
)
Color format and CONFIG_LV_COLOR_DEPTH
Your LVGL build is configured for one color depth (typically 16-bit on MCUs, 32-bit on bigger SoCs). Image color format must be compatible. Check CONFIG_LV_COLOR_DEPTH in menuconfig:
- 16-bit (default for MCUs): use RGB565 for opaque images, RGB565A8 for sprites with alpha
- 32-bit: use RGB888 or ARGB8888
Mixing depths works but LVGL converts at draw time — slower. Match the format to your build for best performance.
Loading from filesystem (LittleFS / SPIFFS)
For large images, embed in flash via the LVGL filesystem layer instead of as a C array. Save the binary as e.g. logo.bin in your spiffs/ partition, then:
lv_image_set_src(img, "S:/spiffs/logo.bin");
The "S:" prefix routes to the SPIFFS driver. Saves compile time, allows OTA image updates without reflashing firmware, but adds 5-50ms decode latency.
Built-in PNG / JPG decoders
LVGL v9 ships with optional libraries for runtime PNG (lv_libs_png) and JPG (lv_libs_tjpgd) decoding. Enable in menuconfig under "LVGL configuration → 3rd-party libraries". Then you can:
lv_image_set_src(img, "S:/spiffs/photo.png");
Tradeoff: decode time (50-300ms for typical images), heap usage during decode (~3-5x image size), but huge flash savings if you have many photos.
Image transforms (rotate, scale, recolor)
LVGL can transform images at draw time without re-encoding:
lv_image_set_rotation(img, 450); // 45 degrees (in 0.1 deg units)
lv_image_set_scale(img, 384); // 1.5x (256 = 1x)
lv_image_set_recolor(img, lv_color_hex(0xff0000)); // tint red
These cost CPU at draw time but no extra flash. For real-time animation, expect a frame-rate hit.
Common pitfalls
- "image header magic mismatch". v9 added a magic field; if you copy v8 image data, set
.magic = LV_IMAGE_HEADER_MAGIC. - Wrong colors. Build is 32-bit but image is RGB565 (or vice versa). Match formats.
- Black sprites where alpha should be transparent. Wrong format — use RGB565A8 not plain RGB565.
- Build error: "section .rodata too large". Image too big for flash; move to filesystem or reduce.
image2cpp vs LVGL converter: when to use which
| Use image2cpp when | Use LVGL converter when |
|---|---|
| Cross-platform (Arduino + LVGL + framebuf) | LVGL-only project |
| You want raw bytes for any consumer | You want zero-config struct output |
| Custom dithering / threshold | Standard color formats |
| Faster iteration (in-browser) | Single source of truth for LVGL assets |
Bottom line
For pure LVGL projects, the official LVGL converter is fastest. For cross-platform codebases or anywhere you want full control, generate raw bytes with image2cpp and wrap them in lv_img_dsc_t manually. Both produce identical pixel output. Read our Arduino tutorial for the fundamentals.
For LVGL UI screenshot tooling, og.hjlabs.in handles your project's GitHub README graphics; fmt.hjlabs.in is handy for debugging JSON config dumps from ESP-IDF.
Try image2cpp now — free, in browser
Drop in a PNG, JPG or BMP. Get a paste-ready C++ array for Arduino, ESP32 or RP2040. 100% client-side. Learn more about image2cpp or jump straight to the tool.
Open the converter →