← All articles · April 21, 2026 · pixel.hjlabs.in · Powered by image2cpp

ESP32 + ILI9341 TFT: Display Color Images from PROGMEM

The ILI9341 320x240 TFT panel is the bread-and-butter color display for ESP32 hobby projects — 76,800 pixels of full color for under $5. This guide gets you from PNG to pushed-pixels with TFT_eSPI, image2cpp, and a handful of optimization tricks the docs gloss over.

Why TFT_eSPI over Adafruit_ILI9341

Both libraries draw to the same chip. TFT_eSPI is 5-10x faster on ESP32 because it uses ESP32's dedicated SPI hardware, DMA, and a tuned driver. Adafruit's library is portable but generic. For ESP32 + ILI9341, TFT_eSPI is the right pick.

Wiring: SPI to ESP32

ILI9341 PinESP32 GPIO
VCC3V3
GNDGND
CS15
RESET4
DC2
SDI (MOSI)23
SCK18
LED3V3 (or PWM pin for backlight control)
SDO (MISO)19 (optional, only if reading)

Configure TFT_eSPI

TFT_eSPI is configured at compile time via User_Setup.h in the library folder. The simplest path is to copy User_Setup_Select.h and uncomment a profile, or edit User_Setup.h directly:

#define ILI9341_DRIVER
#define TFT_WIDTH  240
#define TFT_HEIGHT 320

#define TFT_MISO 19
#define TFT_MOSI 23
#define TFT_SCLK 18
#define TFT_CS   15
#define TFT_DC    2
#define TFT_RST   4

#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_GFXFF
#define SMOOTH_FONT
#define SPI_FREQUENCY  40000000  // 40MHz works on most boards

RGB565: the color format you'll convert to

ILI9341 natively accepts 16-bit RGB565: 5 bits red, 6 bits green (because the eye is most sensitive to green), 5 bits blue. A 320x240 image at RGB565 is 153,600 bytes — way too big for any AVR Arduino, comfortable on ESP32 (which has 4MB+ of flash).

Convert your image with image2cpp

  1. Open pixel.hjlabs.in/converter
  2. Drop in your PNG
  3. Set canvas to your target size (e.g. 320x240 or smaller)
  4. Output format: "Arduino code, 16-bit RGB565"
  5. Endianness: Little-endian (default) or Big-endian if your library expects it. TFT_eSPI's pushImage assumes little-endian unless you set tft.setSwapBytes(true).

Display the image

#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();

// Paste image2cpp RGB565 output here:
const uint16_t myImage [] PROGMEM = {
  0x0000, 0x0841, 0x10A2, /* ... 76,800 entries for 320x240 ... */
};

void setup() {
  tft.init();
  tft.setRotation(1);  // landscape
  tft.fillScreen(TFT_BLACK);

  tft.setSwapBytes(true);  // critical for image2cpp output
  tft.pushImage(0, 0, 320, 240, myImage);
}

void loop() {}

The setSwapBytes gotcha

This single call trips up everyone. ESP32 SPI sends bytes high-byte first, but image2cpp generates the C array with low-byte first (matching how AVR / x86 view the uint16_t in memory). The result: red becomes blue, blue becomes red, green is roughly correct. Call tft.setSwapBytes(true) before pushImage and colors are correct.

Alternative: in image2cpp, choose the "Big-endian RGB565" output and skip setSwapBytes.

Drawing only a region

For a 64x64 sprite at position (100, 80):

tft.setSwapBytes(true);
tft.pushImage(100, 80, 64, 64, sprite);

Note: the array length must be exactly w * h uint16_t — not bytes. A 64x64 sprite is 4096 entries (8192 bytes).

PROGMEM vs flash on ESP32

ESP32 has no separate PROGMEM segment — flash is memory-mapped and accessed directly. The PROGMEM attribute is a no-op on ESP32 but kept for portability. Do not use pgm_read_word in ESP32 code — the macro on ESP32 is also a no-op but it's confusing. Just dereference the pointer normally.

Animation: rolling sprites

For a smooth scrolling sprite or animation, use TFT_eSPI's sprite class instead of full pushImage redraws:

TFT_eSprite sprite = TFT_eSprite(&tft);

void setup() {
  tft.init();
  tft.setRotation(1);
  sprite.createSprite(64, 64);
  sprite.pushImage(0, 0, 64, 64, mySprite);
}

void loop() {
  static int x = 0;
  sprite.pushSprite(x, 100);
  x = (x + 1) % 320;
  delay(16);  // ~60fps
}

The sprite is rendered to off-screen RAM, then pushed in one DMA transaction. Tearing-free and fast.

Memory: when the image is too big for flash

A full-screen 320x240 RGB565 background is 150KB. Multiple full-screen images can blow past your flash partition. Two solutions:

  1. SPIFFS / LittleFS: store images as raw .bin files in the filesystem partition, load on demand. Slower but unlimited.
  2. SD card: for galleries or wallpaper apps with hundreds of images.

For more on memory tradeoffs, see monochrome vs grayscale vs color bitmap memory.

Common problems

JPEG decoding alternative

For storage efficiency, use TJpg_Decoder to render JPEG files directly — a 320x240 JPEG is ~15KB instead of 150KB. The tradeoff is decode time (~50ms) and slightly fuzzy edges. Good for static backgrounds, bad for animations.

Bottom line

ESP32 + ILI9341 + TFT_eSPI + image2cpp's RGB565 output = full-color images in 30 lines of code. Remember setSwapBytes(true). Generate your RGB565 array now, or read our Arduino tutorial for fundamentals first.

Building a kiosk or photo frame? can quote a custom acrylic enclosure, and og.hjlabs.in handles your project README graphics.

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 →

More from the blog