Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions software/control/_def.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions software/control/core/record_zstack_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,17 @@ def __init__(
objective_store,
scan_coordinates,
callbacks,
display_frame_fn=None,
display_fps: float = 0.0,
):
self._microscope = microscope
self._live_controller = live_controller
self._laser_af = laser_autofocus_controller
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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions software/control/core/record_zstack_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {}
Expand Down Expand Up @@ -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
Expand Down
40 changes: 37 additions & 3 deletions software/control/core/streaming_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,20 +425,33 @@ 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.
router: ``RecordingRouter`` (or compatible) — maps timestamps to (t,c,z).
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
Expand All @@ -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():
Expand All @@ -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:
Expand All @@ -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()
Expand Down
50 changes: 42 additions & 8 deletions software/control/gui_hcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions software/tests/control/test_HighContentScreeningGui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading