System designHow-to

Keep a camera open and ready between MQTT-triggered grabs

Open the camera once, keep it open between MQTT-triggered grabs, and cut per-image latency to about one frame period with a queue and worker thread.

LumaSpec Vision9 min read Share on LinkedIn

On this page 11 sections
  1. 01Open once, queue everything in between
  2. 02What you need
  3. 03The pattern: one worker thread owns the camera
    1. Size the queue to the latency you can accept
  4. 04Put it in place, step by step
  5. 05Worked example: 4000 × 3000 USB3 camera triggered at 30 fps
  6. 06Choosing hardware for an event-driven cell
  7. 07How to verify it worked
  8. 08Common mistakes that bring the seconds back
  9. 09When hardware triggering beats MQTT
  10. 10The decision, and the next step
  11. 11Questions

An MQTT message should cost you one frame, not a camera start-up. Opened cold, a machine-vision SDK initialises the transport layer, discovers devices, opens the camera and starts the stream before the first pixel moves — which is why creating an instance per message feels slow while the grab itself is fast. The fix is structural: open the camera once, in a worker thread that owns it for the life of the process, and let the message callback do nothing but enqueue work. This guide gives you the pattern, the shutdown path, a worked latency budget for a 12 MP USB3 camera at 30 fps, and the tests that prove the start-up cost is gone.

Open once, queue everything in between

A cold start runs four slow steps in order: transport-layer initialisation, device discovery on the bus or network, the open call with its register writes, and stream set-up. Repeat that per message and every image carries the full price.

The decision: create the SDK instance, discover and open the camera exactly once at process start, and keep that context alive until the process stops. Once the camera is open, a triggered grab costs exposure plus one frame period and nothing else. Between messages the worker blocks on an empty queue, which costs nothing. Pay the set-up once, then serve grabs at frame rate.

What you need

  • An area scan camera on GigE or USB3 that the host running the MQTT client can reach.
  • The SDK runtime for that camera. Call names differ between SDKs, so the camera calls below stand as placeholders while the queue and thread code runs as written.
  • An MQTT broker both sides can reach, plus credentials for the client.
  • A host that stays powered: a bench PC or a vision controller with a LAN port per camera.
  • Optionally, a strobe light on a controller light channel, so flash and exposure start from the same job.

The pattern: one worker thread owns the camera

An MQTT client delivers every publish on its own network thread. That thread must return almost immediately, or the broker starts to doubt your keep-alive. The callback's entire job is therefore: check the topic, put the payload on a queue, return.

The acquisition worker owns the camera. It enters the SDK context and opens the camera once, before the client even connects, then loops on a blocking get(). Each job carries the settings for that shot; the worker applies them, grabs one frame and hands the image to an output queue your publisher drains. Keep every camera call on this one thread: many SDKs bind the handle to the thread that opened it, and this structure gives you that behaviour for free.

import queue
import threading

jobs: queue.Queue = queue.Queue(maxsize=8)       # bounded: overload fails loudly
results: queue.Queue = queue.Queue(maxsize=8)

def on_message(payload: bytes) -> None:
    # runs on the MQTT client's network thread: do nothing slow here
    jobs.put_nowait(payload)                     # raises queue.Full when the worker lags

def acquire(camera) -> None:
    with open_camera_once(camera):               # your SDK's context manager, entered once
        while True:
            job = jobs.get()                     # blocks; idle cost is zero
            if job is None:                      # sentinel: clean shutdown
                return
            apply_settings(camera, parse_job(job))
            results.put(grab_one(camera))        # your SDK's single-frame grab call

threading.Thread(target=acquire, args=(camera,), daemon=True).start()

open_camera_once, parse_job, apply_settings and grab_one stand in for your SDK's real names. The queue and thread code is standard library and runs as shown.

Size the queue to the latency you can accept

A bounded queue turns overload into an error you can count. At 30 fps, a maxsize of 8 holds 8 × 33.3 ms ≈ 267 ms of backlog; past that, put_nowait raises queue.Full. Decide the policy on purpose: drop-and-alarm for counting jobs where every message matters, or latest-wins for part-present triggers, where the worker drains the queue after each get() and keeps only the newest job.

Put it in place, step by step

  1. Open once, in the worker. Run discovery and the open call inside the acquisition thread and stay within the SDK context manager's scope for the whole run — leaving that scope shuts the API down. If discovery finds no cameras, the transport layer is usually not loaded: diagnose discovery failures at start-up.
  2. Pin the image settings at open. Exposure, gain and white balance to manual, pixel format and ROI fixed; auto modes keep re-converging, so the first frame after an idle hour drifts away from the last — that is the brightness-drift mechanism.
  3. Start the worker before the client connects, so no message can arrive ahead of a ready camera.
  4. Enqueue only in the callback: topic check, put_nowait, return. Microseconds of work, no I/O.
  5. Drain results on the publisher side: take from the results queue, serialize, publish to the reply topic.
  6. Shut down in order: put the None sentinel, join the worker, exit the SDK context, then stop the transport layer.

Worked example: 4000 × 3000 USB3 camera triggered at 30 fps

Inputs, and where each comes from:

  • Camera: the LS-CMR-0166, a 12 MP USB3 area scan camera with an IMX226 sensor, a 4000 × 3000 array and 30 fps at full frame (catalog data).
  • Pixel format: Mono8, 1 byte per pixel (illustrative; a 12-bit format doubles the bytes).
  • Exposure: 1 ms strobe flash (illustrative).
  • Queue wait: 0 ms, the design target — each message arrives to an idle worker.
frame_size_B = width_px × height_px × bytes_per_pixel frame_period_ms = 1000 / frame_rate_fps response_ms = queue_wait_ms + exposure_ms + frame_period_ms

Arithmetic. frame_size_B = 4000 × 3000 × 1 B = 12,000,000 B ≈ 12 MB. frame_period_ms = 1000 / 30 ≈ 33.3 ms. response_ms = 0 + 1 + 33.3 ≈ 34 ms per grab.

Bandwidth cross-check: 12 MB × 30 per second = 360 MB/s, which fits inside USB3's roughly 400 MB/s of effective throughput. The GigE version confirms the ceiling from below: the LS-CMR-0053 runs the same IMX226 sensor at 9 fps. One frame of 12 MB at 9 fps is 108 MB/s, right at GigE's roughly 110 MB/s.

Decision. At full frame this camera sustains 30 triggered grabs per second. Below that message rate every grab answers in about 34 ms, and 67 ms — two frame periods — is a fair pass line so one queued job still passes. Above 30 messages per second the queue grows without limit: narrow the ROI, which cuts the frame period roughly in proportion to the rows read out, or pick a faster camera. On the 9 fps GigE sibling the same arithmetic gives 111 ms per grab and a ceiling of 9 messages per second. The rolling shutter reads a stationary part under a strobe cleanly; moving parts belong on the global-shutter cameras below.

Choosing hardware for an event-driven cell

Match the shutter to the part first: anything moving needs a global shutter, or the scan smears across the readout. For small scenes at high message rates, the MV-CU020-90GC pairs a 2.5 MP global-shutter sensor with GigE. For more detail, the MV-CH120-11UC brings a 12 MP global-shutter sensor to USB3.

Area scan camerasHikrobot MV-CU020-90GC 2.5 MP Area Scan Camera, GigE, GMAX4002, ColorMV-CU020-90GC
Sensor format
1/1.7"
Lens mount
C-mount
$309.00View part
Area scan camerasHikrobot MV-CH120-11UC 12 MP Area Scan Camera, USB3, IMX304, High-speed version, ColorMV-CH120-11UC
Sensor format
1.1"
Lens mount
C-mount
$2,339.00 · quotedView and quote

Host side, count ports: one LAN port per camera plus headroom for the broker connection, and enough light channels to fire every strobe from code. The MV-VC3301H-128G66 lists 5+ LAN ports and 4 built-in light channels. When you would rather power the GigE cameras over the data cable, the MV-VC5525-256G70-NN adds PoE camera ports and 8 built-in light channels.

Vision controllersHikrobot MV-VC3301H-128G66 I3-8100, 8GB+128G SSD, No PoE, PCIe expansion supported, Incl. VM6200Pro licenseMV-VC3301H-128G66
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$2,419.00View partBuild a system with it

The camera finder filters by interface, shutter and resolution when you want to browse beyond these picks.

How to verify it worked

Three tests, all timed with a monotonic clock such as time.perf_counter().

Round trip. Publish one message, block on the results queue and log the elapsed time; the pass line is 67 ms for the example camera.

import time

t0 = time.perf_counter()
results.get(timeout=1.0)             # the publish already went out
elapsed_ms = (time.perf_counter() - t0) * 1000.0
assert elapsed_ms < 67.0             # two 33.3 ms frame periods
print(f'round trip: {elapsed_ms:.1f} ms')

Burst. Publish 20 messages at 10 Hz and require 20 frames back with no queue.Full. Ten per second sits well under the 30 per second ceiling, so a miss is a bug, not bandwidth.

Idle. Leave the service quiet for an hour, then grab. A first frame that is darker or brighter means an auto mode survived; a torn lower edge means the buffer was processed while the SDK was still writing it.

Common mistakes that bring the seconds back

  • A new instance per message: the four start-up steps ride on every image again.
  • An unbounded jobs queue: latency creeps upward under load instead of failing at maxsize.
  • Processing the camera's buffer in place. The grab hands back memory the SDK reuses, so copy before the processing work queues up (why frames tear).
  • A fresh allocation per frame: per-frame allocation shows up as jitter, not a slower average — reuse image buffers instead.
  • Small packets on GigE: a 12 MB frame arrives as thousands of small packets and the transfer time spikes; the packet-size fix for delayed triggers walks the diagnosis.

When hardware triggering beats MQTT

MQTT carries an event from anywhere — an MES, another line, an operator app — but it carries no timing guarantee. When the event is born inside the cell, a part-present sensor or a PLC bit, wire it to the camera's trigger input instead: event-to-exposure drops to microseconds and the jitter follows. If a triggered camera still waits after the pulse, the trigger-ready timeout diagnosis covers the states to check, and pulses that seem ignored usually meet an acquisition mode set the wrong way. From trigger wire to saved file, the hardware trigger grab loop in Python is the companion pattern. Keep both paths: hardware for cycle-critical events inside the cell, MQTT with this queue for commands from the rest of the plant.

The decision, and the next step

Open once, queue everything between grabs, bound the queue, pin manual settings at open. A message-to-image response then costs one frame period — 34 ms on the worked example, less as the ROI narrows — and the MQTT thread never waits on camera work. Size the station with the camera configurator, or bring the message flow and camera list to the contact page and we will spec the pairing with you.

Questions

What belongs in the MQTT payload for each grab?

A job id, the exposure time, the ROI window and the light channel with its strobe duration. The worker parses them and writes them to the camera between get() and the grab, so the callback never parses camera settings. Keep extra fields out: every byte is parse time on the message path.

Should the camera stream continuously between messages?

Either works once the context is open. Continuous streaming keeps the newest frame always warm but occupies the link between triggers, which matters on a shared GigE cable. Starting acquisition per grab costs a register write and one frame period, so on shared links start per grab and stay idle in between.

How does this scale to several cameras?

One worker thread and one jobs queue per camera. The thread that opened each handle remains its only user, so thread affinity holds by construction. Stagger the light channels so strobes do not overlap, and size the host at one LAN port per camera plus one for the broker connection.

How do I keep the MQTT connection healthy while a grab runs?

The pattern already does the main work: the network thread never blocks. Beyond that, set the client keep-alive above the worst queue backlog, publish results on a separate reply topic, and let QoS redeliver command messages instead of blocking on acknowledgements inside the callback.

How much latency does the MQTT hop itself add?

A local broker adds little: the publish round trip over switched Ethernet is typically 1-5 ms. Add that to the 34 ms the worked example needs for exposure and readout and the cell still answers in under 40 ms. Over a wide-area link the jitter grows to tens or hundreds of milliseconds, which is why cycle-critical events never ride MQTT.