arduino esp32 ssd1306 oled

Display a Custom Logo on an SSD1306 OLED with Arduino and ESP32

Convert a logo into a C byte array for a 128×64 SSD1306 OLED: choosing the right byte layout, thresholds and dithering, PROGMEM on AVR vs ESP32, and debugging scrambled or inverted images.

· Mohammed Aquib Ansari

Every hardware project I’ve built eventually wants a boot logo. The display part is easy — an SSD1306 128×64 OLED costs a couple of dollars and Adafruit’s library draws a bitmap in one line. The hard part is the bitmap itself: the library wants a very specific array of bytes, and if the layout is even slightly off you get static, stripes, a mirror image or a negative.

This is the workflow I use now, with the image to C array converter. The conversion runs in the browser, so the image isn’t sent to a server.

1. Start with the right size

A 128×64 display has exactly 8,192 pixels, and each one is on or off. Design for that. A detailed full-colour logo will turn to mush, while a bold silhouette with thick lines survives. I usually make a 256×128 version in whatever editor is handy and let the converter scale it down.

Pick the display size from the presets (128×64, 128×32, 240×240 and so on) and a fit mode:

  • Contain keeps the whole image, adding borders if the aspect ratio differs.
  • Cover fills the display and crops the overflow.
  • Stretch distorts to fill — rarely what you want for a logo.

If your display is mounted sideways, use the rotation setting rather than rotating the source file; the output stays 128×64.

2. Threshold or dither

Converting to 1-bit means deciding, for every pixel, whether it’s on. By default the converter turns on pixels at least as bright as the threshold (128), which matches how an OLED looks: lit pixels are the bright ones. If your artwork is a black logo on a white background, turn on Invert.

For logos, a plain threshold gives the crispest edges. For photos or gradients, use dithering:

  • Floyd–Steinberg spreads each pixel’s rounding error to its neighbours. Best general choice for photos.
  • Atkinson (the original Mac look) spreads only part of the error, keeping more contrast.
  • Ordered (Bayer) produces a regular pattern that looks cleaner on tiny displays and doesn’t shimmer in animations.

Transparent PNGs are composited over a background colour you choose before any of this, so a logo with a transparent background doesn’t come out as a solid block.

3. Pick the byte layout your code expects

This is the step that causes almost every “my image is garbage” post on the Arduino forums. Here’s a 16×8 rectangle outline in two layouts the converter supports.

1-bit horizontal, for Adafruit GFX drawBitmap() — each byte is 8 pixels of a row, most significant bit on the left:

// 16x8, 1-bit horizontal (Adafruit GFX drawBitmap), 16 bytes
#define FRAME_WIDTH  16
#define FRAME_HEIGHT 8
const uint8_t frame[] PROGMEM = {
  0xFF, 0xFF, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01,
  0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0xFF, 0xFF
};

1-bit vertical pages, the SSD1306’s own memory layout — each byte is 8 pixels of a column, least significant bit at the top:

// 16x8, 1-bit vertical pages (SSD1306 / SH1106 buffer), 16 bytes
const uint8_t frame[] PROGMEM = {
  0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xFF
};

Same image, completely different bytes. Feed the vertical array to drawBitmap() and you’ll see stripes. There’s also XBM, which is horizontal but least-significant bit first; that’s what U8g2’s drawXBMP() and Adafruit’s drawXBitmap() expect.

The converter’s preview is drawn by decoding the bytes it just generated, not from your source image. If the layout is wrong for the preview, it’s wrong for the display too — which makes this the quickest way to catch a format mistake before flashing.

4. PROGMEM: needed on AVR, harmless on ESP32

On an Arduino Uno or Nano (ATmega328P), a const array without PROGMEM is copied into RAM at startup — and there are only 2 KB of it. A full-screen 128×64 bitmap is 1,024 bytes, half your RAM, gone. With PROGMEM it stays in flash, and Adafruit’s drawBitmap() reads it from there.

On ESP32, RP2040 and STM32, const data stays in flash anyway. PROGMEM is defined as nothing, so leaving it in is harmless and keeps the code portable. The converter warns you when an array without PROGMEM is bigger than 2 KB, and when it wouldn’t fit in the Uno’s 32 KB of flash at all.

5. The sketch

The converter includes an example sketch for the format you pick. For the horizontal layout it’s essentially:

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// generated array goes here

Adafruit_SSD1306 display(128, 64, &Wire, -1);

void setup() {
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {  // 0x3D on some modules
    for (;;) {}
  }
  display.clearDisplay();
  display.drawBitmap(0, 0, logo, LOGO_WIDTH, LOGO_HEIGHT, SSD1306_WHITE);
  display.display();
}

void loop() {}

If nothing appears at all, it’s usually the I²C address: most 128×64 modules use 0x3C, some use 0x3D.

Troubleshooting checklist

  • Static or diagonal stripes: wrong layout — horizontal vs vertical pages vs XBM.
  • Mirror image: XBM data given to drawBitmap() (or the reverse), since the bit order within each byte is flipped.
  • Negative image: toggle Invert.
  • Image shifted or wrapping: the width in your drawBitmap() call doesn’t match the array. Use the generated #defines.
  • Rows slightly off for odd widths: a width that isn’t a multiple of 8 needs padding bits at the end of each row. The converter adds them and notes it in a comment.
  • Colours wrong on a TFT: for RGB565 output, try “Swap bytes”, or call tft.setSwapBytes(true) in TFT_eSPI.

Beyond monochrome

For colour displays driven by TFT_eSPI or Adafruit’s ST77xx library, choose RGB565. A 240×240 image is 115,200 bytes — fine on an ESP32, far too big for an Uno. If you’re checking individual values by hand, the number base converter shows any 16-bit value in hex, binary and both byte orders; pure red is 0xF800, pure green 0x07E0, pure blue 0x001F.

Once you’ve got one image working, the next thing I usually build is a set of icons at 16×16 or 24×24. Convert each through the image to C array tool with the same settings, and they’ll all draw with the same call.