Monochrome vs Grayscale vs Color Bitmap: Memory Tradeoffs
Choosing a bitmap format is not just an aesthetic decision — it's a memory budget. A 320x240 image is 9.6KB as 1-bit mono, 38KB as 4-bit grayscale, 154KB as 16-bit RGB565, and 230KB as 24-bit RGB. This post lays out the math, the visual tradeoffs, and the right pick per microcontroller class.
The five formats you'll actually meet
| Format | Bits / pixel | Colors | Used by |
|---|---|---|---|
| 1-bit mono | 1 | 2 (on/off) | SSD1306 OLED, e-paper B/W |
| 2-bit grayscale | 2 | 4 grays | Some Sharp memory LCDs |
| 4-bit grayscale | 4 | 16 grays | SSD1325 OLED, some e-paper |
| 8-bit grayscale / palette | 8 | 256 (gray or palette) | SSD1322 OLED, Lvgl indexed |
| RGB565 | 16 | 65,536 | ILI9341, ST7735, ST7789 TFTs |
| RGB888 | 24 | 16.7M | HDMI / desktop output (rare on MCUs) |
| ARGB8888 | 32 | 16.7M + alpha | LVGL true-color w/ alpha, sprites |
Memory math: pick your resolution
Bytes-per-image = (width * height * bits_per_pixel) / 8
| Resolution | 1-bit mono | 4-bit gray | RGB565 | RGB888 |
|---|---|---|---|---|
| 128x64 (small OLED) | 1,024 | 4,096 | 16,384 | 24,576 |
| 128x160 (ST7735) | 2,560 | 10,240 | 40,960 | 61,440 |
| 240x240 (round LCD) | 7,200 | 28,800 | 115,200 | 172,800 |
| 320x240 (ILI9341) | 9,600 | 38,400 | 153,600 | 230,400 |
| 800x480 (7.5" e-paper) | 48,000 | 192,000 | 768,000 | 1,152,000 |
What fits on your MCU
| MCU | Free flash for images | Largest practical image |
|---|---|---|
| ATtiny85 (8KB flash) | ~5KB | 128x64 mono (1KB) |
| Arduino Uno (32KB) | ~25KB | 128x160 RGB565 (40KB) doesn't fit; 128x128 mono (2KB) trivial |
| Mega 2560 (256KB) | ~240KB | 320x240 RGB565 (154KB) fits |
| RP2040 (2MB) | ~1.8MB | multiple 320x240 RGB565 |
| ESP32 (4MB+) | ~3MB | essentially unlimited |
1-bit mono: the workhorse
For text, line art, logos, and UI on OLEDs and e-paper, mono is the right answer. 1KB-per-128x64 means dozens of icons fit even on an ATtiny. The visual quality is excellent for line work but degrades for photos — which is what dithering solves.
Dithering: making mono look photographic
Dithering converts grayscale to a black-and-white pattern that averages to the original gray. The eye blurs the dots into smooth tones. Three algorithms, in order of quality:
- Threshold (no dither) — all pixels above 50% become white, rest black. Harsh.
- Bayer / ordered dither — deterministic 4x4 or 8x8 matrix, looks "patterned" but reproducible.
- Floyd–Steinberg — error-diffusion, looks photographic but adds noise. The default in image2cpp for photos.
- Atkinson — classic Mac dither, less aggressive than Floyd–Steinberg.
For OLED splash screens and e-paper artwork, Floyd–Steinberg gives the best photo reproduction at 1-bit cost.
4-bit grayscale: the e-paper sweet spot
16 levels of gray is enough for fairly convincing photos — this is what Kindle uses. SSD1325, SSD1327 OLEDs and some advanced e-paper panels accept 4-bit input. 4 bits per pixel means 4x the memory of mono, but no dithering noise.
RGB565: the color compromise
16-bit color is the universal MCU format because it's a clean 2-byte fit and matches what TFT controllers (ILI9341, ST7735, ST7789) accept natively. The breakdown: 5 bits red, 6 bits green, 5 bits blue.
// pack RGB888 to RGB565
uint16_t rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
Why 6 bits for green? Human vision is twice as sensitive to green wavelengths. Spending the extra bit there gives perceptually better color than 5-5-5.
RGB888 vs RGB565: visual difference
For photos, the difference is small. RGB565 has banding visible in smooth gradients (sky, skin tones) but for icons, UI, and most product imagery, you cannot tell. The 50% memory savings pays for itself.
Indexed palette (8-bit)
For UI with a limited palette (game graphics, retro UI, infographics), indexed color is brutally efficient: 8 bits per pixel, 256 entries in a palette table of 256x2 = 512 bytes. A 320x240 image is 76,800 bytes — half RGB565 size with comparable visual quality if your palette is well-chosen.
const uint16_t palette[256] PROGMEM = {0x0000, 0xFFFF, 0xF800, /* ... */};
const uint8_t image[320*240] PROGMEM = {/* indices into palette */};
// at draw time
uint8_t idx = pgm_read_byte(&image[y*320 + x]);
uint16_t color = pgm_read_word(&palette[idx]);
LVGL supports CF_INDEXED_8BIT natively. Image2cpp does not currently emit indexed output — if you need it, post-process the converter output or use LVGL's online converter.
Alpha: when do you need it?
For sprite blitting with transparency on a TFT, you have two options:
- Color key — pick a "magic" RGB565 color (e.g. 0xF81F magenta), skip those pixels. Zero memory cost, no per-pixel alpha.
- ARGB8888 with alpha — 4 bytes per pixel. Doubles RGB565 memory cost. Use only when you need anti-aliased sprite edges.
Most embedded UI uses color-key. Most desktop UI uses true alpha. The difference shows on edges of sprites — color-key has a 1-pixel "halo".
Picking the right format: the decision tree
- Is your display 1-bit (OLED mono, B/W e-paper)? → 1-bit mono, dither photos.
- Color display, AVR/Uno target, <32KB flash? → small RGB565 sprites only, or use mono with text overlay.
- Color display, ESP32/RP2040? → RGB565 unless you need transparency.
- UI with limited colors (icons, infographics)? → consider indexed 8-bit (50% RGB565 savings).
- Sprites with anti-aliased edges? → ARGB8888 if memory permits, else color-key RGB565.
Conversion in image2cpp
The relevant settings in pixel.hjlabs.in/converter:
- "1 bit per pixel" + threshold/dither: for mono OLED and e-paper
- "16-bit RGB565": for ILI9341 / ST7735 / ST7789 TFTs
- "24-bit RGB888": for HDMI output or LVGL true-color
Bottom line
The right format is the smallest one that looks acceptable. Always start at 1-bit, dither, see if you can live with it. Move up only when you must. Memory is the most precious resource on a microcontroller. image2cpp shows live previews of every format — iterate visually, not by spec sheet.
Read also: PROGMEM image storage on AVR and our Arduino OLED tutorial. For project marketing graphics, og.hjlabs.in generates favicons and social previews.
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 →