System designHow-to

Save grayscale camera frames as BMP to an SD card

Save grayscale frames as BMP: build the 1,078-byte header and palette, pad rows to 4 bytes, write to an SD card, and check the size in any PC viewer.

LumaSpec Vision9 min read Share on LinkedIn

On this page 9 sections
  1. 01Why BMP wins when there is no image library
  2. 02The file layout: 54 bytes of headers, 1,024 of palette, then pixels
  3. 03Build and write the file, step by step
  4. 04The 320 × 320 frame, worked end to end
  5. 05Other widths and other bit depths
  6. 06What a wrong file looks like
  7. 07Pairing the writer with a camera module
  8. 08Checks before you trust the files
  9. 09Questions

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.
rowSize = ceil(width / 4) × 4 (8 bits per pixel) fileSize = 14 + 40 + 1024 + rowSize × height

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

  1. 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.

  1. Write the block, then the rows from bottom to top. Rows go bottom-up because biHeight is 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 */
  1. Flush and close before power disappears.
  1. 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 bfOffBits is 1078.
  • File size: 1,078 + 102,400 = 103,478 bytes, the value bfSize carries 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 biHeight stayed positive. Fix the row order or store biHeight as −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: bfOffBits left 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 bfSize disagreeing 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.

Board-level camerasHikrobot MV-CB016-10GM-M-W 1.6 MP GigE board-level camera, IMX296, Mono, M12 lens mount, WTB GigE portMV-CB016-10GM-M-W
Sensor format
1/2.9"
Lens mount
M12
Interface
GigE
Resolution
Up to 3 MP
$559.00View partBuild a system with it

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.

Board-level cameras6.3 MP board-level camera, IMX178, 1/1.8" rolling shutter, 18 fps (LS-BCM-0020)LS-BCM-0020
Sensor format
1/1.8"
Lens mount
CS-mount
Resolution
6-10 MP
Shutter
Rolling
$290.00View partBuild a system with it

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: bfOffBits reads 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.