System designTroubleshooting

GUI freezes during image capture: run fetch off the main thread

Why a GUI freezes during image capture: the fetch blocks the GUI thread with no timeout. The fix: a worker thread, a 1000 ms timeout and safe recovery.

LumaSpec Vision12 min read Share on LinkedIn

On this page 9 sections
  1. 01Why does the whole GUI freeze when one image is taken?
  2. 02Which call is actually blocking?
  3. 03How do you move capture off the GUI thread?
  4. 04How long should the fetch timeout be?
  5. 05What is the safe recovery path after a timeout?
  6. 06Is the hang the camera, the cable, or your code?
  7. 07Should the GUI trigger the light as well?
  8. 08How do you prove the fix before shipping?
  9. 09Questions

You press Capture, the image sometimes appears, and sometimes the whole window goes pale and the operating system calls the program unresponsive. The camera is usually fine; your GUI thread is stuck inside a call that waits for a frame that never arrives. This guide is for engineers building a desktop or operator interface over a GenTL consumer — the open-source Harvester Python bindings are a common one — in PyQt5 or any other toolkit. You will find which calls block, the timeout that turns an infinite hang into a one-second failure, the worker-thread pattern that keeps the window alive, and the recovery sequence that rebuilds the acquirer without crossing threads. Every number comes from a 5472 × 3648 BayerRG8 single-frame use case on GigE.

Why does the whole GUI freeze when one image is taken?

The GUI thread is blocked inside a call that waits for a frame that never arrives; a fetch with no timeout waits forever, and while it waits the window cannot repaint, so the operating system reports the program as not responding.

Every desktop toolkit runs one thread that paints the window and executes button slots. PyQt5 is no different: the slot behind your Capture button, and everything it calls, runs on that thread. When the slot reaches with self.ia.fetch_buffer() as buffer:, the GUI thread enters the transport stack and returns only when a complete frame is delivered — or never, if the trigger was lost or the link dropped. On Windows, a window that stops processing messages for about 5 s is greyed out and labelled "not responding": that grey-out is your symptom, not a crash.

A retry wrapper makes it worse. A shoot_image_safe() that catches the exception and calls close_connection() now blocks the window a second time, because self.ia.destroy() and self.h.reset() also sit on the link that just failed. One missed frame becomes a hang with no exit. The same event-loop rule is why a camera feature browser freezes during image acquisition: any blocking call on the GUI thread, not only the fetch, freezes the window.

Which call is actually blocking?

Two families of call block: the frame fetch, which waits for a whole image, and every GenICam node access such as remote_device.node_map.PixelFormat.value, which makes a network round trip to the camera before it returns.

The fetch is the longest wait and the usual suspect, but it is not the only one. Every feature node you read or write — Width, Height, PixelFormat, AcquisitionMode — is a register transaction with the camera. Enumeration (update_device_info_list()) broadcasts and collects replies; teardown (destroy(), reset()) flushes buffers and closes the producer. None of them belongs on the GUI thread. Orders of magnitude on a GigE link:

Call on the worker What it waits for Order of magnitude on GigE
fetch_buffer() one complete frame 200 ms at 20 MP, see the worked example
remote_device.node_map read or write one register round trip 1-10 ms
update_device_info_list() full device enumeration 0.5-2 s
destroy() then reset() buffer and producer teardown 0.1-1 s

One trap worth knowing: ia.device and ia.remote_device are different objects. device is the producer's local device module; remote_device is the camera, and that is where Width, Height and PixelFormat live. Reading nodes through the wrong one gives errors at best and a hang at worst. In one reported case, camera-node reads hung on bindings release 0.2.18 and ran clean on 1.2.0, so rule out the release before blaming the hardware.

Pin the blocking call with log lines: one timestamp immediately before and one after each call, flushed to disk. The call whose "after" line never appears is your hang. Then run the suspect in a console loop, 100 iterations, no GUI: a loop that hangs points at the bindings or the link; a loop that never hangs, with a GUI that still freezes, convicts the GUI thread.

How do you move capture off the GUI thread?

Give the camera one worker thread that owns the acquirer and every blocking call, and hand finished images to the window through a queue or a signal; the GUI thread then reads that queue without ever blocking.

Ownership is the rule: exactly one thread touches the acquirer and the Harvester object. The window posts commands and receives images or error text; it never calls the camera itself. PyQt5's signals and slots exist for this hand-off, and a queue.Queue with a 100 ms polling timer does the same job in any toolkit. It is the pattern that keeps a camera live preview in GUI applications painting at frame rate while captures run.

import queue
import threading

CAPTURE_TIMEOUT_MS = 1000   # example value; the next section derives it

class CaptureWorker:
    """Example worker: the only thread allowed to touch the camera.
    Own variables: cti_path (str), cmd_q, result_q, thread. The API names
    (Harvester, add_cti_file, update_device_info_list, create_image_acquirer,
    start_image_acquisition, stop_image_acquisition, destroy, reset,
    fetch_buffer) are used as the bindings print them."""

    def __init__(self, cti_path: str):
        self.cti_path = cti_path
        self.cmd_q = queue.Queue()     # GUI -> worker: 'capture' | 'stop'
        self.result_q = queue.Queue()  # worker -> GUI: ('image', array) | ('error', text)
        self.thread = threading.Thread(target=self.run, daemon=True)

    def start(self) -> None:
        self.thread.start()

    def run(self) -> None:
        h = Harvester()                      # your bindings' Harvester class
        h.add_cti_file(self.cti_path)
        h.update_device_info_list()
        ia = h.create_image_acquirer(0)
        ia.remote_device.node_map.PixelFormat.value = 'BayerRG8'
        ia.start_image_acquisition()
        while True:
            cmd = self.cmd_q.get()
            if cmd == 'capture':
                try:
                    with ia.fetch_buffer() as buffer:
                        part = buffer.payload.components[0]
                        image = part.data.reshape(
                            part.height, part.width).copy()
                    self.result_q.put(('image', image))
                except Exception as err:      # timeout, link error
                    self.result_q.put(('error', repr(err)))
                    ia = self.rebuild(h)      # recovery stays on this thread
            elif cmd == 'stop':
                ia.stop_image_acquisition()
                ia.destroy()
                h.reset()
                return

    def rebuild(self, h):
        h.reset()
        h.add_cti_file(self.cti_path)
        h.update_device_info_list()
        ia = h.create_image_acquirer(0)
        ia.remote_device.node_map.PixelFormat.value = 'BayerRG8'
        ia.start_image_acquisition()
        return ia

On the window side, poll result_q from a 100 ms timer with a non-blocking read, and re-enable the Capture button only when the worker answers. If your bindings' fetch_buffer() accepts a timeout value, pass 1000 ms there so the except path fires on schedule; if it takes none, the daemon-thread backstop covers you — the window keeps painting, you retire that worker and start a fresh one with a rebuilt acquirer. Wait once per frame, and never queue two fetches for one trigger: the reasoning in frame didn't arrive within timeout: one wait call per frame applies to any toolkit.

How long should the fetch timeout be?

Set 1000 ms for a single-frame 20 MP fetch on GigE; the rule behind it is three to four times the worst-case trigger-to-image time, which the example below computes as 260 ms.

payload = 5472 px × 3648 px × 1 B/px = 19,961,856 B ≈ 20 MB transfer = 20 MB ÷ 100 MB/s = 0.20 s = 200 ms worst = 10 ms exposure + 30 ms readout + 200 ms transfer + 20 ms overhead = 260 ms timeout = 4 × 260 ms ≈ 1000 ms

Where the numbers come from: BayerRG8 carries 1 byte per pixel, so 5472 × 3648 pixels is 19,961,856 bytes, about 20 MB. A 1000 Mbit/s GigE link delivers roughly 100 MB/s of image payload with jumbo frames enabled, closer to 80 MB/s with standard frames, so the frame spends 200 ms in transit. The 10 ms exposure and 30 ms readout are example values for a strobed shot on a rolling-shutter sensor; the 20 ms overhead covers trigger latency and the copy out of the buffer pool. Four times 260 ms is 1040 ms, so 1000 ms keeps nearly four times the worst case: frames that can arrive do arrive, and frames that cannot fail inside a second.

Going below about 800 ms risks aborting mid-transfer on a busy link; going to 5000 ms only makes the operator wait longer for a certain failure. On the worker thread the timeout bounds recovery time, not window responsiveness, so resist padding it. Scale it with the link instead: USB3 at about 400 MB/s moves the same 20 MB in 50 ms, and a 5GBASE-T link does it in about 40 ms — see 5GBASE-T camera interface: bandwidth, cables and distance — so 500 ms is comfortable there.

Smaller frames shrink the budget the same way. A 6 MP Bayer frame on a 1/1.8" rolling-shutter, C-mount GigE camera is about 6 MB, roughly 60 ms on the same link, so the same 1000 ms timeout holds more than fifteen times that transfer time.

Area scan camerasHikrobot MV-CT0MG0060/R/NN/NN 6 MP Area Scan Camera, 3rd Gen., GigE, Mono, BASEMV-CT0MG0060/R/NN/NN
Sensor format
1/1.8"
Lens mount
C-mount
Interface
GigE
Resolution
6-10 MP
$909.00View partBuild a system with it

What is the safe recovery path after a timeout?

Rebuild the whole acquisition stack on the worker thread, never on the GUI thread: stop_image_acquisition(), destroy(), producer reset(), then create the acquirer again, re-apply your node settings and restart.

  1. Report the failure to the window immediately, on the result queue, so the operator knows the shot failed.
  2. On the worker, stop and destroy: stop_image_acquisition(), then destroy() on the acquirer, then reset() on the Harvester object. Guard each call — after a link loss any one of them can throw, and the sequence must continue.
  3. Rebuild: add_cti_file() with your .cti path, update_device_info_list(), create_image_acquirer(0).
  4. Re-apply every node the application sets — Width, Height, PixelFormat, AcquisitionMode — because a fresh acquirer starts from the device's power-on settings. In the running example that is Width 5472, Height 3648, PixelFormat 'BayerRG8'.
  5. Call start_image_acquisition() and take one throwaway frame if your lighting needs settling time — see first camera frame dark: exposure settling or light timing.
  6. Allow exactly one automatic retry; a second consecutive failure goes to the operator as a fault with the last error text attached.

Order matters twice over. Calling start_image_acquisition() while acquisition is still live reports busy — the failure mode in single frame acquisition still busy: why repeated starts need a stop. And never run this sequence from a button slot: teardown blocks on the same link, and the window freezes at exactly the moment the user is watching it.

Is the hang the camera, the cable, or your code?

Blame your code first: a blocking call on the GUI thread explains most frozen windows over a healthy camera; then the cable and connector, then the bindings release, and the camera last.

Test in that order, cheapest first:

  1. Code. Run the capture in a console loop, 500 shots, plus a 100-iteration loop that only reads PixelFormat, Width and Height through remote_device.node_map. Console clean and GUI frozen is a conviction: move the code, as in the worker section above.
  2. Cable and connector. Reseat the plug, check the locking screws, swap in a known-good cable and watch the switch or NIC error counters during a hang. A half-seated RJ45 passes small register packets and drops image payloads — node reads succeed while the fetch never returns, which is the signature to look for. Keep a few buffers in the acquirer's pool (read num_buffers) so the camera always has somewhere to write while you copy one image out.
  3. Bindings. Upgrade to the current release before debugging hardware; the 0.2.18 to 1.2.0 case above started right there.
  4. Camera configuration. In SingleFrame acquisition mode one lost trigger means no frame, ever; the fetch then needs its timeout. Continuous mode with the worker taking the freshest frame is more forgiving — see continuous camera acquisition mode for live image loops — and one-shot capture has timing traps of its own in software trigger image timeout: why one-shot capture misses frames.

When the cable is the suspect on a moving station, use one rated for the motion. The LS-CBL-0199 is a 3 m GigE camera cable built for drag-chain use, with screw locks holding both ends. Browse camera cables for the length and flex rating your station needs.

Cables3 m GigE camera cable, high-flex (drag chain), screw locks (LS-CBL-0199)LS-CBL-0199$54.00View partBuild a system with it

Should the GUI trigger the light as well?

No — wire the light's strobe input to the camera's exposure output through a light controller; the GUI should arm the sequence, not time the flash, because GUI-timed light routinely misses the exposure.

A light switched on from the GUI thread races the exposure across two threads and a network; when they miss each other you fetch a perfectly timed, completely black frame. Wire the controller's strobe input to the camera's exposure output so light and integration are one event; the GUI only arms and disarms. The LS-CTL-0290 is a digital constant-voltage light controller with 4 channels, 24 V output and 60 W, sized for a strobed ring or bar light. Wiring the strobe uses the camera's I/O lines rather than the GUI — the guide on camera GPIO cables for image acquisition covers which cable carries it. Browse light controllers for channel counts and strobe features.

Light controllersDigital constant-voltage light controller, 60 W, 4 channels (LS-CTL-0290)LS-CTL-0290
Output
Constant voltage 24 V
Channels
4 channels
Control
Digital
$449.00View partBuild a system with it

How do you prove the fix before shipping?

Pull the Ethernet cable out mid-capture: the window must keep repainting, the failure must reach the operator inside 1000 ms, and the first capture after you plug it back in must deliver a full image.

  • Yank test: start a capture and unplug the cable. The window keeps repainting (drag it), an error appears within about 1 s, and the first capture after reconnection delivers a full image.
  • Soak: 500 captures at production cadence with timestamps around every blocking call; the log shows no gap.
  • Node loop: 100 reads of PixelFormat, Width and Height on the worker while the window animates; the animation never stutters.
  • Shutdown: close the window during an in-flight capture; the worker stops, destroys and the process exits with no orphaned threads.
  • Stack dumps: enable the standard-library faulthandler module in production, so any future hang prints the exact stack to standard error.

The decisions, by case: a frozen window with a clean console loop — move the fetch and every node access to a worker thread today. Timeouts on a link that tests good — replace or reseat the cable before touching code. Node reads that hang in every program — upgrade the bindings. Well-timed but black frames — move the strobe to the camera's exposure output. For the next station, start from the area scan cameras category, the camera finder or the configurator below, and bring capture-thread questions to the contact page.

Build the whole station

The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.

Start with the camera

Questions

Will a bigger timeout stop the failed captures?

No. A timeout never creates a frame; it only decides how long you wait before recovering. At 1000 ms a 20 MP GigE frame that needs about 260 ms has roughly four times the margin it requires, so frames that can arrive do arrive. Raising the timeout to 5000 ms just makes the operator watch a spinner for five seconds when the link is truly down. Shorten it below about 800 ms only after measuring your own worst-case trigger-to-image time on the production network.

Is it acceptable to create a new acquirer for every shot?

Yes, for a manual button pressed a few times a minute. One reported case ran exactly that pattern — create the acquirer, start acquisition, fetch one buffer, destroy — and the hangs stopped, because a fresh acquirer brings fresh, consistent state. The price is re-enumeration and node set-up on every shot, which adds on the order of a second per image on GigE and hides the underlying fault. For production rates, keep one acquirer alive on a worker thread and rebuild only after a failure.

Why did the hangs stop after a library upgrade?

Because some hangs live in the bindings, not in your code. The reported case ran release 0.2.18 of the GenTL Python bindings, where reads of camera nodes could hang; release 1.2.0 cleared it, and a 100-iteration loop reading PixelFormat, Width and Height ran clean afterwards. Keep the bindings current, but keep the worker thread anyway: a library fix removes one cause of blocking calls, while the GUI thread still cannot afford any of them.

Do I need one worker thread per camera?

Yes — give each camera its own worker with its own command and image queues, and never share an acquirer between threads. Two workers fetching from two cameras in parallel is safe; two threads calling fetch_buffer() on the same acquirer race on the same buffer queue. Aggregate in the window: each worker hands its images to its own slot, and the GUI only ever reads queues. Multi-camera trouble then comes from bandwidth, not threading.

How do I shut down cleanly when the operator closes the window?

Signal the worker before you touch the camera: post a stop command on its queue, join the thread with a bounded wait, and let the worker itself call stop_image_acquisition(), destroy() and reset() in that order. Destroying the acquirer from the close handler while a fetch is in flight reproduces the exact hang you just fixed, at shutdown, with no way to recover. A daemon thread is the backstop when the camera is already gone.