diff --git a/software/control/_def.py b/software/control/_def.py index c53fd1e5b..d53df10e2 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -830,6 +830,12 @@ class SOFTWARE_POS_LIMIT: ENABLE_WELLPLATE_MULTIPOINT = True ENABLE_RECORDING = False +# Throttled live preview during Record + Z-Stack acquisitions: frames/s shown in +# the live display during the recording phase. When preview is enabled, z-stack +# planes display unthrottled (like multipoint). 0 disables the preview for both +# phases. +RECORD_ZSTACK_DISPLAY_FPS = 3 + RESUME_LIVE_AFTER_ACQUISITION = True # When enabled, each multipoint acquisition will write a second log file scoped to that acquisition at: diff --git a/software/control/core/record_zstack_controller.py b/software/control/core/record_zstack_controller.py index 50d8a1504..5f117f059 100644 --- a/software/control/core/record_zstack_controller.py +++ b/software/control/core/record_zstack_controller.py @@ -239,6 +239,8 @@ def __init__( objective_store, scan_coordinates, callbacks, + display_frame_fn=None, + display_fps: float = 0.0, ): self._microscope = microscope self._live_controller = live_controller @@ -246,6 +248,8 @@ def __init__( self._objective_store = objective_store self._scan_coordinates = scan_coordinates self._callbacks = callbacks + self._display_frame_fn = display_frame_fn + self._display_fps = display_fps self._abort_event: Event = Event() self._worker = None @@ -352,6 +356,8 @@ def run_acquisition(self, params: RecordZStackAcquisitionParameters) -> None: scan_region_fov_coords=scan_region_fov_coords, prewarmed_job_runner=prewarmed_runner, prewarmed_bp_values=prewarmed_bp_values, + display_frame_fn=self._display_frame_fn, + display_fps=self._display_fps, ) except Exception: # Clean up the pre-warmed runner if worker construction failed. diff --git a/software/control/core/record_zstack_worker.py b/software/control/core/record_zstack_worker.py index 3d7a0a337..cec2962da 100644 --- a/software/control/core/record_zstack_worker.py +++ b/software/control/core/record_zstack_worker.py @@ -78,6 +78,8 @@ def __init__( scan_region_fov_coords: Dict[object, List[Tuple]], prewarmed_job_runner: Optional[JobRunner] = None, prewarmed_bp_values: Optional[BackpressureValues] = None, + display_frame_fn: Optional[Callable] = None, + display_fps: float = 0.0, ): super().__init__( scope=scope, @@ -88,6 +90,9 @@ def __init__( ) self.params = params + # Plain-callable live-preview tap (Qt-free; supplied by the GUI wrapper). + self._display_frame_fn = display_frame_fn + self._display_fps = display_fps self.laser_af = laser_auto_focus_controller self.objectiveStore = objective_store self._scan: Dict[object, List[Tuple]] = scan_region_fov_coords or {} @@ -470,6 +475,8 @@ def _record_one_plane( CountStop(T), writer, abort_fn=self.abort_requested_fn, + display_fn=self._display_frame_fn, + display_fps=self._display_fps, ) # Generous timeout: enough to gather T frames even at a slow effective rate. timeout = self.params.duration_s * 3 + 5 diff --git a/software/control/core/streaming_capture.py b/software/control/core/streaming_capture.py index 4f22b2964..cfa9b4ff4 100644 --- a/software/control/core/streaming_capture.py +++ b/software/control/core/streaming_capture.py @@ -425,7 +425,7 @@ class StreamingCapture: met or ``abort_fn`` returns True. The ``_on_frame`` callback runs on the hot camera thread — it must stay cheap - (route + enqueue only, no blocking I/O). + (route + enqueue + a throttled non-blocking preview tap; no blocking I/O). Args: frame_source: Any object with ``start(on_frame)`` / ``stop()`` interface. @@ -433,12 +433,25 @@ class StreamingCapture: stop_condition: ``CountStop`` (or compatible) — ``met(emitted)`` returns bool. writer: Object with ``start()``, ``enqueue(frame,t,c,z)``, ``finalize()``, ``abort()``. abort_fn: Zero-argument callable; returns True to abort early. + display_fn: Optional callable receiving a frame (np.ndarray) for live + preview, throttled to display_fps. Failures are logged once and + disable the preview for this capture; they never affect recording. + display_fps: Preview rate in frames/s. <= 0 disables the preview tap. timeout: Optional seconds to wait for completion. If the source does not trigger the done event within this time ``run()`` still stops and finalizes (returns frames emitted so far). None means wait forever. """ - def __init__(self, frame_source, router, stop_condition, writer, abort_fn: Callable[[], bool]): + def __init__( + self, + frame_source, + router, + stop_condition, + writer, + abort_fn: Callable[[], bool], + display_fn: Optional[Callable] = None, + display_fps: float = 0.0, + ): self._source = frame_source self._router = router self._stop = stop_condition @@ -451,8 +464,14 @@ def __init__(self, frame_source, router, stop_condition, writer, abort_fn: Calla # resolve it once here instead of re-probing on every hot-path frame. self._expected: Optional[int] = stop_condition.expected() if hasattr(stop_condition, "expected") else None + # Throttled live-preview tap (plain callable — no Qt in this module). + # display_fps <= 0 disables the tap entirely. + self._display_fn = display_fn if (display_fn is not None and display_fps > 0) else None + self._display_interval = (1.0 / display_fps) if display_fps > 0 else float("inf") + self._last_display_ts = float("-inf") + def _on_frame(self, camera_frame) -> None: - """Hot-thread callback: route + enqueue only. Must not block.""" + """Hot-thread callback: route + enqueue + throttled preview tap. Must not block.""" if self._done.is_set(): return if self._abort_fn(): @@ -467,6 +486,7 @@ def _on_frame(self, camera_frame) -> None: if self._stop.met(self._emitted): self._done.set() return + self._maybe_display(camera_frame) idx = self._router.route(camera_frame.timestamp) if idx is not None: if self._expected is not None and idx[0] >= self._expected: @@ -479,6 +499,20 @@ def _on_frame(self, camera_frame) -> None: if self._stop.met(self._emitted): self._done.set() + def _maybe_display(self, camera_frame) -> None: + """Throttled preview tap. Must never harm the recording: any exception + is logged once and disables the preview for the rest of this capture.""" + if self._display_fn is None: + return + if camera_frame.timestamp - self._last_display_ts < self._display_interval: + return + try: + self._display_fn(camera_frame.frame) + self._last_display_ts = camera_frame.timestamp + except Exception: + _log.exception("live-preview display_fn failed; disabling preview for this capture") + self._display_fn = None + def run(self, timeout: Optional[float] = None) -> int: """Start capture, block until done (or timeout), and return emitted count.""" self._writer.start() diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 5c7d095e7..72407c110 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -602,11 +602,13 @@ class QtRecordZStackController(RecordZStackController, QObject): """Qt-aware wrapper for RecordZStackController. Emits ``acquisition_finished`` as a thread-safe Qt signal so the widget - can reset its Start button via a queued connection, regardless of which - thread the worker runs on. + can reset its Start button via a queued connection, and ``image_to_display`` + for the throttled acquisition live preview (both phases), regardless of + which thread the worker runs on. """ acquisition_finished = Signal() + image_to_display = Signal(np.ndarray) def __init__( self, @@ -616,12 +618,21 @@ def __init__( objective_store, scan_coordinates, ): - # Map signal_acquisition_finished onto our Qt signal; everything else - # stays a no-op. Note the worker's inherited capture machinery also - # invokes signal_current_configuration / signal_new_image — this mode - # has no live-display routing yet, so those emissions are deliberately - # dropped here. - callbacks = dataclasses.replace(NoOpCallbacks, signal_acquisition_finished=self._on_acquisition_finished) + import control._def + + display_fps = float(getattr(control._def, "RECORD_ZSTACK_DISPLAY_FPS", 0)) + preview_on = display_fps > 0 + + # Map signal_acquisition_finished onto our Qt signal. signal_new_image + # carries the z-stack phase's per-plane frames (the inherited + # MultiPointWorkerBase capture path invokes it); the recording phase + # uses the plain display_frame_fn instead. Both funnel into + # image_to_display. Everything else stays a no-op. + callbacks = dataclasses.replace( + NoOpCallbacks, + signal_acquisition_finished=self._on_acquisition_finished, + signal_new_image=self._on_new_image if preview_on else NoOpCallbacks.signal_new_image, + ) RecordZStackController.__init__( self, microscope=microscope, @@ -630,12 +641,22 @@ def __init__( objective_store=objective_store, scan_coordinates=scan_coordinates, callbacks=callbacks, + display_frame_fn=self._emit_display_frame if preview_on else None, + display_fps=display_fps, ) QObject.__init__(self) def _on_acquisition_finished(self): self.acquisition_finished.emit() + def _on_new_image(self, frame, info) -> None: + """Z-stack per-plane preview (signature: CameraFrame, CaptureInfo).""" + self.image_to_display.emit(frame.frame) + + def _emit_display_frame(self, image) -> None: + """Recording-phase preview: plain ndarray from StreamingCapture's tap.""" + self.image_to_display.emit(image) + class HighContentScreeningGui(QMainWindow): fps_software_trigger = 100 @@ -1631,6 +1652,10 @@ def make_connections(self): self.multipointController.image_to_display.connect( lambda image: self.napariLiveWidget.updateLiveLayer(image, from_autofocus=False) ) + if self.recordZStackController is not None: + self.recordZStackController.image_to_display.connect( + lambda image: self.napariLiveWidget.updateLiveLayer(image, from_autofocus=False) + ) self.napariLiveWidget.signal_coordinates_clicked.connect(self.move_from_click_image) self.liveControlWidget.signal_live_configuration.connect(self.napariLiveWidget.set_live_configuration) @@ -1643,6 +1668,8 @@ def make_connections(self): self.imageDisplay.image_to_display.connect(self.imageDisplayWindow.display_image) self.autofocusController.image_to_display.connect(self.imageDisplayWindow.display_image) self.multipointController.image_to_display.connect(self.imageDisplayWindow.display_image) + if self.recordZStackController is not None: + self.recordZStackController.image_to_display.connect(self.imageDisplayWindow.display_image) self.liveControlWidget.signal_autoLevelSetting.connect(self.imageDisplayWindow.set_autolevel) self.imageDisplayWindow.image_click_coordinates.connect(self.move_from_click_image) self.imageDisplayWindow.signal_z_um_delta.connect(self.move_z_from_scroll) @@ -1813,6 +1840,13 @@ def makeNapariConnections(self): (self.napariLiveWidget.signal_coordinates_clicked, self.move_from_click_image), (self.liveControlWidget.signal_live_configuration, self.napariLiveWidget.set_live_configuration), ] + if self.recordZStackController is not None: + self.napari_connections["napariLiveWidget"].append( + ( + self.recordZStackController.image_to_display, + lambda image: self.napariLiveWidget.updateLiveLayer(image, from_autofocus=False), + ) + ) if USE_NAPARI_FOR_LIVE_CONTROL: self.napari_connections["napariLiveWidget"].extend( diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 613f1996c..958cc30af 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -170,3 +170,53 @@ def recording_micro_close(): gui._cleanup_common(for_restart=True) assert calls == ["stage", "microcontroller"] + + +def test_record_zstack_preview_signal(qtbot, monkeypatch, confirm_exit_yes): + """Both preview paths funnel into QtRecordZStackController.image_to_display: + the z-stack signal_new_image callback and the recording display callable.""" + from types import SimpleNamespace + import numpy as np + + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + monkeypatch.setattr(control._def, "RECORD_ZSTACK_DISPLAY_FPS", 3) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + ctrl = win.recordZStackController + assert ctrl is not None + + frames = [] + ctrl.image_to_display.connect(frames.append) + + # Z-stack path: the per-plane callback emits the frame array. + fake = SimpleNamespace(frame=np.zeros((4, 4), np.uint16)) + ctrl._callbacks.signal_new_image(fake, None) + assert len(frames) == 1 + + # Recording path: the plain callable handed to the worker emits too. + assert ctrl._display_frame_fn is not None + assert ctrl._display_fps == 3 + ctrl._display_frame_fn(np.ones((4, 4), np.uint16)) + assert len(frames) == 2 + + +def test_record_zstack_preview_disabled_at_zero_fps(qtbot, monkeypatch, confirm_exit_yes): + from types import SimpleNamespace + import numpy as np + + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + monkeypatch.setattr(control._def, "RECORD_ZSTACK_DISPLAY_FPS", 0) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + ctrl = win.recordZStackController + frames = [] + ctrl.image_to_display.connect(frames.append) + ctrl._callbacks.signal_new_image(SimpleNamespace(frame=np.zeros((4, 4), np.uint16)), None) + assert frames == [] # z-stack handler stays a no-op at 0 fps + assert ctrl._display_frame_fn is None # recording tap absent diff --git a/software/tests/core/test_record_zstack_worker.py b/software/tests/core/test_record_zstack_worker.py index e426534c7..f6a7ce70b 100644 --- a/software/tests/core/test_record_zstack_worker.py +++ b/software/tests/core/test_record_zstack_worker.py @@ -1339,3 +1339,146 @@ def __init__(self, fps, paced=False, **kw): worker.run() assert not aborted["v"] assert captured["paced"] is True and captured["fps"] == 5.0 + + +# --------------------------------------------------------------------------- +# Task 3: live-preview tap threaded through the worker. +# +# The recording phase must invoke a plain (Qt-free) display callable, throttled +# by display_fps. The z-stack phase must invoke the per-plane signal_new_image +# callback once per plane x channel x FOV x timepoint. +# --------------------------------------------------------------------------- + + +def _all_noop_callbacks(): + """Fresh MultiPointControllerFunctions with all-no-op lambdas. + + NoOpCallbacks is a shared module-level *instance*, so it is neither callable + nor safe to mutate per-test; build a fresh one instead. + """ + from control.core.multi_point_utils import MultiPointControllerFunctions + + return MultiPointControllerFunctions( + signal_acquisition_start=lambda *a, **kw: None, + signal_acquisition_finished=lambda *a, **kw: None, + signal_new_image=lambda *a, **kw: None, + signal_current_configuration=lambda *a, **kw: None, + signal_current_fov=lambda *a, **kw: None, + signal_overall_progress=lambda *a, **kw: None, + signal_region_progress=lambda *a, **kw: None, + ) + + +def test_recording_phase_calls_display_fn(tmp_path): + """The recording phase must invoke the plain display callable (throttled). + display_fps=1000 -> effectively every frame; assert at least one call.""" + pytest.importorskip("tensorstore") + import numpy as np + import control._def + import tests.control.test_stubs as ts + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters + from control.core.record_zstack_worker import RecordZStackWorker + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + scope = _build_simulated_microscope(64, 48) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + channels = live_controller.get_channels(scope.objective_store.default_objective) + scope.camera.set_exposure_time(1) + z_cfg = scope.stage.get_config().Z_AXIS + scope.stage.move_z_to((z_cfg.MAX_POSITION + z_cfg.MIN_POSITION) / 2.0) + scope.low_level_drivers.microcontroller.wait_till_operation_is_completed() + x_cfg = scope.stage.get_config().X_AXIS + y_cfg = scope.stage.get_config().Y_AXIS + + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="rec_preview", + Nt=1, + dt_s=0.0, + use_laser_af=False, + recording_enabled=True, + recording_channel=channels[0], + fps=10.0, + duration_s=0.3, + recording_bottom_z_offset_um=0.0, + zstack_enabled=False, + zstack_channels=[], + z_min_um=0.0, + z_max_um=0.0, + z_step_um=1.0, + ) + shown = [] + worker = RecordZStackWorker( + scope=scope, + live_controller=live_controller, + laser_auto_focus_controller=laser_af, + objective_store=scope.objective_store, + params=params, + callbacks=_all_noop_callbacks(), + abort_requested_fn=lambda: False, + request_abort_fn=lambda: None, + scan_region_fov_coords={"A1": [(x_cfg.MIN_POSITION + 1.0, y_cfg.MIN_POSITION + 1.0)]}, + display_frame_fn=shown.append, + display_fps=1000.0, + ) + worker.run() + assert len(shown) >= 1 + assert isinstance(shown[0], np.ndarray) + + +def test_zstack_phase_invokes_signal_new_image(tmp_path): + """Pin the per-plane signal_new_image callback (was silently wired to a + no-op before the preview feature): one call per plane x channel x FOV x t.""" + pytest.importorskip("tensorstore") + import control._def + import tests.control.test_stubs as ts + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters + from control.core.record_zstack_worker import RecordZStackWorker + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + scope = _build_simulated_microscope(64, 48) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + channels = live_controller.get_channels(scope.objective_store.default_objective) + scope.camera.set_exposure_time(1) + z_cfg = scope.stage.get_config().Z_AXIS + scope.stage.move_z_to((z_cfg.MAX_POSITION + z_cfg.MIN_POSITION) / 2.0) + scope.low_level_drivers.microcontroller.wait_till_operation_is_completed() + x_cfg = scope.stage.get_config().X_AXIS + y_cfg = scope.stage.get_config().Y_AXIS + + new_image_calls = [] + callbacks = _all_noop_callbacks() + callbacks.signal_new_image = lambda frame, info: new_image_calls.append(frame.frame.shape) + + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="zstack_preview", + Nt=1, + dt_s=0.0, + use_laser_af=False, + recording_enabled=False, + recording_channel=None, + fps=10.0, + duration_s=0.1, + recording_bottom_z_offset_um=0.0, + zstack_enabled=True, + zstack_channels=channels[:2], + z_min_um=-1.0, + z_max_um=1.0, + z_step_um=1.0, # offsets -1, 0, 1 -> 3 planes + ) + worker = RecordZStackWorker( + scope=scope, + live_controller=live_controller, + laser_auto_focus_controller=laser_af, + objective_store=scope.objective_store, + params=params, + callbacks=callbacks, + abort_requested_fn=lambda: False, + request_abort_fn=lambda: None, + scan_region_fov_coords={"A1": [(x_cfg.MIN_POSITION + 1.0, y_cfg.MIN_POSITION + 1.0)]}, + ) + worker.run() + assert len(new_image_calls) == 3 * 2 # planes x channels x 1 FOV x 1 timepoint diff --git a/software/tests/core/test_streaming_capture.py b/software/tests/core/test_streaming_capture.py index bf0b9759b..abf13f30d 100644 --- a/software/tests/core/test_streaming_capture.py +++ b/software/tests/core/test_streaming_capture.py @@ -1025,3 +1025,67 @@ def test_unpaced_router_still_downsamples_a_2x_source(): r = RecordingRouter(fps=10.0) slots = [r.route(100.0 + k * 0.05) for k in range(10)] assert slots == [(0, 0, 0), None, (1, 0, 0), None, (2, 0, 0), None, (3, 0, 0), None, (4, 0, 0), None] + + +# Task 2: throttled live-preview display tap +# --------------------------------------------------------------------------- + + +def test_display_fn_throttled_and_first_frame_shown(): + # 20 frames at 20 fps (0.05 s apart), preview at 5 fps (0.2 s interval): + # displays at t=100.00, 100.20, 100.40, 100.60, 100.80 -> exactly 5 calls. + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _ListWriter() + shown = [] + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=20.0), + CountStop(20), + w, + abort_fn=lambda: False, + display_fn=shown.append, + display_fps=5.0, + ) + emitted = cap.run() + assert emitted == 20 + assert len(shown) == 5 + + +def test_display_fn_error_disables_preview_but_recording_completes(): + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _ListWriter() + calls = [] + + def bad_display(frame): + calls.append(1) + raise RuntimeError("display broke") + + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=20.0), + CountStop(20), + w, + abort_fn=lambda: False, + display_fn=bad_display, + display_fps=1000.0, # would fire every frame if not disabled + ) + emitted = cap.run() + assert emitted == 20 # recording unaffected + assert len(calls) == 1 # disabled after the first raise + assert w.finalized # store still sealed normally + + +def test_display_fps_zero_means_no_preview(): + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(5)] + shown = [] + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=20.0), + CountStop(5), + _ListWriter(), + abort_fn=lambda: False, + display_fn=shown.append, + display_fps=0.0, + ) + assert cap.run() == 5 + assert shown == []