Callback-based acquisition is convenient until you need accountability. You call the async capture, the call returns immediately, and an image handler wakes up later with a grab result that carries no hint of which call produced it. With three captures in flight you get three events and no way to tell them apart. The fix is a reference queue: tag every capture call with a reference, push it into a thread-safe FIFO, and pop one reference per result inside the handler. It works because a single camera delivers grab results in the order you issued the captures. This guide gives you the pattern, the two rules that keep the pairing exact, a worked timing example, and a test to run before the design goes on the line — written for anyone wiring an SDK's callback mode.
Why a reference queue works
Each camera connection keeps its own order. Every capture exposes one frame, the sensor pipeline fills one buffer for it, and the transport — GigE Vision and USB3 Vision streams alike — completes buffers in acquisition order. The grab thread then calls your handler once per completed buffer, oldest first. So when captures are issued in the order ref A, ref B, ref C, results arrive in that same order.
Two conditions make the mapping one-to-one. First, every capture must produce exactly one event, a success or a failure. Second, references must enter the queue in the same order the captures are issued, which is what the lock in the next section enforces. The order is also strictly per connection: two cameras each keep their own order, but nothing sorts events between them.
If your SDK's capture call already returns a completion handle or a future, use that and skip the queue — the SDK maintains the pairing for you. The queue is the tool for callback-only APIs, where the event arrives with no per-call context.
What you need
- A running callback setup: one camera, one stream, one registered image handler.
- A thread-safe FIFO from the standard library: queue.SimpleQueue in Python, ConcurrentLinkedQueue in Java, ConcurrentQueue in C#, or a std::deque guarded by a std::mutex and a std::condition_variable in C++.
- One reference per capture. An int counter starting at 0 and incremented per call is enough, and you log it with every frame.
- A lock shared by every code path that pushes or pops references, from the day a second thread exists.
No camera setting is involved: the queue lives entirely in your process, between your capture calls and your handler.
The pattern, step by step
- Wrap the capture call. Write a capture_async(ref) function that is the only place your code issues an asynchronous capture.
- Push, then issue, under one lock. Pushing inside the same lock keeps queue order equal to issue order, even with several issuing threads. If the issue call raises, pop the reference straight back out, because that capture will never produce an event.
- Pop in the handler. One pop per event, non-blocking: the handler runs on the grab thread, so a long blocking wait there delays every later result.
- Pair failures, not just frames. A failed grab consumed one capture, so it must consume one reference. Attach the reference to the failure record.
- Drain at re-sync points. After a stop, a reconnect or a buffer-count change, drain the queue and reset the counter before re-arming captures.
import queue
import threading
# Set up once, before the first capture.
pending_refs = queue.SimpleQueue() # FIFO: oldest issued capture first
issue_lock = threading.Lock() # keeps push order equal to issue order
def capture_async(camera, ref: int) -> None:
# Issue one async capture and tag it with ref (an int counter works).
with issue_lock:
pending_refs.put(ref)
try:
camera.start_capture_async() # your SDK's async capture call
except Exception:
pending_refs.get_nowait() # no event will come: remove the tag
raise
def on_image_grabbed(grab_result) -> None:
# Runs on the grab thread, once per result, success or failure.
try:
ref = pending_refs.get_nowait()
except queue.Empty:
log_stray_event() # more events than captures: re-sync
return
if grab_result.valid:
process(ref, grab_result)
else:
report_failure(ref, grab_result)
The names are this example's own: capture_async, on_image_grabbed, pending_refs and issue_lock are illustrative, and camera.start_capture_async() stands for whatever your SDK calls its asynchronous capture. The helper calls process(), report_failure(), log_stray_event() and wait_for_results() are your code, not an API. Read the validity flag and the pixel data through your SDK's own accessors.
Worked timing example
Take a 12 MP USB3 camera such as the LS-CMR-0166. At 30 fps one frame period is:
Set the exposure to 5 ms, an illustrative value, and issue two captures: capture_async(0x10) at t = 0 ms and capture_async(0x20) at t = 40 ms. Each result completes roughly one frame period after its own call, so 0x10 lands near t = 33.3 ms and 0x20 near t = 40 + 33.3 = 73.3 ms.
The completion times are estimates; exposure, transfer and host load move them by milliseconds. The order is not an estimate. 0x10 completes before 0x20 because it was issued first, so the handler pops 0x10 and then 0x20, and each image lands on the capture that requested it. The queue never holds more than two entries here, because two captures are in flight.
For how a capture call maps onto exposure start and readout, see trigger call versus exposure time.
How to verify the pairing
Run three checks before you trust the design.
- Order test. Stub process() so it appends each reference to a list, issue three captures, wait for three results, and compare:
def test_pairing_order(camera) -> None:
issued = [0x10, 0x20, 0x30] # three references, in issue order
seen = [] # filled by a stubbed process()
for ref in issued:
capture_async(camera, ref)
wait_for_results(expected=3, timeout_s=5.0) # your test harness
assert seen == issued, 'issued %s, saw %s' % (issued, seen)
Run the loop 100 times. Races show up on repetition, not on the first pass.
-
Failure test. Arm a hardware trigger you do not wire up and set your capture wait short. One capture must come back as a failure and consume exactly one reference; after the timeout fires, assert pending_refs.qsize() == 0.
-
Desync test. Push a reference without issuing a capture, deliver one real frame, and confirm the stray-event path logs it. Drain, then check that the next capture pairs correctly again.
Common mistakes
- One queue shared by several cameras. Order exists per connection only, so events from two cameras interleave and the pops land on the wrong captures. Give each camera its own queue and dispatch on the event's source — the same discipline that keeps multi-camera callbacks from losing images in the first place (why multi-camera USB3 callbacks miss images).
- Letting a failure skip its pop. One missed pop shifts every later pairing by one position, so each image answers to the previous capture's tag. Pop one reference per event on every path, including wait timeouts (one wait call per frame).
- Heavy work inside the handler. The handler runs on the grab thread; slow processing there delays later events. Do the pop, then hand the image to a worker, and copy the buffer before you queue it onward (torn frames in a camera callback).
- Issuing faster than the camera accepts. A capture the camera cannot take produces no event, and the pairing drifts with no error to show for it. Cap in-flight captures at the number of buffers the SDK holds for the stream.
- Stopping without draining. Drain after the grab thread has stopped, not while results are still arriving, or the last results pop nothing and your log fills with stray events (stopping the grab thread without a segfault).
When the queue is the wrong tool
Two cases call for something else. If the SDK returns a per-call handle, a future you can await, the pairing is built in; adopt it and delete the queue. And if the real question is which part is in the image, tag at the source: have the trigger station write the part ID, and queue tuples of (ref, part_id) in capture_async. The reference then carries the part identity through to the handler, and one queue entry answers both questions at once.
Does the interface change anything?
No. A GigE stream and a USB3 stream keep the same per-camera order, and the pairing rules above are identical on both. Shutter type does not matter either: a rolling-shutter sensor pairs exactly like a global-shutter one, because the queue tracks capture calls, not sensor timing. What the interface changes is bandwidth and cable length, not event order.
If you are wiring this pattern onto hardware, it behaves the same on a 2.5 MP GigE camera such as the MV-CU020-90GC and on a 12 MP USB3 camera such as the LS-CMR-0166.
- Sensor format
- 1/1.7"
- Lens mount
- C-mount
- Sensor format
- 1/1.7"
- Interface
- USB3
Match capture to result with one queue per camera, one pop per event, and a drain at every re-sync point. Once the order test passes 100 runs in a row, the design is ready for the line. To pick the camera itself, start from the camera finder, configure a camera around your frame rate and interface, or browse the systems topic page for the rest of the grab-loop guides. For anything unusual, contact us.
GigE, USB3, 10GigE and CoaXPress
1,287 models · from $149.00Questions
Can I attach my reference to the buffer instead of running a queue?
If your SDK lets you register user data on each buffer, or its capture call returns a result handle you can wait on, prefer that: the SDK pairs call and result for you and the mapping cannot drift. Look for a user-data or context parameter in the acquisition part of the SDK manual before building the queue. The reference queue is the fallback for callback-only APIs whose events carry no per-call context.
What happens to the pairing when one capture times out and no frame arrives?
The queue keeps that capture's reference, and every later frame pairs with the wrong call until you consume it. Your timeout path must pop exactly one reference, the same as the success path. If the camera stamps each buffer with an incrementing frame counter, log both values: the first mismatch shows where the drift started, and you drain and re-sync from that point.
Does the pairing survive a camera reconnect or a change of buffer count?
Treat both as re-sync points. After a reconnect, results resume at a new position while the queue still holds pre-disconnect references, so pairings would shift. Stop the grab thread cleanly, drain the reference queue, reset the counter, and only then re-arm captures. The same applies after changing the buffer count, because restarting the pipeline can drop buffered results.
How large should the reference queue be allowed to grow?
It grows only while captures are in flight, so cap it by capping in-flight captures. Issue at most as many captures as the SDK holds buffers for the stream; the queue then never exceeds the buffer count and a normal pop never finds it empty. If you must issue freely, add a counting semaphore that each result releases.
Do I still need the issue lock if only one thread calls capture_async?
No. The queue itself is already thread-safe, and a single issuing thread cannot interleave push and issue. The lock earns its place when a second thread starts issuing captures or a timeout path pops references while captures are in flight; from that point, every path that pushes or pops must share one lock, or push order and issue order can swap.

