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
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
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.
Mentions
Nibnib, on HN today, decodes two pixels from one byte using a delta palette. Built for an RP2350 with roughly 6 cycles per pixel. More interesting than another GPU flex: what can you build when the hardware budget is the design brief? raphlinus/nibnib
I suspect it probably came from a conversation around raphlinus/nibnib which appeared over the weekend - @raph wrote that to do fast image decompression on low powered systems like his 'vic-2350' (reimagining of a c64 on an rp2350). But while this thing says it'd "allow lightweight encoder and decoder implementations" I don't think it's in the same ballpark as nibnib, which is even simpler than QOI.
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 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 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.
Nibnib uses a palette of color differences. It decodes two pixels from one byte. I like seeing device limits shape an image format. raphlinus/nibnib #OpenSource
【画像を2画素ずつ一気に展開】 ・1バイトから2画素を復元 ・4bppで展開を高速化 ・RP2350向け実験から誕生 組み込み画像の速度設計が変わる? #Nibnib raphlinus/nibnib
Nibnib: Image compression with extreme decompression speed (github.com) Discussion | Main Link
Nibnib: Image compression with extreme decompression speed raphlinus/nibnib news.ycombinator.com
If you have an Adafruit Fruit Jam, you can run the demo on that board [1]. It can stream 640x480 video off an SD card at 60fps, as well as display a static image from flash. Also possibly of interest to HN, the decoder[2] is written in a new low level language called symbolasm[3], which seeks to combine the advantages of assembly language (it's possible to reason about and optimize performance with few limits) with some of the affordances of C, especially named variables and struct fields. [1]: raphlinus/vic-2350 [2]: github.com/raphlinus/vic-2350/blob/main/image.sy [3]: raphlinus/symbolasm
Nibnib: Image compression with extreme decompression speed
Nibnib: Image compression with extreme decompression speed raphlinus/nibnib