System designHow-to

Save 12-bit camera images as 16-bit PNG or TIFF without a black image

Save 12-bit camera images as 16-bit PNG or TIFF without a black frame: the ×16 shift rule, NumPy and OpenCV code, and a check that no value is lost.

LumaSpec Vision8 min read Share on LinkedIn

On this page 10 sections
  1. 01Why the saved image is almost black
  2. 02The scaling rule: multiply by 16
  3. 03Step 1 — check what the frame contains
  4. 04Steps 2 to 5 — scale, save and verify
  5. 05Worked example: one row of pixels
  6. 06Mistakes that destroy 12-bit data
  7. 07Cameras that deliver 12-bit frames
  8. 08Checklist before you archive
  9. 09Choose a camera that outputs 12-bit
  10. 10Questions

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.

value_16bit = value_12bit × 16 (shift left by 4) shift = 16 - bit_depth (4 for 12-bit, 6 for 10-bit, 8 for 8-bit)

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()))

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.

  1. Confirm the frame is unscaled 12-bit: dtype uint16, maximum 4095 or less (Step 1).
  2. Shift left by 4: scaled = np.left_shift(frame, 4). The result stays uint16, and 4095 × 16 = 65520 fits without overflow. frame * 16 gives identical numbers; the shift just makes the intent unmistakable.
  3. Save a TIFF: tifffile.imwrite("frame_16bit.tif", scaled) writes a true 16-bit grayscale TIFF, the safest archive format for measurement images.
  4. 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.
  5. 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) or frame / frame.max() * 65535 rescales 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:

Area scan camerasHikrobot MV-CS200-10UM 20 MP Area Scan Camera, USB3.0, IMX183, 2nd Gen., MonoMV-CS200-10UM
Sensor format
1"
Lens mount
C-mount
$759.00View part
Area scan camerasHikrobot MV-CT0MU0060/R/NN/NN 6 MP Area Scan Camera, 3rd Gen., USB3.0, Mono, BASEMV-CT0MU0060/R/NN/NN
Sensor format
1/1.8"
Lens mount
C-mount
$969.00View part
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
$560.00View part

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:

Board-level cameras3.1 MP USB3 board-level camera, IMX252, 1/1.8" global shutter, 120 fps (LS-BCM-0003)LS-BCM-0003
Sensor format
1/1.8"
Lens mount
C-mount
Interface
USB3
Resolution
5 MP
$690.00View partBuild a system with it

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 >> 4 on 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.

Build the whole station

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.

Start with the camera

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.