System designTroubleshooting

cv_bridge 8UC1 vs mono8: fix grayscale image encoding errors

cv_bridge 8UC1 vs mono8 encoding: why the bytes match but the call fails, and the fixes — passthrough, driver relabel, bag rewrite without re-recording.

LumaSpec Vision9 min read Share on LinkedIn

On this page 8 sections
  1. 01Why does cv_bridge refuse an 8UC1 image when you ask for mono8?
  2. 02How do you confirm the bytes are plain grayscale?
  3. 03What is the difference between 8UC1 and mono8?
  4. 04How do you fix the conversion without touching the driver?
  5. 05How do you publish mono8 at the source instead?
  6. 06How do you rewrite a recorded bag from 8UC1 to mono8?
  7. 07Which other encoding mismatches follow the same pattern?
  8. 08Questions

A depth-camera driver labels its grayscale fisheye stream 8UC1, your node calls imgmsg_to_cv2 with desired_encoding='mono8', and cv_bridge throws instead of handing over the frame. The image data is fine: 8UC1 and mono8 describe the same one-byte-per-pixel layout, so nothing needs re-recording and no camera setting changes. This page is for anyone wiring fisheye, IR or grayscale camera streams into ROS nodes. You will confirm the frame is plain grayscale with one print, repair the conversion with a single argument, relabel the publisher so every consumer works unchanged, and convert already-recorded bags without capturing them again.

Why does cv_bridge refuse an 8UC1 image when you ask for mono8?

cv_bridge converts only along paths it has registered, and no path exists from the generic 8UC1 label to mono8, because silently re-branding arbitrary 8-bit data — masks, class maps, depth flags — as brightness would corrupt images that are not photographs.

The failing call is the one most examples show:

import cv_bridge

bridge = cv_bridge.CvBridge()
gray = bridge.imgmsg_to_cv2(msg, desired_encoding='mono8')  # msg.encoding == '8UC1' -> raises

cv_bridge checks the source label before it touches a single byte, finds no registered conversion from 8UC1 to mono8 and raises its conversion error. Width, height, step and the byte buffer are all intact; the refusal is a label check, not a data problem.

Rank the causes before you change anything:

  1. The publisher uses 8UC1 for an intensity stream. The usual case: drivers that wrap a camera SDK fill Image.encoding with the generic 8UC1 for every single-channel uint8 buffer, fisheye and IR included. Test: print msg.encoding in the callback.
  2. The topic is not grayscale at all. Depth arrives as 16UC1 or 32FC1, and no relabel turns distance into brightness. Test: the same print shows 16UC1, so stop and scale instead.
  3. The string is unrecognized. A driver-specific label such as intensity, or a capital in Mono8 where mono8 is expected: cv_bridge matches strings exactly and rejects anything outside its list.

Cause 1 needs the fixes below; causes 2 and 3 are covered in the last section.

How do you confirm the bytes are plain grayscale?

Print the encoding, the width and the height, then compare len(msg.data) with width × height × 1: on a 640 × 480 frame that is 640 × 480 × 1 = 307,200 bytes, and an exact match proves a single 8-bit channel that only needs a better label.

# inside your Image callback
print(msg.encoding, msg.width, msg.height, len(msg.data))
# expected on the running example:  8UC1 640 480 307200
bytes_per_frame = width × height × channels × bytes_per_pixel bytes_per_row (the Image step) = width × channels × bytes_per_pixel

For mono8 and 8UC1, channels is 1 and bytes_per_pixel is 1, so the 640 × 480 example holds 640 × 480 × 1 = 307,200 bytes and every row is 640 bytes long. An exact match confirms one plain 8-bit channel, and the fixes below apply. Any other total means the topic carries 16-bit depth, three color channels or packed data, where a relabel cannot help.

The split is the same one behind image bytes vs pixel data: the buffer describes layout, the encoding string describes meaning.

What is the difference between 8UC1 and mono8?

Nothing separates 8UC1 from mono8 at the byte level: both store one unsigned 8-bit value per pixel with no padding, so the example frame occupies 307,200 bytes either way; mono8 simply adds the promise that the values are brightness.

Encoding Channels Bytes per pixel What the label promises Typical sources
mono8 1 1 monochrome intensity, values 0 to 255 grayscale area scan cameras, IR and fisheye streams
8UC1 1 1 one uint8, meaning left open masks, class label maps, generic SDK bridges
16UC1 1 2 16-bit unsigned, usually depth in millimeters depth cameras
bgr8 3 3 8-bit blue, green, red color color cameras

The label decides consumer behavior. Viewers, histogram and threshold routines and auto-exposure logic branch on intensity; a mask mislabeled mono8 pushes class IDs through brightness code and skews every statistic downstream. Matchers are stricter still: a template matcher expecting one bit depth refuses the array outright, as in the template matching bit-depth fix.

How do you fix the conversion without touching the driver?

Call imgmsg_to_cv2 without the second argument, or with desired_encoding set to passthrough: both return the frame exactly as stored — a single-channel uint8 NumPy array — with no copy and no conversion, which is why this is the one-line fix.

import numpy as np
import cv_bridge

bridge = cv_bridge.CvBridge()
# msg: sensor_msgs/Image, encoding '8UC1', example frame 640 x 480
gray = bridge.imgmsg_to_cv2(msg, desired_encoding='passthrough')
# gray: numpy.ndarray, shape (480, 640), dtype uint8
assert gray.dtype == np.uint8 and gray.ndim == 2
assert gray.shape == (msg.height, msg.width)

Both forms wrap the existing buffer and skip the conversion table, so the asserts pass: dtype uint8, two dimensions, shape (480, 640). cv2.imshow, cv2.threshold and NumPy statistics accept the result directly, and an np.array(...) wrapper adds nothing because imgmsg_to_cv2 already returns a NumPy array.

The saving shows at stream rate: the example frame carries 307,200 B, so 30 fps moves 30 × 307,200 B ≈ 9.2 MB per second, while converting to bgr8 in the bridge costs 3 × 307,200 × 30 ≈ 27.6 MB per second plus a copy per frame. If the next stage needs three channels, convert after passthrough — the FAQ shows the call — and if you publish arrays back onto a topic, cut the copies with the cv2_to_imgmsg guide.

How do you publish mono8 at the source instead?

Set Image.encoding to mono8 wherever the driver builds the message: it is a one-string change that leaves every byte untouched, and consumers that request mono8 work from the first frame afterwards.

Wherever the driver builds the message — usually one assignment per stream in its fill function — write mono8 for every buffer that holds brightness. Fisheye and IR streams from depth cameras qualify; depth itself stays 16UC1 or 32FC1; masks and class label maps stay 8UC1. Pixels, timestamps and frame rate are untouched, so nothing that already reads the bytes changes behavior.

Industrial cameras already follow the convention: an 8-bit grayscale area scan camera lists Mono8 among its pixel formats over GigE Vision and USB3 Vision, so a well-formed driver publishes mono8 with no extra work. The 3D sensors category covers the depth and stereo side of a mixed rig, and the camera finder filters by interface, resolution and shutter when you choose the hardware.

How do you rewrite a recorded bag from 8UC1 to mono8?

Rewrite the file offline instead of recording again: read each Image message, change the encoding string from 8UC1 to mono8, and write a new bag — the pixel bytes stay identical, so the example frame still occupies 307,200 bytes.

import rosbag

src = rosbag.Bag('fisheye_8uc1.bag')        # input, opened read-only
out = rosbag.Bag('fisheye_mono8.bag', 'w')  # output, created fresh
for topic, msg, t in src.read_messages():
    # topic: str, msg: sensor_msgs/Image or other types, t: rosbag time
    if getattr(msg, 'encoding', '') == '8UC1':
        msg.encoding = 'mono8'
    out.write(topic, msg, t)
out.close()
src.close()

The getattr guard leaves every non-Image topic untouched, and bytes per frame do not change, so the rewritten bag matches the original in size and frame count with timestamps carried over.

Prove it in one minute: play the new bag under the original topic name, run the call that failed in the first section, and bridge.imgmsg_to_cv2(msg, desired_encoding='mono8') now returns the (480, 640) uint8 array. If you cannot touch the files, replay the original bag and run a subscriber that sets msg.encoding = 'mono8' and republishes on a second topic — same effect, nothing new stored.

Which other encoding mismatches follow the same pattern?

Sort every encoding failure of this family with two questions — how many bytes per pixel, and what do the values mean — because each mismatch in the table below is fixed by a relabel, a scaling step or a format conversion, never by re-recording.

What the stream is Label you see Right move
grayscale intensity, 1 byte per pixel 8UC1 passthrough now, then relabel the publisher to mono8
depth, 2 bytes per pixel 16UC1 or 32FC1 keep 16-bit or scale to 8-bit; never relabel
color that arrives grayscale mono8 request the Bayer encoding from the driver
vendor label the bridge rejects for example intensity normalize to mono8 in the driver or a small republisher

Depth is the common second case: 16UC1 depth usually carries distance in millimeters, not brightness, so the fix is scaling or keeping the full bit depth — the 16UC1 depth display fix walks through the conversion errors on that side. Color cameras fail the opposite way: the driver hands out mono8 because nobody requested the Bayer pattern, which the mono8 fallback fix repairs at the request. Sensors with 12-bit data need unpacking and storage that preserves the range rather than a label change — the 12-bit image saving guide covers that path.

Prevention is an agreement rather than a setting: publish mono8 for every intensity stream, keep 8UC1 for masks and label maps, normalize vendor strings at the driver, and let generic glue code read with passthrough. More fixes in this family sit on the systems topic page.

If the stream comes from an industrial camera rather than a depth SDK, pick the hardware once and clean encodings follow: the camera configurator walks interface, resolution and shutter in order, and the contact page is where to ask us to match a camera to an existing ROS pipeline.

Questions

Does changing 8UC1 to mono8 alter pixel values?

No. Both encodings store one unsigned 8-bit value per pixel, so the bytes carry across unchanged and a 640 × 480 frame stays 307,200 bytes. Histograms, thresholds and displayed contrast are identical afterwards; only the label changes, and with it the behavior of consumers that branch on intensity.

Can I ask cv_bridge for bgr8 directly from an 8UC1 topic?

No. There is no registered 8UC1 to bgr8 path either, so the same refusal fires. Read the frame with passthrough, then convert yourself: cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) returns three channels at 3 × 307,200 = 921,600 bytes for the example frame, so convert only when the next stage truly needs color.

What encoding should a 16-bit grayscale camera stream use?

mono16 is the standard label for 16-bit intensity, the same convention mono8 follows at 8 bits. Keep the full bit depth in the message instead of squeezing values into 8 bits, and keep 8UC1 for masks and label maps, where the numbers are classes rather than brightness.

Does the same fix apply to ROS 2 nodes?

The bridge side does: cv_bridge in ROS 2 uses the same encoding strings and the same desired_encoding argument, and passthrough returns the single-channel uint8 array unchanged. Recorded data differs, because ROS 2 stores bags in the rosbag2 format rather than the rosbag module's files, so rewrite or republish with rosbag2 tooling.

Why would a driver publish 8UC1 in the first place?

Because the message specification allows it: 8UC1 is a valid encoding for any single-channel uint8 buffer, and SDK bridge drivers apply it uniformly without deciding what the values mean. It is not broken, only less specific — which is why maintainers accept mono8 as the better label for intensity streams.