Cameras & sensorsHow-to

Thermal grayscale to pseudocolor: map 16-bit counts to a palette

Why your thermal camera arrives as a grayscale Mono16 stream, and how to write the pseudocolor conversion: scale 16-bit counts to a 256-entry RGB palette.

LumaSpec Vision9 min read Share on LinkedIn

On this page 9 sections
  1. 01Why the stream is black and white
  2. 02What you need
  3. 03Choose low and high before you write code
  4. 04The conversion, step by step
  5. 05Worked example: counts to index to color
  6. 06Verify the output
  7. 07Common mistakes
  8. 08Cameras that deliver raw counts
  9. 09Questions

A machine-vision thermal camera does not output the familiar rainbow picture. It hands you a monochrome stream of radiometric counts, one 16-bit number per pixel, and every palette-less viewer shows it as grayscale. The color is a lookup table applied after capture, and on an industrial camera that table is your job. This guide gives you the mapping formula, a worked example from counts to index to color, the test that proves the conversion, and the mistakes that make thermal images lie.

Why the stream is black and white

A thermal sensor measures radiated energy and reports it as a count, one number per pixel. Machine-vision drivers carry those counts in a monochrome pixel format, most commonly Mono16, a value of the standard GenICam PixelFormat feature under the ImageFormatControl group. The camera sends no color at all. Hand-held imagers apply their palette in their own display firmware; an industrial camera leaves that step to your software.

The numbers are easy to check. A 640 × 512 frame holds 327,680 pixels, so a Mono16 stream moves 655,360 bytes per frame, 32.8 MB/s at 50 Hz, or 262 Mbit/s: about a quarter of a GigE link. The monochrome stream is deliberately lean. A palette triples the per-pixel payload for display only, which is why no camera ships it over the wire.

If your viewer shows color, it painted the palette on its side of the link. If it shows gray, nothing is broken: you are looking at the real data. Check the camera's node list once; with no palette or color feature there, the conversion is yours to write. The same gray-image surprise has a second common cause on depth cameras, where the RGB channel is showing the IR stream.

What you need

  • The counts at full depth: set the pixel format to Mono16 before capture. On a GigE Vision or USB3 Vision camera this is the PixelFormat enum in ImageFormatControl; a ROS 2 driver config sets the same node through its pixel_format parameter.
  • A recorded frame of the real scene, to fix the low and high counts.
  • A palette as a 256 × 3 uint8 array of RGB rows, built from color stops: ironbow or rainbow.
  • Any image library that does per-pixel array indexing; the code below uses NumPy.
  • A second channel that keeps the counts untouched: a recorded file, a topic, or a CSV dump as in displaying raw pixel arrays from thermal CSV data.

Choose low and high before you write code

One decision drives the whole mapping: the two counts that render palette row 0 and row 255. Fix them per process, not per frame. Fixed limits render the same count as the same color in every frame, on every camera and every shift; per-frame auto-scaling re-renders the same temperature as a different color whenever the scene histogram moves.

Take the limits from the process when one exists: a line running from ambient to an alarm temperature gets the counts at those two points. Without a process spec, take the 1st and 99th percentile of a recorded scene histogram; that saturates 1 percent of pixels at each end and keeps full contrast in the middle. Write both values into the recording metadata so the colors stay interpretable later.

index = round(255 × (count - low) / (high - low)), clamped to 0-255

Each color step spans (high - low) / 255 counts. With the worked example's limits that is 8192 / 255 ≈ 32 counts per step, and that is the display resolution; the measurement resolution stays the sensor's full range.

The conversion, step by step

  1. Set the format. Configure the camera to Mono16 through PixelFormat under ImageFormatControl and confirm the arriving frames are single-channel 16-bit. If the driver also offers Mono8, leave it alone here: 256 gray levels give away the range the palette needs.
  2. Set the limits. Record one frame of the real scene, read its histogram, and fix low and high from the process or the 1st and 99th percentiles. Store both as plain integers; the worked example below uses 8192 and 16384.
  3. Build the palette. Pick color stops for the look you want and interpolate between them to fill 256 rows. Ironbow moves from black through deep blue on the cold end, up through magenta, orange and yellow, ending at white. Rainbow runs blue, green, yellow, red: it separates neighboring bands more sharply but adds false edges and banding. Store the table as a 256 × 3 uint8 array, row 0 coldest.
  4. Scale, clamp, index. Apply the formula above to every pixel, then look the color up. NumPy rounds halves to even, so the 127.5 tie in the worked example lands on 128.
import numpy as np

def counts_to_rgb(counts, low, high, palette):
    # counts: (rows, cols) uint16 from the Mono16 stream
    # low, high: ints, the limits from step 2
    # palette: (256, 3) uint8, row 0 = coldest, row 255 = hottest
    scaled = np.round((counts.astype(np.float64) - low) * 255.0 / (high - low))
    index = np.clip(scaled, 0, 255).astype(np.uint8)
    return palette[index]  # (rows, cols, 3) uint8, publish as RGB8
  1. Publish both streams. Keep the Mono16 frames on the measurement path exactly as captured, and put the RGB8 image on a display-only path with its limits in the message. When the counts leave the pipeline for files, keep them intact as in exporting high bitdepth grayscale images.
  2. Overlay when it helps. For operator screens, fuse the pseudocolor onto a visible-light frame while the counts stay underneath, as in thermal RGB image fusion without losing temperature data.

Worked example: counts to index to color

Take low = 8192 and high = 16384, two round values you would read off a scene histogram after the camera warms up; the span is 8192 counts. A pixel at 12,288 counts sits halfway up: 255 × (12288 - 8192) / (16384 - 8192) = 255 × 4096 / 8192 = 127.5, which rounds to index 128 and renders the red-magenta band of an ironbow table.

Count 255 × (count - 8192) / 8192 Index Ironbow region
7,000 below 0, clamped 0 black
8,192 0.00 0 black
10,240 63.75 64 blue-violet
12,288 127.50 128 red-magenta
14,336 191.25 191 orange-yellow
16,384 255.00 255 white

Read back the other way: index 128 covers counts from about 12,288 to 12,320, so a color identifies its count only to about ±16 counts around 12,304. That rounding loss is exactly why the counts, not the colors, stay in the measurement path. The temperature meaning of any count comes from your counts-to-temperature calibration; the palette only knows where a count sat between low and high.

Verify the output

Three checks take minutes and catch every bug this converter can hide.

  • Constant-value test: feed a frame that is nothing but low; every output pixel must equal palette row 0. Repeat with high, and with a value 1,000 counts below low. The low frame and the below-low frame must come out identical, which proves the clamp.
  • Ramp test: put a ramp from low to high across one row. The output row must step through the palette in order, with no repeats or jumps.
  • Round trip: recover count = low + index × (high - low) / 255 from the index image. For the worked example's limits every recovered value must sit within about ±16 counts of the original.
# stand-in palette: black at row 0, white at row 255
pal = np.zeros((256, 3), dtype=np.uint8)
pal[255] = (255, 255, 255)

f_low = np.full((4, 4), 8192, dtype=np.uint16)
f_high = np.full((4, 4), 16384, dtype=np.uint16)
f_below = np.full((4, 4), 7000, dtype=np.uint16)

assert (counts_to_rgb(f_low, 8192, 16384, pal) == pal[0]).all()
assert (counts_to_rgb(f_high, 8192, 16384, pal) == pal[255]).all()
assert (counts_to_rgb(f_below, 8192, 16384, pal) == pal[0]).all()

On the live stream, watch the index histogram instead of the picture: a hot object entering the frame pushes it toward 255; a histogram piled at both ends means the limits are too tight, a histogram stuck mid-scale means they are too wide.

Common mistakes

  • Auto-scaling every frame. The picture always looks contrasty and never means the same thing twice; a recording made this way shows temperature drift that is only histogram movement. Fix the limits.
  • Indexing without the clamp. Counts below low give a negative scaled value, and casting that straight to uint8 wraps it to a large index that paints the coldest pixel the hottest color.
  • Down-converting to 8 bits first. Compressing counts to Mono8 before the lookup keeps 256 levels out of a 65,536-count container. Do the palette math at 16 bits and discard the intermediate, not the source.
  • Keeping only the colored image. If a colored file is all that was saved, values must be recovered pixel by pixel, which mapping thermal image colors back to temperature explains; keeping the counts avoids the whole problem.
  • Sending colorized frames without their colormap. A reader without the palette sees arbitrary colors; when a colored image does go out, embed the colormap as in saving a thermal colormap TIFF.

Cameras that deliver raw counts

Every industrial thermal camera on a standard vision interface behaves this way: counts in a monochrome format, palette on your side. The shop's infrared cameras cover LWIR and SWIR area scan models, the camera finder filters them by interface and spectrum, and the cameras topic page collects the thermal guides around this one.

Three LWIR choices cover most benches. The LS-IRC-0044 streams 640 × 512 counts at 50 Hz over GigE in the 8-14 μm band. The LS-IRC-0016 module carries the same 640 × 512 resolution over USB3 for compact builds. The LS-IRC-0026 is a 0.5 MP thermal camera on GigE.

Infrared cameras640 x 512 thermal camera, 8-14 μm, 50 Hz, GigE, FOV 88.5° × 73.2°, 9-24 V DC (LS-IRC-0044)LS-IRC-0044
Lens mount
C-mount
Interface
GigE
$5,885.00 · quotedView and quote
Infrared camerasLWIR thermal camera module, USB3, 640×512 resolution (LS-IRC-0016)LS-IRC-0016
Interface
USB3
$3,999.00 · quotedView and quote
Infrared camerasThermal imaging camera, 0.5 MP, GigE (LS-IRC-0026)LS-IRC-0026
Interface
GigE
Resolution
Up to 3 MP
$3,999.00 · quotedView and quote

The same conversion applies to SWIR, whose sensors are also monochrome. The LS-IRC-0029 pairs 0.3 MP resolution with 15 μm × 15 μm pixels and a global shutter on GigE.

Infrared camerasShort-wave infrared area scan camera, 0.3 MP, GigE, Mono, 15μm x 15μm pixels (LS-IRC-0029)LS-IRC-0029
Sensor format
1/1.2"
Lens mount
C-mount
Interface
GigE
Resolution
Up to 3 MP
$5,999.00 · quotedView and quote

Where a factory-fixed lens is acceptable, the Hikrobot MV-CI003-GL-N25 is a 0.3 MP LWIR camera on GigE with a 25 mm lens.

Infrared camerasHikrobot MV-CI003-GL-N25 0.3 MP LWIR Camera, GigE, EFL 25mmMV-CI003-GL-N25
Lens mount
M34x0.75
Interface
GigE
Resolution
Up to 3 MP
Price on requestView and quote

Set the format to Mono16, fix the limits, build the table, and the palette drops into any pipeline that can index an array. Start the configurator below at the camera step; for an unusual scene range or an SWIR variant, the contact page is the direct line.

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

Which pixel format should I request from a thermal camera?

Mono16 wherever the driver offers it. A 640 × 512 frame then carries one full count per pixel, and every threshold or calibration decision reads the sensor's whole range. Mono8 halves the bandwidth but keeps 256 levels, so a scene spanning thousands of counts collapses into visible banding, and your fixed low and high limits lose meaning if the format ever changes mid-project.

Does applying a palette change the measurement?

No. The lookup table only swaps each count for a display color; the Mono16 frames stay untouched on the measurement path as long as you keep publishing or recording them. Convert counts to temperature in a separate calibration step and alarm on those values. A pipeline that outputs only the colored image has thrown away precision the palette cannot restore.

Can the camera apply the palette in firmware?

Rarely on industrial models. Check the camera's node list in the SDK viewer: if there is no palette or color feature next to the image format controls, the camera sends counts only and the conversion belongs in your software, as the steps above show. Hand-held imagers colorize in their own firmware; some models with an RGB output send display pixels, not calibrated counts.

How often should the low and high limits change?

Fix them per process and change them only when the process range changes. Per-frame auto-scaling recolors the same temperature every time the histogram moves, so a recorded video appears to drift in temperature when nothing changed. Take limits from the process specification, for example ambient to alarm temperature, or from the 1st and 99th percentiles of a recorded scene, and note them in the recording.

Which palette reads best on a report?

Ironbow. It runs black through blue for cold, then magenta, orange and yellow into white for hot, matches what most thermal reports use, and its brightness rises smoothly with temperature, which hides banding. Rainbow separates neighboring bands more sharply but adds false edges. Grayscale white-hot works best when the image sits next to numeric tables, because brightness stays monotonic.