You have a grayscale frame in memory — say 320 × 320 bytes from a camera module — an SD card your code can already write, and no image library to lean on. You still want a file that opens on any PC. The BMP format does exactly that: an 8-bit grayscale BMP is a fixed 1,078-byte header block followed by the raw pixel rows, each padded to a 4-byte boundary. There is no compressor and no encoder pass — one loop of byte writes per row. This guide gives the byte layout, the exact header values, the write order and a verification test, then works the 320 × 320 case end to end: a 103,478-byte file whose size and pixel offsets you can check right on the card.
Why BMP wins when there is no image library
JPEG and PNG both demand real encoders. PNG runs DEFLATE on every scan line, which pulls a zlib-class implementation into your firmware. JPEG adds DCT, quantization and Huffman coding, and it discards information as it goes. A BMP writer is roughly 40 lines of C: fill one header array, copy each row to the card, done.
That makes BMP the right format for evidence frames — defect archives, training sets, support snapshots — where lossless beats small. The only cost is size: every 320 × 320 frame occupies its full 102,400 bytes of pixels, several times a typical JPEG of the same picture. The same lossless-first logic is why machine-vision cameras stream raw rather than compressing in-camera; in-camera JPEG compression: why machine vision cameras stay raw follows that argument at the stream level, and saving camera frames to disk for offline testing is the host-side twin of this technique.
The file layout: 54 bytes of headers, 1,024 of palette, then pixels
An 8-bit grayscale BMP is four blocks written in this order:
-
BITMAPFILEHEADER, 14 bytes: type, total file size, offset of the pixel data. -
BITMAPINFOHEADER, 40 bytes: width, height, bit depth, compression, palette size. - Color palette, 256 entries × 4 bytes = 1,024 bytes. Microsoft's bitmap header types page states that 1, 4 and 8 bpp bitmaps must carry a color table, so this block is not optional.
- Pixel rows, bottom row first, each padded with zero bytes to a 4-byte boundary.
Field definitions live in Microsoft's BITMAPINFOHEADER reference; the BMP file format page on Wikipedia gives the same layout plus the padding rule. Every multi-byte field is little-endian. For the 320 × 320 grayscale frame the block fills in as:
| Offset | Bytes | Field | Value here |
|---|---|---|---|
| 0 | 2 | bfType |
bytes 0x42 0x4D, the letters 'BM' |
| 2 | 4 | bfSize |
103478 |
| 6 | 4 |
bfReserved1, bfReserved2
|
0 |
| 10 | 4 | bfOffBits |
1078 |
| 14 | 4 | biSize |
40 |
| 18 | 4 | biWidth |
320 |
| 22 | 4 | biHeight |
320; positive means rows are stored bottom-up |
| 26 | 2 | biPlanes |
1 |
| 28 | 2 | biBitCount |
8 |
| 30 | 4 | biCompression |
0 (BI_RGB, uncompressed) |
| 34 | 4 | biSizeImage |
102400 |
| 38 | 8 |
biXPelsPerMeter, biYPelsPerMeter
|
0 |
| 46 | 4 | biClrUsed |
256 |
| 50 | 4 | biClrImportant |
0 |
| 54 | 1024 | palette | 256 entries: blue = green = red = index, plus one 0 byte |
| 1078 | 102400 | pixel rows | bottom row first |
Build and write the file, step by step
- Build the 1,078-byte header block in RAM. One array holds the file header, the info header and the palette:
#include <stdint.h>
/* Helper names are this guide's own; the bf/bi names are the BMP standard's fields. */
static void put16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
static void put32(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);
}
#define W 320u
#define H 320u
static uint8_t hdr[14 + 40 + 4 * 256]; /* the whole 1078-byte block */
void bmp_build_header(void) {
const uint32_t row_size = (W + 3u) / 4u * 4u; /* 320 */
const uint32_t data = row_size * H; /* 102400 */
const uint32_t off = 14u + 40u + 4u * 256u; /* 1078 */
for (uint32_t i = 0; i < sizeof hdr; i++) hdr[i] = 0;
hdr[0] = 0x42; hdr[1] = 0x4D; /* 'BM' */
put32(hdr + 2, off + data); /* bfSize = 103478 */
put32(hdr + 10, off); /* bfOffBits = 1078 */
put32(hdr + 14, 40); /* biSize = 40 */
put32(hdr + 18, W); /* biWidth = 320 */
put32(hdr + 22, H); /* biHeight = 320 */
put16(hdr + 26, 1); /* biPlanes = 1 */
put16(hdr + 28, 8); /* biBitCount = 8 */
put32(hdr + 30, 0); /* biCompression = BI_RGB */
put32(hdr + 34, data); /* biSizeImage = 102400 */
put32(hdr + 46, 256); /* biClrUsed = 256 */
for (uint32_t i = 0; i < 256; i++) { /* grayscale palette */
hdr[54 + 4*i] = (uint8_t)i; /* blue */
hdr[54 + 4*i + 1] = (uint8_t)i; /* green */
hdr[54 + 4*i + 2] = (uint8_t)i; /* red */
hdr[54 + 4*i + 3] = 0; /* reserved */
}
}
Every number in that function is a byte count or field value for the 320 × 320 case; change W and H and the sizes recompute themselves.
-
Write the block, then the rows from bottom to top. Rows go bottom-up because
biHeightis positive:
/* frame is your 320x320 8-bit buffer; card_write stands in for the SD library's write call */
static const uint8_t pad[3] = {0, 0, 0};
card_write(hdr, sizeof hdr); /* 1078 bytes */
for (uint32_t y = H; y > 0; y--) {
card_write(&frame[(y - 1) * W], W); /* bottom row first */
if (row_size > W) card_write(pad, row_size - W);
}
card_flush(); /* helper name is this guide's own */
- Flush and close before power disappears.
- Run the size-and-offset test. Reopen the file and check four things without loading it into RAM:
/* reopen the saved file, then: */
/* length == 103478 */
/* byte 0 is 0x42 and byte 1 is 0x4D */
/* seek to 1078: read 1 byte == frame[(H - 1) * W] */ /* bottom-left pixel */
/* seek to 103477: read 1 byte == frame[W - 1] */ /* top-right pixel */
Then open the file on a PC: the viewer must report 320 × 320, 8-bit grayscale. One uniform frame tests the whole path at once — fill the buffer with 0x80 and the picture must be flat mid-gray; 0x00 opens black, 0xFF white.
The 320 × 320 frame, worked end to end
Run the arithmetic once by hand:
- Row size: 320 / 4 = 80 exactly, so
rowSize= 320 bytes and no pad bytes exist. - Pixel data: 320 × 320 = 102,400 bytes.
- Header block: 14 + 40 + 1,024 = 1,078 bytes — which is why
bfOffBitsis 1078. - File size: 1,078 + 102,400 = 103,478 bytes, the value
bfSizecarries and step 4 asserts.
Card life follows from the same number. A 32 GB card holds 32,000,000,000 bytes, so 32,000,000,000 / 103,478 ≈ 309,000 frames before overhead. Files occupy whole allocation units: on a card with 16 KB units, ceil(103,478 / 16,384) = 7 units = 114,688 bytes of card per file, and 32,000,000,000 / 114,688 ≈ 279,000 frames — about 3.1 hours at 25 fps. Write load scales the same way: at a sustained 2 MB/s, one file takes 103,478 / 2,000,000 ≈ 52 ms, so measure your card once and budget from that number.
Other widths and other bit depths
The formula absorbs any width. Padding appears only when the width leaves a remainder mod 4: a 322-pixel row becomes 324 bytes, with 2 zero bytes closing every row. Bit depth changes the constants instead — a 24-bit BMP drops the palette (bfOffBits = 54) and stores 3 bytes per pixel in blue, green, red order.
| Frame | rowSize | File size |
|---|---|---|
| 320 × 320, 8-bit | 320 | 1,078 + 102,400 = 103,478 bytes |
| 322 × 322, 8-bit | 324 | 1,078 + 104,328 = 105,406 bytes |
| 640 × 480, 8-bit | 640 | 1,078 + 307,200 = 308,278 bytes |
| 320 × 320, 24-bit | 960 | 54 + 307,200 = 307,254 bytes |
One limit matters for sensor data: this format stores 256 gray levels. Scaling 10-bit (1,024 levels) or 12-bit (4,096 levels) pixels into it discards dynamic range, so save measurement frames as 16-bit TIFF or raw — saving 12-bit camera images without losing intensity data and saving 16-bit grayscale images reliably cover both — and keep BMP for previews and displays.
What a wrong file looks like
Each failure mode has a signature you can recognize on sight:
- Upside-down image: row 0 was written first while
biHeightstayed positive. Fix the row order or storebiHeightas −320. - Rows drifting diagonally: a width such as 322 written without its 2 pad bytes. The viewer strides 4 bytes regardless and shears everything after row 1.
- Loud false colors:
bfOffBitsleft at 54 with no palette written. The viewer reads pixel bytes as indices into a table that is not there. - Opens in some tools, rejected by others: a multi-byte field written big-endian, or
bfSizedisagreeing with the real length. - File cut short after a run: power dropped before the flush finished — see step 3.
- Structure correct but the picture noisy: the writer is innocent; exposure, gain or lighting is at fault, and fixing grainy grayscale images on a small embedded camera sorts that out at the sensor side.
Pairing the writer with a camera module
The writer needs one thing from a camera: an 8-bit grayscale buffer of known width and height, frame after frame. On a microcontroller board that comes straight out of the module's frame buffer; on larger embedded targets the storage side of the same problem is covered in storing camera images on embedded vision board flash in C. When the project outgrows the demo board, choose the camera for the measurement — board level camera selection for embedded vision machines walks that decision — and this file format stays underneath it unchanged.
- Sensor format
- 1/2.9"
- Lens mount
- M12
- Interface
- GigE
- Resolution
- Up to 3 MP
The MV-CB016-10GM-M-W is a 1.6 MP mono board camera on a 1/2.9" sensor with a global shutter and an M12 mount, and its mono feed is exactly the kind of buffer this writer consumes.
- Sensor format
- 1/1.8"
- Lens mount
- CS-mount
- Resolution
- 6-10 MP
- Shutter
- Rolling
For more resolution, the LS-BCM-0020 is a 6.3 MP board camera with a 1/1.8" rolling shutter delivering 18 fps over GigE; larger sensors and faster interfaces continue in the area scan cameras range.
Checks before you trust the files
Run these once, before the first real capture session:
- Magic bytes: byte 0 is 0x42, byte 1 is 0x4D.
- File size: 103,478 bytes for the example frame; 1,078 + rowSize × height in general.
- Offset:
bfOffBitsreads 1,078, and the byte there equals the bottom-left pixel of your buffer. - Uniform frames: all 0x00 opens black, all 0xFF white, all 0x80 flat mid-gray.
- Corner checks: the bottom-left and top-right spot checks from step 4 both match the buffer.
- Viewer check: a PC viewer reports 320 × 320, 8-bit grayscale.
- Card endurance: write 1,000 uniquely named files, remount the card, and open the first and the last.
If all seven pass, the file format is proven; anything that fails after that is capture-side, not file-side. When you are picking the module that feeds this pipeline, the board-level cameras category and the camera finder filter by sensor format, shutter and interface, and the camera configurator assembles a matched set. More storage and streaming techniques live on the systems topic page, and if you send your frame size and board through the contact page, we will check the arithmetic against your hardware.
Questions
Does an 8-bit grayscale BMP really need the 256-entry palette?
Yes. Microsoft's bitmap header documentation requires a color table for 1, 4 and 8 bpp files, and bfOffBits must point past it. Writing all 256 entries costs 1,024 bytes and puts the first pixel at offset 1,078. Setting biClrUsed to 0 makes a reader assume the full 2^8 entries, so the palette bytes are expected either way — there is no 8-bit indexed BMP without a table.
How large is the file for a 640 × 480 frame?
640 divides by 4, so rows need no padding: 640 × 480 = 307,200 pixel bytes plus the 1,078-byte header block, for 308,278 bytes per file. A 1 GB card (1,000,000,000 bytes) therefore holds about 3,240 frames before file-system overhead — comfortable for a day of triggered captures.
Why does my saved image open upside down?
Positive biHeight tells the reader the first stored row is the bottom of the picture. If you wrote row 0 first while leaving biHeight at 320, every viewer flips the image. Either copy rows from height − 1 down to 0, or keep arrival order and store biHeight as −320; BI_RGB readers accept the negative form, a few very old viewers do not.
Can I keep 10- or 12-bit sensor data in a BMP?
Not without loss. biBitCount = 8 stores 256 gray levels, so squeezing 10-bit (1,024 levels) or 12-bit (4,096 levels) data in discards 2 to 4 bits of dynamic range — usually exactly where faint defect contrast lives. Keep the BMP path for previews and 8-bit displays, and save measurement frames as 16-bit TIFF or raw.
May I leave biSizeImage at 0?
For BI_RGB files biSizeImage may be 0, and many viewers cope. Set the real number anyway — 102,400 for the 320 × 320 case — because some tools use it to size their read buffer. Only compressed formats must always fill it in.

