3D visionTroubleshooting

Color stream fps lower than configured on a depth camera

A color stream at 14-15 Hz, far lower than the configured 60 fps, points at a host cap - MJPG decode, USB scheduling or a slow subscriber - not the camera.

LumaSpec Vision11 min read Share on LinkedIn

On this page 11 sections
  1. 01What the measurement already tells you
  2. 02The four caps, ranked by probability
  3. 03Test 1: measure with a no-op subscriber
  4. 04Test 2: run the color stream alone
  5. 05Test 3: name the decode cost per core
  6. 06Worked example: 1280 × 800 MJPG at 60 fps plus 848 × 480 depth at 30 fps
  7. 07Fixes that hold, in order
  8. 08When it is the camera after all
  9. 09Prevent it on the next install
  10. 10Next step
  11. 11Questions

You set the color stream to 60 fps, launched the depth camera, and ros2 topic hz reports 14-15 Hz on the color topic. The gap is not noise and, in most cases, not a fault in the camera: it is a host-side cap arriving at your subscriber. This guide is for teams running a USB 3.x depth camera under ROS 2 with color and depth streaming together. You will read the burst pattern in your own measurement, rank the four caps that produce it, run three tests that isolate them, and land on a launch configuration that either holds 60 fps or settles cleanly at 30.

What the measurement already tells you

ros2 topic hz /camera/color/image_raw measures arrivals at the receiving end, after USB transport, JPEG decode and DDS delivery. A run with this fault shows a signature that narrows the cause before you touch anything:

  • The average rate decayed from 21.3 Hz in the first window to a steady 14-15 Hz. That is warm-up: queues fill, then the pipeline settles into its true steady state.
  • The shortest gap stayed at 0.004 s while the average interval sat near 68 ms. Frames arrive back-to-back in bursts, not paced at the 16.7 ms period a 60 fps sensor produces.
  • The longest gap grew from 0.133 s to 0.269 s and on to 0.600 s, with a standard deviation near 0.05 s. Stalls that lengthen over a run are queue starvation, not sensor pacing.

A camera capped at a genuine 30 or 15 fps delivers steady intervals at 33.3 ms or 66.7 ms. Burst-then-stall points upstream of the sensor, into decode, transport or delivery. That is the good news: those caps sit on your side of the cable, and every one of them is fixable in configuration.

The four caps, ranked by probability

  1. MJPG decode on the host. At this profile the color camera ships MJPG, and frames reach the raw image topic as decoded RGB, so the decode runs on host CPU inside the driver. Its cost scales with pixels × fps, and the worked example below puts a number on it: 184 MB/s at this resolution, a full core.
  2. The measurement and the subscriber. ros2 topic hz is itself a subscriber with executor work of its own, and it can under-report for multi-megabyte messages. A slow consumer node lands in the same bucket: frames delivered late count as a low rate.
  3. USB controller contention and stream pairing. The log confirms a USB3.x link, and the wire budget below shows the link is far from saturated. What bites is scheduling and pairing: frame sync between a 60 fps color stream and a 30 fps depth stream leaves half the color frames without a partner, and a shared hub or a second camera on the same controller stalls both. The signature matches USB hub drops camera frame rate: budget the bandwidth first, and the same mechanism is what makes a USB camera miss frames on a constrained host.
  4. Exposure. Auto-exposure in dim light stretches beyond the 16.7 ms a 60 fps stream allows and caps the sensor itself. It is a 30-second check, covered in depth camera frame rate drops: exposure settings cap the FPS.

Work down the list in this order; each test below rules one cap in or out.

Test 1: measure with a no-op subscriber

Before changing a launch argument, find out whether frames are late at delivery time or missing at capture time. Run this minimal counter next to your pipeline:

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image

class FpsCounter(Node):
    def __init__(self):
        super().__init__('fps_counter')            # illustrative node name, yours to choose
        self.count = 0                              # int, frames since the last report
        self.create_subscription(Image, '/camera/color/image_raw', self.on_image, 10)
        self.create_timer(1.0, self.report)         # report every 1.0 s

    def on_image(self, msg: Image) -> None:
        self.count += 1

    def report(self) -> None:
        self.get_logger().info(f'{self.count} frames in the last second')
        self.count = 0

def main() -> None:
    rclpy.init()
    rclpy.spin(FpsCounter())

Run the counter and ros2 topic hz side by side on the same launch, and read the driver's diagnostics too, which this driver family publishes once per second. Three outcomes, three decisions:

  • Counter near 60, hz near 14. Capture is fine; the measurement or delivery path is the cap. Raise the subscriber queue depth, move heavy work out of callbacks, or monitor over a compressed transport topic instead of raw RGB.
  • Counter and diagnostics both near 14. Frames are dropped before delivery. Go to Test 2.
  • Counter matches hz, diagnostics report the full rate. The drop sits between the driver and your process: DDS copies, CPU contention or executor backlog.

This one test removes the most expensive wrong turn in the whole fault: replacing a camera whose frames never left it.

Test 2: run the color stream alone

Launch with only the color stream enabled at 60 fps - no depth, no point cloud - and re-measure with the counter.

  • Color alone reaches ~60 Hz. The concurrent depth stream is involved. Move the camera to a port on the root controller, confirm the log still negotiates USB3.x, and re-test both streams together. If pairing still drags the rate, re-test once with frame sync disabled: if 60 fps recovers, the 60/30 mismatch was holding frames, and matching both streams at 30 is the correct configuration rather than a concession. Alignment between color and depth adds host work as well, and resolution mismatches make it worse - see depth to color alignment breaks up: match stream resolution first.
  • Color alone still sits near 14 Hz. The cap is decode (Test 3) or, rarely, the device itself.

On multi-camera rigs, bring cameras up one at a time, as in three depth cameras drop to low FPS: test one stream at a time. Two cameras on one controller split the scheduling budget before a single frame is spent.

Test 3: name the decode cost per core

Open a per-core monitor such as htop while the 60 fps configuration runs. One thread pegged near 100% of a single core inside the driver process is the decode thread, and at this resolution it has no headroom - the arithmetic is in the next section. If no core pegs and the counter still reads low, look at scheduling and delivery, not decode.

Decode is also the one cost that cannot be pushed onto the link: it grows linearly with fps and stays on the CPU. When a processing chain stacks a point cloud or filters on top, the same cores take the penalty, as in pointcloud filter slows depth camera FPS: fix the CPU load.

Worked example: 1280 × 800 MJPG at 60 fps plus 848 × 480 depth at 30 fps

Inputs, each taken from the launch configuration that produced the symptom or from the link class:

  • Color stream: 1280 × 800 px, 60 fps, MJPG - the launch arguments in the log.
  • Depth stream: 848 × 480 px, 30 fps, 16-bit depth at 2 bytes per pixel - the same log.
  • Link: USB3.x at the 5 Gbit/s line-rate class; 8b/10b coding leaves about 4 Gbit/s, and 3.5 Gbit/s is the planning number (USB 3.0 coding overhead).
  • Decode constant: 3 bytes per RGB pixel after decode; MJPG compresses typically 5:1 to 15:1, and 10:1 is used here (an estimate; the ratio moves with scene detail).

The rule in words: the wire carries bytes per frame × frames per second for every stream combined, while the decode stage produces 3 bytes per color pixel × the color frame rate, all on CPU cores.

color raw = 1280 px × 800 px × 2 B/px = 2,048,000 B/frame color raw rate = 2,048,000 B/frame × 60 fps = 122.9 MB/s ≈ 983 Mbit/s color on wire = 122.9 MB/s ÷ 10 (MJPG at 10:1) ≈ 12.3 MB/s ≈ 98 Mbit/s depth rate = 848 px × 480 px × 2 B/px × 30 fps = 24.4 MB/s ≈ 195 Mbit/s wire total ≈ 36.7 MB/s ≈ 294 Mbit/s decode output = 1280 px × 800 px × 3 B/px × 60 fps = 184 MB/s

Result: both streams together claim about 294 Mbit/s, under a tenth of the 3.5 Gbit/s budget, while decode must produce 184 MB/s of RGB every second.

Decision, with thresholds: the link is not the cap - 294 Mbit/s leaves roughly a tenfold margin, so no port, cable or controller swap raises the measured rate on its own. The decode stage is the cap: SIMD-accelerated JPEG decoders typically manage 150-250 MB/s of RGB output per modern desktop core, so 184 MB/s spends a full core before DDS serialization and the subscriber take their share of the same silicon. Queues starve, then release in bursts - the 14-15 Hz in the log. Keep decode output under about 100 MB/s for a monitoring pipeline: color at 30 fps produces 92 MB/s and fits. Keep the summed wire rate under about 3 Gbit/s per host controller before adding a second camera.

Fixes that hold, in order

  1. Parity first. Match the color rate to depth: in this stack, relaunch with color_fps:=30. Decode output halves to 92 MB/s, delivery steadies at 30 Hz with matched pairs, and nothing is lost unless a consumer genuinely needs 60.
  2. If 60 fps color is required, keep MJPG and give decode room: pin the driver container to its own cores, and enable intra-process transport when the consumer lives in the same composable container, which removes DDS copies. Hardware JPEG decode, where the host offers it, removes the load entirely.
  3. For monitoring and RViz, watch a compressed transport topic and leave raw RGB to the algorithm that needs it; the same logic governs troubleshooting an MJPEG stream that delivers below its configured rate on video walls.
  4. Transport hygiene. One camera per root controller, no hub in the capture path, and the negotiated USB3.x class checked in the log at install; depth and color stream timeouts: the USB bandwidth check walks that budget.
  5. Re-verify with the counter, not with topic hz alone. Accept the configuration when the counter and the real consumer agree at the configured rate for a full minute.

When it is the camera after all

Three findings together point away from the host: color alone at 60 fps still measures low with the no-op counter, the driver's own diagnostics report capture-side drops, and a second host reproduces it. Align the SDK and firmware versions first, because drift between them causes exactly this kind of silent rate loss - 3D camera header version mismatch: fix firmware and SDK alignment. If versions match and the rate still falls short on the second host, the device is the suspect, and the general sequence in camera not reaching max frame rate: find the bottleneck closes it out. Keep the exposure check from cap 4 in your back pocket for dim cells; it masquerades as every other cap on this list.

Prevent it on the next install

  • Budget the wire before launch: bytes per frame × fps, summed over every stream and camera on the controller, kept under ~3 Gbit/s per USB3 controller.
  • Match color fps to depth fps unless a consumer needs more; unmatched rates cost sync pairs and double the decode load.
  • Compute the decode budget as pixels × fps × 3 bytes and keep it under ~100 MB/s per monitoring pipeline.
  • Give each camera its own root controller; hubs belong on power lines, not in the capture path.
  • Log the negotiated link class at install: a USB2 fallback caps the whole budget near 480 Mbit/s and looks exactly like this fault.
  • Accept rates from the counter, never from a single ros2 topic hz run.

Next step

If the numbers say your host is the cap, change the launch arguments and re-run the counter - no hardware needed. If you are specifying a depth camera for a new ROS 2 cell instead, start from the 3D sensors category, filter by interface and resolution in the camera finder, or walk the camera configurator. For structured-light depth in a fixed workcell, the MV-DPS200P-02 below is a projected structured-light 3D camera. Placement questions belong to the 3D camera working distance and positioning guide, the rest of the 3D series lives on the 3D camera topic page, and the guides hub indexes everything else. For a configured recommendation, contact the team.

Cameras3D sensors

Laser profilers and stereo 3D cameras

170 models · from $699.00
3D sensorsHikrobot MV-DPS200P-02 Projected structured-light 3D cameraMV-DPS200P-02
Type
Stereo / structured light
$8,829.00 · quotedView and quote

Questions

Why does ros2 topic hz report a lower rate than the camera sends?

The tool is itself a subscriber: it receives and deserializes every image on the same cores as your pipeline, and for multi-megabyte frames this work stretches the measurement window, so the reported rate lands low. Treat it as a lower bound, never as the camera's rate. Cross-check with a no-op counter node and the driver's diagnostics before concluding that frames are missing.

Can two depth cameras share one USB 3 controller?

In wire terms, easily: two cameras at this profile carry about 590 Mbit/s combined, well inside one USB3 controller's roughly 3.5 Gbit/s. The catch is decode, which doubles to roughly 370 MB/s and saturates two cores, plus scheduling, which stalls when endpoints share one root port. Give each camera its own controller and budget the CPU before the link.

Should I stream raw color instead of MJPG to raise the fps?

Only when cores are scarce and the link is not. Raw color at 1280 × 800 costs 123 MB/s on the wire against roughly 12 MB/s for MJPG, but it deletes the 184 MB/s decode stage entirely. Stream raw when the decode thread pegs a core and the link has headroom; stay with MJPG when the wire or the number of cameras is the constraint.

Why does the measured rate decay from 21 Hz to a steady 14-15 Hz?

The first windows are warm-up: counting starts before the pipeline settles, queues are still filling and the executor is ramping. The average decays from 21.3 Hz toward its steady 14-15 Hz as those effects wash out. Always judge the last window after a minute of running, and compare it with the counter node, not the first average the tool prints.

What bandwidth does a 1280 × 800 MJPG color stream at 60 fps need on the wire?

With typical MJPG compression of 5:1 to 15:1, expect roughly 8 to 25 MB/s, about 65 to 200 Mbit/s - any USB3 port carries it with room to spare. That is exactly why the port is rarely the cap at this profile: the decode stage at 184 MB/s is the binding constraint, so size cores, not cable specs, when you configure 60 fps color.