Raw Bitmap
Pixel data with no header: a flat run of bytes whose meaning depends entirely on a width, a bit depth and a bit order that live somewhere else, usually in the firmware.
A raw bitmap is pixel data with nothing wrapped around it. No magic bytes, no width, no height, no palette; just the bytes a display controller expects to be handed. Every image format you normally meet, PNG, BMP, JPEG, exists to avoid exactly this by writing the parameters down. A framebuffer does not, because the two ends already agreed offline.
What it is
Three parameters turn a run of bytes into a picture:
- Width, from which the stride follows: the number of bytes one row occupies. For 1 bit per pixel that is
width / 8. - Bit depth: 1 for monochrome e-paper and small OLEDs, 8 for greyscale, 16 or 24 for colour.
- Bit order and polarity: whether the most significant bit of a byte is the leftmost pixel (almost always yes) and whether a set bit means ink or paper (either, and vendors disagree).
Height then falls out of the total length. So the search is really one dimensional: find the width, and the picture appears.
Recognising that you are holding one is the first step. A raw bitmap has no magic, so file says "data" and binwalk finds nothing. What it does have is a length that factors suspiciously well, large runs of 0x00 or 0xFF where the image is blank, and no compression signature. Those three together are the tell.
Why it matters
This is what actually crosses the wire when a device is told what to display. Electronic shelf labels, e-paper badges, smartwatch faces, small OLED panels on IoT gadgets: none of them ship a PNG decoder, so the phone or the gateway renders the image and pushes the framebuffer. Anything the vendor considers "just a picture" therefore travels as bytes that no parser will recognise for you, and the content is often exactly what you want: a price, a name, a token, a QR code.
The same shape turns up in flash dumps as splash screens and font tables, and in memory dumps as the display buffer itself.
How to work with it
Factor the length, try the plausible widths, and look:
def show(data, width, invert=False):
stride = width // 8
for y in range(len(data) // stride):
row = data[y * stride:(y + 1) * stride]
line = "".join("#" if (b >> (7 - i)) & 1 else "."
for b in row for i in range(8))
print(line.translate(str.maketrans("#.", ".#")) if invert else line)
for w in (64, 88, 128, 176, 256, 352): # divisors of len(data) * 8
print("=== width", w); show(data, w)
Or hand it to a real viewer by writing a PBM, which is a raw bitmap with three lines of header bolted on:
open("out.pbm", "wb").write(b"P4\n%d %d\n" % (width, height) + data)
GIMP opens raw data directly (Open, then choose "Raw image data" and set the width interactively), and ImageMagick will convert with convert -size 352x48 -depth 1 mono:blob.bin out.png. Sweeping the width in a viewer with a slider is faster than any script.
Pitfalls
- A wrong width looks like a shear, not like noise. If your render shows diagonal streaks with visible structure, you are close: the width is off by a small amount. Genuine noise means the wrong bit depth, or that it is not an image.
- Row padding exists. Many formats round the stride up to a 2 or 4 byte boundary, so a 100-pixel-wide 1bpp row can occupy 16 bytes rather than 12.5. If no clean width works, try strides that are not exactly
width / 8. - Polarity is a coin flip and does not matter. If the text renders as paper on ink, invert it. Never let it stop you; legibility is the same either way.
- Scaling is invisible. A picture drawn at 2x on a panel is indistinguishable from a picture drawn at 1x on a panel half the size and then doubled. Both render identically, and neither is wrong.
- Some rows are not the image. Display buffers can carry a few bytes of controller state, a checksum or padding at the end. A last row that is garbage while the rest is clean is usually that, not a wrong width.