My tiny nibnib file was larger than the raw RGB image I fed into it.
For a 16×16 synthetic gradient, the raw RGB pixels took 768 bytes. The nibnib file took 1,220 bytes. Nothing had gone wrong with the encoder. The file had a palette to carry. 📦
That was the useful surprise in trying Raph Levien's image format: “4 bits per pixel” describes the compact pixel coding, not an all-inclusive promise for every file size.
What I Built
I made three original RGB gradients, ran the upstream nibnib encoder and decoder, and wrote an independent reader to check the decoded pixels.
Nibnib is aimed at constrained-device decoding. It works on pixel pairs and uses a palette of differences rather than just a palette of absolute colors.[1] The current interest in it makes a good excuse to inspect the bytes instead of borrowing the speed claim. 🔍
My check covers file layout and round trips. It doesn't measure microcontroller decoding speed.
Original synthetic input and actual decoded output. Display enlargement is for inspection; neither image is a photo.
Demo
I used nibnib version 0.1.0 at commit 2f02f8c225d937d1c19459e2b246f37d87a98f54. With a Rust toolchain available, build that checkout:
cargo build --release --locked For a small input you can generate yourself, save this as make_gradient.py. It needs Pillow:
from PIL import Image width, height = 128, 64 image = Image.new("RGB", (width, height)) image.putdata([ (x*255//(width-1), y*255//(height-1), (x+y)*255//(width+height-2)) for y in range(height) for x in range(width) ]) image.save("gradient.png") Then, from the nibnib checkout with that input file available:
python3 make_gradient.py ./target/release/nibnib gradient.png gradient.nib --compress ./target/release/nibnib gradient.nib decoded.png --decompress The explicit flags matter. At this revision, the default path encodes and then writes a decoded PNG; --compress writes the compressed file.[2]
Here are my checked sizes across the three fixtures:
| Input dimensions | Raw RGB bytes | nibnib file bytes | Whole-file bits per pixel |
|---|---|---|---|
| 16×16 | 768 | 1,220 | 38.125 |
| 64×32 | 6,144 | 2,180 | 8.516 |
| 128×64 | 24,576 | 5,380 | 5.254 |
✅ Each upstream decoder output matched my independent reader byte for byte. Each decoded image differed from its original input: this is a lossy round trip, not a pixel-preserving archive.
Where the bytes go
At the checked revision, the file stores dimensions and a fixed palette before its row data. Each row starts with an explicit pixel pair; later pairs use palette indices. Rows are aligned to four-byte boundaries.[1][2]
Here's the size calculation for positive, even widths:
def nib_bytes(width, height): row_bytes = width // 2 + 3 stride = (row_bytes + 3) // 4 * 4 return 1028 + height * stride print(nib_bytes(128, 64)) # 5380 For that image, the rows occupy 64 × 68 = 4,352 bytes. Add the 1,028-byte header and palette, and the result is 5,380.
The tiny image pays that same fixed palette cost. A larger image spreads it across more pixels. The row seeds and alignment still add overhead too. 💡
Compare against the right baseline
My table compares the file with uncompressed RGB, not with PNG or JPEG at matched quality.
In fact, Pillow's PNG for the 128×64 gradient was only 514 bytes in this run. A smooth synthetic gradient is friendly to PNG. That doesn't settle which format suits a constrained display; it shows why “smaller than raw pixels” and “smaller than an existing compressed image” are different claims.
A DEV discussion about palette reduction makes a related distinction: a lossless container can store pixels that an earlier conversion has already changed.[3] Check the actual decoded image, not just the format label.
Partner Technologies
This walkthrough uses the upstream Rust nibnib CLI and Python with Pillow. The independent reader follows the documented layout and the checked decoder source.
I haven't run this on an RP2350 or compared decoder speed with JPEG. The upstream encoder also generates a partly random palette, so preserve the encoded file when you need to reproduce the exact decoded pixels. The layout calculation doesn't depend on which palette values were selected.
Hackathon Experience
This was a standalone local format check. It isn't an MLH event build or competition entry.
References
- Raph Levien — nibnib README at the checked revision
- nibnib — CLI, encoder and decoder source
- @yue_shu_c621a4a637f22396f — PNG is lossless, palette reduction is not
Asset credits
Original synthetic gradients, actual decoded output and original byte-layout illustration. The nibnib implementation is credited to its upstream project, licensed MIT or Apache-2.0.
AI disclosure
AI was used to draft the article.

