From 605807fe1c5d0ca69df13f83d500fd0ac34870e0 Mon Sep 17 00:00:00 2001 From: Hinderling Date: Fri, 15 May 2026 13:36:42 +0200 Subject: [PATCH 1/3] fix: stop live mode + pump Qt event loop during run_experiment Two related fixes inside _run_mda_with_events: 1. Auto-stop continuous sequence acquisition before MDA. If live mode is still running when the MDA's first snapImage fires, napari-micromanager's _core_link._image_snapped handler reads the snap buffer with getImage() before the engine can, and the engine raises "Camera image buffer read failed". Stopping the sequence unconditionally before MDA starts removes that contention. 2. Pump Qt events instead of plain time.sleep / thread.join. _run_mda_with_events runs on the main thread, the same thread napari paints from. The backpressure throttle (time.sleep(0.1)) and the trailing mda_thread.join() block the Qt event loop, so napari freezes for the duration of the run and any ensure_main_thread-queued callbacks accumulate until the cell exits. Replace with _pump_qt_and_sleep / _qt_join helpers that call QCoreApplication.processEvents() between waits. Both fall back to plain blocking if Qt isn't loaded, so headless/test runs are unaffected. --- faro/core/controller.py | 64 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/faro/core/controller.py b/faro/core/controller.py index b3e92a0..3761ac6 100644 --- a/faro/core/controller.py +++ b/faro/core/controller.py @@ -577,6 +577,13 @@ def __init__(self, mic, pipeline, *, writer: Writer | None = None): pipeline: ImageProcessingPipeline instance. writer: Storage backend. If None, Analyzer uses TiffWriter (default). Pass an OmeZarrWriter for OME-Zarr output. + + Note: + ``run_experiment`` automatically stops any continuous sequence + acquisition (live mode) before starting MDA, and pumps the Qt + event loop while waiting on the MDA worker, so napari-mm's own + ``preview`` layer keeps updating throughout the run without the + window freezing. """ self._mic = mic self._pipeline = pipeline @@ -836,6 +843,18 @@ def _validate_fov_positions(self, events): def _run_mda_with_events(self, events, *, stim_mode): """Run the MDA event loop — shared by run/continue_experiment.""" + # Live mode (continuous sequence acquisition) and MDA both drive the + # camera. If live is still running when the MDA's first snapImage + # fires, the snap buffer is consumed by the live-poll listener (in + # napari-micromanager: ``_core_link._image_snapped``) before the + # engine calls getImage, and the engine raises "Camera image buffer + # read failed". Stop it unconditionally before MDA starts. + try: + if self._mic.mmc.isSequenceRunning(): + self._mic.mmc.stopSequenceAcquisition() + except Exception: + pass + self._mic.connect_frame(self._on_frame_ready) # Set up event queue for extend_experiment support. @@ -867,7 +886,7 @@ def _run_mda_with_events(self, events, *, stim_mode): break while self._queue.qsize() >= 3: - time.sleep(0.1) + self._pump_qt_and_sleep(0.05) self._n_channels = len(rtm_event.channels) # In "previous" mode at t=0 there is no predecessor @@ -909,7 +928,7 @@ def _run_mda_with_events(self, events, *, stim_mode): self._event_queue = None self._queue.put(self.STOP_EVENT) if mda_thread is not None: - mda_thread.join() + self._qt_join(mda_thread) self._mic.disconnect_frame(self._on_frame_ready) if self._fatal_error is not None: @@ -917,6 +936,47 @@ def _run_mda_with_events(self, events, *, stim_mode): self._fatal_error = None raise fatal + # ------------------------------------------------------------------ + # Qt-event-loop hygiene + # ------------------------------------------------------------------ + # + # _run_mda_with_events runs on the main thread — the same thread napari + # paints from. Without explicit pumping, time.sleep / thread.join starve + # the Qt event loop, so napari freezes for the duration of the run and + # any main-thread-queued updates (e.g. napari-micromanager's preview + # layer refreshes) stay in the queue until the cell exits. Both helpers + # below fall back to plain blocking if Qt isn't loaded at all. + + @staticmethod + def _pump_qt_and_sleep(dt: float) -> None: + try: + from qtpy.QtCore import QCoreApplication + except Exception: + time.sleep(dt) + return + app = QCoreApplication.instance() + if app is None: + time.sleep(dt) + return + app.processEvents() + time.sleep(dt) + + @staticmethod + def _qt_join(thread: threading.Thread, poll_s: float = 0.05) -> None: + try: + from qtpy.QtCore import QCoreApplication + except Exception: + thread.join() + return + app = QCoreApplication.instance() + if app is None: + thread.join() + return + while thread.is_alive(): + app.processEvents() + thread.join(timeout=poll_s) + app.processEvents() + # ------------------------------------------------------------------ # Frame handling # ------------------------------------------------------------------ From 1ee582109ae5f1a909aa94f7343c9bcdf0bcd358 Mon Sep 17 00:00:00 2001 From: hinderling Date: Fri, 15 May 2026 14:25:42 +0200 Subject: [PATCH 2/3] Narrow the stop-sequence guard to only the no-mmc case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous try/except swallowed three distinct failure modes — missing ``mmc`` attribute, RPC failure on proxy microscopes, and real core/hardware errors from ``isSequenceRunning`` or ``stopSequenceAcquisition`` — all silently. Surface the latter two so genuine failures aren't hidden behind a pre-emptive cleanup. --- faro/core/controller.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/faro/core/controller.py b/faro/core/controller.py index 3761ac6..1fdc64f 100644 --- a/faro/core/controller.py +++ b/faro/core/controller.py @@ -849,11 +849,9 @@ def _run_mda_with_events(self, events, *, stim_mode): # napari-micromanager: ``_core_link._image_snapped``) before the # engine calls getImage, and the engine raises "Camera image buffer # read failed". Stop it unconditionally before MDA starts. - try: - if self._mic.mmc.isSequenceRunning(): - self._mic.mmc.stopSequenceAcquisition() - except Exception: - pass + mmc = getattr(self._mic, "mmc", None) + if mmc is not None and mmc.isSequenceRunning(): + mmc.stopSequenceAcquisition() self._mic.connect_frame(self._on_frame_ready) From 5c77ffeddbf5d778670445208dfa0c3e9dac4d37 Mon Sep 17 00:00:00 2001 From: Hinderling Date: Fri, 15 May 2026 14:32:55 +0200 Subject: [PATCH 3/3] fix: pump Qt while waiting on the pipeline's stim mask Controller._build_stim_slm (main thread) calls Analyzer.get_stim_mask, which blocks on FrameDispenser.wait_for_frame -- a plain threading.Condition.wait() with no Qt awareness. The actual stim-mask compute is already off-main (it runs in the Analyzer's ThreadPoolExecutor alongside cellpose / DINOv3 / tracking), but the main thread's wait for that result still freezes the Qt event loop, so napari hangs for the GPU's worth of seconds on every stim frame. Slice the wait into 50 ms chunks and call QCoreApplication.processEvents() between attempts, preserving the caller-supplied total timeout. Falls back to a plain wait_for_frame if Qt isn't loaded, so headless / test runs keep their original behaviour. --- faro/core/controller.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/faro/core/controller.py b/faro/core/controller.py index 1fdc64f..1e69abc 100644 --- a/faro/core/controller.py +++ b/faro/core/controller.py @@ -166,6 +166,42 @@ def stimulator_needs_data(self) -> bool: return False return isinstance(self.pipeline.stimulator, (StimWithImage, StimWithPipeline)) + @staticmethod + def _wait_for_frame_pumping_qt( + queue_obj, frame_idx: int, timeout: float, poll_s: float = 0.05 + ): + """Wait for a frame on a ``FrameDispenser`` while pumping Qt events. + + ``FrameDispenser.wait_for_frame`` is a plain ``threading.Condition.wait()`` + with no Qt awareness. Called from the main thread (as + ``Controller._build_stim_slm`` does), it freezes the Qt event loop + for as long as the pipeline takes to produce the stim mask -- napari + freezes during every stim frame even though the actual GPU work is + on the pipeline's executor. + + Poll with a short timeout and call ``QCoreApplication.processEvents`` + between attempts, preserving the caller-supplied total timeout. + Falls back to a plain ``wait_for_frame`` if Qt isn't loaded. + """ + try: + from qtpy.QtCore import QCoreApplication + except Exception: + return queue_obj.wait_for_frame(frame_idx, timeout=timeout) + app = QCoreApplication.instance() + if app is None: + return queue_obj.wait_for_frame(frame_idx, timeout=timeout) + + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + slice_dt = max(0.0, min(poll_s, remaining)) + try: + return queue_obj.wait_for_frame(frame_idx, timeout=slice_dt) + except QueueEmpty: + if remaining <= 0: + raise + app.processEvents() + def get_stim_mask( self, fov_index: int, metadata: dict, *, timeout: float | None = None ) -> np.ndarray | None: @@ -185,8 +221,8 @@ def get_stim_mask( timeout = self._stim_mask_timeout frame_idx = metadata.get("timestep", 0) try: - mask = fov_state.stim_mask_queue.wait_for_frame( - frame_idx, timeout=timeout + mask = self._wait_for_frame_pumping_qt( + fov_state.stim_mask_queue, frame_idx, timeout ) except QueueEmpty as e: # _build_stim_slm still log-and-continues with False, but