A camera configured for 12-bit output sends pixel values from 0 to 4095. Written straight into a 16-bit PNG or TIFF, that frame fills only the bottom 6 percent of the file's 0-65535 range, and every viewer opens it as an almost black picture. The fix is one multiplication on the host, after the frame has arrived: multiply by 16, then save as 16-bit. This guide gives you the scaling rule for 8-, 10- and 12-bit data, a short Python snippet for PNG and TIFF, the one check that tells you whether a frame is already scaled, and the mistakes that quietly destroy 12-bit data. It applies to any GigE or USB3 camera whose SDK hands you the raw frame as a NumPy array.
Why the saved image is almost black
The file is valid; the values inside it are small. A 16-bit PNG or TIFF stores 0-65535. A 12-bit frame tops out at 4095, and a viewer maps 65535 to white, so the brightest pixel of an unscaled frame renders at 4095 / 65535 = 6.25 percent brightness. On screen that is a near-black picture with the histogram crushed into the left edge.
No file format fixes this at save time, because PNG and TIFF have no 12-bit storage type, and neither does OpenCV: images are 8-bit or 16-bit arrays. The 12-bit values therefore travel inside a 16-bit container unchanged. The container is not the problem; the values inside it are.
The scaling rule: multiply by 16
Shift the values left by 4 bits. That multiplies every pixel by 16 and puts the 4095 maximum at 65520, which is 99.98 percent of the 16-bit range.
The left shift is exact: no rounding, no float conversion, and it runs directly on a uint16 array. The 15-level gap at the top (65520 against 65535) is 0.02 percent of the range and invisible. The same rule covers the other depths:
| Sensor depth | Maximum value | Multiply by | Shift | 16-bit maximum after |
|---|---|---|---|---|
| 8-bit | 255 | 256 | << 8 | 65280 |
| 10-bit | 1023 | 64 | << 6 | 65472 |
| 12-bit | 4095 | 16 | << 4 | 65520 |
Because the shift multiplies by a power of two, a right shift by the same amount returns the original numbers exactly. Nothing is lost and nothing is invented.
Step 1 — check what the frame contains
Some SDKs and drivers shift 12-bit data into the 16-bit container before you ever see it, and scaling a second time wraps the values around and corrupts the image. Read the numbers first:
import numpy as np
frame = ... # the frame from your SDK's capture call, as a NumPy array
print(frame.dtype, int(frame.min()), int(frame.max()))
- Maximum near 4095: raw 12-bit data. Scale by 16.
- Maximum near 65520: the data is already shifted into 16 bits. Save it unchanged; 12-bit data in a 16-bit V4L2 frame: why values top out near 65520 is this exact case on a V4L2 driver.
- Maximum near 255: the pipeline delivered 8 bits and four of the sensor's twelve are already gone. ADC bit depth vs pixel format: why 10-bit sensors ship 8-bit images explains how that happens; fix it in the pixel format setting, not in code.
An SDK call that converts a frame to a 16-bit pixel format performs this same shift. Use it or the NumPy shift below — never both.
Steps 2 to 5 — scale, save and verify
You need Python with NumPy, OpenCV (cv2) and tifffile, plus your SDK's capture call that returns the frame as a NumPy array.
- Confirm the frame is unscaled 12-bit: dtype uint16, maximum 4095 or less (Step 1).
-
Shift left by 4:
scaled = np.left_shift(frame, 4). The result stays uint16, and 4095 × 16 = 65520 fits without overflow.frame * 16gives identical numbers; the shift just makes the intent unmistakable. -
Save a TIFF:
tifffile.imwrite("frame_16bit.tif", scaled)writes a true 16-bit grayscale TIFF, the safest archive format for measurement images. -
Save a PNG:
cv2.imwrite("frame_16bit.png", scaled)writes a 16-bit PNG. Never save this data as JPEG, which is 8-bit and lossy: the precision is gone for good. - Verify the file, not the preview. Reload and assert:
import numpy as np
import tifffile
import cv2
frame = ... # uint16 (H, W), values 0 to 4095
assert frame.dtype == np.uint16
assert frame.max() <= 4095
scaled = np.left_shift(frame, 4) # x16: 4095 becomes 65520
tifffile.imwrite("frame_16bit.tif", scaled)
cv2.imwrite("frame_16bit.png", scaled)
check = cv2.imread("frame_16bit.png", cv2.IMREAD_UNCHANGED)
assert check.dtype == np.uint16
assert check.max() == 4095 * 16 # 65520
assert (check >> 4).max() == 4095 # 12-bit range intact
cv2.IMREAD_UNCHANGED matters: without it OpenCV converts a 16-bit file to 8-bit on load, and the dtype assert fails. The load-and-check routine is laid out in How to save 16-bit grayscale camera images reliably.
Worked example: one row of pixels
Take a 12-bit row of four pixels: 0, 512, 2048, 4095.
- After the shift by 4: 0, 8192, 32768, 65520.
- The brightest pixel moves from 4095 / 65535 = 6.25 percent of full scale to 65520 / 65535 = 99.98 percent; the 2048 pixel moves from 3.1 percent to 32768 / 65535 = 50 percent.
- An 8-bit preview of the saved file,
scaled >> 8, gives 0, 32, 128, 255 — the same values you get by shifting the original row right by 4. - Round trip: 65520 >> 4 = 4095 and 32768 >> 4 = 2048, so the archived file still holds the exact measurement numbers.
Because every value is multiplied by the same constant, all ratios between pixels survive. The shifted file is a faithful archive, not just a brighter picture.
Mistakes that destroy 12-bit data
- Saving as JPEG. 8-bit and lossy. Keep the archive in TIFF or PNG and make display copies in 8-bit separately.
-
Per-frame normalization.
cv2.normalize(frame, None, 0, 65535)orframe / frame.max() * 65535rescales each frame by a different factor, so a pixel value of 2048 no longer means the same brightness in two frames of one scene. Use the fixed shift for anything you keep. - Scaling twice. 65520 × 16 = 1,048,320, which wraps in uint16 arithmetic to 65280 and produces a mottled image. If Step 1 says the data is already scaled, stop there.
-
Casting to uint8 before the shift.
frame.astype(np.uint8)clips 4095 to 255 and discards four bits permanently. - Judging the file by one viewer. Some viewers window 16-bit data into 8 bits for display, so a correct file still looks dark. Read pixel values instead; Why 12-bit camera images look black in a 16-bit preview walks through that diagnosis.
For the rest of the saving workflow — compression, folder layout, naming — see Saving 12-bit camera images without losing intensity data.
Cameras that deliver 12-bit frames
You choose this at the camera, before any code runs: select a 12-bit mono pixel format in the camera's format enumeration, and the SDK then hands you the raw values this guide scales. In the shop, mono area scan cameras on GigE or USB3 are the usual starting point:
- Sensor format
- 1"
- Lens mount
- C-mount
- Sensor format
- 1/1.8"
- Lens mount
- C-mount
- Sensor format
- 1/1.7"
- Interface
- USB3
The same shift applies to frames from a board-level camera on an embedded carrier, where the saving path is usually the same NumPy-to-file code:
- Sensor format
- 1/1.8"
- Lens mount
- C-mount
- Interface
- USB3
- Resolution
- 5 MP
Checklist before you archive
- dtype uint16 and maximum at most 4095, so the data is unscaled 12-bit
-
np.left_shift(frame, 4)applied exactly once - File format PNG or TIFF, never JPEG
- Reloaded file reports uint16 with a maximum of 65520
-
value >> 4on a few sample pixels returns the original 12-bit numbers
Choose a camera that outputs 12-bit
The area scan cameras category and the camera finder filter by interface, resolution and shutter. More capture-and-storage fixes sit on the systems topic page. Set a 12-bit mono pixel format on whichever model you choose, and the export code above runs unchanged.
The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.
Questions
Can OpenCV save a 12-bit image directly?
No. OpenCV's array types are 8-bit, 16-bit, 16-bit signed and 32-bit float; there is no 12-bit type, and the PNG and TIFF writers accept 8- or 16-bit arrays. Convert first: multiply the 12-bit values by 16 into a uint16 array, then write PNG with cv2.imwrite or TIFF with tifffile.imwrite. Saving as JPEG instead would clip the file to 8 bits and throw the extra precision away.
Why multiply by 16 instead of stretching to 65535?
Multiplying by 16 is exact and reversible: every value keeps its place on the intensity scale, and a right shift by 4 returns the original 12-bit number. Reaching 65535 would take a factor of 65535 / 4095 = 16.0037, which forces rounding on most pixels, and the roundings differ from frame to frame. The 15-level gap at the top is 0.02 percent of the range and cannot be seen.
Does the same fix work for 10-bit camera data?
Yes, with a different constant: shift left by 6, which multiplies by 64. The 10-bit maximum of 1023 becomes 65472, and the Step 1 check reads near 1023 for unscaled data or near 65472 for data a driver has already shifted. An 8-bit stream shifts left by 8, multiplying by 256 and taking 255 to 65280.
Does multiplying by 16 lose image information?
No. The 12-bit data has 4096 possible values and the shift maps them one-to-one into the 16-bit range: 0 stays 0 and 4095 becomes 65520 with every step preserved. A right shift by 4 restores the exact original numbers, so the archived file still carries the full measurement data. What loses information is clipping to 8 bits or saving lossy JPEG.
How do I get 12-bit frames from the camera in the first place?
Set the pixel format before capture. In GenICam the feature is PixelFormat, and you pick a 12-bit mono entry from the enumeration your SDK lists; packed variants squeeze two 12-bit pixels into three bytes. If the enumeration shows only 8-bit entries, that camera variant does not expose 12-bit output, and this scaling step has nothing to work on.



