System designTroubleshooting

Python frame queue memory leak: bound the queue and reuse frames

Why a hardware-triggered Python capture loop leaks memory each trial — frame queue, writer threads, driver buffers — and the bounded-queue fix that stops it.

LumaSpec Vision10 min read Share on LinkedIn

On this page 10 sections
  1. 01Symptom: memory climbs across trials and gc.collect() changes nothing
  2. 02First test: log RSS, queue depth and thread count per trial
  3. 03Cause 1: the frame queue is unbounded
  4. 04Cause 2: one more writer thread per trial, and none of them exits
  5. 05Cause 3: camera buffers never go back to the driver
  6. 06The fix: bound the queue, return the buffers, keep one writer
  7. 07The numbers, worked end to end
  8. 08Checks before you call it fixed
  9. 09When it is not the queue, and what to order next
  10. 10Questions

A hardware-triggered capture loop in Python works perfectly for one trial, then memory climbs trial after trial until the machine can no longer allocate. The reflex — more gc.collect() calls — changes nothing, because every frame is still referenced by something the loop itself created: an unbounded queue, a writer thread that never exits, or a camera buffer the callback kept. This article walks the three holders in the order they usually occur, with the arithmetic for a 1456 × 1088 Mono8 stream, the bounded producer–consumer pattern that fixes them, and a per-trial measurement that proves the leak is gone.

Symptom: memory climbs across trials and gc.collect() changes nothing

Per trial everything works: the trigger fires, frames arrive, the stack saves to disk. Between trials the process's resident memory never drops back, and after enough trials allocation fails mid-run. Scattering gc.collect() through the loop cannot help: Python's collector frees only objects nothing references, and every candidate here is referenced. The queue holds each tuple you put in it, a blocked thread's stack holds its local arrays, and the driver keeps its buffer pool for as long as streaming ran.

Two questions split the cases before you touch any code. Does memory grow only while streaming runs, or does it step up once per trial even while the camera sits idle? And does the live thread count rise by one per trial? The three causes below follow in the order they usually appear: the queue, the threads, then the driver buffers.

First test: log RSS, queue depth and thread count per trial

Add one log line per trial and the cause usually names itself:

import os
import threading
import psutil                      # pip install psutil

proc = psutil.Process(os.getpid())
rss_mb = proc.memory_info().rss / 1e6      # float: resident memory in MB
threads = len(threading.enumerate())        # int: live Python and SDK threads
print(f'trial {i}: rss={rss_mb:.1f} MB, threads={threads}, queued={frame_queue.qsize()}')

Run ten trials with the camera idle between captures. Growth only during streaming, with the queued count climbing, is the unbounded queue in Cause 1. A step at the end of each trial that persists while idle, with the thread count rising one per trial, is Cause 2. Flat memory with missing frames points away from leaks entirely, toward the checks at the end. The same log doubles as the proof after the fix, so keep it in place. More acquisition-side patterns sit on the systems topic page if your loop outgrows this one.

Cause 1: the frame queue is unbounded

queue.Queue() with no maxsize accepts every frame the callback hands it, without limit. The camera side is the fast producer: hardware trigger rates run from a few frames per second into the hundreds. The consumer side is slow by design — it fills a numpy stack and writes it to disk — so any rate gap becomes a backlog of frames in RAM.

Frame size sets the stakes. A 1456 × 1088 Mono8 frame is 1456 × 1088 × 1 byte = 1,584,128 bytes, about 1.6 MB. At a 60 fps trigger with the consumer keeping only 20 fps, the backlog grows at 40 frames per second: 40 × 1.6 MB ≈ 63 MB per second, roughly 3.8 GB in a one-minute run. Nothing in the standard library slows that down.

The fix is one constructor argument: queue.Queue(maxsize=8) caps the queue at 8 × 1.6 MB ≈ 12.7 MB no matter what the producer does. Put with put_nowait() inside the callback and choose the overflow behaviour deliberately — drop the frame and count the drop, or block for a bounded time you can afford. Print frame_queue.qsize() after each trial: a large value after the writer has finished means the backlog outlived the run.

Cause 2: one more writer thread per trial, and none of them exits

The per-trial step in the RSS trace comes from a loop shape like this: each capture starts a fresh writer thread, and the writer wraps a fixed-length for loop inside while True:. After its last frame the outer while restarts, the thread blocks on get() forever, and nothing ever joins it. The thread itself is small — what it keeps alive is not. Its locals still reference the preallocated result stack, so one complete stack stays resident per trial, and the SDK callback thread joins the pile if streaming is not stopped cleanly either.

The stack is 280 frames × 1,584,128 bytes = 443,555,840 bytes, about 444 MB. Twenty orphaned trials therefore hold 20 × 444 MB ≈ 8.9 GB. On a 16 GB machine with roughly 12 GB free to the process, allocation failures start around trial 27 — exactly the 'works fine, then out of memory' pattern.

Confirm it with the thread count from the first test, then fix the shape: let the writer exit after its frame count and join it before the next trial starts.

def writer(frame_queue, data, ticks, n_frames):
    # data: (n_frames, H, W) uint8 array, preallocated before streaming starts
    for i in range(n_frames):            # exits when the stack is full
        img, ts = frame_queue.get()      # img: (H, W) uint8 copy
        data[i, :, :] = img
        ticks[i] = ts
        frame_queue.task_done()
t = threading.Thread(target=writer, args=(frame_queue, data, ticks, n_frames))
t.start()
recorder.run_session()                   # start streaming, wait for the trigger window, stop
t.join(timeout=10)
assert not t.is_alive(), 'writer thread did not exit'   # the check that proves it

Where the application allows it, start streaming once per session and make each trial a trigger window instead of a new stream plus a new thread. If teardown per trial is unavoidable, stop streaming and join both directions before the next one begins.

Cause 3: camera buffers never go back to the driver

Callback streaming hands you frames from a fixed pool of driver buffers. The contract is simple: copy the pixels out inside the callback, then return the buffer so the driver can fill it with the next exposure. If any code path keeps the frame object alive, or skips the buffer-return call after an exception, the usable pool shrinks a little more each time it happens. The visible effect depends on the SDK: a stall after roughly the buffer count of frames, a rising dropped-frame counter, or fresh allocations that do show in RSS. The copy-out and buffer-return call names differ between SDKs, so take both from your SDK's Python reference — the pattern itself is identical everywhere.

Whatever the calls are named, make the return unconditional: on the success path and on every drop and exception path, before the callback returns. And keep the slow work out of the callback; a pixel copy costs microseconds, a TIFF write does not.

The fix: bound the queue, return the buffers, keep one writer

Assembled, the bounded pattern looks like this:

import queue
import threading

MAX_IN_FLIGHT = 8                        # int: frames allowed in the queue at once
frame_queue: queue.Queue = queue.Queue(maxsize=MAX_IN_FLIGHT)

def on_frame(cam, frame):                # callback signature per your SDK reference
    img = frame.copy_pixels()            # generic name: the documented copy-out call
    try:
        frame_queue.put_nowait((img, frame.get_timestamp()))
    except queue.Full:
        pass                             # deliberate drop; count it if frames matter
    cam.requeue_frame(frame)             # generic name: the documented buffer-return call

Run the ten-trial log again after the change. Three results prove the fix: the queued count never exceeds 8, the thread count is identical every trial, and RSS plateaus within about 50 MB of its trial-1 value instead of stepping up by roughly 444 MB per trial.

If you would rather not carry a queue at all, the hardware trigger grab loop in Python builds the same capture single-threaded. The ownership discipline carries across runtimes too: the frame memory lifetime article for managed code covers the same rules in C#, and in LabVIEW the queue reuse notes with malleable VIs configure the equivalent buffer reuse.

The numbers, worked end to end

frame bytes = width × height × bytes per pixel queue ceiling = maxsize × frame bytes leak per trial = frames per trial × frame bytes (writer never exits)

Worked end to end for the example stream: one frame is 1456 × 1088 × 1 B = 1,584,128 B ≈ 1.6 MB. The 280-frame stack is 280 × 1,584,128 = 443,555,840 B ≈ 444 MB, so an orphaned writer leaks 444 MB per trial and a 12 GB budget runs out near trial 27. With maxsize 8 the ceiling is 8 × 1.6 MB ≈ 12.7 MB, and nothing scales with trial count any more.

Quantity Example value What moves it
Frame bytes, Mono8 1456 × 1088 × 1 B = 1.6 MB Pixel format: Mono16 doubles it, RGB8 triples it
Queue ceiling, maxsize 8 8 × 1.6 MB ≈ 12.7 MB maxsize and frame bytes
Producer rate at 60 fps 60 × 1.6 MB ≈ 95 MB/s Trigger rate
Backlog rate, consumer at 20 fps 40 × 1.6 MB ≈ 63 MB/s The producer–consumer rate gap
Unfixed leak per 280-frame trial 280 × 1.6 MB ≈ 444 MB Frames per trial × frame bytes
Trials to exhaust 12 GB free 12,000 MB ÷ 444 MB ≈ 27 Free RAM ÷ leak per trial

Resolution moves the first row, not the logic: a 4000 × 3000 Mono8 frame is 4000 × 3000 × 1 B = 12.0 MB, so the same eight-slot queue holds about 96 MB. Still bounded — just four times higher.

Checks before you call it fixed

Run each check at the stage where it catches the failure:

While writing the loop:

  • Construct the queue with maxsize from the first line — unbounded is the default, and the default is the bug.
  • Copy pixels inside the callback and return the buffer on every path — the driver pool is finite.
  • Preallocate the result stack once per trial and write frames into it in place — 280 × 1.6 MB ≈ 444 MB allocated once, not 280 separate allocations.

Per trial:

  • Log RSS, queued and thread count on one line — three numbers, three signatures, one grep.
  • join() the writer with a timeout and assert it exited — a thread blocked on get() leaks its locals even with a bounded queue.
  • Read the SDK's dropped-frame counter alongside RSS — rising drops with flat memory is a throughput problem, not a leak.

Before you ship:

  • Run twenty or more idle trials between captures — the per-trial step only shows across trials.
  • Trigger at the production rate, not a bench rate — every rate figure above scales with fps.

When it is not the queue, and what to order next

Flat memory with missing frames means the writer cannot keep pace: the frame-rate gap article shows how to measure acquisition against processing, and the hardware trigger frame rate article explains the ceiling an external trigger imposes. A stream that stops after exactly one frame is trigger configuration, not memory — see the one-frame trigger fix. Frames that arrive slower than expected are best measured directly with the frame rate measurement how-to, and the set-up that makes each trigger window deterministic — trigger source, activation, the free-run default — is covered in the per-camera trigger configuration guide.

The queue discipline also matters more as resolution rises, because every ceiling above scales with frame bytes. For bench work this loop typically runs on a USB3 area scan camera; the LS-CMR-0166 is a 12 MP USB3 model with a 1/1.7" rolling-shutter sensor, 4000 × 3000 resolution and 30 fps.

Area scan cameras12 MP USB3 area scan camera, IMX226, 1/1.7" rolling shutter, 30 fps, 4000 × 3000 resolution (LS-CMR-0166)LS-CMR-0166
Sensor format
1/1.7"
Interface
USB3
Resolution
12 MP
Shutter
Rolling
$560.00View partBuild a system with it

On a triggered line, where every frame must correspond to exactly one exposure, a global-shutter GigE model is the standard choice: the MV-CU020-90GC is a 2.5 MP color camera with a 1/1.7" sensor, global shutter and a C-mount.

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
Interface
GigE
Resolution
Up to 3 MP
$309.00View partBuild a system with it

Next step: shortlist the camera in the area scan cameras category or the camera finder, then step through the camera configurator for sensor, interface and mount. If a memory or trigger symptom does not match anything above, send the per-trial log from the first test through the contact page and we will read it with you.

Questions

Why doesn't gc.collect() release the queued frames?

Because the frames are still reachable, and the collector only frees unreachable objects. The queue holds a strong reference to every tuple put into it, and a blocked writer thread's stack holds its locals, including a multi-hundred-MB result array. Remove the references instead: bound the queue, let the writer exit and join it. Memory then returns without any explicit collector call.

How large should the frame queue be?

Size it to absorb the burst the writer cannot: the SDK's driver buffer count plus a few frames of slack is a sound rule. With a writer keeping pace at 60 fps and 1.6 MB frames, eight slots give a 12.7 MB ceiling and drop almost nothing. Raise maxsize only for a known slow phase in the writer, and recompute the ceiling as maxsize × frame bytes first.

Is this leak specific to one camera SDK or interface?

No. The queue and thread behaviour is plain Python and identical for GigE and USB3 cameras; only the copy-out and buffer-return call names change between SDKs. If RSS still creeps after the fix on a GigE set-up, check the driver's socket buffer sizes before suspecting the code, and log RSS per trial so you can see which trial profile the growth follows.

How do the numbers change for 16-bit or color frames?

Every figure scales with bytes per pixel: Mono8 uses 1, Mono16 and packed Mono12 use 2, RGB8 uses 3. A 1456 × 1088 Mono16 frame is 1456 × 1088 × 2 = 3,168,256 bytes, about 3.2 MB, so the same eight-slot queue holds about 25 MB and a 280-frame stack is about 887 MB. Double or triple the worked table to match your pixel format.