You have two cameras on a robot arm and one OpenCV window: show the front camera for five seconds, the bottom camera for the next five, and keep alternating without ever closing the window. The pattern almost everyone writes first — cv2.imshow inside each subscriber callback, a timer flipping two flags — plays one camera fine and then freezes on the first switch. This guide explains why the window sticks, which thread must own the drawing, and the two-timer fix that keeps both callbacks running while a single 10 Hz display timer repaints the window. Full node code, the numbers for display rate and switch latency, and the checks that prove both topics keep publishing are included. It is written for ROS 1 (rospy) with cv_bridge, but the one-owner-thread rule applies to any framework that delivers frames in callbacks.
Why does the window freeze on the first switch?
The freeze comes from two subscriber threads driving one HighGUI window: each callback calls cv2.imshow and cv2.waitKey itself, and when the 5-second flip silences the front thread, the window is no longer pumped by the thread that created it, and the bottom camera's cross-thread draws do not repaint it.
rospy delivers each subscriber's messages on its own thread, and in the failing pattern both callbacks create, resize, draw into and pump the same named window. While the front flag is true, that camera's thread calls cv2.imshow and cv2.waitKey(1) at frame rate and everything looks healthy. The timer flips both flags; the front callback now returns without drawing, so the thread that created the window stops pumping it. HighGUI carries no thread-safety guarantee, and the events that repaint, resize or close a window are processed inside waitKey — a window whose pumper went silent is a window that freezes.
Two minutes of testing separates the causes. Swap the starting flags so the bottom camera is enabled first: if it displays and the freeze returns at the first switch, both topics are healthy and the drawing pattern is at fault. If the window opens and never shows anything, the second topic is silent — work through the checks further down.
Two more defects ride along in the same code. The main thread's while-loop prints both flags as fast as the CPU allows, flooding the terminal and pinning a core, and every callback rebuilds a CvBridge and re-calls cv2.namedWindow and cv2.resizeWindow on every frame.
Which thread should own cv2.imshow and cv2.waitKey?
Exactly one thread — the display timer's callback thread — must make every HighGUI call (cv2.namedWindow, cv2.resizeWindow, cv2.imshow, cv2.waitKey, cv2.destroyAllWindows), while both subscriber callbacks only store the latest converted frame.
rospy runs each timer's callback in a dedicated thread, so a display timer hands you that owner for free: it fires at its own period whatever the cameras do, and the subscriber threads shrink to convert-and-store. The 5-second switch timer also gets its own thread and only flips one boolean — a single assignment is atomic under CPython's global interpreter lock, so three threads reading and writing show_front is safe.
This is the same rule behind GUI freezes during image capture, where the fix is likewise to keep blocking work off the thread that draws. The mirror-image failure, a crash from unsafe state sharing inside a frame callback, appears when several callbacks mutate shared data without a plan.
What does the fixed switching node look like?
A class with two subscriber callbacks that at most convert and store one frame, a 5.0-second switch timer, a 0.1-second display timer that does all the drawing, and rospy.spin() in place of the busy print loop.
import cv2
import rospy
from cv_bridge import CvBridge, CvBridgeError
from sensor_msgs.msg import Image
class CameraSwitchNode:
def __init__(self):
self.bridge = CvBridge()
self.window_name = "CAMERA"
self.window_ready = False
self.latest_front = None
self.latest_bottom = None
self.show_front = True
self.front_count = 0
self.bottom_count = 0
rospy.Subscriber("/front_camera/image_raw", Image,
self.front_callback, queue_size=1)
rospy.Subscriber("/bottom_camera/image_raw", Image,
self.bottom_callback, queue_size=1)
rospy.Timer(rospy.Duration(5.0), self.switch_callback)
rospy.Timer(rospy.Duration(0.1), self.display_callback)
def front_callback(self, msg):
self.front_count += 1
if not self.show_front:
return
try:
self.latest_front = self.bridge.imgmsg_to_cv2(
msg, desired_encoding="bgr8")
except CvBridgeError as e:
rospy.logerr(e)
def bottom_callback(self, msg):
self.bottom_count += 1
if self.show_front:
return
try:
self.latest_bottom = self.bridge.imgmsg_to_cv2(
msg, desired_encoding="bgr8")
except CvBridgeError as e:
rospy.logerr(e)
def switch_callback(self, event):
self.show_front = not self.show_front
def display_callback(self, event):
frame = self.latest_front if self.show_front else self.latest_bottom
if frame is None:
return
if not self.window_ready:
cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
cv2.resizeWindow(self.window_name, 640, 480)
self.window_ready = True
cv2.imshow(self.window_name, frame)
if cv2.waitKey(1) == ord("q"):
rospy.signal_shutdown("q pressed")
if __name__ == "__main__":
rospy.init_node("camera_switch")
CameraSwitchNode()
rospy.spin()
cv2.destroyAllWindows()
The topic strings are placeholders — substitute your own. What moved where:
- The window is created inside the display callback, the first time it has a frame, so every HighGUI call in the process comes from one thread.
- Each subscriber converts only while its camera is on screen and stores one frame; queue_size=1 keeps the newest message and drops the backlog instead of replaying old frames — the same latest-frame-only idea as draining a buffer queue.
- rospy.spin() blocks the main thread instead of the print loop, and cv2.destroyAllWindows runs once at exit.
- The counters increment before the flag check, so they advance for both cameras even while one is hidden — that is the diagnostic used below.
How fast should the display timer run?
Run it at 10 Hz — rospy.Duration(0.1) — for a monitoring window, because an operator watching a switched feed loses nothing above roughly 10 refreshes per second, and the hidden camera's conversion is skipped anyway.
Worked example, a 30 fps camera and a 0.1 s period: the display rate is 1 / 0.1 s = 10 Hz, and each refresh covers 30 × 0.1 = 3 camera frames, so the window shows 10 of the 30 frames delivered. The subscriber still receives every message — only the repaint is throttled — so recording and processing downstream of the callbacks are untouched. After a switch, the new camera reaches the screen within 0.1 + 1/30 = 0.133 s at worst.
Decision: 0.1 s for a monitoring window; 0.033 s (30 Hz) only when someone must judge fast motion in the displayed feed. Below that you are redrawing images nobody can see.
Why convert only the camera you are showing?
Because the hidden camera's conversion is pure waste: a 640 × 480 bgr8 frame is 640 × 480 × 3 = 921,600 bytes, about 0.9 MB, and at 30 fps that is 921,600 × 30 ≈ 27.6 MB/s of pixel conversion per camera that nobody looks at.
In the fixed node the flag question moved from "should I draw" to "should I convert", and skipping the conversion is the cheaper skip. Convert both streams only when something else consumes the hidden one — a recorder, a detector — and budget the CPU for it. Right after a switch the incoming camera's slot is empty; the display callback skips ticks until its next message arrives, one frame period at 30 fps being 0.033 s, which no operator perceives.
How do I confirm both topics keep publishing?
Print a per-camera counter and check each topic's rate from the command line; if both counters advance while the window is stuck, the fault is in the display code, not the cameras.
Use the front_count and bottom_count already in the node and print both from the display callback while diagnosing — one line per second is enough. Three readings:
- Both counters advance and the window changes every 5 s: fixed. Let it run ten cycles, 50 s, before you close it out.
- Both counters advance but the picture sticks: something still draws from another thread; re-check the ownership rules above.
- One counter stalls: that topic is silent. Check the name character by character, check the driver, and read ROS multi camera topic names — namespaces are the usual culprit with two identical drivers.
If the conversion itself throws, the message encoding and your desired_encoding disagree; the mono8-versus-8UC1 mismatch is worked through in cv_bridge encoding errors.
Does running two cameras on one host change anything?
Yes: both cameras keep publishing during the whole cycle, so budget the link for two simultaneous streams — two 12 MP USB3 cameras at 30 fps need about 720 MB/s of raw pixels, which one USB3 controller cannot carry.
The arithmetic, assuming 8-bit pixels on the wire: 4000 × 3000 = 12,000,000 pixels per frame, × 1 byte = 12 MB, × 30 fps = 360 MB/s per camera, × 2 cameras = 720 MB/s. A USB3 controller delivers about 400 MB/s of usable payload (5 Gbps of line rate minus 8b/10b encoding and protocol overhead), so 720 MB/s does not fit on one. Give each camera its own controller — its own root-hub ports — or halve the frame rate; a single such camera at 360 MB/s fits with margin.
On GigE, plan per link: about 100 MB/s usable on a 1 Gbps connection, one camera per cable and port. Two GigE cameras on one host shows the incomplete-frame errors that follow an overrun link, USB3 callbacks missing images with multiple cameras covers the dropouts, and acquiring from multiple USB cameras goes through the bandwidth math.
For the cameras themselves, browse area scan cameras, the compact board-level cameras that build into an arm segment, or the camera finder. The LS-CMR-0166 is a 12 MP USB3 area scan camera at 30 fps.
- Sensor format
- 1/1.7"
- Interface
- USB3
- Resolution
- 12 MP
- Shutter
- Rolling
The LS-CMR-0053 puts the same 12 MP resolution on GigE at 9 fps. The two 12 MP options differ in interface and frame rate:
| Specification | LS-CMR-0166 | LS-CMR-0053 |
|---|---|---|
| Sensor model | Sony IMX226 | IMX 226 |
| Resolution | 4000 × 3000 (12MP) | 4000 px x 3000 px |
| Max. frame rate | 30 fps | 9 fps |
| Pixel size | 1.85 μm | 1.85 μm x 1.85 μm |
| Shutter | rolling shutter | Rolling Shutter |
| Data interface | USB 3.0 | GigE |
| Pixel bit depth | 10-bit | 8 / 10 / 12 bits |
| Price | $560.00 | $560.00 |
All 2 share: Sensor size 1/1.7". Values as printed in each product's data sheet; open a part for its drawing and full specifications.
Decision per case: short runs from an arm to a host nearby take the USB3 model; longer routes, or the discipline of one camera per link, take the GigE model.
On a moving arm the cable is the first thing to fail, and it fails as intermittent frames that look like a software bug. Use a drag-chain rated cable with the camera's own connector: the LS-CBL-0219 is a 3 m USB3.0 camera cable for drag-chain use. More lengths and connectors sit under camera data cables and the accessories finder.
When is one alternating window the wrong answer?
When an operator must watch both feeds at once, or when your processing needs paired frames from the same instant, one alternating window hides half the information — tile both images instead, and synchronize only if the algorithm truly needs matching timestamps.
Tiling is the direct move: paste the two frames into one image on each display tick and imshow that. The layout questions are covered in displaying multiple IP camera streams in one window, and merging CSI and USB camera feeds into one overlay handles mixed sources. An algorithm that needs matched frames is a synchronization problem, not a display problem: message_filters with an ApproximateTimeSynchronizer holds each image until a partner arrives, which adds latency and stalls outright when the two frame rates differ, so add it for that algorithm only. And when the deliverable is an operator screen rather than a ROS window, the same alternation exists in hardware: a dual-timer A/B switch that alternates camera feeds drives one monitor from two sources with no software in the loop.
To size the two-camera station behind these windows, walk the system configurator from the camera step, or send the layout through the contact page.
Questions
How do I quit the node with a key press when drawing runs in a timer callback?
cv2.waitKey(1) returns the code of the key pressed during the wait, or -1 when none was. Compare it with ord('q') inside the display callback and call rospy.signal_shutdown from that same callback, so the thread that owns the window is also the one that requests shutdown. At a 0.1 s period the key is sampled every 100 ms, so hold it briefly rather than tapping.
What happens if the two cameras publish at different frame rates?
Nothing breaks. Each callback only overwrites its own slot, and the display timer always shows the newest converted frame of the selected camera, so the rates never have to match. If the selected camera publishes at 5 fps, a 10 Hz timer repaints the same frame between camera updates — the window looks slow, not stuck, and both counters keep advancing. That difference is how you tell a slow topic from a frozen window.
Can I switch cameras with a key instead of a timer?
Yes. Read the return value of cv2.waitKey(1) in the display callback and flip the selection boolean there when it equals ord('s'); then delete the 5-second switch timer. Every HighGUI call and the selection change stay in one thread, which is the property that fixed the freeze, and the change shows on that same repaint because the flip happens before the frame is chosen.
Do I need message_filters or an ApproximateTimeSynchronizer for this?
No. Alternating display shows one stream at a time, so nothing needs pairing. A synchronizer belongs in the processing path when an algorithm needs frames from both cameras from the same instant; it holds each image until a partner matches, which adds latency and can stall outright when the two frame rates differ. For switching a window it is pure overhead.
What cable should carry a camera mounted on a moving arm?
A drag-chain rated data cable, long enough for the reach plus a service loop, with the connector the camera actually carries. The LS-CBL-0219 is a 3 m USB3.0 camera cable built for drag-chain use, and high-flex versions cover GigE runs too. On a moving arm ordinary patch cable fails first, and it fails as intermittent frames — which looks exactly like the software bug you just fixed.

