English · 简体中文
Pixel format conversion for industrial cameras. Pure managed, zero dependencies, SIMD-accelerated.
The goal is to cover every pixel format defined by GenICam PFNC.
var pixels = new ushort[width * height];
PixelConverter.Unpack(rawFrame, PixelFormat.Mono12p, pixels);Industrial cameras do not emit one integer per pixel. To save bandwidth, PFNC (Pixel Format Naming Convention) defines a large set of packed, non-byte-aligned formats with differing bit orders: 12-bit data with two pixels sharing three bytes, Bayer mosaics, YUV chroma subsampling, and so on. These raw byte streams cannot be used as images directly.
PixelPack handles that conversion layer: it turns the camera's raw buffer into something you can
display, store, or hand to an algorithm (Mono16, Mono8, RGB24, ...). Pure managed C#, no
native dependencies, not tied to any camera vendor.
This kind of conversion has a distinctive failure mode: when it goes wrong, nothing throws, nothing crashes, and an image still comes out — only the pixel values are wrong.
A single misidentified format may not surface for months, by which time results have piled up on top of bad data. That is far harder to diagnose than an outright error, and it is the starting point for every design decision in this library: fail at the call site rather than let an error pass silently.
The two cases below come from the implemented subset and are typical of this failure mode.
Both pack two pixels into three bytes, and the names are similar, but the bit order differs:
byte0 byte1 byte2
Mono12p P0[7:0] P1[3:0]<<4 | P0[11:8] P1[11:4] <- PFNC standard, lsb-aligned
Mono12Packed P0[11:4] P1[3:0]<<4 | P0[3:0] P1[11:4] <- GigE Vision 1.x legacy
Choosing the wrong one throws nothing and crashes nothing; the image still comes out. And it is only half wrong:
Under both layouts the odd-pixel formula is identical ((b1>>4) | (b2<<4)); only the even pixel
has its three nibbles rotated. The symptom is therefore not overall noise but every other pixel
being wrong — a vertical comb pattern that is hard to trace back to the pixel format.
The two layouts are implemented as separate code paths, each verified bit by bit against
hand-computed samples, with one test dedicated to pinning down exactly what a wrong choice
produces (Mono12Tests.cs).
Which one a camera uses is reported by the SFNC PixelFormat node.
Saving to BMP or JPEG, recording video, or feeding an 8-bit-only algorithm all require narrowing 16 bits to 8 first. Two things here silently produce a wrong image.
1. The shift depends on the effective bit depth, not the container width. After 12-bit data is
stored right-aligned in a 16-bit container, its range is only 0..4095. Narrowing as if it were
16-bit (shift right by 8) compresses it into 0..16 — the output is nearly black, and nothing is
reported. The correct shift is effective bit depth - 8.
var gray8 = new byte[width * height];
PixelConverter.ConvertMono16ToMono8(pixels, gray8, sourceBitDepth: 12); // 12, not 16sourceBitDepth must match the Mono16Alignment used when unpacking: Mono12 unpacked with Lsb
(the default) takes 12; unpacked with Msb it already fills 16 bits, so it takes 16. The
parameter cannot be inferred — a 16-bit buffer does not carry whether it holds 12-bit or 14-bit
data, so the caller has to supply it.
2. Shifting right truncates; it does not round. Truncation darkens the whole image by half an LSB on average, and "the saved file looks slightly darker than the screen" is rarely investigated as a defect. This library adds half an LSB before shifting.
A bit depth outside 8..16 throws rather than being silently clamped — clamping would hide the caller's bug.
Mono10p and Mono14p group four pixels into five and seven bytes, so a run is only byte-aligned
at multiples of four pixels. PFNC deliberately leaves line-boundary behaviour to the transport
standard (§6.7) and allows two answers:
- Image padding — the bit stream runs continuously across lines, so a line may start mid-byte.
- Line padding — each line is padded out to a byte boundary, so every line starts fresh.
If the camera uses line padding and the image width is not a multiple of four, decoding a whole frame as one contiguous run leaves every line after the first at the wrong bit offset. The image shears progressively instead of failing. Unpack one line at a time in that case:
int stride = PixelConverter.GetPackedByteCount(PixelFormat.Mono10p, width);
for (int y = 0; y < height; y++)
{
PixelConverter.Unpack(
frame.Slice(y * stride, stride),
PixelFormat.Mono10p,
pixels.AsSpan(y * width, width));
}Mono12p packs two pixels per three bytes, so it only needs an even width. Mono8 and Mono16
are always aligned.
Because these errors do not surface on their own, correctness has to come from tests. Every format goes through the same procedure:
- Bit layouts are traced to the specification text. Bit order is never written from memory; comments identify the specific clause each layout comes from.
- Hand-computed samples are the baseline. Manually derived samples (such as
P0 = 0x123, P1 = 0xABC) verify the scalar implementation, and the scalar implementation in turn verifies the SIMD one. Two levels of reference, so that two paths failing the same way cannot confirm each other. - SIMD and scalar are compared pixel by pixel for every length from 0 to 200. Unpacking defects concentrate in tail handling; testing whole blocks alone does not cover them.
- Anti-regression assertions. Comparison tests are not sufficient on their own: if a SIMD implementation started returning 0 unconditionally, the scalar path would do all the work, every test would still pass, and the acceleration would be gone. Separate tests pin down how much of the input SIMD must cover.
- Every trap has a test pinning down what a mistake actually produces, not just the correct path.
- CI runs on three platforms (x64 Linux, x64 Windows, ARM64 macOS). SIMD branches by instruction set, so both the AVX2 and the NEON path have to execute for real; one side alone proves nothing.
Apple M5 / .NET 10 / macOS 26.6. The baseline is an equivalent scalar loop, also zero-allocation:
| 5 MP (2448×2048) | 12 MP (4096×3000) | |
|---|---|---|
Mono10p scalar |
1417 µs | 3690 µs |
Mono10p SIMD |
215 µs (6.6×) | 611 µs (6.0×) |
Mono12p scalar |
1899 µs | 4621 µs |
Mono12p SIMD |
288 µs (6.6×) | 698 µs (6.6×) |
Mono12Packed scalar |
1866 µs | 4554 µs |
Mono12Packed SIMD |
387 µs (4.8×) | 954 µs (4.8×) |
Mono14p scalar |
1884 µs | 4757 µs |
Mono14p SIMD |
399 µs (4.7×) | 991 µs (4.8×) |
Mono16→Mono8 scalar |
2031 µs | 4835 µs |
Mono16→Mono8 SIMD |
197 µs (10.3×) | 519 µs (9.3×) |
Every path clears 12 MP in about a millisecond, so unpacking will not become the bottleneck in a high-frame-rate acquisition path.
The ratios differ by how much byte reordering each layout needs. Mono10p and Mono12p are the
cheapest packed formats: every pixel's bits fit inside a 16-bit window of two adjacent bytes, so
one shuffle and one multiply handle a whole vector. Mono12Packed needs a second shuffle because
its low nibble sits in a different position for even and odd pixels. Mono14p pays for 32-bit
lanes plus a narrowing step, since two of its four pixels straddle three bytes and no 16-bit
window can hold them. Mono16→Mono8 has the best ratio of all because it reorders nothing — a
single Narrow merges two vectors.
To reproduce:
dotnet run -c Release --project bench/PixelPack.Benchmarks -- --filter '*'- Zero allocation.
ReadOnlySpan<byte>in,Span<ushort>(orSpan<byte>) out; buffers are owned by the caller. - No native dependencies.
net8.0usesSystem.Runtime.Intrinsics, so one body of code serves both AVX2 and NEON. - Older frameworks supported.
netstandard2.0is shipped alongside, covering .NET Framework 4.6.1+ host applications (scalar path). - Anything that cannot be inferred is passed explicitly. Whether 12-bit data sits left- or
right-aligned in its 16-bit container is not carried by the byte stream, so it is not guessed:
Mono16Alignmentstates it, defaulting to right-aligned (0..4095). - One entry point per output shape.
Unpacktakes the pixel format as an argument rather than exposing a method per format, so covering more of PFNC widens a switch rather than the API surface. - Invalid arguments throw. No silent clamping, no substituting a "reasonable default" — clamping would hide the caller's bug.
Long-term goal: cover every pixel format defined by PFNC.
In active development — nothing has been released yet. The whole PFNC lsb-packed mono family (10, 12 and 14-bit) unpacks to Mono16, plus 16→8 narrowing. The API is not stable and may change without notice.
| Format family | State |
|---|---|
Mono packed, PFNC lsb-aligned (Mono10p, Mono12p, Mono14p) |
All three done |
Mono packed, GigE Vision 1.x legacy (Mono12Packed, Mono10Packed) |
Mono12Packed done, rest pending |
Mono bit-depth conversion (Mono16 → Mono8) |
Done; configurable LUT (false colour / custom mapping) pending |
Bayer (BayerXX8/10/12/16 → RGB24 / BGR24) |
Pending; bilinear first, then Malvar-He-Cutler |
YUV / YCbCr (YUV422_8_UYVY, YCbCr422_8, ...) |
Pending |
| RGB / BGR, packed and planar, 10 / 12 / 16-bit | Pending |
PFNC 32-bit numeric ID ↔ PixelFormat mapping |
Pending |
Implementation order follows how often each format actually appears on industrial cameras: Mono packed → Bayer → YUV → the rest. The PFNC ID table will be added only after each entry has been checked against the specification — shipping a table that disagrees with the spec is worse than shipping none.
MIT. See NOTICE for code provenance.