Cameras & sensorsHow-to

Unpack Mono12p to a 16-bit image from the raw buffer

Two 12-bit pixels share three bytes in a packed Mono12p frame. Unpack the raw buffer into a 16-bit image: byte layouts, code, tests and 16-bit TIFF saving.

LumaSpec Vision10 min read Share on LinkedIn

On this page 8 sections
  1. 01Why the bitmap conversion fails on packed 12-bit
  2. 02The two packed layouts and the names that choose between them
  3. 03Unpack the buffer in six steps
  4. 04Worked example: a 4000 × 3000 Mono12Packed frame
  5. 05Prove it on the first live frame
  6. 06When Mono16 is the better answer
  7. 07Common mistakes
  8. 08Questions

Your camera runs in a 12-bit packed format, the SDK's bitmap conversion throws an error or quietly returns 8 bits, and you need all 4096 gray levels in memory. The frame is not the problem: packed 12-bit data is just two pixels sharing three bytes, and display-oriented converters do not carry it as 16-bit. Read the raw buffer, unpack it into a true 16-bit image with a few shifts and masks, prove the byte order with a three-byte test, and save a 16-bit TIFF. Everything here applies to any GigE, USB3 or CoaXPress camera that reports Mono12p or Mono12Packed.

Why the bitmap conversion fails on packed 12-bit

The bitmap helper inside a camera SDK exists to feed on-screen displays, and display surfaces take 8 bits per channel. A packed 12-bit frame has no 16-bit display target, so the call either raises a conversion error or hands back an image that has already squeezed 4096 levels into 256. An error objecting to a conversion from Mono12p into Mono12Packed is telling you this directly: both names are packed 12-bit inputs, and the function was never a general pixel-format engine.

The fix is to stop asking for a bitmap. Read the raw frame buffer the SDK already holds in memory and unpack it yourself. Two shifts and two masks per pixel pair return every original value, 0 to 4095, unchanged. It is the same verify-first discipline as checking a converted image's pixel format before processing.

The two packed layouts and the names that choose between them

GenICam's pixel format naming convention, PFNC, defines two packed 12-bit layouts. USB3 Vision cameras report Mono12p; GigE Vision cameras report Mono12Packed. Both store two pixels in three bytes, but the nibble roles swap between them, so the name your camera reports decides which formulas you run. Two further names need no unpacking at all: Mono12 stores each value in a 16-bit word, and Mono16 is that word at full width.

PFNC name Data per pixel Bytes per 2 pixels Unpack step
Mono8 8 bits 2 None, but only 256 levels remain
Mono12p (USB3 Vision packing) 12 bits, packed 3 MSBs in bytes 0 and 2, nibbles in byte 1
Mono12Packed (GigE Vision packing) 12 bits, packed 3 LSBs in bytes 0 and 1, nibbles in byte 2
Mono12 / Mono16 12 bits in a 16-bit word 4 None, read as 16-bit values
Mono12Packed: pixel 1 = byte0 | ((byte2 & 0x0F) << 8) pixel 2 = byte1 | ((byte2 & 0xF0) << 4)
Mono12p: pixel 1 = (byte0 << 4) | (byte1 & 0x0F) pixel 2 = (byte2 << 4) | (byte1 >> 4)

Read the GenICam PixelFormat feature at runtime rather than trusting what you selected last; whichever name it reports selects the block above. A wrong guess is not subtle, and the flat-target check below catches it on the first frame. Color sensors add packed Bayer variants on top, and packed Bayer 12-bit conversion to a 16-bit image handles that case separately.

Unpack the buffer in six steps

You need a frame from a camera whose PixelFormat reports Mono12p or Mono12Packed, access to the raw buffer pointer and its length in bytes, an output buffer of width × height 16-bit values, and a uniform target for the final check.

  1. Confirm the format. Read the PixelFormat feature from the camera and note the exact name: Mono12p selects the PFNC formulas, Mono12Packed selects the GigE Vision ones.
  2. Size the output. Allocate width × height 16-bit values, 2 bytes each. If a 16-bit buffer ever shows half the expected pixels, bytes were mixed with pixels; why your 16-bit image buffer has half the expected pixels walks through it.
  3. Take the raw pointer and check its length. A packed frame occupies width × height × 3 / 2 bytes. If the SDK reports more, rows are padded, so advance row by row using the SDK's stride instead of assuming 1.5 bytes per row.
  4. Unpack each three-byte group with the formulas for the reported name. The two functions below cover both.
  5. Run the known-bytes test on the first buffer, before any live frame reaches your inspection code.
  6. Use the 16-bit image. Hand it to your image library, for example by wrapping the SDK frame buffer in a cv::Mat without copying, and write TIFFs with 16 bits per sample and no scaling. The full path from raw bytes to a processing-ready image is in convert a raw camera buffer to a processing-ready image.
#include <cstdint>
#include <cstddef>

// Mono12Packed, GigE Vision packing: 3 bytes in, two uint16_t out.
void unpackMono12Packed(const uint8_t* in, uint16_t* out, size_t pixelCount)
{
    for (size_t i = 0; i < pixelCount / 2; ++i) {
        const uint8_t b0 = in[3 * i + 0];  // pixel 1, bits 7..0
        const uint8_t b1 = in[3 * i + 1];  // pixel 2, bits 7..0
        const uint8_t b2 = in[3 * i + 2];  // low nibble: pixel 1 bits 11..8
        out[2 * i + 0] = static_cast<uint16_t>(b0 | ((b2 & 0x0F) << 8));
        out[2 * i + 1] = static_cast<uint16_t>(b1 | ((b2 & 0xF0) << 4));
    }
}

// Mono12p, PFNC packing: same bytes, nibble roles swapped.
void unpackMono12p(const uint8_t* in, uint16_t* out, size_t pixelCount)
{
    for (size_t i = 0; i < pixelCount / 2; ++i) {
        const uint8_t b0 = in[3 * i + 0];  // pixel 1, bits 11..4
        const uint8_t b1 = in[3 * i + 1];  // low nibble: pixel 1 bits 3..0, high nibble: pixel 2
        const uint8_t b2 = in[3 * i + 2];  // pixel 2, bits 11..4
        out[2 * i + 0] = static_cast<uint16_t>((b0 << 4) | (b1 & 0x0F));
        out[2 * i + 1] = static_cast<uint16_t>((b2 << 4) | (b1 >> 4));
    }
}

The test that proves both:

const uint8_t src[3] = { 0x12, 0x34, 0x56 };
uint16_t dst[2] = { 0, 0 };

unpackMono12Packed(src, dst, 2);
// dst[0] == 0x0612 == 1554, dst[1] == 0x0534 == 1332

unpackMono12p(src, dst, 2);
// dst[0] == 0x0124 == 292,  dst[1] == 0x0563 == 1379

Camera widths are even in practice; if yours is odd, the final three-byte group holds only its first pixel as valid data. At high frame rates, allocate the 16-bit output once per camera and reuse it every frame; reusing image buffers instead of per-frame allocation explains the stall you avoid.

Worked example: a 4000 × 3000 Mono12Packed frame

Inputs, and where each comes from: the sensor is 4000 × 3000, the output of a 12 MP USB3 area scan camera like the one below (catalog specification); the pixel format is Mono12Packed, read from the PixelFormat feature; the test bytes 0x12, 0x34, 0x56 are a chosen vector so every step of the arithmetic stays visible.

The example frame matches the output of a 12 MP USB3 area scan camera:

Area scan cameras12 MP USB3 area scan camera, IMX226, 1/1.7" rolling shutter, 30 fps, 4000 × 3000 resolution (LS-CMR-0166)LS-CMR-0166
Sensor format
1/1.7"
Interface
USB3
Resolution
12 MP
Shutter
Rolling
$560.00View partBuild a system with it

The rule in words: every three bytes carry two pixels; bytes 0 and 1 hold each pixel's eight low bits, byte 2's low nibble completes pixel 1, and its high nibble completes pixel 2.

The arithmetic:

  • Pixels per frame: 4000 × 3000 = 12,000,000.
  • Packed transfer: 12,000,000 × 1.5 bytes = 18,000,000 bytes (18 MB).
  • Unpacked 16-bit output: 12,000,000 × 2 bytes = 24,000,000 bytes (24 MB), a third more than packed.
  • Test decode, pixel 1: 0x56 & 0x0F = 0x06, so 6 << 8 = 1536, and 1536 + 0x12 (18) = 1554 = 0x0612.
  • Test decode, pixel 2: 0x56 & 0xF0 = 0x50, so 0x50 << 4 = 1280, and 1280 + 0x34 (52) = 1332 = 0x0534.

The decision: on a 1 GbE link, which moves roughly 110 MB/s of image payload after protocol overhead, 18 MB frames stream at about 6.1 fps while 24 MB Mono16 frames reach about 4.6 fps. The unpack is one pass over 12,000,000 pixels; at roughly 1.5 ns per pixel in a scalar loop that is about 20 ms per frame, an order-of-magnitude estimate for this code. Keep the transfer packed and unpack in RAM whenever the link is the bottleneck: the 6.1 against 4.6 fps margin buys more than 20 ms of CPU costs. At a 30 fps cadence the frame budget is 33 ms, so one core keeps up; past about 50 fps, vectorize the loop or run Mono16 on a faster link.

Prove it on the first live frame

The assertions in the test above are the complete proof of the byte math: 1554 and 1332 for Mono12Packed, 292 and 1379 for Mono12p, all four from the same three bytes. If your SDK offers a conversion utility, decode one frame with it too and compare arrays; a mismatch points to a stride or offset problem, not a formula problem.

Then point the camera at a uniform target and raise exposure until it just saturates. A correct unpack shows a histogram that is one narrow peak, and a maximum that climbs toward 4095 without ever exceeding it. Three failures name themselves. Values above 4095 mean a high nibble landed in a low position. A ceiling at 255 means the bytes were read as 8-bit somewhere upstream. A fine comb of histogram peaks with vertical striping means nibbles or pixel order are swapped.

A diagonal shear, each row starting a little further along than the last, means the row stride is wrong: the buffer is padded and the loop stepped 1.5 × width bytes per row. An image narrower than the sensor means packed bytes were read directly as 16-bit words.

When Mono16 is the better answer

If the link and disk have headroom, skip the code. Set the GenICam PixelFormat to Mono16 and the camera or driver unpacks before delivery, so the buffer arrives as 16-bit values. The price is size: 2 bytes travel for every 12-bit pixel, a third more than packed, which on 1 GbE is the 4.6 fps case from the worked example. On USB3, whose payload runs near 400 MB/s, a 24 MB frame is rarely the constraint and Mono16 costs nothing you will notice.

If your decision needs only 256 levels, presence checks under steady lighting are the common case, Mono8 is smaller still and the whole question disappears. Keep 12 bits when you threshold near the noise floor, compute image ratios, or combine exposures. And if a pipeline configured for 12-bit quietly delivers Mono8, the negotiated format fell back; camera image encoding falls back to Mono8 covers that case.

Common mistakes

  • Reading packed bytes as 16-bit words directly. You get a striped, wrongly sized image; unpack first.
  • Asking the bitmap converter for another packed target. No display consumer exists for packed 12-bit, so the call can only fail.
  • Ignoring row padding. Check the SDK's buffer length against width × height × 3 / 2 before looping.
  • Rescaling after unpacking. The 16-bit container already holds the sensor's 0 to 4095 range unchanged; scaling only discards levels.
  • Trusting the format you configured instead of the one reported. Read PixelFormat at runtime, because cameras and drivers negotiate.

The mask-and-shift pattern here is the same discipline used to unpack packed values in automation scripting; unpacking packed values with bit masks instead of per-bit arrays applies it outside image data.

To pick hardware for this pipeline, filter the area scan cameras by interface and resolution in the camera finder, or start at the camera configuration step. Related camera-side fixes are collected on the cameras topic page, and the applications team answers buffer questions through the contact page.

Questions

Why does my SDK's bitmap conversion only deliver 8-bit from a packed 12-bit frame?

The bitmap path exists to feed on-screen displays, and display surfaces are 8-bit. Asked for a 16-bit result from packed source data it raises a conversion error, and asked for a display format it scales 4096 levels down to 256 before you ever see the pixels. Keeping the full range means leaving that path: take the raw buffer and unpack the bytes yourself.

How many bytes does a Mono12p frame occupy in memory?

Width × height × 1.5. A 1920 × 1080 frame is 1920 × 1080 × 3 / 2 = 3,110,400 bytes, and its unpacked 16-bit form needs 1920 × 1080 × 2 = 4,147,200 bytes. Budget both sizes when you plan buffers, and size any ring buffer from the packed number if you store frames before unpacking.

What is the difference between Mono12, Mono12p and Mono12Packed?

Mono12 puts each 12-bit value in a 16-bit word, so no unpacking is needed. Mono12p is the PFNC packing from the USB3 Vision world: bytes carry the eight high bits of each pixel and a middle byte carries both low nibbles. Mono12Packed is the GigE Vision packing: bytes carry the eight low bits and the final byte's nibbles carry the high bits. Same 12 bits of data, different byte order.

Can I get the 16-bit image without writing unpack code?

Yes. Set the camera's PixelFormat to Mono16 instead of a packed name: the camera or driver unpacks on the way through, and the delivered buffer already reads as 16-bit values. The cost is transfer and storage, because 16 bits travel for every 12-bit pixel, about a third more than packed. It is the simpler choice whenever the link has headroom.

Does unpacking change the pixel values in any way?

No. Each 12-bit value lands unchanged in the 16-bit container: 0 stays 0, 4095 stays 4095, and the gray-level count stays 4096. No scaling is applied, so thresholds and calibrations tuned on unpacked data match what the sensor produced. Only conversions that target 8 bits discard information, collapsing those 4096 levels into 256.