System designTroubleshooting

Push the camera buffer back only after copying your frame data

Triggered frames look right, then show a stale view. Why the SDK reuses your buffer, and why to push it back only after copying your frame data.

LumaSpec Vision10 min read Share on LinkedIn

On this page 9 sections
  1. 01Why do the first triggered frames look right and later ones show a different view?
  2. 02Is a one-buffer stream the bug?
  3. 03What does the SDK's get_data call actually return?
  4. 04How do you prove your array is a view before changing code?
  5. 05Where exactly does the push back belong?
  6. 06What does copying every frame actually cost?
  7. 07Does re-creating the stream for every capture fix it?
  8. 08How do you verify the fix before it ships?
  9. 09Questions

The signature is hard to miss: an event-triggered capture is correct for the first few frames, then consistently shows a different view, and restarting the script buys a few good frames again. You set the stream pool to a single buffer, pop and push in a tight loop, and the problem survives every pool size you try. That pattern points at buffer lifetime, not at the trigger, the lens or the network. This guide walks the diagnosis in a Python GenICam binding: what get_data hands you, a checksum test that proves it, where the push back belongs, what copying really costs, and a regression test before the fix ships.

Why do the first triggered frames look right and later ones show a different view?

Because a buffer you pushed back is reused for the next arriving frame, and your array still points into it. Every pixel you read after that arrival belongs to a newer exposure, which is why the view changes without any camera fault.

The stream keeps a pool of receive blocks. The pop call hands you the next filled one; the push call returns it to the free side; the receive thread lands each arriving frame into a free block. Nothing copies along the way. A view you keep after the push is a window onto whatever the network delivers next.

The first frames look right because your first pops come from blocks your code has never referenced before. Corruption starts once the pool wraps around to a block you still hold a view into. The later it wraps, the longer the bug hides. The count of good frames at start is a direct clue to the pool depth.

What you see Most likely cause First test
Complete frame, scene from an earlier trigger, always after the same number of good frames Buffer returned before the data was copied Checksum test below
Torn rows or a shifted lower half NIC or driver dropping packets under load torn frames in a camera callback
The same first frame repeats on every trigger Buffer handling set to keep the oldest frame newest-only buffer handling
No frame after the first trigger Acquisition mode still single-shot acquisition mode check

A stale but complete frame points at lifetime — this guide. A torn or shifted frame points at the transport, the row above it in the table. The pattern appears on any pool-style SDK, from GigE area scan cameras to 3D profilers, so the diagnosis ports between them.

Is a one-buffer stream the bug?

No: pool size sets when the overwrite appears, not whether it happens. With a single buffer your view goes stale after exactly one more arrival, and a pool of N delays the first bad frame by about N arrivals.

Size the pool from the pipeline, not from the bug. Frames in flight equal wire time divided by the trigger interval. The worked example below puts a 6.3 MB mono8 frame at about 55 ms on a 1 Gb/s link, so near the 18 fps ceiling roughly one frame is in flight at any instant. Add the block in your hands and one spare, and three buffers cover the pipeline. If your trigger interval is shorter than the wire time, size to the in-flight count plus two.

Pool of one has a second cost: while you hold the only block, the receive thread has nowhere to land the next frame, so a slow loop starts dropping. Returning buffers too slowly starves any ring. The V4L2 DQBUF starvation math is the same failure in another API.

Set three, watch the drop counter stay at zero, and treat pool size as tuning: the ordering fix in the next two sections is the cure.

What does the SDK's get_data call actually return?

Unless your binding's reference states otherwise, get_data returns a numpy view over memory the library still owns. Data and the buffer share one address, so the pixels you see change when the next frame lands.

The pool exists to avoid allocating per frame, so the getter wraps the receive block instead of copying it. This is the aliasing Python's own buffer protocol formalizes: a view holds a reference to the exporter's memory, not to a private copy. Scalar reads are safe. The timestamp, width and dtype were true at pop time and stay true; the pixel block is not yours.

Wrapping that view in a cv::Mat without a copy inherits the trap, and the wrap-without-copying pattern shows when a Mat is safe and when it must clone. Some bindings do return owned arrays. Five lines in the next section settle what yours does, without reading its source.

How do you prove your array is a view before changing code?

Checksum the array at pop time, push, let fifty frames arrive and checksum the same array again. If the second sum differs from the first while a copy taken at pop time still matches its own sum, your data was a view into pool memory.

import numpy as np

buffer = self.stream.pop_buffer()    # next filled buffer from the stream's pool
data = buffer.get_data()             # numpy.ndarray, dtype uint8 — view into pool memory
before = int(data.sum())             # int — checksum at pop time
kept = data.copy()                   # numpy.ndarray — memory this code owns
ts = buffer.get_timestamp()          # int — scalar, safe to read now
self.stream.push_buffer(buffer)      # the block returns to the receive pool here

# ... after 50 more triggers:
after = int(data.sum())              # int — same view, newer pixels
print(before, after, int(kept.sum()))
# 182341957 201144532 182341957  -> data moved, kept did not

kept is the control: it was copied from the same pixels, so its unchanged sum isolates the overwrite as the cause. A sum is not a cryptographic hash, but under a static scene and a fixed trigger it proves memory moved. The printed values show the shape of the result; yours will differ in magnitude.

Where the binding exposes a per-frame counter, log it beside the checksum. A stored image whose counter repeats is the smoking gun in a site log.

Where exactly does the push back belong?

Push immediately after the copy: the call returns the block to the receive pool, and every byte it holds is forfeit from that line on. If you only read scalars such as the timestamp, push right after reading them.

data = buffer.get_data()          # numpy.ndarray view — pool memory
frame = data.copy()               # numpy.ndarray — owned by this code
self.stream.push_buffer(buffer)   # pool reuse is now harmless

If you genuinely process in place, holding the buffer and pushing later is a different contract with real bookkeeping. The retrieve several images before processing pattern shows how to hold frames safely, and the zero-copy pointer trick on GigE shows why raw pointers do not survive the round trip. The same wrong lifetime is what stereo frames saved wrong looks like in a different API.

What does copying every frame actually cost?

About 1-2 ms for a 6 MP mono8 frame on an ordinary industrial PC, roughly 2% of the 55 ms that frame needs on a 1 Gb/s link. The copy is never the expensive part of the loop.

frame bytes = width × height × bytes per pixel wire time = frame bytes ÷ effective payload throughput copy time = frame bytes ÷ memory copy bandwidth

Worked example: a 6 MP mono8 camera at 3072 × 2048 gives frame bytes = 3072 × 2048 × 1 = 6,291,456, about 6.3 MB. A 1 Gb/s link carries about 115 MB/s of payload with jumbo frames enabled, so wire time = 6.3 MB ÷ 115 MB/s ≈ 55 ms — a ceiling near 18 fps for this format. A DRAM-to-DRAM copy at about 5 GB/s takes 6.3 MB ÷ 5 GB/s ≈ 1.3 ms, about 2% of one frame interval.

What moves the numbers: 1500-byte frames lower the payload share and stretch wire time to roughly 60 ms; a packed 12-bit frame carries 1.5 bytes per pixel and scales both figures by half again. Even at the worst point the copy sits an order of magnitude below the frame interval, so correctness costs you nothing here. If wire time rather than copy time is your bottleneck, the 5GBASE-T camera interface guide covers the bandwidth levers.

Does re-creating the stream for every capture fix it?

No: a fresh stream per capture only hides the symptom and bills you stream bring-up on every trigger. Fix the push order and keep one stream for the session.

It appears to work because a new stream allocates new pool blocks, so the views from the previous capture can never alias the new arrivals. The cost is real: packet-size negotiation, block allocation and the first filled frame all land inside your trigger budget. That puts per-shot overhead in the tens of milliseconds at best, and it grows with resolution. Above a few hertz the loop cannot keep up, and the stale view returns the first time a colleague reuses a buffer.

Keep stream recreation as a diagnostic — it isolates lifetime from everything else in five lines — never as the production fix.

How do you verify the fix before it ships?

Trigger a long run while a lens cap alternates on and off every ten frames, copy and checksum at pop time, and assert three things: capped frames come back dark, uncapped frames lit, and no stored checksum repeats while the scene changes.

  1. Store the copy, its checksum and the timestamp at pop time; push right after the copy.
  2. Alternate a lens cap every ten frames for 200 frames at the production trigger rate.
  3. Assert the stored frames alternate dark and lit in step with the cap — two lit frames in a row after the cap came off means a stale block was displayed.
  4. Assert timestamps increase monotonically and the per-frame counter, where the binding exposes one, steps by exactly one.
  5. Soak for an hour at the production rate and require zero drops and zero checksum mismatches in the log.

In production, watch frame age as well as content. The frozen stream alarm pattern alarms when the newest frame goes stale, which catches a swapped lifetime the day it regresses.

If the first table pointed at bandwidth rather than lifetime, work from the frame grabbers and the camera finder before touching code, and size a faster link from the camera step of the configurator. More streaming and buffering patterns live on the systems topic page.

CamerasArea scan cameras

GigE, USB3, 10GigE and CoaXPress

1,287 models · from $149.00

Questions

How many buffers should the stream pool hold for single-frame triggered capture?

Three as the floor: one frame in flight on the wire, one in your hands, one spare. Five absorbs scheduler jitter on a busy host. More than about eight delays the first visible corruption by whole seconds, which hides a lifetime bug instead of exposing it. Pool size never fixes the aliasing — it only moves when the stale view appears.

How long can I hold a buffer before the pool starves?

With a three-buffer pool you can hold one buffer for about two arrival intervals — roughly 110 ms at the 55 ms spacing of a 6 MP mono8 frame on GigE — before a third concurrent hold leaves the receive thread nowhere to land a frame and drops begin. Copy within a few milliseconds and push. If processing needs longer than one frame time, add pool depth and watch the drop counter instead of hoping.

My saved file's image changes after the script moves on — same bug?

Yes: the save was deferred past the push, so the file writer read pool memory at write time instead of capture time. The same rule applies anywhere a reference outlives the return — work queues, GUI callbacks, deferred tofile calls. Copy at pop time or write the file before the push. The stored image then stops changing under you.

Does the copy cost change for 12-bit or packed formats?

It scales with bytes per pixel. Mono8 uses 1 B/px and packed mono12 uses 1.5 B/px, so the same 6 MP frame is about 9.4 MB instead of 6.3 MB, and the copy moves from about 1.3 ms to about 1.9 ms at 5 GB/s. Wire time rises the same way, while the copy stays a small fraction of the frame interval.

Is the fix different for callback-based acquisition?

The lifetime rule is identical: the callback receives a buffer the driver still owns, so copy before returning it. The extra constraint is that callbacks often run on the receive path. Heavy processing inside the callback delays every return, and a starved pool shows up as dropped frames rather than stale ones. Copy first, queue the copy, and keep the callback to a few lines.