DEV Community

Cover image for Nibnib says 4bpp. The file still has a header.
Shivam Dwivedi
Shivam Dwivedi

Posted on

Nibnib says 4bpp. The file still has a header.

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.

The original 128 by 64 RGB gradient beside the actual nibnib-decoded output. A byte ledger beneath shows the 1,028-byte header and palette plus 4,352 bytes of row data, totaling 5,380 bytes.

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Raph Levien — nibnib README at the checked revision
  2. nibnib — CLI, encoder and decoder source
  3. @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.

Top comments (0)